mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 23:05:05 +00:00
Merge main into feat/dynamic-text-release-path
This commit is contained in:
@@ -30,7 +30,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
|
||||
with:
|
||||
toolchain: "1.95"
|
||||
- name: Fetch pinned and checksum-verified corpus
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "52 2 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -77,6 +81,7 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: vectorcraft
|
||||
app-title: VectorCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
@@ -159,6 +159,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-free-transform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-fx.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-gamut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-grad-conic.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-grad-freeform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-grad-linear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-grad-radial.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
||||
Generated
+34
-32
@@ -6313,7 +6313,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -6353,13 +6353,14 @@ dependencies = [
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -6370,14 +6371,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-apischema"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -6389,7 +6390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6405,7 +6406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -6428,7 +6429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -6440,7 +6441,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-ccitt",
|
||||
@@ -6459,7 +6460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6477,7 +6478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6498,6 +6499,7 @@ dependencies = [
|
||||
"roxmltree 0.21.2",
|
||||
"rustix 1.1.6",
|
||||
"ruzstd",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skrifa 0.47.0",
|
||||
@@ -6530,7 +6532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"encoding_rs",
|
||||
@@ -6558,7 +6560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-vectorcraft-format",
|
||||
"flate2",
|
||||
@@ -6574,7 +6576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -6586,17 +6588,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-js"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"getrandom 0.4.3",
|
||||
"proptest",
|
||||
"unicode-general-category",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -6615,7 +6617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6629,7 +6631,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -6641,7 +6643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"chrono",
|
||||
@@ -6675,7 +6677,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-vectorcraft-format",
|
||||
"image",
|
||||
@@ -6694,7 +6696,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif 0.14.2",
|
||||
@@ -6724,15 +6726,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"unicode-general-category",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"flate2",
|
||||
@@ -6759,7 +6761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"image",
|
||||
@@ -6777,7 +6779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -6798,7 +6800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -6806,7 +6808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6823,7 +6825,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6835,7 +6837,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"craft-keymap",
|
||||
@@ -6873,7 +6875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"eframe",
|
||||
@@ -8005,7 +8007,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
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.24.0"
|
||||
version = "0.24.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -237,7 +237,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday
|
||||
vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, JPEG XL in, Print, Package).
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out, PSD/PSB opened with their layers and placed as their merged image, JPEG XL in, Print, Package).
|
||||
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
|
||||
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
|
||||
and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master
|
||||
|
||||
@@ -3,8 +3,6 @@ use std::process::{Command, Stdio};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
/// Returns the path of `name` in `vectorcraft-cli-test-<pid>` in the temp folder. The first call removes
|
||||
/// that folder and creates it again empty.
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
@@ -27,7 +25,12 @@ fn approval_param_needs_the_start_up_flag(key: &str, value: Value) {
|
||||
std::fs::write(&steps, json!([{"command": "plugin.install", "params": {"dataBase64": module, key: value}}]).to_string()).unwrap();
|
||||
let config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{key}-{}", std::process::id())));
|
||||
let run = |extra: &[&str]| {
|
||||
Command::new(BIN).env("VECTORCRAFT_CONFIG_DIR", &config).args(["run", "--commands", steps.to_str().unwrap()]).args(extra).output().unwrap()
|
||||
Command::new(crate::bin())
|
||||
.env("VECTORCRAFT_CONFIG_DIR", &config)
|
||||
.args(["run", "--commands", steps.to_str().unwrap()])
|
||||
.args(extra)
|
||||
.output()
|
||||
.unwrap()
|
||||
};
|
||||
let out = run(&[]);
|
||||
assert!(!out.status.success());
|
||||
@@ -38,7 +41,7 @@ fn approval_param_needs_the_start_up_flag(key: &str, value: Value) {
|
||||
|
||||
let mcp_config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-mcp-{key}-{}", std::process::id())));
|
||||
let mcp_install = |extra: &[&str]| -> Value {
|
||||
let mut child = Command::new(BIN)
|
||||
let mut child = Command::new(crate::bin())
|
||||
.env("VECTORCRAFT_CONFIG_DIR", &mcp_config)
|
||||
.args(["mcp", "--headless"])
|
||||
.args(extra)
|
||||
@@ -77,22 +80,23 @@ fn plugin_grants_need_the_start_up_flag() {
|
||||
approval_param_needs_the_start_up_flag("grant", json!(["doc.read"]));
|
||||
approval_param_needs_the_start_up_flag("allowHeavy", json!(true));
|
||||
|
||||
let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap();
|
||||
let out = Command::new(crate::bin()).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants applies to the headless server"));
|
||||
let root = std::env::temp_dir();
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", flag, root.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap();
|
||||
let out =
|
||||
Command::new(crate::bin()).args(["mcp", "--allow-plugin-grants", flag, root.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success(), "{flag}");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants can't be used with"), "{flag}");
|
||||
}
|
||||
let usage = Command::new(BIN).arg("help").output().unwrap();
|
||||
let usage = Command::new(crate::bin()).arg("help").output().unwrap();
|
||||
assert!(String::from_utf8_lossy(&usage.stdout).contains("--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_prints_catalogue() {
|
||||
let out = Command::new(BIN).arg("commands").output().unwrap();
|
||||
let out = Command::new(crate::bin()).arg("commands").output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
let ids: Vec<&str> = v.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
|
||||
@@ -104,7 +108,7 @@ fn run_batch_exports() {
|
||||
let svg = tmp("batch.svg");
|
||||
let png = tmp("batch.png");
|
||||
let dc = tmp("batch.vectorcraft");
|
||||
let out = Command::new(BIN)
|
||||
let out = Command::new(crate::bin())
|
||||
.args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#])
|
||||
.args(["--cmd", "shape.ellipse", "--params", r#"{"x":10,"y":10,"width":80,"height":60}"#])
|
||||
.args(["--cmd", "paint.setFill", "--params", r##"{"color":"#3366ff"}"##])
|
||||
@@ -121,7 +125,7 @@ fn run_batch_exports() {
|
||||
assert_eq!(u32::from_be_bytes(png_bytes[16..20].try_into().unwrap()), 400);
|
||||
|
||||
// Re-open the native file and inspect.
|
||||
let out = Command::new(BIN).args(["run", "--in", dc.to_str().unwrap(), "--cmd", "document.inspect"]).output().unwrap();
|
||||
let out = Command::new(crate::bin()).args(["run", "--in", dc.to_str().unwrap(), "--cmd", "document.inspect"]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let last: Value = serde_json::from_str(String::from_utf8(out.stdout).unwrap().lines().last().unwrap()).unwrap();
|
||||
assert_eq!(last["result"]["artboards"][0]["width"], 200.0);
|
||||
@@ -137,7 +141,7 @@ fn scale_sizes_raster_exports_only() {
|
||||
let svg = svg.to_str().unwrap();
|
||||
let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png"));
|
||||
let ok = |args: &[&str]| {
|
||||
let out = Command::new(BIN).args(args).output().unwrap();
|
||||
let out = Command::new(crate::bin()).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
ok(&["convert", svg, plain.to_str().unwrap()]);
|
||||
@@ -151,11 +155,11 @@ fn scale_sizes_raster_exports_only() {
|
||||
|
||||
#[test]
|
||||
fn run_reports_errors() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
|
||||
let out = Command::new(crate::bin()).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}");
|
||||
let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap();
|
||||
let out = Command::new(crate::bin()).args(["run", "--params", "{}"]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
|
||||
@@ -184,7 +188,7 @@ fn run_reads_steps_from_a_commands_file() {
|
||||
{"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}},
|
||||
]);
|
||||
std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap();
|
||||
let out = Command::new(BIN)
|
||||
let out = Command::new(crate::bin())
|
||||
.args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()])
|
||||
.args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()])
|
||||
.output()
|
||||
@@ -204,7 +208,7 @@ fn run_reads_steps_from_a_commands_file() {
|
||||
#[test]
|
||||
fn run_reads_json_lines_from_standard_input() {
|
||||
let svg = tmp("stdin.svg");
|
||||
let mut child = Command::new(BIN)
|
||||
let mut child = Command::new(crate::bin())
|
||||
.args(["run", "--commands", "-", "--export", svg.to_str().unwrap()])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -227,7 +231,7 @@ fn run_reads_json_lines_from_standard_input() {
|
||||
#[test]
|
||||
fn run_refuses_malformed_commands_naming_the_step() {
|
||||
let fails = |args: &[&str]| {
|
||||
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
let out = Command::new(crate::bin()).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success(), "{args:?}");
|
||||
(String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len())
|
||||
};
|
||||
@@ -275,11 +279,11 @@ fn run_refuses_malformed_commands_naming_the_step() {
|
||||
/// `--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();
|
||||
let usage = Command::new(crate::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();
|
||||
let out = Command::new(crate::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));
|
||||
}
|
||||
@@ -290,7 +294,7 @@ fn help_after_a_subcommand_prints_the_usage() {
|
||||
/// with them.
|
||||
#[test]
|
||||
fn help_later_in_the_line_prints_the_usage() {
|
||||
let usage = Command::new(BIN).arg("help").output().unwrap();
|
||||
let usage = Command::new(crate::bin()).arg("help").output().unwrap();
|
||||
let mut wrong = vec![];
|
||||
for args in [
|
||||
&["mcp", "--headless", "--help"][..],
|
||||
@@ -311,7 +315,7 @@ fn help_later_in_the_line_prints_the_usage() {
|
||||
&["perf", "--paths", "many", "-h"],
|
||||
&["convert", "--decimals", "many", "--help"],
|
||||
] {
|
||||
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
let out = Command::new(crate::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)));
|
||||
}
|
||||
@@ -322,17 +326,18 @@ fn help_later_in_the_line_prints_the_usage() {
|
||||
/// 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 out = Command::new(crate::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 out = Command::new(crate::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();
|
||||
let mut child =
|
||||
Command::new(crate::bin()).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
{
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
for m in [
|
||||
@@ -359,13 +364,13 @@ fn mcp_headless_over_stdio() {
|
||||
/// plug-ins.
|
||||
#[test]
|
||||
fn safe_mode_mcp_does_not_connect_to_a_running_app() {
|
||||
let out = Command::new(BIN).args(["--safe-mode", "mcp", "--connect", "127.0.0.1:9"]).env("VECTORCRAFT_NO_PREFS", "1").output().unwrap();
|
||||
let out = Command::new(crate::bin()).args(["--safe-mode", "mcp", "--connect", "127.0.0.1:9"]).env("VECTORCRAFT_NO_PREFS", "1").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success() && err.contains("--safe-mode runs a headless session"), "{err}");
|
||||
// Something listening on the default address, as a running app does; a running app or another
|
||||
// program already holding it serves as well.
|
||||
let _app = std::net::TcpListener::bind(vectorcraft_mcp::DEFAULT_ADDR);
|
||||
let out = Command::new(BIN).args(["--safe-mode", "mcp"]).env("VECTORCRAFT_NO_PREFS", "1").stdin(Stdio::null()).output().unwrap();
|
||||
let out = Command::new(crate::bin()).args(["--safe-mode", "mcp"]).env("VECTORCRAFT_NO_PREFS", "1").stdin(Stdio::null()).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && err.contains("MCP server on stdio (headless)"), "{err}");
|
||||
}
|
||||
@@ -402,7 +407,7 @@ fn mcp_automation_roots_take_several_folders() {
|
||||
}
|
||||
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
|
||||
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
|
||||
let mut child = Command::new(BIN)
|
||||
let mut child = Command::new(crate::bin())
|
||||
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-read-root={}", dir("in2"))])
|
||||
.args(["--automation-write-root", &dir("out"), "--automation-write-root", &dir("out2")])
|
||||
.stdin(Stdio::piped())
|
||||
@@ -457,7 +462,7 @@ fn automation_roots(base: &std::path::Path) {
|
||||
}
|
||||
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
|
||||
let fails = |args: &[&str], why: &str| {
|
||||
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
let out = Command::new(crate::bin()).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success() && err.contains(why), "{args:?}: {err}");
|
||||
};
|
||||
@@ -469,7 +474,7 @@ fn automation_roots(base: &std::path::Path) {
|
||||
"a running app enforces its own roots: start it with `vectorcraft --control <port>",
|
||||
);
|
||||
|
||||
let mut child = Command::new(BIN)
|
||||
let mut child = Command::new(crate::bin())
|
||||
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-write-root={}", dir("out"))])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -530,7 +535,7 @@ fn automation_roots(base: &std::path::Path) {
|
||||
fn mcp_protocol_over_stdio() {
|
||||
use std::io::{BufRead, BufReader};
|
||||
let mut child =
|
||||
Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
Command::new(crate::bin()).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
|
||||
let mut notes = vec![];
|
||||
@@ -711,7 +716,7 @@ fn run_command(id: u64, command: &str, params: Value) -> Value {
|
||||
#[test]
|
||||
fn closed_stdout_ends_quietly() {
|
||||
use std::io::Read;
|
||||
let mut child = Command::new(BIN).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut child = Command::new(crate::bin()).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut first = [0u8; 16];
|
||||
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
|
||||
drop(child.stdout.take());
|
||||
@@ -728,13 +733,13 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
|
||||
let _ = std::fs::remove_file(&svg);
|
||||
let rect = r#"{"x":1,"y":1,"width":5,"height":5}"#;
|
||||
let args = ["run", "--cmd", "document.json", "--cmd", "shape.rectangle", "--params", rect, "--export", svg.to_str().unwrap()];
|
||||
let mut child = Command::new(BIN).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut child = Command::new(crate::bin()).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && err.is_empty(), "{:?}: {err}", out.status);
|
||||
assert!(svg.is_file(), "the export after the closed reader still ran");
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
|
||||
let out = Command::new(crate::bin()).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
|
||||
@@ -749,7 +754,7 @@ fn config_dir(name: &str) -> std::path::PathBuf {
|
||||
/// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`;
|
||||
/// the result of each step.
|
||||
fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec<Value> {
|
||||
let mut cmd = Command::new(BIN);
|
||||
let mut cmd = Command::new(crate::bin());
|
||||
cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS");
|
||||
for (id, params) in steps {
|
||||
cmd.args(["--cmd", id, "--params", ¶ms.to_string()]);
|
||||
@@ -820,7 +825,7 @@ fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() {
|
||||
#[test]
|
||||
fn runs_without_preferences_remember_nothing() {
|
||||
let config = config_dir("noprefs");
|
||||
let out = Command::new(BIN)
|
||||
let out = Command::new(crate::bin())
|
||||
.args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#])
|
||||
.env("VECTORCRAFT_CONFIG_DIR", &config)
|
||||
.env("VECTORCRAFT_NO_PREFS", "1")
|
||||
|
||||
@@ -4,8 +4,6 @@ use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
/// Returns the path of `name` in `vectorcraft-cli-fileio-<pid>` in the temp folder. The first call
|
||||
/// removes that folder and creates it again empty.
|
||||
fn tmp(name: &str) -> String {
|
||||
@@ -20,7 +18,7 @@ fn tmp(name: &str) -> String {
|
||||
}
|
||||
|
||||
fn ok(args: &[&str]) -> String {
|
||||
let out = Command::new(BIN).args(args).output().unwrap();
|
||||
let out = Command::new(crate::bin()).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap()
|
||||
}
|
||||
@@ -130,7 +128,7 @@ fn raster_exports_use_document_resolution_unless_overridden() {
|
||||
fn raster_export_rejects_invalid_ppi() {
|
||||
for ppi in ["0", "-150", "NaN", "inf", "high"] {
|
||||
for args in [vec!["run", "--export", "unused.png", "--ppi", ppi], vec!["convert", "unused.svg", "unused.png", "--ppi", ppi]] {
|
||||
let out = Command::new(BIN).args(&args).output().unwrap();
|
||||
let out = Command::new(crate::bin()).args(&args).output().unwrap();
|
||||
assert!(!out.status.success(), "{args:?}");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("positive finite number"), "{args:?}: {out:?}");
|
||||
}
|
||||
@@ -228,7 +226,7 @@ fn convert_writes_legacy_ai_with_its_options() {
|
||||
assert!(r["warnings"].to_string().contains("hidden objects are left out"), "{r}");
|
||||
assert!(std::fs::read(&one).unwrap().starts_with(b"%!PS-Adobe-3.0"));
|
||||
// A value the engine doesn't take is an error.
|
||||
let bad = Command::new(BIN).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap();
|
||||
let bad = Command::new(crate::bin()).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap();
|
||||
assert!(!bad.status.success());
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
}
|
||||
@@ -253,7 +251,7 @@ fn convert_and_run_write_ait_templates() {
|
||||
assert_eq!(info(ait)["artboards"].as_array().unwrap().len(), 2, "{ait}");
|
||||
}
|
||||
// A template holds every artboard: one can't be picked.
|
||||
let one = Command::new(BIN).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap();
|
||||
let one = Command::new(crate::bin()).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap();
|
||||
assert!(!one.status.success());
|
||||
assert!(String::from_utf8_lossy(&one.stderr).contains("every artboard"), "{}", String::from_utf8_lossy(&one.stderr));
|
||||
}
|
||||
|
||||
@@ -6,3 +6,11 @@ mod cli;
|
||||
mod fileio;
|
||||
mod mcp_e2e;
|
||||
mod script;
|
||||
|
||||
/// Nextest relocates archived binaries; Cargo's compile-time path still names the build runner.
|
||||
fn bin() -> std::path::PathBuf {
|
||||
std::env::var_os("NEXTEST_BIN_EXE_vectorcraft_cli")
|
||||
.or_else(|| std::env::var_os("CARGO_BIN_EXE_vectorcraft-cli"))
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| env!("CARGO_BIN_EXE_vectorcraft-cli").into())
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_testkit::raster::Image;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
struct Mcp {
|
||||
child: Child,
|
||||
stdin: Option<ChildStdin>,
|
||||
@@ -17,7 +15,7 @@ struct Mcp {
|
||||
|
||||
impl Mcp {
|
||||
fn spawn() -> Self {
|
||||
let mut child = Command::new(BIN)
|
||||
let mut child = Command::new(crate::bin())
|
||||
.args(["mcp", "--headless"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
|
||||
@@ -2,11 +2,9 @@ use std::process::Command;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
/// `run` with `script.run` after `file.new`, with `params`.
|
||||
fn run_script(params: &Value) -> std::process::Output {
|
||||
Command::new(BIN).args(["run", "--cmd", "file.new", "--cmd", "script.run", "--params", ¶ms.to_string()]).output().unwrap()
|
||||
Command::new(crate::bin()).args(["run", "--cmd", "file.new", "--cmd", "script.run", "--params", ¶ms.to_string()]).output().unwrap()
|
||||
}
|
||||
|
||||
/// `run` runs a script headless: the reply holds the report, with what the script logged and the
|
||||
|
||||
@@ -113,6 +113,10 @@ wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||
# exactly: review the diff before bumping.
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
|
||||
zbus = "=5.19.0"
|
||||
# Tells whether arboard reads through the compositor's data-control protocol or through X11, the
|
||||
# same check arboard makes (src/clipboard.rs). MIT OR Apache-2.0, already in the build through
|
||||
# arboard.
|
||||
wl-clipboard-rs = "0.9"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
+1066
-139
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,9 @@ use vectorcraft_ui_egui::native_menu::{self, Backend, Event, MenuBar, MenuRole,
|
||||
// AppKit can deliver window input while its system menu is still tracking (especially with
|
||||
// asynchronous menu presentation). Keep the native tracking state separate from egui popups.
|
||||
static TRACKING: AtomicBool = AtomicBool::new(false);
|
||||
/// A tracking session began since the last [`Backend::drain`], which reports it as
|
||||
/// [`Event::Opened`].
|
||||
static OPENED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub(crate) fn is_tracking() -> bool {
|
||||
TRACKING.load(Ordering::Relaxed)
|
||||
@@ -46,9 +49,11 @@ fn observe_tracking(ctx: &egui::Context) {
|
||||
let block = RcBlock::new(move |_: std::ptr::NonNull<NSNotification>| {
|
||||
TRACKING.store(tracking, Ordering::Relaxed);
|
||||
if tracking {
|
||||
OPENED.store(true, Ordering::Relaxed);
|
||||
super::mac_cursor::release();
|
||||
}
|
||||
// Apply any deferred menu updates as soon as the tracking session ends.
|
||||
// Report the opened menu and apply any deferred menu updates as soon as the
|
||||
// tracking session ends.
|
||||
if !tracking {
|
||||
repaint.request_repaint();
|
||||
}
|
||||
@@ -267,6 +272,9 @@ impl Backend for MacMenu {
|
||||
|
||||
fn drain(&mut self) -> Vec<Event> {
|
||||
let mut out = Vec::new();
|
||||
if OPENED.swap(false, Ordering::Relaxed) {
|
||||
out.push(Event::Opened);
|
||||
}
|
||||
while let Ok(chosen) = self.rx.try_recv() {
|
||||
cancel_tracking();
|
||||
// Ids that aren't ours are AppKit's own items (Services, Zoom…), already handled.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
|
||||
|
||||
/// Invalid roots must exit before desktop initialization on every platform, including a CI
|
||||
/// machine with no display. A regression must fail rather than hang with a window open.
|
||||
fn rejects(args: &[&str], message: &str) {
|
||||
@@ -10,7 +8,7 @@ fn rejects(args: &[&str], message: &str) {
|
||||
}
|
||||
|
||||
fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) {
|
||||
let mut child = Command::new(BIN)
|
||||
let mut child = Command::new(crate::bin())
|
||||
.args(args)
|
||||
.env_remove("VECTORCRAFT_CONTROL_PORT")
|
||||
.env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT")
|
||||
@@ -62,7 +60,7 @@ fn missing_empty_or_option_operands_fail_before_startup() {
|
||||
#[test]
|
||||
fn repeated_roots_are_accepted_for_both_spellings() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
let out = Command::new(BIN).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap();
|
||||
let out = Command::new(crate::bin()).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap();
|
||||
assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
|
||||
assert!(out.stderr.is_empty());
|
||||
@@ -73,7 +71,8 @@ fn repeated_roots_are_accepted_for_both_spellings() {
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_does_not_consume_version() {
|
||||
let out = Command::new(BIN).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap();
|
||||
let out =
|
||||
Command::new(crate::bin()).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
|
||||
assert!(out.stderr.is_empty());
|
||||
@@ -95,7 +94,7 @@ fn control_without_a_valid_port_fails_before_startup() {
|
||||
|
||||
#[test]
|
||||
fn version_wins_over_a_bad_control_port_variable() {
|
||||
let out = Command::new(BIN).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap();
|
||||
let out = Command::new(crate::bin()).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
|
||||
}
|
||||
|
||||
@@ -6,12 +6,10 @@ use std::process::{Command, Output, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
|
||||
|
||||
/// Runs the desktop binary without a display and with a control port it cannot parse, so a
|
||||
/// regression exits with that error instead of opening a window.
|
||||
fn run(args: &[&str], env: &[(&str, &str)]) -> Output {
|
||||
let mut child = Command::new(BIN)
|
||||
let mut child = Command::new(crate::bin())
|
||||
.args(args)
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
|
||||
@@ -4,3 +4,11 @@
|
||||
|
||||
mod automation_args;
|
||||
mod help;
|
||||
|
||||
/// Nextest relocates archived binaries; Cargo's compile-time path still names the build runner.
|
||||
fn bin() -> std::path::PathBuf {
|
||||
std::env::var_os("NEXTEST_BIN_EXE_vectorcraft")
|
||||
.or_else(|| std::env::var_os("CARGO_BIN_EXE_vectorcraft"))
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| env!("CARGO_BIN_EXE_vectorcraft").into())
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><defs><clipPath id="c"><rect x="3.5" y="3.5" width="17" height="17"/></clipPath></defs><g clip-path="url(#c)" stroke="none" fill="white"><path d="M12 12L26 12L21.9 21.9Z" fill-opacity="0.1"/><path d="M12 12L21.9 21.9L12 26Z" fill-opacity="0.23"/><path d="M12 12L12 26L2.1 21.9Z" fill-opacity="0.36"/><path d="M12 12L2.1 21.9L-2 12Z" fill-opacity="0.49"/><path d="M12 12L-2 12L2.1 2.1Z" fill-opacity="0.61"/><path d="M12 12L2.1 2.1L12 -2Z" fill-opacity="0.74"/><path d="M12 12L12 -2L21.9 2.1Z" fill-opacity="0.87"/><path d="M12 12L21.9 2.1L26 12Z"/></g><rect x="3.5" y="3.5" width="17" height="17" stroke="currentColor" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 826 B |
@@ -35,7 +35,7 @@ Affinity files.
|
||||
|
||||
Everything else is reported in the import warnings, one line per kind with a count, never
|
||||
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush strokes, pressure curves that can't be read or that go past the million points one document may hold,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
|
||||
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages,
|
||||
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
|
||||
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
|
||||
active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with
|
||||
|
||||
+118
-10
@@ -15,20 +15,36 @@ pub enum GradientKind {
|
||||
Radial,
|
||||
/// Freeform gradients: colour points and lines (see [`crate::freeform`]).
|
||||
Freeform,
|
||||
/// Conic (angle) gradients: the stops sweep once around the center (`start`), clockwise on
|
||||
/// the page from the vector, with a seam where the last stop meets the first. A negative
|
||||
/// `aspect` mirrors the sweep (see [`GradientGeom::transform`]).
|
||||
Conic,
|
||||
}
|
||||
|
||||
impl GradientKind {
|
||||
/// Parse a kind name (`linear`, `radial`, `freeform`; any case).
|
||||
/// Every kind, in the order the Gradient panel lists them.
|
||||
pub const ALL: [GradientKind; 4] = [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Freeform];
|
||||
/// Parse a kind name (`linear`, `radial`, `conic`, `freeform`; any case). `angle` and
|
||||
/// `conical` also mean conic.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[GradientKind::Linear, GradientKind::Radial, GradientKind::Freeform].into_iter().find(|k| k.label().eq_ignore_ascii_case(s))
|
||||
if s.eq_ignore_ascii_case("angle") || s.eq_ignore_ascii_case("conical") {
|
||||
return Some(GradientKind::Conic);
|
||||
}
|
||||
Self::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GradientKind::Linear => "Linear",
|
||||
GradientKind::Radial => "Radial",
|
||||
GradientKind::Freeform => "Freeform",
|
||||
GradientKind::Conic => "Conic",
|
||||
}
|
||||
}
|
||||
/// Is it placed by a center (`start`) and an extent ellipse (radial and conic), rather than
|
||||
/// by a line?
|
||||
pub fn is_centered(self) -> bool {
|
||||
matches!(self, GradientKind::Radial | GradientKind::Conic)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a gradient mixes the colours between its stops (the Gradient panel's Method).
|
||||
@@ -642,19 +658,23 @@ pub struct GradientGeom {
|
||||
/// How far out a focal point may sit, as a fraction of the extent ellipse.
|
||||
const FOCAL_MAX: f64 = 0.99;
|
||||
|
||||
/// Most wedges [`GradientGeom::conic_wedges`] draws a conic gradient as.
|
||||
pub const MAX_CONIC_WEDGES: usize = 4096;
|
||||
|
||||
fn one64() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl GradientGeom {
|
||||
/// Default placement for a bounding box: linear spans the box through the centre at `angle_deg`,
|
||||
/// radial is centred with radius = half the larger dimension and its vector at `angle_deg`.
|
||||
/// radial and conic are centered with radius = half the larger dimension and their vector at
|
||||
/// `angle_deg`.
|
||||
pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self {
|
||||
let c = b.center();
|
||||
let a = angle_deg.to_radians();
|
||||
let d = Vec2::new(a.cos(), -a.sin());
|
||||
match kind {
|
||||
GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None },
|
||||
GradientKind::Radial | GradientKind::Conic => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None },
|
||||
_ => {
|
||||
// Project the box corners onto the direction to cover the whole box.
|
||||
let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0;
|
||||
@@ -668,7 +688,12 @@ impl GradientGeom {
|
||||
/// Linear (and freeform) gradients keep their isolines: the new vector is the normal to the
|
||||
/// mapped isolines, reaching the line the old end maps onto. Radial gradients map their ellipse
|
||||
/// (the radius along the vector, `aspect` × the radius across it); its principal axes give the
|
||||
/// new end (on the axis nearest the mapped vector) and aspect.
|
||||
/// new end (on the axis nearest the mapped vector) and aspect. Conic gradients keep their
|
||||
/// vector exactly (where the sweep starts, so the seam stays in place) and take the mapped
|
||||
/// extent across it as their aspect, its sign flipping under a mirror so the sweep turns the
|
||||
/// other way: exact for moves, rotations, uniform scales and mirrors, and the nearest frame
|
||||
/// that keeps the seam under a shear or a non-uniform scale. A conic whose vector has no
|
||||
/// length, or whose aspect is 0, still has the sign of its aspect flipped by a mirror.
|
||||
pub fn transform(&mut self, a: Affine, kind: GradientKind) {
|
||||
// The focal point is a point like any other.
|
||||
self.focal = self.focal.map(|f| a * f);
|
||||
@@ -677,9 +702,26 @@ impl GradientGeom {
|
||||
let perp = |v: Vec2| Vec2::new(-v.y, v.x);
|
||||
let start = a * self.start;
|
||||
let u = self.end - self.start;
|
||||
let mirror = m0 * m3 - m1 * m2 < 0.0;
|
||||
if u.hypot2() < 1e-18 {
|
||||
self.start = start;
|
||||
self.end = start;
|
||||
if kind == GradientKind::Conic && mirror {
|
||||
self.aspect = -self.aspect;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if kind == GradientKind::Conic {
|
||||
let (lu, lw) = (lin(u), lin(perp(u) * self.aspect));
|
||||
self.start = start;
|
||||
self.end = start + lu;
|
||||
let aspect = lw.dot(perp(lu)) / lu.hypot2();
|
||||
self.aspect = match (lu.hypot2() > 1e-18 && aspect.is_finite(), aspect == 0.0) {
|
||||
(false, _) => 1.0,
|
||||
// A flat extent keeps its sign, so a mirror still turns the sweep the other way.
|
||||
(true, true) => 0.0f64.copysign(if self.aspect.is_sign_negative() != mirror { -1.0 } else { 1.0 }),
|
||||
(true, false) => aspect,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if kind != GradientKind::Radial {
|
||||
@@ -724,8 +766,15 @@ impl GradientGeom {
|
||||
/// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection
|
||||
/// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial,
|
||||
/// measured from the focal point (each level is the ellipse scaled about the line from the
|
||||
/// focal point to the centre).
|
||||
/// focal point to the centre). For conic gradients it is the turn from the vector in
|
||||
/// [`Self::conic_frame`], clockwise on the page (counterclockwise when mirrored), in 0..=1.
|
||||
pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 {
|
||||
if kind == GradientKind::Conic {
|
||||
let q = (self.conic_frame().inverse() * p).to_vec2();
|
||||
let t = q.y.atan2(q.x) / std::f64::consts::TAU;
|
||||
let t = if t < 0.0 { t + 1.0 } else { t };
|
||||
return if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
let u = self.end - self.start;
|
||||
if u.hypot2() < 1e-18 {
|
||||
return 0.0;
|
||||
@@ -746,13 +795,72 @@ impl GradientGeom {
|
||||
(-b - (b * b - a * d.hypot2()).max(0.0).sqrt()) / a
|
||||
}
|
||||
|
||||
/// The map from the unit circle onto a radial's extent ellipse: (1, 0) to the end, (0, 1) to
|
||||
/// `aspect` × the radius across the vector. None when the ellipse is flat (no inverse).
|
||||
/// The map from the unit circle onto a radial's (or conic's) extent ellipse: (1, 0) to the
|
||||
/// end, (0, 1) to `aspect` × the radius across the vector (a conic's negative aspect mirrors
|
||||
/// it). None when the ellipse is flat (no inverse).
|
||||
pub fn unit_frame(&self) -> Option<Affine> {
|
||||
let u = self.end - self.start;
|
||||
(u.hypot2() * self.aspect.abs() > 1e-18).then(|| Affine::new([u.x, u.y, -u.y * self.aspect, u.x * self.aspect, self.start.x, self.start.y]))
|
||||
}
|
||||
|
||||
/// The frame a conic gradient sweeps in: its [`Self::unit_frame`], or, when the extent
|
||||
/// ellipse is flat or not finite (a vector without length, an aspect of 0, or a file's
|
||||
/// out-of-range numbers), the unit circle about the start turning from the vector's direction
|
||||
/// (from the right when the vector has none), mirrored by a negative aspect. The canvas,
|
||||
/// Expand and every export draw a conic in this frame.
|
||||
pub fn conic_frame(&self) -> Affine {
|
||||
if let Some(m) = self.unit_frame().filter(|m| m.is_finite() && m.determinant().abs() > 1e-18) {
|
||||
return m;
|
||||
}
|
||||
if !self.start.is_finite() {
|
||||
return Affine::IDENTITY;
|
||||
}
|
||||
let u = self.end - self.start;
|
||||
let len = u.hypot();
|
||||
let d = if len.is_finite() && len > 1e-9 { u / len } else { Vec2::new(1.0, 0.0) };
|
||||
let s = if self.aspect.is_sign_negative() && !self.aspect.is_nan() { -1.0 } else { 1.0 };
|
||||
Affine::new([d.x, d.y, -d.y * s, d.x * s, self.start.x, self.start.y])
|
||||
}
|
||||
|
||||
/// Is the sweep mirrored (counterclockwise on the page): a conic's negative aspect?
|
||||
pub fn is_mirrored(&self) -> bool {
|
||||
self.conic_frame().determinant() < 0.0
|
||||
}
|
||||
|
||||
/// A conic gradient as `n` solid wedges around its center (at most [`MAX_CONIC_WEDGES`]), for
|
||||
/// formats without conic gradients: each wedge's outline (the center, then an arc that
|
||||
/// reaches past every corner of `b`) and the turn its color is sampled at (its middle). With
|
||||
/// `overlap` (opaque stops), each wedge runs on to the seam and the next is drawn over it, so
|
||||
/// no hairline of what lies below shows between neighbors and the seam stays sharp.
|
||||
pub fn conic_wedges(&self, b: Rect, n: usize, overlap: bool) -> Vec<(Vec<Point>, f64)> {
|
||||
let n = n.clamp(1, MAX_CONIC_WEDGES);
|
||||
let frame = self.conic_frame();
|
||||
let to_unit = frame.inverse();
|
||||
let corners = [b.origin(), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)];
|
||||
let reach = corners.iter().map(|&c| (to_unit * c).to_vec2().hypot()).fold(0.0f64, f64::max).max(1e-6);
|
||||
let reach = if reach.is_finite() { reach * 1.01 } else { 1.0 };
|
||||
// Arc points at most ARC of a turn apart, at a radius where their chords stay outside
|
||||
// `reach`.
|
||||
const ARC: f64 = 1.0 / 32.0;
|
||||
let r = reach / (std::f64::consts::PI * ARC).cos();
|
||||
let at = |turn: f64| {
|
||||
let a = turn * std::f64::consts::TAU;
|
||||
frame * Point::new(r * a.cos(), r * a.sin())
|
||||
};
|
||||
let center = frame * Point::ORIGIN;
|
||||
(0..n)
|
||||
.map(|k| {
|
||||
let from = k as f64 / n as f64;
|
||||
let to = if overlap { 1.0 } else { (k + 1) as f64 / n as f64 };
|
||||
let steps = ((to - from) / ARC).ceil().max(1.0) as usize;
|
||||
let mut pts = Vec::with_capacity(steps + 2);
|
||||
pts.push(center);
|
||||
pts.extend((0..=steps).map(|i| at(from + (to - from) * i as f64 / steps as f64)));
|
||||
(pts, (k as f64 + 0.5) / n as f64)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The map that squashes a radial gradient's circle (centre `start`, radius the vector's
|
||||
/// length) into its extent ellipse: by `aspect` across the vector, about the centre.
|
||||
pub fn radial_squash(&self) -> Affine {
|
||||
@@ -901,7 +1009,7 @@ impl GradientPaint {
|
||||
pub fn draws_as_pixels(&self, fill: bool) -> bool {
|
||||
self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic()
|
||||
}
|
||||
/// A linear or radial gradient placed on box `bounds` as a `cols` × `rows` grid of
|
||||
/// A linear, radial or conic gradient placed on box `bounds` as a `cols` × `rows` grid of
|
||||
/// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours
|
||||
/// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered and painting a
|
||||
/// fill (`fill`; dither applies to fills only), each colour channel of pixel (column, row) gets
|
||||
@@ -932,7 +1040,7 @@ impl GradientPaint {
|
||||
) -> impl Iterator<Item = [u8; 4]> + use<> {
|
||||
self.grid(Ramp::new(&self.gradient, rgb), bounds, area, cols, rows, cells, fill, false)
|
||||
}
|
||||
/// [`Self::pixels`] of a linear or radial gradient whose stops are all CMYK, as C, M, Y, K
|
||||
/// [`Self::pixels`] of a linear, radial or conic gradient whose stops are all CMYK, as C, M, Y, K
|
||||
/// and opacity levels mixed from the stops' inks; `None` when a stop is in another model,
|
||||
/// there are no stops, or a Perceptual curve has too many stops for [`Gradient::ink_mixes`].
|
||||
/// Dither's noise adds no ink where the ramp has none.
|
||||
|
||||
@@ -697,3 +697,186 @@ fn ink_pixels_stop_at_the_ink_mixes_limit() {
|
||||
assert!(GradientPaint::new(perceptual).ink_pixels(b, b, 2, 1, false).is_none());
|
||||
assert!(GradientPaint::new(classic).ink_pixels(b, b, 2, 1, false).is_some());
|
||||
}
|
||||
|
||||
/// A conic placed at `center` with its vector `dx` long to the right and aspect `aspect`.
|
||||
fn conic_at(center: Point, dx: f64, aspect: f64) -> GradientGeom {
|
||||
GradientGeom { start: center, end: center + Vec2::new(dx, 0.0), aspect, focal: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conic_turns_clockwise_on_the_page_from_its_vector() {
|
||||
// The page's y runs down: a quarter turn clockwise from a vector pointing right is straight
|
||||
// below the center.
|
||||
let g = conic_at(Point::new(100.0, 100.0), 50.0, 1.0);
|
||||
let t = |x: f64, y: f64| g.param_at(GradientKind::Conic, Point::new(x, y));
|
||||
assert!(t(200.0, 100.0).abs() < 1e-12);
|
||||
assert!((t(100.0, 130.0) - 0.25).abs() < 1e-12);
|
||||
assert!((t(10.0, 100.0) - 0.5).abs() < 1e-12);
|
||||
assert!((t(100.0, 3.0) - 0.75).abs() < 1e-12);
|
||||
// Either side of the seam: the start of the ramp just below the vector, its end just above.
|
||||
assert!(t(200.0, 100.01) < 1e-3);
|
||||
let before = t(200.0, 99.99);
|
||||
assert!(before > 0.999 && before <= 1.0, "{before}");
|
||||
// A flatter extent ellipse turns faster near the vector's line: half as tall, the point 1/8
|
||||
// of a turn around is half as far below it.
|
||||
let squashed = conic_at(Point::new(100.0, 100.0), 50.0, 0.5);
|
||||
let p = Point::new(150.0, 125.0);
|
||||
assert!((squashed.param_at(GradientKind::Conic, p) - 0.125).abs() < 1e-12);
|
||||
assert!(g.param_at(GradientKind::Conic, p) < 0.1);
|
||||
// Mirrored (a negative aspect), it turns counterclockwise.
|
||||
let mirrored = conic_at(Point::new(100.0, 100.0), 50.0, -1.0);
|
||||
assert!(mirrored.is_mirrored() && !g.is_mirrored());
|
||||
assert!((mirrored.param_at(GradientKind::Conic, Point::new(100.0, 70.0)) - 0.25).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conic_follows_moves_turns_scales_and_mirrors() {
|
||||
let c = Point::new(10.0, 20.0);
|
||||
let g0 = conic_at(c, 30.0, 1.0);
|
||||
let probes = [c + Vec2::new(5.0, 7.0), c + Vec2::new(-9.0, 2.0), c + Vec2::new(3.0, -11.0)];
|
||||
for a in [
|
||||
Affine::rotate(0.7),
|
||||
Affine::scale(2.5),
|
||||
Affine::translate((4.0, -9.0)) * Affine::rotate(-2.0),
|
||||
Affine::FLIP_X,
|
||||
Affine::FLIP_Y * Affine::rotate(1.1),
|
||||
Affine::scale_non_uniform(2.0, 0.5),
|
||||
] {
|
||||
let mut g = g0;
|
||||
g.transform(a, GradientKind::Conic);
|
||||
assert!(g.start.distance(a * c) < 1e-9, "{a:?}");
|
||||
for p in probes {
|
||||
let (want, got) = (g0.param_at(GradientKind::Conic, p), g.param_at(GradientKind::Conic, a * p));
|
||||
assert!((want - got).abs() < 1e-9, "{a:?} at {p:?}: {want} vs {got}");
|
||||
}
|
||||
}
|
||||
// A mirror and a mirror back: the same sweep.
|
||||
let mut g = g0;
|
||||
g.transform(Affine::FLIP_X, GradientKind::Conic);
|
||||
assert!(g.is_mirrored());
|
||||
g.transform(Affine::FLIP_X, GradientKind::Conic);
|
||||
assert!(!g.is_mirrored() && (g.aspect - 1.0).abs() < 1e-12);
|
||||
// A conic without a vector, or with a flat extent, turns the other way under a mirror too.
|
||||
for flat in [conic_at(c, 0.0, 1.0), conic_at(c, 30.0, 0.0)] {
|
||||
let mut g = flat;
|
||||
g.transform(Affine::FLIP_Y, GradientKind::Conic);
|
||||
assert!(g.is_mirrored(), "{g:?}");
|
||||
g.transform(Affine::rotate(0.3), GradientKind::Conic);
|
||||
assert!(g.is_mirrored(), "{g:?}");
|
||||
g.transform(Affine::FLIP_X, GradientKind::Conic);
|
||||
assert!(!g.is_mirrored(), "{g:?}");
|
||||
}
|
||||
// Under a shear the seam stays where the vector maps.
|
||||
let shear = Affine::new([1.0, 0.0, 0.6, 1.0, 0.0, 0.0]);
|
||||
let mut g = g0;
|
||||
g.transform(shear, GradientKind::Conic);
|
||||
let on_seam = shear * (c + Vec2::new(20.0, 0.0));
|
||||
assert!(g.param_at(GradientKind::Conic, on_seam) < 1e-9, "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conic_kind_names_and_fit() {
|
||||
for s in ["conic", "Conic", "angle", "CONICAL"] {
|
||||
assert_eq!(GradientKind::parse(s), Some(GradientKind::Conic), "{s}");
|
||||
}
|
||||
assert_eq!(GradientKind::parse("spiral"), None);
|
||||
assert_eq!(GradientKind::Conic.label(), "Conic");
|
||||
assert!(GradientKind::Conic.is_centered() && GradientKind::Radial.is_centered() && !GradientKind::Linear.is_centered());
|
||||
let fit = GradientGeom::fit(GradientKind::Conic, Rect::new(0.0, 0.0, 200.0, 100.0), 90.0);
|
||||
assert_eq!(fit.start, Point::new(100.0, 50.0));
|
||||
assert!(fit.end.distance(Point::new(100.0, -50.0)) < 1e-9);
|
||||
}
|
||||
|
||||
/// Is `p` inside polygon `poly` (non-zero winding)?
|
||||
fn inside(poly: &[Point], p: Point) -> bool {
|
||||
let mut w = 0i32;
|
||||
for (i, &a) in poly.iter().enumerate() {
|
||||
let c = poly[(i + 1) % poly.len()];
|
||||
let cross = (c - a).cross(p - a);
|
||||
if a.y <= p.y && c.y > p.y && cross > 0.0 {
|
||||
w += 1;
|
||||
} else if a.y > p.y && c.y <= p.y && cross < 0.0 {
|
||||
w -= 1;
|
||||
}
|
||||
}
|
||||
w != 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conic_wedges_cover_the_box_and_meet_at_the_seam() {
|
||||
let b = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let g = conic_at(Point::new(60.0, 40.0), 30.0, 0.5);
|
||||
let wedges = g.conic_wedges(b, 36, false);
|
||||
assert_eq!(wedges.len(), 36);
|
||||
// Each point of the box lies in the wedge whose share of the turn holds it.
|
||||
for (x, y) in [(1.0, 1.0), (199.0, 99.0), (150.0, 30.0), (10.0, 90.0), (61.0, 39.0), (59.0, 41.0)] {
|
||||
let p = Point::new(x, y);
|
||||
let t = g.param_at(GradientKind::Conic, p);
|
||||
let k = ((t * 36.0).floor() as usize).min(35);
|
||||
assert!(inside(&wedges[k].0, p), "({x}, {y}) at {t} is not in wedge {k}");
|
||||
assert!((wedges[k].1 - (k as f64 + 0.5) / 36.0).abs() < 1e-12);
|
||||
}
|
||||
// Overlapping wedges run on from their own start to the seam.
|
||||
let over = g.conic_wedges(b, 36, true);
|
||||
let frame = g.unit_frame().unwrap();
|
||||
let on = |turn: f64| frame * Point::new(0.5 * (turn * std::f64::consts::TAU).cos(), 0.5 * (turn * std::f64::consts::TAU).sin());
|
||||
assert!(inside(&over[0].0, on(0.5)) && inside(&over[0].0, on(35.9 / 36.0)));
|
||||
assert!(inside(&over[20].0, on(35.9 / 36.0)) && !inside(&over[20].0, on(19.9 / 36.0)));
|
||||
// The count is kept to what a file can hold.
|
||||
assert_eq!(g.conic_wedges(b, 0, false).len(), 1);
|
||||
assert_eq!(g.conic_wedges(b, usize::MAX, false).len(), MAX_CONIC_WEDGES);
|
||||
}
|
||||
|
||||
/// A conic whose extent can't be drawn (a vector without length, an aspect of 0, or numbers out
|
||||
/// of range, as a file can hold them) sweeps from the right about its start, or the origin; the
|
||||
/// parameter, the frame and the wedges stay finite.
|
||||
#[test]
|
||||
fn a_conic_with_no_extent_or_bad_numbers_still_sweeps() {
|
||||
let c = Point::new(50.0, 50.0);
|
||||
for g in [
|
||||
conic_at(c, 0.0, 1.0),
|
||||
conic_at(c, 30.0, 0.0),
|
||||
conic_at(c, 30.0, f64::NAN),
|
||||
conic_at(c, 30.0, f64::INFINITY),
|
||||
conic_at(c, f64::INFINITY, 1.0),
|
||||
GradientGeom { end: Point::new(f64::NAN, 3.0), ..conic_at(c, 30.0, 1.0) },
|
||||
] {
|
||||
assert!(g.conic_frame().as_coeffs().iter().all(|v| v.is_finite()), "{g:?}");
|
||||
let t = |x: f64, y: f64| g.param_at(GradientKind::Conic, Point::new(x, y));
|
||||
assert!(t(90.0, 50.0).abs() < 1e-12, "{g:?}");
|
||||
assert!((t(50.0, 90.0) - 0.25).abs() < 1e-12, "{g:?}");
|
||||
let wedges = g.conic_wedges(Rect::new(0.0, 0.0, 100.0, 100.0), 4, false);
|
||||
assert!(wedges.iter().flat_map(|(pts, _)| pts).all(|p| p.is_finite()), "{g:?}");
|
||||
}
|
||||
// A flat extent keeps the vector's direction and the sign of its aspect.
|
||||
let down = GradientGeom { end: Point::new(50.0, 80.0), ..conic_at(c, 30.0, 0.0) };
|
||||
assert!(down.param_at(GradientKind::Conic, Point::new(50.0, 90.0)).abs() < 1e-12);
|
||||
assert!((down.param_at(GradientKind::Conic, Point::new(10.0, 50.0)) - 0.25).abs() < 1e-12);
|
||||
for g in [conic_at(c, 0.0, -1.0), conic_at(c, 30.0, -0.0), conic_at(c, 30.0, f64::NEG_INFINITY)] {
|
||||
assert!(g.is_mirrored(), "{g:?}");
|
||||
assert!((g.param_at(GradientKind::Conic, Point::new(50.0, 10.0)) - 0.25).abs() < 1e-12, "{g:?}");
|
||||
}
|
||||
let lost = GradientGeom { start: Point::new(f64::NAN, 0.0), ..conic_at(c, 30.0, 1.0) };
|
||||
assert_eq!(lost.conic_frame(), Affine::IDENTITY);
|
||||
for p in [Point::new(3.0, 4.0), Point::new(f64::NAN, 1.0), Point::new(f64::INFINITY, 0.0)] {
|
||||
let t = lost.param_at(GradientKind::Conic, p);
|
||||
assert!((0.0..=1.0).contains(&t), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A conic's pixels (what SVG and PDF draw a dithered or Perceptual one as) follow the sweep,
|
||||
/// with one stop as well as many.
|
||||
#[test]
|
||||
fn conic_pixels_follow_the_sweep() {
|
||||
let b = Rect::new(0.0, 0.0, 4.0, 4.0);
|
||||
let mut g = GradientPaint::new(Gradient::new(GradientKind::Conic, Gradient::default().stops));
|
||||
g.geom = Some(conic_at(Point::new(2.0, 2.0), 1.0, 1.0));
|
||||
let px: Vec<[u8; 4]> = g.pixels(b, b, 4, 4, true, &Color::to_rgb).collect();
|
||||
// Below right of the center, early in the sweep: light. Above right, late: dark.
|
||||
assert!(px[3 * 4 + 3][0] > 180 && px[3][0] < 75, "{px:?}");
|
||||
let one = GradientPaint { gradient: Gradient::new(GradientKind::Conic, vec![GradientStop::new(0.3, Color::rgb(1.0, 0.0, 0.0))]), ..g.clone() };
|
||||
assert!(one.pixels(b, b, 4, 4, true, &Color::to_rgb).all(|p| p == [255, 0, 0, 255]));
|
||||
let many = (0..=1000).map(|i| GradientStop::new(i as f32 / 1000.0, Color::gray((i % 2) as f32))).collect();
|
||||
let many = GradientPaint { gradient: Gradient::new(GradientKind::Conic, many), ..g };
|
||||
assert_eq!(many.pixels(b, b, 4, 4, true, &Color::to_rgb).count(), 16);
|
||||
}
|
||||
|
||||
@@ -189,10 +189,22 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
|
||||
let value = match &w.paint {
|
||||
Paint::Solid { color, .. } => css_color(color, 1.0),
|
||||
Paint::Gradient(g) if g.gradient.kind != GradientKind::Freeform => {
|
||||
// A mirrored conic turns counterclockwise, and CSS only turns clockwise: its stops
|
||||
// are written mirrored about the seam instead.
|
||||
let mirrored = g.gradient.kind == GradientKind::Conic && g.geom.is_some_and(|geom| geom.is_mirrored());
|
||||
let stop = |s: &crate::GradientStop| {
|
||||
let at = if mirrored { 1.0 - s.offset } else { s.offset };
|
||||
format!("{} {}%", css_color(&s.color, s.opacity), at * 100.0)
|
||||
};
|
||||
let stops: Vec<String> =
|
||||
g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), s.offset * 100.0)).collect();
|
||||
if mirrored { g.gradient.stops.iter().rev().map(stop).collect() } else { g.gradient.stops.iter().map(stop).collect() };
|
||||
match g.gradient.kind {
|
||||
GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")),
|
||||
// CSS sweeps clockwise from straight up; the angle is counted counterclockwise
|
||||
// from the right.
|
||||
GradientKind::Conic => {
|
||||
format!("conic-gradient(from {}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", "))
|
||||
}
|
||||
_ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,26 @@ fn css_escapes_names_and_writes_gradients() {
|
||||
assert!(read(&text, "x").is_err(), "CSS is written only");
|
||||
}
|
||||
|
||||
/// A conic gradient swatch is a CSS conic gradient starting where its angle points (CSS turns
|
||||
/// clockwise from straight up); a mirrored one, which turns counterclockwise, has its stops mirrored
|
||||
/// about the seam so each color lies on the same side.
|
||||
#[test]
|
||||
fn css_writes_conic_gradient_swatches() {
|
||||
let one = |name: &str, g: GradientPaint| SwatchLibrary {
|
||||
name: "x".into(),
|
||||
swatches: vec![Swatch { name: name.into(), paint: Paint::Gradient(Box::new(g)), global: false, spot: false }],
|
||||
groups: vec![],
|
||||
};
|
||||
let mut conic = GradientPaint::new(Gradient { kind: GradientKind::Conic, ..Gradient::default() });
|
||||
conic.angle = 90.0;
|
||||
let text = written(&one("Sweep", conic.clone()), PaletteFormat::Css);
|
||||
assert!(text.contains("--sweep: conic-gradient(from 0deg, #ffffff 0%, #000000 100%);"), "{text}");
|
||||
conic.gradient.stops[0].offset = 0.2;
|
||||
conic.geom = Some(crate::GradientGeom { start: kurbo::Point::ZERO, end: kurbo::Point::new(0.0, -10.0), aspect: -1.0, focal: None });
|
||||
let text = written(&one("Back", conic), PaletteFormat::Css);
|
||||
assert!(text.contains("--back: conic-gradient(from 0deg, #000000 0%, #ffffff 80%);"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_preserves_fractional_gradient_stop_positions() {
|
||||
for kind in [GradientKind::Linear, GradientKind::Radial] {
|
||||
|
||||
@@ -444,13 +444,13 @@ impl Appearance {
|
||||
}
|
||||
}
|
||||
/// Map placed gradients through a warp, given its local affine approximation at a point (taken
|
||||
/// at each gradient's centre: the start of a radial, the middle of a linear vector). Freeform
|
||||
/// points each follow the warp at their own position.
|
||||
/// at each gradient's center: the start of a radial or conic, the middle of a linear vector).
|
||||
/// Freeform points each follow the warp at their own position.
|
||||
pub fn warp_gradients(&mut self, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) {
|
||||
for g in self.gradients_mut() {
|
||||
let freeform = g.freeform.take();
|
||||
if let Some(geom) = g.geom {
|
||||
let c = if g.gradient.kind == vectorcraft_color::GradientKind::Radial { geom.start } else { geom.start.midpoint(geom.end) };
|
||||
let c = if g.gradient.kind.is_centered() { geom.start } else { geom.start.midpoint(geom.end) };
|
||||
g.transform(near(c));
|
||||
}
|
||||
g.freeform = freeform.map(|mut f| {
|
||||
|
||||
@@ -15,7 +15,8 @@ pub struct BeyondCanvas {
|
||||
/// Objects removed whole (a layer or group keeps its children within the canvas).
|
||||
pub objects: usize,
|
||||
pub artboards: usize,
|
||||
/// Guides beyond the canvas, and the artboard guides of the artboards removed.
|
||||
/// Guides beyond the canvas, the artboard guides of the artboards removed, and the layer
|
||||
/// guides of the layers removed.
|
||||
pub guides: usize,
|
||||
/// The ruler origin was beyond the canvas and is back at the origin.
|
||||
pub ruler_origin: bool,
|
||||
@@ -100,11 +101,20 @@ fn layers_in(n: &Node, layers: &mut Vec<NodeId>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many ruler guides the layers in `n` hold, if it is a layer or group.
|
||||
fn guides_in(n: &Node) -> usize {
|
||||
match clipping(n) {
|
||||
None => 0,
|
||||
Some(_) => n.children().into_iter().flatten().map(|c| if c.is_ruler_guide() { 1 } else { guides_in(c) }).sum(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the objects in `node` that reach beyond the canvas: a layer or group keeps those of its
|
||||
/// objects within it, unless its clipping path reaches beyond (see [`clips_beyond`]); anything
|
||||
/// else goes whole. Only containers holding such an object are copied. Recursion follows the
|
||||
/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`.
|
||||
fn drop_children_beyond(node: &mut Arc<Node>, dropped: &mut usize, layers: &mut Vec<NodeId>) {
|
||||
/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`, and
|
||||
/// the ruler guides they held to `guides`.
|
||||
fn drop_children_beyond(node: &mut Arc<Node>, dropped: &mut usize, layers: &mut Vec<NodeId>, guides: &mut usize) {
|
||||
if !node.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))) {
|
||||
return;
|
||||
}
|
||||
@@ -114,10 +124,11 @@ fn drop_children_beyond(node: &mut Arc<Node>, dropped: &mut usize, layers: &mut
|
||||
return true;
|
||||
}
|
||||
if clipping(c).is_some() && !clips_beyond(c) {
|
||||
drop_children_beyond(c, dropped, layers);
|
||||
drop_children_beyond(c, dropped, layers, guides);
|
||||
return true;
|
||||
}
|
||||
layers_in(c, layers);
|
||||
*guides += guides_in(c);
|
||||
*dropped += 1;
|
||||
false
|
||||
});
|
||||
@@ -159,13 +170,14 @@ impl Document {
|
||||
for c in children.iter() {
|
||||
layers_in(c, &mut layers);
|
||||
}
|
||||
// The layer's own ruler guides stay; only its art goes.
|
||||
// The layer's own ruler guides stay; only its art goes, with its sublayers' guides.
|
||||
gone.objects += children.iter().filter(|c| !c.is_ruler_guide()).count();
|
||||
gone.guides += children.iter().filter(|c| !c.is_ruler_guide()).map(|c| guides_in(c)).sum::<usize>();
|
||||
children.retain(|c| c.is_ruler_guide());
|
||||
*clip = false;
|
||||
}
|
||||
} else {
|
||||
drop_children_beyond(layer, &mut gone.objects, &mut layers);
|
||||
drop_children_beyond(layer, &mut gone.objects, &mut layers, &mut gone.guides);
|
||||
}
|
||||
}
|
||||
if gone.objects > 0 {
|
||||
@@ -184,7 +196,7 @@ impl Document {
|
||||
let guides = self.saved_guides.len();
|
||||
self.saved_guides
|
||||
.retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l)));
|
||||
gone.guides = guides - self.saved_guides.len();
|
||||
gone.guides += guides - self.saved_guides.len();
|
||||
let out = |n: &Node| match n.kind {
|
||||
NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || artboard.is_some_and(|a| removed.contains(&a)),
|
||||
_ => false,
|
||||
@@ -324,6 +336,21 @@ mod tests {
|
||||
let layer = d2.layers[0].id.0;
|
||||
assert_eq!(d2.drop_beyond_canvas().objects, 1);
|
||||
assert_eq!(ids(&d2), [layer, 10]);
|
||||
// The guides of a sublayer emptied out of a top-level layer go with it, counted.
|
||||
let probe = Document::new(1.0, 1.0);
|
||||
let sub = sublayer(&probe, 40, vec![rect(41, 0.0)], false);
|
||||
let mut d3 = doc(vec![rect(10, 1e17), sub]);
|
||||
if let NodeKind::Layer { clip, .. } = &mut Arc::make_mut(&mut d3.layers[0]).kind {
|
||||
*clip = true;
|
||||
}
|
||||
d3.saved_guides =
|
||||
vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d3.layers[0].id), ..Guide::new(true, 5.0) }];
|
||||
let mut nodes = d3.clone();
|
||||
assert_eq!(nodes.guides_into_nodes(), 2);
|
||||
let gone = d3.drop_beyond_canvas();
|
||||
assert_eq!((gone.objects, gone.guides), (2, 1), "the clipping path, the sublayer and its guide; the layer's own guide stays");
|
||||
assert_eq!(nodes.drop_beyond_canvas(), gone);
|
||||
assert_eq!(nodes.ruler_guides().len(), 1);
|
||||
assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default());
|
||||
}
|
||||
|
||||
@@ -353,8 +380,13 @@ mod tests {
|
||||
];
|
||||
d.variables.bindings.insert(NodeId(11), ["v".to_string()].into());
|
||||
d.variables.bindings.insert(NodeId(10), ["w".to_string()].into());
|
||||
// The same guides as nodes, as a document has them once read.
|
||||
let mut nodes = d.clone();
|
||||
assert_eq!(nodes.guides_into_nodes(), 2);
|
||||
let gone = d.drop_beyond_canvas();
|
||||
assert_eq!((gone.objects, gone.guides), (2, 1), "the far rectangle, the clipping sublayer and its layer guide");
|
||||
assert_eq!(nodes.drop_beyond_canvas(), gone, "a guide node goes with its sublayer, counted as a saved one is");
|
||||
assert_eq!(nodes.ruler_guides().len(), 1);
|
||||
assert_eq!(d.saved_guides.len(), 1, "the guide of a layer that stays, stays");
|
||||
assert_eq!(d.saved_selections[0].objects, [NodeId(10)]);
|
||||
assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::<Vec<_>>(), [(2, vec![NodeId(10)])]);
|
||||
|
||||
@@ -78,7 +78,9 @@ pub struct EnvelopeOptions {
|
||||
/// Rasters: Preserve Shape Using.
|
||||
pub preserve_shape: PreserveShape,
|
||||
/// Distort Appearance: strokes and live effects are applied before the distortion, so they
|
||||
/// bend with the art (off: they apply to the distorted paths).
|
||||
/// bend with the art (off: they apply to the distorted paths). Type's strokes are the exception:
|
||||
/// they paint along its distorted glyph outlines at their own weight either way, as `.ai` files
|
||||
/// draw them (#6644).
|
||||
pub distort_appearance: bool,
|
||||
/// Distort Linear Gradients (with Distort Appearance): placed gradients bend with the art.
|
||||
pub distort_linear_gradients: bool,
|
||||
@@ -638,6 +640,7 @@ pub(super) fn expand_envelope(
|
||||
hooks,
|
||||
options,
|
||||
depth: 0,
|
||||
type_outlines: false,
|
||||
};
|
||||
content.iter().map(|c| map_node(c, &w)).collect()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ pub struct Hooks<'a> {
|
||||
/// Distort Appearance: a path's strokes and geometry effects baked into filled art (`None`
|
||||
/// when it has none to bake).
|
||||
pub appearance: Outliner<'a>,
|
||||
/// Distort Appearance for the pieces of type's glyph outlines: their geometry effects baked
|
||||
/// as [`Hooks::appearance`] bakes them, their strokes left as strokes, which then paint along
|
||||
/// the bent outlines at their own weight (#6644). `None` when a piece has nothing to bake, and
|
||||
/// without this hook nothing in type's outlines is baked.
|
||||
pub type_appearance: Outliner<'a>,
|
||||
/// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]).
|
||||
pub pattern: Option<PatternTiles<'a>>,
|
||||
/// The document whose symbols' art counts toward the instance budget of a Repeat of their
|
||||
@@ -61,6 +66,12 @@ pub(super) struct Warper<'a> {
|
||||
pub(super) options: EnvelopeOptions,
|
||||
/// How many hooks the art being mapped came through.
|
||||
pub(super) depth: u32,
|
||||
/// Are groups marked as type's glyph outlines ([`crate::NodeExtras::type_outlines`]) in the art
|
||||
/// being mapped type that live evaluation outlined: the type of the envelope's contents, or of
|
||||
/// a live object in them, such as an envelope inside the envelope? Their strokes then paint
|
||||
/// along the bent outlines at their own weight (#6644). Groups so marked in the contents as
|
||||
/// they are, art an earlier Expand made of type, are plain art.
|
||||
pub(super) type_outlines: bool,
|
||||
}
|
||||
|
||||
impl Warper<'_> {
|
||||
@@ -117,6 +128,13 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node {
|
||||
*live = None;
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
|
||||
// Type's glyph outlines: with Distort Appearance too, their strokes paint along the
|
||||
// bent outlines at their own weight, as `.ai` files draw them (#6644). Their pieces are
|
||||
// baked by `type_appearance`, which keeps strokes as strokes; their geometry effects,
|
||||
// gradients and pattern fills follow the options as a path's do. The group's opacity
|
||||
// mask, mapped above, bends as other art does.
|
||||
let typed = Warper { hooks: Hooks { appearance: w.hooks.type_appearance, ..w.hooks }, ..*w };
|
||||
let w = if w.type_outlines && n.extras().type_outlines { &typed } else { w };
|
||||
for c in children.iter_mut() {
|
||||
*c = Arc::new(map_node(c, w));
|
||||
}
|
||||
@@ -124,14 +142,17 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node {
|
||||
NodeKind::Mesh(m) => m.map(w.f),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
|
||||
if let Some(d) = &deeper {
|
||||
return map_node(&expanded_group_hooks(n, w.hooks), d);
|
||||
// The type's outlines in its art are type (an envelope inside the envelope, #6644).
|
||||
return map_node(&expanded_group_hooks(n, w.hooks), &Warper { type_outlines: true, ..*d });
|
||||
}
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => {
|
||||
if let Some(d) = &deeper
|
||||
&& let Some(o) = w.hooks.outline.and_then(|h| h(n))
|
||||
{
|
||||
return map_node(&carry_look(n, o), d);
|
||||
// Type's outlines are type (#6644, the group arm above).
|
||||
let d = Warper { type_outlines: d.type_outlines || matches!(n.kind, NodeKind::Text(_)), ..*d };
|
||||
return map_node(&carry_look(n, o), &d);
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => {
|
||||
@@ -439,8 +460,11 @@ pub fn subtree_has_live(n: &Node) -> bool {
|
||||
any
|
||||
}
|
||||
|
||||
/// Largest stroke outset in a subtree (for visual bounds of envelopes). With `symbols`, the
|
||||
/// outsets in the art of the symbols its instances show count too
|
||||
/// Largest stroke outset in a subtree (for visual bounds of envelopes, blends and repeats, and the
|
||||
/// reach of symbol art), type's character strokes included as type's own visual bounds measure them
|
||||
/// ([`crate::TextObject::char_stroke_outset`]): in an envelope they paint at their own weight
|
||||
/// around the bent glyphs, past its grid where the glyphs reach its edge (#6644). With
|
||||
/// `symbols`, the outsets in the art of the symbols its instances show count too
|
||||
/// ([`crate::symbols::ArtBox::reach`]), grown by [`crate::SymbolBoxes::brush_pad`]. In boxes made
|
||||
/// by [`crate::SymbolBoxes::without_art_strokes`], the outsets in the art do not count, and the pad
|
||||
/// does.
|
||||
@@ -449,6 +473,9 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxe
|
||||
for n in nodes {
|
||||
n.walk(&mut |c| {
|
||||
o = o.max(c.appearance.outset());
|
||||
if let NodeKind::Text(t) = &c.kind {
|
||||
o = o.max(t.char_stroke_outset());
|
||||
}
|
||||
if let (Some(s), NodeKind::SymbolInstance { symbol, .. }) = (symbols, &c.kind)
|
||||
&& let Some(art) = s.art(symbol)
|
||||
{
|
||||
|
||||
@@ -77,10 +77,13 @@ pub struct TextObject {
|
||||
/// them up round the inside.
|
||||
#[serde(default, rename = "pathSpacing", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub path_spacing: f64,
|
||||
/// Type › Dynamic Text (type on a path only): the layout scales the text by one factor so it
|
||||
/// fills the span between its start and end brackets, and fits it again after each edit. The
|
||||
/// stored sizes keep their proportions; the layout's `fit_scale` is the factor drawn. Off for new
|
||||
/// type and for documents from before it, and saved only when on.
|
||||
/// Type › Dynamic Text. On type on a path, the layout scales the text by one factor so it
|
||||
/// fills the span between its start and end brackets, and fits it again after each edit; the
|
||||
/// stored sizes keep their proportions, and the layout's `fit_scale` is the factor drawn. On
|
||||
/// area type it is Block: after each edit the engine sets each line as typed to the frame's
|
||||
/// width by its font size and writes the sizes into the characters, with the gaps of
|
||||
/// [`AreaOptions::word_gap`] and [`AreaOptions::line_gap`]. Point type does not use it. Off for
|
||||
/// new type and for documents from before it, and saved only when on.
|
||||
#[serde(default, rename = "dynamicText", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub dynamic_text: bool,
|
||||
/// Wrap objects above this area type, resolved by the engine after each edit (text space).
|
||||
|
||||
@@ -167,6 +167,15 @@ pub struct AreaOptions {
|
||||
/// How the frame and its text fit each other (Auto Size, Shrink Text to Fit).
|
||||
#[serde(skip_serializing_if = "crate::skip::is_default")]
|
||||
pub fit: AreaFit,
|
||||
/// Dynamic Text's Block ([`super::TextObject::dynamic_text`] on area type): the Desired word
|
||||
/// spacing the layout sets every paragraph with ([`super::TextObject::layout_justification`]), in
|
||||
/// percent of a word space (100, the default, is left out when saved).
|
||||
#[serde(skip_serializing_if = "AreaOptions::is_default_word_gap")]
|
||||
pub word_gap: f64,
|
||||
/// Dynamic Text's Block: the points added to each character's size to give its leading (0, the
|
||||
/// default, is left out when saved).
|
||||
#[serde(skip_serializing_if = "crate::skip::is_default")]
|
||||
pub line_gap: f64,
|
||||
}
|
||||
|
||||
impl Default for AreaOptions {
|
||||
@@ -182,6 +191,8 @@ impl Default for AreaOptions {
|
||||
vertical_align: VerticalAlign::Top,
|
||||
flow: TextFlow::Columns,
|
||||
fit: AreaFit::None,
|
||||
word_gap: Self::DEFAULT_WORD_GAP,
|
||||
line_gap: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +200,21 @@ impl Default for AreaOptions {
|
||||
impl AreaOptions {
|
||||
/// The most rows, and the most columns, area type can have.
|
||||
pub const MAX_ROWS_COLUMNS: usize = 100;
|
||||
/// Block's word gap range (percent of a word space), the Justification word spacing range.
|
||||
pub const WORD_GAP: (f64, f64) = (0.0, 1000.0);
|
||||
/// Block's word gap new type starts with: the font's own word space.
|
||||
pub const DEFAULT_WORD_GAP: f64 = 100.0;
|
||||
/// Block's line gap range in points.
|
||||
pub const LINE_GAP: (f64, f64) = (-1296.0, 1296.0);
|
||||
fn is_default_word_gap(v: &f64) -> bool {
|
||||
*v == Self::DEFAULT_WORD_GAP
|
||||
}
|
||||
/// Block's gaps kept within their ranges; a gap that is not a number gives its default.
|
||||
pub fn block_gaps(&self) -> (f64, f64) {
|
||||
let word = if self.word_gap.is_finite() { self.word_gap.clamp(Self::WORD_GAP.0, Self::WORD_GAP.1) } else { Self::DEFAULT_WORD_GAP };
|
||||
let line = if self.line_gap.is_finite() { self.line_gap.clamp(Self::LINE_GAP.0, Self::LINE_GAP.1) } else { 0.0 };
|
||||
(word, line)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::area::{AreaOptions, PathAlign, PathEffect};
|
||||
use super::char_style::CharStyle;
|
||||
use super::para_style::ParaStyle;
|
||||
use super::para_style::{JustificationOptions, ParaStyle};
|
||||
use super::runs::{RubySpan, TextKind, TextRun, TypeAntiAlias, move_anchors, reverse_from_end};
|
||||
use super::tabs_lists::{ListCounts, ParaList};
|
||||
use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2};
|
||||
@@ -170,6 +170,19 @@ impl TextObject {
|
||||
pub fn para_at(&self, i: usize) -> &ParaStyle {
|
||||
self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para)
|
||||
}
|
||||
/// The Justification… options the layout sets `para` with: its own, except that with Dynamic
|
||||
/// Text's Block on area type ([`Self::dynamic_text`]) the Desired word spacing is the word gap
|
||||
/// ([`AreaOptions::block_gaps`]), and Minimum and Maximum move out to it when it lies outside
|
||||
/// them. The paragraph keeps its own values, which apply again when Block is off.
|
||||
pub fn layout_justification(&self, para: &ParaStyle) -> JustificationOptions {
|
||||
let mut j = para.justification.clone();
|
||||
if self.dynamic_text && matches!(self.kind, TextKind::Area { .. }) {
|
||||
let gap = self.area.block_gaps().0;
|
||||
let [min, _, max] = j.word_spacing;
|
||||
j.word_spacing = [min.min(gap), gap, max.max(gap)];
|
||||
}
|
||||
j
|
||||
}
|
||||
/// Every paragraph's attributes, one per paragraph.
|
||||
pub fn paragraph_styles(&self) -> Vec<ParaStyle> {
|
||||
(0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect()
|
||||
|
||||
@@ -552,6 +552,21 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
||||
/// painting their pieces name are looked up in `doc`. `None` when it has neither a stroke to
|
||||
/// outline nor a geometry effect, so bending first changes nothing.
|
||||
pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
bake_appearance_as(doc, n, true)
|
||||
}
|
||||
|
||||
/// [`bake_appearance`] for the glyph outlines of type in an envelope with Distort Appearance:
|
||||
/// geometry effects are applied as it applies them, and fills, gradients and patterns are left as
|
||||
/// it leaves them, but every stroke stays a stroke on its piece instead of becoming its filled
|
||||
/// outline. The envelope then bends the piece's path, and the stroke paints along the bent outline
|
||||
/// at its own weight, as `.ai` files draw the strokes of type in an envelope (#6644). `None` when
|
||||
/// it has no geometry effect, so bending first changes nothing.
|
||||
pub fn bake_type_appearance(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
bake_appearance_as(doc, n, false)
|
||||
}
|
||||
|
||||
/// [`bake_appearance`], or with `outline_strokes` false [`bake_type_appearance`].
|
||||
fn bake_appearance_as(doc: Option<&Document>, n: &Node, outline_strokes: bool) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve_art_in(doc, n).or_else(|| crate::solid_art_in(doc, n)) {
|
||||
return Some(m);
|
||||
}
|
||||
@@ -564,10 +579,10 @@ pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
if crate::has_transform_art(n)
|
||||
&& let Some(mut m) = reshape(doc, n, None)
|
||||
{
|
||||
bake_appearance_pieces(doc, &mut m);
|
||||
bake_appearance_pieces(doc, &mut m, outline_strokes);
|
||||
return Some(m);
|
||||
}
|
||||
let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none());
|
||||
let outlined = |i: &AppearanceItem| outline_strokes && paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none());
|
||||
let geometric = has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i)));
|
||||
if !geometric && !n.appearance.items.iter().any(outlined) {
|
||||
return None;
|
||||
@@ -585,7 +600,7 @@ pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
let (ig, mut it) = apply_item(item, carried, &g, &ctx);
|
||||
clear_item_effects(&mut it);
|
||||
pieces.push(match &it {
|
||||
AppearanceItem::Stroke(st) if st.brush.is_none() => {
|
||||
AppearanceItem::Stroke(st) if outline_strokes && st.brush.is_none() => {
|
||||
let outline = crate::stroke::outline_region(&ig, rule, st);
|
||||
match st.path_gradient() {
|
||||
// A gradient along or across the stroke: its gradient meshes clipped to the outline.
|
||||
@@ -618,16 +633,16 @@ pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// [`bake_appearance`] every path and compound path in `n` that it changes, in place.
|
||||
fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node) {
|
||||
/// [`bake_appearance_as`] every path and compound path in `n` that it changes, in place.
|
||||
fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node, outline_strokes: bool) {
|
||||
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
if let Some(m) = bake_appearance(doc, n) {
|
||||
if let Some(m) = bake_appearance_as(doc, n, outline_strokes) {
|
||||
*n = m;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
bake_appearance_pieces(doc, Arc::make_mut(c));
|
||||
bake_appearance_pieces(doc, Arc::make_mut(c), outline_strokes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -575,6 +575,27 @@ fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline
|
||||
assert_eq!(ch[1].opacity, 0.5);
|
||||
}
|
||||
|
||||
/// Distort Appearance on the pieces of type's glyph outlines (#6644): a stroke's geometry effect
|
||||
/// still bakes into its piece, but the stroke stays a stroke at its own weight; a piece with no
|
||||
/// geometry effect is left as it is, its stroke on it (Distort Appearance on a path outlines it).
|
||||
#[test]
|
||||
fn type_pieces_bake_their_geometry_effects_and_keep_their_strokes() {
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
assert!(bake_type_appearance(None, &n).is_none(), "nothing to bake");
|
||||
assert!(bake_appearance(None, &n).is_some(), "a path's stroke is outlined");
|
||||
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
|
||||
s.width = 4.0;
|
||||
s.effects.push(fx("path.offsetPath", json!({"offset": 5.0})));
|
||||
}
|
||||
let m = bake_type_appearance(None, &n).unwrap();
|
||||
let ch = m.children().unwrap();
|
||||
assert_eq!(ch.len(), 2);
|
||||
let Some(AppearanceItem::Stroke(st)) = ch[1].appearance.items.first() else { panic!("still a stroke: {:?}", ch[1].appearance) };
|
||||
assert!(st.width == 4.0 && st.effects.is_empty(), "{st:?}");
|
||||
assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5, "the stroke's offset is baked into its path");
|
||||
assert!((ch[0].geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6, "the fill keeps the square");
|
||||
}
|
||||
|
||||
/// A 100 pt square at the origin painted by `fill` and `stroke` (`width` pt), with the Transform
|
||||
/// effect `params`.
|
||||
fn transformed(fill: Paint, stroke: Paint, width: f64, params: serde_json::Value) -> Node {
|
||||
|
||||
@@ -719,6 +719,37 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)],
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Whether a sublayer among the members of `n`, or among its groups' members, holds a ruler guide.
|
||||
fn sublayer_guides(n: &Node) -> bool {
|
||||
n.children().is_some_and(|ch| ch.iter().any(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c))))
|
||||
}
|
||||
|
||||
/// Move the ruler guides of the sublayers among the members of `n`, and among its groups'
|
||||
/// members, to `out`, in order. A guide taken from a hidden or locked sublayer, or from one inside
|
||||
/// such a sublayer or group, is hidden or locked itself. Only containers holding one are copied.
|
||||
fn take_sublayer_guides(n: &mut Node, out: &mut Vec<Arc<Node>>, hidden: bool, locked: bool) {
|
||||
let Some(ch) = n.children_mut() else { return };
|
||||
for c in ch.iter_mut().filter(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c))) {
|
||||
let c = Arc::make_mut(c);
|
||||
let (hidden, locked) = (hidden || !c.visible, locked || c.locked);
|
||||
if c.is_layer()
|
||||
&& let Some(lc) = c.children_mut()
|
||||
{
|
||||
let (taken, art): (Vec<_>, Vec<_>) = std::mem::take(lc).into_iter().partition(|g| g.is_ruler_guide());
|
||||
*lc = art;
|
||||
out.extend(taken.into_iter().map(|mut g| {
|
||||
if hidden || locked {
|
||||
let g = Arc::make_mut(&mut g);
|
||||
g.visible &= !hidden;
|
||||
g.locked |= locked;
|
||||
}
|
||||
g
|
||||
}));
|
||||
}
|
||||
take_sublayer_guides(c, out, hidden, locked);
|
||||
}
|
||||
}
|
||||
|
||||
/// `n`, a group or layer, as plain art: its Pathfinder effects evaluated, its geometry effects
|
||||
/// reshaping all its members as one piece (the whole art's bounds as their reference box), and its
|
||||
/// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below
|
||||
@@ -739,7 +770,12 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
}
|
||||
let mut m = n.clone();
|
||||
// A layer's ruler guides take no part: they go back above the evaluated art at the end.
|
||||
let guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default();
|
||||
let mut guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default();
|
||||
// So do its sublayers' guides, which would otherwise go with the art the effects replace or
|
||||
// copy: they stay on this layer, hidden and locked as their sublayers were.
|
||||
if m.is_layer() && (pathfinder || !fx.is_empty()) {
|
||||
take_sublayer_guides(&mut m, &mut guides, false, false);
|
||||
}
|
||||
if pathfinder {
|
||||
let hook = |c: &Node| crate::outline_object(doc, c);
|
||||
let result = pathfinder_children(doc, &m, Some(&hook)).unwrap_or_default();
|
||||
|
||||
@@ -862,3 +862,70 @@ fn a_layers_ruler_guides_stay_last_through_its_effects() {
|
||||
painted.appearance.items = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).items;
|
||||
assert_eq!(tail(&evaluate_container(None, &painted).unwrap()), guides);
|
||||
}
|
||||
|
||||
/// The ruler guides of a sublayer in a layer with a Pathfinder effect, or a Transform effect that
|
||||
/// copies its art, stay on a layer of the evaluated art, once, hidden and locked as the sublayer is.
|
||||
#[test]
|
||||
fn a_sublayers_ruler_guides_survive_its_layers_effects() {
|
||||
use vectorcraft_doc::LayerColor;
|
||||
let layer = |effect: &str, params: serde_json::Value, shown: bool| {
|
||||
let mut sub = Node::layer(NodeId(11), "Sub", LayerColor::Preset(1));
|
||||
(sub.visible, sub.locked) = (shown, !shown);
|
||||
if let Some(ch) = sub.children_mut() {
|
||||
*ch = vec![
|
||||
sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)),
|
||||
Arc::new(Node::new(NodeId(20), NodeKind::RulerGuide { vertical: true, pos: 3.0, artboard: None })),
|
||||
];
|
||||
}
|
||||
let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0));
|
||||
if let Some(ch) = l.children_mut() {
|
||||
*ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(sub)];
|
||||
}
|
||||
l.appearance.effects.push(Effect { id: effect.into(), params, visible: true });
|
||||
l
|
||||
};
|
||||
// The guides found where ruler guides are looked for: directly in a layer or sublayer.
|
||||
fn listed(n: &Node, out: &mut Vec<(NodeId, bool, bool)>) {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if c.is_ruler_guide() {
|
||||
out.push((c.id, c.visible, c.locked));
|
||||
} else if c.is_layer() {
|
||||
listed(c, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] {
|
||||
for shown in [true, false] {
|
||||
let art = evaluate_container(None, &layer(effect, params.clone(), shown)).unwrap();
|
||||
let mut found = vec![];
|
||||
listed(&art, &mut found);
|
||||
assert_eq!(found, [(NodeId(20), shown, !shown)], "{effect}: the sublayer's guide, once, on a layer");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A sublayer's ruler guide inside a hidden or locked group stays hidden or locked when its layer's
|
||||
/// Pathfinder effect moves it onto the layer, as the group made it.
|
||||
#[test]
|
||||
fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked() {
|
||||
use vectorcraft_doc::LayerColor;
|
||||
for (hidden, locked) in [(true, false), (false, true), (false, false)] {
|
||||
let mut sub = Node::layer(NodeId(11), "Sub", LayerColor::Preset(1));
|
||||
if let Some(ch) = sub.children_mut() {
|
||||
*ch = vec![
|
||||
sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)),
|
||||
Arc::new(Node::new(NodeId(20), NodeKind::RulerGuide { vertical: true, pos: 3.0, artboard: None })),
|
||||
];
|
||||
}
|
||||
let mut group = Node::group(NodeId(12), vec![Arc::new(sub)]);
|
||||
(group.visible, group.locked) = (!hidden, locked);
|
||||
let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0));
|
||||
if let Some(ch) = l.children_mut() {
|
||||
*ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(group)];
|
||||
}
|
||||
l.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true });
|
||||
let art = evaluate_container(None, &l).unwrap();
|
||||
let guides: Vec<_> = art.children().into_iter().flatten().filter(|c| c.is_ruler_guide()).map(|c| (c.id, c.visible, c.locked)).collect();
|
||||
assert_eq!(guides, [(NodeId(20), !hidden, locked)], "hidden {hidden}, locked {locked}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ pub use adjust::{
|
||||
ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment,
|
||||
is_channel,
|
||||
};
|
||||
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
pub use group::{
|
||||
OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder,
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//! every exporter, with what the pure evaluation in [`vectorcraft_doc::live`] can't do itself
|
||||
//! ([`Hooks`]): type inside an envelope distorts as its glyph outlines, run by run
|
||||
//! ([`outline_text`]); symbol instances as their symbol's art; with Distort Appearance strokes and
|
||||
//! geometry effects bend with the art ([`bake_appearance`]); with Distort Pattern Fills the pattern
|
||||
//! tiles do.
|
||||
//! geometry effects bend with the art ([`bake_appearance`]), except type's strokes, which paint
|
||||
//! along its bent outlines at their own weight ([`bake_type_appearance`], #6644); with Distort
|
||||
//! Pattern Fills the pattern tiles do.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -11,7 +12,7 @@ use vectorcraft_doc::live::{self, Hooks, Outliner};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::{bake_appearance, outline_text};
|
||||
use crate::{bake_appearance, bake_type_appearance, outline_text};
|
||||
|
||||
/// The text outliner live evaluation uses: type as its glyph outlines, one path per run in the
|
||||
/// run's paint ([`outline_text`]).
|
||||
@@ -44,7 +45,8 @@ fn with_hooks<R>(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
|
||||
let outline = |n: &Node| outline_object(doc, n);
|
||||
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
|
||||
let appearance = |n: &Node| bake_appearance(doc, n);
|
||||
f(Hooks { outline: Some(&outline), appearance: Some(&appearance), pattern: Some(&tiles), symbols: doc })
|
||||
let type_appearance = |n: &Node| bake_type_appearance(doc, n);
|
||||
f(Hooks { outline: Some(&outline), appearance: Some(&appearance), type_appearance: Some(&type_appearance), pattern: Some(&tiles), symbols: doc })
|
||||
}
|
||||
|
||||
/// One level of evaluation of live object `n` ([`live::expand_live_hooks`]).
|
||||
|
||||
@@ -67,6 +67,11 @@ log = { workspace = true }
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { workspace = true, features = ["std", "process"] }
|
||||
|
||||
# Whether a recovery area's lock file is still the one that was opened (`FolderStore::still_named`).
|
||||
# Already in Cargo.lock (walkdir), so this adds no new crate.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
same-file = "1.0.6"
|
||||
|
||||
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"], optional = true }
|
||||
|
||||
@@ -113,7 +113,8 @@ pub(super) fn batch_trace_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for path in &files {
|
||||
let opened_before = s.documents().len();
|
||||
let one = |s: &mut Session| -> Result<String> {
|
||||
s.execute("document.open", &json!({ "path": path }))?;
|
||||
// A PSD or PSB is traced as its merged image, not as the first of its layers.
|
||||
s.execute("document.open", &json!({ "path": path, "layers": false }))?;
|
||||
s.execute("select.all", &json!({}))?;
|
||||
s.execute(trace, &json!({ "preset": preset }))?;
|
||||
let stem = fileio::file_stem(path);
|
||||
|
||||
@@ -333,6 +333,34 @@ fn batch_trace_folder_traces_and_saves_each_image() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A layered PSD is traced as its merged image: a white layer under a black square gives the
|
||||
/// square's shape, not a trace of the white layer alone.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn batch_trace_folder_traces_a_layered_psd_as_its_merged_image() {
|
||||
const C: &str = "addon.document.batchTraceFolder";
|
||||
let dir = folder("batch-trace-psd");
|
||||
let (src, out) = (dir.join("in"), dir.join("out"));
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 24, "height": 24})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 24, "height": 24})).unwrap();
|
||||
s.execute("layer.new", &json!({"name": "Square"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 6, "y": 6, "width": 12, "height": 12})).unwrap();
|
||||
let psd = s.execute("document.export", &json!({"format": "psd"})).unwrap();
|
||||
std::fs::write(src.join("two.psd"), vectorcraft_format::base64_decode(psd["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let r =
|
||||
s.execute(C, &json!({"folder": src, "destinationFolder": out, "format": "svg", "preset": "Black and White Logo", "expand": true})).unwrap();
|
||||
assert_eq!(r["traced"].as_array().unwrap().len(), 1, "{r}");
|
||||
let svg = std::fs::read_to_string(out.join("two.svg")).unwrap();
|
||||
assert!(svg.contains("fill=\"#000000\""), "the black square is traced: {svg}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Batch Trace Folder rejects optional values with the wrong type before opening images.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
|
||||
@@ -285,3 +285,54 @@ fn the_tool_draws_a_path_one_step_per_anchor_and_closes_it() {
|
||||
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
|
||||
assert_eq!(steps(&s), n + 8);
|
||||
}
|
||||
|
||||
/// #6634: the tool's closing drag on the first anchor sets that anchor's incoming handle to the
|
||||
/// mirror of the pointer and does not give it symmetric handles: a corner keeps its outgoing
|
||||
/// handle, a smooth anchor turns it to stay in line at its own length, and Alt pressed mid-drag
|
||||
/// keeps it where the drag had turned it.
|
||||
#[test]
|
||||
fn the_tools_closing_drag_does_not_make_the_first_anchor_symmetric() {
|
||||
// An open path whose first anchor, at (50, 50), has the handles `first`, drawn on from its last
|
||||
// anchor; the closing gesture is then up to the caller.
|
||||
let draw = |first: Value| {
|
||||
let mut s = session();
|
||||
let mut a = json!({"x": 50, "y": 50});
|
||||
a.as_object_mut().unwrap().extend(first.as_object().unwrap().clone());
|
||||
let anchors = json!([a, {"x": 150, "y": 50}, {"x": 150, "y": 150}]);
|
||||
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
|
||||
s.select_tool("smartPen", view()).unwrap();
|
||||
gesture(&mut s, &[(150.0, 150.0)], Mods::default());
|
||||
(s, id)
|
||||
};
|
||||
let first = |s: &Session, id: NodeId| {
|
||||
let sp = &path(s, id).subpaths[0];
|
||||
assert!(sp.closed, "{sp:?}");
|
||||
sp.anchors[0]
|
||||
};
|
||||
let near = |a: Point, b: Point| a.distance(b) < 1e-6;
|
||||
|
||||
// A corner: the first segment keeps its shape.
|
||||
let (mut s, id) = draw(json!({"out": [80, 30]}));
|
||||
gesture(&mut s, &[(50.0, 50.0), (50.0, 100.0)], Mods::default());
|
||||
let a = first(&s, id);
|
||||
assert_eq!((a.h_in, a.h_out, a.kind), (Point::new(50.0, 0.0), Point::new(80.0, 30.0), AnchorKind::Corner), "{a:?}");
|
||||
|
||||
// A smooth anchor: the outgoing handle turns opposite the incoming one, keeping its length 30.
|
||||
let (mut s, id) = draw(json!({"in": [20, 50], "out": [80, 50]}));
|
||||
assert_eq!(path(&s, id).subpaths[0].anchors[0].kind, AnchorKind::Smooth);
|
||||
gesture(&mut s, &[(50.0, 50.0), (50.0, 100.0)], Mods::default());
|
||||
let a = first(&s, id);
|
||||
assert_eq!((a.h_in, a.kind), (Point::new(50.0, 0.0), AnchorKind::Smooth), "{a:?}");
|
||||
assert!(near(a.h_out, Point::new(50.0, 80.0)), "{a:?}");
|
||||
|
||||
// Alt pressed mid-drag: a corner, its outgoing handle where the drag had turned it.
|
||||
let (mut s, id) = draw(json!({"in": [20, 50], "out": [80, 50]}));
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), view()).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 50.0, 100.0), view()).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 80.0, 100.0).with_mods(alt), view()).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 100.0).with_mods(alt), view()).unwrap();
|
||||
let a = first(&s, id);
|
||||
assert_eq!((a.h_in, a.kind), (Point::new(20.0, 0.0), AnchorKind::Corner), "{a:?}");
|
||||
assert!(near(a.h_out, Point::new(50.0, 80.0)), "{a:?}");
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use super::clipboard::Clipboard;
|
||||
use super::clipboard::resources::SwatchChoices;
|
||||
use super::patterncmds::add_pattern;
|
||||
use super::swatch::str_list;
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem};
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, concealed_library_id, concealed_library_ids, shown_id, shown_library_id, stem};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
|
||||
|
||||
/// The extension of saved brush libraries: native documents.
|
||||
@@ -462,6 +462,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<BrushLibrary>
|
||||
if let Some(found) = s.brush_libraries.get(key) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(found) = concealed_library_id(s, key).and_then(|id| s.brush_libraries.get(&id)) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(b) = BUILTIN.iter().find(|b| b.id == key) {
|
||||
return Some((builtin_info(b), builtin_library(key)?));
|
||||
}
|
||||
@@ -476,20 +479,22 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.brush_libraries.rescan();
|
||||
let concealed = concealed_library_ids(s, []);
|
||||
let libs: Vec<Value> = libraries(s)
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len());
|
||||
json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.brush_libraries.path(&l.id)})
|
||||
let (id, path) = (shown_id(&concealed, &l.id), s.brush_libraries.shown_path(&l.id));
|
||||
json!({"id": id, "name": l.name, "category": l.category, "count": count, "path": path})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.brush_libraries.user_dir() }))
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.brush_libraries.shown_user_dir() }))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (info, lib) = library_param(s, p, "brush.library")?;
|
||||
let brushes: Vec<Value> = lib.brushes.iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect();
|
||||
Ok(json!({"id": info.id, "name": info.name, "category": info.category, "brushes": brushes}))
|
||||
Ok(json!({"id": shown_library_id(s, &info.id), "name": info.name, "category": info.category, "brushes": brushes}))
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -545,7 +550,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (added, existing): (Vec<&String>, Vec<&String>) = doc_names.iter().partition(|n| !before.contains(*n));
|
||||
let applied: Vec<u64> = paths.iter().map(|id| id.0).collect();
|
||||
Ok(json!({"library": info.id, "names": doc_names, "added": added, "existing": existing, "applied": applied}))
|
||||
Ok(json!({"library": shown_library_id(s, &info.id), "names": doc_names, "added": added, "existing": existing, "applied": applied}))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -571,5 +576,5 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
BrushLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e))
|
||||
})?;
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
|
||||
Ok(json!({"library": shown_library_id(s, &info.id), "name": info.name, "count": lib.len()}))
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ pub(crate) use symbols::{
|
||||
};
|
||||
use symbols::{
|
||||
symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_instance_reset_transform, symbol_list, symbol_new, symbol_options,
|
||||
symbol_place, symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update,
|
||||
symbol_place, symbol_replace, symbol_select_instances, symbol_set_current, symbol_sort, symbol_spray, symbol_unused, symbol_update,
|
||||
};
|
||||
|
||||
const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES;
|
||||
@@ -312,6 +312,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
symbol_unused
|
||||
),
|
||||
cmd!(
|
||||
"symbol.sortByName",
|
||||
"Sort by Name",
|
||||
["Window", "Symbols"],
|
||||
None,
|
||||
"{} sort the document's symbols by name, ignoring case (symbols whose names differ only in case keep their order), as one undo step; the current symbol stays current; no undo step is added when they are already in order",
|
||||
has_doc,
|
||||
symbol_sort
|
||||
),
|
||||
cmd!(query "symbol.selectInstances", "Select All Instances", [], None, "{name} select every instance of a symbol → {count}", has_doc, symbol_select_instances),
|
||||
cmd!(
|
||||
"symbol.spray",
|
||||
|
||||
@@ -140,6 +140,27 @@ pub(super) fn symbol_unused(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "names": names }))
|
||||
}
|
||||
|
||||
/// `symbol.sortByName`: the symbols in name order, ignoring case; a stable sort, so names that
|
||||
/// differ only in case keep their order. The current symbol is stored by name first, so a document
|
||||
/// whose current symbol is its first one keeps that symbol current. Nothing is changed, and no undo
|
||||
/// step is added, when the symbols are already in order.
|
||||
pub(super) fn symbol_sort(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let key = |sym: &Symbol| sym.name.to_lowercase();
|
||||
let d = &s.doc()?.doc;
|
||||
if d.symbols.is_sorted_by_key(key) {
|
||||
return ok();
|
||||
}
|
||||
let current = current_symbol(d).or_else(|| symbol_names(d).into_iter().next());
|
||||
s.edit("Sort by Name", |d, _| {
|
||||
if let Some(name) = current {
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
|
||||
}
|
||||
d.symbols.sort_by_cached_key(key);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn symbol_names(d: &Document) -> Vec<String> {
|
||||
d.symbols.iter().map(|s| s.name.clone()).collect()
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, bool_or, check_numbers, cmd, color_value, f64_or, f64_req, has_selection, ids_param,
|
||||
str_param, unique_name,
|
||||
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, f64_req, has_selection, ids_param, switch_or,
|
||||
text_param, unique_name,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -166,13 +166,15 @@ pub(crate) struct Scope {
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
pub(crate) fn of(p: &Value) -> Self {
|
||||
Self {
|
||||
fill: bool_or(p, "fill", true),
|
||||
stroke: bool_or(p, "stroke", true),
|
||||
images: bool_or(p, "includeImages", true),
|
||||
patterns: bool_or(p, "includePatterns", true),
|
||||
}
|
||||
/// The scope `p` gives; each switch left out (or null) is on. A switch that is not true or false,
|
||||
/// such as `"false"`, is an error that names `cmd`.
|
||||
pub(crate) fn of(p: &Value, cmd: &str) -> Result<Self> {
|
||||
Ok(Self {
|
||||
fill: switch_or(p, "fill", true, cmd)?,
|
||||
stroke: switch_or(p, "stroke", true, cmd)?,
|
||||
images: switch_or(p, "includeImages", true, cmd)?,
|
||||
patterns: switch_or(p, "includePatterns", true, cmd)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,18 +416,18 @@ impl ColorVisitor for Visit<'_, '_> {
|
||||
}
|
||||
|
||||
pub(crate) fn recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
run_pass(s, p, cmd, label, Recolor::new(Scope::of(p), f))
|
||||
run_pass(s, p, cmd, label, Recolor::new(Scope::of(p, cmd)?, f))
|
||||
}
|
||||
|
||||
/// [`recolor`] where `f` gives gray colors, so embedded images are stored with one gray channel.
|
||||
fn recolor_gray(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
run_pass(s, p, cmd, label, Recolor { gray_images: true, ..Recolor::new(Scope::of(p), f) })
|
||||
run_pass(s, p, cmd, label, Recolor { gray_images: true, ..Recolor::new(Scope::of(p, cmd)?, f) })
|
||||
}
|
||||
|
||||
/// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch
|
||||
/// link and tint.
|
||||
pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result<Value> {
|
||||
run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p), f, links))
|
||||
run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p, cmd)?, f, links))
|
||||
}
|
||||
|
||||
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`;
|
||||
@@ -548,8 +550,9 @@ fn saturate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.colors.adjustBalance";
|
||||
check_numbers(p, &["r", "g", "b", "c", "m", "y", "k", "gray", "tint"], C)?;
|
||||
let convert = switch_or(p, "convert", false, C)?;
|
||||
let given = |keys: &[&str]| keys.iter().any(|k| p.get(*k).is_some());
|
||||
let mode = match str_param(p, "mode") {
|
||||
let mode = match text_param(p, "mode", C)? {
|
||||
Some(m) => m.to_ascii_lowercase(),
|
||||
None if given(&["c", "m", "y", "k"]) => "cmyk".into(),
|
||||
None if given(&["gray"]) => "gray".into(),
|
||||
@@ -592,11 +595,10 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(*tint, *c) = (t, base.tinted(t));
|
||||
true
|
||||
};
|
||||
return run_pass(s, p, C, "Adjust Colors", Recolor::links(Scope::of(p), &shift));
|
||||
return run_pass(s, p, C, "Adjust Colors", Recolor::links(Scope::of(p, C)?, &shift));
|
||||
}
|
||||
other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))),
|
||||
};
|
||||
let convert = bool_or(p, "convert", false);
|
||||
let f = move |c| {
|
||||
let out = adjust(c);
|
||||
if convert { out } else { keep_model(c, out) }
|
||||
@@ -703,7 +705,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
|
||||
fn harmony(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "color.harmony";
|
||||
let base = p.get("color").and_then(color_value).ok_or_else(|| bad(C, "missing or invalid `color`"))?;
|
||||
let rule = str_param(p, "rule").ok_or_else(|| bad(C, "missing `rule`"))?;
|
||||
let rule = text_param(p, "rule", C)?.ok_or_else(|| bad(C, "missing `rule`"))?;
|
||||
let rule = Harmony::parse(rule).ok_or_else(|| {
|
||||
let ids: Vec<String> = Harmony::ALL.iter().map(|h| h.id()).collect();
|
||||
bad(C, format!("unknown rule `{rule}` ({})", ids.join("|")))
|
||||
@@ -713,7 +715,7 @@ fn harmony(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(n) = p.get("steps").and_then(Value::as_u64) {
|
||||
opts.steps = n.min(u32::MAX as u64) as u32;
|
||||
}
|
||||
if let Some(v) = str_param(p, "variation") {
|
||||
if let Some(v) = text_param(p, "variation", C)? {
|
||||
opts.variation = Variation::parse(v).ok_or_else(|| bad(C, format!("unknown variation `{v}` (tintsShades|warmCool|vividMuted)")))?;
|
||||
}
|
||||
if let Some(a) = p.get("amount").and_then(Value::as_f64) {
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -752,7 +752,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let w = f64_or(a, "width", 200.0);
|
||||
let h = f64_or(a, "height", 100.0);
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) };
|
||||
t.area.fit = new_area_fit(s, a, "text.create")?;
|
||||
new_area_fit(s, a, "text.create", &mut t)?;
|
||||
}
|
||||
t.system_layout = s.doc()?.doc.setup.system_layout;
|
||||
if bool_or(p, "placeholder", false) {
|
||||
@@ -827,11 +827,16 @@ pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The fit new area type gets: `fit`/`fitMinPercent` from `p` (as `text.areaOptions` takes
|
||||
/// them), else Auto Size when the "Auto Size New Area Type" preference is on.
|
||||
pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result<vectorcraft_doc::AreaFit> {
|
||||
/// The fit new area type `t` gets: `fit`/`fitMinPercent` from `p` (as `text.areaOptions` takes
|
||||
/// them, `block` turning Dynamic Text on), else Auto Size when the "Auto Size New Area Type"
|
||||
/// preference is on; and Block's `wordGap` and `lineGap`.
|
||||
pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str, t: &mut TextObject) -> Result<()> {
|
||||
let default = if s.prefs.auto_size_area_type { vectorcraft_doc::AreaFit::AutoHeight } else { vectorcraft_doc::AreaFit::None };
|
||||
Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default))
|
||||
let (word_gap, line_gap) = super::typecmd::block_gaps(p, c)?;
|
||||
(t.area.fit, t.dynamic_text) = super::typecmd::fit_param(p, default, false, c)?.unwrap_or((default, false));
|
||||
t.area.word_gap = word_gap.unwrap_or(t.area.word_gap);
|
||||
t.area.line_gap = line_gap.unwrap_or(t.area.line_gap);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Info",
|
||||
["Window", "Document Info"],
|
||||
None,
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}",
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -42,7 +42,7 @@ pub const CATEGORIES: [(&str, &str); 13] = [
|
||||
("brushes", "Brushes"),
|
||||
("spotColors", "Spot Colors"),
|
||||
("patterns", "Pattern Objects"),
|
||||
("gradients", "Gradient Swatches"),
|
||||
("gradients", "Gradient Objects"),
|
||||
("symbols", "Symbols"),
|
||||
("fonts", "Fonts"),
|
||||
("fontDetails", "Font Details"),
|
||||
@@ -131,7 +131,8 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S
|
||||
|
||||
/// The Document Info categories of `d` (only `category` when given) from `info` (what
|
||||
/// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the
|
||||
/// rows of its objects filled or stroked with a pattern.
|
||||
/// rows of its objects filled or stroked with a pattern and with a gradient.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sections(
|
||||
d: &Document,
|
||||
info: &Value,
|
||||
@@ -139,6 +140,7 @@ fn sections(
|
||||
images: &[ImageRow],
|
||||
repeats: &[(String, String)],
|
||||
pattern_objects: &[(String, String)],
|
||||
gradient_objects: &[(String, String)],
|
||||
category: Option<&str>,
|
||||
) -> Vec<Section> {
|
||||
let names = |k: &str| -> Vec<(String, String)> {
|
||||
@@ -183,7 +185,7 @@ fn sections(
|
||||
"brushes" => names("brushNames"),
|
||||
"spotColors" => names("spotColors"),
|
||||
"patterns" => pattern_objects.to_vec(),
|
||||
"gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(),
|
||||
"gradients" => gradient_objects.to_vec(),
|
||||
"symbols" => names("symbols"),
|
||||
"fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(),
|
||||
"fontDetails" => {
|
||||
@@ -254,19 +256,37 @@ pub fn report(s: &mut Session, selection_only: bool) -> Result<String> {
|
||||
Ok(v["text"].as_str().unwrap_or_default().to_string())
|
||||
}
|
||||
|
||||
/// The type object `n` holds and the runs its layout draws, when it uses a pattern: the runs with a
|
||||
/// glyph outline (list markers and ruby included) or an underline or strikethrough bar. Spaces and
|
||||
/// line breaks draw no outline. A type object's characters carry their own fill and stroke, and the
|
||||
/// type object's own fills and strokes paint on its characters, so only those runs paint. The type
|
||||
/// is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the renderer lays it out.
|
||||
/// The pattern paint `p` uses: the pattern's name.
|
||||
fn pattern_label(p: &Paint) -> Option<String> {
|
||||
match p {
|
||||
Paint::Pattern { pattern, .. } => Some(pattern.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The gradient paint `p` uses: the name of the gradient swatch it is linked to, else its kind
|
||||
/// ("Linear gradient", "Radial gradient" or "Freeform gradient").
|
||||
fn gradient_label(p: &Paint) -> Option<String> {
|
||||
match p {
|
||||
Paint::Gradient(g) => Some(g.swatch.clone().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("{} gradient", g.gradient.kind.label()))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The type object `n` holds and the runs its layout draws, when it uses a pattern or a gradient:
|
||||
/// the runs with a glyph outline (list markers and ruby included) or an underline or strikethrough
|
||||
/// bar. Spaces and line breaks draw no outline. A type object's characters carry their own fill and
|
||||
/// stroke, and the type object's own fills and strokes paint on its characters, so only those runs
|
||||
/// paint. The type is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the
|
||||
/// renderer lays it out.
|
||||
fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet<usize>)> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let is_pattern = |p: &Paint| matches!(p, Paint::Pattern { .. });
|
||||
let is_listed = |p: &Paint| matches!(p, Paint::Pattern { .. } | Paint::Gradient(_));
|
||||
let own = n.appearance.items.iter().any(|i| match i {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => is_pattern(&f.paint),
|
||||
vectorcraft_doc::AppearanceItem::Stroke(s) => is_pattern(&s.paint),
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => is_listed(&f.paint),
|
||||
vectorcraft_doc::AppearanceItem::Stroke(s) => is_listed(&s.paint),
|
||||
});
|
||||
if !own && !t.runs.iter().any(|r| is_pattern(&r.style.fill) || is_pattern(&r.style.stroke)) {
|
||||
if !own && !t.runs.iter().any(|r| is_listed(&r.style.fill) || is_listed(&r.style.stroke)) {
|
||||
return None;
|
||||
}
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
@@ -277,42 +297,38 @@ fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::T
|
||||
Some((t, painted))
|
||||
}
|
||||
|
||||
/// The patterns `n`'s own fills and strokes paint, each once. A hidden fill or stroke and a stroke
|
||||
/// with no width paint nothing, and neither does any fill or stroke of a guide (drawn in the guide
|
||||
/// color), of a compound path's member (`members`: the compound path's own fills and strokes paint
|
||||
/// it) or of type whose layout draws nothing (`painted_runs`). An image's fills and strokes paint
|
||||
/// only where an effect gives them a shape.
|
||||
fn own_patterns<'a>(
|
||||
/// The patterns or gradients (`label`: [`pattern_label`] or [`gradient_label`]) that `n`'s own
|
||||
/// fills and strokes paint, each once. A hidden fill or stroke and a stroke with no width paint
|
||||
/// nothing, and neither does any fill or stroke of a guide (drawn in the guide color), of a compound
|
||||
/// path's member (`members`: the compound path's own fills and strokes paint it) or of type whose
|
||||
/// layout draws nothing (`painted_runs`). An image's fills and strokes paint only where an effect
|
||||
/// gives them a shape.
|
||||
fn own_paints(
|
||||
d: &Document,
|
||||
n: &'a Node,
|
||||
n: &Node,
|
||||
members: &HashSet<*const Node>,
|
||||
painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet<usize>)>,
|
||||
) -> Vec<&'a str> {
|
||||
label: fn(&Paint) -> Option<String>,
|
||||
) -> Vec<String> {
|
||||
if matches!(n.kind, NodeKind::Path { guide: true, .. })
|
||||
|| members.contains(&std::ptr::from_ref(n))
|
||||
|| painted_runs.is_some_and(|(_, r)| r.is_empty())
|
||||
|| !n.appearance.items.iter().any(|i| label(i.paint()).is_some())
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
// The patterns an image's shaped fills and strokes paint (their effects can transform the
|
||||
// pattern, so they are matched by name).
|
||||
let shaped: Option<Vec<String>> = matches!(n.kind, NodeKind::Image(_)).then(|| {
|
||||
vectorcraft_render::effects::image_paint(Some(d), n)
|
||||
.into_iter()
|
||||
.filter_map(|(_, _, item)| match item.paint() {
|
||||
Paint::Pattern { pattern, .. } => Some(pattern.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let mut out: Vec<&str> = vec![];
|
||||
// The patterns or gradients an image's shaped fills and strokes paint (their effects can
|
||||
// transform the paint, so they are matched by label).
|
||||
let shaped: Option<Vec<String>> = matches!(n.kind, NodeKind::Image(_))
|
||||
.then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().filter_map(|(_, _, item)| label(item.paint())).collect());
|
||||
let mut out: Vec<String> = vec![];
|
||||
for item in &n.appearance.items {
|
||||
let Paint::Pattern { pattern, .. } = item.paint() else { continue };
|
||||
let Some(name) = label(item.paint()) else { continue };
|
||||
let paints = item.visible()
|
||||
&& !matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0)
|
||||
&& shaped.as_ref().is_none_or(|s| s.contains(pattern));
|
||||
if paints && !out.contains(&pattern.as_str()) {
|
||||
out.push(pattern);
|
||||
&& shaped.as_ref().is_none_or(|s| s.contains(&name));
|
||||
if paints && !out.contains(&name) {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -354,6 +370,11 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// `pattern_objects` and `pattern_rows` hold the `patternObjects` entries and the rows of the objects filled or stroked with a pattern, in walk order.
|
||||
let mut pattern_objects: Vec<Value> = vec![];
|
||||
let mut pattern_rows: Vec<(String, String)> = vec![];
|
||||
// `gradient_objects`, `gradient_rows` and `gradients` hold the `gradientObjects` entries, the rows of the objects filled or stroked
|
||||
// with a gradient, in walk order, and the gradients they use.
|
||||
let mut gradient_objects: Vec<Value> = vec![];
|
||||
let mut gradient_rows: Vec<(String, String)> = vec![];
|
||||
let mut gradients = BTreeSet::new();
|
||||
// The objects the walk has visited: a selection can hold a group and objects inside it, and each object counts once.
|
||||
// The set holds each object's address, so two objects that share an id (as a damaged file can hold) count as two.
|
||||
let mut seen: HashSet<*const Node> = HashSet::new();
|
||||
@@ -412,27 +433,17 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(k) = k {
|
||||
*counts.entry(k).or_default() += 1;
|
||||
}
|
||||
// The paint kinds this object's fills and strokes use, each once.
|
||||
let mut kinds = BTreeSet::new();
|
||||
// The patterns this object's fills and strokes use, each once.
|
||||
let mut used_patterns: Vec<&str> = vec![];
|
||||
let painted_runs = painted_runs(d, n);
|
||||
for pattern in own_patterns(d, n, &members, painted_runs.as_ref()) {
|
||||
patterns.insert(pattern.to_string());
|
||||
used_patterns.push(pattern);
|
||||
}
|
||||
for item in &n.appearance.items {
|
||||
let paint = item.paint();
|
||||
// Objects counts an object with a pattern when Pattern Objects lists it, below.
|
||||
if let Paint::Gradient(_) = paint {
|
||||
kinds.insert("gradients");
|
||||
}
|
||||
if let Paint::Solid { swatch: Some(name), .. } = paint
|
||||
if let Paint::Solid { swatch: Some(name), .. } = item.paint()
|
||||
&& d.swatch(name).is_some_and(|s| s.spot)
|
||||
{
|
||||
spot.insert(name.clone());
|
||||
}
|
||||
}
|
||||
// The patterns and the gradients this object's fills and strokes paint, each once.
|
||||
let painted_runs = painted_runs(d, n);
|
||||
let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), pattern_label);
|
||||
let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), gradient_label);
|
||||
// The characters' own paints: an inline graphic draws no outline and does not take its
|
||||
// run's paint (a list marker set in its run does), and a stroke with no width paints
|
||||
// nothing.
|
||||
@@ -440,23 +451,32 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for run in painted.iter().filter_map(|i| t.runs.get(*i)) {
|
||||
let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke);
|
||||
for paint in std::iter::once(&run.style.fill).chain(stroke) {
|
||||
if let Paint::Pattern { pattern, .. } = paint {
|
||||
patterns.insert(pattern.clone());
|
||||
if !used_patterns.contains(&pattern.as_str()) {
|
||||
used_patterns.push(pattern);
|
||||
for (used, label) in
|
||||
[(&mut used_patterns, pattern_label as fn(&Paint) -> Option<String>), (&mut used_gradients, gradient_label)]
|
||||
{
|
||||
if let Some(name) = label(paint)
|
||||
&& !used.contains(&name)
|
||||
{
|
||||
used.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Objects counts the objects that Pattern Objects and Gradient Objects list.
|
||||
if !used_patterns.is_empty() {
|
||||
let name = n.display_name();
|
||||
patterns.extend(used_patterns.iter().cloned());
|
||||
pattern_rows.push((name.clone(), used_patterns.join(", ")));
|
||||
pattern_objects.push(json!({ "id": n.id.0, "name": name, "patterns": used_patterns }));
|
||||
pattern_rows.push((name, used_patterns.join(", ")));
|
||||
kinds.insert("patterns");
|
||||
*counts.entry("patterns").or_default() += 1;
|
||||
}
|
||||
for k in kinds {
|
||||
*counts.entry(k).or_default() += 1;
|
||||
if !used_gradients.is_empty() {
|
||||
let name = n.display_name();
|
||||
gradients.extend(used_gradients.iter().cloned());
|
||||
gradient_rows.push((name.clone(), used_gradients.join(", ")));
|
||||
gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients }));
|
||||
*counts.entry("gradients").or_default() += 1;
|
||||
}
|
||||
if n.extras().mask.is_some() {
|
||||
*counts.entry("opacityMasks").or_default() += 1;
|
||||
@@ -506,10 +526,12 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"patterns": d.patterns.len(),
|
||||
"patternNames": patterns,
|
||||
"patternObjects": pattern_objects,
|
||||
"gradientNames": gradients,
|
||||
"gradientObjects": gradient_objects,
|
||||
});
|
||||
// The document's name: its file's, else its title.
|
||||
out["document"]["name"] = json!(st.title());
|
||||
let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, category);
|
||||
let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, &gradient_rows, category);
|
||||
if text {
|
||||
return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) }));
|
||||
}
|
||||
@@ -583,12 +605,14 @@ mod tests {
|
||||
&json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap();
|
||||
let t = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone();
|
||||
let i = s.execute("document.info", &json!({})).unwrap();
|
||||
// The type's characters take the current gradient fill, so it is a gradient object too.
|
||||
assert_eq!(
|
||||
(i["objects"]["paths"].as_u64(), i["objects"]["textObjects"].as_u64(), i["objects"]["gradients"].as_u64()),
|
||||
(Some(1), Some(1), Some(1))
|
||||
(Some(1), Some(1), Some(2))
|
||||
);
|
||||
assert_eq!((i["gradientObjects"][0]["id"].clone(), i["gradientObjects"][1]["id"].clone()), (r.clone(), t));
|
||||
assert_eq!(i["fonts"][0], "Source Sans 3 Regular");
|
||||
assert_eq!(i["document"]["artboards"][0]["width"], 200.0);
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
@@ -842,6 +866,124 @@ mod tests {
|
||||
assert!(!text.contains("Unused"), "{text}");
|
||||
}
|
||||
|
||||
/// Document Info › Gradient Objects lists each object filled or stroked with a gradient once,
|
||||
/// with the gradients it uses (a linked swatch by name, else the gradient's kind), one in a
|
||||
/// group and a type object whose characters use a gradient included; a gradient swatch no art
|
||||
/// uses adds no row, paint that draws nothing is left out by the rules Pattern Objects follows,
|
||||
/// and Objects counts the listed objects. With Selection Only it lists the objects in the
|
||||
/// selection, each once, and the report writes the same rows.
|
||||
#[test]
|
||||
fn info_lists_the_gradient_objects() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let square =
|
||||
|s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let ramp = |kind: &str| json!({"kind": kind, "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]});
|
||||
s.execute("swatch.new", &json!({"name": "Ramp", "gradient": ramp("linear")})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Spare", "gradient": ramp("radial")})).unwrap();
|
||||
let (a, b, c, f) = (square(&mut s, 100.0), square(&mut s, 200.0), square(&mut s, 300.0), square(&mut s, 0.0));
|
||||
s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Ramp"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [a], "gradient": ramp("linear")})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": a, "name": "Both"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [b], "gradient": ramp("radial")})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [f], "gradient": ramp("freeform")})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [b, c]})).unwrap();
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].clone();
|
||||
// A type object whose characters are filled with the gradient.
|
||||
let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone();
|
||||
s.execute("paint.setFill", &json!({"ids": [t], "swatch": "Ramp"})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap();
|
||||
// A hidden gradient fill and a gradient stroke with no width paint nothing, so neither object is listed.
|
||||
let (d, e) = (square(&mut s, 100.0), square(&mut s, 200.0));
|
||||
s.execute("paint.setStroke", &json!({"ids": [d], "none": true})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [d], "swatch": "Ramp"})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [d], "index": 0, "visible": false})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [e], "gradient": ramp("linear")})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap();
|
||||
// Type with no characters, or of only spaces and a line break, paints nothing, and neither
|
||||
// does a guide made from a path with a gradient.
|
||||
let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone();
|
||||
s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Ramp"})).unwrap();
|
||||
s.execute("appearance.addFill", &json!({"ids": [empty]})).unwrap();
|
||||
let blank = s.execute("text.create", &json!({"x": 0, "y": 320, "text": " \n"})).unwrap()["id"].clone();
|
||||
s.execute("paint.setFill", &json!({"ids": [blank], "swatch": "Ramp"})).unwrap();
|
||||
let guide = square(&mut s, 300.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [guide], "swatch": "Ramp"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [guide]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
// Nor does a compound path's member's own gradient fill, or an image's gradient fill
|
||||
// without an effect that gives it a shape.
|
||||
let (m1, m2) = (square(&mut s, 320.0), square(&mut s, 340.0));
|
||||
s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap();
|
||||
let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].clone();
|
||||
s.execute("paint.setFill", &json!({"ids": [compound], "color": "#000000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [compound], "none": true})).unwrap();
|
||||
let node = |s: &Session, id: &Value| s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().clone();
|
||||
let (member, paint) = (node(&s, &compound).children().unwrap()[0].id, node(&s, &a).appearance);
|
||||
let pic = square(&mut s, 360.0);
|
||||
s.execute("select.set", &json!({"ids": [pic]})).unwrap();
|
||||
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
|
||||
let image = s.doc().unwrap().selection.objects[0];
|
||||
s.edit("own paint", |d, _| {
|
||||
d.node_mut(member).unwrap().appearance = paint.clone();
|
||||
d.node_mut(image).unwrap().appearance = paint;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// Underlined spaces draw a bar in the gradient, so that type object is listed.
|
||||
let bar = s.execute("text.create", &json!({"x": 0, "y": 340, "text": " "})).unwrap()["id"].clone();
|
||||
s.execute("paint.setFill", &json!({"ids": [bar], "swatch": "Ramp"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [bar]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"underline": true})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": bar, "name": "Bar"})).unwrap();
|
||||
let i = s.execute("document.info", &json!({})).unwrap();
|
||||
assert_eq!(
|
||||
i["gradientObjects"],
|
||||
json!([
|
||||
{"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"]},
|
||||
{"id": b, "name": "<Rectangle>", "gradients": ["Radial gradient"]},
|
||||
{"id": f, "name": "<Rectangle>", "gradients": ["Freeform gradient"]},
|
||||
{"id": t, "name": "Type", "gradients": ["Ramp"]},
|
||||
{"id": bar, "name": "Bar", "gradients": ["Ramp"]},
|
||||
])
|
||||
);
|
||||
assert_eq!(i["gradientNames"], json!(["Freeform gradient", "Linear gradient", "Radial gradient", "Ramp"]));
|
||||
// Objects counts the objects that Gradient Objects lists.
|
||||
assert_eq!(i["objects"]["gradients"].as_u64(), Some(5), "{}", i["objects"]);
|
||||
s.execute("select.set", &json!({"ids": [d, e, empty, blank, guide, compound, image.0]})).unwrap();
|
||||
let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap();
|
||||
assert!(i["objects"]["gradients"].is_null() && i["gradientObjects"] == json!([]), "{}", i["objects"]);
|
||||
let i = s.execute("document.info", &json!({"category": "gradients"})).unwrap();
|
||||
assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("gradients"), Some("Gradient Objects")));
|
||||
assert_eq!(
|
||||
i["sections"][0]["rows"],
|
||||
json!([
|
||||
["Both", "Ramp, Linear gradient"],
|
||||
["<Rectangle>", "Radial gradient"],
|
||||
["<Rectangle>", "Freeform gradient"],
|
||||
["Type", "Ramp"],
|
||||
["Bar", "Ramp"]
|
||||
])
|
||||
);
|
||||
// A selection that holds the group and the object inside it lists that object once.
|
||||
for ids in [json!([group, b]), json!([b, group])] {
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "gradients"})).unwrap();
|
||||
assert_eq!(i["sections"][0]["rows"], json!([["<Rectangle>", "Radial gradient"]]), "{ids}");
|
||||
assert_eq!(i["objects"]["gradients"].as_u64(), Some(1), "{ids}");
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [t]})).unwrap();
|
||||
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "gradients"})).unwrap();
|
||||
assert_eq!((i["sections"][0]["rows"].clone(), i["gradientNames"].clone()), (json!([["Type", "Ramp"]]), json!(["Ramp"])));
|
||||
let text = s.execute("document.info", &json!({"category": "gradients", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(
|
||||
text.contains("\nGRADIENT OBJECTS\nBoth: Ramp, Linear gradient\n<Rectangle>: Radial gradient\n<Rectangle>: Freeform gradient\nType: Ramp\nBar: Ramp\n"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(!text.contains("Spare"), "{text}");
|
||||
}
|
||||
|
||||
/// Document Info › Objects counts an object with a gradient or a pattern on both its fill and
|
||||
/// its stroke once, and with Selection Only a selection that holds a group and an object inside
|
||||
/// it counts that object once.
|
||||
|
||||
@@ -13,8 +13,8 @@ use super::edit::selected_roots;
|
||||
use super::pathops::Beside;
|
||||
use super::select::{first_selectable, is_selectable, selectable_ids, walk_selectable};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Point, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ok,
|
||||
str_param, switch_or,
|
||||
CommandSpec, EngineError, Point, Result, Session, bad, checked_ids_param, cmd, f64_req, has_doc, has_selection, id_param, number_or, ok,
|
||||
str_param, switch_or, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -482,7 +482,7 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let ids = st.selection.objects.clone();
|
||||
let saved = &st.doc.saved_selections;
|
||||
let name = str_param(p, "name").and_then(SavedSelection::clean_name).unwrap_or_else(|| SavedSelection::default_name(saved));
|
||||
let name = text_param(p, "name", C)?.and_then(SavedSelection::clean_name).unwrap_or_else(|| SavedSelection::default_name(saved));
|
||||
let existing = saved.iter().position(|x| x.name == name);
|
||||
if existing.is_none() && saved.len() >= SavedSelection::MAX {
|
||||
return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX)));
|
||||
@@ -577,10 +577,12 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for e in edits {
|
||||
let name = str_param(e, "name").ok_or_else(|| bad(C, "missing name"))?;
|
||||
let i = *index.get(name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
let delete = switch_or(e, "delete", false, C)?;
|
||||
let new_name = text_param(e, "newName", C)?;
|
||||
let Some(slot) = after.get_mut(i) else { continue };
|
||||
if bool_or(e, "delete", false) {
|
||||
if delete {
|
||||
*slot = None;
|
||||
} else if let Some(n) = str_param(e, "newName").and_then(SavedSelection::clean_name)
|
||||
} else if let Some(n) = new_name.and_then(SavedSelection::clean_name)
|
||||
&& let Some(x) = slot
|
||||
{
|
||||
x.name = n;
|
||||
@@ -764,7 +766,7 @@ fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pos = f64_req(p, "pos", "guide.add")?;
|
||||
let vertical = bool_or(p, "vertical", false);
|
||||
let vertical = switch_or(p, "vertical", false, "guide.add")?;
|
||||
let artboard = match p.get("artboard").filter(|v| !v.is_null()) {
|
||||
Some(v) => {
|
||||
let boards = &s.doc()?.doc.artboards;
|
||||
@@ -845,7 +847,7 @@ fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?;
|
||||
let ids = guide_ids(&s.doc()?.doc);
|
||||
let picked = list.iter().map(|v| guide_index_in(&ids, Some(v), "guide.select")).collect::<Result<Vec<_>>>()?;
|
||||
if bool_or(p, "toggle", false) {
|
||||
if switch_or(p, "toggle", false, "guide.select")? {
|
||||
// Added to (or taken from) what is selected, the art included.
|
||||
s.select(|_, sel| {
|
||||
for i in picked {
|
||||
@@ -924,7 +926,9 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if moving.is_empty() {
|
||||
return Err(bad("guide.move", "no guide selected (or give an index)"));
|
||||
}
|
||||
let (dx, dy, copy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0), bool_or(p, "copy", false));
|
||||
let dx = number_or(p, "dx", 0.0, "guide.move")?;
|
||||
let dy = number_or(p, "dy", 0.0, "guide.move")?;
|
||||
let copy = switch_or(p, "copy", false, "guide.move")?;
|
||||
if !s.doc()?.selection.is_empty() {
|
||||
let ids = super::edit::selected_roots(s)?;
|
||||
return super::object::apply_transform(
|
||||
|
||||
@@ -9,9 +9,9 @@ use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::clipboard::SwatchChoices;
|
||||
use super::{
|
||||
CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, clock_date, cmd, date_param,
|
||||
f64_or, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param,
|
||||
index, ok, point_param, str_param, switch_or,
|
||||
CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param,
|
||||
f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index,
|
||||
number_or, ok, point_param, str_param, switch_or,
|
||||
};
|
||||
use crate::{Clipboard, EngineError, inspect};
|
||||
|
||||
@@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste in Front",
|
||||
["Edit"],
|
||||
Some("Cmd+F"),
|
||||
"{swatchConflict?, artboard?: index (the active artboard: the objects go where they were on the artboard they were copied from, on this one)} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
"{swatchConflict?, artboard?: index (the active artboard: the objects go where they were on the artboard they were copied from, on this one), artboards?: [index…] (a copy on each of these artboards, placed as on `artboard`, the copies in artboard order; `artboard` wins when both are given; default while the Artboard tool is chosen with several artboards selected: those; art copied from no artboard keeps its place on the first and its offset to it on the others; an index the document lacks, a repeated one or an empty list is an error)} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Front)
|
||||
),
|
||||
@@ -193,7 +193,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste in Back",
|
||||
["Edit"],
|
||||
Some("Cmd+B"),
|
||||
"{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
"{swatchConflict?, artboard?: index, artboards?: [index…] (both as edit.pasteInFront)} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Back)
|
||||
),
|
||||
@@ -202,7 +202,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste in Place",
|
||||
["Edit"],
|
||||
Some("Cmd+Shift+V"),
|
||||
"{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste where the objects were copied (resources, layers and drawing modes as edit.paste) → {ids, added, merged, renamed}",
|
||||
"{swatchConflict?, artboard?: index, artboards?: [index…] (both as edit.pasteInFront)} paste where the objects were copied (resources, layers and drawing modes as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::InPlace)
|
||||
),
|
||||
@@ -237,7 +237,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(index).or(s.active_index()).ok_or(EngineError::NoDocument)?;
|
||||
let i = opt_u64(p, "file.close", "index")?.map(index).or(s.active_index()).ok_or(EngineError::NoDocument)?;
|
||||
if !s.close_document(i) {
|
||||
return Err(bad("file.close", "no such document"));
|
||||
}
|
||||
@@ -269,7 +269,7 @@ fn doc_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
|
||||
let mut opts = slice_opts(p, "document.node")?;
|
||||
let summary = bool_or(p, "summary", false);
|
||||
let summary = switch_or(p, "summary", false, "document.node")?;
|
||||
if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) {
|
||||
// The full object JSON is never truncated: a slice without `summary` would
|
||||
// otherwise silently return the whole subtree.
|
||||
@@ -663,7 +663,8 @@ fn undo_group_begin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.end_undo_group(bool_or(p, "cancel", false));
|
||||
let cancel = switch_or(p, "cancel", false, "edit.undoGroup.end")?;
|
||||
s.end_undo_group(cancel);
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -1231,6 +1232,9 @@ impl PasteMode {
|
||||
|
||||
fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
|
||||
let choices = SwatchChoices::parse(mode.label(), p)?;
|
||||
if mode == PasteMode::Offset {
|
||||
check_numbers(p, &["dx", "dy"], "edit.paste")?;
|
||||
}
|
||||
// Taken out of the session for the edit (and put back whatever happens).
|
||||
let clip = std::mem::take(&mut s.clipboard);
|
||||
let r = paste_clip(s, p, mode, &clip, &choices);
|
||||
@@ -1246,15 +1250,79 @@ pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_
|
||||
paste_clip(s, &json!({ "center": [center.x, center.y] }), PasteMode::Library, clip, &choices)
|
||||
}
|
||||
|
||||
/// Paste in Place, in Front or in Back onto artboard `artboard` (the active one, #693): where the
|
||||
/// objects were on the artboard they were copied from, on that one; without it (or a copy from no
|
||||
/// artboard), where they were.
|
||||
fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine {
|
||||
let target = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| doc.artboards.get(i));
|
||||
match (target, clip.source_artboard) {
|
||||
(Some(t), Some(src)) => Affine::translate(t.rect.origin() - src.origin()),
|
||||
_ => Affine::IDENTITY,
|
||||
/// The artboards Paste in Place, in Front or in Back puts a copy on, in artboard order: `artboard`
|
||||
/// (the active one, #693), else `artboards`, else, while the Artboard tool is chosen with several
|
||||
/// artboards selected, those (noted in the journal as `artboards`, so a replay pastes on the same
|
||||
/// ones); `None` when none is named. A named index that is not an artboard of the document, a
|
||||
/// repeated one or an empty list is an error.
|
||||
fn paste_targets(s: &mut Session, p: &Value, cmd: &str) -> Result<Option<Vec<usize>>> {
|
||||
let n = s.doc()?.doc.artboards.len();
|
||||
let one = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < n);
|
||||
let single = match p.get("artboard") {
|
||||
Some(v) => Some(one(v).ok_or_else(|| bad(cmd, "`artboard` must be the index of an artboard of the document"))?),
|
||||
None => None,
|
||||
};
|
||||
let listed = match p.get("artboards") {
|
||||
Some(v) => {
|
||||
let list = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`artboards` must be a list of artboard indices"))?;
|
||||
let mut all = list
|
||||
.iter()
|
||||
.map(one)
|
||||
.collect::<Option<Vec<usize>>>()
|
||||
.ok_or_else(|| bad(cmd, "`artboards` names an artboard the document does not have"))?;
|
||||
all.sort_unstable();
|
||||
if all.windows(2).any(|w| w[0] == w[1]) {
|
||||
return Err(bad(cmd, "`artboards` names an artboard twice"));
|
||||
}
|
||||
Some(all)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if let Some(i) = single {
|
||||
return Ok(Some(vec![i]));
|
||||
}
|
||||
if listed.is_some() {
|
||||
return Ok(listed);
|
||||
}
|
||||
let picked = artboard_tool_picks(s);
|
||||
if picked.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
s.note_journal("artboards", json!(picked));
|
||||
Ok(Some(picked))
|
||||
}
|
||||
|
||||
/// The artboards of the active document the Artboard tool has selected, in artboard order, while
|
||||
/// that tool is chosen with two or more of them selected; otherwise none. Indices the document
|
||||
/// does not have are left out.
|
||||
pub(crate) fn artboard_tool_picks(s: &Session) -> Vec<usize> {
|
||||
if s.tool_id() != "artboard" {
|
||||
return Vec::new();
|
||||
}
|
||||
let n = s.active().map_or(0, |d| d.doc.artboards.len());
|
||||
let mut picked: Vec<usize> = s.tool_options_of("artboard")["selected"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_u64().and_then(|i| usize::try_from(i).ok()))
|
||||
.filter(|i| *i < n)
|
||||
.collect();
|
||||
picked.sort_unstable();
|
||||
picked.dedup();
|
||||
if picked.len() < 2 {
|
||||
picked.clear();
|
||||
}
|
||||
picked
|
||||
}
|
||||
|
||||
/// Paste in Place, in Front or in Back onto artboards `targets`: one copy on each, where the objects
|
||||
/// were on the artboard they were copied from. A copy from no artboard keeps its place on the first
|
||||
/// target and its offset to that one on the others; without targets, the objects go where they were.
|
||||
fn onto_artboards(doc: &Document, targets: Option<&[usize]>, clip: &Clipboard) -> Vec<Affine> {
|
||||
let rects: Vec<_> = targets.unwrap_or_default().iter().filter_map(|i| doc.artboards.get(*i)).map(|a| a.rect.origin()).collect();
|
||||
let Some(first) = rects.first().copied() else { return vec![Affine::IDENTITY] };
|
||||
let src = clip.source_artboard.map_or(first, |r| r.origin());
|
||||
rects.iter().map(|o| Affine::translate(*o - src)).collect()
|
||||
}
|
||||
|
||||
/// Insert clipboard resources atomically in the active editable context using the requested placement mode.
|
||||
@@ -1268,6 +1336,12 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
let targets = match mode {
|
||||
PasteMode::Front => paste_targets(s, p, "edit.pasteInFront")?,
|
||||
PasteMode::Back => paste_targets(s, p, "edit.pasteInBack")?,
|
||||
PasteMode::InPlace => paste_targets(s, p, "edit.pasteInPlace")?,
|
||||
_ => None,
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let same_doc = clip.source_doc == Some(st.uid);
|
||||
let parent = st.target_parent_for_path()?;
|
||||
@@ -1315,14 +1389,15 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
(_, Some((_, dv))) => vec![Affine::translate(dv)],
|
||||
(PasteMode::Offset | PasteMode::Library | PasteMode::Drag, None) => vec![match point_param(p, "center") {
|
||||
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
||||
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
|
||||
None => Affine::translate((number_or(p, "dx", off, mode.label())?, number_or(p, "dy", off, mode.label())?)),
|
||||
}],
|
||||
(PasteMode::AllArtboards, None) => {
|
||||
let src = clip.source_artboard.or_else(|| st.doc.artboards.first().map(|a| a.rect)).map(|r| r.origin()).unwrap_or_default();
|
||||
st.doc.artboards.iter().map(|a| Affine::translate(a.rect.origin() - src)).collect()
|
||||
}
|
||||
// In place, in front, in back: onto the active artboard when the UI names it.
|
||||
_ => vec![onto_artboard(&st.doc, p, clip)],
|
||||
// In place, in front, in back: onto the active artboard when the UI names it, or onto each
|
||||
// artboard the Artboard tool has selected.
|
||||
_ => onto_artboards(&st.doc, targets.as_deref(), clip),
|
||||
};
|
||||
// Guides alone don't follow a plain paste's offset or the view centre: they stay where they
|
||||
// were. With objects they move as the objects do.
|
||||
@@ -1489,8 +1564,8 @@ fn layer_named(d: &mut Document, name: &str) -> NodeId {
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let dx = f64_or(p, "dx", 0.0);
|
||||
let dy = f64_or(p, "dy", 0.0);
|
||||
let dx = number_or(p, "dx", 0.0, "edit.duplicate")?;
|
||||
let dy = number_or(p, "dy", 0.0, "edit.duplicate")?;
|
||||
let ids = s.edit("Duplicate", |d, sel| duplicate_in(d, sel, &roots, Affine::translate((dx, dy))))?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ pub const MAX_STEPS: u32 = 1000;
|
||||
const FREEFORM_MESH: u32 = 8;
|
||||
/// Columns (around the centre) of the mesh a radial gradient expands to.
|
||||
const RADIAL_MESH_COLS: usize = 8;
|
||||
/// Largest turn between a conic gradient's mesh columns, so their straight outer edges stay
|
||||
/// close enough to a circle to reach past the box.
|
||||
const CONIC_MESH_GAP: f64 = 1.0 / 32.0;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -44,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -1279,7 +1282,10 @@ fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32
|
||||
/// end, the vector across it (radial: the ellipse's other axis), and the parameter ranges that
|
||||
/// cover the box (linear: along the vector `t0..t1` and across it `s0..s1` in points; radial:
|
||||
/// `0..t1`, each level's ellipse centred on the way from the focal point to the start).
|
||||
/// A conic gradient also has its placement, the box and the radius in its frame that reaches
|
||||
/// past the box's corners ([`Frame::pieces`] and [`Frame::mesh`] go around its center).
|
||||
struct Frame {
|
||||
conic: Option<(vectorcraft_color::GradientGeom, Rect, f64)>,
|
||||
radial: bool,
|
||||
start: Point,
|
||||
focal: Point,
|
||||
@@ -1309,7 +1315,15 @@ impl Frame {
|
||||
}
|
||||
// Keep a sliver of span so the art maps back invertibly.
|
||||
t1 = t1.max(t0 + 1e-6);
|
||||
Self { radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 }
|
||||
let conic = (g.gradient.kind == GradientKind::Conic).then(|| {
|
||||
let to_unit = geom.conic_frame().inverse();
|
||||
let reach = corners.iter().map(|c| (to_unit * *c).to_vec2().hypot()).fold(0.0f64, f64::max).max(1e-6);
|
||||
// Past the corners, and far enough out that the chords between columns at most
|
||||
// CONIC_MESH_GAP of a turn apart stay outside them.
|
||||
let reach = if reach.is_finite() { reach * 1.01 / (std::f64::consts::PI * CONIC_MESH_GAP).cos() } else { 1.0 };
|
||||
(geom, bounds, reach)
|
||||
});
|
||||
Self { conic, radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 }
|
||||
}
|
||||
|
||||
/// Linear: the point at parameter `t` along and `s` across. Radial: the point at parameter `t`
|
||||
@@ -1325,6 +1339,18 @@ impl Frame {
|
||||
/// A gradient mesh covering the box: columns (linear) or rings (radial) where the colour
|
||||
/// changes, so it paints the gradient exactly.
|
||||
fn mesh(&self, g: &Gradient) -> GradientMesh {
|
||||
if let Some((geom, _, reach)) = &self.conic {
|
||||
// Columns around the center where the color changes, at most CONIC_MESH_GAP of a turn
|
||||
// apart so the outer edge stays past the box; the seam is two columns in one place.
|
||||
let br = breaks(g, 0.0, 1.0, CONIC_MESH_GAP);
|
||||
let us: Vec<f64> = br.iter().map(|(t, _)| *t).collect();
|
||||
let frame = geom.conic_frame();
|
||||
let at = |u: f64, v: f64| {
|
||||
let a = u * std::f64::consts::TAU;
|
||||
frame * Point::new(reach * v * a.cos(), reach * v * a.sin())
|
||||
};
|
||||
return GradientMesh::from_grid(&us, &[0.0, 1.0], &at, &|_, c, _| br[c].1);
|
||||
}
|
||||
// Rings as close as the columns around them, so the mesh draws smoothly.
|
||||
let gap = if self.radial { (self.t1 - self.t0) / RADIAL_MESH_COLS as f64 } else { f64::INFINITY };
|
||||
let br = breaks(g, self.t0, self.t1, gap);
|
||||
@@ -1340,13 +1366,21 @@ impl Frame {
|
||||
|
||||
/// `n` solid shapes covering the box in paint order, each coloured as the gradient in the
|
||||
/// middle of its band: for a linear gradient, rectangles from each band's start to the far
|
||||
/// end, for a radial one, nested ellipses (largest first). Each paints over the ones
|
||||
/// below except its band, so no seam shows between bands. With translucent stops each shape
|
||||
/// is just its band, so nothing doubles up.
|
||||
/// end, for a radial one, nested ellipses (largest first), for a conic one, wedges around the
|
||||
/// center from each band's start to the seam. Each paints over the ones below except its
|
||||
/// band, so no seam shows between bands. With translucent stops each shape is just its band,
|
||||
/// so nothing doubles up.
|
||||
fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> {
|
||||
let opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
|
||||
if let Some((geom, b, _)) = &self.conic {
|
||||
return geom
|
||||
.conic_wedges(*b, n, opaque)
|
||||
.into_iter()
|
||||
.map(|(pts, t)| (PathData::single(SubPath::polyline(&pts, true)), g.sample_keep_model(t as f32)))
|
||||
.collect();
|
||||
}
|
||||
let step = (self.t1 - self.t0) / n as f64;
|
||||
let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32);
|
||||
let opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
|
||||
if self.radial {
|
||||
let ellipse = |t: f64| {
|
||||
let c = self.focal + (self.start - self.focal) * t;
|
||||
|
||||
@@ -50,6 +50,7 @@ mod pdfimport;
|
||||
pub mod pngtext;
|
||||
pub mod ppi;
|
||||
pub mod progress;
|
||||
mod psdlayers;
|
||||
pub mod psdread;
|
||||
mod pxrread;
|
||||
mod save;
|
||||
@@ -218,6 +219,8 @@ mod tests_pdfx;
|
||||
#[cfg(test)]
|
||||
mod tests_psd;
|
||||
#[cfg(test)]
|
||||
mod tests_psdlayers;
|
||||
#[cfg(test)]
|
||||
mod tests_psdread;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -716,6 +716,11 @@ fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result<Ve
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
// Names that are not valid text come back lossily, so different names can give one path: each
|
||||
// folder is listed once.
|
||||
files.dedup();
|
||||
subdirs.sort();
|
||||
subdirs.dedup();
|
||||
for sub in subdirs {
|
||||
let mut nested = collect_files(&sub, true, depth + 1)?;
|
||||
files.append(&mut nested);
|
||||
@@ -749,6 +754,22 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn folders_whose_names_alias_after_lossy_conversion_are_listed_once() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let root = tmp("alias");
|
||||
// `a\u{FFFD}` is a real folder; two others with bytes that aren't text give the same name.
|
||||
let real = root.join("a\u{FFFD}");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
write_svg(&real, "x.svg", "red");
|
||||
for bad in [b"a\xFF", b"a\xFE"] {
|
||||
let _ = std::fs::create_dir_all(root.join(std::ffi::OsStr::from_bytes(bad)));
|
||||
}
|
||||
let files = collect_files(&root.to_string_lossy(), true, 0).unwrap();
|
||||
assert_eq!(files.len(), 1, "{files:?}");
|
||||
}
|
||||
|
||||
fn write_svg(dir: &std::path::Path, name: &str, fill: &str) -> std::path::PathBuf {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(
|
||||
|
||||
@@ -188,17 +188,24 @@ impl Builder {
|
||||
let kind = match g.kind {
|
||||
AfKind::Linear => GradientKind::Linear,
|
||||
AfKind::Radial => GradientKind::Radial,
|
||||
AfKind::Conical => {
|
||||
self.warn("conical gradients import as radial gradients");
|
||||
GradientKind::Radial
|
||||
}
|
||||
AfKind::Conical => GradientKind::Conic,
|
||||
};
|
||||
let (u, v): (Vec2, Vec2) = (x - o, y - o);
|
||||
if kind == GradientKind::Radial && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 {
|
||||
if kind.is_centered() && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 {
|
||||
self.warn("skewed elliptical gradients import as upright ellipses");
|
||||
}
|
||||
let aspect = if u.hypot() > 0.0 { (v.hypot() / u.hypot()).clamp(0.01, 100.0) } else { 1.0 };
|
||||
let geom = GradientGeom { start: o, end: x, aspect: if kind == GradientKind::Radial { aspect } else { 1.0 }, focal: None };
|
||||
let geom = match kind {
|
||||
// A conic sweeps from unit +x towards unit +y: its unit frame carried onto the
|
||||
// page as a transform carries it (mirrored when the transform is a reflection).
|
||||
GradientKind::Conic => {
|
||||
let mut geom = GradientGeom { start: Point::ZERO, end: Point::new(1.0, 0.0), aspect: 1.0, focal: None };
|
||||
geom.transform(m, GradientKind::Conic);
|
||||
geom
|
||||
}
|
||||
GradientKind::Radial => GradientGeom { start: o, end: x, aspect, focal: None },
|
||||
_ => GradientGeom { start: o, end: x, aspect: 1.0, focal: None },
|
||||
};
|
||||
let mut gp = GradientPaint::new(Gradient::new(kind, stops));
|
||||
gp.angle = (-(u.y)).atan2(u.x).to_degrees();
|
||||
gp.geom = Some(geom);
|
||||
|
||||
@@ -76,7 +76,9 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// starts (one for all the steps in that document), so a step may open, switch or close documents.
|
||||
/// A step that acts on the active artboard ([`crate::ON_ACTIVE_ARTBOARD`]) and names none gets
|
||||
/// `artboard`, as the Artboards panel shows it in the document the step runs in: past that
|
||||
/// document's last artboard, the last one. A step that resizes the active artboard
|
||||
/// document's last artboard, the last one; a Paste in Place, in Front or in Back step that names
|
||||
/// `artboards` keeps them, and one that names neither, while the Artboard tool is chosen with
|
||||
/// several artboards selected, pastes on those. A step that resizes the active artboard
|
||||
/// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` gets that artboard as its `index`.
|
||||
/// `grouped` gets each step's undo groups, as [`Session::batch_steps`] keeps them.
|
||||
fn run_steps(
|
||||
@@ -104,9 +106,14 @@ fn run_steps(
|
||||
} else {
|
||||
crate::FIT_ACTIVE_ARTBOARD.contains(&id.as_str()).then_some("index")
|
||||
};
|
||||
// A paste step that names its `artboards` keeps them; one that names neither key, while the
|
||||
// Artboard tool is chosen with several artboards selected, pastes on those.
|
||||
let names_artboards = id.starts_with("edit.pasteIn")
|
||||
&& (params.get("artboards").is_some() || (params.get("artboard").is_none() && !crate::cmd::edit::artboard_tool_picks(s).is_empty()));
|
||||
if let Some(a) = artboard
|
||||
&& let Some(key) = key
|
||||
&& params.get(key).is_none()
|
||||
&& !names_artboards
|
||||
&& let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1))
|
||||
&& let Some(o) = params.as_object_mut()
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
//! 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 and art shows), the preview and thumbnail rendered,
|
||||
//! and the native document embedded for reopening.
|
||||
//! document when there are several and art shows), the preview rendered and the thumbnail made
|
||||
//! from it, and the native document embedded for reopening.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -11,6 +11,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_eps::{EpsOptions, Level, Overprint, Preview, Raster};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_render::Rendered;
|
||||
|
||||
use super::super::flatten::{FlattenOptions, expand_transparent_tile_images, flatten_expanded};
|
||||
use super::super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param};
|
||||
@@ -231,14 +232,38 @@ fn pages(doc: &Document, set: &Settings) -> Result<Vec<(Option<usize>, Rect, Poi
|
||||
}
|
||||
}
|
||||
|
||||
/// `rect` of `doc` drawn at a pixel a point (fewer when larger than `max` a side), straight RGBA,
|
||||
/// transparent or on white.
|
||||
fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result<Raster> {
|
||||
/// Pixels a point for `rect`: one, or fewer when it is larger than `max` a side.
|
||||
fn fit(rect: Rect, max: f64) -> f64 {
|
||||
let long = rect.width().max(rect.height());
|
||||
let scale = if long > max { max / long } else { 1.0 };
|
||||
if long > max { max / long } else { 1.0 }
|
||||
}
|
||||
|
||||
/// `rect` of `doc` drawn at [`fit`]'s scale for `max`, transparent or on white.
|
||||
fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result<Rendered> {
|
||||
let scale = fit(rect, max);
|
||||
vectorcraft_render::raster_size(rect, scale).map_err(|e| bad(C, e))?;
|
||||
let img = r.render_region(doc, rect, scale, !transparent);
|
||||
Ok(Raster { width: img.width, height: img.height, rgba: img.to_straight() })
|
||||
Ok(r.render_region(doc, rect, scale, !transparent))
|
||||
}
|
||||
|
||||
/// `img` as the writer takes it: straight RGBA.
|
||||
fn straight(img: &Rendered) -> Raster {
|
||||
Raster { width: img.width, height: img.height, rgba: img.to_straight() }
|
||||
}
|
||||
|
||||
/// The thumbnail of `rect` made from `preview`, the same rect rendered larger: at the size a
|
||||
/// thumbnail render of `rect` has, each pixel the average of the preview's pixels under it, on white.
|
||||
fn thumbnail_of(preview: &Rendered, rect: Rect) -> Result<Raster> {
|
||||
let (width, height) = vectorcraft_render::raster_size(rect, fit(rect, THUMBNAIL)).map_err(|e| bad(C, e))?;
|
||||
let mut rgba = preview.area_resized(width, height).map_err(|e| bad(C, e))?.pixels;
|
||||
// Premultiplied over white: each channel gains the white the pixel's alpha leaves showing.
|
||||
for px in rgba.as_chunks_mut::<4>().0 {
|
||||
let clear = 255 - px[3];
|
||||
for c in &mut px[..3] {
|
||||
*c = c.saturating_add(clear);
|
||||
}
|
||||
px[3] = 255;
|
||||
}
|
||||
Ok(Raster { width, height, rgba })
|
||||
}
|
||||
|
||||
/// What the file carries to reopen: `doc` (with only artboard `board` when the file is one of
|
||||
@@ -318,11 +343,18 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> {
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
for (board, region, origin) in pages {
|
||||
let rect = vectorcraft_eps::preview_rect(region, origin);
|
||||
let preview = match set.eps.preview {
|
||||
let shown = match set.eps.preview {
|
||||
Preview::None => None,
|
||||
kind => Some(render(&mut renderer, doc, rect, preview_limit, set.eps.transparent_preview && kind == Preview::TiffColor)?),
|
||||
};
|
||||
let thumbnail = set.thumbnails.then(|| render(&mut renderer, doc, rect, THUMBNAIL, false)).transpose()?;
|
||||
// The thumbnail is made from the preview; only a file without one renders it.
|
||||
let thumbnail = match (set.thumbnails, &shown) {
|
||||
(false, _) => None,
|
||||
(true, Some(img)) => Some(thumbnail_of(img, rect)?),
|
||||
(true, None) => Some(straight(&render(&mut renderer, doc, rect, THUMBNAIL, false)?)),
|
||||
};
|
||||
let preview = shown.as_ref().map(straight);
|
||||
drop(shown);
|
||||
let o = EpsOptions {
|
||||
region,
|
||||
origin,
|
||||
|
||||
@@ -9,14 +9,14 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
|
||||
use crate::DocState;
|
||||
|
||||
use super::super::{Result, Session, bad, bool_or, edit, f64_or, str_param};
|
||||
use super::super::{Result, Session, bad, check_switches, edit, number_or, switch_or, text_param};
|
||||
use super::encode::single_artboard;
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return,
|
||||
};
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
||||
let f = writable("document.serialize", Some(text_param(p, "format", "document.serialize")?.unwrap_or("vectorcraft")), None)?;
|
||||
let expanded = expand_presets(s, "document.serialize", p)?;
|
||||
let p = with_black_output(s, &expanded);
|
||||
let (doc, display_doc_title) = source(s, f, &p, "document.serialize")?;
|
||||
@@ -53,11 +53,11 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path");
|
||||
let path = text_param(p, "path", "document.export")?;
|
||||
if let Some(path) = path {
|
||||
super::check_not_lossy_overwrite(s.doc()?, path, p, "document.export")?;
|
||||
}
|
||||
let f = writable("document.export", str_param(p, "format"), path)?;
|
||||
let f = writable("document.export", text_param(p, "format", "document.export")?, path)?;
|
||||
let expanded = expand_presets(s, "document.export", p)?;
|
||||
let p = with_black_output(s, &expanded);
|
||||
let (doc, display_doc_title) = source(s, f, &p, "document.export")?;
|
||||
@@ -86,7 +86,7 @@ pub fn export_doc<'a>(st: &'a DocState, f: &Format, p: &Value) -> Result<Cow<'a,
|
||||
/// The document an export of `st` writes: the whole document, or with `selectedOnly` just the
|
||||
/// selected objects, each in its layers and groups (a clipping group keeps its clipping path).
|
||||
pub fn export_source<'a>(st: &'a DocState, p: &Value) -> Result<Cow<'a, Document>> {
|
||||
if !bool_or(p, "selectedOnly", false) {
|
||||
if !switch_or(p, "selectedOnly", false, "document.export")? {
|
||||
return Ok(Cow::Borrowed(&*st.doc));
|
||||
}
|
||||
let keep: HashSet<NodeId> = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc)).into_iter().collect();
|
||||
@@ -237,7 +237,8 @@ fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Va
|
||||
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text
|
||||
/// with `selectionOnly` the selected objects alone.
|
||||
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(Cow<'a, Document>, bool)> {
|
||||
if f.id == "txt" && bool_or(p, "selectionOnly", false) {
|
||||
check_switches(p, &["selectedOnly"], cmd)?;
|
||||
if switch_or(p, "selectionOnly", false, cmd)? && f.id == "txt" {
|
||||
let doc = selection(s, cmd, true)?.0;
|
||||
let display_doc_title = super::has_own_title(&doc);
|
||||
return Ok((Cow::Owned(doc), display_doc_title));
|
||||
@@ -309,11 +310,11 @@ pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Do
|
||||
/// are guides, not artwork, and are left out.
|
||||
pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportSelection";
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
let path = text_param(p, "path", C)?;
|
||||
if let Some(path) = path {
|
||||
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
|
||||
}
|
||||
let path = str_param(p, "path");
|
||||
let f = writable(C, str_param(p, "format"), path)?;
|
||||
let f = writable(C, text_param(p, "format", C)?, path)?;
|
||||
let expanded = expand_presets(s, C, p)?;
|
||||
let p = with_black_output(s, &expanded);
|
||||
let (d, bounds) = selection(s, C, f.id == "txt")?;
|
||||
@@ -327,11 +328,12 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// `transparent`, with the encoder's warnings.
|
||||
pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportForOffice";
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
let path = text_param(p, "path", C)?;
|
||||
if let Some(path) = path {
|
||||
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
|
||||
}
|
||||
let path = str_param(p, "path");
|
||||
let ppi = f64_or(p, "ppi", 150.0);
|
||||
let ppi = number_or(p, "ppi", 150.0, C)?;
|
||||
let transparent = switch_or(p, "transparent", false, C)?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let artboard = match p.get("artboard") {
|
||||
None | Some(Value::Null) => 0,
|
||||
@@ -340,7 +342,7 @@ pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if artboard >= doc.artboards.len() {
|
||||
return Err(bad(C, format!("no artboard {artboard} (0-based; the document has {})", doc.artboards.len())));
|
||||
}
|
||||
let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" };
|
||||
let background = if transparent { "transparent" } else { "white" };
|
||||
let opts = with_black_output(s, &json!({ "ppi": ppi, "background": background, "artboard": artboard }));
|
||||
let (bytes, warnings) = encode_with_warnings(doc, "png", &opts)?;
|
||||
// The pixel size, from the PNG header.
|
||||
|
||||
@@ -493,7 +493,8 @@ pub const FORMATS: &[Format] = &[
|
||||
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
|
||||
// vector app for a raster editor's documents.
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/x-affinity", false),
|
||||
// Photoshop's large document format: read like a PSD (its merged image), never written.
|
||||
// The large document form of PSD: read like a PSD (its layers, or its merged image), never
|
||||
// written.
|
||||
reader("psb", "PSB", &["psb"], "image/x-psb", true),
|
||||
// PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't.
|
||||
reader("ps", "PostScript", &["ps"], "application/postscript", false),
|
||||
|
||||
@@ -492,6 +492,16 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
preview_only = i.preview_only;
|
||||
(i.doc, i.warnings, false)
|
||||
}
|
||||
// A PSD or PSB file's layers become layers (`layers`, default on); without layers, or with
|
||||
// `layers: false`, its merged image opens. Layers that can't be read leave the merged image.
|
||||
"psd" | "psb" if opts.layers => {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
match super::psdlayers::import(bytes, opts.hidden_layers, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)) {
|
||||
Ok(Some((doc, warnings))) => (doc, warnings, false),
|
||||
Ok(None) => (raster_doc(&title, bytes)?, vec![], false),
|
||||
Err(e) => (raster_doc(&title, bytes)?, vec![format!("the layers can't be read ({e}): the file opened as its merged image")], false),
|
||||
}
|
||||
}
|
||||
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
|
||||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -28,8 +28,10 @@ pub struct LoadOptions {
|
||||
/// What a PDF's text becomes.
|
||||
pub text_as: TextAs,
|
||||
/// A PDF's optional content groups become layers (else one layer per page, without the art
|
||||
/// that is off).
|
||||
/// that is off), and a PSD or PSB file's layers become layers (else its merged image opens).
|
||||
pub layers: bool,
|
||||
/// A PSD or PSB file's hidden layers come in, hidden (`hiddenLayers`; else they are left out).
|
||||
pub hidden_layers: bool,
|
||||
/// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]).
|
||||
pub dxf: vectorcraft_cad::ImportOptions,
|
||||
/// An Illustrator EPS or `.ai` that carries the editing copy of its art opens from it (its
|
||||
@@ -57,6 +59,7 @@ impl Default for LoadOptions {
|
||||
grays: Grays::default(),
|
||||
text_as: TextAs::default(),
|
||||
layers: true,
|
||||
hidden_layers: false,
|
||||
dxf: Default::default(),
|
||||
editing_data: true,
|
||||
trust_editing_data: false,
|
||||
@@ -69,7 +72,8 @@ impl Default for LoadOptions {
|
||||
impl LoadOptions {
|
||||
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
|
||||
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`,
|
||||
/// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false | `always`),
|
||||
/// `textAs` (`text` | `outlines`), `layers` (true | false), `hiddenLayers` (true | false),
|
||||
/// `editingData` (true | false | `always`),
|
||||
/// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]) and `dxf` (the DXF
|
||||
/// import options).
|
||||
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
|
||||
@@ -95,6 +99,10 @@ impl LoadOptions {
|
||||
None => true,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
|
||||
};
|
||||
let hidden_layers = match p.get("hiddenLayers").filter(|v| !v.is_null()) {
|
||||
None => false,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "hiddenLayers must be true or false"))?,
|
||||
};
|
||||
let (editing_data, trust_editing_data) = match p.get("editingData").filter(|v| !v.is_null()) {
|
||||
None => (true, false),
|
||||
Some(Value::String(s)) if s.eq_ignore_ascii_case("always") => (true, true),
|
||||
@@ -108,7 +116,21 @@ impl LoadOptions {
|
||||
.ok_or_else(|| bad(cmd, format!("legacyNameEncoding must be one of {}", vectorcraft_eps::NameEncoding::LABELS.join(", "))))?,
|
||||
};
|
||||
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
|
||||
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data, legacy_names, native: true })
|
||||
Ok(Self {
|
||||
pages,
|
||||
crop,
|
||||
password,
|
||||
color_mode,
|
||||
grays,
|
||||
text_as,
|
||||
layers,
|
||||
hidden_layers,
|
||||
dxf,
|
||||
editing_data,
|
||||
trust_editing_data,
|
||||
legacy_names,
|
||||
native: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Does the document read only part of the file, or read it differently (a page range, another
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
//! Reading Photoshop documents (PSD, and PSB, the large document format) for File › Open and
|
||||
//! File › Place (#918): the merged image, the flattened picture every PSD carries after its layers,
|
||||
//! as a linked PSD shows. Written from Adobe's public "Photoshop File Formats Specification"
|
||||
//! (<https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/>). Layers aren't read.
|
||||
//! (<https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/>). File › Open reads the
|
||||
//! layers with [`super::psdlayers`], which shares this reader.
|
||||
//!
|
||||
//! Every mode Photoshop saves a merged image in is read: Bitmap, Grayscale, Duotone (as its
|
||||
//! grayscale data), Indexed, RGB, CMYK and Lab, at 1, 8, 16 and 32 bits per channel, stored raw,
|
||||
@@ -16,7 +17,7 @@ use vectorcraft_color::cms::{Lab, lab::lab_to_srgb};
|
||||
|
||||
/// Photoshop's colour modes (the header's numbers), as far as the merged image is concerned.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
pub(super) enum Mode {
|
||||
Bitmap,
|
||||
Gray,
|
||||
Indexed,
|
||||
@@ -27,7 +28,7 @@ enum Mode {
|
||||
|
||||
impl Mode {
|
||||
/// The channels holding colour (an alpha channel may follow them).
|
||||
fn colors(self) -> usize {
|
||||
pub(super) fn colors(self) -> usize {
|
||||
match self {
|
||||
Mode::Bitmap | Mode::Gray | Mode::Indexed => 1,
|
||||
Mode::Rgb | Mode::Lab => 3,
|
||||
@@ -46,68 +47,68 @@ pub fn is_psb(bytes: &[u8]) -> bool {
|
||||
is_psd(bytes) && bytes.get(4..6) == Some(&[0, 2])
|
||||
}
|
||||
|
||||
const TRUNCATED: &str = "the PSD file is cut short";
|
||||
pub(super) const TRUNCATED: &str = "the PSD file is cut short";
|
||||
const DAMAGED: &str = "the PSD file's header is damaged";
|
||||
|
||||
/// Big-endian reads through `b`, each checked against its end.
|
||||
struct Reader<'a> {
|
||||
pub(super) struct Reader<'a> {
|
||||
b: &'a [u8],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
fn new(b: &'a [u8]) -> Self {
|
||||
pub(super) fn new(b: &'a [u8]) -> Self {
|
||||
Self { b, at: 0 }
|
||||
}
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
|
||||
pub(super) fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
|
||||
let s = self.at.checked_add(n).and_then(|end| self.b.get(self.at..end)).ok_or(TRUNCATED)?;
|
||||
self.at += n;
|
||||
Ok(s)
|
||||
}
|
||||
fn array<const N: usize>(&mut self) -> Result<[u8; N], String> {
|
||||
pub(super) fn array<const N: usize>(&mut self) -> Result<[u8; N], String> {
|
||||
self.take(N)?.try_into().map_err(|_| TRUNCATED.to_string())
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8, String> {
|
||||
pub(super) fn u8(&mut self) -> Result<u8, String> {
|
||||
self.array::<1>().map(|[v]| v)
|
||||
}
|
||||
fn u16(&mut self) -> Result<u16, String> {
|
||||
pub(super) fn u16(&mut self) -> Result<u16, String> {
|
||||
self.array().map(u16::from_be_bytes)
|
||||
}
|
||||
fn u32(&mut self) -> Result<u32, String> {
|
||||
pub(super) fn u32(&mut self) -> Result<u32, String> {
|
||||
self.array().map(u32::from_be_bytes)
|
||||
}
|
||||
/// A section length: 4 bytes in a PSD, 8 in a PSB.
|
||||
fn length(&mut self, psb: bool) -> Result<usize, String> {
|
||||
pub(super) fn length(&mut self, psb: bool) -> Result<usize, String> {
|
||||
let n = if psb { self.array().map(u64::from_be_bytes)? } else { u64::from(self.u32()?) };
|
||||
usize::try_from(n).map_err(|_| TRUNCATED.to_string())
|
||||
}
|
||||
/// A section: its length, then that many bytes.
|
||||
fn section(&mut self, psb: bool) -> Result<&'a [u8], String> {
|
||||
pub(super) fn section(&mut self, psb: bool) -> Result<&'a [u8], String> {
|
||||
let n = self.length(psb)?;
|
||||
self.take(n)
|
||||
}
|
||||
fn rest(&self) -> &'a [u8] {
|
||||
pub(super) fn rest(&self) -> &'a [u8] {
|
||||
self.b.get(self.at..).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed-size header and the sections before the image data.
|
||||
struct Header<'a> {
|
||||
psb: bool,
|
||||
pub(super) struct Header<'a> {
|
||||
pub(super) psb: bool,
|
||||
channels: usize,
|
||||
width: u32,
|
||||
height: u32,
|
||||
depth: u16,
|
||||
mode: Mode,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
pub(super) depth: u16,
|
||||
pub(super) mode: Mode,
|
||||
/// Colour mode data: an indexed image's palette (256 reds, then greens, then blues).
|
||||
palette: &'a [u8],
|
||||
/// The image resources.
|
||||
resources: &'a [u8],
|
||||
/// The layer and mask information.
|
||||
layers: &'a [u8],
|
||||
pub(super) layers: &'a [u8],
|
||||
}
|
||||
|
||||
fn header<'a>(r: &mut Reader<'a>) -> Result<Header<'a>, String> {
|
||||
pub(super) fn header<'a>(r: &mut Reader<'a>) -> Result<Header<'a>, String> {
|
||||
if !is_psd(r.b) {
|
||||
return Err("not a PSD file".into());
|
||||
}
|
||||
@@ -183,41 +184,45 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
/// count is negative: in the layer info, or for 16- and 32-bit documents (whose layer info is
|
||||
/// empty) in their `Lr16` / `Lr32` block.
|
||||
fn merged_alpha(layers: &[u8], psb: bool) -> bool {
|
||||
let count_negative = |info: &[u8]| info.get(..2).is_some_and(|c| i16::from_be_bytes([c[0], c[1]]) < 0);
|
||||
layer_info(layers, psb).is_some_and(|info| info.get(..2).is_some_and(|c| i16::from_be_bytes([c[0], c[1]]) < 0))
|
||||
}
|
||||
|
||||
/// Do tagged blocks with this key give an 8-byte length in a PSB?
|
||||
pub(super) fn long_key(key: &[u8; 4]) -> bool {
|
||||
matches!(key, b"Lr16" | b"Lr32" | b"Layr" | b"LMsk" | b"Mt16" | b"Mt32" | b"Mtrn" | b"Alph" | b"FMsk" | b"lnk2" | b"FEid" | b"FXid" | b"PxSD")
|
||||
}
|
||||
|
||||
/// The layer info: the layer count, the layer records and their channels. It is the first part of
|
||||
/// the layer and mask information, or for 16- and 32-bit documents (whose first part is empty) the
|
||||
/// `Lr16` / `Lr32` block after it. `None` when the file has neither.
|
||||
pub(super) fn layer_info(layers: &[u8], psb: bool) -> Option<&[u8]> {
|
||||
let mut r = Reader::new(layers);
|
||||
let Ok(info) = r.section(psb) else { return false };
|
||||
let info = r.section(psb).ok()?;
|
||||
if !info.is_empty() {
|
||||
return count_negative(info);
|
||||
return Some(info);
|
||||
}
|
||||
// The global layer mask info, then tagged blocks: signature, key, length (8 bytes for the
|
||||
// layer blocks of a PSB), data padded to 4 bytes.
|
||||
if r.section(false).is_err() {
|
||||
return false;
|
||||
}
|
||||
r.section(false).ok()?;
|
||||
while r.rest().len() >= 12 {
|
||||
let (Ok(_), Ok(key)) = (r.take(4), r.array::<4>()) else { return false };
|
||||
let layer_block = matches!(&key, b"Lr16" | b"Lr32" | b"Layr");
|
||||
let long = layer_block || matches!(&key, b"LMsk" | b"Mt16" | b"Mt32" | b"Mtrn" | b"Alph" | b"FMsk" | b"lnk2" | b"FEid" | b"FXid" | b"PxSD");
|
||||
let Ok(data) = r.section(psb && long) else {
|
||||
return false;
|
||||
};
|
||||
if layer_block {
|
||||
return count_negative(data);
|
||||
}
|
||||
if r.take((4 - data.len() % 4) % 4).is_err() {
|
||||
return false;
|
||||
r.take(4).ok()?;
|
||||
let key = r.array::<4>().ok()?;
|
||||
let data = r.section(psb && long_key(&key)).ok()?;
|
||||
if matches!(&key, b"Lr16" | b"Lr32" | b"Layr") {
|
||||
return Some(data);
|
||||
}
|
||||
r.take((4 - data.len() % 4) % 4).ok()?;
|
||||
}
|
||||
false
|
||||
None
|
||||
}
|
||||
|
||||
/// The most bytes one channel row may take (a PSB at 32 bits).
|
||||
fn row_bytes(width: usize, depth: u16) -> usize {
|
||||
pub(super) fn row_bytes(width: usize, depth: u16) -> usize {
|
||||
if depth == 1 { width.div_ceil(8) } else { width * usize::from(depth / 8) }
|
||||
}
|
||||
|
||||
/// Undo PackBits: `src` into `dst` (a row; a short or overlong run leaves the rest as it is).
|
||||
fn unpack_bits(mut src: &[u8], dst: &mut [u8]) {
|
||||
pub(super) fn unpack_bits(mut src: &[u8], dst: &mut [u8]) {
|
||||
let mut at = 0;
|
||||
while let Some((&n, rest)) = src.split_first() {
|
||||
src = rest;
|
||||
@@ -269,32 +274,77 @@ fn channel_data(data: &[u8], h: &Header<'_>, channels: usize, row: usize) -> Res
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
2 | 3 => {
|
||||
let mut out = Vec::new();
|
||||
flate2::read::ZlibDecoder::new(r.rest())
|
||||
.take(wanted as u64)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| format!("the PSD file's image data can't be read: {e}"))?;
|
||||
if out.len() < wanted {
|
||||
return Err(TRUNCATED.into());
|
||||
}
|
||||
if method == 3 {
|
||||
// Each row stores the difference from the sample before it.
|
||||
match h.depth {
|
||||
8 => out.chunks_exact_mut(row).for_each(|r| (1..r.len()).for_each(|i| r[i] = r[i].wrapping_add(r[i - 1]))),
|
||||
16 => out.chunks_exact_mut(row).for_each(|r| {
|
||||
let mut prev = 0u16;
|
||||
for s in r.as_chunks_mut::<2>().0 {
|
||||
prev = prev.wrapping_add(u16::from_be_bytes(*s));
|
||||
*s = prev.to_be_bytes();
|
||||
}
|
||||
}),
|
||||
_ => return Err("zipped 32-bit PSD image data with prediction can't be read".into()),
|
||||
2 | 3 => inflate(r.rest(), wanted, method == 3, h.depth, row),
|
||||
m => Err(format!("the PSD file's image data uses an unknown compression ({m})")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `wanted` bytes of zipped image data, rows of `row` bytes; with `predicted`, each row stores the
|
||||
/// difference from the sample before it. In 32-bit data a row stores the first bytes of its samples,
|
||||
/// then their second bytes, and so on, and the differences run byte by byte along the whole row (as
|
||||
/// psd-tools, MIT, reads it: <https://github.com/psd-tools/psd-tools>, `compression/__init__.py`).
|
||||
fn inflate(src: &[u8], wanted: usize, predicted: bool, depth: u16, row: usize) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::new();
|
||||
flate2::read::ZlibDecoder::new(src)
|
||||
.take(wanted as u64)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| format!("the PSD file's image data can't be read: {e}"))?;
|
||||
if out.len() < wanted {
|
||||
return Err(TRUNCATED.into());
|
||||
}
|
||||
if predicted && row > 0 {
|
||||
match depth {
|
||||
8 => out.chunks_exact_mut(row).for_each(|r| (1..r.len()).for_each(|i| r[i] = r[i].wrapping_add(r[i - 1]))),
|
||||
16 => out.chunks_exact_mut(row).for_each(|r| {
|
||||
let mut prev = 0u16;
|
||||
for s in r.as_chunks_mut::<2>().0 {
|
||||
prev = prev.wrapping_add(u16::from_be_bytes(*s));
|
||||
*s = prev.to_be_bytes();
|
||||
}
|
||||
}),
|
||||
32 => {
|
||||
let w = row / 4;
|
||||
let mut planes = vec![0u8; row];
|
||||
for r in out.chunks_exact_mut(row) {
|
||||
let mut prev = 0u8;
|
||||
for (dst, &b) in planes.iter_mut().zip(r.iter()) {
|
||||
prev = prev.wrapping_add(b);
|
||||
*dst = prev;
|
||||
}
|
||||
for (x, s) in r.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
for (k, v) in s.iter_mut().enumerate() {
|
||||
*v = planes.get(k * w + x).copied().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1-bit.
|
||||
_ => return Err("zipped 1-bit PSD image data with prediction can't be read".into()),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One channel of a layer: its compression method, then `rows` rows of `row` bytes (PackBits rows
|
||||
/// after their byte counts, 4 bytes each in a PSB).
|
||||
pub(super) fn layer_channel(data: &[u8], rows: usize, row: usize, psb: bool, depth: u16) -> Result<Vec<u8>, String> {
|
||||
let mut r = Reader::new(data);
|
||||
let method = r.u16()?;
|
||||
let wanted = rows.checked_mul(row).ok_or(TRUNCATED)?;
|
||||
match method {
|
||||
0 => r.take(wanted).map(<[u8]>::to_vec),
|
||||
1 => {
|
||||
let size = if psb { 4 } else { 2 };
|
||||
let counts = r.take(rows.checked_mul(size).ok_or(TRUNCATED)?)?;
|
||||
let mut out = vec![0; wanted];
|
||||
for (dst, n) in out.chunks_exact_mut(row.max(1)).zip(counts.chunks_exact(size)) {
|
||||
let n = n.iter().fold(0usize, |n, &b| n << 8 | usize::from(b));
|
||||
unpack_bits(r.take(n)?, dst);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
m => Err(format!("the PSD file's image data uses an unknown compression ({m})")),
|
||||
2 | 3 => inflate(r.rest(), wanted, method == 3, depth, row),
|
||||
m => Err(format!("the PSD file's layer data uses an unknown compression ({m})")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +354,7 @@ fn srgb_encode(v: f32) -> f32 {
|
||||
}
|
||||
|
||||
/// Sample `x` of a channel row, 0..1.
|
||||
fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 {
|
||||
pub(super) fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 {
|
||||
match depth {
|
||||
1 => {
|
||||
// Bitmap: a set bit is black.
|
||||
@@ -321,10 +371,25 @@ fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
fn byte(v: f32) -> u8 {
|
||||
pub(super) fn byte(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
/// The sRGB color of a pixel whose color channels `v` gives (0..1, as stored), in `h`'s mode.
|
||||
pub(super) fn to_rgb(h: &Header<'_>, v: impl Fn(usize) -> f32, cmyk: &impl Fn([f32; 4]) -> [f32; 3]) -> [f32; 3] {
|
||||
match h.mode {
|
||||
Mode::Bitmap | Mode::Gray => [v(0); 3],
|
||||
Mode::Indexed => {
|
||||
let i = usize::from(byte(v(0)));
|
||||
[0, 256, 512].map(|at| h.palette.get(at + i).map_or(0.0, |&c| f32::from(c) / 255.0))
|
||||
}
|
||||
Mode::Rgb => [v(0), v(1), v(2)],
|
||||
// Inks are stored as 1 − amount.
|
||||
Mode::Cmyk => cmyk([0, 1, 2, 3].map(|c| 1.0 - v(c))),
|
||||
Mode::Lab => lab_to_srgb(Lab { l: v(0) * 100.0, a: v(1) * 255.0 - 128.0, b: v(2) * 255.0 - 128.0 }),
|
||||
}
|
||||
}
|
||||
|
||||
/// A Photoshop document's merged image as straight RGBA. CMYK converts through `cmyk` (ink
|
||||
/// amounts 0..1 → sRGB). Fails, without allocating, when the image would need more than
|
||||
/// `max_alloc` bytes.
|
||||
@@ -348,17 +413,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64, cmyk: impl Fn([f32; 4]) -> [f32; 3])
|
||||
let planes: Vec<&[u8]> = (0..channels).map(|c| data.get((c * rows + y) * row..(c * rows + y + 1) * row).unwrap_or_default()).collect();
|
||||
for (x, px) in line.enumerate() {
|
||||
let v = |c: usize| planes.get(c).map_or(0.0, |p| sample(p, x, h.depth, linear && c < colors));
|
||||
let rgb = match h.mode {
|
||||
Mode::Bitmap | Mode::Gray => [v(0); 3],
|
||||
Mode::Indexed => {
|
||||
let i = usize::from(byte(v(0)));
|
||||
[0, 256, 512].map(|at| h.palette.get(at + i).map_or(0.0, |&c| f32::from(c) / 255.0))
|
||||
}
|
||||
Mode::Rgb => [v(0), v(1), v(2)],
|
||||
// Inks are stored as 1 − amount.
|
||||
Mode::Cmyk => cmyk([0, 1, 2, 3].map(|c| 1.0 - v(c))),
|
||||
Mode::Lab => lab_to_srgb(Lab { l: v(0) * 100.0, a: v(1) * 255.0 - 128.0, b: v(2) * 255.0 - 128.0 }),
|
||||
};
|
||||
let rgb = to_rgb(&h, v, &cmyk);
|
||||
let a = if alpha { v(colors) } else { 1.0 };
|
||||
// The merged image is stored on white: take the white back out.
|
||||
let straight = |c: f32| if a <= 0.0 { 0.0 } else { (c - (1.0 - a)) / a };
|
||||
|
||||
@@ -267,6 +267,66 @@ mod native {
|
||||
synth::container(&[("doc.dat", &doc, method)], Some(&png()))
|
||||
}
|
||||
|
||||
/// A version-12 document at 144 ppi holding a 100 px square filled with a red-to-blue conical
|
||||
/// gradient centered at (50, 40) px, 40 px across: its unit +y points down the page, or up
|
||||
/// (`mirror`).
|
||||
fn conical_document(mirror: bool) -> Vec<u8> {
|
||||
let stop = |id: u32, c: F| F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), c)]);
|
||||
let positions = [[0.0f32, 0.5], [1.0, 0.5]].iter().map(|p| p.iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
|
||||
let grad = F::Obj(
|
||||
tag(b"Grad"),
|
||||
vec![
|
||||
(tag(b"Cols"), F::Shared(vec![stop(23, rgba(1.0, 0.0, 0.0, 1.0)), stop(24, rgba(0.0, 0.0, 1.0, 1.0))])),
|
||||
(tag(b"Posn"), F::Records(8, positions)),
|
||||
],
|
||||
);
|
||||
let fill = F::Def(22, vec![tag(b"FilG")], vec![(tag(b"Type"), F::Enum(3, 0)), (tag(b"Grad"), grad)]);
|
||||
let unit = F::F64s(vec![40.0, 0.0, 50.0, 0.0, if mirror { -40.0 } else { 40.0 }, 40.0]);
|
||||
let gradient = F::Def(21, vec![tag(b"FDsc")], vec![(tag(b"FDeX"), unit), (tag(b"FDeF"), fill)]);
|
||||
let square = F::Def(
|
||||
20,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
(tag(b"Shpe"), F::Def(25, vec![tag(b"ShNR")], vec![])),
|
||||
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 100.0, 100.0])),
|
||||
(tag(b"BFFl"), F::Shared(vec![gradient])),
|
||||
],
|
||||
);
|
||||
let spread =
|
||||
F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 200.0])), (tag(b"Chld"), F::Shared(vec![square]))]);
|
||||
let doc = synth::stream(&[
|
||||
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(144.0))])),
|
||||
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])),
|
||||
]);
|
||||
synth::container(&[("doc.dat", &doc, Method::Stored)], Some(&png()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conical_gradients_open_as_conic_gradients() {
|
||||
use vectorcraft_color::{GradientKind, Paint};
|
||||
for mirror in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let r = open_bytes(&mut s, "conic.af", &conical_document(mirror), None).unwrap();
|
||||
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("conical")), "{r}");
|
||||
let mut paint = None;
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if let Paint::Gradient(g) = n.appearance.fill_paint() {
|
||||
paint = Some(g.clone());
|
||||
}
|
||||
});
|
||||
let g = paint.expect("a gradient fill");
|
||||
assert_eq!(g.gradient.kind, GradientKind::Conic);
|
||||
let geom = g.geom.unwrap();
|
||||
// At 144 ppi: centered at (25, 20) pt, 20 pt across, red where the sweep starts.
|
||||
assert!(geom.start.distance(kurbo::Point::new(25.0, 20.0)) < 1e-9 && (geom.length() - 20.0).abs() < 1e-9, "{geom:?}");
|
||||
assert_eq!(geom.is_mirrored(), mirror, "{geom:?}");
|
||||
// A quarter turn on lies along the unit +y: below the center, or above it mirrored.
|
||||
let quarter = kurbo::Point::new(25.0, if mirror { 10.0 } else { 30.0 });
|
||||
assert!((geom.param_at(GradientKind::Conic, quarter) - 0.25).abs() < 1e-9, "{geom:?}");
|
||||
assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_artboards_curves_and_paints_open_as_editable_art() {
|
||||
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
|
||||
|
||||
@@ -218,6 +218,106 @@ fn placed_images_are_written_and_previews_are_black_and_white_on_request() {
|
||||
assert!(l3.contains("/FlateDecode filter >> image"));
|
||||
}
|
||||
|
||||
/// The PNG thumbnail of an EPS file, decoded.
|
||||
fn thumbnail_png(bytes: &[u8]) -> image::RgbaImage {
|
||||
let png = vectorcraft_eps::thumbnail(bytes).expect("a thumbnail");
|
||||
image::load_from_memory_with_format(&png, image::ImageFormat::Png).unwrap().to_rgba8()
|
||||
}
|
||||
|
||||
/// The TIFF preview of an EPS file, decoded.
|
||||
fn preview_tiff(bytes: &[u8]) -> image::RgbaImage {
|
||||
let tiff = vectorcraft_eps::sections(bytes).unwrap().1.expect("a preview");
|
||||
image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).unwrap().to_rgba8()
|
||||
}
|
||||
|
||||
/// The size of a thumbnail render of the rect an EPS file's preview shows (its bounding box).
|
||||
fn thumbnail_size(bytes: &[u8]) -> (u32, u32) {
|
||||
let b: Vec<f64> = dsc(&postscript(bytes), "BoundingBox").unwrap().split(' ').map(|v| v.parse().unwrap()).collect();
|
||||
let rect = vectorcraft_geom::Rect::new(b[0], b[1], b[2], b[3]);
|
||||
let long = rect.width().max(rect.height());
|
||||
vectorcraft_render::raster_size(rect, if long > 128.0 { 128.0 / long } else { 1.0 }).unwrap()
|
||||
}
|
||||
|
||||
/// The thumbnail is the color preview made smaller, the area average of its pixels at the size a
|
||||
/// 128 px render has, not a second render of the document; it shows the same picture as one.
|
||||
#[test]
|
||||
fn the_thumbnail_is_made_from_the_preview() {
|
||||
let mut s = session(1000.0, 700.0, 1);
|
||||
s.execute("paint.setFill", &json!({"color": "#2060c0"})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": 37.3, "y": 41.9, "width": 811.7, "height": 563.1})).unwrap();
|
||||
let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": "tiffColor", "transparentPreview": false})).unwrap();
|
||||
let bytes = b64(&r);
|
||||
let preview = preview_tiff(&bytes);
|
||||
assert_eq!(preview.dimensions(), (1000, 700));
|
||||
let thumb = thumbnail_png(&bytes);
|
||||
assert_eq!(thumb.dimensions(), (128, 90));
|
||||
// An opaque preview's straight pixels are its premultiplied ones.
|
||||
let shown = vectorcraft_render::Rendered { width: 1000, height: 700, pixels: preview.into_raw() };
|
||||
assert!(thumb.as_raw() == &shown.area_resized(128, 90).unwrap().pixels, "the thumbnail isn't the preview made smaller");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let fresh = vectorcraft_render::Renderer::new().render_region(doc, vectorcraft_geom::Rect::new(0.0, 0.0, 1000.0, 700.0), 0.128, true);
|
||||
let mut diffs: Vec<u8> = thumb.as_raw().iter().zip(&fresh.pixels).map(|(a, b)| a.abs_diff(*b)).collect();
|
||||
diffs.sort_unstable();
|
||||
let mean = diffs.iter().map(|d| f64::from(*d)).sum::<f64>() / diffs.len() as f64;
|
||||
let p95 = diffs[diffs.len() * 95 / 100];
|
||||
assert!(mean <= 8.0 && p95 <= 32, "mean {mean}, 95th percentile {p95}");
|
||||
}
|
||||
|
||||
/// A thumbnail is opaque color on white whatever the preview: a transparent color preview's, and a
|
||||
/// black-and-white preview's, which keeps the art's colors.
|
||||
#[test]
|
||||
fn thumbnails_of_transparent_and_black_and_white_previews_are_opaque_color() {
|
||||
let mut s = session(400.0, 200.0, 1);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let fresh = vectorcraft_render::Renderer::new().render_region(doc, vectorcraft_geom::Rect::new(0.0, 0.0, 400.0, 200.0), 0.32, true);
|
||||
let red: [u8; 4] = fresh.pixels[(32 * 128 + 10) * 4..][..4].try_into().unwrap();
|
||||
for preview in ["tiffColor", "tiffBw"] {
|
||||
let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": preview})).unwrap();
|
||||
let thumb = thumbnail_png(&b64(&r));
|
||||
assert_eq!(thumb.dimensions(), (128, 64), "{preview}");
|
||||
assert_eq!(thumb.get_pixel(10, 32).0, red, "{preview}");
|
||||
assert_eq!(thumb.get_pixel(120, 32).0, [255, 255, 255, 255], "{preview}");
|
||||
assert!(thumb.pixels().all(|p| p[3] == 255), "{preview}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A page that fits in a thumbnail gets its preview's own pixels, unresampled.
|
||||
#[test]
|
||||
fn a_small_page_s_thumbnail_is_its_preview() {
|
||||
let mut s = session(100.0, 60.0, 1);
|
||||
s.execute("shape.ellipse", &json!({"x": 10.5, "y": 7.25, "width": 70, "height": 41})).unwrap();
|
||||
let bytes = b64(&s.execute("document.exportEps", &json!({"useArtboards": true, "transparentPreview": false})).unwrap());
|
||||
let thumb = thumbnail_png(&bytes);
|
||||
assert_eq!(thumb.dimensions(), (100, 60));
|
||||
assert!(thumb == preview_tiff(&bytes), "the thumbnail isn't the preview");
|
||||
}
|
||||
|
||||
/// Every file has a thumbnail at the size a thumbnail render has, with any page shape, on Save
|
||||
/// too and without a preview; with thumbnails off there is none.
|
||||
#[test]
|
||||
fn every_file_s_thumbnail_has_the_thumbnail_size() {
|
||||
let mut s = session(333.3, 77.7, 2);
|
||||
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
|
||||
s.execute("shape.rectangle", &json!({"x": 3.7, "y": 5.1, "width": 300, "height": 61.3})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": second + 1.0, "y": 2.0, "width": 20, "height": 70})).unwrap();
|
||||
for preview in ["tiffColor", "tiffBw", "none"] {
|
||||
let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": preview})).unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 3);
|
||||
for f in files {
|
||||
let bytes = b64(f);
|
||||
assert_eq!(thumbnail_png(&bytes).dimensions(), thumbnail_size(&bytes), "{preview} {}", f["name"]);
|
||||
}
|
||||
}
|
||||
assert_eq!(thumbnail_size(&b64(&s.execute("document.exportEps", &json!({"range": "1"})).unwrap())), (128, 30));
|
||||
let saved = b64(&s.execute("document.save", &json!({"format": "eps", "previewFormat": "tiffColor"})).unwrap());
|
||||
assert_eq!(thumbnail_png(&saved).dimensions(), thumbnail_size(&saved));
|
||||
let r = s.execute("document.exportEps", &json!({"useArtboards": true, "thumbnails": false})).unwrap();
|
||||
assert!(r["files"].as_array().unwrap().iter().all(|f| vectorcraft_eps::thumbnail(&b64(f)).is_none()));
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings`
|
||||
/// holds the note raster exports give, once, without naming either image's key.
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,31 @@ fn packbits_zip_and_psb_files_read() {
|
||||
zip.extend(z.finish().unwrap());
|
||||
let px = pixels(&psd(1, 1, (3, 1), 16, 1, &[], &[], &[], &zip));
|
||||
assert_eq!(px.iter().map(|p| p[0]).collect::<Vec<_>>(), [16, 32, 48]);
|
||||
// Zipped with prediction, 32 bits: the row's first bytes, then its second bytes and so on,
|
||||
// each byte stored as the difference from the byte before it.
|
||||
let samples = [0.0f32, 1.0, 0.0].map(f32::to_be_bytes);
|
||||
let planes: Vec<u8> = (0..4).flat_map(|k| samples.iter().map(move |s| s[k])).collect();
|
||||
let deltas: Vec<u8> = planes.iter().scan(0u8, |prev, &b| Some(b.wrapping_sub(std::mem::replace(prev, b)))).collect();
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut z, &deltas).unwrap();
|
||||
let mut zip = vec![0, 3];
|
||||
zip.extend(z.finish().unwrap());
|
||||
let px = pixels(&psd(1, 1, (3, 1), 32, 1, &[], &[], &[], &zip));
|
||||
assert_eq!(px.iter().map(|p| p[0]).collect::<Vec<_>>(), [0, 255, 0]);
|
||||
// The same layout as another implementation writes it: psd-tools 1.26.1 (MIT,
|
||||
// <https://github.com/psd-tools/psd-tools>), `compression.compress(data,
|
||||
// Compression.ZIP_WITH_PREDICTION, 3, 2, 32)` of the samples 0, 1, 0.25 and 0.5, 0.125, 1,
|
||||
// reads as the same samples stored raw.
|
||||
let theirs = [
|
||||
0x78, 0x9c, 0x63, 0xb0, 0xff, 0x7f, 0xa8, 0x81, 0xa1, 0x81, 0x01, 0x04, 0xec, 0xff, 0x33, 0x1e, 0x64, 0x68, 0x80, 0xb0, 0x01, 0x59, 0xd1,
|
||||
0x06, 0x01,
|
||||
];
|
||||
let mut zip = vec![0, 3];
|
||||
zip.extend(theirs);
|
||||
let samples: Vec<u8> = [0.0f32, 1.0, 0.25, 0.5, 0.125, 1.0].iter().flat_map(|v| v.to_be_bytes()).collect();
|
||||
let stored = pixels(&psd(1, 1, (3, 2), 32, 1, &[], &[], &[], &raw(&[&samples])));
|
||||
assert_eq!(pixels(&psd(1, 1, (3, 2), 32, 1, &[], &[], &[], &zip)), stored);
|
||||
assert_eq!(stored.iter().map(|p| p[0]).collect::<Vec<_>>()[..2], [0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear or radial, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right), aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial or conic, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial and conic: the vector turns about the center), aspect?: % (radial and conic; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial or conic gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient Vector",
|
||||
[],
|
||||
None,
|
||||
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial: the extent ellipse's height / width; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)",
|
||||
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial and conic: the extent ellipse's height / width, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)",
|
||||
has_doc,
|
||||
set_gradient_geom
|
||||
),
|
||||
@@ -118,7 +118,7 @@ type Parsed<T> = std::result::Result<T, String>;
|
||||
|
||||
/// Parse a kind name.
|
||||
fn parse_kind(k: &str) -> Parsed<GradientKind> {
|
||||
GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)"))
|
||||
GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, conic, freeform)"))
|
||||
}
|
||||
|
||||
/// The `method` param of `p`, if given: how the colours between the stops mix.
|
||||
@@ -274,9 +274,16 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
|
||||
gp.angle = f64_or(g, "angle", 0.0);
|
||||
gp.swatch = str_param(g, "swatch").map(str::to_string);
|
||||
let geom = GeomEdit::parse(g)?;
|
||||
let aspect = geom.aspect.map(|a| aspect_for(kind, a)).transpose()?;
|
||||
if kind == GradientKind::Conic {
|
||||
no_focal(&geom)?;
|
||||
}
|
||||
if let Some((start, end)) = geom.vector {
|
||||
let mut g = GradientGeom { start, end, aspect: geom.aspect.unwrap_or(1.0), focal: None };
|
||||
g.set_focal(geom.focal.flatten());
|
||||
let mut g = GradientGeom { start, end, aspect: aspect.unwrap_or(1.0), focal: None };
|
||||
// Only a radial gradient has a focal point (as `paint.setGradientGeom` keeps it).
|
||||
if kind == GradientKind::Radial {
|
||||
g.set_focal(geom.focal.flatten());
|
||||
}
|
||||
gp.angle = g.angle_deg();
|
||||
gp.geom = Some(g);
|
||||
}
|
||||
@@ -286,13 +293,36 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
|
||||
Ok(gp)
|
||||
}
|
||||
|
||||
/// The `aspect` param (a percentage) as a ratio.
|
||||
/// The `aspect` param (a percentage) as a ratio of 0.005 to 327.67, its sign kept: a negative one
|
||||
/// mirrors a conic gradient's sweep ([`aspect_for`]).
|
||||
fn aspect_param(p: &Value) -> Parsed<Option<f64>> {
|
||||
p.get("aspect")
|
||||
.map(|v| v.as_f64().map(|a| (a / 100.0).clamp(0.005, 327.67)).ok_or_else(|| "`aspect` must be a number (%)".to_string()))
|
||||
.map(|v| {
|
||||
v.as_f64()
|
||||
.filter(|a| a.is_finite())
|
||||
.map(|a| (a.abs() / 100.0).clamp(0.005, 327.67).copysign(a))
|
||||
.ok_or_else(|| "`aspect` must be a number (%)".to_string())
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Aspect ratio `a` ([`aspect_param`]) for a gradient of `kind`: a negative one mirrors a conic's
|
||||
/// sweep, and is an error for the other kinds.
|
||||
fn aspect_for(kind: GradientKind, a: f64) -> Parsed<f64> {
|
||||
if a < 0.0 && kind != GradientKind::Conic {
|
||||
return Err("a negative `aspect` applies to conic gradients".into());
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// An error when `e` gives a focal point: a conic gradient has none (`null` is allowed).
|
||||
fn no_focal(e: &GeomEdit) -> Parsed<()> {
|
||||
match e.focal {
|
||||
Some(Some(_)) => Err("`focal` applies to radial gradients".into()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `paint` without the placement it had on the art it came from (a swatch, or the default paint
|
||||
/// for new art): a placed gradient keeps its angle and fits each object it lands on (freeform
|
||||
/// points are placed afresh, coloured along the stops, which follow the points' colours).
|
||||
@@ -400,7 +430,7 @@ fn apply_gradient_edit_in_model(
|
||||
if let Some(g) = &mut gp.geom {
|
||||
let r = a.to_radians();
|
||||
let dir = vectorcraft_geom::Vec2::new(r.cos(), -r.sin());
|
||||
if gp.gradient.kind == GradientKind::Radial {
|
||||
if gp.gradient.kind.is_centered() {
|
||||
g.end = g.start + dir * g.length();
|
||||
} else {
|
||||
let c = g.start.midpoint(g.end);
|
||||
@@ -411,6 +441,7 @@ fn apply_gradient_edit_in_model(
|
||||
}
|
||||
}
|
||||
if let Some(asp) = aspect_param(p)? {
|
||||
let asp = aspect_for(gp.gradient.kind, asp)?;
|
||||
if gp.geom.is_none()
|
||||
&& let Some(b) = bounds
|
||||
{
|
||||
@@ -472,7 +503,20 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = &link_stops(&s.doc()?.doc, p).map_err(|e| bad(C, e))?;
|
||||
// Validate against the defaults first so bad params fail without touching the document.
|
||||
let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() };
|
||||
let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(p, &default_paint, &stored), None, &|_| true, model)
|
||||
// A negative aspect for selected conic gradients gives a default of another kind its size.
|
||||
let default_kind = str_param(p, "kind").and_then(GradientKind::parse).or(match &default_paint {
|
||||
Paint::Gradient(g) => Some(g.gradient.kind),
|
||||
_ => None,
|
||||
});
|
||||
let for_default = match p.get("aspect").and_then(Value::as_f64) {
|
||||
Some(a) if a < 0.0 && !ids.is_empty() && default_kind != Some(GradientKind::Conic) => {
|
||||
let mut q = p.clone();
|
||||
q["aspect"] = json!(-a);
|
||||
std::borrow::Cow::Owned(q)
|
||||
}
|
||||
_ => std::borrow::Cow::Borrowed(p),
|
||||
};
|
||||
let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model)
|
||||
.map_err(|e| bad(C, e))?;
|
||||
let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform);
|
||||
edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| {
|
||||
@@ -599,11 +643,23 @@ impl GeomEdit {
|
||||
|
||||
/// `cur` with the vector, aspect and focal point of `e` (given in the document). `to_doc` maps
|
||||
/// the paint's space (text space for type runs) to the document and `bounds` (in that space) fits
|
||||
/// an unplaced gradient, so what the document shows carries over where `e` keeps it.
|
||||
fn vector_paint(cur: &Paint, e: &GeomEdit, to_doc: Affine, bounds: Option<Rect>, model: Option<vectorcraft_color::cms::Model>) -> Option<Paint> {
|
||||
let from_doc = invert(to_doc)?;
|
||||
/// an unplaced gradient, so what the document shows carries over where `e` keeps it. An error
|
||||
/// when `e` gives what the gradient's kind can't take (a negative aspect other than a conic's, or
|
||||
/// a conic's focal point).
|
||||
fn vector_paint(
|
||||
cur: &Paint,
|
||||
e: &GeomEdit,
|
||||
to_doc: Affine,
|
||||
bounds: Option<Rect>,
|
||||
model: Option<vectorcraft_color::cms::Model>,
|
||||
) -> Parsed<Option<Paint>> {
|
||||
let mut gp = gradient_for_edit(cur, model);
|
||||
let kind = gp.gradient.kind;
|
||||
let edit_aspect = e.aspect.map(|a| aspect_for(kind, a)).transpose()?;
|
||||
if kind == GradientKind::Conic {
|
||||
no_focal(e)?;
|
||||
}
|
||||
let Some(from_doc) = invert(to_doc) else { return Ok(None) };
|
||||
let map = |mut g: GradientGeom, a: Affine| {
|
||||
if a != Affine::IDENTITY {
|
||||
g.transform(a, kind);
|
||||
@@ -611,8 +667,8 @@ fn vector_paint(cur: &Paint, e: &GeomEdit, to_doc: Affine, bounds: Option<Rect>,
|
||||
g
|
||||
};
|
||||
let shown = gp.geom.or_else(|| bounds.map(|b| gp.resolve(b))).map(|g| map(g, to_doc));
|
||||
let (start, end) = e.vector.or_else(|| shown.map(|g| (g.start, g.end)))?;
|
||||
let aspect = e.aspect.or_else(|| shown.map(|g| g.aspect)).unwrap_or(1.0);
|
||||
let Some((start, end)) = e.vector.or_else(|| shown.map(|g| (g.start, g.end))) else { return Ok(None) };
|
||||
let aspect = edit_aspect.or_else(|| shown.map(|g| g.aspect)).unwrap_or(1.0);
|
||||
let mut geom = GradientGeom { start, end, aspect, focal: None };
|
||||
match (e.focal, shown) {
|
||||
_ if kind != GradientKind::Radial => {}
|
||||
@@ -623,7 +679,7 @@ fn vector_paint(cur: &Paint, e: &GeomEdit, to_doc: Affine, bounds: Option<Rect>,
|
||||
let geom = map(geom, from_doc);
|
||||
gp.angle = geom.angle_deg();
|
||||
gp.geom = Some(geom);
|
||||
Some(Paint::Gradient(Box::new(gp)))
|
||||
Ok(Some(Paint::Gradient(Box::new(gp))))
|
||||
}
|
||||
|
||||
fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -658,14 +714,15 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
run_stroke_weight(&mut r.style);
|
||||
}
|
||||
let (paint, b) = run_paint_mut(r, stroke, lb);
|
||||
if let Some(np) = vector_paint(paint, &edit, xf, Some(b), model) {
|
||||
if let Some(np) = vector_paint(paint, &edit, xf, Some(b), model).map_err(|e| bad(C, e))? {
|
||||
*paint = np;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let b = item_paint_bounds(n, index, !stroke);
|
||||
if let Some(np) = vector_paint(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), &edit, Affine::IDENTITY, b, model) {
|
||||
let cur = n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None);
|
||||
if let Some(np) = vector_paint(cur, &edit, Affine::IDENTITY, b, model).map_err(|e| bad(C, e))? {
|
||||
n.appearance.set_paint_at(index, !stroke, np);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -17,7 +17,9 @@ use vectorcraft_doc::{
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
|
||||
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, named_id, ok, str_param};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, cmd, f64_req, has_doc, has_selection, named_id, number_or, ok, str_param, switch_or, text_param,
|
||||
};
|
||||
|
||||
mod axis;
|
||||
mod columns;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{GraphSpec, Value};
|
||||
use super::{GraphSpec, Result, Value, bad};
|
||||
|
||||
pub(super) const LABEL_SIZE: f64 = 9.0;
|
||||
|
||||
@@ -22,6 +22,16 @@ pub(super) fn count_param(p: &Value, key: &str, max: u64) -> Option<usize> {
|
||||
usize::try_from(n.min(max)).ok()
|
||||
}
|
||||
|
||||
/// [`count_param`], or `None` when `p` leaves `key` out or gives null. A negative number is an error, and so is any
|
||||
/// other value, such as `"5"`.
|
||||
pub(super) fn count_given(p: &Value, key: &str, max: u64, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) if !v.is_number() => Err(bad(cmd, format!("`{key}` must be a number, not {v}"))),
|
||||
Some(_) => count_param(p, key, max).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be 0 or more"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A "nice" axis: (min, max, step).
|
||||
pub(super) fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) {
|
||||
let (lo, hi) = if (hi - lo).abs() < 1e-12 { (lo.min(0.0), lo.max(0.0) + 1.0) } else { (lo, hi) };
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Graph",
|
||||
[],
|
||||
None,
|
||||
"{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[number|null]], csv?} → {id}; an empty CSV cell or a null is a blank value, a number in straight quotes is a label",
|
||||
"{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[number|null]], csv?} → {id}; an empty CSV cell or a null is a blank value, a number in straight quotes is a label; a `type` that is not a string, a `width` or `height` that is not a number, and data of the wrong type (as `graph.setData` reads it) are refused",
|
||||
has_doc,
|
||||
create
|
||||
),
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Data…",
|
||||
["Object", "Graph"],
|
||||
None,
|
||||
"{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty); then one row per category: label; values…; an empty cell or a null is a blank value; a quoted number a label; a `|` in a label a line break, transpose?: true swaps rows and columns (Transpose row/column), switchXY?: true swaps each scatter series' y and x columns (Switch x/y)} replace the graph's data; no data → the current {csv, series, categories, rows}",
|
||||
"{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty); then one row per category: label; values…; an empty cell or a null is a blank value; a quoted number a label; a `|` in a label a line break, transpose?: true swaps rows and columns (Transpose row/column), switchXY?: true swaps each scatter series' y and x columns (Switch x/y)} replace the graph's data; a `csv` that is not a string, `series` or `categories` that are not lists, `rows` that are not lists of numbers and nulls, or a `transpose` or `switchXY` that is not true or false is refused and the graph stays as it was; no data → the current {csv, series, categories, rows}",
|
||||
has_selection,
|
||||
set_data
|
||||
),
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Type…",
|
||||
["Object", "Graph"],
|
||||
None,
|
||||
"{id?, type?, seriesIndexes?: [index], columnWidth?: % (1–1000; past 100 columns overlap), clusterWidth?: % (1–1000), legend?: bool, markPoints?: bool, connectPoints?: bool, edgeToEdge?: bool (line graphs: true runs the lines across the whole plot, false puts the points at the centres of their categories), ticks?: n, axisMin?, axisMax?, valueAxis?: left|right|both (the bar graphs' value axis stays along the bottom), separateScales?: bool, rightTicks?: n, rightAxisMin?, rightAxisMax?, tickLength?: none|short|full, tickMarks?: n per division, rightTickLength?, rightTickMarks?, categoryTickLength?: none|short|full, categoryTickMarks?: n, ticksBetweenLabels?: bool, prefix?, suffix?, rightPrefix?, rightSuffix?: text around the value axis numbers, firstRowInFront?: bool, firstColumnInFront?: bool (which overlapping columns are drawn on top: the first category over the next within each series, the first series over the others), filledLines?: bool (Draw Filled Lines: connecting lines as 3 pt bands filled with the series colour), dropShadow?: bool (Add Drop Shadow: a translucent black shadow down and right of the columns, bars, lines and pie wedges), legendAcrossTop?: bool (Add Legend Across Top: the legend in rows above the plot), pieLegend?: none|standard|wedges (Legends in Wedges: the series labels inside their wedges), piePosition?: ratio|even|stacked (several pies sized by their totals, at one size (the default), or stacked), pieSort?: all|first|none (wedges largest first in each pie, in the first pie's order, or in data order (the default)); the query lists the pie options for pie graphs} change the graph type and options; with `seriesIndexes`, or (no `id`) with only series selected with Group Selection, `type` goes to those series only and `valueAxis` (left|right; both is refused) puts them on that value axis, while the other options still apply to the whole graph (Combine different graph types: column, stacked column, line and area mix, and so do bar and stacked bar; a series given the graph's type follows the graph again) (axisMin and axisMax together override the calculated value axis: exactly that range in `ticks` divisions, 5 when 0; with the value axis on both sides, separateScales gives the series on the right axis a scale of their own, set the same way with rightTicks, rightAxisMin and rightAxisMax); no options → the current ones",
|
||||
"{id?, type?, seriesIndexes?: [index], columnWidth?: % (1–1000; past 100 columns overlap), clusterWidth?: % (1–1000), legend?: bool, markPoints?: bool, connectPoints?: bool, edgeToEdge?: bool (line graphs: true runs the lines across the whole plot, false puts the points at the centres of their categories), ticks?: n, axisMin?: number|null (null = calculated), axisMax?: number|null, valueAxis?: left|right|both (the bar graphs' value axis stays along the bottom), separateScales?: bool, rightTicks?: n, rightAxisMin?, rightAxisMax?, tickLength?: none|short|full, tickMarks?: n per division, rightTickLength?, rightTickMarks?, categoryTickLength?: none|short|full, categoryTickMarks?: n, ticksBetweenLabels?: bool, prefix?, suffix?, rightPrefix?, rightSuffix?: text around the value axis numbers, firstRowInFront?: bool, firstColumnInFront?: bool (which overlapping columns are drawn on top: the first category over the next within each series, the first series over the others), filledLines?: bool (Draw Filled Lines: connecting lines as 3 pt bands filled with the series colour), dropShadow?: bool (Add Drop Shadow: a translucent black shadow down and right of the columns, bars, lines and pie wedges), legendAcrossTop?: bool (Add Legend Across Top: the legend in rows above the plot), pieLegend?: none|standard|wedges (Legends in Wedges: the series labels inside their wedges), piePosition?: ratio|even|stacked (several pies sized by their totals, at one size (the default), or stacked), pieSort?: all|first|none (wedges largest first in each pie, in the first pie's order, or in data order (the default)); the query lists the pie options for pie graphs} change the graph type and options; with `seriesIndexes`, or (no `id`) with only series selected with Group Selection, `type` goes to those series only and `valueAxis` (left|right; both is refused) puts them on that value axis, while the other options still apply to the whole graph (Combine different graph types: column, stacked column, line and area mix, and so do bar and stacked bar; a series given the graph's type follows the graph again) (axisMin and axisMax together override the calculated value axis: exactly that range in `ticks` divisions, 5 when 0; with the value axis on both sides, separateScales gives the series on the right axis a scale of their own, set the same way with rightTicks, rightAxisMin and rightAxisMax); a value of the wrong type, such as \"10\" for a number or \"true\" for a bool, or a negative count, is refused and the graph stays as it was; a null is read as a key left out, except that a null axis bound goes back to the calculated value; no options → the current ones",
|
||||
has_selection,
|
||||
set_type
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::edit::{regenerate, spec_of, target};
|
||||
use super::series::{switch_xy, transpose};
|
||||
use super::{Document, EngineError, GraphKind, GraphSpec, NodeId, Result, Session, Value, bad, bool_or, json, str_param};
|
||||
use super::{Document, EngineError, GraphKind, GraphSpec, NodeId, Result, Session, Value, bad, json, switch_or, text_param};
|
||||
|
||||
// ---------- data ----------
|
||||
|
||||
@@ -101,7 +101,7 @@ pub(crate) fn apply_csv(d: &mut Document, id: NodeId, csv: &str) -> Result<()> {
|
||||
return Err(bad("graph.applyCsv", format!("node {} is not a graph", id.0)));
|
||||
};
|
||||
let mut spec = current.clone();
|
||||
apply_data(&mut spec, &json!({ "csv": csv }));
|
||||
apply_data(&mut spec, &json!({ "csv": csv }), "graph.applyCsv")?;
|
||||
keep_right_series(&mut spec);
|
||||
if spec == *current {
|
||||
return Ok(());
|
||||
@@ -115,34 +115,58 @@ fn keep_right_series(g: &mut GraphSpec) {
|
||||
g.right_series.retain(|i| *i < count);
|
||||
}
|
||||
|
||||
pub(super) fn apply_data(g: &mut GraphSpec, p: &Value) -> bool {
|
||||
/// Apply the data `p` gives (`csv`, `series`, `categories`, `rows`) to `g`; true when it gave any. A null is read as a
|
||||
/// key left out. A `csv` that is not a string, a `series` or `categories` that is not a list, and `rows` that are not
|
||||
/// lists of numbers and nulls are errors, and `g` is left as it was.
|
||||
pub(super) fn apply_data(g: &mut GraphSpec, p: &Value, cmd: &str) -> Result<bool> {
|
||||
let list = |key: &str| -> Result<Option<&Vec<Value>>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Array(a)) => Ok(Some(a)),
|
||||
Some(v) => Err(bad(cmd, format!("`{key}` must be a list, not {v}"))),
|
||||
}
|
||||
};
|
||||
let csv = text_param(p, "csv", cmd)?;
|
||||
let strings = |a: &Vec<Value>| a.iter().map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).collect::<Vec<_>>();
|
||||
let series = list("series")?.map(strings);
|
||||
let categories = list("categories")?.map(strings);
|
||||
// A null is a blank cell.
|
||||
let rows = list("rows")?
|
||||
.map(|rows| {
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
let bad_row = || bad(cmd, format!("`rows` must be lists of numbers or nulls, not {r}"));
|
||||
r.as_array()
|
||||
.ok_or_else(bad_row)?
|
||||
.iter()
|
||||
.map(|v| if v.is_null() { Ok(None) } else { v.as_f64().map(|v| Some(v).filter(|v| v.is_finite())).ok_or_else(bad_row) })
|
||||
.collect::<Result<Vec<_>>>()
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
})
|
||||
.transpose()?;
|
||||
let mut changed = false;
|
||||
if let Some(csv) = str_param(p, "csv") {
|
||||
if let Some(csv) = csv {
|
||||
let (s, c, r) = parse_csv(csv);
|
||||
g.series = s;
|
||||
g.categories = c;
|
||||
g.set_cells(r);
|
||||
changed = true;
|
||||
}
|
||||
let strings =
|
||||
|v: &Value| v.as_array().map(|a| a.iter().map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).collect::<Vec<_>>());
|
||||
if let Some(s) = p.get("series").and_then(strings) {
|
||||
if let Some(s) = series {
|
||||
g.series = s;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(c) = p.get("categories").and_then(strings) {
|
||||
if let Some(c) = categories {
|
||||
g.categories = c;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(rows) = p.get("rows").and_then(Value::as_array) {
|
||||
// `null` (or anything that isn't a finite number) is a blank cell.
|
||||
g.set_cells(
|
||||
rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().filter(|v| v.is_finite())).collect()).unwrap_or_default()).collect(),
|
||||
);
|
||||
if let Some(rows) = rows {
|
||||
g.set_cells(rows);
|
||||
changed = true;
|
||||
}
|
||||
drop_empty_rows(g);
|
||||
changed
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Drop rows with neither a value nor a label; a label-only row stays, its values blank. Each kept row keeps its own
|
||||
@@ -184,13 +208,13 @@ pub(super) fn set_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.setData";
|
||||
let id = target(s, p, C)?;
|
||||
let mut spec = spec_of(s, id)?;
|
||||
let flip = bool_or(p, "transpose", false);
|
||||
let swap = bool_or(p, "switchXY", false);
|
||||
let flip = switch_or(p, "transpose", false, C)?;
|
||||
let swap = switch_or(p, "switchXY", false, C)?;
|
||||
if swap && spec.kind != GraphKind::Scatter {
|
||||
return Err(bad(C, "Switch x/y is for scatter graphs"));
|
||||
}
|
||||
// Transpose and Switch x/y act after any new data in the same call.
|
||||
let changed = apply_data(&mut spec, p);
|
||||
let changed = apply_data(&mut spec, p, C)?;
|
||||
if flip {
|
||||
// The categories become series: at most as many as a graph has series.
|
||||
if spec.cells().len() > vectorcraft_doc::MAX_GRAPH_SERIES {
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
use super::axis::{MAX_TICK_MARKS, count_param, one_line};
|
||||
use super::axis::{MAX_TICK_MARKS, count_given, one_line};
|
||||
use super::data::apply_data;
|
||||
use super::generate::{children_bounds, generate};
|
||||
use super::series::{picked_series, reselect_series};
|
||||
use super::{
|
||||
Affine, Document, EngineError, GraphKind, GraphSpec, Node, NodeId, Rect, Result, Session, TickLength, Value, ValueAxisSide, bad, bool_or, f64_or,
|
||||
f64_req, json, named_id, str_param,
|
||||
Affine, Document, EngineError, GraphKind, GraphSpec, Node, NodeId, Rect, Result, Session, TickLength, Value, ValueAxisSide, bad, f64_req, json,
|
||||
named_id, number_or, switch_or, text_param,
|
||||
};
|
||||
|
||||
/// An axis bound `p` gives for `key`: `None` when left out, `Some(None)` for null (calculated from the data), or the
|
||||
/// number. Any other value, such as `"10"`, is an error.
|
||||
fn bound_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Option<f64>>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(v) => v.as_f64().map(|x| Some(Some(x))).ok_or_else(|| bad(cmd, format!("`{key}` must be a number or null, not {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last
|
||||
/// generation, which is recorded in the spec's `placed` bounds).
|
||||
pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> {
|
||||
@@ -33,12 +43,14 @@ pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> R
|
||||
|
||||
pub(super) fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.create";
|
||||
let kind =
|
||||
str_param(p, "type").map(|t| GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))).transpose()?.unwrap_or_default();
|
||||
let kind = text_param(p, "type", C)?
|
||||
.map(|t| GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`"))))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let (x, y) = (f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let (w, h) = (f64_or(p, "width", 200.0).abs().max(1.0), f64_or(p, "height", 150.0).abs().max(1.0));
|
||||
let (w, h) = (number_or(p, "width", 200.0, C)?.abs().max(1.0), number_or(p, "height", 150.0, C)?.abs().max(1.0));
|
||||
let mut spec = GraphSpec { kind, rect: Rect::new(x, y, x + w, y + h), ..GraphSpec::default() };
|
||||
if !apply_data(&mut spec, p) {
|
||||
if !apply_data(&mut spec, p, C)? {
|
||||
// Starter data so the new graph shows something (edit it with Object → Graph → Data…).
|
||||
spec.series = vec!["Series 1".into(), "Series 2".into()];
|
||||
spec.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()];
|
||||
@@ -150,7 +162,8 @@ pub(super) fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
spec.right_series.retain(|i| *i < count);
|
||||
spec.right_series.sort_unstable();
|
||||
spec.right_series.dedup();
|
||||
if p.get("seriesIndexes").is_some() && str_param(p, "type").is_none() && str_param(p, "valueAxis").is_none() {
|
||||
let (new_type, new_axis) = (text_param(p, "type", C)?, text_param(p, "valueAxis", C)?);
|
||||
if p.get("seriesIndexes").is_some() && new_type.is_none() && new_axis.is_none() {
|
||||
return Err(bad(C, "`seriesIndexes` needs a `type` or a `valueAxis`"));
|
||||
}
|
||||
let picked = picked_series(s, p, id, count, C)?;
|
||||
@@ -196,7 +209,7 @@ pub(super) fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Series picked with Group Selection stay selected through the regeneration (their groups are new nodes).
|
||||
let reselect: Vec<u32> = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] };
|
||||
if let Some(t) = str_param(p, "type") {
|
||||
if let Some(t) = new_type {
|
||||
let kind = GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))?;
|
||||
if picked.is_empty() {
|
||||
spec.kind = kind;
|
||||
@@ -215,7 +228,7 @@ pub(super) fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(v) = str_param(p, "valueAxis") {
|
||||
if let Some(v) = new_axis {
|
||||
let side = ValueAxisSide::parse(v).ok_or_else(|| bad(C, format!("unknown value axis `{v}` (left, right or both)")))?;
|
||||
if picked.is_empty() {
|
||||
spec.value_axis = side;
|
||||
@@ -231,54 +244,58 @@ pub(super) fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
spec.separate_scales = bool_or(p, "separateScales", spec.separate_scales);
|
||||
spec.separate_scales = switch_or(p, "separateScales", spec.separate_scales, C)?;
|
||||
for (key, slot) in [
|
||||
("tickLength", &mut spec.tick_length),
|
||||
("rightTickLength", &mut spec.right_tick_length),
|
||||
("categoryTickLength", &mut spec.category_tick_length),
|
||||
] {
|
||||
if let Some(v) = str_param(p, key) {
|
||||
if let Some(v) = text_param(p, key, C)? {
|
||||
*slot = TickLength::parse(v).ok_or_else(|| bad(C, format!("unknown {key} `{v}` (none, short or full)")))?;
|
||||
}
|
||||
}
|
||||
for (key, slot) in
|
||||
[("tickMarks", &mut spec.tick_marks), ("rightTickMarks", &mut spec.right_tick_marks), ("categoryTickMarks", &mut spec.category_tick_marks)]
|
||||
{
|
||||
*slot = count_param(p, key, MAX_TICK_MARKS as u64).map_or(*slot, |n| n.max(1));
|
||||
*slot = count_given(p, key, MAX_TICK_MARKS as u64, C)?.map_or(*slot, |n| n.max(1));
|
||||
}
|
||||
spec.ticks_between_labels = bool_or(p, "ticksBetweenLabels", spec.ticks_between_labels);
|
||||
if let Some(v) = str_param(p, "piePosition") {
|
||||
spec.ticks_between_labels = switch_or(p, "ticksBetweenLabels", spec.ticks_between_labels, C)?;
|
||||
if let Some(v) = text_param(p, "piePosition", C)? {
|
||||
spec.pie_position =
|
||||
vectorcraft_doc::PiePosition::parse(v).ok_or_else(|| bad(C, format!("unknown piePosition `{v}` (even, ratio or stacked)")))?;
|
||||
}
|
||||
if let Some(v) = str_param(p, "pieSort") {
|
||||
if let Some(v) = text_param(p, "pieSort", C)? {
|
||||
spec.pie_sort = vectorcraft_doc::PieSort::parse(v).ok_or_else(|| bad(C, format!("unknown pieSort `{v}` (none, all or first)")))?;
|
||||
}
|
||||
for (key, slot) in
|
||||
[("prefix", &mut spec.prefix), ("suffix", &mut spec.suffix), ("rightPrefix", &mut spec.right_prefix), ("rightSuffix", &mut spec.right_suffix)]
|
||||
{
|
||||
if let Some(v) = str_param(p, key) {
|
||||
if let Some(v) = text_param(p, key, C)? {
|
||||
// A label, not a paragraph: one line, at most MAX_AFFIX characters.
|
||||
*slot = one_line(v);
|
||||
}
|
||||
}
|
||||
spec.right_ticks = count_param(p, "rightTicks", 100).unwrap_or(spec.right_ticks);
|
||||
if let Some(v) = p.get("rightAxisMin") {
|
||||
spec.right_axis_min = v.as_f64();
|
||||
spec.right_ticks = count_given(p, "rightTicks", 100, C)?.unwrap_or(spec.right_ticks);
|
||||
for (key, slot) in [
|
||||
("rightAxisMin", &mut spec.right_axis_min),
|
||||
("rightAxisMax", &mut spec.right_axis_max),
|
||||
("axisMin", &mut spec.axis_min),
|
||||
("axisMax", &mut spec.axis_max),
|
||||
] {
|
||||
if let Some(v) = bound_param(p, key, C)? {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("rightAxisMax") {
|
||||
spec.right_axis_max = v.as_f64();
|
||||
}
|
||||
spec.column_width = f64_or(p, "columnWidth", spec.column_width).clamp(1.0, 1000.0);
|
||||
spec.cluster_width = f64_or(p, "clusterWidth", spec.cluster_width).clamp(1.0, 1000.0);
|
||||
spec.legend = bool_or(p, "legend", spec.legend);
|
||||
spec.drop_shadow = bool_or(p, "dropShadow", spec.drop_shadow);
|
||||
spec.first_row_in_front = bool_or(p, "firstRowInFront", spec.first_row_in_front);
|
||||
spec.first_column_in_front = bool_or(p, "firstColumnInFront", spec.first_column_in_front);
|
||||
spec.filled_lines = bool_or(p, "filledLines", spec.filled_lines);
|
||||
spec.legend_across_top = bool_or(p, "legendAcrossTop", spec.legend_across_top);
|
||||
spec.column_width = number_or(p, "columnWidth", spec.column_width, C)?.clamp(1.0, 1000.0);
|
||||
spec.cluster_width = number_or(p, "clusterWidth", spec.cluster_width, C)?.clamp(1.0, 1000.0);
|
||||
spec.legend = switch_or(p, "legend", spec.legend, C)?;
|
||||
spec.drop_shadow = switch_or(p, "dropShadow", spec.drop_shadow, C)?;
|
||||
spec.first_row_in_front = switch_or(p, "firstRowInFront", spec.first_row_in_front, C)?;
|
||||
spec.first_column_in_front = switch_or(p, "firstColumnInFront", spec.first_column_in_front, C)?;
|
||||
spec.filled_lines = switch_or(p, "filledLines", spec.filled_lines, C)?;
|
||||
spec.legend_across_top = switch_or(p, "legendAcrossTop", spec.legend_across_top, C)?;
|
||||
// After `legend`, which the Graph Type dialog sends too.
|
||||
if let Some(v) = str_param(p, "pieLegend") {
|
||||
if let Some(v) = text_param(p, "pieLegend", C)? {
|
||||
(spec.legend, spec.pie_legend_in_wedges) = match v.to_ascii_lowercase().as_str() {
|
||||
"none" => (false, false),
|
||||
"standard" => (true, false),
|
||||
@@ -286,16 +303,10 @@ pub(super) fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, format!("unknown pieLegend `{v}` (none, standard or wedges)"))),
|
||||
};
|
||||
}
|
||||
spec.mark_points = bool_or(p, "markPoints", spec.mark_points);
|
||||
spec.connect_points = bool_or(p, "connectPoints", spec.connect_points);
|
||||
spec.edge_to_edge = bool_or(p, "edgeToEdge", spec.edge_to_edge);
|
||||
spec.ticks = count_param(p, "ticks", 100).unwrap_or(spec.ticks);
|
||||
if let Some(v) = p.get("axisMin") {
|
||||
spec.axis_min = v.as_f64();
|
||||
}
|
||||
if let Some(v) = p.get("axisMax") {
|
||||
spec.axis_max = v.as_f64();
|
||||
}
|
||||
spec.mark_points = switch_or(p, "markPoints", spec.mark_points, C)?;
|
||||
spec.connect_points = switch_or(p, "connectPoints", spec.connect_points, C)?;
|
||||
spec.edge_to_edge = switch_or(p, "edgeToEdge", spec.edge_to_edge, C)?;
|
||||
spec.ticks = count_given(p, "ticks", 100, C)?.unwrap_or(spec.ticks);
|
||||
let label = format!("{} Graph", spec.kind.label());
|
||||
s.edit("Graph Type", |d, sel| {
|
||||
regenerate(d, id, spec)?;
|
||||
|
||||
@@ -2638,9 +2638,12 @@ fn counts_come_back_from_the_dialog_as_floats_and_are_capped() {
|
||||
s.execute("graph.setType", &json!({"tickMarks": 3.0, "ticks": 4.0, "categoryTickMarks": 2.6})).unwrap();
|
||||
let g = spec(&s, id);
|
||||
assert_eq!((g.tick_marks, g.ticks, g.category_tick_marks), (3, 4, 3));
|
||||
s.execute("graph.setType", &json!({"categoryTickMarks": 0, "rightTickMarks": 1e300, "rightTicks": -2.0})).unwrap();
|
||||
s.execute("graph.setType", &json!({"categoryTickMarks": 0, "rightTickMarks": 1e300})).unwrap();
|
||||
let g = spec(&s, id);
|
||||
assert_eq!((g.category_tick_marks, g.right_tick_marks, g.right_ticks), (1, 20, 0));
|
||||
// A negative count is refused.
|
||||
s.execute("graph.setType", &json!({"rightTicks": -2.0})).unwrap_err();
|
||||
assert_eq!(spec(&s, id).right_ticks, 0);
|
||||
// A file's values come back as drawn.
|
||||
let mut fields = s.execute("graph.setType", &json!({})).unwrap();
|
||||
assert_eq!(fields["rightTickMarks"], 20);
|
||||
@@ -4498,3 +4501,93 @@ fn a_legend_label_with_kept_negative_space_before_stays_below_the_entry_above_it
|
||||
let (first, next) = (bounds("2024"), bounds("20\n25"));
|
||||
assert!(next.y0 >= first.y1, "the second legend label starts below the first one: {first:?} {next:?}");
|
||||
}
|
||||
|
||||
/// A graph command given a value of the wrong type refuses it, naming the key, and leaves the graph and the undo
|
||||
/// history as they were; a null axis bound still goes back to the calculated value.
|
||||
#[test]
|
||||
fn graph_commands_refuse_params_of_the_wrong_type() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "line");
|
||||
s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 10})).unwrap();
|
||||
let spec = |s: &Session| s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().cloned().unwrap();
|
||||
let undo_len = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let (before, steps) = (spec(&s), undo_len(&s));
|
||||
let refused = |s: &mut Session, cmd: &str, p: serde_json::Value, msg: &str| {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{cmd} {p}: {e}");
|
||||
};
|
||||
for (key, v, msg) in [
|
||||
("axisMin", json!("10"), "`axisMin` must be a number or null, not \"10\""),
|
||||
("axisMax", json!(true), "`axisMax` must be a number or null"),
|
||||
("rightAxisMin", json!([1]), "`rightAxisMin` must be a number or null"),
|
||||
("rightAxisMax", json!({}), "`rightAxisMax` must be a number or null"),
|
||||
("columnWidth", json!("50"), "`columnWidth` must be a number, not \"50\""),
|
||||
("clusterWidth", json!(false), "`clusterWidth` must be a number"),
|
||||
("ticks", json!("5"), "`ticks` must be a number, not \"5\""),
|
||||
("ticks", json!(-2), "`ticks` must be 0 or more"),
|
||||
("rightTicks", json!("5"), "`rightTicks` must be a number"),
|
||||
("tickMarks", json!("2"), "`tickMarks` must be a number"),
|
||||
("rightTickMarks", json!(-1), "`rightTickMarks` must be 0 or more"),
|
||||
("categoryTickMarks", json!(true), "`categoryTickMarks` must be a number"),
|
||||
("legend", json!("false"), "`legend` must be true or false"),
|
||||
("markPoints", json!(0), "`markPoints` must be true or false"),
|
||||
("connectPoints", json!("no"), "`connectPoints` must be true or false"),
|
||||
("edgeToEdge", json!(1), "`edgeToEdge` must be true or false"),
|
||||
("separateScales", json!("true"), "`separateScales` must be true or false"),
|
||||
("ticksBetweenLabels", json!(1), "`ticksBetweenLabels` must be true or false"),
|
||||
("firstRowInFront", json!("yes"), "`firstRowInFront` must be true or false"),
|
||||
("firstColumnInFront", json!(0), "`firstColumnInFront` must be true or false"),
|
||||
("filledLines", json!("on"), "`filledLines` must be true or false"),
|
||||
("dropShadow", json!(1), "`dropShadow` must be true or false"),
|
||||
("legendAcrossTop", json!("x"), "`legendAcrossTop` must be true or false"),
|
||||
("type", json!(5), "`type` must be a string"),
|
||||
("valueAxis", json!(true), "`valueAxis` must be a string"),
|
||||
("tickLength", json!(1), "`tickLength` must be a string"),
|
||||
("rightTickLength", json!(1), "`rightTickLength` must be a string"),
|
||||
("categoryTickLength", json!(1), "`categoryTickLength` must be a string"),
|
||||
("prefix", json!(1), "`prefix` must be a string"),
|
||||
("suffix", json!(true), "`suffix` must be a string"),
|
||||
("rightPrefix", json!([]), "`rightPrefix` must be a string"),
|
||||
("rightSuffix", json!(2), "`rightSuffix` must be a string"),
|
||||
("pieLegend", json!(1), "`pieLegend` must be a string"),
|
||||
("piePosition", json!(1), "`piePosition` must be a string"),
|
||||
("pieSort", json!(1), "`pieSort` must be a string"),
|
||||
] {
|
||||
refused(&mut s, "graph.setType", json!({"id": id.0, key: v}), msg);
|
||||
}
|
||||
for (key, v, msg) in [
|
||||
("csv", json!(5), "`csv` must be a string"),
|
||||
("series", json!("a"), "`series` must be a list, not \"a\""),
|
||||
("categories", json!({}), "`categories` must be a list"),
|
||||
("rows", json!("1,2"), "`rows` must be a list"),
|
||||
("rows", json!([1, 2]), "`rows` must be lists of numbers or nulls, not 1"),
|
||||
("rows", json!([[1, "2"]]), "`rows` must be lists of numbers or nulls"),
|
||||
("transpose", json!("true"), "`transpose` must be true or false"),
|
||||
("switchXY", json!(1), "`switchXY` must be true or false"),
|
||||
] {
|
||||
refused(&mut s, "graph.setData", json!({"id": id.0, key: v}), msg);
|
||||
}
|
||||
assert_eq!(spec(&s), before);
|
||||
assert_eq!(undo_len(&s), steps);
|
||||
let count = |s: &Session| s.doc().unwrap().doc.node_count();
|
||||
let objects = count(&s);
|
||||
for (key, v, msg) in [
|
||||
("type", json!(1), "`type` must be a string"),
|
||||
("width", json!("300"), "`width` must be a number, not \"300\""),
|
||||
("height", json!(true), "`height` must be a number"),
|
||||
("csv", json!(1), "`csv` must be a string"),
|
||||
("rows", json!([[true]]), "`rows` must be lists of numbers or nulls"),
|
||||
] {
|
||||
let mut p = json!({"type": "column", "x": 0, "y": 0, "width": 100, "height": 100});
|
||||
p[key] = v;
|
||||
refused(&mut s, "graph.create", p, msg);
|
||||
}
|
||||
assert_eq!((count(&s), undo_len(&s)), (objects, steps));
|
||||
// A null is a key left out, except for an axis bound, where it goes back to the calculated value.
|
||||
s.execute("graph.setType", &json!({"id": id.0, "legend": null, "ticks": null, "prefix": null, "axisMin": null})).unwrap();
|
||||
assert_eq!(spec(&s).axis_min, None);
|
||||
assert_eq!(spec(&s).axis_max, Some(10.0));
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, null], [2, 3]], "series": null, "transpose": null})).unwrap();
|
||||
assert_eq!(spec(&s).cells(), vec![vec![Some(1.0), None], vec![Some(2.0), Some(3.0)]]);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use vectorcraft_doc::Node;
|
||||
use vectorcraft_render::effects::addons::halftone::{DotShape, Halftone, art_outline, dot_nodes};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, color_value, f64_or, has_selection, str_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, color_value, has_selection, number_or, switch_or, text_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
@@ -27,28 +27,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.vectorHalftone";
|
||||
let shape = match str_param(p, "shape") {
|
||||
let shape = match text_param(p, "shape", C)? {
|
||||
Some(v) => DotShape::parse(v).ok_or_else(|| bad(C, format!("unknown shape `{v}` (circle|ellipse|square|diamond|line)")))?,
|
||||
None => DotShape::Circle,
|
||||
};
|
||||
let frequency = f64_or(p, "frequency", 20.0);
|
||||
let frequency = number_or(p, "frequency", 20.0, C)?;
|
||||
if !(1.0..=300.0).contains(&frequency) {
|
||||
return Err(bad(C, "frequency must be between 1 and 300 lines per inch"));
|
||||
}
|
||||
let angle = f64_or(p, "angle", 45.0);
|
||||
let angle = number_or(p, "angle", 45.0, C)?;
|
||||
if !angle.is_finite() {
|
||||
return Err(bad(C, "invalid angle"));
|
||||
}
|
||||
let mono = match str_param(p, "mode").unwrap_or("mono") {
|
||||
let mono = match text_param(p, "mode", C)?.unwrap_or("mono") {
|
||||
"mono" => true,
|
||||
"cmyk" => false,
|
||||
other => return Err(bad(C, format!("unknown mode `{other}` (mono|cmyk)"))),
|
||||
};
|
||||
let color = match p.get("color") {
|
||||
None | Some(Value::Null) => Color::BLACK,
|
||||
Some(v) => color_value(v).ok_or_else(|| bad(C, "invalid color"))?,
|
||||
None => Color::BLACK,
|
||||
};
|
||||
let (invert, clip, keep) = (bool_or(p, "invert", false), bool_or(p, "clip", true), bool_or(p, "keepOriginal", false));
|
||||
let (invert, clip, keep) = (switch_or(p, "invert", false, C)?, switch_or(p, "clip", true, C)?, switch_or(p, "keepOriginal", false, C)?);
|
||||
let ht = Halftone { shape, frequency, angle, mono, color, from_art: false, invert, clip };
|
||||
|
||||
let roots = selected_roots(s)?;
|
||||
|
||||
@@ -36,6 +36,8 @@ pub(crate) use arrange::group_nodes;
|
||||
pub(crate) use bounds::{bounds_rewrite, object_bounds_transform};
|
||||
pub use commands::specs;
|
||||
pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip};
|
||||
pub(crate) use pixelgrid::{keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, snap_if_aligned};
|
||||
pub(crate) use pixelgrid::{
|
||||
can_align, keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, snap_if_aligned,
|
||||
};
|
||||
pub(super) use shape::recut;
|
||||
pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects};
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_geom::FillRule;
|
||||
|
||||
use super::appearance::{ItemTarget, item_target};
|
||||
use super::edit::selected_roots;
|
||||
use super::{CommandSpec, Result, Session, bad, bool_or, cmd, f64_or, has_doc, ids_param, targets};
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, ids_param, number_or, switch_or, targets};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -221,15 +221,16 @@ fn overprint_black_tree(n: &mut Node, op: &OverprintBlack, spots: &[String]) ->
|
||||
}
|
||||
|
||||
pub(crate) fn overprint_black(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.colors.overprintBlack";
|
||||
let op = OverprintBlack {
|
||||
remove: bool_or(p, "remove", false),
|
||||
min_k: (f64_or(p, "percentage", 100.0) / 100.0).clamp(0.0, 1.0) as f32,
|
||||
fill: bool_or(p, "fill", true),
|
||||
stroke: bool_or(p, "stroke", true),
|
||||
rich: bool_or(p, "includeCmyBlacks", false),
|
||||
spot: bool_or(p, "includeSpotBlacks", false),
|
||||
remove: switch_or(p, "remove", false, C)?,
|
||||
min_k: (number_or(p, "percentage", 100.0, C)? / 100.0).clamp(0.0, 1.0) as f32,
|
||||
fill: switch_or(p, "fill", true, C)?,
|
||||
stroke: switch_or(p, "stroke", true, C)?,
|
||||
rich: switch_or(p, "includeCmyBlacks", false, C)?,
|
||||
spot: switch_or(p, "includeSpotBlacks", false, C)?,
|
||||
};
|
||||
let ids = match ids_param(p, "ids", "edit.colors.overprintBlack")? {
|
||||
let ids = match ids_param(p, "ids", C)? {
|
||||
Some(v) => v,
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::fileio::{create_dir, write_file};
|
||||
use super::links::LinkBase;
|
||||
use super::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc, str_param, unique_name};
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param, unique_name};
|
||||
|
||||
const C: &str = "file.package";
|
||||
|
||||
@@ -451,19 +451,20 @@ impl Session {
|
||||
/// name, the report's Document Info text and the document.
|
||||
fn prepare(s: &mut Session, p: &Value) -> Result<Package> {
|
||||
let o = Options {
|
||||
copy_links: bool_or(p, "copyLinks", true),
|
||||
links_folder: bool_or(p, "linksFolder", true),
|
||||
relink: bool_or(p, "relink", true),
|
||||
copy_fonts: bool_or(p, "copyFonts", true),
|
||||
report: bool_or(p, "report", true),
|
||||
copy_links: switch_or(p, "copyLinks", true, C)?,
|
||||
links_folder: switch_or(p, "linksFolder", true, C)?,
|
||||
relink: switch_or(p, "relink", true, C)?,
|
||||
copy_fonts: switch_or(p, "copyFonts", true, C)?,
|
||||
report: switch_or(p, "report", true, C)?,
|
||||
};
|
||||
let given_name = text_param(p, "name", C)?;
|
||||
let folder = text_param(p, "folder", C)?;
|
||||
let doc_path = s.doc()?.path.clone().ok_or_else(|| bad(C, "save the document first: Package collects a saved document"))?;
|
||||
let stem = Path::new(&doc_path).file_stem().map_or_else(|| "Untitled".into(), |s| s.to_string_lossy().into_owned());
|
||||
let name = str_param(p, "name").map_or_else(|| format!("{stem} Folder"), str::to_string);
|
||||
let name = given_name.map_or_else(|| format!("{stem} Folder"), str::to_string);
|
||||
if name.trim().is_empty() || name == "." || name == ".." || name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
|
||||
return Err(bad(C, format!("`{name}` can't name a folder")));
|
||||
}
|
||||
let folder = str_param(p, "folder");
|
||||
let root = folder.map(|f| Path::new(f).join(&name));
|
||||
let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None };
|
||||
let doc = s.doc()?.doc.clone();
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), 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 counter-clockwise (0: the gradient runs left to right), 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; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {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)}",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), 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|conic|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector), 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 counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial and conic; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; 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; an error with a conic), dither?: bool (linear, radial and conic 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, radial or conic `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; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {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)
|
||||
),
|
||||
@@ -641,7 +641,7 @@ fn paint_new_art(s: &mut Session, paint: &Paint, fill: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets
|
||||
/// Does `p` ask for `span: "selection"` (#2127)? Only a linear, radial or conic gradient the targets
|
||||
/// would each fit to their own bounds can span them: one with its own vector (`start`/`end`),
|
||||
/// a freeform one or a gradient swatch already has its placement.
|
||||
fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result<bool, String> {
|
||||
@@ -656,7 +656,7 @@ fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result<bool, String>
|
||||
return Err("span: \"selection\" places a `gradient`, not a swatch".into());
|
||||
}
|
||||
if g.gradient.kind == vectorcraft_color::GradientKind::Freeform || g.freeform.is_some() {
|
||||
return Err("span: \"selection\" places a linear or radial gradient, not a freeform one".into());
|
||||
return Err("span: \"selection\" places a linear, radial or conic gradient, not a freeform one".into());
|
||||
}
|
||||
if g.geom.is_some() {
|
||||
return Err(
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Close Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor), independent?: bool}",
|
||||
"{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor, placed as path.setHandle places it: a smooth first anchor keeps its outgoing handle in line at its own length, a corner keeps it where it is), out?: [x,y] (the first anchor's outgoing handle, placed after `in`; the anchor becomes a corner), independent?: bool (`in` makes the first anchor a corner and leaves its outgoing handle)}",
|
||||
has_doc,
|
||||
close
|
||||
),
|
||||
@@ -202,14 +202,18 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Close the active open subpath of `id`, optionally setting the incoming handle of the first anchor.
|
||||
/// Close the active open subpath of `id`, optionally setting the handles of the first anchor. The
|
||||
/// incoming handle moves as Move Direction Handle moves it: the outgoing handle turns with it only
|
||||
/// on a smooth anchor, so the first segment of a path that starts at a corner keeps its shape.
|
||||
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
|
||||
let h_in = match p.get("in").filter(|h| !h.is_null()) {
|
||||
None => None,
|
||||
Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?),
|
||||
let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(h) => finite_numbers(h).map(|[x, y]| Some(Point::new(x, y))).ok_or_else(|| bad("path.close", format!("`{key}` must be [x, y]"))),
|
||||
};
|
||||
let h_in = handle("in")?;
|
||||
let h_out = handle("out")?;
|
||||
let independent = switch_or(p, "independent", false, "path.close")?;
|
||||
s.edit("Close Path", |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
@@ -218,11 +222,13 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?,
|
||||
};
|
||||
sp.closed = true;
|
||||
if let (Some(h), Some(first)) = (h_in, sp.anchors.first_mut()) {
|
||||
first.h_in = h;
|
||||
if !independent {
|
||||
first.h_out = first.p - (h - first.p);
|
||||
first.kind = AnchorKind::Smooth;
|
||||
if let Some(first) = sp.anchors.first_mut() {
|
||||
if let Some(h) = h_in {
|
||||
first.set_handle(false, h, independent);
|
||||
}
|
||||
if let Some(h) = h_out {
|
||||
first.h_out = h;
|
||||
first.kind = AnchorKind::Corner;
|
||||
}
|
||||
}
|
||||
let top = compound_or_self(d, id);
|
||||
|
||||
@@ -47,7 +47,10 @@ use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
use vectorcraft_pdf::CropTo;
|
||||
|
||||
use super::fileio::{self, Format, RasterImage};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, object_param, str_param, unique_name};
|
||||
use super::{
|
||||
CommandSpec, Result, Session, always, bad, bool_or, check_numbers, check_switches, cmd, finite_numbers, has_doc, object_param, str_param,
|
||||
switch_or, unique_name,
|
||||
};
|
||||
use crate::{EngineError, MAX_COORD};
|
||||
|
||||
const PLACE: &str = "file.place";
|
||||
@@ -556,8 +559,9 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn prepare(s: &Session, p: &Value) -> Result<PlaceJob> {
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let template = bool_or(p, "template", false);
|
||||
let replace = switch_or(p, "replace", false, PLACE)?;
|
||||
let template = switch_or(p, "template", false, PLACE)?;
|
||||
check_switches(p, &["link"], PLACE)?;
|
||||
let at = at_param(p)?;
|
||||
let rect = rect_param(p)?;
|
||||
if replace && (template || at.is_some() || rect.is_some()) {
|
||||
@@ -778,6 +782,8 @@ impl Session {
|
||||
/// desktop the Place dialog's OK with several files does this off the UI thread).
|
||||
pub fn prepare_place_queue(&self, params: &Value) -> Result<QueueJob> {
|
||||
has_doc(self).map_err(|why| EngineError::Disabled(QUEUE.into(), why))?;
|
||||
check_switches(params, &["link", "template"], QUEUE)?;
|
||||
check_numbers(params, &["thumbnail"], QUEUE)?;
|
||||
Ok(QueueJob { params: params.clone(), files: queued_files(params)? })
|
||||
}
|
||||
|
||||
@@ -846,6 +852,8 @@ fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// `file.place.info` with `p`, without the session (on the desktop the Place dialog reads its
|
||||
/// files' lines this way off the UI thread).
|
||||
pub fn read_info(p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["link"], "file.place.info")?;
|
||||
check_numbers(p, &["thumbnail"], "file.place.info")?;
|
||||
// A PDF/.ai page linked or not has the same size and art.
|
||||
let mut l = load(p, "file.place.info", None, false)?;
|
||||
let natural = l.natural;
|
||||
|
||||
@@ -108,7 +108,7 @@ pub(in crate::cmd) fn windows_1252(b: u8) -> char {
|
||||
}
|
||||
|
||||
/// Byte `b` of Mac Roman.
|
||||
pub(super) fn mac_roman(b: u8) -> char {
|
||||
pub(in crate::cmd) fn mac_roman(b: u8) -> char {
|
||||
const HIGH: [char; 128] = [
|
||||
'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', //
|
||||
'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', //
|
||||
|
||||
@@ -1096,7 +1096,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, or its grant can't be removed from the plug-in store, the command fails and the plug-in stays installed); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}",
|
||||
"{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, or its grant can't be removed from the plug-in store, the command fails and the plug-in stays installed; when the settings can't be moved back either, the error names the folder they stay in, and the next load, install or removal of the plug-in moves them back); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}",
|
||||
always,
|
||||
remove
|
||||
),
|
||||
|
||||
@@ -396,6 +396,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"paraStyle.apply",
|
||||
"paraStyle.delete",
|
||||
"paraStyle.duplicate",
|
||||
"paraStyle.edit",
|
||||
"paraStyle.list",
|
||||
"paraStyle.new",
|
||||
"paraStyle.redefine",
|
||||
@@ -484,6 +485,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"select.object.brushStrokes",
|
||||
"select.object.clippingMasks",
|
||||
"select.object.directionHandles",
|
||||
"select.object.notAlignedToPixelGrid",
|
||||
"select.object.pointText",
|
||||
"select.object.slices",
|
||||
"select.object.strayPoints",
|
||||
@@ -578,6 +580,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"symbol.replace",
|
||||
"symbol.selectInstances",
|
||||
"symbol.setCurrent",
|
||||
"symbol.sortByName",
|
||||
"symbol.spray",
|
||||
"symbol.unused",
|
||||
"symbol.update",
|
||||
|
||||
@@ -13,9 +13,7 @@ use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::colorcmds::{NotChanged, Recolor, Scope, left_out};
|
||||
use super::{
|
||||
CommandSpec, Result, Session, always, bad, check_switches, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param,
|
||||
};
|
||||
use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -566,8 +564,8 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() && group.is_none() {
|
||||
return Err(bad(C, "select artwork to recolour, or give a colour `group`"));
|
||||
}
|
||||
check_switches(p, &["includeImages", "includePatterns"], C)?;
|
||||
let scope = Scope { fill: true, stroke: true, ..Scope::of(p) };
|
||||
let scope =
|
||||
Scope { fill: true, stroke: true, images: switch_or(p, "includeImages", true, C)?, patterns: switch_or(p, "includePatterns", true, C)? };
|
||||
let label = if ids.is_empty() { "Edit Color Group" } else { "Recolor Artwork" };
|
||||
// `model`: keep each colour's model (default), take the new colour's own, or the document's.
|
||||
// A given `model` must be a string; only a missing one means "keep". A row's own `model`
|
||||
|
||||
@@ -111,8 +111,13 @@ pub trait RecoveryStore: Send + Sync {
|
||||
fn announced(&self, _area: &str) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
/// Tidy away area `area` once it holds no copies and nobody holds it (its lock file and folder).
|
||||
/// Tidy away area `area` once it holds no copies (its lock file and folder). The caller holds
|
||||
/// the area until this returns.
|
||||
fn remove_area(&self, _area: &str) {}
|
||||
/// Remove area `area`'s folder if it is empty, after [`RecoveryStore::remove_area`] and after the
|
||||
/// caller let the area go. On some file systems a file that is open is removed only when it is
|
||||
/// closed, so `remove_area` leaves the folder there while the area is held.
|
||||
fn remove_empty_area(&self, _area: &str) {}
|
||||
/// The clock heartbeats are judged by (Unix seconds; `None`: none, so no area counts as gone).
|
||||
fn now(&self) -> Option<i64> {
|
||||
vectorcraft_doc::metadata::now_unix()
|
||||
@@ -295,6 +300,73 @@ impl FolderStore {
|
||||
.filter(|n| Self::plain(n))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Lock area folder `dir`, which was just made. Another app tidies an empty area away
|
||||
/// (`tidy`) by removing its lock file and folder while it holds the lock. So when the folder
|
||||
/// or the lock file is gone by now, or the lock taken is on a lock file the folder no longer
|
||||
/// has, that app had the area: it is [`Lock::Busy`], and the caller tries another one.
|
||||
pub(crate) fn lock_area(dir: &std::path::Path) -> std::result::Result<Lock, String> {
|
||||
if let Err(e) = Self::own_area(dir) {
|
||||
let gone = std::fs::symlink_metadata(dir).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound);
|
||||
return if gone { Ok(Lock::Busy) } else { Err(e) };
|
||||
}
|
||||
let path = dir.join(LOCK_FILE);
|
||||
match std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path) {
|
||||
Ok(file) => Self::take(file, &path),
|
||||
Err(e) => Self::open_failed(e, &path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opening lock file `path` failed with `e`. When the folder is gone by now, another app had the
|
||||
/// area: it is [`Lock::Busy`]. So it is when the lock file is there but can't be opened: some
|
||||
/// file systems keep a removed file that another app still has open until that app closes it,
|
||||
/// and refuse to open it meanwhile. A lock file that can't be made is an error.
|
||||
pub(crate) fn open_failed(e: std::io::Error, path: &std::path::Path) -> std::result::Result<Lock, String> {
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => Ok(Lock::Busy),
|
||||
std::io::ErrorKind::PermissionDenied if std::fs::symlink_metadata(path).is_ok() => Ok(Lock::Busy),
|
||||
_ => Err(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock `file`, opened as lock file `path`. The lock holds the area only while `path` still
|
||||
/// names that file.
|
||||
pub(crate) fn take(file: std::fs::File, path: &std::path::Path) -> std::result::Result<Lock, String> {
|
||||
match file.try_lock() {
|
||||
Ok(()) if Self::still_named(&file, path) => Ok(Lock::Held(Box::new(file))),
|
||||
Ok(()) | Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy),
|
||||
// The lock file was removed meanwhile, and locking it failed instead.
|
||||
Err(std::fs::TryLockError::Error(_)) if !Self::still_named(&file, path) => Ok(Lock::Busy),
|
||||
Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `path` still name `file`? On Unix the two must be the same file (device and inode); on
|
||||
/// Windows they must be the same file (volume serial number and file index); elsewhere `path`
|
||||
/// must exist. When either file can't be read, it does not.
|
||||
fn still_named(file: &std::fs::File, path: &std::path::Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
match (file.metadata(), std::fs::symlink_metadata(path)) {
|
||||
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let opened = file.try_clone().and_then(same_file::Handle::from_file);
|
||||
match (opened, same_file::Handle::from_path(path)) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = file;
|
||||
std::fs::symlink_metadata(path).is_ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -342,29 +414,23 @@ impl RecoveryStore for FolderStore {
|
||||
fn lock(&self, area: &str) -> std::result::Result<Lock, String> {
|
||||
let dir = self.area(area)?;
|
||||
Self::create_area(&dir)?;
|
||||
Self::own_area(&dir)?;
|
||||
let path = dir.join(LOCK_FILE);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
match file.try_lock() {
|
||||
Ok(()) => Ok(Lock::Held(Box::new(file))),
|
||||
Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy),
|
||||
Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())),
|
||||
}
|
||||
Self::lock_area(&dir)
|
||||
}
|
||||
fn remove_area(&self, area: &str) {
|
||||
// Best effort: whatever is left is tidied at a later launch. A folder that isn't empty
|
||||
// (copies came back meanwhile) stays.
|
||||
// (copies came back meanwhile) stays. The caller still holds the lock here.
|
||||
if let Ok(dir) = self.area(area) {
|
||||
let _ = std::fs::remove_file(dir.join(LOCK_FILE));
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
}
|
||||
fn remove_empty_area(&self, area: &str) {
|
||||
// Best effort, as above. An app that made the folder again meanwhile and has not locked it
|
||||
// yet finds it gone and takes another area (`lock_area`); one that has keeps it.
|
||||
if let Ok(dir) = self.area(area) {
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The area this session keeps its copies in, while it holds it.
|
||||
@@ -527,8 +593,10 @@ pub fn release(s: &mut Session) {
|
||||
for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) {
|
||||
let _ = store.remove(name);
|
||||
}
|
||||
drop(hold);
|
||||
// Held until it is gone, as in `tidy`.
|
||||
store.remove_area(&area);
|
||||
drop(hold);
|
||||
store.remove_empty_area(&area);
|
||||
}
|
||||
|
||||
/// Let go of this session's area but leave its copies, as a crash would, so the next app started
|
||||
@@ -967,7 +1035,9 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
|
||||
}
|
||||
}
|
||||
|
||||
/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`).
|
||||
/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). The
|
||||
/// area is removed while it is still held, so an app taking a new area of that name meanwhile
|
||||
/// finds it held or gone and takes the next name.
|
||||
fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec<Hold>) {
|
||||
let names = store.list().unwrap_or_default();
|
||||
let mine: Vec<&String> = names.iter().filter(|n| n.split_once('/').is_some_and(|(a, _)| a == area)).collect();
|
||||
@@ -977,8 +1047,9 @@ fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec<Hold>) {
|
||||
for n in mine {
|
||||
let _ = store.remove(n);
|
||||
}
|
||||
drop(hold);
|
||||
store.remove_area(area);
|
||||
drop(hold);
|
||||
store.remove_empty_area(area);
|
||||
}
|
||||
|
||||
/// The copies of documents open now in this session.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Document, LockedArtboards, Node, NodeId, NodeKind};
|
||||
use vectorcraft_tools::builder::is_live_paint;
|
||||
|
||||
use super::object::can_align;
|
||||
use super::wand::{fill_of, stroke_of};
|
||||
use super::{
|
||||
CommandSpec, Result, Session, bad, bool_or, checked_id, checked_ids_param, cmd, color_value, has_doc, has_selection, index, ok, str_param,
|
||||
@@ -178,6 +179,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("select.object.openPaths", "Open Paths", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n
|
||||
.path_data()
|
||||
.is_some_and(|p| !p.is_closed()))),
|
||||
cmd!(
|
||||
"select.object.notAlignedToPixelGrid",
|
||||
"Not Aligned to Pixel Grid",
|
||||
["Select", "Object"],
|
||||
None,
|
||||
"{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups but not into compound paths → {count}",
|
||||
has_doc,
|
||||
not_pixel_aligned
|
||||
),
|
||||
cmd!(
|
||||
"select.same.graphicStyle",
|
||||
"Graphic Style",
|
||||
@@ -865,3 +875,19 @@ fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result<Value> {
|
||||
// The objects selected, after Lock Guides has left out guide paths.
|
||||
Ok(json!({ "count": s.doc()?.selection.objects.len() }))
|
||||
}
|
||||
|
||||
/// Select › Object › Not Aligned to Pixel Grid: the objects that can take the pixel-aligned
|
||||
/// property ([`can_align`]) and do not have it. A compound path counts as one object, so the walk
|
||||
/// does not look at the paths inside it.
|
||||
fn not_pixel_aligned(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mut ids = vec![];
|
||||
walk_selectable(&st.doc, st.isolation, |n| {
|
||||
if can_align(n) && !n.extras().pixel_aligned {
|
||||
ids.push(n.id);
|
||||
}
|
||||
!matches!(n.kind, NodeKind::Compound { .. })
|
||||
});
|
||||
s.select(|_, sel| sel.set(ids))?;
|
||||
Ok(json!({ "count": s.doc()?.selection.objects.len() }))
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKin
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::{
|
||||
CommandSpec, EngineError, NodeId, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ids_param, index,
|
||||
switch_or, targets,
|
||||
CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, id_param, ids_param, index, number_or, switch_or,
|
||||
targets,
|
||||
};
|
||||
|
||||
/// The most slices one Divide Slices makes.
|
||||
@@ -380,7 +380,8 @@ fn from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = selected(s)?;
|
||||
let (dx, dy) = (f64_or(p, "dx", 10.0), f64_or(p, "dy", 10.0));
|
||||
let dx = number_or(p, "dx", 10.0, "object.slice.duplicate")?;
|
||||
let dy = number_or(p, "dy", 10.0, "object.slice.duplicate")?;
|
||||
let ids = s.edit("Duplicate Slice", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &targets {
|
||||
@@ -737,7 +738,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let slices = p.get("slices").filter(|v| !v.is_null()).cloned().unwrap_or(json!([]));
|
||||
let ids = slice_targets(s, &json!({ "slices": slices }), "object.slice.select")?;
|
||||
let toggle = bool_or(p, "toggle", false);
|
||||
let toggle = switch_or(p, "toggle", false, "object.slice.select")?;
|
||||
s.select(|_, sel| {
|
||||
let mut now = if toggle { sel.slices.clone() } else { vec![] };
|
||||
for id in ids {
|
||||
|
||||
@@ -14,7 +14,9 @@ use vectorcraft_doc::style_libs::{self, STYLE_LIBRARIES, STYLES_EXT, builtin_sty
|
||||
use vectorcraft_doc::{Document, GraphicStyle, PatternDef, StyleLibrary};
|
||||
|
||||
use super::menucmds::squash;
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, names_param, stem};
|
||||
use super::swatchlib::{
|
||||
FileData, LibraryFile, LibraryInfo, concealed_library_id, concealed_library_ids, names_param, shown_id, shown_library_id, stem,
|
||||
};
|
||||
use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, has_doc, str_param, switch_or, text_param, unique_name};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -94,6 +96,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<StyleLibrary>
|
||||
if let Some(found) = s.style_libraries.get(key) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(found) = concealed_library_id(s, key).and_then(|id| s.style_libraries.get(&id)) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(b) = STYLE_LIBRARIES.iter().find(|b| b.id == key) {
|
||||
return Some((builtin_info(b), builtin_style_library(key)?));
|
||||
}
|
||||
@@ -108,20 +113,22 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.style_libraries.rescan();
|
||||
let concealed = concealed_library_ids(s, []);
|
||||
let libs: Vec<Value> = libraries(s)
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len());
|
||||
json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.style_libraries.path(&l.id)})
|
||||
let (id, path) = (shown_id(&concealed, &l.id), s.style_libraries.shown_path(&l.id));
|
||||
json!({"id": id, "name": l.name, "category": l.category, "count": count, "path": path})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.style_libraries.user_dir() }))
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.style_libraries.shown_user_dir() }))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (info, lib) = library_param(s, p, "graphicStyle.library")?;
|
||||
let styles: Vec<Value> = lib.styles.iter().map(super::style::style_json).collect();
|
||||
Ok(json!({"id": info.id, "name": info.name, "category": info.category, "styles": styles}))
|
||||
Ok(json!({"id": shown_library_id(s, &info.id), "name": info.name, "category": info.category, "styles": styles}))
|
||||
}
|
||||
|
||||
/// Pattern `name` of `lib` in `d`: the document's own when it has the same pattern (under any
|
||||
@@ -192,7 +199,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let doc_name = |lib_name: &str| added.iter().find(|(l, _)| l == lib_name).map_or_else(|| lib_name.to_string(), |(_, n)| n.clone());
|
||||
let mut out = json!({
|
||||
"library": info.id,
|
||||
"library": shown_library_id(s, &info.id),
|
||||
"added": added.iter().map(|(_, n)| n).collect::<Vec<_>>(),
|
||||
"existing": existing.iter().map(|g| &g.name).collect::<Vec<_>>(),
|
||||
});
|
||||
@@ -238,5 +245,5 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
|
||||
Ok(json!({"library": shown_library_id(s, &info.id), "name": info.name, "count": lib.len()}))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent};
|
||||
@@ -222,9 +222,10 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The `names` list and `name` parameters, without repeats; at least one is required.
|
||||
pub(super) fn names_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut names: Vec<String> = vec![];
|
||||
for n in str_list(p, "names").into_iter().chain(str_param(p, "name").map(str::to_string)) {
|
||||
if !names.contains(&n) {
|
||||
if seen.insert(n.clone()) {
|
||||
names.push(n);
|
||||
}
|
||||
}
|
||||
@@ -234,17 +235,73 @@ pub(super) fn names_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Take swatches `names` out of the document in that order, as [`Document::remove_swatch`] for
|
||||
/// each name in turn would, in one pass over the swatches: a name takes the first swatch of that
|
||||
/// name still there (ungrouped first, then each colour group's) when `take` accepts it, and
|
||||
/// nothing when `take` refuses it or no swatch has the name.
|
||||
fn take_swatches(d: &mut Document, names: &[String], take: impl Fn(&Swatch) -> bool) -> Vec<Swatch> {
|
||||
let wanted: HashSet<&str> = names.iter().map(String::as_str).collect();
|
||||
// Each wanted name's swatches in lookup order: (list, index, accepted), where list 0 holds the
|
||||
// ungrouped swatches and list g + 1 colour group g's.
|
||||
let mut places: HashMap<&str, VecDeque<(usize, usize, bool)>> = HashMap::new();
|
||||
let lists = std::iter::once(&d.swatches[..]).chain(d.swatch_groups.iter().map(|g| &g.swatches[..]));
|
||||
for (l, list) in lists.enumerate() {
|
||||
for (i, w) in list.iter().enumerate() {
|
||||
if let Some(n) = wanted.get(w.name.as_str()) {
|
||||
places.entry(n).or_default().push_back((l, i, take(w)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut picked: Vec<(usize, usize)> = vec![];
|
||||
for n in names {
|
||||
if let Some(q) = places.get_mut(n.as_str())
|
||||
&& let Some(&(l, i, true)) = q.front()
|
||||
{
|
||||
q.pop_front();
|
||||
picked.push((l, i));
|
||||
}
|
||||
}
|
||||
if picked.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let hit: HashSet<(usize, usize)> = picked.iter().copied().collect();
|
||||
let hit_lists: HashSet<usize> = picked.iter().map(|&(l, _)| l).collect();
|
||||
let mut taken: HashMap<(usize, usize), Swatch> = HashMap::with_capacity(picked.len());
|
||||
let mut split = |l: usize, list: &mut Vec<Swatch>| {
|
||||
for (i, w) in std::mem::take(list).into_iter().enumerate() {
|
||||
if hit.contains(&(l, i)) {
|
||||
taken.insert((l, i), w);
|
||||
} else {
|
||||
list.push(w);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Both lists are copied on write, so each is only written when a swatch leaves it.
|
||||
if hit_lists.contains(&0) {
|
||||
split(0, &mut d.swatches);
|
||||
}
|
||||
if hit_lists.iter().any(|&l| l > 0) {
|
||||
for (g, group) in d.swatch_groups.iter_mut().enumerate() {
|
||||
if hit_lists.contains(&(g + 1)) {
|
||||
split(g + 1, &mut group.swatches);
|
||||
}
|
||||
}
|
||||
}
|
||||
picked.iter().filter_map(|k| taken.remove(k)).collect()
|
||||
}
|
||||
|
||||
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.delete";
|
||||
let names = names_param(p, C)?;
|
||||
check_switches(p, &["unlink"], C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let by_name = swatches_by_name(d);
|
||||
// Global swatches going away (named ones and those in named groups): their links are dropped.
|
||||
let mut global: Vec<String> = vec![];
|
||||
let mut global: HashSet<String> = HashSet::new();
|
||||
for n in &names {
|
||||
let swatches: Vec<&Swatch> = match d.swatch_groups.iter().find(|g| g.name == *n) {
|
||||
Some(g) => g.swatches.iter().collect(),
|
||||
None => vec![d.swatch(n).ok_or_else(|| bad(C, format!("no swatch or colour group `{n}`")))?],
|
||||
None => vec![by_name.get(n.as_str()).copied().ok_or_else(|| bad(C, format!("no swatch or colour group `{n}`")))?],
|
||||
};
|
||||
if swatches.iter().any(|w| w.is_reserved()) {
|
||||
return Err(bad(C, format!("`{n}` can't be deleted")));
|
||||
@@ -258,10 +315,9 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|_: &mut Color, link: &mut Option<String>, _: &mut f32| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some();
|
||||
let colorized_ok = super::paint::check_colorized(d).is_ok();
|
||||
let unlinked = s.edit("Delete Swatch", |d, _| {
|
||||
d.swatch_groups.retain(|g| !names.contains(&g.name));
|
||||
for n in &names {
|
||||
d.remove_swatch(n);
|
||||
}
|
||||
let named: HashSet<&str> = names.iter().map(String::as_str).collect();
|
||||
d.swatch_groups.retain(|g| !named.contains(g.name.as_str()));
|
||||
take_swatches(d, &names, |_| true);
|
||||
let unlinked = map_swatch_links(d, &mut unlink);
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
|
||||
Ok(unlinked)
|
||||
@@ -347,6 +403,7 @@ fn gradient_key(g: &Gradient) -> Option<GradientKey<'_>> {
|
||||
GradientKind::Linear => 0,
|
||||
GradientKind::Radial => 1,
|
||||
GradientKind::Freeform => 2,
|
||||
GradientKind::Conic => 3,
|
||||
};
|
||||
let method = match g.method {
|
||||
GradientMethod::Classic => 0,
|
||||
@@ -419,15 +476,9 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
new.extend(art.into_iter().map(|c| (c, to_global)));
|
||||
}
|
||||
let name = d.free_swatch_name(requested.as_deref().unwrap_or("Color Group"));
|
||||
let mut group = SwatchGroup { name: name.clone(), swatches: vec![] };
|
||||
// Colour groups hold solid colours only.
|
||||
for n in &names {
|
||||
if d.swatch(n).is_some_and(|sw| matches!(sw.paint, Paint::Solid { .. }))
|
||||
&& let Some(sw) = d.remove_swatch(n)
|
||||
{
|
||||
group.swatches.push(sw);
|
||||
}
|
||||
}
|
||||
let swatches = take_swatches(d, &names, |sw| matches!(sw.paint, Paint::Solid { .. }));
|
||||
let mut group = SwatchGroup { name: name.clone(), swatches };
|
||||
// The global swatches made from the art's colours, which its unlinked paints link to.
|
||||
let mut links: Vec<(Color, String)> = vec![];
|
||||
let mut free = d.free_swatch_names();
|
||||
@@ -483,8 +534,9 @@ fn swatch_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
insert_before(&mut d.swatch_groups, items, at.as_deref(), |g| &g.name);
|
||||
return Ok(());
|
||||
}
|
||||
let by_name = swatches_by_name(d);
|
||||
for n in &names {
|
||||
let sw = d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}` (swatches and colour groups move separately)")))?;
|
||||
let sw = by_name.get(n.as_str()).ok_or_else(|| bad(C, format!("no swatch `{n}` (swatches and colour groups move separately)")))?;
|
||||
if sw.is_reserved() {
|
||||
return Err(bad(C, format!("`{n}` can't be moved")));
|
||||
}
|
||||
@@ -503,7 +555,7 @@ fn swatch_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let at = anchor(list(d, dest), to, &names, |w| &w.name);
|
||||
let items: Vec<Swatch> = names.iter().filter_map(|n| d.remove_swatch(n)).collect();
|
||||
let items = take_swatches(d, &names, |_| true);
|
||||
insert_before(list(d, dest), items, at.as_deref(), |w| &w.name);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -955,8 +1007,9 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.merge";
|
||||
let names = names_param(p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let by_name = swatches_by_name(d);
|
||||
for n in &names {
|
||||
let sw = d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}`")))?;
|
||||
let sw = by_name.get(n.as_str()).ok_or_else(|| bad(C, format!("no swatch `{n}`")))?;
|
||||
if sw.is_reserved() {
|
||||
return Err(bad(C, format!("`{n}` can't be merged")));
|
||||
}
|
||||
@@ -970,9 +1023,10 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let kept = d.swatch(keep).ok_or_else(|| bad(C, format!("no swatch `{keep}`")))?;
|
||||
let (color, link) = (d.linked_color(kept.paint.color().unwrap_or_default(), kept.spot), kept.global.then(|| keep.clone()));
|
||||
let merged_names: HashSet<&str> = merged.iter().map(String::as_str).collect();
|
||||
// Tints keep their tint (of the kept colour).
|
||||
let mut relink = |c: &mut Color, l: &mut Option<String>, t: &mut f32| {
|
||||
if !l.as_ref().is_some_and(|l| merged.contains(l)) {
|
||||
if !l.as_ref().is_some_and(|l| merged_names.contains(l.as_str())) {
|
||||
return false;
|
||||
}
|
||||
*c = color.tinted(*t);
|
||||
@@ -981,9 +1035,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let colorized_ok = super::paint::check_colorized(d).is_ok();
|
||||
let relinked = s.edit("Merge Swatches", |d, _| {
|
||||
for n in merged {
|
||||
d.remove_swatch(n);
|
||||
}
|
||||
take_swatches(d, merged, |_| true);
|
||||
let relinked = map_swatch_links(d, &mut relink);
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
|
||||
Ok(relinked)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! pattern) into the document; `swatch.library.save` writes the document's ([`palette_io`]).
|
||||
//! `swatch.library.copyToUser` copies a loaded library into the user library folder.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -211,6 +211,22 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
self.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.as_deref())
|
||||
}
|
||||
|
||||
/// The ids [`Libraries::get`] finds: the User Defined and loaded libraries', and the loaded ids of
|
||||
/// libraries copied into the user library folder.
|
||||
fn ids(&self) -> impl Iterator<Item = String> + '_ {
|
||||
self.extra.iter().map(|e| e.info.id.clone()).chain(self.copied.iter().map(|(loaded, _)| loaded.clone()))
|
||||
}
|
||||
|
||||
/// The file library `id` was read from, when the request being served may read it.
|
||||
pub fn shown_path(&self, id: &str) -> Option<&str> {
|
||||
self.path(id).filter(|p| readable(p))
|
||||
}
|
||||
|
||||
/// The user library folder, when the request being served may read it.
|
||||
pub fn shown_user_dir(&self) -> Option<&str> {
|
||||
self.user_dir().filter(|d| readable(d))
|
||||
}
|
||||
|
||||
/// The User Defined library whose file has the extension of file name `file` and holds `bytes`.
|
||||
fn user_file(&self, file: &str, bytes: &[u8]) -> Option<&Extra<L>> {
|
||||
let ext = extension(file);
|
||||
@@ -281,7 +297,10 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
self.saved_as
|
||||
.insert(format!("user/{file}").to_lowercase(), SavedAs { name: name.to_string(), hash: vectorcraft_doc::links::hash_bytes(bytes) });
|
||||
self.rescan();
|
||||
out["path"] = json!(path);
|
||||
// The folder is the app's own: a confined request sees the file only inside its read roots.
|
||||
if readable(&path) {
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
out["library"] = json!(format!("user/{file}"));
|
||||
} else if let Some(path) = path {
|
||||
write_file(path, bytes)?;
|
||||
@@ -349,6 +368,10 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
|
||||
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
|
||||
Some(p) => {
|
||||
// Outside the request's read roots: the error names no path.
|
||||
if !readable(&p) {
|
||||
return Err(bad(cmd, "the library's file is outside the read roots"));
|
||||
}
|
||||
let ext = extension(&p);
|
||||
let base = file_name(&p);
|
||||
// A sandbox copy of an import often lands as tmp….tmp.acb (#2996): store it under the
|
||||
@@ -446,6 +469,72 @@ fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without
|
||||
/// roots in force.
|
||||
fn readable(path: &str) -> bool {
|
||||
crate::file_access::check_read(path).is_ok()
|
||||
}
|
||||
|
||||
/// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each
|
||||
/// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is
|
||||
/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then
|
||||
/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free
|
||||
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The
|
||||
/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows
|
||||
/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none.
|
||||
pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = String>) -> Vec<(String, String)> {
|
||||
if crate::file_access::current().is_none() {
|
||||
return vec![];
|
||||
}
|
||||
let session: BTreeSet<String> =
|
||||
s.swatch_libraries.ids().chain(s.brush_libraries.ids()).chain(s.symbol_libraries.ids()).chain(s.style_libraries.ids()).collect();
|
||||
let others: BTreeSet<String> = others.into_iter().filter(|id| !session.contains(id)).collect();
|
||||
let mut hidden = Vec::new();
|
||||
let mut taken = BTreeSet::new();
|
||||
for ids in [session, others] {
|
||||
for id in ids {
|
||||
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (file_name(path), path));
|
||||
match name {
|
||||
Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)),
|
||||
_ => {
|
||||
taken.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hidden
|
||||
.into_iter()
|
||||
.map(|(id, name)| {
|
||||
let mut shown = format!("loaded/{name}");
|
||||
let mut n = 2;
|
||||
while taken.contains(&shown) {
|
||||
shown = format!("loaded/{name} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
taken.insert(shown.clone());
|
||||
(id, shown)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The id a reply shows for library `id`, given the [`concealed_library_ids`] in force.
|
||||
pub(crate) fn shown_id(concealed: &[(String, String)], id: &str) -> String {
|
||||
concealed.iter().find(|(own, _)| own == id).map_or_else(|| id.to_string(), |(_, shown)| shown.clone())
|
||||
}
|
||||
|
||||
/// The id a reply shows for library `id` ([`concealed_library_ids`]).
|
||||
pub(crate) fn shown_library_id(s: &Session, id: &str) -> String {
|
||||
shown_id(&concealed_library_ids(s, []), id)
|
||||
}
|
||||
|
||||
/// The library id behind `key` when `key` is the id a reply shows for a concealed loaded library.
|
||||
pub(crate) fn concealed_library_id(s: &Session, key: &str) -> Option<String> {
|
||||
if !key.starts_with("loaded/") {
|
||||
return None;
|
||||
}
|
||||
concealed_library_ids(s, []).into_iter().find(|(_, shown)| shown == key).map(|(own, _)| own)
|
||||
}
|
||||
|
||||
/// `name` without its extension.
|
||||
pub(crate) fn stem(name: &str) -> &str {
|
||||
name.rsplit_once('.').map_or(name, |(s, _)| s)
|
||||
@@ -479,6 +568,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<SwatchLibrary
|
||||
if let Some(found) = s.swatch_libraries.get(key) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(found) = concealed_library_id(s, key).and_then(|id| s.swatch_libraries.get(&id)) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(b) = builtins().find(|(_, b)| b.id == key) {
|
||||
return Some((builtin_info(b), builtin_library(key)?));
|
||||
}
|
||||
@@ -556,14 +648,16 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.swatch_libraries.rescan();
|
||||
let concealed = concealed_library_ids(s, []);
|
||||
let libs: Vec<Value> = libraries(s)
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len());
|
||||
json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.swatch_libraries.path(&l.id)})
|
||||
let (id, path) = (shown_id(&concealed, &l.id), s.swatch_libraries.shown_path(&l.id));
|
||||
json!({"id": id, "name": l.name, "category": l.category, "count": count, "path": path})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.swatch_libraries.user_dir() }))
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.swatch_libraries.shown_user_dir() }))
|
||||
}
|
||||
|
||||
/// The document's swatches `names` (swatches or colour groups; empty: all) as a library named
|
||||
@@ -619,7 +713,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
document_library(&loaded.doc, &[], stem(file).to_string(), C)
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
|
||||
Ok(json!({"library": shown_library_id(s, &info.id), "name": info.name, "count": lib.len()}))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -629,7 +723,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let groups: Vec<Value> =
|
||||
lib.groups.iter().map(|g| json!({"name": g.name, "swatches": g.swatches.iter().map(|w| &w.name).collect::<Vec<_>>()})).collect();
|
||||
Ok(
|
||||
json!({"id": info.id, "name": info.name, "category": info.category, "swatches": ungrouped.chain(grouped).collect::<Vec<_>>(), "groups": groups}),
|
||||
json!({"id": shown_library_id(s, &info.id), "name": info.name, "category": info.category, "swatches": ungrouped.chain(grouped).collect::<Vec<_>>(), "groups": groups}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -652,7 +746,7 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"user" => (info, lib, false),
|
||||
_ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))),
|
||||
};
|
||||
let path = s.swatch_libraries.path(&info.id);
|
||||
let path = s.swatch_libraries.shown_path(&info.id);
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied}))
|
||||
}
|
||||
|
||||
@@ -792,7 +886,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?
|
||||
};
|
||||
let names = |list: &[(String, String)]| list.iter().map(|(_, n)| n.clone()).collect::<Vec<_>>();
|
||||
let mut out = json!({"library": info.id, "added": names(&added), "existing": names(&existing)});
|
||||
let mut out = json!({"library": shown_library_id(s, &info.id), "added": names(&added), "existing": names(&existing)});
|
||||
if let (Some(cmd), Some((_, first))) = (apply, picked.first()) {
|
||||
let name = added.iter().chain(&existing).find(|(l, _)| *l == first.name).map_or(first.name.clone(), |(_, n)| n.clone());
|
||||
s.execute(cmd, &json!({"swatch": name, "focus": focus}))?;
|
||||
|
||||
@@ -19,7 +19,7 @@ use super::brushsym::{CURRENT_SYMBOL, natural_size, normalize, place_xf, set_nat
|
||||
use super::clipboard::Clipboard;
|
||||
use super::clipboard::resources::SwatchChoices;
|
||||
use super::swatch::str_list;
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem};
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, concealed_library_id, concealed_library_ids, shown_id, shown_library_id, stem};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, number_or, str_param};
|
||||
|
||||
/// The extension of saved symbol libraries: native documents.
|
||||
@@ -386,6 +386,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<SymbolLibrary
|
||||
if let Some(found) = s.symbol_libraries.get(key) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(found) = concealed_library_id(s, key).and_then(|id| s.symbol_libraries.get(&id)) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(b) = BUILTIN.iter().find(|b| b.id == key) {
|
||||
return Some((builtin_info(b), builtin_library(key)?));
|
||||
}
|
||||
@@ -400,14 +403,16 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.symbol_libraries.rescan();
|
||||
let concealed = concealed_library_ids(s, []);
|
||||
let libs: Vec<Value> = libraries(s)
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len());
|
||||
json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.symbol_libraries.path(&l.id)})
|
||||
let (id, path) = (shown_id(&concealed, &l.id), s.symbol_libraries.shown_path(&l.id));
|
||||
json!({"id": id, "name": l.name, "category": l.category, "count": count, "path": path})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.symbol_libraries.user_dir() }))
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.symbol_libraries.shown_user_dir() }))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -421,7 +426,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
json!({"name": sym.name, "size": [w, h], "registration": d.symbol_registration(&sym.name).0, "exportType": d.symbol_export_type(&sym.name).id()})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"id": info.id, "name": info.name, "category": info.category, "symbols": symbols}))
|
||||
Ok(json!({"id": shown_library_id(s, &info.id), "name": info.name, "category": info.category, "symbols": symbols}))
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -456,7 +461,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let centre = st.doc.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
|
||||
let at = if place { Some(Point::new(number_or(p, "x", centre.x, C)?, number_or(p, "y", centre.y, C)?)) } else { None };
|
||||
let parent = if place { st.target_parent()? } else { None };
|
||||
let mut out = json!({ "library": info.id });
|
||||
let mut out = json!({ "library": shown_library_id(s, &info.id) });
|
||||
if imported.added > 0 || place {
|
||||
let placed = s.edit("Add to Symbols", |d, sel| {
|
||||
let imported = clip.import_into(d, &choices, false)?;
|
||||
@@ -512,7 +517,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
SymbolLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e))
|
||||
})?;
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
|
||||
Ok(json!({"library": shown_library_id(s, &info.id), "name": info.name, "count": lib.len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area / Path Type",
|
||||
[],
|
||||
None,
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}",
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}",
|
||||
has_doc,
|
||||
create_in_path
|
||||
),
|
||||
@@ -1637,7 +1637,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
super::create::new_type_alignment(s, p, C, &mut t)?;
|
||||
if !on_path {
|
||||
t.area.fit = super::create::new_area_fit(s, p, C)?;
|
||||
super::create::new_area_fit(s, p, C, &mut t)?;
|
||||
}
|
||||
t.system_layout = s.doc()?.doc.setup.system_layout;
|
||||
if bool_or(p, "placeholder", false) {
|
||||
|
||||
@@ -143,7 +143,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| edit_style(s, p, Kind::Char),
|
||||
true,
|
||||
));
|
||||
v.extend(style_cmds!(Kind::Para, "paraStyle", "Paragraph Styles", "Paragraph", ""));
|
||||
v.extend(style_cmds!(
|
||||
Kind::Para,
|
||||
"paraStyle",
|
||||
"Paragraph Styles",
|
||||
"Paragraph",
|
||||
"; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt"
|
||||
));
|
||||
v.push(spec(
|
||||
"paraStyle.edit",
|
||||
"Edit Paragraph Style",
|
||||
&[],
|
||||
"{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt",
|
||||
|s, p| edit_style(s, p, Kind::Para),
|
||||
true,
|
||||
));
|
||||
v.push(spec("textStyle.loadAll", "Load All Styles…", &["Window", "Type"], LOAD_PARAMS, |s, p| load(s, p, &[Kind::Char, Kind::Para]), true));
|
||||
v
|
||||
}
|
||||
@@ -554,7 +568,11 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
|
||||
}
|
||||
char_attrs(&st)
|
||||
}
|
||||
Kind::Para => para_attrs(&with_attrs(&ParaStyle::default(), m, &c)?),
|
||||
Kind::Para => {
|
||||
let st = with_attrs(&ParaStyle::default(), m, &c)?;
|
||||
para_ranges(&st, m, kept, &c)?;
|
||||
para_attrs(&st)
|
||||
}
|
||||
};
|
||||
Ok(Some(m.keys().filter_map(|k| full.get(k).map(|v| (k.clone(), v.clone()))).collect()))
|
||||
}
|
||||
@@ -562,6 +580,82 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
|
||||
}
|
||||
}
|
||||
|
||||
/// The range of a paragraph style's indents, space before and space after, and Hyphenation Zone (pt),
|
||||
/// as `text.setFormat` keeps them.
|
||||
const PARA_DISTANCE: f64 = 1296.0;
|
||||
|
||||
/// Check the paragraph attributes of `st` (read from `attrs`) that differ from `kept` against the
|
||||
/// ranges `text.setFormat` keeps them to: the indents and the space before and after, each
|
||||
/// Hyphenation option, each Justification option (Minimum, Desired and Maximum in order, as
|
||||
/// `attrs` gives them) and the position of each tab stop. A value `kept` has is not checked.
|
||||
fn para_ranges(st: &ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<String, Value>>, c: &str) -> Result<()> {
|
||||
let within = |key: &str, value: f64, lo: f64, hi: f64| {
|
||||
if (lo..=hi).contains(&value) { Ok(()) } else { Err(bad(c, format!("`{key}` must be from {lo} to {hi}"))) }
|
||||
};
|
||||
let changed = |key: &str| attrs.contains_key(key) && kept.and_then(|k| k.get(key)) != attrs.get(key);
|
||||
let nested_changed = |key: &str, field: &str| {
|
||||
changed(key) && attrs.get(key).and_then(|v| v.get(field)) != kept.and_then(|k| k.get(key)).and_then(|v| v.get(field))
|
||||
};
|
||||
for (key, value) in [
|
||||
("left_indent", st.left_indent),
|
||||
("right_indent", st.right_indent),
|
||||
("first_line_indent", st.first_line_indent),
|
||||
("space_before", st.space_before),
|
||||
("space_after", st.space_after),
|
||||
] {
|
||||
if changed(key) {
|
||||
within(key, value, -PARA_DISTANCE, PARA_DISTANCE)?;
|
||||
}
|
||||
}
|
||||
let h = &st.hyphenation;
|
||||
for (field, value, lo, hi) in [
|
||||
("words_longer_than", f64::from(h.words_longer_than), 1.0, 25.0),
|
||||
("after_first", f64::from(h.after_first), 1.0, 15.0),
|
||||
("before_last", f64::from(h.before_last), 1.0, 15.0),
|
||||
("hyphen_limit", f64::from(h.hyphen_limit), 0.0, 25.0),
|
||||
("zone", h.zone, 0.0, PARA_DISTANCE),
|
||||
] {
|
||||
if nested_changed("hyphenation", field) {
|
||||
within(&format!("hyphenation.{field}"), value, lo, hi)?;
|
||||
}
|
||||
}
|
||||
// Read as given: a paragraph's attributes keep justification options in range as they load.
|
||||
if let Some(given) = attrs.get("justification").filter(|_| changed("justification")) {
|
||||
let j: vectorcraft_doc::JustificationOptions = serde_json::from_value(given.clone()).map_err(|e| bad(c, e.to_string()))?;
|
||||
for (field, values, (lo, hi)) in [
|
||||
("word_spacing", j.word_spacing, vectorcraft_doc::JustificationOptions::WORD_SPACING),
|
||||
("letter_spacing", j.letter_spacing, vectorcraft_doc::JustificationOptions::LETTER_SPACING),
|
||||
("glyph_scaling", j.glyph_scaling, vectorcraft_doc::JustificationOptions::GLYPH_SCALING),
|
||||
] {
|
||||
if !nested_changed("justification", field) {
|
||||
continue;
|
||||
}
|
||||
let name = format!("justification.{field}");
|
||||
for value in values {
|
||||
within(&name, value, lo, hi)?;
|
||||
}
|
||||
if !(values[0] <= values[1] && values[1] <= values[2]) {
|
||||
return Err(bad(c, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum")));
|
||||
}
|
||||
}
|
||||
if nested_changed("justification", "auto_leading") {
|
||||
let (lo, hi) = vectorcraft_doc::JustificationOptions::AUTO_LEADING;
|
||||
within("justification.auto_leading", j.auto_leading, lo, hi)?;
|
||||
}
|
||||
}
|
||||
if changed("tabs") {
|
||||
let kept_tabs: Vec<f64> = kept
|
||||
.and_then(|k| k.get("tabs"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|stops| stops.iter().filter_map(|s| s.get("position").and_then(Value::as_f64)).collect())
|
||||
.unwrap_or_default();
|
||||
for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(&stop.position)) {
|
||||
within("tabs.position", stop.position, 0.0, 100_000.0)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check a character style's `fill` or `stroke` (`key`): each solid color and gradient stop has a
|
||||
/// tint from 0 to 1, and a link to a global swatch of `d`, whose color at that tint it takes.
|
||||
fn style_paint(d: &Document, paint: &mut vectorcraft_color::Paint, key: &str, c: &str) -> Result<()> {
|
||||
|
||||
@@ -1585,3 +1585,147 @@ fn para_style_range_capture_samples_start_paragraph() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paras(s: &Session, id: u64) -> Vec<ParaStyle> {
|
||||
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.paragraph_styles(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `paraStyle.edit` renames a paragraph style and replaces its attributes in one undo step: every
|
||||
/// paragraph using it takes the new values, a paragraph's local override stays, and the saved file
|
||||
/// keeps the sparse definition.
|
||||
#[test]
|
||||
fn paragraph_style_options_edit_applies_to_users_and_is_one_saved_undo_step() {
|
||||
use vectorcraft_doc::Composer;
|
||||
let (mut s, id) = session_with_text("One\nTwo\nThree");
|
||||
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"left_indent": 6.0, "composer": "everyLine", "tabs": [{"position": 36.0}]}}))
|
||||
.unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Body", "id": id, "start": 0, "end": 7})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"id": id, "start": 4, "end": 4, "leftIndent": 20.0})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let attrs = json!({"left_indent": 12.0, "composer": "singleLine", "tabs": [{"position": 36.0}],
|
||||
"hyphenation": {"words_longer_than": 8}, "justification": {"word_spacing": [70.0, 90.0, 120.0]}});
|
||||
s.execute("paraStyle.edit", &json!({"name": "Body", "to": "Text", "attrs": attrs})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let p = paras(&s, id);
|
||||
assert_eq!(
|
||||
p.iter().map(|p| (p.style_name.as_deref(), p.left_indent, p.composer)).collect::<Vec<_>>(),
|
||||
[(Some("Text"), 12.0, Composer::SingleLine), (Some("Text"), 20.0, Composer::SingleLine), (None, 0.0, Composer::EveryLine)]
|
||||
);
|
||||
assert_eq!(p[0].hyphenation.words_longer_than, 8);
|
||||
assert_eq!(p[1].justification.word_spacing, [70.0, 90.0, 120.0]);
|
||||
let def = s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Text").unwrap().attrs.clone();
|
||||
let mut keys: Vec<&str> = def.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(keys, ["composer", "hyphenation", "justification", "left_indent", "tabs"]);
|
||||
let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(saved.para_styles.iter().find(|d| d.name == "Text").unwrap().attrs, def);
|
||||
let changed = s.doc().unwrap().doc.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, changed);
|
||||
// The Normal style, an empty or a taken name and an unknown style are errors.
|
||||
s.execute("paraStyle.new", &json!({"name": "Other"})).unwrap();
|
||||
for (params, why) in [
|
||||
(json!({"name": "Text", "to": " ", "attrs": {}}), "give the style a name"),
|
||||
(json!({"name": "Text", "to": "Other", "attrs": {}}), "already exists"),
|
||||
(json!({"name": "Missing", "to": "Missing", "attrs": {}}), "no style `Missing`"),
|
||||
(json!({"name": NORMAL_PARA, "to": NORMAL_PARA, "attrs": {}}), "can't be edited"),
|
||||
] {
|
||||
let error = s.execute("paraStyle.edit", ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains(why), "{params}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A changed paragraph attribute outside the range `text.setFormat` keeps it to is an error for
|
||||
/// `paraStyle.edit`, `paraStyle.setAttrs` and `paraStyle.new`, with no change to the document; the
|
||||
/// ends of each range are accepted, and an out-of-range value the style already has can be kept.
|
||||
#[test]
|
||||
fn paragraph_style_attributes_are_checked_before_a_definition_changes() {
|
||||
let (mut s, _) = session_with_text("Text");
|
||||
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"left_indent": 6.0}})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for (attrs, key) in [
|
||||
(json!({"left_indent": -1296.1}), "left_indent"),
|
||||
(json!({"right_indent": 1296.1}), "right_indent"),
|
||||
(json!({"first_line_indent": 1e300}), "first_line_indent"),
|
||||
(json!({"space_before": 1296.1}), "space_before"),
|
||||
(json!({"space_after": -1296.1}), "space_after"),
|
||||
(json!({"hyphenation": {"words_longer_than": 0}}), "hyphenation.words_longer_than"),
|
||||
(json!({"hyphenation": {"words_longer_than": 4000000000_u64}}), "hyphenation.words_longer_than"),
|
||||
(json!({"hyphenation": {"after_first": 16}}), "hyphenation.after_first"),
|
||||
(json!({"hyphenation": {"before_last": 0}}), "hyphenation.before_last"),
|
||||
(json!({"hyphenation": {"hyphen_limit": 26}}), "hyphenation.hyphen_limit"),
|
||||
(json!({"hyphenation": {"zone": -1.0}}), "hyphenation.zone"),
|
||||
(json!({"hyphenation": {"zone": 1296.1}}), "hyphenation.zone"),
|
||||
(json!({"justification": {"word_spacing": [80.0, 100.0, 1000.1]}}), "justification.word_spacing"),
|
||||
(json!({"justification": {"word_spacing": [120.0, 100.0, 133.0]}}), "justification.word_spacing"),
|
||||
(json!({"justification": {"letter_spacing": [-100.1, 0.0, 0.0]}}), "justification.letter_spacing"),
|
||||
(json!({"justification": {"letter_spacing": [0.0, 10.0, 5.0]}}), "justification.letter_spacing"),
|
||||
(json!({"justification": {"glyph_scaling": [49.0, 100.0, 100.0]}}), "justification.glyph_scaling"),
|
||||
(json!({"justification": {"auto_leading": 500.1}}), "justification.auto_leading"),
|
||||
(json!({"tabs": [{"position": 100000.1}]}), "tabs.position"),
|
||||
(json!({"tabs": [{"position": -1.0}]}), "tabs.position"),
|
||||
] {
|
||||
for cmd in ["paraStyle.edit", "paraStyle.setAttrs", "paraStyle.new"] {
|
||||
let params = match cmd {
|
||||
"paraStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": attrs}),
|
||||
"paraStyle.new" => json!({"name": "Other", "attrs": attrs}),
|
||||
_ => json!({"name": "Body", "attrs": attrs}),
|
||||
};
|
||||
let error = s.execute(cmd, ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains(&format!("`{key}`")), "{cmd} {attrs}: {error}");
|
||||
}
|
||||
}
|
||||
// Malformed values are errors too.
|
||||
for attrs in [json!({"hyphenation": {"words_longer_than": -1}}), json!({"justification": {"word_spacing": [1.0, 2.0]}}), json!({"tabs": 3})] {
|
||||
assert!(s.execute("paraStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": attrs})).is_err(), "{attrs}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
|
||||
let ends = json!({
|
||||
"left_indent": -1296.0, "right_indent": 1296.0, "first_line_indent": 0.0, "space_before": 1296.0, "space_after": -1296.0,
|
||||
"hyphenation": {"words_longer_than": 25, "after_first": 1, "before_last": 15, "hyphen_limit": 0, "zone": 1296.0},
|
||||
"justification": {"word_spacing": [0.0, 0.0, 1000.0], "letter_spacing": [-100.0, 0.0, 500.0], "glyph_scaling": [50.0, 100.0, 200.0], "auto_leading": 500.0},
|
||||
"tabs": [{"position": 0.0}, {"position": 100000.0}]
|
||||
});
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": ends})).unwrap();
|
||||
// A style that already holds a value out of range (one read from a file) can be renamed with it.
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("left_indent".into(), json!(2000.0));
|
||||
s.execute("paraStyle.edit", &json!({"name": "Body", "to": "Renamed", "attrs": {"left_indent": 2000.0}})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.para_styles.iter().any(|d| d.name == "Renamed"));
|
||||
// Such a Hyphenation, Justification or tab value is kept while another option of its group changes,
|
||||
// and a changed option is still checked.
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let held = json!({
|
||||
"hyphenation": {"words_longer_than": 40, "after_first": 2},
|
||||
"justification": {"letter_spacing": [0.0, 0.0, 600.0], "auto_leading": 120.0},
|
||||
"tabs": [{"position": 200000.0}]
|
||||
});
|
||||
let attrs = &mut doc.para_styles.iter_mut().find(|d| d.name == "Renamed").unwrap().attrs;
|
||||
attrs.extend(held.as_object().unwrap().clone());
|
||||
let edited = json!({
|
||||
"hyphenation": {"words_longer_than": 40, "after_first": 3},
|
||||
"justification": {"letter_spacing": [0.0, 0.0, 600.0], "auto_leading": 150.0},
|
||||
"tabs": [{"position": 200000.0}, {"position": 72.0}]
|
||||
});
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Renamed", "attrs": edited})).unwrap();
|
||||
let def = |s: &Session| s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Renamed").unwrap().attrs.clone();
|
||||
assert_eq!(def(&s)["hyphenation"]["after_first"], json!(3));
|
||||
assert_eq!(def(&s)["justification"]["auto_leading"], json!(150.0));
|
||||
for (attrs, key) in [
|
||||
(json!({"hyphenation": {"words_longer_than": 40, "after_first": 16}}), "hyphenation.after_first"),
|
||||
(json!({"justification": {"auto_leading": 501.0}}), "justification.auto_leading"),
|
||||
(json!({"tabs": [{"position": 200000.0}, {"position": 72.0}, {"position": 100001.0}]}), "tabs.position"),
|
||||
] {
|
||||
let error = s.execute("paraStyle.setAttrs", &json!({"name": "Renamed", "attrs": attrs})).unwrap_err().to_string();
|
||||
assert!(error.contains(&format!("`{key}`")), "{attrs}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area Type Options…",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?|id?, width?: pt, height?: pt, rows?, columns?, gutter?: pt (between rows and columns), rowGutter?: pt|null (between rows when it differs from gutter, which is then between columns; gutter given alone sets both; the reply gives the rows' gutter either way), inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, flow?: columns|rows (Text Flow: down each column's rows, then across, the default; or across each row's columns, then down), fit?: none|autoHeight|shrinkText, fitMinPercent?: 10..100, …the keys of its own reply (it can be passed back; the values it only reports are ignored)} set the selected area type's options (each frame takes the options given over its own and keeps the rest; fit resolves against each frame's own; options that change nothing add no undo step); width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit (none given: query) → the first object's options, plus overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)",
|
||||
"{ids?|id?, width?: pt, height?: pt, rows?, columns?, gutter?: pt (between rows and columns), rowGutter?: pt|null (between rows when it differs from gutter, which is then between columns; gutter given alone sets both; the reply gives the rows' gutter either way), inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, flow?: columns|rows (Text Flow: down each column's rows, then across, the default; or across each row's columns, then down), fit?: none|autoHeight|shrinkText|block, fitMinPercent?: 10..100, wordGap?: % 0..1000, lineGap?: pt -1296..1296, …the keys of its own reply (it can be passed back; the values it only reports are ignored)} set the selected area type's options (each frame takes the options given over its own and keeps the rest; fit resolves against each frame's own; options that change nothing add no undo step); width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit; fit block is Dynamic Text's Block, as type.dynamicText sets it (in place of Auto Size and Shrink Text to Fit; threaded frames are refused; another fit turns it off, keeping the sizes it wrote), and wordGap and lineGap set its gaps (kept while Block is off; out of range is an error) (none given: query) → the first object's options, plus wordGap and lineGap, overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)",
|
||||
has_selection,
|
||||
area_options
|
||||
),
|
||||
@@ -304,11 +304,13 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Recompute the layout caches (bounds and baselines) after a text edit. Every engine text edit ends here (typing
|
||||
/// through `text.editRange` too), inside its `Session::edit`, so this is where Auto Size area type
|
||||
/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo
|
||||
/// step as the edit.
|
||||
/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text, and Dynamic Text's Block
|
||||
/// (`dynamic_text` on area type, [`block::block_fit`]) sizes each line to the frame: part of the
|
||||
/// same undo step as the edit.
|
||||
pub(crate) fn refresh_bounds(t: &mut TextObject) {
|
||||
// Edits that changed the paragraph count keep one paragraph style per paragraph.
|
||||
t.normalize_paras();
|
||||
block::block_fit(t);
|
||||
auto_height(t);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
t.cached_bounds = Some(lay.bounds);
|
||||
@@ -320,6 +322,7 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) {
|
||||
/// ([`vectorcraft_text::take_kept_layout`]) instead of laying the object out again.
|
||||
pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) {
|
||||
t.normalize_paras();
|
||||
block::block_fit(t);
|
||||
auto_height(t);
|
||||
let stamp = vectorcraft_text::LayoutStamp::now();
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
@@ -1026,7 +1029,7 @@ pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObje
|
||||
}
|
||||
|
||||
/// Area type's text, if `n` is area type.
|
||||
fn area_text(n: Option<&Node>) -> Option<&TextObject> {
|
||||
pub(crate) fn area_text(n: Option<&Node>) -> Option<&TextObject> {
|
||||
match n.map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => Some(t),
|
||||
_ => None,
|
||||
@@ -1059,10 +1062,15 @@ fn size_area(t: &mut TextObject, w: Option<f64>, h: Option<f64>) -> bool {
|
||||
reshape_area_with(t, |t| t.transform_area(a))
|
||||
}
|
||||
|
||||
/// The fit `p` asks for (`fit`: none|autoHeight|shrinkText or `{"shrinkText": {"minPercent"}}`,
|
||||
/// `fitMinPercent`), starting from `cur`. None when `p` sets neither (or only `fitMinPercent`
|
||||
/// while the fit isn't Shrink Text).
|
||||
pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result<Option<AreaFit>> {
|
||||
/// A fit `p` asks for: the Area Type Options fit, and whether Dynamic Text's Block
|
||||
/// ([`TextObject::dynamic_text`]) is on. Block's fit is [`AreaFit::None`]: it replaces Auto Size
|
||||
/// and Shrink Text to Fit.
|
||||
pub(crate) type FitChoice = (AreaFit, bool);
|
||||
|
||||
/// The fit `p` asks for (`fit`: none|autoHeight|shrinkText|block or `{"shrinkText": {"minPercent"}}`,
|
||||
/// `fitMinPercent`), starting from `cur` (`block`: Block is on). None when `p` sets neither (or only
|
||||
/// `fitMinPercent` while the fit isn't Shrink Text).
|
||||
pub(crate) fn fit_param(p: &Value, cur: AreaFit, block: bool, c: &str) -> Result<Option<FitChoice>> {
|
||||
check_numbers(p, &["fitMinPercent"], c)?;
|
||||
let min = p.get("fitMinPercent").and_then(Value::as_f64);
|
||||
let cur_min = match cur {
|
||||
@@ -1071,21 +1079,48 @@ pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result<Option<AreaF
|
||||
};
|
||||
let fit = match p.get("fit") {
|
||||
None | Some(Value::Null) => match (cur, min) {
|
||||
(AreaFit::ShrinkText { .. }, Some(m)) => AreaFit::ShrinkText { min_percent: m },
|
||||
(AreaFit::ShrinkText { .. }, Some(m)) if !block => AreaFit::ShrinkText { min_percent: m },
|
||||
_ => return Ok(None),
|
||||
},
|
||||
Some(Value::String(id)) if is_block_id(id) => return Ok(Some((AreaFit::None, true))),
|
||||
Some(Value::String(id)) => {
|
||||
AreaFit::parse(id, min.or(cur_min)).ok_or_else(|| bad(c, format!("fit must be none|autoHeight|shrinkText, got {id}")))?
|
||||
AreaFit::parse(id, min.or(cur_min)).ok_or_else(|| bad(c, format!("fit must be none|autoHeight|shrinkText|block, got {id}")))?
|
||||
}
|
||||
Some(v) => match serde_json::from_value::<AreaFit>(v.clone()).map_err(|e| bad(c, format!("bad fit: {e}")))? {
|
||||
AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: min.unwrap_or(min_percent) },
|
||||
f => f,
|
||||
},
|
||||
};
|
||||
Ok(Some(match fit {
|
||||
AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: AreaFit::clamp_percent(min_percent) },
|
||||
f => f,
|
||||
}))
|
||||
Ok(Some((
|
||||
match fit {
|
||||
AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: AreaFit::clamp_percent(min_percent) },
|
||||
f => f,
|
||||
},
|
||||
false,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Whether a `fit` id names Dynamic Text's Block (case, spaces, dashes and underscores ignored).
|
||||
fn is_block_id(id: &str) -> bool {
|
||||
matches!(id.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str(), "block" | "dynamictext")
|
||||
}
|
||||
|
||||
/// The `fit` id area type reports: `block` while Dynamic Text's Block is on, else its fit's.
|
||||
pub(crate) fn fit_id(t: &TextObject) -> &'static str {
|
||||
if t.dynamic_text { "block" } else { t.area.fit.id() }
|
||||
}
|
||||
|
||||
/// Dynamic Text's Block gaps `p` gives: `wordGap` (% of a word space, 0..1000) and `lineGap` (pt,
|
||||
/// -1296..1296). A value that is not a number, or is outside its range, is an error.
|
||||
pub(crate) fn block_gaps(p: &Value, c: &str) -> Result<(Option<f64>, Option<f64>)> {
|
||||
let gap = |key: &str, (lo, hi): (f64, f64)| -> Result<Option<f64>> {
|
||||
match number_given(p, key, c)? {
|
||||
Some(v) if !(v.is_finite() && (lo..=hi).contains(&v)) => Err(bad(c, format!("`{key}` must be from {lo} to {hi}, not {v}"))),
|
||||
v => Ok(v),
|
||||
}
|
||||
};
|
||||
use vectorcraft_doc::AreaOptions as O;
|
||||
Ok((gap("wordGap", O::WORD_GAP)?, gap("lineGap", O::LINE_GAP)?))
|
||||
}
|
||||
|
||||
/// Area type's options as `text.areaOptions` reports them: the Area Type Options with `fit` as
|
||||
@@ -1095,7 +1130,7 @@ pub(crate) fn area_options_json(t: &TextObject) -> Result<Value> {
|
||||
let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("fit".into(), json!(t.area.fit.id()));
|
||||
o.insert("fit".into(), json!(fit_id(t)));
|
||||
o.insert("flow".into(), json!(t.area.flow));
|
||||
// The gutter between rows, the gutter's when the rows have none of their own.
|
||||
o.insert("rowGutter".into(), json!(t.area.row_gutter.unwrap_or(t.area.gutter)));
|
||||
@@ -1104,6 +1139,9 @@ pub(crate) fn area_options_json(t: &TextObject) -> Result<Value> {
|
||||
_ => AreaFit::DEFAULT_MIN_PERCENT,
|
||||
};
|
||||
o.insert("fitMinPercent".into(), json!(min));
|
||||
let (word_gap, line_gap) = t.area.block_gaps();
|
||||
o.insert("wordGap".into(), json!(word_gap));
|
||||
o.insert("lineGap".into(), json!(line_gap));
|
||||
if let Some((w, h, _)) = area_size(t) {
|
||||
o.insert("width".into(), json!(w));
|
||||
o.insert("height".into(), json!(h));
|
||||
@@ -1132,8 +1170,9 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Checked once up front (its errors don't depend on the frame); each frame resolves `fit`
|
||||
// against its own setting below.
|
||||
let cur = area_text(s.doc()?.doc.node(first)).map(|t| t.area.clone()).unwrap_or_default();
|
||||
fit_param(p, cur.fit, C)?;
|
||||
let fit_given = p.get("fit").is_some_and(|v| !v.is_null()) || p.get("fitMinPercent").is_some_and(|v| !v.is_null());
|
||||
fit_param(p, cur.fit, false, C)?;
|
||||
let (word_gap, line_gap) = block_gaps(p, C)?;
|
||||
let fit_given = ["fit", "fitMinPercent", "wordGap", "lineGap"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null()));
|
||||
let size = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(1.0, 100_000.0));
|
||||
let (w, h) = (size("width"), size("height"));
|
||||
let given: Vec<(&String, &Value)> = p.as_object().into_iter().flatten().filter(|(k, _)| AREA_KEYS.contains(&k.as_str())).collect();
|
||||
@@ -1142,7 +1181,7 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Each area type takes the options given over its own (#3759): setting one option on several
|
||||
// frames leaves their other options as they were.
|
||||
let merged = |own: &vectorcraft_doc::AreaOptions| -> Result<vectorcraft_doc::AreaOptions> {
|
||||
let merged = |own: &vectorcraft_doc::AreaOptions, block: bool| -> Result<(vectorcraft_doc::AreaOptions, bool)> {
|
||||
let mut v = serde_json::to_value(own).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
for (k, val) in &given {
|
||||
@@ -1162,17 +1201,29 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
opts.inset = finite(opts.inset).clamp(0.0, 10_000.0);
|
||||
opts.first_baseline_min = finite(opts.first_baseline_min).clamp(0.0, 10_000.0);
|
||||
// Each frame's fit from its own (`fitMinPercent` alone changes only Shrink Text frames).
|
||||
opts.fit = fit_param(p, own.fit, C)?.unwrap_or(own.fit);
|
||||
Ok(opts)
|
||||
let (fit, block) = fit_param(p, own.fit, block, C)?.unwrap_or((own.fit, block));
|
||||
opts.fit = fit;
|
||||
opts.word_gap = word_gap.unwrap_or(own.word_gap);
|
||||
opts.line_gap = line_gap.unwrap_or(own.line_gap);
|
||||
Ok((opts, block))
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let planned: Vec<(NodeId, vectorcraft_doc::AreaOptions)> = ids
|
||||
let planned: Vec<(NodeId, (vectorcraft_doc::AreaOptions, bool))> = ids
|
||||
.iter()
|
||||
.filter_map(|id| area_text(d.node(*id)).map(|t| (*id, &t.area)))
|
||||
.map(|(id, own)| merged(own).map(|o| (id, o)))
|
||||
.filter_map(|id| area_text(d.node(*id)).map(|t| (*id, t)))
|
||||
.map(|(id, t)| merged(&t.area, t.dynamic_text).map(|o| (id, o)))
|
||||
.collect::<Result<_>>()?;
|
||||
let apply = |t: &mut TextObject, opts: &vectorcraft_doc::AreaOptions| {
|
||||
// Dynamic Text's Block is refused for threaded frames, as `type.dynamicText` refuses it.
|
||||
let frames: Vec<String> =
|
||||
planned.iter().filter(|(id, (_, block))| *block && !super::threads::story_of(d, *id).is_empty()).map(|(id, _)| id.0.to_string()).collect();
|
||||
if !frames.is_empty() {
|
||||
let frames = frames.join(", ");
|
||||
return Err(bad(C, format!("area type in a thread cannot use Dynamic Text: {frames}")));
|
||||
}
|
||||
let apply = |t: &mut TextObject, (opts, block): &(vectorcraft_doc::AreaOptions, bool)| {
|
||||
t.area = opts.clone();
|
||||
// Block off keeps the sizes it wrote into the characters.
|
||||
t.dynamic_text = *block;
|
||||
// Auto Size sets the height (Illustrator's dialog turns the Height field off).
|
||||
let h = if opts.fit == AreaFit::AutoHeight { None } else { h };
|
||||
// A resized frame has laid its text out again; otherwise the new options do here.
|
||||
@@ -1313,6 +1364,9 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod area_tests;
|
||||
mod block;
|
||||
#[cfg(test)]
|
||||
mod block_tests;
|
||||
|
||||
/// Width of the first line and of the space it can fill, with `tracking` on the first paragraph.
|
||||
/// The third value is where the first line ends in the plain text.
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Dynamic Text's Block for area type ([`TextObject::dynamic_text`] on area type): each line as
|
||||
//! typed is set to the frame's width by changing its font size, after every edit
|
||||
//! ([`super::refresh_bounds`]).
|
||||
|
||||
use vectorcraft_doc::{TextKind, TextObject, TextRun};
|
||||
|
||||
/// The font size range a line's first character is fitted within, in document points (the
|
||||
/// Character panel's).
|
||||
const SIZES: (f64, f64) = (0.1, 1296.0);
|
||||
|
||||
/// Most passes a refit runs to correct lines that still wrap after the first fit; each pass measures
|
||||
/// the lines and runs at most one more area layout.
|
||||
pub(crate) const BLOCK_PASSES: usize = 4;
|
||||
|
||||
/// One line as typed: `start..content` is its text, `content..end` the `\n` or U+2028 that ends it
|
||||
/// (empty for the last line). `para` is the index of its paragraph, and `para_start` whether it
|
||||
/// starts that paragraph.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct TypedLine {
|
||||
start: usize,
|
||||
content: usize,
|
||||
end: usize,
|
||||
para: usize,
|
||||
para_start: bool,
|
||||
}
|
||||
|
||||
/// The lines as typed: the text split after each `\n` and U+2028.
|
||||
fn typed_lines(text: &str) -> Vec<TypedLine> {
|
||||
let mut out = vec![];
|
||||
let (mut start, mut para, mut para_start) = (0, 0, true);
|
||||
for (i, c) in text.char_indices() {
|
||||
if c == '\n' || c == '\u{2028}' {
|
||||
let end = i + c.len_utf8();
|
||||
out.push(TypedLine { start, content: i, end, para, para_start });
|
||||
start = end;
|
||||
para_start = c == '\n';
|
||||
if c == '\n' {
|
||||
para += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(TypedLine { start, content: text.len(), end: text.len(), para, para_start });
|
||||
out
|
||||
}
|
||||
|
||||
/// The stored size of the character at `byte` (the run that holds it, not the one before it).
|
||||
pub(crate) fn size_at(runs: &[TextRun], byte: usize) -> f64 {
|
||||
let mut off = 0;
|
||||
for r in runs {
|
||||
let end = off + r.text.len();
|
||||
if (off..end).contains(&byte) {
|
||||
return r.style.size;
|
||||
}
|
||||
off = end;
|
||||
}
|
||||
vectorcraft_text::edit::style_at(runs, byte).size
|
||||
}
|
||||
|
||||
/// The stored size of each line's first character, in one pass over the runs.
|
||||
fn first_sizes(runs: &[TextRun], lines: &[TypedLine]) -> Vec<f64> {
|
||||
let mut out = Vec::with_capacity(lines.len());
|
||||
let (mut ri, mut off) = (0, 0);
|
||||
for l in lines {
|
||||
while let Some(r) = runs.get(ri) {
|
||||
if l.start < off + r.text.len() {
|
||||
break;
|
||||
}
|
||||
off += r.text.len();
|
||||
ri += 1;
|
||||
}
|
||||
out.push(runs.get(ri).map_or_else(|| size_at(runs, l.start), |r| r.style.size));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Each line's content width (trailing spaces left out) by its first byte, with `t` laid out as
|
||||
/// point type, where no line wraps, with the word spacing `t`'s layout uses.
|
||||
fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collections::HashMap<usize, f64> {
|
||||
let mut probe = t.clone();
|
||||
for p in probe.para_styles_mut() {
|
||||
p.justification = t.layout_justification(p);
|
||||
}
|
||||
probe.kind = TextKind::Point;
|
||||
let laid = vectorcraft_text::layout(db, &probe);
|
||||
let mut out = std::collections::HashMap::with_capacity(laid.lines.len());
|
||||
for m in &laid.lines {
|
||||
out.entry(m.start).or_insert(m.x1 - m.x0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The size a line set at `size` points and `width` wide takes to fill `avail`, its width growing
|
||||
/// by `slope` a point of size (`width / size` when the whole line follows its size): rounded down
|
||||
/// to tenths and kept within [`SIZES`]. A line that fits and would not fit a tenth larger keeps its
|
||||
/// size rounded down to tenths, and None when that is its size, so fitting again changes nothing.
|
||||
/// Room taken entirely by indents gives the smallest size.
|
||||
fn refit(size: f64, width: f64, avail: f64, slope: f64) -> Option<f64> {
|
||||
if width <= avail + 1e-6 && width + 0.1 * slope > avail {
|
||||
let rounded = tenths(size);
|
||||
return ((rounded - size).abs() > 1e-9).then_some(rounded);
|
||||
}
|
||||
Some(tenths(size + (avail.max(0.0) - width) / slope))
|
||||
}
|
||||
|
||||
/// A size in document points rounded down to a whole number of tenths and kept within [`SIZES`].
|
||||
fn tenths(points: f64) -> f64 {
|
||||
((points * 10.0 + 1e-6).floor() / 10.0).clamp(SIZES.0, SIZES.1)
|
||||
}
|
||||
|
||||
/// Fit area type with Dynamic Text (Block) on: with the word gap as every paragraph's Desired word
|
||||
/// spacing ([`TextObject::layout_justification`]; the paragraphs keep their own values), each line
|
||||
/// as typed gets the size (its first character's, in steps of 0.1 pt within 0.1 to
|
||||
/// 1,296 pt, the line's other characters keeping their proportions to it) at which its width
|
||||
/// equals the frame's width less the inset, the gutters and the paragraph's indents, and each
|
||||
/// character's leading is its size plus the line gap. An empty line or one of only spaces keeps
|
||||
/// its size. Nothing changes when the frame has no width along the lines or the type's transform
|
||||
/// has no size. A line that still wraps at its first fit is made smaller, in at most
|
||||
/// [`BLOCK_PASSES`] more passes. True when the text changed.
|
||||
pub(crate) fn block_fit(t: &mut TextObject) -> bool {
|
||||
if !t.dynamic_text {
|
||||
return false;
|
||||
}
|
||||
let TextKind::Area { frame } = &t.kind else { return false };
|
||||
let (_, line_gap) = t.area.block_gaps();
|
||||
let Some(b) = frame.bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) else { return false };
|
||||
let Some(points) = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12) else { return false };
|
||||
let before = t.runs.clone();
|
||||
|
||||
// The room along a line in one cell of the frame, before indents.
|
||||
let a = &t.area;
|
||||
let (along, cells, gutter) = if t.vertical { (b.height(), a.rows, a.row_gutter.unwrap_or(a.gutter)) } else { (b.width(), a.columns, a.gutter) };
|
||||
let cells = cells.max(1) as f64;
|
||||
let cell = (along - 2.0 * a.inset.max(0.0) - gutter.max(0.0) * (cells - 1.0)) / cells;
|
||||
if !(cell.is_finite() && cell > 1e-9) {
|
||||
return t.runs != before;
|
||||
}
|
||||
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let text = t.plain_text();
|
||||
let lines = typed_lines(&text);
|
||||
let widths = line_widths(db, t);
|
||||
let sizes = first_sizes(&t.runs, &lines);
|
||||
// Per line: (factor applied to its sizes, the room it fills, the last size measured and its
|
||||
// width), or None to keep its size.
|
||||
let mut fits: Vec<Option<(f64, f64, f64, f64)>> = lines
|
||||
.iter()
|
||||
.zip(&sizes)
|
||||
.map(|(l, size)| {
|
||||
if l.content == l.start {
|
||||
return None;
|
||||
}
|
||||
let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?;
|
||||
let para = t.para_at(l.para);
|
||||
let first = if l.para_start { para.first_line_indent } else { 0.0 };
|
||||
let avail = cell - para.left_indent - para.right_indent - first;
|
||||
let size = size * points;
|
||||
if !(size.is_finite() && size > 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some((refit(size, width, avail, width / size).map_or(1.0, |n| n / size), avail, size, width))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let gap = line_gap / points;
|
||||
let min_leading = SIZES.0 / points;
|
||||
// Always from the sizes the refit started with, so each pass sets a line's size once: the runs
|
||||
// are split where the lines end and each piece scaled by its line's factor, in one pass.
|
||||
let apply = |t: &mut TextObject, from: &[TextRun], fits: &[Option<(f64, f64, f64, f64)>]| {
|
||||
let mut out = Vec::with_capacity(from.len() + lines.len());
|
||||
let (mut off, mut li) = (0, 0);
|
||||
for r in from {
|
||||
let end = off + r.text.len();
|
||||
let mut at = off;
|
||||
loop {
|
||||
while lines.get(li).is_some_and(|l| l.end <= at && li + 1 < lines.len()) {
|
||||
li += 1;
|
||||
}
|
||||
let stop = lines.get(li).map_or(end, |l| l.end.clamp(at, end));
|
||||
let stop = if stop == at { end } else { stop };
|
||||
let f = fits.get(li).copied().flatten().map_or(1.0, |(f, ..)| f);
|
||||
let mut piece = TextRun { text: r.text.get(at - off..stop - off).unwrap_or_default().to_string(), ..r.clone() };
|
||||
if at > off {
|
||||
piece.style.glyph_alternate = None;
|
||||
}
|
||||
let st = &mut piece.style;
|
||||
st.size *= f;
|
||||
st.baseline_shift *= f;
|
||||
st.tcy_up_down *= f;
|
||||
st.tcy_left_right *= f;
|
||||
st.leading = Some((st.size + gap).max(min_leading));
|
||||
if let Some(art) = &mut piece.inline {
|
||||
art.baseline_shift *= f;
|
||||
}
|
||||
out.push(piece);
|
||||
at = stop;
|
||||
if at >= end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
off = end;
|
||||
}
|
||||
vectorcraft_text::edit::normalize(&mut out);
|
||||
t.runs = out;
|
||||
};
|
||||
let start = t.runs.clone();
|
||||
apply(t, &start, &fits);
|
||||
|
||||
// Each pass measures the lines again: a line whose width did not follow its size (a tab to a
|
||||
// fixed stop) is fitted again from its measured width, and when every line measures right, a
|
||||
// line the frame's shape or a list marker still wraps is made smaller, by what the line the
|
||||
// layout gave it holds.
|
||||
// Per line: the smallest size measured too wide, which a later pass never goes back to.
|
||||
let mut too_wide = vec![f64::INFINITY; lines.len()];
|
||||
for _ in 0..BLOCK_PASSES {
|
||||
let now_widths = line_widths(db, t);
|
||||
let mut again = false;
|
||||
for (((l, fit), size), wide) in lines.iter().zip(fits.iter_mut()).zip(&sizes).zip(too_wide.iter_mut()) {
|
||||
let Some((f, avail, last_size, last_width)) = fit else { continue };
|
||||
let size = size * points;
|
||||
let now = size * *f;
|
||||
let Some(width) = now_widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9) else { continue };
|
||||
if width > *avail + 1e-6 {
|
||||
*wide = wide.min(now);
|
||||
}
|
||||
// The slope between the last two sizes measured, where they differ: a line whose width
|
||||
// has a part that stays as the size changes is fitted in one step.
|
||||
let slope = Some((width - *last_width) / (now - *last_size))
|
||||
.filter(|s| (now - *last_size).abs() > 1e-9 && s.is_finite() && *s > 1e-9)
|
||||
.unwrap_or(width / now);
|
||||
(*last_size, *last_width) = (now, width);
|
||||
let next = refit(now, width, *avail, slope).map(|n| if n >= *wide - 1e-9 { tenths(*wide - 0.1) } else { n });
|
||||
if let Some(next) = next.filter(|n| (n - now).abs() > 1e-9) {
|
||||
*f = next / size;
|
||||
again = true;
|
||||
}
|
||||
}
|
||||
if !again {
|
||||
let laid = vectorcraft_text::layout(db, t);
|
||||
// Per typed line: how many laid-out lines it took, and the room of its first.
|
||||
let mut parts: Vec<(usize, f64)> = vec![(0, 0.0); lines.len()];
|
||||
for m in &laid.lines {
|
||||
let i = lines.partition_point(|l| l.start <= m.start).saturating_sub(1);
|
||||
if let Some(p) = parts.get_mut(i) {
|
||||
if p.0 == 0 {
|
||||
p.1 = m.avail.1 - m.avail.0;
|
||||
}
|
||||
p.0 += 1;
|
||||
}
|
||||
}
|
||||
for (((l, fit), (count, room)), size) in lines.iter().zip(fits.iter_mut()).zip(&parts).zip(&sizes) {
|
||||
let Some((f, ..)) = fit else { continue };
|
||||
if *count < 2 {
|
||||
continue;
|
||||
}
|
||||
let size = size * points;
|
||||
let now = size * *f;
|
||||
if now <= SIZES.0 + 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let width = now_widths.get(&l.start).copied().unwrap_or(f64::INFINITY);
|
||||
let smaller = if room.is_finite() && *room > 0.0 && *room < width { tenths(now * room / width) } else { now };
|
||||
*f = smaller.min(tenths(now - 0.1)) / size;
|
||||
again = true;
|
||||
}
|
||||
}
|
||||
if !again {
|
||||
break;
|
||||
}
|
||||
apply(t, &start, &fits);
|
||||
}
|
||||
t.runs != before
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn typed_lines_split_after_returns_and_forced_breaks() {
|
||||
let l = typed_lines("ab\ncd\u{2028}e\n");
|
||||
let spans: Vec<_> = l.iter().map(|l| (l.start, l.content, l.end, l.para, l.para_start)).collect();
|
||||
assert_eq!(spans, vec![(0, 2, 3, 0, true), (3, 5, 8, 1, true), (8, 9, 10, 1, false), (10, 10, 10, 2, true)]);
|
||||
assert_eq!(typed_lines("").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fitting_line_keeps_its_size_in_tenths() {
|
||||
// 100 pt wide at 12 pt, 100.5 pt of room: a tenth larger would not fit.
|
||||
assert_eq!(refit(12.0, 100.0, 100.5, 100.0 / 12.0), None);
|
||||
assert_eq!(refit(12.34, 100.0, 100.5, 100.0 / 12.34), Some(12.3), "a size between tenths is rounded down");
|
||||
// Room for twice the width: twice the size.
|
||||
assert_eq!(refit(12.0, 100.0, 200.0, 100.0 / 12.0), Some(24.0));
|
||||
// A part of the width that stays (a slope below width / size) needs more size.
|
||||
assert_eq!(refit(12.0, 100.0, 150.0, 5.0), Some(22.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_round_down_to_tenths_within_the_range() {
|
||||
assert_eq!(tenths(23.99), 23.9);
|
||||
assert_eq!(tenths(24.0 - 1e-9), 24.0);
|
||||
assert_eq!(tenths(0.01), 0.1);
|
||||
assert_eq!(tenths(5000.0), 1296.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! Dynamic Text's Block for area type: `type.dynamicText`, Area Type Options › Block and the refit
|
||||
//! after edits.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{AreaFit, NodeId, NodeKind, TextKind, TextObject};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn area(s: &mut Session, w: f64, h: f64, text: &str) -> u64 {
|
||||
let id = s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "area": {"width": w, "height": h}, "text": text})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: u64) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
k => panic!("not text: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// The size of the character at `byte`.
|
||||
fn size_at(t: &TextObject, byte: usize) -> f64 {
|
||||
super::block::size_at(&t.runs, byte)
|
||||
}
|
||||
|
||||
/// Each laid-out line's (start byte, content width, room): the room is the frame's span less the
|
||||
/// indents.
|
||||
fn lines(t: &TextObject) -> Vec<(usize, f64, f64)> {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
lay.lines.iter().map(|l| (l.start, l.x1 - l.x0, l.avail.1 - l.avail.0)).collect()
|
||||
}
|
||||
|
||||
/// Every typed line is one laid-out line whose width is within one 0.1 pt size step of its room.
|
||||
fn assert_block(t: &TextObject) {
|
||||
let typed: Vec<usize> = std::iter::once(0).chain(t.plain_text().match_indices(['\n', '\u{2028}']).map(|(i, c)| i + c.len())).collect();
|
||||
let laid = lines(t);
|
||||
assert_eq!(laid.iter().map(|l| l.0).collect::<Vec<_>>(), typed, "one laid-out line per typed line: {laid:?}");
|
||||
for &(start, width, room) in &laid {
|
||||
let size = size_at(t, start);
|
||||
if width <= 1e-9 || size >= 1296.0 - 1e-9 || size <= 0.1 + 1e-9 {
|
||||
continue;
|
||||
}
|
||||
assert!(width <= room + 0.01, "line at {start} is wider than its room: {width} > {room}");
|
||||
assert!(width >= room * (size - 0.1) / size - 0.01, "line at {start} at {size} pt is short of its room: {width} < {room}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_sets_each_typed_line_to_the_frame_width_by_its_size() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 300.0, 400.0, "BIG\na much longer second line\nmid one");
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"on": true, "ids": [id], "fitScale": 1.0, "wordGap": 100.0, "lineGap": 0.0}));
|
||||
assert_eq!(undo_len(&s), n + 1, "one undo step");
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text);
|
||||
assert_eq!(t.area.fit, AreaFit::None);
|
||||
assert_block(&t);
|
||||
let (big, long, mid) = (size_at(&t, 0), size_at(&t, 4), size_at(&t, 31));
|
||||
assert!(big > mid && mid > long, "a shorter line is set larger: {big} {mid} {long}");
|
||||
for size in [big, long, mid] {
|
||||
assert!(((size * 10.0).round() - size * 10.0).abs() < 1e-6, "{size} is a whole number of tenths");
|
||||
}
|
||||
// Each character's leading is its size (line gap 0).
|
||||
assert!(t.runs.iter().all(|r| r.style.leading == Some(r.style.size)), "{:?}", t.runs);
|
||||
// Inspect reports it.
|
||||
let doc = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let node = doc["layers"][0]["children"].as_array().unwrap().iter().find(|k| k["id"] == id).unwrap().clone();
|
||||
assert_eq!((node["dynamicText"].as_bool(), node["fit"].as_str()), (Some(true), Some("block")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_keeps_the_proportions_inside_a_line_and_fits_again_after_edits() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 300.0, 400.0, "one two three\nfour");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "size": 24})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_block(&t);
|
||||
let ratio = size_at(&t, 0) / size_at(&t, 4);
|
||||
assert!((ratio - 2.0).abs() < 0.02, "the first word stays twice the size of the rest: {ratio}");
|
||||
let four = size_at(&t, 14);
|
||||
// Typing into the first line makes it smaller, in the edit's own undo step; the second line stays.
|
||||
let first = size_at(&t, 4);
|
||||
let n = undo_len(&s);
|
||||
s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": " and more words"})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
let t = text(&s, id);
|
||||
assert_block(&t);
|
||||
assert!(size_at(&t, 4) < first, "{} < {first}", size_at(&t, 4));
|
||||
assert_eq!(size_at(&t, t.plain_text().find("four").unwrap()), four);
|
||||
// A narrower frame sets every line smaller.
|
||||
s.execute("text.areaOptions", &json!({"width": 150})).unwrap();
|
||||
let narrow = text(&s, id);
|
||||
assert_block(&narrow);
|
||||
let f = size_at(&narrow, narrow.plain_text().find("four").unwrap()) / four;
|
||||
assert!((f - 0.5).abs() < 0.02, "half the width, half the size: {f}");
|
||||
// Undo brings the wider frame's sizes back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id).runs, t.runs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_turns_off_keeping_its_sizes_and_records_nothing_when_nothing_changes() {
|
||||
let mut s = session();
|
||||
// The tab goes to a stop that stays where it is as the size changes.
|
||||
let id = area(&mut s, 300.0, 400.0, "Fit me\nand\tme too");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let fitted = text(&s, id).runs;
|
||||
let laid = lines(&text(&s, id));
|
||||
assert_eq!(laid.iter().map(|l| l.0).collect::<Vec<_>>(), vec![0, 7], "{laid:?}");
|
||||
assert!(laid.iter().all(|l| l.1 <= l.2 + 0.01 && l.1 > l.2 * 0.95), "each line fills its room: {laid:?}");
|
||||
let n = undo_len(&s);
|
||||
assert_eq!(s.execute("type.dynamicText", &json!({"on": true})).unwrap()["on"], true);
|
||||
assert_eq!(undo_len(&s), n, "already on: no undo step");
|
||||
let r = s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
assert_eq!(r["on"], false);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
let t = text(&s, id);
|
||||
assert!(!t.dynamic_text);
|
||||
assert_eq!(t.runs, fitted, "off keeps the sizes it was drawn at");
|
||||
// Edits no longer fit the lines.
|
||||
s.execute("text.areaOptions", &json!({"width": 100})).unwrap();
|
||||
assert_eq!(text(&s, id).runs, fitted);
|
||||
assert_eq!(s.execute("type.dynamicText", &json!({"on": false})).unwrap()["on"], false);
|
||||
assert_eq!(undo_len(&s), n + 2, "already off: no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_and_other_type_are_refused() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 300.0, 400.0, "Fit me");
|
||||
for bad in [
|
||||
json!({"on": "yes"}),
|
||||
json!({"on": 1}),
|
||||
json!({"wordGap": "wide"}),
|
||||
json!({"wordGap": -1}),
|
||||
json!({"wordGap": 1001}),
|
||||
json!({"lineGap": 1297}),
|
||||
json!({"lineGap": -1297}),
|
||||
json!({"lineGap": true}),
|
||||
] {
|
||||
assert!(s.execute("type.dynamicText", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(!text(&s, id).dynamic_text);
|
||||
let point = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [point]})).unwrap();
|
||||
let e = s.execute("type.dynamicText", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("select type on a path or area type"), "{e}");
|
||||
// With point type selected too, only the area type changes.
|
||||
s.execute("select.set", &json!({"ids": [id, point]})).unwrap();
|
||||
let r = s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
assert_eq!(r["ids"], json!([id]));
|
||||
assert!(text(&s, id).dynamic_text && !text(&s, point).dynamic_text);
|
||||
// A gap with only type on a path selected is an error, and nothing changes.
|
||||
let path = s.execute("path.create", &json!({"d": "M60 550 L340 550"})).unwrap()["id"].as_u64().unwrap();
|
||||
let along = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Along"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [along]})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
for gap in [json!({"wordGap": 300}), json!({"on": false, "lineGap": 2})] {
|
||||
let e = s.execute("type.dynamicText", &gap).unwrap_err().to_string();
|
||||
assert!(e.contains("need area type"), "{e}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), n);
|
||||
assert!(!text(&s, along).dynamic_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaps_set_word_spacing_and_leading() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 300.0, 400.0, "two words\nthree more words");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let plain = text(&s, id);
|
||||
let r = s.execute("type.dynamicText", &json!({"wordGap": 300, "lineGap": 6})).unwrap();
|
||||
assert_eq!((r["on"].as_bool(), r["wordGap"].as_f64(), r["lineGap"].as_f64()), (Some(true), Some(300.0), Some(6.0)));
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text);
|
||||
assert_eq!((t.area.word_gap, t.area.line_gap), (300.0, 6.0));
|
||||
// The layout uses the gap as the Desired word spacing; the paragraph keeps its own.
|
||||
assert_eq!(t.para_at(0).justification.word_spacing, [80.0, 100.0, 133.0]);
|
||||
assert_eq!(t.layout_justification(t.para_at(0)).word_spacing, [80.0, 300.0, 300.0]);
|
||||
assert!(t.runs.iter().all(|r| (r.style.leading.unwrap() - r.style.size - 6.0).abs() < 1e-9), "{:?}", t.runs);
|
||||
assert_block(&t);
|
||||
assert!(size_at(&t, 0) < size_at(&plain, 0), "wider word spaces leave the letters less room");
|
||||
// (own, used by the layout) word spacing of the first paragraph.
|
||||
let spacing = |s: &Session| {
|
||||
let t = text(s, id);
|
||||
(t.para_at(0).justification.word_spacing, t.layout_justification(t.para_at(0)).word_spacing)
|
||||
};
|
||||
// Minimum and Maximum move out only as far as the current gap needs, and the paragraph's own
|
||||
// values apply again when Block is off.
|
||||
s.execute("type.dynamicText", &json!({"wordGap": 1000})).unwrap();
|
||||
assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [80.0, 1000.0, 1000.0]));
|
||||
s.execute("type.dynamicText", &json!({"wordGap": 100})).unwrap();
|
||||
assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [80.0, 100.0, 133.0]));
|
||||
s.execute("type.dynamicText", &json!({"wordGap": 0})).unwrap();
|
||||
assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [0.0, 0.0, 133.0]));
|
||||
s.execute("type.dynamicText", &json!({"on": false})).unwrap();
|
||||
assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [80.0, 100.0, 133.0]));
|
||||
// A paragraph's own values, outside the default range and equal to Desired too, stay through
|
||||
// gap changes and refits after edits.
|
||||
s.execute("text.setFormat", &json!({"wordSpacing": [20, 20, 150]})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true, "wordGap": 20})).unwrap();
|
||||
assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 20.0, 150.0]));
|
||||
s.execute("type.dynamicText", &json!({"wordGap": 1000})).unwrap();
|
||||
assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 1000.0, 1000.0]));
|
||||
s.execute("type.dynamicText", &json!({"wordGap": 100})).unwrap();
|
||||
s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 0, "insert": "a "})).unwrap();
|
||||
assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 100.0, 150.0]));
|
||||
s.execute("type.dynamicText", &json!({"on": false})).unwrap();
|
||||
assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 20.0, 150.0]));
|
||||
// A gap alone on a frame without Block turns it on.
|
||||
let other = area(&mut s, 200.0, 200.0, "x y");
|
||||
let r = s.execute("type.dynamicText", &json!({"lineGap": -2})).unwrap();
|
||||
assert_eq!(r["on"], true);
|
||||
let t = text(&s, other);
|
||||
assert!(t.dynamic_text);
|
||||
assert_eq!((t.area.word_gap, t.area.line_gap), (100.0, -2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_options_offer_block_with_its_gaps() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 300.0, 400.0, "Fit me\nand me too");
|
||||
let q = s.execute("text.areaOptions", &json!({})).unwrap();
|
||||
assert_eq!((q["wordGap"].as_f64(), q["lineGap"].as_f64()), (Some(100.0), Some(0.0)));
|
||||
// Gaps alone are kept for Block, without turning it on or changing the text.
|
||||
let (n, runs) = (undo_len(&s), text(&s, id).runs);
|
||||
s.execute("text.areaOptions", &json!({"wordGap": 200})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!((undo_len(&s), t.dynamic_text, t.area.word_gap), (n + 1, false, 200.0));
|
||||
assert_eq!(t.runs, runs);
|
||||
let r = s.execute("text.areaOptions", &json!({"fit": "block", "lineGap": 4})).unwrap();
|
||||
assert_eq!((r["fit"].as_str(), r["wordGap"].as_f64(), r["lineGap"].as_f64()), (Some("block"), Some(200.0), Some(4.0)));
|
||||
assert!(text(&s, id).dynamic_text);
|
||||
assert_block(&text(&s, id));
|
||||
// The reply passed back (as the dialog does) changes nothing; naming Block again keeps the gaps.
|
||||
let n = undo_len(&s);
|
||||
assert_eq!(s.execute("text.areaOptions", &r).unwrap(), r);
|
||||
s.execute("text.areaOptions", &json!({"fit": "block"})).unwrap();
|
||||
assert_eq!(undo_len(&s), n);
|
||||
s.execute("text.areaOptions", &json!({"lineGap": 2})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!((t.dynamic_text, t.area.word_gap, t.area.line_gap), (true, 200.0, 2.0));
|
||||
assert!(s.execute("text.areaOptions", &json!({"lineGap": 5000})).is_err());
|
||||
assert!(s.execute("text.areaOptions", &json!({"wordGap": 2000})).is_err());
|
||||
assert!(s.execute("text.areaOptions", &json!({"wordGap": "wide"})).is_err());
|
||||
assert!(s.execute("text.areaOptions", &json!({"fit": "squash"})).unwrap_err().to_string().contains("shrinkText|block"));
|
||||
// Shrink Text to Fit replaces Block, keeping the sizes.
|
||||
let runs = text(&s, id).runs;
|
||||
s.execute("text.areaOptions", &json!({"fit": "shrinkText"})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(!t.dynamic_text);
|
||||
assert_eq!(t.runs, runs);
|
||||
// Block replaces Shrink Text to Fit and Auto Size.
|
||||
s.execute("text.areaOptions", &json!({"fit": "block"})).unwrap();
|
||||
assert_eq!(text(&s, id).area.fit, AreaFit::None);
|
||||
// New area type can start with it.
|
||||
let made = s
|
||||
.execute(
|
||||
"text.create",
|
||||
&json!({"x": 10, "y": 300, "size": 12, "area": {"width": 200, "height": 200, "fit": "block", "wordGap": 150}, "text": "made"}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let t = text(&s, made);
|
||||
assert_eq!((t.dynamic_text, t.area.word_gap, t.area.line_gap), (true, 150.0, 0.0));
|
||||
assert_block(&t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_round_trips_through_the_native_format() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 300.0, 400.0, "Fit me\nand me too");
|
||||
s.execute("type.dynamicText", &json!({"wordGap": 120, "lineGap": 3})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let bytes = vectorcraft_format::save(&doc, false);
|
||||
let back = vectorcraft_format::load(&bytes).unwrap();
|
||||
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!((t.dynamic_text, t.area.word_gap, t.area.line_gap), (true, 120.0, 3.0));
|
||||
assert_eq!(t.runs, text(&s, id).runs, "the fitted sizes are saved in the characters");
|
||||
// Saved as `dynamicText` with the gaps beside the area options; missing gaps read as their
|
||||
// defaults, which are left out when saved.
|
||||
let saved = serde_json::to_value(&**t).unwrap();
|
||||
assert_eq!((&saved["dynamicText"], &saved["area"]["wordGap"], &saved["area"]["lineGap"]), (&json!(true), &json!(120.0), &json!(3.0)));
|
||||
let plain = serde_json::from_value::<vectorcraft_doc::AreaOptions>(json!({})).unwrap();
|
||||
assert_eq!((plain.word_gap, plain.line_gap), (100.0, 0.0));
|
||||
let plain: Value = serde_json::to_value(&plain).unwrap();
|
||||
assert!(plain.get("wordGap").is_none() && plain.get("lineGap").is_none(), "{plain}");
|
||||
// Turned off, it opens off with the sizes it had.
|
||||
s.execute("type.dynamicText", &json!({"on": false})).unwrap();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert!(!t.dynamic_text);
|
||||
assert_eq!(t.runs, text(&s, id).runs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_cases_fit_without_failing() {
|
||||
let mut s = session();
|
||||
// Empty text, an empty line and a line of spaces keep their sizes.
|
||||
let empty = area(&mut s, 300.0, 400.0, "");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
assert_eq!(size_at(&text(&s, empty), 0), 12.0);
|
||||
let gaps = area(&mut s, 300.0, 400.0, "top\n\n \nbottom line");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, gaps);
|
||||
assert_eq!((size_at(&t, 4), size_at(&t, 5)), (12.0, 12.0));
|
||||
assert_block(&t);
|
||||
// One word.
|
||||
let word = area(&mut s, 300.0, 400.0, "Word");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
assert_block(&text(&s, word));
|
||||
// A wide frame stops at 1,296 pt; a very long line in a narrow frame stops at 0.1 pt and wraps.
|
||||
let wide = area(&mut s, 100_000.0, 400.0, "i");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
assert_eq!(size_at(&text(&s, wide), 0), 1296.0);
|
||||
let narrow = area(&mut s, 1.0, 400.0, &"long words ".repeat(40));
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, narrow);
|
||||
assert_eq!(size_at(&t, 0), 0.1);
|
||||
assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "at 0.1 pt the line still does not fit");
|
||||
// A frame inset so far that it has no width keeps the sizes.
|
||||
let inset = area(&mut s, 50.0, 400.0, "Inset");
|
||||
s.execute("text.areaOptions", &json!({"inset": 30})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
assert_eq!(size_at(&text(&s, inset), 0), 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_fits_columns_lists_vertical_and_right_to_left_type() {
|
||||
let mut s = session();
|
||||
// Two columns: each line is set to a column's width.
|
||||
let cols = area(&mut s, 400.0, 600.0, "left column\nsecond");
|
||||
s.execute("text.areaOptions", &json!({"columns": 2, "gutter": 20})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, cols);
|
||||
assert_block(&t);
|
||||
assert!(lines(&t).iter().all(|l| (l.2 - 190.0).abs() < 1e-6), "{:?}", lines(&t));
|
||||
// A bulleted list: each item stays one line, the marker and its tab taking their room before
|
||||
// the text (the room the layout reports takes in the marker's too).
|
||||
let list = area(&mut s, 300.0, 400.0, "first item\nsecond item here");
|
||||
s.execute("text.setFormat", &json!({"list": "bullet"})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, list);
|
||||
let laid = lines(&t);
|
||||
assert_eq!(laid.iter().map(|l| l.0).collect::<Vec<_>>(), vec![0, 11], "{laid:?}");
|
||||
assert!(laid.iter().all(|l| l.1 <= l.2), "{laid:?}");
|
||||
assert!(size_at(&t, 0) > size_at(&t, 11), "the shorter item is larger");
|
||||
// Right-to-left text.
|
||||
let rtl = area(&mut s, 300.0, 400.0, "שלום עולם\nטקסט ארוך יותר בשורה");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
assert_block(&text(&s, rtl));
|
||||
// Vertical type: each column of text is set to the frame's height.
|
||||
let v = s
|
||||
.execute(
|
||||
"text.create",
|
||||
&json!({"x": 40, "y": 40, "size": 12, "vertical": true, "area": {"width": 300, "height": 200}, "text": "縦書き\nもっと長い縦書きの行"}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [v]})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, v);
|
||||
assert!(t.vertical);
|
||||
assert_block(&t);
|
||||
assert!(lines(&t).iter().all(|l| (l.2 - 200.0).abs() < 1e-6), "{:?}", lines(&t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threaded_frames_are_refused_and_threading_turns_block_off() {
|
||||
let mut s = session();
|
||||
let a = area(&mut s, 200.0, 300.0, "Fit me\nand me too");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let fitted = text(&s, a).runs;
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 400, "y": 40, "width": 200, "height": 300})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
let t = text(&s, a);
|
||||
assert!(!t.dynamic_text, "threading turns Block off");
|
||||
assert_eq!(t.runs, fitted, "the text keeps its sizes");
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
let e = s.execute("type.dynamicText", &json!({"on": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("area type in a thread") && e.contains(&a.to_string()), "{e}");
|
||||
let runs = text(&s, a).runs;
|
||||
let e = s.execute("text.areaOptions", &json!({"fit": "block"})).unwrap_err().to_string();
|
||||
assert!(e.contains("area type in a thread") && e.contains(&a.to_string()), "{e}");
|
||||
assert_eq!(text(&s, a).runs, runs, "Area Type Options refuses it too, changing nothing");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(text(&s, a).dynamic_text, "undo brings it back with the thread gone");
|
||||
assert!(matches!(text(&s, a).kind, TextKind::Area { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_text_fits_in_a_bounded_number_of_layouts() {
|
||||
let mut s = session();
|
||||
let story: String = (0..300).map(|i| format!("line number {i} {}\n", "x".repeat(i % 17))).collect();
|
||||
let id = area(&mut s, 300.0, 100_000.0, &story);
|
||||
let before = vectorcraft_text::test_fonts::layouts_on_this_thread();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let used = vectorcraft_text::test_fonts::layouts_on_this_thread() - before;
|
||||
assert_block(&text(&s, id));
|
||||
// The command fits the object to compare and to apply (each a measure, at most two layouts a
|
||||
// pass and the caches), plus the reply's own.
|
||||
let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 4;
|
||||
assert!(used <= bound, "{used} layouts");
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
//! Type menu long tail (Change Case, Smart Punctuation, Convert To Area/Point Type, Type on a Path
|
||||
//! Options, Fill with Placeholder Text, Insert Special/Whitespace/Break characters) and the text
|
||||
//! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting).
|
||||
//! Options, Dynamic Text, Fill with Placeholder Text, Insert Special/Whitespace/Break characters)
|
||||
//! and the text commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, Document, Justify, LockedArtboards, Node, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, Node, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::object::choice;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::typecmd::{area_text, block_gaps, refresh_bounds};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or,
|
||||
switch_or, targets, text_param,
|
||||
@@ -65,8 +65,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Dynamic Text",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?|id?, on?: bool} turn Dynamic Text on or off for the selected type on a path (on omitted: the opposite of the first object's setting). On, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. Off, the text keeps the sizes it was drawn at. Point type and area type in the selection are left as they are; a selection holding type on a path in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When every object already has the setting, nothing changes and no undo step is recorded → {on, ids, fitScale (the first object's factor, 1 when off)}",
|
||||
has_free_path_type,
|
||||
"{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}",
|
||||
has_dynamic_type,
|
||||
dynamic_text
|
||||
),
|
||||
cmd!(
|
||||
@@ -520,26 +520,40 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
options(s)
|
||||
}
|
||||
|
||||
/// Whether type on a path `id` is a frame of a thread.
|
||||
/// Whether type `id` is a frame of a thread.
|
||||
fn threaded(d: &Document, id: NodeId) -> bool {
|
||||
!super::threads::story_of(d, id).is_empty()
|
||||
}
|
||||
|
||||
/// `type.dynamicText` is enabled for a selection holding type on a path, none of it in a thread.
|
||||
fn has_free_path_type(s: &Session) -> std::result::Result<(), String> {
|
||||
/// Type on a path or area type: the type Dynamic Text works on.
|
||||
fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> {
|
||||
path_text(n).or_else(|| area_text(n))
|
||||
}
|
||||
|
||||
/// `type.dynamicText` is enabled for a selection holding type on a path or area type, none of it
|
||||
/// in a thread.
|
||||
fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
let d = &st.doc;
|
||||
let selected: Vec<NodeId> = st.selection.objects.to_vec();
|
||||
let paths: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect();
|
||||
if paths.is_empty() {
|
||||
return Err("select type on a path".into());
|
||||
let types: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| dynamic_type(d.node(*id)).is_some()).collect();
|
||||
if types.is_empty() {
|
||||
return Err("select type on a path or area type".into());
|
||||
}
|
||||
threaded_refusal(d, &types).map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
/// Why Dynamic Text is refused for `ids`: the frames among them that are in a thread, type on a
|
||||
/// path first; None when none is.
|
||||
fn threaded_refusal(d: &Document, ids: &[NodeId]) -> Option<String> {
|
||||
let (paths, areas): (Vec<NodeId>, Vec<NodeId>) = ids.iter().copied().partition(|id| path_text(d.node(*id)).is_some());
|
||||
let frames = threaded_ids(d, &paths);
|
||||
if !frames.is_empty() {
|
||||
return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}"));
|
||||
return Some(format!("type on a path in a thread cannot use Dynamic Text: {frames}"));
|
||||
}
|
||||
Ok(())
|
||||
let frames = threaded_ids(d, &areas);
|
||||
(!frames.is_empty()).then(|| format!("area type in a thread cannot use Dynamic Text: {frames}"))
|
||||
}
|
||||
|
||||
/// The ids among `ids` of frames of threads, as a list for a message; empty when there are none.
|
||||
@@ -547,32 +561,60 @@ fn threaded_ids(d: &Document, ids: &[NodeId]) -> String {
|
||||
ids.iter().filter(|id| threaded(d, **id)).map(|id| id.0.to_string()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting) on the
|
||||
/// selected type on a path, in one undo step, and none when every object already has it. Turning
|
||||
/// it off keeps the sizes the text was drawn at ([`bake_dynamic_text`]).
|
||||
/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on
|
||||
/// when a gap is given) on the selected type on a path and area type (Block, with its gaps), in
|
||||
/// one undo step, and none when nothing changes. Turning it off keeps the sizes the text was drawn
|
||||
/// at ([`bake_dynamic_text`]).
|
||||
fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.dynamicText";
|
||||
let (ids, on, changing) = {
|
||||
let (word_gap, line_gap) = block_gaps(p, C)?;
|
||||
let (ids, on, planned) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect();
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select type on a path"))?;
|
||||
let frames = threaded_ids(d, &ids);
|
||||
if !frames.is_empty() {
|
||||
return Err(bad(C, format!("type on a path in a thread cannot use Dynamic Text: {frames}")));
|
||||
let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| dynamic_type(d.node(*i)).is_some()).collect();
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select type on a path or area type"))?;
|
||||
if let Some(why) = threaded_refusal(d, &ids) {
|
||||
return Err(bad(C, why));
|
||||
}
|
||||
let on = switch_or(p, "on", !path_text(d.node(first)).is_some_and(|t| t.dynamic_text), C)?;
|
||||
let changing: Vec<NodeId> = ids.iter().copied().filter(|id| path_text(d.node(*id)).is_some_and(|t| t.dynamic_text != on)).collect();
|
||||
(ids, on, changing)
|
||||
let gap_given = word_gap.is_some() || line_gap.is_some();
|
||||
if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some()) {
|
||||
return Err(bad(C, "wordGap and lineGap need area type in the selection"));
|
||||
}
|
||||
let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?;
|
||||
// Each object as the command leaves it, and only those it changes.
|
||||
let mut planned: Vec<(NodeId, TextObject)> = vec![];
|
||||
for id in &ids {
|
||||
let Some(t) = dynamic_type(d.node(*id)) else { continue };
|
||||
let mut after = t.clone();
|
||||
if matches!(t.kind, TextKind::Area { .. }) {
|
||||
after.area.word_gap = word_gap.unwrap_or(t.area.word_gap);
|
||||
after.area.line_gap = line_gap.unwrap_or(t.area.line_gap);
|
||||
}
|
||||
match (on, t.dynamic_text) {
|
||||
(true, true) => refresh_bounds(&mut after),
|
||||
(true, false) => {
|
||||
after.dynamic_text = true;
|
||||
// Block replaces Auto Size and Shrink Text to Fit.
|
||||
if matches!(t.kind, TextKind::Area { .. }) {
|
||||
after.area.fit = AreaFit::None;
|
||||
}
|
||||
refresh_bounds(&mut after);
|
||||
}
|
||||
(false, true) => {
|
||||
bake_dynamic_text(&mut after);
|
||||
}
|
||||
(false, false) => {}
|
||||
}
|
||||
if after != *t {
|
||||
planned.push((*id, after));
|
||||
}
|
||||
}
|
||||
(ids, on, planned)
|
||||
};
|
||||
if !changing.is_empty() {
|
||||
if !planned.is_empty() {
|
||||
s.edit("Dynamic Text", |d, _| {
|
||||
for id in &changing {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
if on {
|
||||
t.dynamic_text = true;
|
||||
refresh_bounds(t);
|
||||
} else {
|
||||
bake_dynamic_text(t);
|
||||
for (id, after) in &planned {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
**t = after.clone();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -581,7 +623,13 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let fit =
|
||||
ids.first().and_then(|id| path_text(d.node(*id))).map_or(1.0, |t| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale);
|
||||
Ok(json!({"on": on, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "fitScale": fit}))
|
||||
let mut reply = json!({"on": on, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "fitScale": fit});
|
||||
if let Some(t) = ids.iter().find_map(|id| area_text(d.node(*id))) {
|
||||
let (w, l) = t.area.block_gaps();
|
||||
reply["wordGap"] = json!(w);
|
||||
reply["lineGap"] = json!(l);
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// Turn Dynamic Text off on `t`, keeping the sizes its layout draws it at: every run's size,
|
||||
|
||||
@@ -25,10 +25,10 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo
|
||||
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
|
||||
/// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the
|
||||
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the
|
||||
/// Artboard tool had selected for its commands; the artboards and art Cut and Copy took with that
|
||||
/// Artboard tool had selected for its commands and for Paste in Place, in Front and in Back; the artboards and art Cut and Copy took with that
|
||||
/// tool chosen; the view's center the app passes to Edit Symbol); an action leaves them out
|
||||
/// ([`Session::journal_for_action`]).
|
||||
const REPLAY_ONLY: [(&str, &str); 26] = [
|
||||
const REPLAY_ONLY: [(&str, &str); 29] = [
|
||||
("file.new", "created"),
|
||||
("edit.newDocumentFromState", "created"),
|
||||
("document.save", "modified"),
|
||||
@@ -36,6 +36,9 @@ const REPLAY_ONLY: [(&str, &str); 26] = [
|
||||
("edit.pasteInPlace", "artboard"),
|
||||
("edit.pasteInFront", "artboard"),
|
||||
("edit.pasteInBack", "artboard"),
|
||||
("edit.pasteInPlace", "artboards"),
|
||||
("edit.pasteInFront", "artboards"),
|
||||
("edit.pasteInBack", "artboards"),
|
||||
("object.align", "artboard"),
|
||||
("object.distribute", "artboard"),
|
||||
("object.distributeSpacing", "artboard"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user