mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 22:22:25 +00:00
Merge main into fix/h2473-safe-file-name-leaves-a-windows
Combines this branch's screens.rs test module with the one #4123 added.
This commit is contained in:
Generated
+28
-28
@@ -4582,7 +4582,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4622,7 +4622,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4633,7 +4633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4645,7 +4645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4661,7 +4661,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4683,7 +4683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4695,7 +4695,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-ccitt",
|
||||
@@ -4714,7 +4714,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4730,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
@@ -4769,7 +4769,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -4793,7 +4793,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4808,7 +4808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4820,7 +4820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4839,7 +4839,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4853,7 +4853,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4865,7 +4865,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"chrono",
|
||||
@@ -4896,7 +4896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4910,7 +4910,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4937,14 +4937,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"flate2",
|
||||
@@ -4969,7 +4969,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"image",
|
||||
@@ -4987,7 +4987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -5008,7 +5008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -5016,7 +5016,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -5032,7 +5032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -5044,7 +5044,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"craft-keymap",
|
||||
@@ -5081,7 +5081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6119,7 +6119,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -134,6 +134,29 @@ fn usage() -> String {
|
||||
format!("{USAGE}\nReadable formats: .{}\nWritable formats: .{}\n", OPEN_EXTS.join(", ."), export_extensions().join(", ."))
|
||||
}
|
||||
|
||||
/// Does a subcommand's `args` ask for the usage? `--help` or `-h` counts where an option goes, not as
|
||||
/// the value of one of the `valued` options before it, so `run --cmd -h` keeps `-h` for `--cmd`. The
|
||||
/// subcommands check this before they parse or read anything else.
|
||||
fn help_requested(args: &[String], valued: &[&str]) -> bool {
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--help" | "-h" => return true,
|
||||
a if valued.contains(&a) => {
|
||||
it.next();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Prints the usage, for `help`, `--help` and `-h`.
|
||||
fn help() -> Result<(), String> {
|
||||
out!("{}", usage());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Before any font lookup: the fonts font services load (#579).
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
@@ -143,6 +166,9 @@ fn main() -> ExitCode {
|
||||
prefs_dir::install_fonts();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
// `commands` and `info` take no options, so `--help` or `-h` anywhere after them prints the
|
||||
// usage. The other subcommands check for it themselves.
|
||||
Some("commands" | "info") if help_requested(&args[1..], &[]) => help(),
|
||||
Some("mcp") => mcp(&args[1..]),
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("commands") => commands(),
|
||||
@@ -150,10 +176,7 @@ fn main() -> ExitCode {
|
||||
Some("info") => info(&args[1..]),
|
||||
Some("bench") => bench(&args[1..]),
|
||||
Some("perf") => perf::run(&args[1..]),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
out!("{}", usage());
|
||||
Ok(())
|
||||
}
|
||||
Some("-h" | "--help" | "help") | None => help(),
|
||||
Some("-V" | "--version") => {
|
||||
outln!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
@@ -180,6 +203,9 @@ fn with_stores(mut h: Headless) -> Headless {
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
if help_requested(args, &["--connect", "--automation-read-root", "--automation-write-root"]) {
|
||||
return help();
|
||||
}
|
||||
let mut connect: Option<String> = None;
|
||||
let mut headless = false;
|
||||
let (mut read_roots, mut write_roots): (Vec<String>, Vec<String>) = (vec![], vec![]);
|
||||
@@ -207,7 +233,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let write: Vec<&Path> = write_roots.iter().map(Path::new).collect();
|
||||
let roots = AutomationRoots::new(&read, &write)?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
|
||||
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
|
||||
.into());
|
||||
}
|
||||
let backend: Box<dyn Backend> = if let Some(roots) = roots {
|
||||
@@ -243,6 +269,10 @@ fn commands() -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
let valued = ["--scale", "-s", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"];
|
||||
if help_requested(args, &valued) {
|
||||
return help();
|
||||
}
|
||||
let mut files = vec![];
|
||||
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data) =
|
||||
(1.0f64, None::<u64>, None::<String>, false, false, false);
|
||||
@@ -422,6 +452,10 @@ fn command_step(v: Value) -> Result<Step, String> {
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let valued = ["--in", "-i", "--cmd", "-c", "--params", "-p", "--commands", "--export", "-o", "--scale", "-s"];
|
||||
if help_requested(args, &valued) {
|
||||
return help();
|
||||
}
|
||||
let mut input: Option<String> = None;
|
||||
let mut steps: Vec<Step> = vec![];
|
||||
// The step of the `--cmd` just given, which a `--params` sets.
|
||||
@@ -522,6 +556,9 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn bench(args: &[String]) -> Result<(), String> {
|
||||
if help_requested(args, &["--size", "--iters"]) {
|
||||
return help();
|
||||
}
|
||||
let mut file = None;
|
||||
let (mut w, mut h, mut iters) = (2880u32, 1800u32, 5u32);
|
||||
let mut it = args.iter();
|
||||
|
||||
@@ -341,6 +341,9 @@ fn deep(n: usize) -> Result<(), String> {
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
if crate::help_requested(args, &["--paths"]) {
|
||||
return crate::help();
|
||||
}
|
||||
let mut n = 50_000usize;
|
||||
let mut want_deep = false;
|
||||
let mut it = args.iter();
|
||||
|
||||
@@ -201,6 +201,64 @@ fn run_refuses_malformed_commands_naming_the_step() {
|
||||
assert_eq!(printed, 2);
|
||||
}
|
||||
|
||||
/// `--help` and `-h` right after a subcommand print the usage that `vectorcraft-cli help` prints.
|
||||
#[test]
|
||||
fn help_after_a_subcommand_prints_the_usage() {
|
||||
let usage = Command::new(BIN).arg("help").output().unwrap();
|
||||
assert!(usage.status.success() && String::from_utf8_lossy(&usage.stdout).contains("USAGE:"));
|
||||
for sub in ["mcp", "run", "commands", "convert", "info", "bench", "perf"] {
|
||||
for flag in ["--help", "-h"] {
|
||||
let out = Command::new(BIN).args([sub, flag]).stdin(Stdio::null()).output().unwrap();
|
||||
assert!(out.status.success(), "{sub} {flag}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.stdout == usage.stdout, "{sub} {flag}: {}", String::from_utf8_lossy(&out.stdout));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--help` and `-h` after other arguments print the usage too, before the subcommand does anything
|
||||
/// with them.
|
||||
#[test]
|
||||
fn help_later_in_the_line_prints_the_usage() {
|
||||
let usage = Command::new(BIN).arg("help").output().unwrap();
|
||||
let mut wrong = vec![];
|
||||
for args in [
|
||||
&["mcp", "--headless", "--help"][..],
|
||||
&["mcp", "--headless", "-h"],
|
||||
&["mcp", "--connect", "127.0.0.1:1", "--help"],
|
||||
&["run", "--cmd", "file.new", "--help"],
|
||||
&["run", "--in", "missing.svg", "-h"],
|
||||
&["run", "--commands", "missing.json", "--help"],
|
||||
&["run", "--cmd", "file.new", "--params", "not json", "-h"],
|
||||
&["commands", "x", "-h"],
|
||||
&["convert", "missing.svg", "out.png", "--help"],
|
||||
&["convert", "missing.svg", "--scale", "2", "-h"],
|
||||
&["info", "missing.svg", "--help"],
|
||||
&["bench", "missing.svg", "-h"],
|
||||
&["bench", "--size", "10x10", "--help"],
|
||||
&["bench", "--size", "bad", "-h"],
|
||||
&["perf", "--paths", "10", "--help"],
|
||||
&["perf", "--paths", "many", "-h"],
|
||||
&["convert", "--decimals", "many", "--help"],
|
||||
] {
|
||||
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
if !out.status.success() || out.stdout != usage.stdout {
|
||||
wrong.push(format!("{args:?}: {}{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout)));
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
|
||||
}
|
||||
|
||||
/// Further along the line, `-h` is the value of the option before it.
|
||||
#[test]
|
||||
fn help_as_an_option_value_goes_to_the_option() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "-h"]).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success() && err.contains("vectorcraft-cli: step 0 (-h):") && out.stdout.is_empty(), "{err}");
|
||||
let out = Command::new(BIN).args(["convert", "in.svg", "--range", "-h"]).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success() && err.contains("convert needs IN and OUT") && out.stdout.is_empty(), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_headless_over_stdio() {
|
||||
let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
@@ -319,7 +377,10 @@ fn automation_roots(base: &std::path::Path) {
|
||||
fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root");
|
||||
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("missing")], "cannot open the automation read root");
|
||||
fails(&["mcp", "--automation-write-root"], "needs a folder");
|
||||
fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots");
|
||||
fails(
|
||||
&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))],
|
||||
"a running app enforces its own roots: start it with `vectorcraft --control <port>",
|
||||
);
|
||||
|
||||
let mut child = Command::new(BIN)
|
||||
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-write-root={}", dir("out"))])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files,
|
||||
//! and the profiles exports embed ([`encode_builtin`]), written in code from the CMS.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use moxcms::{
|
||||
@@ -8,7 +9,7 @@ use moxcms::{
|
||||
RenderingIntent, ToneReprCurve, TransformF32Executor, TransformOptions, Vector3d,
|
||||
};
|
||||
|
||||
use super::{CmsError, Intent, ProfileKind, lab};
|
||||
use super::{CmsError, Intent, ProfileKind, lab, profiles_generation};
|
||||
|
||||
fn moxcms_intent(i: Intent) -> RenderingIntent {
|
||||
match i {
|
||||
@@ -27,12 +28,17 @@ fn intent_index(i: Intent) -> usize {
|
||||
|
||||
type Xf = Option<Arc<TransformF32Executor>>;
|
||||
|
||||
/// A direct transform's cache key: destination name, intent index, black-point compensation.
|
||||
type DirectKey = (String, usize, bool);
|
||||
/// A direct transform's cache key: destination [`IccProfile::id`], [`profiles_generation`] it was
|
||||
/// built in, intent index, black-point compensation. The id tells a reloaded profile from the old
|
||||
/// one of the same name, which settings built before the reload still convert into; the
|
||||
/// generation only lets the cache drop what reloads left behind.
|
||||
type DirectKey = (u64, u64, usize, bool);
|
||||
|
||||
/// A parsed ICC profile plus lazily-built transforms to/from sRGB.
|
||||
pub struct IccProfile {
|
||||
pub name: String,
|
||||
/// Unique to this instance: never reused, unlike its name or address.
|
||||
id: u64,
|
||||
pub kind: ProfileKind,
|
||||
/// The file it was loaded from (empty for profiles made in code).
|
||||
source: Vec<u8>,
|
||||
@@ -82,8 +88,10 @@ impl IccProfile {
|
||||
other => return Err(CmsError::Unsupported(format!("profile colour space {other:?} is not RGB, CMYK or Gray"))),
|
||||
};
|
||||
let name = name.or_else(|| text(&profile.description)).unwrap_or_else(|| format!("Untitled {kind:?} profile"));
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
|
||||
Ok(Self {
|
||||
name,
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
kind,
|
||||
source: Vec::new(),
|
||||
profile,
|
||||
@@ -182,8 +190,11 @@ impl IccProfile {
|
||||
if dest.kind != ProfileKind::Rgb {
|
||||
return None;
|
||||
}
|
||||
let key = (dest.name.clone(), intent_index(intent), bpc);
|
||||
let generation = profiles_generation();
|
||||
let key = (dest.id, generation, intent_index(intent), bpc);
|
||||
let mut cache = self.direct.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// an older generation's entries can't match again: drop them so reloads don't pile up
|
||||
cache.retain(|(k, _)| k.1 == generation);
|
||||
if let Some((_, t)) = cache.iter().find(|(k, _)| *k == key) {
|
||||
return t.clone();
|
||||
}
|
||||
|
||||
@@ -180,3 +180,49 @@ fn aliased_localized_names_are_refused() {
|
||||
assert!(super::icc::localized_text_fits(&profile(3, 64)));
|
||||
assert!(!super::icc::localized_text_fits(&profile(4096, 64 << 10)));
|
||||
}
|
||||
|
||||
/// Reloading an RGB profile under the name it had (a new file for "My RGB") changes what CMYK
|
||||
/// converts to in it: the direct CMYK → RGB transform isn't kept from the old file.
|
||||
#[test]
|
||||
fn a_reloaded_rgb_profile_replaces_the_direct_cmyk_transform_into_it() {
|
||||
let cmyk = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Reload Test CMYK".into())).unwrap().name;
|
||||
let rgb = "Reload Test RGB";
|
||||
let cyan = [1.0, 0.0, 0.0, 0.0];
|
||||
let convert = || {
|
||||
let cms = Cms::new(&ColorSettings { rgb: rgb.into(), cmyk: cmyk.clone(), ..ColorSettings::default() }).unwrap();
|
||||
cms.cmyk_to_rgb(cyan, Intent::RelativeColorimetric)
|
||||
};
|
||||
register_icc(&icc::builtin_rgb_bytes(SRGB).unwrap(), Some(rgb.into())).unwrap();
|
||||
let in_srgb = convert();
|
||||
register_icc(&icc::builtin_rgb_bytes(PROPHOTO_RGB).unwrap(), Some(rgb.into())).unwrap();
|
||||
let in_prophoto = convert();
|
||||
// What a CMYK profile loaded afresh gives in the new file.
|
||||
let fresh = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Reload Test CMYK 2".into())).unwrap().name;
|
||||
let want = Cms::new(&ColorSettings { rgb: rgb.into(), cmyk: fresh, ..ColorSettings::default() })
|
||||
.unwrap()
|
||||
.cmyk_to_rgb(cyan, Intent::RelativeColorimetric);
|
||||
assert!(in_srgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 0.05), "sRGB {in_srgb:?} vs ProPhoto {want:?}");
|
||||
assert!(in_prophoto.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4), "after the reload {in_prophoto:?}, want {want:?}");
|
||||
}
|
||||
|
||||
/// The app loads a profile and only then switches to the new settings, so the working spaces
|
||||
/// built before the reload keep converting in between: a CMYK color they convert after it doesn't
|
||||
/// leave the old RGB file's transform for the settings built afterwards.
|
||||
#[test]
|
||||
fn settings_built_before_a_reload_dont_leave_the_old_transform_to_the_new_ones() {
|
||||
let cmyk = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Stale Test CMYK".into())).unwrap().name;
|
||||
let rgb = "Stale Test RGB";
|
||||
let cyan = [1.0, 0.0, 0.0, 0.0];
|
||||
let settings = ColorSettings { rgb: rgb.into(), cmyk: cmyk.clone(), ..ColorSettings::default() };
|
||||
register_icc(&icc::builtin_rgb_bytes(SRGB).unwrap(), Some(rgb.into())).unwrap();
|
||||
let old = Cms::new(&settings).unwrap();
|
||||
let in_srgb = old.cmyk_to_rgb(cyan, Intent::RelativeColorimetric);
|
||||
register_icc(&icc::builtin_rgb_bytes(PROPHOTO_RGB).unwrap(), Some(rgb.into())).unwrap();
|
||||
assert_eq!(old.cmyk_to_rgb(cyan, Intent::RelativeColorimetric), in_srgb, "the old settings keep the old file");
|
||||
let in_prophoto = Cms::new(&settings).unwrap().cmyk_to_rgb(cyan, Intent::RelativeColorimetric);
|
||||
// What a CMYK profile loaded afresh gives in the new file.
|
||||
let fresh = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Stale Test CMYK 2".into())).unwrap().name;
|
||||
let want = Cms::new(&ColorSettings { cmyk: fresh, ..settings }).unwrap().cmyk_to_rgb(cyan, Intent::RelativeColorimetric);
|
||||
assert!(in_srgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 0.05), "sRGB {in_srgb:?} vs ProPhoto {want:?}");
|
||||
assert!(in_prophoto.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4), "after the reload {in_prophoto:?}, want {want:?}");
|
||||
}
|
||||
|
||||
+11
-2
@@ -360,10 +360,12 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
||||
};
|
||||
}
|
||||
}
|
||||
// Plain decimals only, as in `number_unit` (`str::parse` would accept `1e5`, `inf`, `nan`).
|
||||
let plain = |t: &str| t.trim().chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') && t.trim().parse::<f64>().is_ok();
|
||||
if let Some((p, pt)) = s.split_once('p')
|
||||
&& !p.is_empty()
|
||||
&& p.trim().parse::<f64>().is_ok()
|
||||
&& (pt.is_empty() || pt.trim().parse::<f64>().is_ok())
|
||||
&& plain(p)
|
||||
&& (pt.is_empty() || plain(pt))
|
||||
&& !s.ends_with("pt")
|
||||
&& !s.ends_with("px")
|
||||
{
|
||||
@@ -2713,6 +2715,13 @@ mod tests {
|
||||
assert_eq!(Unit::Points.parse("-2p"), Some(-24.0));
|
||||
assert_eq!(Unit::Points.parse("2p6"), Some(30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pica_form_rejects_exponent_notation() {
|
||||
for s in ["1e5p", "infp", "nanp", "infp3", "1p1e2"] {
|
||||
assert_eq!(Unit::Points.parse(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -146,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Symbol…",
|
||||
[],
|
||||
None,
|
||||
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}",
|
||||
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}",
|
||||
has_selection,
|
||||
symbol_new
|
||||
),
|
||||
@@ -191,7 +191,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Redefine Symbol",
|
||||
[],
|
||||
None,
|
||||
"{name, ids?|id?} replace the symbol's art with the selection (instances update; the selection becomes an instance) → {name, id}",
|
||||
"{name, ids?|id?} replace the symbol's art with the selection (instances update; the selection becomes an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds) → {name, id}",
|
||||
has_selection,
|
||||
symbol_update
|
||||
),
|
||||
@@ -874,7 +874,8 @@ fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affin
|
||||
fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "symbol.new";
|
||||
let ids = roots(s, p)?;
|
||||
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
super::threads::keep_shown_text(&s.doc()?.doc, &mut art);
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol"));
|
||||
let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER);
|
||||
@@ -890,7 +891,10 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.set_symbol_export_type(&name, export);
|
||||
d.set_symbol_nine_slice(&name, nine);
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
|
||||
let threaded = super::threads::frames(d);
|
||||
let id = replace_with_instance(d, &ids, &name, place)?;
|
||||
// Frames of threaded text in the symbol leave their threads with the text they show.
|
||||
super::threads::leave_threads(d, &threaded);
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -1070,7 +1074,8 @@ fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("no symbol named `{name}`")));
|
||||
}
|
||||
let ids = roots(s, p)?;
|
||||
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
super::threads::keep_shown_text(&s.doc()?.doc, &mut art);
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let old = natural_size(&s.doc()?.doc, &name);
|
||||
let id = s.edit("Redefine Symbol", |d, sel| {
|
||||
@@ -1086,7 +1091,10 @@ fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let reg = d.symbol_registration(&name);
|
||||
let threaded = super::threads::frames(d);
|
||||
let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?;
|
||||
// Frames of threaded text in the symbol leave their threads with the text they show.
|
||||
super::threads::leave_threads(d, &threaded);
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
|
||||
@@ -195,6 +195,12 @@ fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?;
|
||||
// The bytes are kept, not the path: the file may move or go away.
|
||||
let remembered = s.store_profile(&info.name, &bytes);
|
||||
// A new file of a working space: the active settings still hold the old one.
|
||||
let working = cms::active_settings();
|
||||
if working.rgb == info.name || working.cmyk == info.name {
|
||||
cms::set_active(&working).map_err(|e| bad(C, e.to_string()))?;
|
||||
touch_all(s);
|
||||
}
|
||||
Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered}))
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,13 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "batches cannot nest"));
|
||||
}
|
||||
if find_command(id).is_none() {
|
||||
return Err(bad(C, format!("commands[{i}]: unknown command `{id}`")));
|
||||
return Err(bad(
|
||||
C,
|
||||
match super::super::params::nearest_command(id) {
|
||||
Some(near) => format!("commands[{i}]: unknown command `{id}`; did you mean `{near}`?"),
|
||||
None => format!("commands[{i}]: unknown command `{id}`"),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
let source = str_param(p, "source").unwrap_or("folder");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! EPS export for every EPS path (`document.export {format: eps}`, `document.exportEps`, Export As
|
||||
//! → EPS Options): the options parsed from the params, transparency flattened with a flattener
|
||||
//! preset, one file of the art's bounds or one per artboard (with a central file of the whole
|
||||
//! document when there are several), the preview and thumbnail rendered,
|
||||
//! document when there are several and art shows), the preview and thumbnail rendered,
|
||||
//! and the native document embedded for reopening.
|
||||
|
||||
use std::borrow::Cow;
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export EPS",
|
||||
[],
|
||||
None,
|
||||
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (with useArtboards: the artboards written)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}",
|
||||
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}",
|
||||
has_doc,
|
||||
export_eps
|
||||
)]
|
||||
@@ -44,7 +44,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
name: "useArtboards",
|
||||
ty: "boolean",
|
||||
default: "null",
|
||||
description: "true: one file per chosen artboard (default all), <file>_<artboard>.eps, and with two or more a central <file>.eps of the whole document; false or absent: one file of the visible art (naming artboards also uses them)",
|
||||
description: "true: one file per chosen artboard (default all), <file>_<artboard>.eps, and with two or more a central <file>.eps of the whole document (none when no art shows); absent: the same when artboard, artboards or range names artboards, else one file of the visible art; false: one file of the visible art, even when artboards are named",
|
||||
},
|
||||
FormatOption { name: "level", ty: "integer", default: "3", description: "PostScript language level, 2 or 3" },
|
||||
FormatOption {
|
||||
@@ -257,8 +257,8 @@ fn native(doc: &Document, board: Option<usize>, include_linked: bool) -> Vec<u8>
|
||||
}
|
||||
|
||||
/// Encode `doc` as EPS: one file of the visible art, or with Use Artboards one per chosen
|
||||
/// artboard (after a central file of the whole document when there are several). Transparency is
|
||||
/// flattened first (once for all of them); the previews show `doc` itself.
|
||||
/// artboard (after a central file of the whole document when there are several and art shows).
|
||||
/// Transparency is flattened first (once for all of them); the previews show `doc` itself.
|
||||
pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
let set = parse(p)?;
|
||||
let original = doc;
|
||||
|
||||
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
|
||||
@@ -46,7 +46,14 @@ impl ScreenSize {
|
||||
/// `2x` (or `2`), `100w`, `100h` or `72ppi`, in any case; positive numbers only.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
let num = |t: &str| t.trim().parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0);
|
||||
// Digits and `.` only: `str::parse` would also take `+2`, `1e2`, `inf` and `nan`.
|
||||
let num = |t: &str| {
|
||||
let t = t.trim();
|
||||
if !t.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
|
||||
return None;
|
||||
}
|
||||
t.parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0)
|
||||
};
|
||||
if let Some(n) = s.strip_suffix("ppi") {
|
||||
return num(n).map(Self::Ppi);
|
||||
}
|
||||
@@ -385,7 +392,7 @@ struct Naming {
|
||||
}
|
||||
|
||||
impl Naming {
|
||||
/// The naming of params `o` for a document titled `title`.
|
||||
/// The naming of params `o` for a document named `title`: its file's path, or its title before it is saved.
|
||||
fn of(o: &ScreenParams, title: &str) -> Result<Self> {
|
||||
let folders = Folders::of(o)?;
|
||||
// A prefix names files, not folders.
|
||||
@@ -672,7 +679,9 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = ScreenParams::of(p)?;
|
||||
let rows = rows_of(s, p, o.preset.as_deref())?;
|
||||
o.one_source()?;
|
||||
let naming = Naming::of(&o, &s.doc()?.doc.title)?;
|
||||
// `<filename>`: the document's file, as Save names it, or its title before it is saved.
|
||||
let st = s.doc()?;
|
||||
let naming = Naming::of(&o, st.path.as_deref().unwrap_or(&st.doc.title))?;
|
||||
let selection = o.selection_mode()?;
|
||||
let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
|
||||
let doc = s.doc()?.doc.clone();
|
||||
@@ -849,13 +858,14 @@ pub fn first_file(s: &Session, p: &Value) -> Result<String> {
|
||||
let rows = rows_of(s, &p, o.preset.as_deref())?;
|
||||
o.one_source()?;
|
||||
let selection = o.selection_mode()?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let st = s.doc()?;
|
||||
let doc = &st.doc;
|
||||
let mut selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
|
||||
// A file per object: objects that draw nothing write no file.
|
||||
if selection == Some(true) {
|
||||
selected = selected.into_iter().find(|id| export_crop(doc, &[*id]).is_some()).into_iter().collect();
|
||||
}
|
||||
let naming = Naming::of(&o, &doc.title)?;
|
||||
let naming = Naming::of(&o, st.path.as_deref().unwrap_or(&doc.title))?;
|
||||
let items = items(doc, &o, selection, &selected, &naming.filename, false, false)?;
|
||||
let (Some(item), Some(r)) = (items.first(), rows.first()) else { return Err(bad(C, "nothing to export")) };
|
||||
let (sub, file) = naming.file(item, 0, 0, r);
|
||||
@@ -1001,7 +1011,18 @@ pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::safe_file_name;
|
||||
use super::{ScreenSize, safe_file_name};
|
||||
|
||||
#[test]
|
||||
fn size_parse_refuses_signs_and_exponents() {
|
||||
for s in ["+2x", "+2", "+100w", "+100h", "+72ppi", "-2x", "1e2", "2e0w"] {
|
||||
assert_eq!(ScreenSize::parse(s), None, "{s}");
|
||||
}
|
||||
assert_eq!(ScreenSize::parse("2x"), Some(ScreenSize::Scale(2.0)));
|
||||
assert_eq!(ScreenSize::parse("1.5"), Some(ScreenSize::Scale(1.5)));
|
||||
assert_eq!(ScreenSize::parse("100W"), Some(ScreenSize::Width(100.0)));
|
||||
assert_eq!(ScreenSize::parse("72ppi"), Some(ScreenSize::Ppi(72.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_name_with_space_before_extension_is_protected() {
|
||||
|
||||
@@ -102,6 +102,10 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() {
|
||||
let r = s.execute("document.exportEps", &json!({"range": "2", "previewFormat": "none"})).unwrap();
|
||||
assert!(r.get("files").is_none());
|
||||
assert!(postscript(&b64(&r)).contains(&format!("[1 0 0 -1 {} 100] cm", -second)));
|
||||
// useArtboards: false writes one file of the visible art, even with artboards named.
|
||||
let r = s.execute("document.exportEps", &json!({"useArtboards": false, "artboards": [0, 1], "previewFormat": "none"})).unwrap();
|
||||
assert!(r.get("files").is_none());
|
||||
assert_eq!(dsc(&postscript(&b64(&r)), "BoundingBox").as_deref(), Some(format!("10 60 {} 90", second + 40.0).as_str()));
|
||||
assert!(s.execute("document.exportEps", &json!({"artboard": 5})).is_err());
|
||||
// Without art there is nothing to bound.
|
||||
let mut empty = session(100.0, 100.0, 1);
|
||||
@@ -110,6 +114,9 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() {
|
||||
let mut empty = session(100.0, 100.0, 2);
|
||||
let r = empty.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": "none"})).unwrap();
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
// Named artboards without useArtboards get the same files.
|
||||
let r = empty.execute("document.exportEps", &json!({"range": "1-2", "previewFormat": "none"})).unwrap();
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -205,6 +205,39 @@ fn the_first_file_is_the_file_the_export_writes_first() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved document's `<filename>`, Full Document file and ZIP are named after its file without the
|
||||
/// extension, as Save names it, whatever its File Info title says.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_saved_document_exports_under_its_file_name() {
|
||||
let mut s = session(1);
|
||||
s.execute("file.info", &json!({"title": "Logo: final version"})).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("vc-screens-filename-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
s.execute("document.save", &json!({"path": dir.join("my-logo.vectorcraft").to_string_lossy()})).unwrap();
|
||||
for (p, name) in [
|
||||
(json!({"name": "<filename>__<name>", "formats": [{"format": "png"}]}), "my-logo__Artboard 1.png"),
|
||||
(json!({"fullDocument": true, "formats": [{"format": "png"}]}), "my-logo.png"),
|
||||
] {
|
||||
assert_eq!(names(&export(&mut s, p.clone())), [name], "{p}");
|
||||
assert_eq!(super::screens::first_file(&s, &p).unwrap(), name, "{p}");
|
||||
}
|
||||
assert_eq!(export(&mut s, json!({"zip": true, "formats": [{"format": "svg"}]}))["name"], "my-logo.zip");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A document that has not been saved has no file: `<filename>` is its title.
|
||||
#[test]
|
||||
fn an_unsaved_document_exports_under_its_title() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 40, "height": 30, "name": "Poster"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("file.info", &json!({"title": "Spring poster"})).unwrap();
|
||||
let p = json!({"name": "<filename>", "formats": [{"format": "png"}]});
|
||||
assert_eq!(names(&export(&mut s, p.clone())), ["Spring-poster.png"]);
|
||||
assert_eq!(super::screens::first_file(&s, &p).unwrap(), "Spring-poster.png");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn sub_folders_are_made_in_the_export_folder() {
|
||||
|
||||
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rasterize…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
has_selection,
|
||||
rasterize
|
||||
),
|
||||
@@ -561,8 +561,11 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "nothing selected"));
|
||||
}
|
||||
let id = s.edit("Rasterize", |d, sel| {
|
||||
let threaded = super::threads::frames(d);
|
||||
let node = image.into_node(d);
|
||||
let id = d.wrap(&roots, |_| node)?;
|
||||
// Rasterized frames of threaded text leave their threads with the text they show.
|
||||
super::threads::leave_threads(d, &threaded);
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
|
||||
@@ -1804,7 +1804,7 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match bounds_transform(s, p, selected_roots(s)?, 0.0)? {
|
||||
match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0)? {
|
||||
Bounds::Anchors(ids, b) => set_anchor_bounds(s, p, ids, b),
|
||||
Bounds::Objects(ids, xf) => apply_transform(s, "Transform", ids, xf, p, false),
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({ "fillActive": s.fill_active }))
|
||||
}
|
||||
),
|
||||
cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None", has_doc, |s, _| {
|
||||
cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None → {objects}", has_doc, |s, _| {
|
||||
let f = s.fill_active;
|
||||
set_paint(s, &json!({"none": true}), f)
|
||||
}),
|
||||
@@ -101,7 +101,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Apply Last Color",
|
||||
[],
|
||||
Some(","),
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let paint = Paint::solid(s.last_solid);
|
||||
@@ -113,7 +113,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Apply Last Gradient",
|
||||
[],
|
||||
Some("."),
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
|
||||
@@ -419,7 +419,7 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint, cmd: &str) -> Result
|
||||
|
||||
/// Set the fill (or stroke) of the targets of `p` (the `item` and `ids` params, see
|
||||
/// `paint.setFill`) and of the defaults for new art, focus its proxy unless `focus` is false, and
|
||||
/// remember the paint.
|
||||
/// remember the paint. Replies `{objects}`: how many objects took the paint.
|
||||
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
|
||||
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
|
||||
let focus = switch_or(p, "focus", true, cmd)?;
|
||||
@@ -452,7 +452,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
||||
s.fill_active = fill;
|
||||
}
|
||||
s.remember_paint(&paint);
|
||||
return ok();
|
||||
return Ok(json!({"objects": 1}));
|
||||
}
|
||||
// Resolve the requested targets before mutating defaults or the active proxy.
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, fill);
|
||||
@@ -535,7 +535,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
||||
s.fill_active = fill;
|
||||
}
|
||||
s.remember_paint(&paint);
|
||||
ok()
|
||||
Ok(json!({"objects": ids.iter().collect::<std::collections::BTreeSet<_>>().len()}))
|
||||
}
|
||||
|
||||
/// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{bad, find_command};
|
||||
use super::{bad, command_specs, find_command};
|
||||
use crate::Result;
|
||||
|
||||
/// What a params description says about the keys of the params object.
|
||||
@@ -208,8 +208,12 @@ fn distance(a: &str, b: &str) -> usize {
|
||||
|
||||
/// The key of `keys` a misspelt `key` most likely meant: the nearest one, when it is near.
|
||||
fn nearest<'a>(key: &str, keys: &[&'a str]) -> Option<&'a str> {
|
||||
let near = keys.iter().map(|k| (distance(key, k), *k)).min()?;
|
||||
(near.0 <= (key.chars().count() / 3).max(1)).then_some(near.1)
|
||||
let len = key.chars().count();
|
||||
let most = (len / 3).max(1);
|
||||
// A key whose length differs by more than `most` is more than `most` edits away and isn't
|
||||
// measured.
|
||||
let near = keys.iter().filter(|k| k.chars().count().abs_diff(len) <= most).map(|k| (distance(key, k), *k)).min()?;
|
||||
(near.0 <= most).then_some(near.1)
|
||||
}
|
||||
|
||||
/// `params` has only keys the description `doc` of command `id` names, or the parameter error
|
||||
@@ -480,6 +484,21 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The command id a misspelled `id`, which names no command, most likely meant: the nearest one,
|
||||
/// when it is near (as [`check_keys`] matches a misspelled key).
|
||||
pub(crate) fn nearest_command(id: &str) -> Option<&'static str> {
|
||||
let ids: Vec<&'static str> = command_specs().iter().map(|c| c.id).collect();
|
||||
nearest(id, &ids)
|
||||
}
|
||||
|
||||
/// The error for `id`, which names no command: with the nearest command id, when one is near.
|
||||
pub(crate) fn unknown_command(id: &str) -> crate::EngineError {
|
||||
match nearest_command(id) {
|
||||
Some(near) => crate::EngineError::UnknownCommandNear(id.to_string(), near.to_string()),
|
||||
None => crate::EngineError::UnknownCommand(id.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// [`check_params`], and [`check_effect_params`] for the effects `effect.setParams` edits in
|
||||
/// this session's document (not as a step of a `command.batch`: what it edits is only there
|
||||
|
||||
@@ -996,6 +996,9 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
|
||||
let meta_bytes = store.read(&format!("{file}{META_EXT}")).unwrap_or_default();
|
||||
let meta: Meta = serde_json::from_slice(&meta_bytes).unwrap_or_default();
|
||||
let (mut doc, _, mut warnings) = fileio::native_file(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?;
|
||||
// A frame whose first paragraph continues from the frame before draws it without the
|
||||
// paragraph's first-line indent and list marker.
|
||||
super::threads::mark_continued(&mut doc);
|
||||
// The images, from the entries of the copy's area.
|
||||
let from = file.split_once('/').map_or("", |(a, _)| a);
|
||||
let mut blobs = vec![];
|
||||
|
||||
@@ -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), 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\"} 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)",
|
||||
"{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), 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\"} 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
|
||||
),
|
||||
@@ -258,7 +258,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if new_art && let Some(st) = s.new_art_stroke_mut() {
|
||||
set(st);
|
||||
}
|
||||
ok()
|
||||
Ok(json!({"objects": ids.iter().collect::<std::collections::BTreeSet<_>>().len()}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
|
||||
@@ -329,6 +329,52 @@ pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut each frame in `art`, a copy of objects of `d` under their ids, that is the last frame of a
|
||||
/// thread with frames outside `art`, to the text it shows. When such a frame leaves its thread,
|
||||
/// [`leave_threads`] puts the text it has no room for after the text of the last frame that stays.
|
||||
pub(crate) fn keep_shown_text(d: &Document, art: &mut Node) {
|
||||
let mut inside = vec![];
|
||||
art.walk(&mut |n| inside.push(n.id));
|
||||
let last: Vec<NodeId> = d
|
||||
.text_threads
|
||||
.iter()
|
||||
.filter(|t| t.iter().any(|id| !inside.contains(id)))
|
||||
.filter_map(|t| t.last().copied().filter(|id| inside.contains(id)))
|
||||
.collect();
|
||||
if !last.is_empty() {
|
||||
cut_to_shown(art, &last);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut the area type and type on a path `ids` in `n` to the text each one shows.
|
||||
fn cut_to_shown(n: &mut Node, ids: &[NodeId]) {
|
||||
if ids.contains(&n.id)
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
&& !matches!(t.kind, TextKind::Point)
|
||||
{
|
||||
// Split as `take_overflow` splits it: the first part is what the frame shows.
|
||||
let from: &TextObject = t;
|
||||
let parts = vectorcraft_text::thread::distribute_continuing(
|
||||
vectorcraft_text::FontDb::global(),
|
||||
&[from, from],
|
||||
&from.runs,
|
||||
&from.paragraph_styles(),
|
||||
from.first_para_continues,
|
||||
from.list_count,
|
||||
);
|
||||
if let (Some(head), Some(_)) = (parts.first(), parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty()))) {
|
||||
t.runs = head.runs.clone();
|
||||
t.set_paragraph_styles(head.paras.clone());
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
if let Some(children) = n.children_mut() {
|
||||
for c in children {
|
||||
cut_to_shown(std::sync::Arc::make_mut(c), ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The only area type or type on a path in the art at `id`.
|
||||
fn kept_type(d: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let mut found = vec![];
|
||||
@@ -1176,6 +1222,50 @@ mod tests {
|
||||
assert_eq!([a, b, c].map(|id| text(&s, id)), shown);
|
||||
}
|
||||
|
||||
/// Object › Rasterize on frames of threaded text takes them out of their thread with the text
|
||||
/// they show, which the image draws, and the other frames keep the text they hold. Undo threads
|
||||
/// the frames again. When the last frame is rasterized, the frame before it takes the text it
|
||||
/// has no room for.
|
||||
#[test]
|
||||
fn rasterized_frames_leave_their_thread_with_their_text() {
|
||||
for clip in [false, true] {
|
||||
for out in [&[0][..], &[1], &[2], &[0, 2]] {
|
||||
let case = format!("clippingMask {clip} {out:?}");
|
||||
let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0);
|
||||
let shown = frames.map(|id| text(&s, id));
|
||||
assert_eq!(shown.concat(), TEXT.repeat(4));
|
||||
let ids: Vec<u64> = out.iter().map(|k| frames[*k]).collect();
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute("object.rasterize", &json!({"ppi": 36, "clippingMask": clip})).unwrap();
|
||||
let rest: Vec<u64> = frames.iter().copied().filter(|id| !ids.contains(id)).collect();
|
||||
for id in &ids {
|
||||
assert!(!is_frame(&s, *id), "{case}: frame {id} is rasterized");
|
||||
}
|
||||
for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) {
|
||||
assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text");
|
||||
}
|
||||
assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(threads(&s), [frames], "{case}: undo");
|
||||
assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo");
|
||||
}
|
||||
}
|
||||
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0);
|
||||
let shown = [a, b, c].map(|id| text(&s, id));
|
||||
let laid = {
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") };
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
|
||||
};
|
||||
assert!(laid.overflow, "the last frame has more text than it shows");
|
||||
let end = laid.lines.last().map_or(0, |l| l.end);
|
||||
let hidden = shown[2][end..].trim_start_matches(' ');
|
||||
s.execute("select.set", &json!({"ids": [c]})).unwrap();
|
||||
s.execute("object.rasterize", &json!({"ppi": 36})).unwrap();
|
||||
assert_eq!(text(&s, a), shown[0]);
|
||||
assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest");
|
||||
assert_eq!(threads(&s), [[a, b]]);
|
||||
}
|
||||
|
||||
/// Frame `id`'s first line: whether its first paragraph continues from the frame before, where
|
||||
/// its text starts from the frame's left edge, and how many list markers it starts with.
|
||||
fn first_line(s: &Session, id: u64) -> (bool, f64, usize) {
|
||||
@@ -1608,6 +1698,89 @@ mod tests {
|
||||
assert_eq!(doc.layers.iter().flat_map(|l| counts(l)).collect::<Vec<_>>(), [0, 0]);
|
||||
}
|
||||
|
||||
/// The text of each frame in symbol `name`'s art, sorted.
|
||||
fn symbol_texts(s: &Session, name: &str) -> Vec<String> {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let Some(sym) = doc.symbols.iter().find(|x| x.name == name) else { panic!("no symbol {name}") };
|
||||
let mut texts = vec![];
|
||||
sym.art.walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
texts.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
texts.sort();
|
||||
texts
|
||||
}
|
||||
|
||||
/// New Symbol and Redefine Symbol on frames of threaded text replace them with an instance
|
||||
/// whose art holds the text they show, and the frames leave their thread: the other frames keep
|
||||
/// the text they hold, so each character is drawn once. Undo threads the frames again. When the
|
||||
/// last frame goes into the symbol, the frame before it takes the text it has no room for, and
|
||||
/// the symbol's art holds only the text the frame shows.
|
||||
#[test]
|
||||
fn frames_made_into_a_symbol_leave_their_thread_with_their_text() {
|
||||
let symbol = |s: &mut Session, command: &str, ids: &[u64]| {
|
||||
let name = if command == "symbol.update" {
|
||||
let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [mark]})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
|
||||
"Mark"
|
||||
} else {
|
||||
"Story"
|
||||
};
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute(command, &json!({"name": name})).unwrap();
|
||||
name
|
||||
};
|
||||
for command in ["symbol.new", "symbol.update"] {
|
||||
for out in [&[0][..], &[1], &[2], &[0, 2]] {
|
||||
let case = format!("{command} {out:?}");
|
||||
let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0);
|
||||
let shown = frames.map(|id| text(&s, id));
|
||||
assert_eq!(shown.concat(), TEXT.repeat(4));
|
||||
let ids: Vec<u64> = out.iter().map(|k| frames[*k]).collect();
|
||||
let name = symbol(&mut s, command, &ids);
|
||||
let rest: Vec<u64> = frames.iter().copied().filter(|id| !ids.contains(id)).collect();
|
||||
for id in &ids {
|
||||
assert!(!is_frame(&s, *id), "{case}: frame {id} is in the symbol");
|
||||
}
|
||||
let mut held: Vec<String> = out.iter().map(|k| shown[*k].clone()).collect();
|
||||
held.sort();
|
||||
assert_eq!(symbol_texts(&s, name), held, "{case}: the symbol's art holds the text the frames show");
|
||||
for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) {
|
||||
assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text");
|
||||
}
|
||||
assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(threads(&s), [frames], "{case}: undo");
|
||||
assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo");
|
||||
}
|
||||
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0);
|
||||
let shown = [a, b, c].map(|id| text(&s, id));
|
||||
let laid = {
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") };
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
|
||||
};
|
||||
assert!(laid.overflow, "the last frame has more text than it shows");
|
||||
let end = laid.lines.last().map_or(0, |l| l.end);
|
||||
let hidden = shown[2][end..].trim_start_matches(' ');
|
||||
let name = symbol(&mut s, command, &[c]);
|
||||
assert_eq!(text(&s, a), shown[0], "{command}");
|
||||
assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "{command}: the frame before it keeps its text and takes the rest");
|
||||
assert_eq!(threads(&s), [[a, b]], "{command}");
|
||||
let held = symbol_texts(&s, name);
|
||||
let held: Vec<&str> = held.iter().map(|t| t.trim_end()).collect();
|
||||
assert_eq!(held, [shown[2][..end].trim_end()], "{command}: the symbol's art holds what the frame shows");
|
||||
// With every frame of the thread in the symbol, the last frame keeps all its text.
|
||||
let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0);
|
||||
let mut shown = frames.map(|id| text(&s, id)).to_vec();
|
||||
shown.sort();
|
||||
let name = symbol(&mut s, command, &frames);
|
||||
assert_eq!(symbol_texts(&s, name), shown, "{command}: the whole thread");
|
||||
assert!(threads(&s).is_empty(), "{command}: the whole thread");
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame of a numbered thread pasted while an opacity mask is edited numbers from 1 in the
|
||||
/// mask art too.
|
||||
#[test]
|
||||
|
||||
@@ -44,6 +44,9 @@ pub use vectorcraft_tools as tools;
|
||||
pub enum EngineError {
|
||||
#[error("unknown command `{0}`")]
|
||||
UnknownCommand(String),
|
||||
/// An unknown command id, with the command id it most likely misspells.
|
||||
#[error("unknown command `{0}`; did you mean `{1}`?")]
|
||||
UnknownCommandNear(String, String),
|
||||
#[error("command `{0}` is not available right now: {1}")]
|
||||
Disabled(String, String),
|
||||
#[error("invalid parameters for `{cmd}`: {msg}")]
|
||||
@@ -1784,7 +1787,7 @@ impl Session {
|
||||
|
||||
/// Execute a command by id. This is THE entry point for every frontend.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
|
||||
// The crate's own tests are the audit of the params descriptions (#1442): a key a test
|
||||
// passes that its command's description doesn't name fails the test.
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -90,6 +90,42 @@ fn unknown_and_disabled() {
|
||||
assert!(matches!(s.execute("object.group", &json!({})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_names_the_nearest_id() {
|
||||
let mut s = session();
|
||||
for typo in ["select.al", "Select.All", "selcet.all"] {
|
||||
let e = s.execute(typo, &json!({})).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::UnknownCommandNear(id, near) if id == typo && near == "select.all"), "{typo}: {e:?}");
|
||||
assert_eq!(e.to_string(), format!("unknown command `{typo}`; did you mean `select.all`?"));
|
||||
}
|
||||
let e = s.execute("nope", &json!({})).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::UnknownCommand(id) if id == "nope"), "{e:?}");
|
||||
assert_eq!(e.to_string(), "unknown command `nope`");
|
||||
let e = s.execute("command.batch", &json!({"commands": [{"command": "select.al", "params": {}}]})).unwrap_err();
|
||||
assert!(e.to_string().contains("did you mean `select.all`?"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_unknown_command_id_is_answered_at_once() {
|
||||
let mut s = session();
|
||||
let long = "x".repeat(200_000);
|
||||
let start = std::time::Instant::now();
|
||||
assert!(matches!(s.execute(&long, &json!({})), Err(EngineError::UnknownCommand(_))));
|
||||
let e = crate::check_params("file.new", &json!({ long.as_str(): 1 })).unwrap_err();
|
||||
assert!(e.to_string().contains("the command takes:"), "{e}");
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn action_batch_names_the_nearest_command_id() {
|
||||
let mut s = session();
|
||||
let e = s.execute("action.batch", &json!({"source": "dataSets", "commands": [{"command": "select.al"}]})).unwrap_err();
|
||||
assert_eq!(e.to_string(), "invalid parameters for `action.batch`: commands[0]: unknown command `select.al`; did you mean `select.all`?");
|
||||
let e = s.execute("action.batch", &json!({"source": "dataSets", "commands": [{"command": "nope"}]})).unwrap_err();
|
||||
assert_eq!(e.to_string(), "invalid parameters for `action.batch`: commands[0]: unknown command `nope`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_ungroup() {
|
||||
let mut s = session();
|
||||
@@ -2451,6 +2487,34 @@ fn an_isolated_compound_paths_member_moves_alone() {
|
||||
assert_eq!((x0(&s, outer), x0(&s, hole)), (5.0, 55.0), "the compound path selected: the whole ring");
|
||||
}
|
||||
|
||||
/// The X, Y, W and H of the Properties and Transform panels run `object.setBounds`, which takes the
|
||||
/// same objects as the other transforms: a path of a compound path selected alone sets its own
|
||||
/// box and leaves the ring around it (#4046). The hole is off-centre, and a plain rectangle
|
||||
/// outside the ring keeps "everything moved" from passing.
|
||||
#[test]
|
||||
fn set_bounds_of_a_compound_paths_member_sets_only_that_path() {
|
||||
let x = |s: &Session, id: NodeId| {
|
||||
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
(b.x0, b.x1)
|
||||
};
|
||||
let mut s = session();
|
||||
let (c, outer, hole) = isolated_ring(&mut s);
|
||||
let other = rect(&mut s, 100.0, 0.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [hole.0]})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"width": 20, "reference": 0})).unwrap();
|
||||
assert_eq!((x(&s, outer), x(&s, hole)), ((0.0, 40.0), (10.0, 30.0)), "isolated: only the hole is wider");
|
||||
s.execute("object.setBounds", &json!({"x": 25, "reference": 0})).unwrap();
|
||||
assert_eq!((x(&s, outer), x(&s, hole)), ((0.0, 40.0), (25.0, 45.0)), "isolated: only the hole moved");
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [hole.0]})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"x": 12, "reference": 0})).unwrap();
|
||||
assert_eq!((x(&s, outer), x(&s, hole)), ((0.0, 40.0), (12.0, 32.0)), "not isolated: still only the hole");
|
||||
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"x": 5, "reference": 0})).unwrap();
|
||||
assert_eq!((x(&s, outer), x(&s, hole)), ((5.0, 45.0), (17.0, 37.0)), "the compound path selected: the whole ring");
|
||||
assert_eq!(x(&s, other), (100.0, 110.0), "the rectangle outside never moves");
|
||||
}
|
||||
|
||||
/// `depth` and `childLimit` slice a summary read; a level that shows fewer children
|
||||
/// than it has reports `childCount`, and invalid values are rejected, not reinterpreted.
|
||||
#[test]
|
||||
|
||||
@@ -138,6 +138,33 @@ fn a_crash_leaves_copies_that_restore_modified_and_titled() {
|
||||
assert!(s.execute("file.recovery.restore", &json!({"file": format!("{mine}/Poster-1")})).is_err(), "an open document's copy");
|
||||
}
|
||||
|
||||
/// A threaded frame whose first paragraph continues from the frame before is restored without
|
||||
/// that paragraph's first-line indent.
|
||||
#[test]
|
||||
fn a_restored_copy_keeps_continued_paragraphs_in_threaded_frames() {
|
||||
let store = Arc::new(MemoryStore::default());
|
||||
let mut s = relaunch(&store);
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame of this story. ".repeat(4);
|
||||
let a =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "leftIndent": 0, "firstLineIndent": 24})).unwrap();
|
||||
let continues = |s: &Session| match &s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(b)).unwrap().kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => t.first_para_continues,
|
||||
_ => panic!("not text"),
|
||||
};
|
||||
assert!(continues(&s));
|
||||
s.execute("file.recovery.save", &json!({})).unwrap();
|
||||
drop(s);
|
||||
|
||||
let mut s = relaunch(&store);
|
||||
assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1);
|
||||
assert!(continues(&s), "the second frame continues the first frame's paragraph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restored_copy_still_refuses_to_replace_the_file_an_import_left_things_out_of() {
|
||||
let (mut s, store) = session();
|
||||
|
||||
@@ -346,3 +346,47 @@ fn a_freeform_gradient_expands_with_its_rgb_values_in_the_working_rgb_space() {
|
||||
assert!(matches!(c, Color::Rgb { .. }) && close, "{c:?} shows {shown:?} for {field:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Loading a new file of the working RGB profile (named after its description, as before) takes
|
||||
/// effect at once: CMYK colors convert into the new file and the canvas redraws, without choosing
|
||||
/// the working spaces again.
|
||||
#[test]
|
||||
fn loading_the_working_rgb_profile_again_takes_effect_at_once() {
|
||||
let _g = settings();
|
||||
let old = cms::active_settings();
|
||||
// A built-in RGB profile's file renamed `name`: padded with spaces, which names drop.
|
||||
let renamed = |builtin: &str, name: &str| {
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec();
|
||||
let (from, to) = (utf16(builtin), utf16(&format!("{name:<width$}", width = builtin.len())));
|
||||
let at = bytes.windows(from.len()).position(|w| w == from).unwrap();
|
||||
bytes[at..at + to.len()].copy_from_slice(&to);
|
||||
bytes
|
||||
};
|
||||
let name = "Reload RGB";
|
||||
let dir = vectorcraft_testkit::temp_dir("reload-working-rgb");
|
||||
let load = |s: &mut Session, builtin: &str| {
|
||||
let path = dir.join(format!("{}.icc", builtin.len()));
|
||||
std::fs::write(&path, renamed(builtin, name)).unwrap();
|
||||
let r = s.execute("color.loadProfile", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(r["name"], name);
|
||||
};
|
||||
let cmyk = cms::register_icc(&cms::icc_bytes(cms::GENERIC_CMYK).unwrap(), Some("Reload Working CMYK".into())).unwrap().name;
|
||||
let cyan = [1.0, 0.0, 0.0, 0.0];
|
||||
let convert = || cms::active().cmyk_to_rgb(cyan, old.intent);
|
||||
let mut s = session();
|
||||
load(&mut s, cms::DISPLAY_P3);
|
||||
s.execute("edit.colorSettings", &json!({"rgb": name, "cmyk": cmyk})).unwrap();
|
||||
let in_p3 = convert();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
load(&mut s, cms::PROPHOTO_RGB);
|
||||
let after = convert();
|
||||
let redrawn = s.doc().unwrap().revision > revision;
|
||||
// What a CMYK profile loaded afresh gives in the new file.
|
||||
let fresh = cms::register_icc(&cms::icc_bytes(cms::GENERIC_CMYK).unwrap(), Some("Reload Working CMYK 2".into())).unwrap().name;
|
||||
let want = cms::Cms::new(&cms::ColorSettings { cmyk: fresh, ..cms::active_settings() }).unwrap().cmyk_to_rgb(cyan, old.intent);
|
||||
cms::set_active(&old).unwrap();
|
||||
assert!(in_p3.iter().zip(want).any(|(a, b)| (a - b).abs() > 0.05), "Display P3 {in_p3:?} vs ProPhoto {want:?}");
|
||||
assert!(after.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4), "after the reload {after:?}, want {want:?}");
|
||||
assert!(redrawn, "the canvas redraws");
|
||||
}
|
||||
|
||||
@@ -694,17 +694,23 @@ pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
|
||||
for c in clean.chunks(4) {
|
||||
let groups = clean.len() / 4;
|
||||
for (i, c) in clean.chunks(4).enumerate() {
|
||||
let mut n = 0u32;
|
||||
let mut pad = 0;
|
||||
for &b in c {
|
||||
n <<= 6;
|
||||
if b == b'=' {
|
||||
pad += 1;
|
||||
} else if pad > 0 {
|
||||
return None;
|
||||
} else {
|
||||
n |= val(b)?;
|
||||
}
|
||||
}
|
||||
if pad > 2 || (pad > 0 && i + 1 != groups) {
|
||||
return None;
|
||||
}
|
||||
out.push((n >> 16) as u8);
|
||||
if pad < 2 {
|
||||
out.push((n >> 8) as u8);
|
||||
@@ -782,4 +788,14 @@ mod tests {
|
||||
}
|
||||
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_rejects_misplaced_padding() {
|
||||
for s in ["====", "A===", "AA=A", "=AAA", "QQ==QUJD", "QUI=QUJD"] {
|
||||
assert_eq!(base64_decode(s), None, "{s}");
|
||||
}
|
||||
assert_eq!(base64_decode("QQ=="), Some(vec![0x41]));
|
||||
assert_eq!(base64_decode("QUI="), Some(vec![0x41, 0x42]));
|
||||
assert_eq!(base64_decode("QUJD"), Some(vec![0x41, 0x42, 0x43]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +449,63 @@ fn inspect_document_reports_the_paint_of_type() {
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}");
|
||||
}
|
||||
|
||||
/// set_paint says how many objects it painted, and says so when it only set the paint for new art.
|
||||
#[test]
|
||||
fn set_paint_reports_how_many_objects_it_painted() {
|
||||
let mut s = server();
|
||||
let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "fill": "#ffffff"}));
|
||||
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
|
||||
let fill = |s: &mut Server, n: u64| {
|
||||
let v: Value = serde_json::from_str(&text_of(&call(s, n, "inspect_document", json!({})))).unwrap();
|
||||
v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap()["fill"].clone()
|
||||
};
|
||||
let paint = |s: &mut Server, n: u64, args: Value| {
|
||||
let r = call(s, n, "set_paint", args);
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
serde_json::from_str::<Value>(&text_of(&r)).unwrap()
|
||||
};
|
||||
|
||||
// Nothing selected and no ids: only the paint for new art changes.
|
||||
let r = call(&mut s, 3, "run_command", json!({"command": "select.set", "params": {"ids": []}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let v = paint(&mut s, 4, json!({"fill": "#ff0000"}));
|
||||
assert_eq!(v["applied"], json!({"fill": "#ff0000"}), "{v}");
|
||||
assert_eq!(v["objects"], 0, "{v}");
|
||||
assert!(v["note"].as_str().is_some_and(|n| n.contains("new art")), "{v}");
|
||||
assert_eq!(fill(&mut s, 5), "#ffffff");
|
||||
|
||||
// The ids given, counted once each.
|
||||
let v = paint(&mut s, 6, json!({"fill": "#00ff00", "ids": [id, id]}));
|
||||
assert_eq!(v["objects"], 1, "{v}");
|
||||
assert!(v.get("note").is_none(), "{v}");
|
||||
assert_eq!(fill(&mut s, 7), "#00ff00");
|
||||
|
||||
// The selection.
|
||||
let r = call(&mut s, 8, "run_command", json!({"command": "select.set", "params": {"ids": [id]}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let v = paint(&mut s, 9, json!({"fill": "#0000ff"}));
|
||||
assert_eq!(v["objects"], 1, "{v}");
|
||||
assert!(v.get("note").is_none(), "{v}");
|
||||
assert_eq!(fill(&mut s, 10), "#0000ff");
|
||||
|
||||
// Empty `ids` paint no object: only the paint for new art changes.
|
||||
let v = paint(&mut s, 11, json!({"strokeWidth": 3, "ids": []}));
|
||||
assert_eq!(v["objects"], 0, "{v}");
|
||||
assert!(v["note"].as_str().is_some_and(|n| n.contains("new art")), "{v}");
|
||||
|
||||
// A selected group counts the objects inside it.
|
||||
let r = call(&mut s, 12, "draw_shape", json!({"shape": "ellipse", "x": 20, "y": 0, "width": 10, "height": 10}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
for (n, command) in [(13, "select.all"), (14, "object.group")] {
|
||||
let r = call(&mut s, n, "run_command", json!({"command": command}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
}
|
||||
let v = paint(&mut s, 15, json!({"stroke": "#000000"}));
|
||||
assert_eq!(v["objects"], 2, "{v}");
|
||||
let v = paint(&mut s, 16, json!({"strokeWidth": 2}));
|
||||
assert_eq!(v["objects"], 2, "{v}");
|
||||
}
|
||||
|
||||
/// #1003: type made on or in a path takes add_text's `color` too, over the current fill.
|
||||
#[test]
|
||||
fn add_text_on_or_in_a_path_takes_its_color() {
|
||||
@@ -743,6 +800,41 @@ fn headless_file_tools_take_relative_paths() {
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_file_tools_send_paths_as_given() {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
struct Recording(Rc<RefCell<Vec<Value>>>);
|
||||
impl Backend for Recording {
|
||||
fn call(&mut self, _: &str, params: Value) -> Result<Value, String> {
|
||||
self.0.borrow_mut().push(params);
|
||||
Ok(json!({}))
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"test".into()
|
||||
}
|
||||
}
|
||||
|
||||
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut s = Server::new(Box::new(Recording(seen.clone())));
|
||||
call(&mut s, 1, "open_file", json!({"path": "rel/in.svg"}));
|
||||
call(&mut s, 2, "save_file", json!({"path": "rel/out.vectorcraft"}));
|
||||
call(&mut s, 3, "export", json!({"path": "rel/out.png"}));
|
||||
call(&mut s, 4, "export", json!({"format": "svg", "options": {"path": "rel/opt.svg"}}));
|
||||
call(&mut s, 5, "screenshot", json!({"path": "rel/shot.png"}));
|
||||
let seen = seen.borrow();
|
||||
assert_eq!(seen.len(), 5, "{seen:?}");
|
||||
assert_eq!(seen[0]["path"], "rel/in.svg");
|
||||
assert_eq!(seen[1]["path"], "rel/out.vectorcraft");
|
||||
assert_eq!(seen[2]["params"]["path"], "rel/out.png");
|
||||
assert_eq!(seen[3]["params"]["path"], "rel/opt.svg");
|
||||
assert_eq!(seen[4]["path"], "rel/shot.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_rejects_uncorrelated_replies_without_replaying() {
|
||||
for reply in [
|
||||
|
||||
@@ -306,8 +306,9 @@ fn save_file_waits_for_the_file_in_the_app() {
|
||||
assert_eq!(r.is_error, ui, "the app's error is the call's: {r:?}");
|
||||
let [(method, params)] = &b.seen[..] else { panic!("one call: {:?}", b.seen) };
|
||||
assert_eq!(method, "app.save");
|
||||
// The tool sends the path made absolute (on Windows, against the current drive).
|
||||
let path = std::path::absolute("/out/a.vectorcraft").unwrap().to_string_lossy().into_owned();
|
||||
// The tool sends the app the path made absolute (on Windows, against the current drive), and
|
||||
// a headless session the path as given.
|
||||
let path = if ui { std::path::absolute("/out/a.vectorcraft").unwrap().to_string_lossy().into_owned() } else { "/out/a.vectorcraft".into() };
|
||||
let mut want = json!({"path": path});
|
||||
if ui {
|
||||
want["wait"] = json!(true);
|
||||
|
||||
+30
-16
@@ -290,7 +290,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"set_paint",
|
||||
"Set paint",
|
||||
"Set fill, stroke and/or stroke weight on the selection (or `ids`) and as the default for new art.",
|
||||
"Set fill, stroke and/or stroke weight on the selection (or `ids`) and as the default for new art. Returns {applied, objects}: `objects` counts the objects painted, as paint.setFill, paint.setStroke and stroke.set report them (the largest of their counts); when it is 0, a `note` says that only the default for new art changed.",
|
||||
obj(
|
||||
json!({
|
||||
"fill": paint_schema("Fill"),
|
||||
@@ -388,7 +388,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"path": string("Destination file (omit to get dataBase64)"),
|
||||
"scale": num("Raster pixels per point (default 1)"),
|
||||
"artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF and .ai default: all)"},
|
||||
"range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more; rasters, DXF, EMF and WMF: one, or one file each with options.useArtboards: true)"),
|
||||
"range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more and art shows; rasters, DXF, EMF and WMF: one, or one file each with options.useArtboards: true)"),
|
||||
"selection": {"type": "boolean", "description": "Export only the selection, cropped to it"},
|
||||
"outlineText": {"type": "boolean", "description": "SVG/SVGZ: true writes text as outlines (viewable without the fonts). DXF takes it too (type outlined anyway unless preserve: editability). PDF uses options.advanced.outlineText instead (default false: live text)"},
|
||||
"options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg or {\"advanced\": {\"outlineText\": true}} for PDF text as outlines (default false: live text)"},
|
||||
@@ -520,11 +520,11 @@ fn req_str<'a>(a: &'a Args, k: &str) -> Result<&'a str, String> {
|
||||
a.get(k).and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| format!("missing string argument `{k}`"))
|
||||
}
|
||||
|
||||
/// A file tool's path made absolute against this process's working directory. An empty path is
|
||||
/// returned as given.
|
||||
fn absolute_path(p: &str) -> Result<String, String> {
|
||||
if p.is_empty() {
|
||||
return Ok(String::new());
|
||||
/// A file tool's path made absolute against this process's working directory when it goes to the app
|
||||
/// (`--connect`). Headless, and for an empty path, it is returned as given.
|
||||
fn absolute_path(b: &dyn Backend, p: &str) -> Result<String, String> {
|
||||
if p.is_empty() || !b.has_ui() {
|
||||
return Ok(p.to_owned());
|
||||
}
|
||||
let abs = std::path::absolute(p).map_err(|e| format!("{p}: {e}"))?;
|
||||
abs.into_os_string().into_string().map_err(|abs| format!("{p}: the absolute path {} is not UTF-8", abs.to_string_lossy()))
|
||||
@@ -590,15 +590,20 @@ fn paint_steps(a: &Args) -> Result<(Vec<Step<'static>>, Map<String, Value>), Str
|
||||
Ok((steps, applied))
|
||||
}
|
||||
|
||||
fn apply_paint(b: &mut dyn Backend, a: &Args, ids: Option<Value>) -> Result<Value, String> {
|
||||
/// Sets the paint `a` gives on `ids` (default: the selection) and as the default for new art.
|
||||
/// Returns the paint applied and the most objects one of its edits painted (the `objects` of the
|
||||
/// `paint.setFill`, `paint.setStroke` and `stroke.set` replies).
|
||||
fn apply_paint(b: &mut dyn Backend, a: &Args, ids: Option<Value>) -> Result<(Value, u64), String> {
|
||||
let (steps, applied) = paint_steps(a)?;
|
||||
let mut objects = 0;
|
||||
for (cmd, mut p) in steps {
|
||||
if let Some(ids) = &ids {
|
||||
p["ids"] = ids.clone();
|
||||
}
|
||||
exec(b, cmd, p)?;
|
||||
let r = exec(b, cmd, p)?;
|
||||
objects = objects.max(r.get("objects").and_then(Value::as_u64).unwrap_or(0));
|
||||
}
|
||||
Ok(Value::Object(applied))
|
||||
Ok((Value::Object(applied), objects))
|
||||
}
|
||||
|
||||
/// Checks `draw_path` / `draw_shape`'s `dash`, `cap`, `join`, `opacity`, `name` and `effects`
|
||||
@@ -838,7 +843,7 @@ fn transform(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
|
||||
}
|
||||
|
||||
fn screenshot(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
let path = a.get("path").and_then(Value::as_str).map(absolute_path).transpose()?;
|
||||
let path = a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?;
|
||||
let path = path.as_deref();
|
||||
let window = a.get("window").and_then(Value::as_bool) == Some(true);
|
||||
let max = max_side(a)?;
|
||||
@@ -1063,8 +1068,14 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
if !["fill", "stroke", "strokeWidth"].iter().any(|k| a.contains_key(*k)) {
|
||||
return Err("give at least one of `fill`, `stroke`, `strokeWidth`".into());
|
||||
}
|
||||
let ids = a.get("ids").cloned();
|
||||
j(json!({"applied": apply_paint(b, a, ids)?}))
|
||||
let (applied, objects) = apply_paint(b, a, a.get("ids").cloned())?;
|
||||
let mut reply = json!({"applied": applied, "objects": objects});
|
||||
if objects == 0 {
|
||||
reply["note"] = json!(
|
||||
"no object was painted (nothing selected, or `ids` is empty or holds no object this paint applies to): only the paint for new art changed"
|
||||
);
|
||||
}
|
||||
j(reply)
|
||||
}
|
||||
"press_key" => {
|
||||
let mut p = json!({"key": req_str(a, "key")?});
|
||||
@@ -1084,9 +1095,12 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
j(b.call("ui.set", json!({"panel": req_str(a, "panel")?}))?)
|
||||
}
|
||||
"screenshot" => screenshot(b, a),
|
||||
"open_file" => j(b.call("app.open", json!({"path": absolute_path(req_str(a, "path")?)?}))?),
|
||||
"open_file" => {
|
||||
let path = absolute_path(b, req_str(a, "path")?)?;
|
||||
j(b.call("app.open", json!({"path": path}))?)
|
||||
}
|
||||
"save_file" => {
|
||||
let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(absolute_path).transpose()?});
|
||||
let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?});
|
||||
for k in SAVE_FILE_OPTIONS {
|
||||
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
||||
p[k] = v.clone();
|
||||
@@ -1121,7 +1135,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
}
|
||||
}
|
||||
if let Some(path) = params.get("path").and_then(Value::as_str) {
|
||||
let path = absolute_path(path)?;
|
||||
let path = absolute_path(b, path)?;
|
||||
params.insert("path".into(), json!(path));
|
||||
}
|
||||
let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" };
|
||||
|
||||
@@ -146,7 +146,7 @@ shown_when = [
|
||||
"With Use Precise Cursors on, the crosshair shows instead.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "A pen nib. Every Pen-family cursor is this nib with a different badge."
|
||||
look = "A fountain-pen nib pointing up-left along the selection arrow's axis (so swapping to the selection tools doesn't rotate the cursor), its slit widening into the breather hole and its heel clipped at the back; the shape approximates the pen tool's icon. Every Pen-family cursor is this nib with a different badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenDrawing"
|
||||
@@ -550,6 +550,13 @@ shown_when = ["The Pencil tool with Shift or Alt held before a press, except wit
|
||||
hotspot = "The lead tip."
|
||||
look = "The Pencil cursor with a short straight-line badge, where PencilClose has its ring."
|
||||
|
||||
[[cursor]]
|
||||
id = "PencilContinue"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Pencil tool over an end of a selected open path, within Edit Selected Paths, with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line that continues the path from that end."]
|
||||
hotspot = "The lead tip."
|
||||
look = "PencilStraight's badge with a small filled square at the line's lower end (the end point the line continues from)."
|
||||
|
||||
[[cursor]]
|
||||
id = "Paintbrush"
|
||||
group = "Drawing and editing tools"
|
||||
|
||||
@@ -517,15 +517,16 @@ impl Tool for GestureTool {
|
||||
o.push(Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" });
|
||||
o
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
// A drag that smooths, or Alt before a Pencil stroke has begun to draw (a stroke that
|
||||
// has begun keeps drawing with Alt).
|
||||
if self.smoothing || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) {
|
||||
return Cursor::Smooth;
|
||||
}
|
||||
// Before a press that would draw a straight line.
|
||||
// Before a press that would draw a straight line: over an end of a selected open path,
|
||||
// the line continues that path from the end (`PointerKind::Down`).
|
||||
if !self.active && self.straight_at_press(m) {
|
||||
return Cursor::PencilStraight;
|
||||
return if self.find_extend(cx, p).is_some() { Cursor::PencilContinue } else { Cursor::PencilStraight };
|
||||
}
|
||||
// Alt held at the release closes the path (`finish`).
|
||||
// A straight line stays open, so its release has nothing to close, and before a press Alt
|
||||
@@ -952,6 +953,66 @@ mod tests {
|
||||
assert_eq!(v["points"], json!([[400.0, 300.0], [400.0 + 100f64.hypot(4.0), 300.0]]));
|
||||
}
|
||||
|
||||
/// Before a press over an end of a selected open path, the Pencil shows its continuation cursor
|
||||
/// exactly when the press would draw a straight line that continues the path; away from the
|
||||
/// ends, with nothing selected, with Edit Selected Paths off, once the press is down and with
|
||||
/// the Paintbrush, it does not.
|
||||
#[test]
|
||||
fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
|
||||
vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)),
|
||||
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)),
|
||||
]));
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let shift_alt = Mods { shift: true, alt: true, ..Default::default() };
|
||||
// The release's stroke: a straight line that continues the path.
|
||||
let continues = |acts: &[Vec<Action>]| {
|
||||
acts.last().and_then(|a| a.first()).is_some_and(
|
||||
|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() && v["points"].as_array().is_some_and(|q| q.len() == 2)),
|
||||
)
|
||||
};
|
||||
for alt_smooth in [false, true] {
|
||||
for m in [Mods::default(), shift, alt, shift_alt] {
|
||||
for (at, other_end) in [(Point::new(405.0, 303.0), (500.0, 296.0)), (Point::new(296.0, 302.0), (200.0, 310.0))] {
|
||||
let mut t = GestureTool::new("pencil");
|
||||
t.set_option("altSmooth", &json!(alt_smooth));
|
||||
let shown = t.cursor(&cx, at, m) == Cursor::PencilContinue;
|
||||
let acts = drag_held(&mut t, &cx, &[(at.x, at.y), (450.0, 250.0), other_end], m);
|
||||
assert_eq!(shown, continues(&acts), "altSmooth {alt_smooth}, {m:?} at {at:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut t = GestureTool::new("pencil");
|
||||
let end = Point::new(405.0, 303.0);
|
||||
assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilContinue);
|
||||
assert_eq!(t.cursor(&cx, end, alt), Cursor::PencilContinue, "Alt alone continues at any angle");
|
||||
assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "without Shift or Alt the stroke is freehand");
|
||||
assert_eq!(t.cursor(&cx, Point::new(350.0, 360.0), shift), Cursor::PencilStraight, "away from the ends");
|
||||
// While the line is drawn, the Pencil's own cursor.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, end.x, end.y).with_mods(shift));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 303.0).with_mods(shift));
|
||||
assert_eq!(t.cursor(&cx, end, shift), Cursor::Pencil, "while the line is drawn");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 450.0, 303.0).with_mods(shift));
|
||||
// Edit Selected Paths off: the press draws a new line.
|
||||
t.set_option("editWithin", &json!(0.0));
|
||||
assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilStraight, "Edit Selected Paths off");
|
||||
let none = Selection::default();
|
||||
let cx_none = crate::testutil::cx(&d, &none, &p);
|
||||
assert_eq!(GestureTool::new("pencil").cursor(&cx_none, end, shift), Cursor::PencilStraight, "nothing selected");
|
||||
assert_eq!(GestureTool::new("paintbrush").cursor(&cx, end, shift), Cursor::Paintbrush);
|
||||
assert_eq!(Cursor::PencilContinue.precise(), Cursor::Crosshair, "Use Precise Cursors");
|
||||
}
|
||||
|
||||
/// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt
|
||||
/// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag.
|
||||
#[test]
|
||||
|
||||
@@ -582,6 +582,10 @@ pub enum Cursor {
|
||||
/// The Pencil tool before a press that draws a straight line (Shift or Alt held). A crosshair with a
|
||||
/// short straight line.
|
||||
PencilStraight,
|
||||
/// The Pencil tool before a press that draws a straight line (Shift or Alt held), over an end of
|
||||
/// a selected open path: the line continues the path from that end. A crosshair with a short
|
||||
/// straight line leaving a square end point.
|
||||
PencilContinue,
|
||||
/// The Paintbrush tool.
|
||||
Paintbrush,
|
||||
/// The Paintbrush tool with Alt held: releasing the mouse button with Alt still held closes
|
||||
@@ -700,6 +704,7 @@ impl Cursor {
|
||||
| Cursor::Pencil
|
||||
| Cursor::PencilClose
|
||||
| Cursor::PencilStraight
|
||||
| Cursor::PencilContinue
|
||||
| Cursor::Paintbrush
|
||||
| Cursor::PaintbrushClose
|
||||
| Cursor::BlobBrush
|
||||
|
||||
@@ -642,7 +642,12 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
|
||||
let largest = job.boxes.iter().flatten().map(|b| (b.2 - b.0) * (b.3 - b.1)).max().unwrap_or(1).max(1);
|
||||
let at_a_time = ((512 << 20) / (largest * 10)).max(1);
|
||||
for (paths, &(_, ci)) in parallel_map_at_most(at_a_time, &layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) {
|
||||
out.paths.extend(paths.ok_or(TraceError::TooComplex { max: max_anchors })?);
|
||||
let paths = paths.ok_or(TraceError::TooComplex { max: max_anchors })?;
|
||||
// A layer with no paths adds no color to the palette.
|
||||
if paths.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.paths.extend(paths);
|
||||
let color = q.palette[ci];
|
||||
if !out.palette.contains(&color) {
|
||||
out.palette.push(color);
|
||||
|
||||
@@ -338,8 +338,9 @@ pub(crate) fn fold_tones(pix: &[Px], mut palette: Vec<[u8; 3]>) -> Vec<[u8; 3]>
|
||||
|
||||
/// The color of the ground a flat bar two pixels wide lies on, if the pixel at (`x`, `y`) is one of
|
||||
/// the bar's. Across the bar (down or across the image) the pixel and one neighbor are the bar, and
|
||||
/// the pixels on either side of the two are the ground; along the bar, the pixel's neighbors on
|
||||
/// both sides are bar and ground in the same way. Every bar pixel lies within √12, about 3.5 (in
|
||||
/// the pixels on either side of the two are the ground; along the bar, three pixels in a row that
|
||||
/// include the pixel (at either end or in the middle) are bar and ground in the same way, so all
|
||||
/// six pixels of a bar three pixels long count. Every bar pixel lies within √12, about 3.5 (in
|
||||
/// RGB), of the pixel's color; the ground's pixels lie within 10 (in RGB) of the first one and at
|
||||
/// least 25 from the pixel's color. `at` is the opaque color at a pixel, `None` outside the image or where it is
|
||||
/// not opaque.
|
||||
@@ -350,12 +351,15 @@ fn bar_ground(at: &impl Fn(isize, isize) -> Option<[f64; 3]>, x: isize, y: isize
|
||||
// Across the bar: ground, the pixel, its partner, ground.
|
||||
let Some(g) = at(x + 2 * s * bx, y + 2 * s * by) else { continue };
|
||||
let ground = |q: Option<[f64; 3]>| q.is_some_and(|q| dist2(q, g) < 100.0 && dist2(q, c) >= 625.0);
|
||||
let flat = (-1..=1).all(|k| {
|
||||
let (px, py) = (x + k * ax, y + k * ay);
|
||||
bar(at(px, py))
|
||||
&& bar(at(px + s * bx, py + s * by))
|
||||
&& ground(at(px - s * bx, py - s * by))
|
||||
&& ground(at(px + 2 * s * bx, py + 2 * s * by))
|
||||
// Along the bar: three pixels in a row, the pixel at either end of them or in the middle.
|
||||
let flat = (-1..=1).any(|o| {
|
||||
(o - 1..=o + 1).all(|k| {
|
||||
let (px, py) = (x + k * ax, y + k * ay);
|
||||
bar(at(px, py))
|
||||
&& bar(at(px + s * bx, py + s * by))
|
||||
&& ground(at(px - s * bx, py - s * by))
|
||||
&& ground(at(px + 2 * s * bx, py + 2 * s * by))
|
||||
})
|
||||
});
|
||||
if flat {
|
||||
return Some(g);
|
||||
@@ -500,6 +504,31 @@ mod tests {
|
||||
assert_eq!(fold_fringes(groups), vec![[30, 90, 200], [210, 40, 50], [240, 150, 40]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bar_two_pixels_wide_and_three_long_is_an_unexplained_color() {
|
||||
// A red bar two pixels wide on white beside a black block, three and four pixels long,
|
||||
// lying across and down the image. Only the bar three pixels long needs its end pixels
|
||||
// counted to reach the three pixels a color needs.
|
||||
const RED: [u8; 3] = [210, 40, 50];
|
||||
for long in [3, 4] {
|
||||
for across in [true, false] {
|
||||
let (w, h) = (16, 12);
|
||||
let pix = blocks(w, h, |x, y| {
|
||||
let (along, side) = if across { (x, y) } else { (y, x) };
|
||||
if x < 3 {
|
||||
[0, 0, 0, 255]
|
||||
} else if (6..6 + long).contains(&along) && (5..7).contains(&side) {
|
||||
[RED[0], RED[1], RED[2], 255]
|
||||
} else {
|
||||
[255, 255, 255, 255]
|
||||
}
|
||||
});
|
||||
let residual = vec![1.0; w * h];
|
||||
assert_eq!(unexplained_colours(&pix, w, h, &residual, &[[0, 0, 0]]), vec![RED], "{long} long, across: {across}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_find_in_empty_or_blank_images() {
|
||||
assert!(auto_palette(&[], 0, 0, 6).is_empty());
|
||||
|
||||
@@ -296,6 +296,28 @@ fn shape_subpaths(s: &Shape, map: &ToSource) -> Vec<SubPath> {
|
||||
subs
|
||||
}
|
||||
|
||||
/// A shape's subpaths in source pixels, with no outline or hole that paints nothing.
|
||||
struct Kept {
|
||||
class: usize,
|
||||
area: f64,
|
||||
subs: Vec<SubPath>,
|
||||
}
|
||||
|
||||
/// Adds `s` to `kept` unless it paints nothing, and counts the anchors of the subpaths it keeps
|
||||
/// in `anchors_made`: the trace is too complex once they come to more than `max_anchors`.
|
||||
fn keep_shape(s: &Shape, map: &ToSource, kept: &mut Vec<Kept>, anchors_made: &mut usize, max_anchors: usize) -> Result<(), TraceError> {
|
||||
let subs = shape_subpaths(s, map);
|
||||
if subs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
*anchors_made = anchors_made.saturating_add(subs.iter().map(|sp| sp.anchors.len()).sum::<usize>());
|
||||
if *anchors_made > max_anchors {
|
||||
return Err(TraceError::TooComplex { max: max_anchors });
|
||||
}
|
||||
kept.push(Kept { class: s.class, area: s.area, subs });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A hard-edged source (a GIF, say) has no anti-aliasing to read an edge position from, and its
|
||||
/// outlines would follow the pixel staircase. Blurring the coverage by under a pixel gives the
|
||||
/// outline a smooth edge to find. Sources with plenty of blended edge pixels are left alone.
|
||||
@@ -449,7 +471,8 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
// `noise` is in source pixels (areas under noise/10 are ignored), whatever the working size.
|
||||
let min_area = ((f64::from(params.noise) / 10.0) / (factor * factor) as f64).max(0.3);
|
||||
let mut stats = Stats::default();
|
||||
let mut shapes: Vec<Shape> = Vec::new();
|
||||
let to_source = ToSource { factor: factor as f64, work: (w, h), source: (w0, h0) };
|
||||
let mut shapes: Vec<Kept> = Vec::new();
|
||||
let mut anchors_made = 0usize;
|
||||
for class in 0..=white {
|
||||
if class == white && params.ignore_white {
|
||||
@@ -465,6 +488,10 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
if a2.abs() / 2.0 < min_area {
|
||||
continue;
|
||||
}
|
||||
// Holes are cut only under Ignore White; otherwise white and the other colors are traced as their own shapes.
|
||||
if a2 >= 0.0 && !params.ignore_white {
|
||||
continue;
|
||||
}
|
||||
// More real outlines than we will fit is refused, not silently truncated.
|
||||
if outers.len() + holes.len() >= MAX_LOOPS_PER_CLASS {
|
||||
return Err(TraceError::TooComplex { max: max_anchors });
|
||||
@@ -489,28 +516,19 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
let Some(outer) = fit_outline(&pts, &style, &mut stats) else { continue };
|
||||
let hole_fits: Vec<Vec<Cubic>> =
|
||||
holes.iter().zip(&owner).filter(|(_, o)| **o == Some(idx)).filter_map(|(hole, _)| fit_outline(hole, &style, &mut stats)).collect();
|
||||
anchors_made += outer.len() + hole_fits.iter().map(Vec::len).sum::<usize>();
|
||||
if anchors_made > max_anchors {
|
||||
return Err(TraceError::TooComplex { max: max_anchors });
|
||||
}
|
||||
shapes.push(Shape { class, area, outer, holes: hole_fits });
|
||||
keep_shape(&Shape { class, area, outer, holes: hole_fits }, &to_source, &mut shapes, &mut anchors_made, max_anchors)?;
|
||||
}
|
||||
}
|
||||
// Painter's order: biggest shapes first, so smaller ones sit on top of what they belong to.
|
||||
shapes.sort_by(|a, b| b.area.total_cmp(&a.area).then(a.class.cmp(&b.class)));
|
||||
let to_source = ToSource { factor: factor as f64, work: (w, h), source: (w0, h0) };
|
||||
let mut out = TraceResult { worked_at: (factor > 1).then_some((w as u32, h as u32)), ..TraceResult::default() };
|
||||
for s in shapes {
|
||||
let color = palette.get(s.class).copied().unwrap_or([255, 255, 255]);
|
||||
let subs = shape_subpaths(&s, &to_source);
|
||||
if subs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !out.palette.contains(&color) {
|
||||
out.palette.push(color);
|
||||
}
|
||||
out.paths.push(TracedPath {
|
||||
path: PathData::new(subs),
|
||||
path: PathData::new(s.subs),
|
||||
color,
|
||||
pixels: (s.area * (factor * factor) as f64) as usize,
|
||||
stroke: None,
|
||||
@@ -563,4 +581,17 @@ mod tests {
|
||||
};
|
||||
assert!(shape_subpaths(&shape, &map).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_anchor_limit_counts_only_the_subpaths_kept() {
|
||||
let map = ToSource { factor: 1.0, work: (40, 40), source: (40, 40) };
|
||||
let square = || polygon(&[(5.0, 5.0), (35.0, 5.0), (35.0, 35.0), (5.0, 35.0)]);
|
||||
let with_flat_hole = Shape { class: 0, area: 900.0, outer: square(), holes: vec![polygon(&[(10.0, 20.0), (20.0, 20.0)])] };
|
||||
let flat_outline = Shape { class: 0, area: 1.0, outer: polygon(&[(5.0, 5.0), (35.0, 5.0)]), holes: vec![square()] };
|
||||
let (mut kept, mut anchors_made) = (Vec::new(), 0usize);
|
||||
assert!(keep_shape(&with_flat_hole, &map, &mut kept, &mut anchors_made, 4).is_ok());
|
||||
assert!(keep_shape(&flat_outline, &map, &mut kept, &mut anchors_made, 4).is_ok());
|
||||
assert_eq!((kept.len(), anchors_made), (1, 4));
|
||||
assert!(matches!(keep_shape(&with_flat_hole, &map, &mut kept, &mut anchors_made, 4), Err(TraceError::TooComplex { max: 4 })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +364,24 @@ fn too_many_outlines_are_refused_not_silently_truncated() {
|
||||
assert!(res.paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holes_that_are_not_cut_do_not_count_towards_the_outline_limit() {
|
||||
// A blue field with 625 holes, each filled with red, white or green: more holes than the limit, fewer of each fill.
|
||||
const GREEN: [u8; 3] = [40, 170, 60];
|
||||
let img = render(125, 125, 0.0, |x, y| {
|
||||
let (cx, cy) = (x.rem_euclid(5.0), y.rem_euclid(5.0));
|
||||
if (1.0..4.0).contains(&cx) && (1.0..4.0).contains(&cy) {
|
||||
Some([RED, [255, 255, 255], GREEN][((x / 5.0) as usize + (y / 5.0) as usize) % 3])
|
||||
} else {
|
||||
Some(BLUE)
|
||||
}
|
||||
});
|
||||
let res = trace_within(&img, &logo_params(), 1_000_000).expect("trace");
|
||||
assert!(res.paths.iter().any(|p| near(p.color, BLUE, 3)), "the blue field is traced");
|
||||
// Under Ignore White the holes are cut, so they count.
|
||||
assert!(matches!(trace_within(&img, &TraceParams { ignore_white: true, ..logo_params() }, 1_000_000), Err(TraceError::TooComplex { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_odd_sized_image_never_overshoots_its_edges() {
|
||||
// 1025 wide: the last working pixel is narrower than the others.
|
||||
@@ -560,38 +578,53 @@ fn sparse_marks_on_paper_are_not_promoted_to_colours() {
|
||||
/// found: no pixel of a bar has a 3 × 3 neighborhood of its own color, and a gray bar is a blend of
|
||||
/// black and white. Light grays, a red and a pale yellow are found and traced in their own color. A
|
||||
/// gray bar darker than about `#9B9B9B` stays black: a black stroke 1 to 1.6 px wide on the pixel
|
||||
/// grid gives the same pixels.
|
||||
/// grid gives the same pixels. A single bar two pixels wide and three pixels long, down or across
|
||||
/// the image, is found in its color too.
|
||||
#[test]
|
||||
fn a_bar_two_pixels_wide_beside_black_art_is_found_in_its_color() {
|
||||
const INK: [u8; 3] = [4, 4, 3];
|
||||
for (bar, want) in [
|
||||
([170u8, 170, 170], None),
|
||||
([200, 200, 200], None),
|
||||
([230, 230, 230], None),
|
||||
([210, 40, 50], None),
|
||||
([250, 230, 150], None),
|
||||
([128, 128, 128], Some(INK)),
|
||||
([51, 51, 51], Some(INK)),
|
||||
] {
|
||||
let img = Raster::from_fn(120, 60, |x, y| {
|
||||
let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||||
let c = if (fx - 25.0).hypot(fy - 28.0) <= 18.0 {
|
||||
INK
|
||||
} else if ((50..52).contains(&x) && (8..50).contains(&y)) || ((60..110).contains(&x) && (30..32).contains(&y)) {
|
||||
bar
|
||||
} else {
|
||||
[255, 255, 255]
|
||||
};
|
||||
[c[0], c[1], c[2], 255]
|
||||
});
|
||||
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
|
||||
let colour_at = |p: Point| res.paths.iter().rev().find(|t| t.path.to_bezpath().winding(p) != 0).map(|t| t.color);
|
||||
let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect();
|
||||
let want = want.unwrap_or(bar);
|
||||
for at in [Point::new(51.0, 20.0), Point::new(51.0, 40.0), Point::new(70.0, 31.0), Point::new(100.0, 31.0)] {
|
||||
assert_eq!(colour_at(at), Some(want), "bar {bar:?}: {at:?}; {paths:?}");
|
||||
let layouts = [
|
||||
(
|
||||
vec![(50..52, 8..50), (60..110, 30..32)],
|
||||
vec![Point::new(51.0, 20.0), Point::new(51.0, 40.0), Point::new(70.0, 31.0), Point::new(100.0, 31.0)],
|
||||
),
|
||||
(vec![(50..52, 20..23)], vec![Point::new(51.0, 21.5)]),
|
||||
(vec![(70..73, 30..32)], vec![Point::new(71.5, 31.0)]),
|
||||
];
|
||||
for (bars, points) in &layouts {
|
||||
for (bar, want) in [
|
||||
([170u8, 170, 170], None),
|
||||
([200, 200, 200], None),
|
||||
([230, 230, 230], None),
|
||||
([210, 40, 50], None),
|
||||
([250, 230, 150], None),
|
||||
([128, 128, 128], Some(INK)),
|
||||
([51, 51, 51], Some(INK)),
|
||||
] {
|
||||
if bars.len() == 1 && bar == [128, 128, 128] {
|
||||
// Half ink and half paper, a single bar three pixels long is traced as paper.
|
||||
continue;
|
||||
}
|
||||
let img = Raster::from_fn(120, 60, |x, y| {
|
||||
let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||||
let c = if (fx - 25.0).hypot(fy - 28.0) <= 18.0 {
|
||||
INK
|
||||
} else if bars.iter().any(|(xs, ys)| xs.contains(&x) && ys.contains(&y)) {
|
||||
bar
|
||||
} else {
|
||||
[255, 255, 255]
|
||||
};
|
||||
[c[0], c[1], c[2], 255]
|
||||
});
|
||||
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
|
||||
let colour_at = |p: Point| res.paths.iter().rev().find(|t| t.path.to_bezpath().winding(p) != 0).map(|t| t.color);
|
||||
let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect();
|
||||
let want = want.unwrap_or(bar);
|
||||
for &at in points {
|
||||
assert_eq!(colour_at(at), Some(want), "bars {bars:?}, bar {bar:?}: {at:?}; {paths:?}");
|
||||
}
|
||||
assert_eq!(colour_at(Point::new(25.0, 28.0)), Some(INK), "bars {bars:?}, bar {bar:?}: the disc; {paths:?}");
|
||||
}
|
||||
assert_eq!(colour_at(Point::new(25.0, 28.0)), Some(INK), "bar {bar:?}: the disc; {paths:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1355,6 +1355,46 @@ fn a_traced_pixel_that_straightens_to_no_area_is_left_out_of_fills_and_kept_as_a
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_color_whose_every_path_straightens_to_no_area_is_left_out_of_the_palette() {
|
||||
// A light rectangle with a black block on it and a one-pixel row of a pale blue along its lower
|
||||
// edge, on a transparent background. Traced with a library of the light, the black and a
|
||||
// blue, the row is the only part in the blue.
|
||||
const BLUE: [u8; 3] = [40, 120, 240];
|
||||
let img = Raster::from_fn(200, 120, |x, y| {
|
||||
if (60..140).contains(&x) && (70..90).contains(&y) {
|
||||
BLACK
|
||||
} else if (20..180).contains(&x) && (20..100).contains(&y) {
|
||||
[240, 240, 220, 255]
|
||||
} else if y == 100 && (30..170).contains(&x) {
|
||||
[140, 180, 230, 255]
|
||||
} else {
|
||||
[0, 0, 0, 0]
|
||||
}
|
||||
});
|
||||
let params = TraceParams {
|
||||
mode: Mode::Color,
|
||||
palette: Palette::DocumentLibrary,
|
||||
swatches: vec![[240, 240, 220], [0, 0, 0], BLUE],
|
||||
colors: 3,
|
||||
paths: 90.0,
|
||||
corners: 60.0,
|
||||
noise: 25,
|
||||
method: Method::Abutting,
|
||||
..TraceParams::default()
|
||||
};
|
||||
let used = |res: &TraceResult| res.palette.iter().all(|c| res.paths.iter().any(|p| p.color == *c));
|
||||
// Left alone, the row is a path in the blue, and the blue is in the palette.
|
||||
let res = trace(&img, ¶ms);
|
||||
assert!(res.paths.iter().any(|p| p.color == BLUE), "{:?}", res.paths.iter().map(|p| (p.color, p.path.bounds())).collect::<Vec<_>>());
|
||||
assert!(res.palette.contains(&BLUE) && used(&res), "{:?}", res.palette);
|
||||
// Snapped, both sides of the row straighten onto one line and its path is left out: no path is
|
||||
// in the blue, and the palette leaves it out too.
|
||||
let res = trace(&img, &TraceParams { snap_curves_to_lines: true, ..params });
|
||||
assert!(res.paths.iter().all(|p| p.color != BLUE), "{:?}", res.paths.iter().map(|p| (p.color, p.path.bounds())).collect::<Vec<_>>());
|
||||
assert!(!res.palette.contains(&BLUE) && used(&res), "{:?}", res.palette);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straight_anti_aliased_line_is_two_anchors_in_its_middle_as_wide_as_its_ink() {
|
||||
// 2.6 px wide, from (20.3, 30.7) to (170.6, 52.2): a little off the pixel rows.
|
||||
|
||||
@@ -898,7 +898,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
Cursor::Width | Cursor::WidthAdd => C::Crosshair,
|
||||
Cursor::WidthPoint => C::Move,
|
||||
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair,
|
||||
Cursor::PencilClose | Cursor::PencilStraight | Cursor::PaintbrushClose => C::Crosshair,
|
||||
Cursor::PencilClose | Cursor::PencilStraight | Cursor::PencilContinue | Cursor::PaintbrushClose => C::Crosshair,
|
||||
Cursor::TypeVertical => C::VerticalText,
|
||||
// Not drawn yet: the cursor each borrows.
|
||||
Cursor::Lasso
|
||||
@@ -3876,6 +3876,7 @@ mod tests {
|
||||
Cursor::ShapeBuilder,
|
||||
Cursor::ShapeBuilderErase,
|
||||
Cursor::PencilStraight,
|
||||
Cursor::PencilContinue,
|
||||
] {
|
||||
assert_eq!(cursor_icon(c), egui::CursorIcon::Crosshair, "{c:?}");
|
||||
assert_eq!(cursor_icon(c), cursor_icon(c.precise()), "{c:?}");
|
||||
|
||||
+100
-20
@@ -77,22 +77,37 @@ fn eyedropper(p: &mut Ink, o: Pos2) {
|
||||
line(p, o + vec2(11.0, -19.0), o + vec2(19.0, -11.0));
|
||||
}
|
||||
|
||||
/// The selection arrow's axis: the midline between its two edges at the tip, a unit vector.
|
||||
/// The pen nib lies along it, so swapping between the Pen and the selection tools (Cmd/Ctrl)
|
||||
/// doesn't rotate the cursor.
|
||||
fn arrow_axis() -> Vec2 {
|
||||
let a = arrow_points(Pos2::ZERO);
|
||||
let (e1, e2) = (a[1] - a[0], a[6] - a[0]);
|
||||
(e1.normalized() + e2.normalized()).normalized()
|
||||
}
|
||||
|
||||
/// The Pen family's nib: the pen-tool icon's, swung round onto the selection arrow's axis
|
||||
/// (`arrow_axis`, down-right) so swapping to the selection tools doesn't rotate the cursor, its
|
||||
/// point on the hotspot `o`. In nib coordinates `u` runs along the axis and `v` across it: the
|
||||
/// silhouette is symmetric about the axis — the point, shoulders flaring to their widest
|
||||
/// two-thirds back, then a narrower flat heel. The slit runs back along the axis from the point,
|
||||
/// widening into the round breather hole; both are cut to the halo's white. Every Pen cursor is
|
||||
/// this nib plus a badge below right of it saying what a click does.
|
||||
fn pen(p: &mut Ink, o: Pos2, badge: &str) {
|
||||
// Nib pointing to the top-left hotspot.
|
||||
let pts = vec![o, o + vec2(4.0, 12.0), o + vec2(8.0, 16.0), o + vec2(16.0, 8.0), o + vec2(12.0, 4.0)];
|
||||
poly(p, pts, INK, INK);
|
||||
p.circle_filled(o + vec2(6.0, 6.0), 1.4, HALO);
|
||||
line(p, o + vec2(10.0, 14.0), o + vec2(14.0, 18.0));
|
||||
let b = o + vec2(16.0, 14.0);
|
||||
let d = arrow_axis();
|
||||
let n = vec2(-d.y, d.x);
|
||||
let at = |u: f32, v: f32| o + u * d + v * n;
|
||||
poly(p, vec![at(0.0, 0.0), at(10.5, -6.3), at(15.5, -3.0), at(15.5, 3.0), at(10.5, 6.3)], INK, INK);
|
||||
// The slit is a wedge opening at the point, widening into the round breather hole.
|
||||
p.add(Shape::convex_polygon(vec![at(2.0, 0.0), at(10.4, -0.85), at(10.4, 0.85)], HALO, Stroke::NONE));
|
||||
p.circle_filled(at(11.8, 0.0), 1.7, HALO);
|
||||
let b = o + vec2(13.0, 12.0);
|
||||
match badge {
|
||||
"o" => {
|
||||
p.circle_stroke(b + vec2(3.0, 3.0), 3.0, Stroke::new(3.0, HALO));
|
||||
p.circle_stroke(b + vec2(3.0, 3.0), 3.0, Stroke::new(1.2, INK));
|
||||
}
|
||||
"+" => {
|
||||
line(p, b + vec2(0.0, 3.0), b + vec2(6.0, 3.0));
|
||||
line(p, b + vec2(3.0, 0.0), b + vec2(3.0, 6.0));
|
||||
}
|
||||
"+" => strokes(p, &[vec![b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)], vec![b + vec2(3.0, 0.0), b + vec2(3.0, 6.0)]]),
|
||||
"-" => line(p, b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)),
|
||||
"/" => line(p, b + vec2(0.0, 6.0), b + vec2(5.0, 0.0)),
|
||||
"~" => {
|
||||
@@ -108,14 +123,18 @@ fn pen(p: &mut Ink, o: Pos2, badge: &str) {
|
||||
let sq = [(3.0, -1.0), (7.0, -1.0), (7.0, 3.0), (3.0, 3.0)].map(|(x, y)| b + vec2(x, y));
|
||||
poly(p, sq.to_vec(), INK, INK);
|
||||
}
|
||||
"^" => {
|
||||
line(p, b + vec2(0.0, 6.0), b + vec2(3.0, 0.0));
|
||||
line(p, b + vec2(3.0, 0.0), b + vec2(6.0, 6.0));
|
||||
}
|
||||
"^" => strokes(p, &[vec![b + vec2(0.0, 6.0), b + vec2(3.0, 0.0)], vec![b + vec2(3.0, 0.0), b + vec2(6.0, 6.0)]]),
|
||||
"*" => {
|
||||
line(p, b + vec2(0.0, 0.0), b + vec2(6.0, 6.0));
|
||||
line(p, b + vec2(6.0, 0.0), b + vec2(0.0, 6.0));
|
||||
line(p, b + vec2(3.0, -1.0), b + vec2(3.0, 7.0));
|
||||
// The new-path star: eight spokes on the badge's centre.
|
||||
strokes(
|
||||
p,
|
||||
&[
|
||||
vec![b + vec2(0.6, 0.6), b + vec2(5.4, 5.4)],
|
||||
vec![b + vec2(5.4, 0.6), b + vec2(0.6, 5.4)],
|
||||
vec![b + vec2(3.0, 0.0), b + vec2(3.0, 6.0)],
|
||||
vec![b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)],
|
||||
],
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -409,7 +428,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
|
||||
|
||||
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
|
||||
#[cfg(test)]
|
||||
const GLYPHS: [Cursor; 79] = [
|
||||
const GLYPHS: [Cursor; 80] = [
|
||||
Cursor::Hand,
|
||||
Cursor::HandGrab,
|
||||
Cursor::ZoomIn,
|
||||
@@ -465,6 +484,7 @@ const GLYPHS: [Cursor; 79] = [
|
||||
Cursor::Pencil,
|
||||
Cursor::PencilClose,
|
||||
Cursor::PencilStraight,
|
||||
Cursor::PencilContinue,
|
||||
Cursor::Paintbrush,
|
||||
Cursor::PaintbrushClose,
|
||||
Cursor::BlobBrush,
|
||||
@@ -524,6 +544,14 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
crosshair(ink, p);
|
||||
line(ink, p + vec2(5.0, 12.0), p + vec2(12.0, 5.0));
|
||||
}
|
||||
// The straight-line cursor's line leaving a filled square: the end point a line continues from.
|
||||
Cursor::PencilContinue => {
|
||||
crosshair(ink, p);
|
||||
// The square first, so its halo does not cover the line.
|
||||
let end = p + vec2(5.0, 12.0);
|
||||
poly(ink, [(-2.0, -2.0), (2.0, -2.0), (2.0, 2.0), (-2.0, 2.0)].map(|(x, y)| end + vec2(x, y)).to_vec(), INK, INK);
|
||||
line(ink, end, p + vec2(12.0, 5.0));
|
||||
}
|
||||
Cursor::Eyedropper => eyedropper(ink, p),
|
||||
Cursor::ResizeH => double_arrow(ink, p, vec2(1.0, 0.0)),
|
||||
Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)),
|
||||
@@ -841,16 +869,45 @@ mod tests {
|
||||
assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}");
|
||||
}
|
||||
|
||||
/// The pen nib is a fountain pen's lying along the selection arrow's axis: its point on the
|
||||
/// hotspot, the slit running back along it into the round breather hole, the heel clipped
|
||||
/// at the back.
|
||||
#[test]
|
||||
fn the_pen_nib_has_a_slit_and_a_breather_hole() {
|
||||
for ppp in [1.0f32, 2.0] {
|
||||
let img = rasterize(Cursor::PenDrawing, ppp).expect("a bitmap");
|
||||
let [hx, hy] = img.hotspot.map(usize::from);
|
||||
let at = |x: f32, y: f32| {
|
||||
let (px, py) = ((hx as f32 + x * ppp) as usize, (hy as f32 + y * ppp) as usize);
|
||||
if px < img.size[0] as usize && py < img.size[1] as usize { pixel(&img, px, py) } else { [0; 4] }
|
||||
};
|
||||
// The breather hole, ~12 points along the axis ((4.6, 10.9) on screen): fully white.
|
||||
let hole = at(4.6, 10.9);
|
||||
assert!(hole[3] > 200 && hole[0] > 192, "the breather hole at {ppp}: {hole:?}");
|
||||
// The slit on the way to the hole: white over a black field.
|
||||
let slit = at(2.4, 5.5);
|
||||
assert!(slit[3] > 40 && slit[0] > 128, "the slit at {ppp}: {slit:?}");
|
||||
// The body past the hole is solid ink.
|
||||
let body = at(5.5, 12.9);
|
||||
assert!(body[3] > 200 && body[0] < 64, "the nib's body at {ppp}: {body:?}");
|
||||
// The heel's corners carry ink out near the bitmap's edge.
|
||||
for (x, y) in [(8.8f32, 13.1), (3.3, 15.4)] {
|
||||
let corner = at(x, y);
|
||||
assert!(corner[3] > 0, "a heel corner at {ppp} ({x}, {y}): {corner:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Pen drawing a path is the same nib as the new-path Pen, without the asterisk.
|
||||
#[test]
|
||||
fn the_drawing_pen_has_no_new_path_asterisk() {
|
||||
for ppp in [1.0, 2.0] {
|
||||
let (new, drawing) = (rasterize(Cursor::Pen, ppp).unwrap(), rasterize(Cursor::PenDrawing, ppp).unwrap());
|
||||
assert_eq!(new.hotspot, drawing.hotspot, "at {ppp}");
|
||||
// The asterisk's center, 19 × 17 points from the hotspot.
|
||||
// The asterisk's center, 16 × 15 points from the hotspot.
|
||||
let at = |img: &CustomCursorImage| {
|
||||
let [hx, hy] = img.hotspot.map(usize::from);
|
||||
let (x, y) = (hx + (19.0 * ppp) as usize, hy + (17.0 * ppp) as usize);
|
||||
let (x, y) = (hx + (16.0 * ppp) as usize, hy + (15.0 * ppp) as usize);
|
||||
if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y)[3] } else { 0 }
|
||||
};
|
||||
assert_eq!(at(&new), 255, "the new-path Pen has its asterisk at {ppp}");
|
||||
@@ -920,6 +977,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Pencil's continuation cursor is the straight-line cursor with a filled square on the
|
||||
/// line's lower end, beside the line where the straight-line cursor has no ink.
|
||||
#[test]
|
||||
fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point() {
|
||||
for ppp in [1.0, 2.0] {
|
||||
let cross = rasterize(Cursor::Crosshair, ppp).unwrap();
|
||||
let img = rasterize(Cursor::PencilContinue, ppp).unwrap();
|
||||
let straight = rasterize(Cursor::PencilStraight, ppp).unwrap();
|
||||
assert_eq!(img.hotspot, cross.hotspot, "at {ppp}");
|
||||
let at = |img: &CustomCursorImage, dx: f32, dy: f32| {
|
||||
let [hx, hy] = img.hotspot.map(usize::from);
|
||||
let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize);
|
||||
if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] }
|
||||
};
|
||||
let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64;
|
||||
assert!(inked(at(&img, 8.5, 8.5)), "the line crosses the badge's middle at {ppp}");
|
||||
assert!(inked(at(&img, 3.5, 13.5)), "the end point's square at {ppp}");
|
||||
assert!(!inked(at(&straight, 3.5, 13.5)), "the straight-line cursor has no end point at {ppp}");
|
||||
assert!(!inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}");
|
||||
assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() {
|
||||
let mut images = Images::default();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! File → Export → Export As…: the format and Use Artboards (All or a range: one file per
|
||||
//! artboard, after a central file of the whole document for EPS with two or more, or one page
|
||||
//! each in a PDF); without artboards the export covers the visible art. OK
|
||||
//! artboard, after a central file of the whole document for EPS with two or more when art shows,
|
||||
//! or one page each in a PDF); without artboards the export covers the visible art. OK
|
||||
//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG
|
||||
//! Options, Text Export Options, DXF Options, EPS Options, and Save PDF for artboards as pages),
|
||||
//! else the file is written. DWG is listed, greyed out, with what to use instead. The formats of
|
||||
|
||||
@@ -4224,6 +4224,7 @@ axis Both И двете
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) клавишните комбинации за инструменти трябва да са един клавиш (по избор със Shift)
|
||||
@msg truncated съкратено
|
||||
@msg unknown command `{0}` непозната команда `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? непозната команда `{0}`; имахте ли предвид `{1}`?
|
||||
@msg unknown effect `{id}` непознат ефект `{id}`
|
||||
@msg unknown format непознат формат
|
||||
@msg unknown format `{f}` (Save writes {_1}) непознат формат `{f}` (Запазването записва {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4629,6 +4629,7 @@ imageTrace Outline Obrys
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) zkratky nástrojů musí být jediná klávesa (volitelně se Shift)
|
||||
@msg truncated zkráceno
|
||||
@msg unknown command `{0}` neznámý příkaz `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? neznámý příkaz `{0}`; mysleli jste `{1}`?
|
||||
@msg unknown effect `{id}` neznámý efekt `{id}`
|
||||
@msg unknown format neznámý formát
|
||||
@msg unknown format `{f}` (Save writes {_1}) neznámý formát `{f}` (Uložit zapisuje {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4324,6 +4324,7 @@ imageTrace Outline Kontur
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) værktøjsgenveje skal være en enkelt tast (eventuelt med Skift)
|
||||
@msg truncated afkortet
|
||||
@msg unknown command `{0}` ukendt kommando `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? ukendt kommando `{0}`; mente du `{1}`?
|
||||
@msg unknown effect `{id}` ukendt effekt `{id}`
|
||||
@msg unknown format ukendt format
|
||||
@msg unknown format `{f}` (Save writes {_1}) ukendt format `{f}` (Gem skriver {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4252,6 +4252,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) Tastaturbefehle für Werkzeuge müssen aus einer einzelnen Taste bestehen (optional mit Shift)
|
||||
@msg truncated abgeschnitten
|
||||
@msg unknown command `{0}` unbekannter Befehl `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? unbekannter Befehl `{0}`; meinten Sie `{1}`?
|
||||
@msg unknown effect `{id}` unbekannter Effekt `{id}`
|
||||
@msg unknown format unbekanntes Format
|
||||
@msg unknown format `{f}` (Save writes {_1}) unbekanntes Format `{f}` („Speichern“ schreibt {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4300,6 +4300,7 @@ theme Appearance Εμφάνιση
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) οι συντομεύσεις εργαλείων πρέπει να περιλαμβάνουν ένα πλήκτρο (προαιρετικά με Shift)
|
||||
@msg truncated περικομμένο
|
||||
@msg unknown command `{0}` άγνωστη εντολή `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? άγνωστη εντολή `{0}`· μήπως εννοείτε `{1}`;
|
||||
@msg unknown effect `{id}` άγνωστο εφέ `{id}`
|
||||
@msg unknown format άγνωστη μορφή
|
||||
@msg unknown format `{f}` (Save writes {_1}) άγνωστη μορφή `{f}` (η Αποθήκευση υποστηρίζει {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2069 and column 19.
|
@@ -4327,6 +4327,7 @@ axis Both Ambos
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) los atajos de herramientas deben ser una sola tecla (opcionalmente con Shift)
|
||||
@msg truncated truncado
|
||||
@msg unknown command `{0}` comando desconocido `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? comando desconocido `{0}`; ¿quiso decir `{1}`?
|
||||
@msg unknown effect `{id}` efecto desconocido `{id}`
|
||||
@msg unknown format formato desconocido
|
||||
@msg unknown format `{f}` (Save writes {_1}) formato desconocido `{f}` (Guardar escribe {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4536,6 +4536,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) työkalujen pikanäppäinten on oltava yksittäinen näppäin (valinnaisesti Vaihto-näppäimen kanssa)
|
||||
@msg truncated katkaistu
|
||||
@msg unknown command `{0}` tuntematon komento `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? tuntematon komento `{0}`; tarkoititko `{1}`?
|
||||
@msg unknown effect `{id}` tuntematon tehoste `{id}`
|
||||
@msg unknown format tuntematon muoto
|
||||
@msg unknown format `{f}` (Save writes {_1}) tuntematon muoto `{f}` (Tallenna kirjoittaa muotoja {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4324,6 +4324,7 @@ axis Both Les deux
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) les raccourcis d’outils doivent être une seule touche (éventuellement avec Maj)
|
||||
@msg truncated tronqué
|
||||
@msg unknown command `{0}` commande inconnue `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? commande inconnue `{0}` ; vouliez-vous dire `{1}` ?
|
||||
@msg unknown effect `{id}` effet inconnu `{id}`
|
||||
@msg unknown format format inconnu
|
||||
@msg unknown format `{f}` (Save writes {_1}) format inconnu `{f}` (Enregistrer écrit {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5075,6 +5075,7 @@ imageTrace Outline Körvonal
|
||||
@msg truncated csonka
|
||||
@msg Unembedded to {_1} Beágyazás megszüntetve, ide: {_1}
|
||||
@msg unknown command `{0}` ismeretlen parancs: `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? ismeretlen parancs: `{0}`; erre gondolt: `{1}`?
|
||||
@msg unknown effect `{id}` ismeretlen effektus: `{id}`
|
||||
@msg unknown format ismeretlen formátum
|
||||
@msg unknown format `{f}` (Save writes {_1}) ismeretlen formátum: `{f}` (a Mentés ezeket írja: {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4324,6 +4324,7 @@ imageTrace Outline Kontur
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) pintasan alat harus satu tombol (opsional dengan Shift)
|
||||
@msg truncated terpotong
|
||||
@msg unknown command `{0}` perintah `{0}` tidak dikenal
|
||||
@msg unknown command `{0}`; did you mean `{1}`? perintah `{0}` tidak dikenal; apakah maksud Anda `{1}`?
|
||||
@msg unknown effect `{id}` efek `{id}` tidak dikenal
|
||||
@msg unknown format format tidak dikenal
|
||||
@msg unknown format `{f}` (Save writes {_1}) format `{f}` tidak dikenal (Simpan menulis {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3840 and column 19.
|
@@ -4367,6 +4367,7 @@ axis Both Entrambi
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) le scelte rapide degli strumenti devono essere un singolo tasto (eventualmente con Shift)
|
||||
@msg truncated troncato
|
||||
@msg unknown command `{0}` comando sconosciuto `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? comando sconosciuto `{0}`; forse intendevate `{1}`?
|
||||
@msg unknown effect `{id}` effetto sconosciuto `{id}`
|
||||
@msg unknown format formato sconosciuto
|
||||
@msg unknown format `{f}` (Save writes {_1}) formato sconosciuto `{f}` (Salva scrive {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4190,6 +4190,7 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg objects can't be nested more than 32 deep オブジェクトは 32 階層より深く入れ子にできません
|
||||
@msg the SVG's elements are nested more than {0} deep SVG の要素が {0} 階層より深く入れ子になっています
|
||||
@msg unknown command `{0}` `{0}` は不明なコマンドです
|
||||
@msg unknown command `{0}`; did you mean `{1}`? `{0}` は不明なコマンドです。`{1}` のことですか?
|
||||
@msg `{what}` closes nothing `{what}` に対応する開始部分がありません
|
||||
@msg `{what}` closes something it didn't open `{what}` が対応しない開始部分を閉じています
|
||||
@msg a path has too many points ポイントが多すぎるパスがあります
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4089,6 +4089,7 @@ font-technology Variable ცვლადი
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) ხელსაწყოს მალსახმობი ერთი კლავიში უნდა იყოს (სურვილისამებრ Shift-ით)
|
||||
@msg truncated შეკვეცილია
|
||||
@msg unknown command `{0}` უცნობი ბრძანება `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? უცნობი ბრძანება `{0}`; იქნებ `{1}` იგულისხმეთ?
|
||||
@msg unknown effect `{id}` უცნობი ეფექტი `{id}`
|
||||
@msg unknown format უცნობი ფორმატი
|
||||
@msg unknown format `{f}` (Save writes {_1}) უცნობი ფორმატი `{f}` (შენახვა წერს: {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4122,6 +4122,7 @@ theme Appearance 외관
|
||||
@msg objects can't be nested more than 32 deep 개체는 32단계보다 깊게 중첩할 수 없습니다
|
||||
@msg the SVG's elements are nested more than {0} deep SVG 요소가 {0}단계보다 깊게 중첩되어 있습니다
|
||||
@msg unknown command `{0}` 알 수 없는 명령 `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? 알 수 없는 명령 `{0}`: `{1}`을(를) 의미하셨습니까?
|
||||
@msg `{what}` closes nothing `{what}`이(가) 닫는 항목이 없습니다
|
||||
@msg `{what}` closes something it didn't open `{what}`이(가) 열지 않은 항목을 닫습니다
|
||||
@msg a path has too many points 패스의 점이 너무 많습니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4336,6 +4336,7 @@ imageTrace Outline Contour
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) gereedschapsneltoetsen moeten uit één toets bestaan (eventueel met Shift)
|
||||
@msg truncated afgekapt
|
||||
@msg unknown command `{0}` onbekende opdracht `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? onbekende opdracht `{0}`; bedoelde je `{1}`?
|
||||
@msg unknown effect `{id}` onbekend effect `{id}`
|
||||
@msg unknown format onbekende indeling
|
||||
@msg unknown format `{f}` (Save writes {_1}) onbekende indeling `{f}` (Opslaan schrijft {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4454,6 +4454,7 @@ imageTrace Outline Kontur
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) skróty narzędzi muszą być pojedynczym klawiszem (opcjonalnie z Shift)
|
||||
@msg truncated obcięty
|
||||
@msg unknown command `{0}` nieznane polecenie `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? nieznane polecenie `{0}`; czy chodziło o `{1}`?
|
||||
@msg unknown effect `{id}` nieznany efekt `{id}`
|
||||
@msg unknown format nieznany format
|
||||
@msg unknown format `{f}` (Save writes {_1}) nieznany format `{f}` (zapis tworzy {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3900,6 +3900,7 @@ axis Both Ambos
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) os atalhos de ferramenta devem ser uma única tecla (opcionalmente com Shift)
|
||||
@msg truncated truncado
|
||||
@msg unknown command `{0}` comando desconhecido `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? comando desconhecido `{0}`; você quis dizer `{1}`?
|
||||
@msg unknown effect `{id}` efeito desconhecido `{id}`
|
||||
@msg unknown format formato desconhecido
|
||||
@msg unknown format `{f}` (Save writes {_1}) formato desconhecido `{f}` (Salvar grava {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4363,6 +4363,7 @@ axis Both Оба
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) сочетания инструментов должны быть одной клавишей (необязательно с Shift)
|
||||
@msg truncated усечено
|
||||
@msg unknown command `{0}` неизвестная команда `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? неизвестная команда `{0}`; возможно, имелась в виду `{1}`?
|
||||
@msg unknown effect `{id}` неизвестный эффект `{id}`
|
||||
@msg unknown format неизвестный формат
|
||||
@msg unknown format `{f}` (Save writes {_1}) неизвестный формат `{f}` (сохранение записывает {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4236,6 +4236,7 @@ axis Both Båda
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) kortkommandon för verktyg måste vara en enda tangent (eventuellt med Shift)
|
||||
@msg truncated avkortad
|
||||
@msg unknown command `{0}` okänt kommando `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? okänt kommando `{0}`; menade du `{1}`?
|
||||
@msg unknown effect `{id}` okänd effekt `{id}`
|
||||
@msg unknown format okänt format
|
||||
@msg unknown format `{f}` (Save writes {_1}) okänt format `{f}` (Spara skriver {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -4090,6 +4090,7 @@ axis Both Her İkisi
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) araç kısayolları tek tuş olmalıdır (isteğe bağlı olarak Shift ile)
|
||||
@msg truncated kesik
|
||||
@msg unknown command `{0}` bilinmeyen komut `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? bilinmeyen komut `{0}`; `{1}` mi demek istediniz?
|
||||
@msg unknown effect `{id}` bilinmeyen etki `{id}`
|
||||
@msg unknown format bilinmeyen format
|
||||
@msg unknown format `{f}` (Save writes {_1}) bilinmeyen format `{f}` (Kaydet, {_1} yazar)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -4351,6 +4351,7 @@ axis Both Обидві
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) поєднання інструментів має бути однією клавішею (необов’язково з Shift)
|
||||
@msg truncated усічено
|
||||
@msg unknown command `{0}` невідома команда `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? невідома команда `{0}`; можливо, ви мали на увазі `{1}`?
|
||||
@msg unknown effect `{id}` невідомий ефект `{id}`
|
||||
@msg unknown format невідомий формат
|
||||
@msg unknown format `{f}` (Save writes {_1}) невідомий формат `{f}` (збереження записує {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 749 and column 40.
|
@@ -4730,6 +4730,7 @@ theme Appearance Giao diện
|
||||
@msg truncated bị cắt ngắn
|
||||
@msg Unembedded to {_1} Đã bỏ nhúng vào {_1}
|
||||
@msg unknown command `{0}` lệnh không xác định `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? lệnh không xác định `{0}`; ý bạn là `{1}`?
|
||||
@msg unknown effect `{id}` hiệu ứng không xác định `{id}`
|
||||
@msg unknown format định dạng không xác định
|
||||
@msg unknown format `{f}` (Save writes {_1}) định dạng không xác định `{f}` (Lưu ghi {_1})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4054 and column 10.
|
@@ -3921,6 +3921,7 @@ imageTrace Outline 轮廓
|
||||
@msg objects can't be nested more than 32 deep 对象的嵌套不能超过 32 层
|
||||
@msg the SVG's elements are nested more than {0} deep SVG 的元素嵌套超过 {0} 层
|
||||
@msg unknown command `{0}` 未知命令 `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? 未知命令 `{0}`;您是否想输入 `{1}`?
|
||||
@msg `{what}` closes nothing `{what}` 没有可结束的内容
|
||||
@msg `{what}` closes something it didn't open `{what}` 结束了并非由它开始的内容
|
||||
@msg a path has too many points 某条路径的点过多
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1901,6 +1901,7 @@ file-filter Templates 範本
|
||||
@msg objects can't be nested more than 32 deep 物件的巢狀層級不能超過 32 層
|
||||
@msg the SVG's elements are nested more than {0} deep SVG 的元素巢狀超過 {0} 層
|
||||
@msg unknown command `{0}` 未知的指令 `{0}`
|
||||
@msg unknown command `{0}`; did you mean `{1}`? 未知的指令 `{0}`;您是指 `{1}` 嗎?
|
||||
@msg `{what}` closes nothing `{what}` 沒有可結束的內容
|
||||
@msg `{what}` closes something it didn't open `{what}` 結束了並非由它開始的內容
|
||||
@msg a path has too many points 有一條路徑的點過多
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -930,9 +930,16 @@ mod tests {
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// Ctrl and Ctrl+Alt chords that type no text still run their commands while the Type tool
|
||||
/// edits, and with the Selection tool a Ctrl+Alt press runs its chord as before. Ctrl+L is New
|
||||
/// Layer, and Ctrl+Alt+L is New Layer Options…, which opens Layer Options for a new layer.
|
||||
/// What egui reports for the command key: Cmd on a Mac, Ctrl elsewhere. The keymap reads a
|
||||
/// Mac's Ctrl as a key of its own, so Ctrl+L there is not Cmd+L.
|
||||
fn command_key() -> Modifiers {
|
||||
if crate::keymap::platform().is_mac() { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND }
|
||||
}
|
||||
|
||||
/// Ctrl and Ctrl+Alt chords (Cmd and Cmd+Alt on a Mac) that type no text still run their
|
||||
/// commands while the Type tool edits, and with the Selection tool a Ctrl+Alt press runs its
|
||||
/// chord as before. Ctrl+L is New Layer, and Ctrl+Alt+L is New Layer Options…, which opens
|
||||
/// Layer Options for a new layer.
|
||||
#[test]
|
||||
fn ctrl_alt_chords_without_typed_text_still_run() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
@@ -944,20 +951,21 @@ mod tests {
|
||||
let layers = text_and_layers(&mut app, id).1;
|
||||
let new_layer_options =
|
||||
|app: &VectorcraftApp| app.ui.dialog.as_ref().is_some_and(|d| d.kind == "layerOptions" && d.fields.get("mode") == Some(&json!("new")));
|
||||
let m = Modifiers::COMMAND | Modifiers::CTRL;
|
||||
let ctrl_l = egui::Event::Key { key: Key::L, physical_key: Some(Key::L), pressed: true, repeat: false, modifiers: m };
|
||||
frame(&mut app, vec![egui::Event::ModifiersChanged(m), ctrl_l]);
|
||||
let m = command_key();
|
||||
frame(&mut app, press(Key::L, m));
|
||||
assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "Ctrl+L while editing: New Layer");
|
||||
assert!(app.ui.dialog.is_none(), "Ctrl+L opens no dialog");
|
||||
app.select_tool("type");
|
||||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||||
assert!(app.session.tool_wants_text());
|
||||
frame(&mut app, ctrl_alt_press(Key::L, None));
|
||||
frame(&mut app, press(Key::L, m | Modifiers::ALT));
|
||||
assert!(new_layer_options(&app), "Ctrl+Alt+L with no text typed while editing: New Layer Options…");
|
||||
assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "no layer is added until OK");
|
||||
crate::dialogs::cancel(&mut app);
|
||||
app.select_tool("selection");
|
||||
frame(&mut app, ctrl_alt_press(Key::L, Some("ł")));
|
||||
let mut typed = press(Key::L, m | Modifiers::ALT);
|
||||
typed.push(egui::Event::Text("ł".into()));
|
||||
frame(&mut app, typed);
|
||||
assert!(new_layer_options(&app), "Selection tool: Ctrl+Alt+L runs New Layer Options… as before");
|
||||
assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "no layer is added until OK");
|
||||
}
|
||||
@@ -1419,7 +1427,8 @@ mod tests {
|
||||
|
||||
/// A command bound to Cmd+Escape runs without the active tool's Escape: the Pen tool's path
|
||||
/// stays open, isolation mode stays on, and the Type tool goes on editing its text. With nothing
|
||||
/// bound to it, Cmd+Escape goes to the tool as Escape does.
|
||||
/// bound to it, Cmd+Escape goes to the tool as Escape does. Windows keeps Cmd+Escape (Ctrl+Esc)
|
||||
/// for the Start menu, so no command can be bound to it there and it goes to the tool.
|
||||
#[test]
|
||||
fn a_command_on_cmd_escape_runs_without_the_tools_escape() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
@@ -1427,12 +1436,17 @@ mod tests {
|
||||
let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap()["id"].clone();
|
||||
let grid = app.ui.view.grid;
|
||||
let cmd_escape = || press(Key::Escape, Modifiers::COMMAND);
|
||||
let bindable = crate::keymap::platform() != craft_keymap::Platform::Windows;
|
||||
bind_grid(&mut app, Some("Cmd+Escape"));
|
||||
app.select_tool("pen");
|
||||
click_at(&mut app, 100.0);
|
||||
let objects = click_at(&mut app, 150.0);
|
||||
frame(&mut app, cmd_escape());
|
||||
assert_eq!((click_at(&mut app, 200.0), app.ui.view.grid), (objects, !grid), "Cmd+Escape runs Show Grid and the path stays open");
|
||||
if bindable {
|
||||
assert_eq!((click_at(&mut app, 200.0), app.ui.view.grid), (objects, !grid), "Cmd+Escape runs Show Grid and the path stays open");
|
||||
} else {
|
||||
assert_eq!((click_at(&mut app, 200.0), app.ui.view.grid), (objects + 1, grid), "Windows: Cmd+Escape stays unbound and ends the path");
|
||||
}
|
||||
let text = app.session.execute("text.create", &json!({"x": 20, "y": 250, "text": "Hi"})).unwrap()["id"].clone();
|
||||
let edit = |app: &mut VectorcraftApp| {
|
||||
app.session.execute("select.set", &json!({"ids": [text]})).unwrap();
|
||||
@@ -1440,14 +1454,22 @@ mod tests {
|
||||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||||
assert!(app.session.tool_wants_text());
|
||||
};
|
||||
edit(&mut app);
|
||||
frame(&mut app, cmd_escape());
|
||||
assert_eq!((app.session.tool_wants_text(), app.ui.view.grid), (true, grid), "Cmd+Escape runs Show Grid and the Type tool goes on editing");
|
||||
if bindable {
|
||||
edit(&mut app);
|
||||
frame(&mut app, cmd_escape());
|
||||
assert_eq!(
|
||||
(app.session.tool_wants_text(), app.ui.view.grid),
|
||||
(true, grid),
|
||||
"Cmd+Escape runs Show Grid and the Type tool goes on editing"
|
||||
);
|
||||
}
|
||||
app.select_tool("selection");
|
||||
app.session.execute("object.isolate", &json!({"id": id})).unwrap();
|
||||
let isolated = |app: &VectorcraftApp| app.session.active().unwrap().isolation.is_some();
|
||||
frame(&mut app, cmd_escape());
|
||||
assert_eq!((isolated(&app), app.ui.view.grid), (true, !grid), "Cmd+Escape runs Show Grid and isolation mode stays on");
|
||||
if bindable {
|
||||
frame(&mut app, cmd_escape());
|
||||
assert_eq!((isolated(&app), app.ui.view.grid), (true, !grid), "Cmd+Escape runs Show Grid and isolation mode stays on");
|
||||
}
|
||||
bind_grid(&mut app, None);
|
||||
frame(&mut app, cmd_escape());
|
||||
assert!(!isolated(&app), "nothing bound: Cmd+Escape leaves isolation mode as Escape does");
|
||||
|
||||
@@ -658,7 +658,7 @@ EPS Options: Export As… with the EPS format (`file.exportAs {format: "eps"}`,
|
||||
`range`). `ui.dialog.confirm` checks them (a bad value or an unknown preset keeps the dialog open), remembers them for
|
||||
the next EPS Options and writes the file(s), asking for a path when there is none. With Use Artboards and two or more
|
||||
artboards, it writes a central file of the whole document under the chosen name and one `{stem}_{artboard}.eps` per
|
||||
artboard.
|
||||
artboard. When no art shows, it writes only the artboard files.
|
||||
|
||||
Data Recovery: at launch, copies left behind by a crash open the `recovery` dialog (never copies another running
|
||||
instance keeps, so instances started side by side for screenshots don't get it). Fields: `copies` (`[{file, title,
|
||||
|
||||
@@ -32,6 +32,18 @@ white beside a cream), at most six; scattered specks of a colour add none. Adobe
|
||||
its own palettes are chosen; this is VectorCraft's own method (see
|
||||
[image-trace-quality.md](image-trace-quality.md#auto-color-keeps-rare-colours)).
|
||||
|
||||
**Abutting and Overlapping.** Abutting is the default Method, and the Default, Auto Color, 3 Colors,
|
||||
6 Colors and 16 Colors presets use it. With Abutting, two neighboring colors share one exact edge. The
|
||||
canvas smooths the edges of each path on its own, so each of the two paths covers only part of the
|
||||
pixels along that edge, and a thin line of the background can show between the colors. Web browsers
|
||||
and PDF viewers usually draw an exported SVG or PDF file the same way and show the same line. Raster
|
||||
exports (PNG, JPEG, TIFF and the other bitmap formats) with None anti-aliasing do not show it at any
|
||||
size. Raster exports with Art Optimized anti-aliasing, the default, do not show it up to 10 million
|
||||
pixels and 16,384 pixels on a side. A raster export shows the line as the canvas does when it uses
|
||||
Type Optimized anti-aliasing, or when it uses Art Optimized and is larger than either limit.
|
||||
Overlapping stacks the colors instead: a lower color continues under the edge of the color above it,
|
||||
and no background shows along that edge.
|
||||
|
||||
**Large images.** An image of more than 1500 pixels on a side is traced as any other, only more
|
||||
slowly, and the trace says so: `imageTrace.make` returns the note in `warnings` and the status bar
|
||||
shows it (the [results optimization](https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html)
|
||||
|
||||
+19
-5
@@ -413,7 +413,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
| `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. |
|
||||
| `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. |
|
||||
| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. |
|
||||
| `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. |
|
||||
| `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. |
|
||||
| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee). |
|
||||
| `type_text` | `{text}` | Remote only. |
|
||||
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. |
|
||||
@@ -421,7 +421,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
| `screenshot` | `{path?, scale?, artboard?, window?, max_side?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. |
|
||||
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, Targa (`.tga`/`.vda`/`.icb`/`.vst`), PSD/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. |
|
||||
| `save_file` | `{path?, compress?, version?, preview?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), and `preview: true` embeds a PNG of the first artboard. `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version` or `preview` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version` or `preview`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version` or `preview`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |
|
||||
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG, SVGZ and EPS write one file per chosen artboard. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
|
||||
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
|
||||
| `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. |
|
||||
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
|
||||
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
|
||||
@@ -1030,6 +1030,14 @@ of the format they write (one object can carry several formats' options). A comm
|
||||
(`document.setup`, `text.areaOptions`) says so the same way. The app's own journal isn't checked: an action or
|
||||
journal recorded by an older version replays as it did, except for an effect `color` that the effect can't read.
|
||||
|
||||
An unknown command id is matched the same way as an unknown parameter key: the error names the nearest command id when
|
||||
one is near, ignoring case. A step of `command.batch` or `action.batch` gets the same suggestion in its step error:
|
||||
|
||||
```json
|
||||
{"id": "select.al"}
|
||||
→ unknown command `select.al`; did you mean `select.all`?
|
||||
```
|
||||
|
||||
These Object menu commands refuse a string or another value that is not a number with
|
||||
`` `scaleH` must be a number, not "50" ``, naming the key, and change nothing: `scaleH`, `scaleV`, `moveH`, `moveV`
|
||||
and `rotate` in `object.transformEach`; `ppi` in `object.rasterize`; `width`, `height`, `spacingX` and `spacingY` in
|
||||
@@ -3267,7 +3275,9 @@ images, symbol instances, live objects, and those in opacity masks, symbols and
|
||||
go in an image under the untouched vector art, other effects replace the object with its image, and a path whose
|
||||
fills or strokes carry effects is written as one piece per fill and stroke, so only the affected ones become images.
|
||||
When an image in the art can't be decoded, `object.rasterize` and `object.cropImage` (without `trim`) make their image
|
||||
without it, and their results carry the `warning` that Flatten Transparency gives.
|
||||
without it, and their results carry the `warning` that Flatten Transparency gives. `object.rasterize` takes each frame
|
||||
of threaded text that it rasterizes out of its thread with the text it shows, as `type.createOutlines` does (see
|
||||
Threading text from the in and out ports), and the other frames keep the text they hold.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}}
|
||||
@@ -3628,8 +3638,9 @@ fallback font…), each note once; the export still writes or returns every file
|
||||
Batch exports (#1935):
|
||||
- **What:** `layers: [id…]` (each layer's art alone) or `selection: "one" | "each"` instead of artboards
|
||||
(`assets`, `fullDocument` as before).
|
||||
- **Names:** `name` is a pattern for every file: `<filename>` (the document), `<name>` (the artboard, asset, layer
|
||||
or object), `<index>`, `####` (a counter zero-padded to the run of `#`, from `counterStart`; `counterPer: "item"`
|
||||
- **Names:** `name` is a pattern for every file: `<filename>` (the document's file name without its extension, or
|
||||
its title before it is saved; the Full Document file and the `.zip` use the same name), `<name>` (the artboard,
|
||||
asset, layer or object), `<index>`, `####` (a counter zero-padded to the run of `#`, from `counterStart`; `counterPer: "item"`
|
||||
or `"file"`), `YYYYMMDD`, `YYYY-MM-DD`, `<date>`, `<time>`, `<format>`, `<scale>` and `<suffix>` (else the row's
|
||||
suffix ends the name; `date` sets the day the pattern uses). Different files it names alike get `-2`, `-3`….
|
||||
- **Folders:** `folders: "none" | "rows" | "format" | "item" | "itemFormat"` (all in one, a folder per row, per
|
||||
@@ -4226,6 +4237,9 @@ thread. When `effect.expandAppearance` keeps a frame as type in a group (type wi
|
||||
effects are shadows, outer glows or Crop Marks), the type stays in the thread in the frame's place. Object › Expand
|
||||
(`object.expand`) outlines type with these two commands. `object.flattenTransparency` takes each frame of threaded text
|
||||
that it outlines (`textToOutlines`) or turns into flat regions or an image out of its thread in the same way.
|
||||
`symbol.new` (New Symbol) and `symbol.update` (Redefine Symbol) take each frame of threaded text in the selection out
|
||||
of its thread in the same way, and the symbol's art holds a copy of the frame with the text it shows. When every frame
|
||||
of a thread goes into the symbol, the copy of the last frame keeps the text it has no room for.
|
||||
|
||||
## Converting between point type and area type
|
||||
|
||||
|
||||
@@ -634,11 +634,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. A filled Pencil path keeps a stroke of None (#1804, part of #1679). While a Pencil or Paintbrush stroke is drawn back to within Close Paths When Ends Are Within of its start, the cursor shows the closing ring and a release there closes the path, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes for the Pencil. The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661).
|
||||
With the Blob Brush, Shift held during a drag keeps the stroke on the horizontal or vertical line through the press point, whichever is nearer to the pointer (90° steps from the Constrain Angle preference), and the points dragged since Shift went down move to the other line when the pointer crosses to it; the points dragged while Shift is up follow the pointer.
|
||||
Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests_draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512).
|
||||
While Alt is held, the Paintbrush, and the Pencil with Alt key toggles to Smooth Tool off, show a closing cursor (a crosshair with a ring); releasing the mouse button with Alt still held closes the path.
|
||||
With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). While Shift is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line). Option/Alt's unconstrained straight line waits for #3959: Alt still closes the path or lends the Smooth tool.
|
||||
With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). While Shift is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line). Option/Alt's unconstrained straight line remains: Alt still closes the path or lends the Smooth tool.
|
||||
While Alt is held, the Paintbrush (before a press and while drawing) shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open.
|
||||
With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). The straight-line cursor remains.
|
||||
With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
@@ -765,8 +762,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`).
|
||||
A paragraph that continues from one threaded frame into the next has its first-line indent and bullet or number in the frame where it starts; in the next frame its first line starts where its wrapped lines start. In frames that are no longer threaded, copies of a frame and point type converted from one, the first paragraph has its indent and bullet or number again.
|
||||
Type › Convert To Point Type on a frame of threaded text makes point type of the text that frame holds and takes it out of the thread; the story flows through the other frames without that text, so each character is in the document once.
|
||||
Object › Rasterize on a frame of threaded text takes it out of the thread with the text it shows, which the image draws, and the other frames keep the text they hold. When the last frame is rasterized, the text it has no room for goes after the text of the last frame that stays.
|
||||
Type › Create Outlines, Object › Expand and Object › Expand Appearance on a frame of threaded text take it out of the thread with the text it shows, which the outlines draw, and the other frames keep the text they hold. When the last frame is outlined, the text it has no room for goes after the text of the last frame that stays. When Expand Appearance keeps the frame as type in a group with its shadow, outer glow or Crop Marks, the type stays in the thread.
|
||||
Object › Flatten Transparency takes a frame of threaded text out of the thread in the same way when Convert All Text to Outlines outlines it or when the frame becomes flat regions or an image, and the other frames keep the text they hold.
|
||||
New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text.
|
||||
With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines.
|
||||
The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it.
|
||||
Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests_hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines.
|
||||
@@ -917,7 +916,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
- The Open and Place dialogs list PostScript `.ps` files and `.epsf` files. A `.ps` file opens and places through the EPS reader (its first page), also when its header is only `%!`, as the `ps` format, which exports don't write (Print to PostScript does); an `.epsf` file opens and places as EPS. Copied in a file manager, these files paste as EPS, and `clipboard.importEps` also reads PostScript whose header is only `%!` (`crates/engine/src/cmd/fileio/tests_epsimport.rs`).
|
||||
`document.pdfInfo` reads an EPS, a `.ps` file or a `.ai` in PostScript form as one page the size of its bounding box (a US Letter page without one), and its `thumbnail: 1` is a PNG of what that page prints (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`).
|
||||
- EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards (`crates/engine/src/cmd/fileio/tests_eps.rs`).
|
||||
- EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards; a document with no visible art gets only the artboard files (`crates/engine/src/cmd/fileio/tests_eps.rs`).
|
||||
- Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests_links.rs`).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
- TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`).
|
||||
|
||||
+2
-5
@@ -81,7 +81,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Click an open path's end to continue it; click another path's end to join | done | #776 |
|
||||
| Rubber Band preview | done | Preferences option |
|
||||
| Auto Add/Delete on a selected path; Shift or the preference disables it | done | |
|
||||
| Pen cursors (new path ×, continue /, close o, add +, delete −, convert ^) | partial | vector cursors exist; not every state verified |
|
||||
| Pen cursors (new path ×, continue /, close o, add +, delete −, convert ^) | done | the nib is a fountain-pen nib, tip on the hotspot, with the slit widening into the breather hole and the heel clipped square to its axis; bitmap tests at 1× and 2× pin the nib and the new-path asterisk, and in-app screenshots compare the Pen with Direct Selection |
|
||||
| Node-editing cursors: Direct Selection's white arrow with a hollow or filled square over an anchor (path unselected or selected); the Reshape Segment cursor over a curved segment (Direct Selection, Anchor Point, the Pen with Alt); the Anchor Point caret for the Pen with Alt; Group Selection's plus (also Direct Selection with Alt); a filled arrow while dragging that turns hollow on a snap; a double arrowhead for an Alt-drag copy | partial | as the public manual describes them, drawn in code; no cue over handles (none documented) |
|
||||
| Anchor Point and Curvature tools' own cursors | done | the caret on the anchor it converts; the pen nib with a curve badge |
|
||||
| Free Transform's Cmd/Ctrl side-shear cursor | done | two arrows passing along the side (ShearH, ShearV), instead of the resize arrow across it |
|
||||
@@ -91,10 +91,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain |
|
||||
| Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 |
|
||||
| Add/Delete/Anchor Point tools; Anchor Point tool drags segments to reshape | done | #2267: a click on a handle end retracts that handle and leaves the anchor a corner with the other one; Shift held at the press on an anchor moves it, handles and all (Shift kept down: at 45° steps) instead of pulling out handles |
|
||||
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, and Alt held at the press (with Alt key toggles to Smooth Tool off) a straight line at any angle (#3799); Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) off, the path a stroke draws or continues is not selected afterwards; a filled Pencil path keeps a stroke of None (#1804, part of #1679); while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start, the Pencil and the Paintbrush show the closing cursor, and a release there closes the path |
|
||||
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, with a straight-line cursor while Shift is held before the press (part of #3799; Alt's unconstrained straight line is not done); Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) off, the path a stroke draws or continues is not selected afterwards; a filled Pencil path keeps a stroke of None (#1804, part of #1679) |
|
||||
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, and Alt held at the press (with Alt key toggles to Smooth Tool off) a straight line at any angle (#3799); Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) off, the path a stroke draws or continues is not selected afterwards; a filled Pencil path keeps a stroke of None (#1804, part of #1679) |
|
||||
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | partial | Alt to Smooth is missing; Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) off, the path a stroke draws or continues is not selected afterwards; a filled Pencil path keeps a stroke of None (#1804, part of #1679) |
|
||||
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, and Alt held at the press (with Alt key toggles to Smooth Tool off) a straight line at any angle (#3799), with a straight-line cursor while Shift or Alt is held before the press, and a path-continuation cursor over an end of a selected open path, where the line continues the path; Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) off, the path a stroke draws or continues is not selected afterwards; a filled Pencil path keeps a stroke of None (#1804, part of #1679); while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start, the Pencil and the Paintbrush show the closing cursor, and a release there closes the path |
|
||||
| Join tool scrubbing, Average, Join | done | Join merges the direct-selected anchors lying on one another along a path, as after Average, into one (#1533) |
|
||||
| Average and Join in one step, with a corner or smooth choice for the joined anchor | partial | `path.join` accepts `to: corner|smooth` on the merged anchor; Average and Join are still separate commands; [gap 1](gaps.md) |
|
||||
| Smooth tool smooths only the stretch of path under the drag | done | #1512 |
|
||||
|
||||
Reference in New Issue
Block a user