Merge main into feat/help-bar-visibility

This commit is contained in:
dotCooCoo
2026-11-08 09:57:25 -08:00
208 changed files with 10377 additions and 487 deletions
+12
View File
@@ -368,6 +368,18 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `crates/engine/tests/fixtures/jpeg2000/checker.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `3de9c2bb5b8aaec241d939b795002a68eb3d50e41d51c43368000804ad3de057` |
| `crates/engine/tests/fixtures/jpeg2000/checker-raw.j2k` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `76fa51ab8ccbc2534098b26b8e7fe16dd886e2b6298ba52d06a6b1bde9b61d9e` |
| `crates/engine/tests/fixtures/jpeg2000/checker-300ppi.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `1b1aeed8975d3cdc832da6ecf5855852d07d0c490105121e61a8aefb31af009b` |
| `crates/engine/tests/fixtures/heif/quadrants.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 red, green, blue and white quadrants encoded with libheif's `heif-enc` 1.23.2 (x265); SHA-256 `122d14963fe899daa736cd216902cf4fc8f2192b3a0ba81f94004adcd71a0ff5` |
| `crates/engine/tests/fixtures/heif/quadrants-alpha.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with a transparent right half, encoded with `heif-enc` 1.23.2; SHA-256 `55b5a537401ead742714c3a1bc526559ec8c1ba97cdb7f43ac63f4037167475b` |
| `crates/engine/tests/fixtures/heif/quadrants-10bit.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants at 10 bits, encoded with `heif-enc` 1.23.2; SHA-256 `11014848c6a838e5799d87e034eff1b06b816712ed70ba451bd582d72741774f` |
| `crates/engine/tests/fixtures/heif/quadrants-grid.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 128 × 128 quadrants as a 2 × 2 grid of 64 × 64 tiles, encoded with `heif-enc` 1.23.2; SHA-256 `5802f8715110954eb9c58d4bdcc10d1200426a48cf726f1b88dfb60da825ba7a` |
| `crates/engine/tests/fixtures/heif/quadrants-300ppi.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with EXIF resolution tags of 300 ppi, encoded with `heif-enc` 1.23.2; SHA-256 `6a344a6de04fc5fceaee46b6e0028b12478c90bdcb6f6f14ed8c86a44cb203d7` |
| `crates/engine/tests/fixtures/heif/wide-rotated.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 16 red and blue halves stored with a quarter-turn rotation, encoded with `heif-enc` 1.23.2; SHA-256 `e3a2c3a9beb4066c13d6deb8e1311805c6aac485fde8784f1ee317dd95b89973` |
| `crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the rotated halves with EXIF resolution tags of 300 ppi across and 150 ppi down, encoded with `heif-enc` 1.23.2; SHA-256 `79ad7945cd2c1ea9d3e70ac9b93d9a162fd8ce37541a0f3de44e12f04745921d` |
| `crates/engine/tests/fixtures/heif/tones.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants of four mid tones, encoded with `heif-enc` 1.23.2; SHA-256 `29ed7d75fbfbc4c298eb2da3a6b62625a150cd6bfe5b7ff1d847437501b1b4b1` |
| `crates/engine/tests/fixtures/heif/tones-p3-nclx.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones labeled Display P3 in an `nclx` color box, encoded with `heif-enc` 1.23.2; SHA-256 `baeb32ffa58ac1ac29a00c9e388b9281f4acb4695c4681e4933d0ad061447101` |
| `crates/engine/tests/fixtures/heif/tones-p3-icc.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile (`vectorcraft_color::cms::icc_bytes`), encoded with `heif-enc` 1.23.2; SHA-256 `472e1a123b9515926a8985a04c3f680387e8c410dbf8f7ff0cfb8cb99c89e93b` |
| `crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile as their only color box, encoded with `heif-enc` 1.23.2; SHA-256 `2654fdd21e56641107db20b3f4d0a66a86420ac040d42ddf55e20f7e789d5c55` |
| `crates/engine/tests/fixtures/heif/quadrants-grid16.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each cropped from a 64 × 64 picture, encoded with `heif-enc` 1.23.2; SHA-256 `bd114ee66cdac10142729dc9dddfa1cd96993a5cefa54c2f25b65496652f007f` |
## Assets embedded by the PDF backend
Generated
+11
View File
@@ -2286,6 +2286,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heic-rs"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
dependencies = [
"rayon",
]
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -6246,10 +6255,12 @@ dependencies = [
"crc32fast",
"flate2",
"hayro-jpeg2000",
"heic-rs",
"image",
"kurbo 0.13.1",
"log",
"proptest",
"rayon",
"roxmltree 0.21.2",
"rustix 1.1.6",
"ruzstd",
+4 -1
View File
@@ -15,7 +15,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -35,6 +35,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| | Value | Kind |
|---|---|---|
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) |
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
@@ -135,6 +136,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged.
- 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged.
- 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged.
- 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged.
@@ -224,6 +226,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
| 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. |
| 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. |
+1 -1
View File
@@ -16,7 +16,7 @@ workspace = true
[dependencies]
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] }
vectorcraft-format = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-svg = { workspace = true }
+6 -1
View File
@@ -128,12 +128,17 @@ USAGE:
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
(warm), multithreaded and single-threaded.
vectorcraft-cli perf [--paths N] [--deep]
vectorcraft-cli perf [--paths N] [--deep] [--native-io [--artboards 1..4]]
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
budget is exceeded, unless the report says the machine is busy. --deep also times work with
no budget yet and prints memory use (the working set on Windows), with the peak so far on
Windows and Linux.
--native-io instead measures the application's native save/open commands (one warmup,
five samples, no disk I/O), including preview, PDF, compression and API base64 costs.
It reports the default and explicit PDF opt-out, with and without preview/compression;
--artboards repeats the same artwork
on 1 to 4 overlapping artboards. This mode allows at most 50000 paths.
";
/// The usage text plus the formats `document.open` reads and `document.export` writes.
+104 -2
View File
@@ -64,6 +64,61 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
v.get(v.len() / 2).copied().unwrap_or(0.0)
}
/// Exercise the application's native save/open commands, including previews, PDF output,
/// compression and API base64 conversion. Session setup and validation are outside timing.
/// Five measured runs follow one warmup; no filesystem or GPU window is involved.
fn native_io(doc: &Document) -> Result<(), String> {
for (label, compatible, preview) in [
("default options", None, false),
("PDF disabled", Some(false), false),
("preview+gzip", None, true),
("preview+gzip, PDF disabled", Some(false), true),
] {
let mut session = vectorcraft_engine::Session::new();
session.add_document(doc.clone(), None);
let mut params = json!({"format":"vectorcraft","modified":null,"preview":preview,"compress":preview});
if let Some(value) = compatible {
params["pdfCompatible"] = json!(value);
}
let (mut saves, mut opens) = (Vec::new(), Vec::new());
let (mut size, mut has_pdf, mut compressed) = (0, false, false);
for run in 0..6 {
let start = Instant::now();
let saved = session.execute("file.saveAs", &params).map_err(|e| e.to_string())?;
let save_ms = start.elapsed().as_secs_f64() * 1000.0;
let data = saved["dataBase64"].as_str().ok_or("native save returned no dataBase64")?;
let bytes = vectorcraft_format::base64_decode(data).ok_or("native save returned invalid base64")?;
size = bytes.len();
has_pdf = vectorcraft_format::pdf_content(&bytes).is_some();
if compatible == Some(false) && has_pdf {
return Err("native save ignored the explicit PDF opt-out".into());
}
compressed = vectorcraft_format::is_compressed(&bytes);
if compressed != preview || vectorcraft_format::preview(&bytes).is_some() != preview {
return Err("native save did not apply the requested preview/compression options".into());
}
let args = json!({"name":"perf.vectorcraft","dataBase64":data});
let mut opened = vectorcraft_engine::Session::new();
let start = Instant::now();
opened.execute("document.open", &args).map_err(|e| e.to_string())?;
let open_ms = start.elapsed().as_secs_f64() * 1000.0;
if opened.doc().map_err(|e| e.to_string())?.doc.artboards.len() != doc.artboards.len() {
return Err("native round trip changed the artboard count".into());
}
if run > 0 {
saves.push(save_ms);
opens.push(open_ms);
}
}
saves.sort_by(f64::total_cmp);
opens.sort_by(f64::total_cmp);
let save = saves.get(2).copied().ok_or("no save measurements")?;
let open = opens.get(2).copied().ok_or("no open measurements")?;
outln!(" native {label}: save {save:.2} ms, open {open:.2} ms, {size} bytes, PDF {has_pdf}, compressed {compressed}");
}
Ok(())
}
/// 1-minute load average (macOS / Linux), if available.
#[cfg(not(windows))]
fn load_average() -> Option<f64> {
@@ -393,19 +448,36 @@ fn deep(n: usize) -> Result<(), String> {
}
pub fn run(args: &[String]) -> Result<(), String> {
if crate::help_requested(args, &["--paths"]) {
if crate::help_requested(args, &["--paths", "--artboards"]) {
return crate::help();
}
let mut n = 50_000usize;
let mut want_deep = false;
let mut want_native = false;
let mut artboards = None::<usize>;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--deep" => want_deep = true,
"--native-io" => want_native = true,
"--artboards" => {
artboards =
Some(it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=4).contains(n)).ok_or("--artboards needs a number from 1 to 4")?)
}
"--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?,
other => return Err(format!("unknown perf option `{other}`")),
}
}
if want_native && n > 50_000 {
return Err("--native-io allows at most 50000 paths".into());
}
if want_native && want_deep {
return Err("--native-io and --deep are separate measurement modes".into());
}
if artboards.is_some() && !want_native {
return Err("--artboards requires --native-io".into());
}
let artboards = artboards.unwrap_or(1);
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
// The figure is the 1-minute load average, or on Windows the cores busy over one second.
#[cfg(not(windows))]
@@ -418,8 +490,20 @@ pub fn run(args: &[String]) -> Result<(), String> {
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
}
let t = Instant::now();
let doc = synthetic(n)?;
let mut doc = synthetic(n)?;
outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
if want_native {
let first = doc.artboards.first().cloned().ok_or("synthetic document has no artboard")?;
// Overlapping artboards intentionally repeat the same paths on every PDF page.
for index in 1..artboards {
let mut page = first.clone();
page.id = first.id.checked_add(index as u32).ok_or("artboard id overflow")?;
page.name = format!("Perf {}", index + 1);
doc.artboards.push(page);
}
outln!(" application native I/O: {artboards} overlapping artboard(s), one warmup + five samples; medians; no disk I/O");
return native_io(&doc);
}
let mut rows: Vec<(&str, f64, f64)> = vec![];
let opts = RenderOptions::default();
@@ -513,6 +597,24 @@ pub fn run(args: &[String]) -> Result<(), String> {
mod tests {
use super::*;
#[test]
fn native_io_exercises_the_application_save_and_open_commands() {
native_io(&synthetic(3).unwrap()).unwrap();
}
#[test]
fn native_io_rejects_conflicting_modes_and_excessive_workloads() {
for (args, expected) in [
(vec!["--native-io", "--deep"], "separate measurement modes"),
(vec!["--native-io", "--paths", "50001"], "at most 50000"),
(vec!["--native-io", "--artboards", "5"], "1 to 4"),
(vec!["--artboards", "1"], "requires --native-io"),
(vec!["--artboards", "2"], "requires --native-io"),
] {
assert!(run(&args.into_iter().map(str::to_owned).collect::<Vec<_>>()).unwrap_err().contains(expected));
}
}
#[test]
fn busy_share_leaves_out_idle_time() {
// In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time,
+1 -1
View File
@@ -14,7 +14,7 @@ name = "vectorcraft-web"
path = "src/main.rs"
[dependencies]
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] }
vectorcraft-ui-egui = { workspace = true }
# The page command API (storytold/craft-libs#413): the request plumbing behind window.vectorcraft.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
+1 -1
View File
@@ -15,7 +15,7 @@ wgpu = ["eframe/wgpu", "dep:winsafe"]
accessibility = ["eframe/accesskit"]
[dependencies]
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-text = { workspace = true }
# Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app)
+1
View File
@@ -467,6 +467,7 @@ fn main() -> std::process::ExitCode {
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
app.custom_titlebar = custom_titlebar;
app.desktop_frame = !custom_titlebar;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx).with_automation_roots(roots.clone());
+72
View File
@@ -308,6 +308,78 @@ impl Default for DxfOptions {
}
}
/// A named set of DXF options (the `dxfPreset` param): the options it sets, and the ones a set of
/// options matches.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DxfPreset {
/// The options as chosen (the export's defaults for those left out).
#[default]
Custom,
/// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight
/// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize
/// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths),
/// and placed images as PNG.
Cnc,
}
impl DxfPreset {
pub const ALL: [Self; 2] = [Self::Custom, Self::Cnc];
/// The `dxfPreset` param value.
pub fn id(self) -> &'static str {
match self {
Self::Custom => "custom",
Self::Cnc => "cnc",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Custom => "Custom",
Self::Cnc => "CNC / CAM",
}
}
/// A preset by id (`custom`, `cnc`), any case.
pub fn from_id(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id()))
}
/// The options the preset sets (Custom: the defaults); the region and crop are the defaults.
pub fn options(self) -> DxfOptions {
match self {
Self::Custom => DxfOptions::default(),
Self::Cnc => DxfOptions {
version: DxfVersion::R2000,
unit: Unit::Millimeters,
scale: 1.0,
scale_lineweights: false,
colors: ColorDepth::Aci256,
raster: RasterFormat::Png,
preserve: Preserve::Editability,
alter_paths: false,
outline_text: true,
..DxfOptions::default()
},
}
}
/// The preset `o` matches: CNC / CAM when every option it sets is as it sets it, else Custom.
pub fn of(o: &DxfOptions) -> Self {
let c = Self::Cnc.options();
let same = o.version == c.version
&& o.unit == c.unit
&& o.scale == c.scale
&& o.scale_lineweights == c.scale_lineweights
&& o.colors == c.colors
&& o.raster == c.raster
&& o.preserve == c.preserve
&& o.alter_paths == c.alter_paths
&& o.outline_text == c.outline_text;
if same { Self::Cnc } else { Self::Custom }
}
}
/// An image file a drawing links to, to be written next to it under `name`.
#[derive(Clone, Debug)]
pub struct DxfImage {
+24
View File
@@ -823,6 +823,30 @@ fn units_scale_coordinates_and_set_insunits() {
assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err());
}
#[test]
fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines() {
let mut d = text_doc("Cut", 20.0);
let layer = d.layers[0].id;
let frame = path(&mut d, shapes::rectangle(Rect::new(5.0, 5.0, 195.0, 95.0)), Paint::None, Paint::solid(Color::BLACK), 10.0);
d.insert(Some(layer), usize::MAX, frame).unwrap();
let o = DxfOptions { region: d.artboards[0].rect, ..DxfPreset::Cnc.options() };
let out = export(&d, &o).unwrap();
let p = pairs(&out.bytes);
assert_eq!(header(&p, "$ACADVER").as_deref(), Some("AC1015"));
assert_eq!(header(&p, "$INSUNITS").as_deref(), Some("4"));
let k = kinds(&out);
assert!(!k.iter().any(|k| matches!(k.as_str(), "TEXT" | "MTEXT" | "IMAGE")), "type is paths: {k:?}");
assert!(k.len() > 2, "the glyphs' outlines: {k:?}");
// The 10 pt stroke (wider than the widest lineweight) is one center line, not a filled outline.
assert_eq!(k.last().map(String::as_str), Some("LWPOLYLINE"), "{k:?}");
assert_ne!(k.get(k.len() - 2).map(String::as_str), Some("LWPOLYLINE"), "{k:?}");
assert!(out.images.is_empty());
assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc);
assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom);
assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom);
assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc));
}
#[test]
fn layers_become_dxf_layers() {
let mut d = shapes_doc();
+251
View File
@@ -936,6 +936,257 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option
}
}
/// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and
/// how the canvas places each object of it: through the symbol's 9-slice guides when the instance
/// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform.
struct InstanceArt {
art: Node,
map: Option<crate::NineSliceMap>,
xf: vectorcraft_geom::Affine,
}
impl InstanceArt {
/// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that
/// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of,
/// which [`Document::place_symbol_art`] does not place and the canvas does not draw.
fn of(doc: &Document, inst: &Node) -> Option<Self> {
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
let art = crate::symbol_overrides::dynamic_art(&sym.art, inst);
let map = doc.symbol_slice_map(symbol, *xf);
if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) {
return None;
}
Some(Self { art, map, xf: *xf })
}
/// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the
/// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell
/// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides
/// object by object.
fn whole(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<vectorcraft_geom::Affine> {
whole.or_else(|| self.map.as_ref()?.one_cell(n, doc))
}
/// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by
/// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when
/// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as
/// [`Document::place_symbol_art`] refuses it.
fn place(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> {
match (&self.map, whole) {
(_, Some(a)) => Some(moved(n, a)),
(Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)),
(None, None) => Some(moved(n, self.xf)),
}
}
/// Object `n` of the art placed on the canvas without clip groups
/// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the
/// same limit ([`Document::place_symbol_art_unclipped`]).
fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> {
match (&self.map, whole) {
(_, Some(a)) => Some(moved(n, a)),
(Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)),
(None, None) => Some(moved(n, self.xf)),
}
}
}
/// `n` transformed by `xf`, its strokes at their own weight.
fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node {
let mut out = n.clone();
out.transform(xf, false);
out
}
/// The object of symbol instance `inst`'s art under `p`, for Direct Selection on a dynamic symbol's
/// instance: its path of child indices from the art's root ([`crate::symbol_overrides::part`]), the
/// topmost object hit inside the art's groups. With [`HitOptions::clip_outlines`], a press on the
/// outline of the clipping path of a clip group or a clipping layer picks that path before the art
/// it clips, as [`hit_test`] does outside symbols. Hidden, locked and template objects are passed
/// over, as [`hit_test`] passes over them when it looks for editable objects, and so are a Shaper
/// group's original shapes, which the canvas does not draw, and guide paths with
/// [`HitOptions::skip_guides`]. Nothing is picked in art that placing through the symbol's 9-slice
/// guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, which the canvas does not
/// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`.
pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option<Vec<u32>> {
struct Find<'a> {
doc: &'a Document,
art: &'a InstanceArt,
p: Point,
opt: HitOptions,
symbols: &'a SymbolBoxes<'a>,
}
impl Find<'_> {
fn hit(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>) -> bool {
self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some())
}
fn skips_guide(&self, n: &Node) -> bool {
self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. })
}
fn find(&self, n: &Node, path: &mut Vec<u32>, whole: Option<vectorcraft_geom::Affine>) -> bool {
if !n.visible || n.locked || n.is_template() || self.skips_guide(n) {
return false;
}
let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole) };
if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH {
return self.hit(n, whole);
}
let whole = self.art.whole(n, self.doc, whole);
if n.clips()
&& let Some(first) = children.first()
{
let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false };
if self.opt.clip_outlines
&& first.visible
&& !first.locked
&& !self.skips_guide(first)
&& clip_outline(&clip, self.p, self.opt).is_some()
{
path.push(0);
return true;
}
if !clip_contains(&clip, self.p) {
return false;
}
}
for (i, c) in children.iter().enumerate().rev() {
// A Shaper group's original shapes are not drawn; symbol art has no isolation scope.
if shaper_hides(n, i, None) {
continue;
}
let Ok(i) = u32::try_from(i) else { continue };
path.push(i);
if self.find(c, path, whole) {
return true;
}
path.pop();
}
false
}
}
let art = InstanceArt::of(doc, inst)?;
let symbols = doc.symbol_boxes();
let mut path = vec![];
Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path)
}
/// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]),
/// placed on the canvas as it is drawn: its paths' outlines, and the bounds of objects without a
/// path. Inside a clip group (an object drawn once in each 9-slice cell it reaches is one clip
/// group per cell) they are cut to the bounds of the group's clip path, which is not outlined. An
/// object inside a clip group or a clipping layer of the art, other than its clipping path, is cut
/// to the bounds of that clipping path as well, which contain the region where [`symbol_part_at`]
/// picks it.
/// `None` for art past the 9-slice object limit that [`symbol_part_at`] applies.
pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option<BezPath> {
let art = InstanceArt::of(doc, inst)?;
let part = crate::symbol_overrides::part(&art.art, path)?;
let mut out = BezPath::new();
let mut limit = None;
let mut whole = None;
let mut n = &art.art;
for &i in path {
let children = n.children()?;
whole = art.whole(n, doc, whole);
if i != 0
&& n.clips()
&& let Some(first) = children.first()
&& let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds()
{
match meet(Some(clip), limit) {
Some(r) => limit = Some(r),
None => return Some(out),
}
}
n = children.get(usize::try_from(i).ok()?)?;
}
outline_within(&art.place(part, doc, whole)?, limit, &mut out);
Some(out)
}
/// [`symbol_part_outline`] for placed object `n`, cut to `limit` when there is one.
fn outline_within(n: &Node, limit: Option<Rect>, out: &mut BezPath) {
use vectorcraft_geom::kurbo::Shape;
if let Some(pd) = n.path_data() {
match limit {
Some(r) => push_cut(out, &pd.to_bezpath(), r),
None => out.extend(pd.to_bezpath()),
}
} else if n.children().is_none()
&& let Some(b) = n.geometric_bounds()
&& let Some(b) = meet(Some(b), limit)
{
out.extend(b.to_path(0.1));
}
let Some(children) = n.children() else { return };
if n.clips()
&& let Some((first, rest)) = children.split_first()
{
let inner = match first.geometric_bounds() {
Some(c) => match meet(Some(c), limit) {
Some(r) => Some(r),
None => return,
},
None => limit,
};
rest.iter().for_each(|c| outline_within(c, inner, out));
return;
}
children.iter().for_each(|c| outline_within(c, limit, out));
}
/// The overlap of `a` and `b` (`a` alone without `b`), `None` when they do not overlap.
fn meet(a: Option<Rect>, b: Option<Rect>) -> Option<Rect> {
let a = a?;
let Some(b) = b else { return Some(a) };
let r = Rect::new(a.x0.max(b.x0), a.y0.max(b.y0), a.x1.min(b.x1), a.y1.min(b.y1));
(r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r)
}
/// The parts of path `p`, flattened to lines, that lie inside `r`, appended to `out`.
fn push_cut(out: &mut BezPath, p: &BezPath, r: Rect) {
use vectorcraft_geom::kurbo::PathEl;
let (mut at, mut start, mut last) = (Point::ZERO, Point::ZERO, None::<Point>);
vectorcraft_geom::kurbo::flatten(p.iter(), 0.1, |el| {
let to = match el {
PathEl::MoveTo(q) => {
(at, start, last) = (q, q, None);
return;
}
PathEl::LineTo(q) => q,
PathEl::ClosePath => start,
_ => return,
};
if let Some((a, b)) = cut_segment(at, to, r) {
if last != Some(a) {
out.move_to(a);
}
out.line_to(b);
last = Some(b);
}
at = to;
});
}
/// The part of segment `a` to `b` inside `r`, `None` when none of it is.
fn cut_segment(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> {
let d = b - a;
let (mut t0, mut t1) = (0.0_f64, 1.0_f64);
for (p, q) in [(-d.x, a.x - r.x0), (d.x, r.x1 - a.x), (-d.y, a.y - r.y0), (d.y, r.y1 - a.y)] {
if p.abs() < 1e-12 {
if q < 0.0 {
return None;
}
} else if p < 0.0 {
t0 = t0.max(q / p);
} else {
t1 = t1.min(q / p);
}
}
(t0 <= t1).then(|| (a + d * t0, a + d * t1))
}
/// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is
/// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an
/// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`.
+125
View File
@@ -1373,3 +1373,128 @@ fn cull_box_holds_the_reach_of_paths_and_compound_paths() {
assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}");
assert_eq!(checked, 201);
}
/// In a dynamic symbol's art, a layer with a clipping mask clips its objects as a clip group does:
/// Direct Selection picks an object only where the mask shows it, and outlines it there.
#[test]
fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2));
if let NodeKind::Layer { children, .. } = &mut layer.kind {
*children = vec![Arc::new(mask), Arc::new(art)];
}
layer.set_clips(true);
let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]);
d.symbols.push(crate::Symbol { dynamic: true, name: "Clipped".into(), art: Arc::new(root) });
let inst = d.alloc_id();
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
let n = d.node(inst).unwrap();
let opt = HitOptions::default();
assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "where the mask shows the art");
assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), None, "where the mask hides it");
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0, 1]).unwrap());
assert!((b.x1 - 100.0).abs() < 1e-6, "the outline is cut to the mask: {b:?}");
// Its outline inside the group around the layer is cut too.
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0]).unwrap());
assert!((b.x1 - 100.0).abs() < 1e-6, "the layer's outline is cut to the mask: {b:?}");
}
/// In a dynamic symbol's art that placing through its 9-slice guides could make more than
/// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the
/// canvas draws nothing of it. At the art's natural size the same art is picked.
#[test]
fn symbol_art_past_the_object_limit_picks_nothing() {
use vectorcraft_color::{Gradient, GradientPaint};
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()));
let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800]));
let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true);
mask.linked = false;
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
// Drawn in six cells, each with its own copy of the mask's 3,240,000 squares.
let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(gradient, Paint::None, 0.0));
big.extras_mut().mask = Some(Box::new(mask));
let plain = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 100.0, 100.0)), Appearance::default_art());
let root = Node::group(d.alloc_id(), vec![Arc::new(plain), Arc::new(big)]);
d.symbols.push(crate::Symbol { dynamic: true, name: "Big".into(), art: Arc::new(root) });
d.set_symbol_nine_slice("Big", Some(crate::SymbolNineSlice::default()));
let scaled = d.alloc_id();
let natural = d.alloc_id();
let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5);
d.insert(Some(l), 0, Node::new(scaled, NodeKind::SymbolInstance { symbol: "Big".into(), xf })).unwrap();
d.insert(Some(l), 0, Node::new(natural, NodeKind::SymbolInstance { symbol: "Big".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
assert!(d.place_symbol_art("Big", &d.symbols[0].art, xf).is_none(), "the art is past the limit");
let opt = HitOptions::default();
let n = d.node(scaled).unwrap();
assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 110.0), opt), None, "the plain square");
assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 40.0), opt), None, "the masked path");
assert!(symbol_part_outline(&d, n, &[0]).is_none());
assert!(symbol_part_outline(&d, n, &[1]).is_none());
let n = d.node(natural).unwrap();
assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 75.0), opt), Some(vec![0]));
assert!(symbol_part_outline(&d, n, &[0]).is_some());
}
/// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it
/// included, so an object in it is picked and outlined there even when placing that object through
/// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects.
#[test]
fn objects_of_a_clip_group_in_one_cell_are_placed_with_it() {
use vectorcraft_color::{Gradient, GradientPaint};
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()));
let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800]));
let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true);
mask.linked = false;
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0));
big.extras_mut().mask = Some(Box::new(mask));
let clip = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art());
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(big.clone())]);
group.set_clips(true);
let frame = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
let root = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(group)]);
d.symbols.push(crate::Symbol { dynamic: true, name: "Corner".into(), art: Arc::new(root) });
d.set_symbol_nine_slice("Corner", Some(crate::SymbolNineSlice::default()));
let inst = d.alloc_id();
let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5);
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Corner".into(), xf })).unwrap();
let m = d.symbol_slice_map("Corner", xf).unwrap();
assert!(m.most_placed(&big, &d) > crate::MAX_SYMBOL_NODES, "the object alone is past the limit");
assert!(d.place_symbol_art("Corner", &d.symbols[0].art, xf).is_some(), "the art is drawn");
let n = d.node(inst).unwrap();
assert_eq!(symbol_part_at(&d, n, Point::new(10.0, 10.0), HitOptions::default()), Some(vec![1, 1]));
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[1, 1]).unwrap());
assert!((b.x1 - 20.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "cut to the clip in its corner: {b:?}");
}
/// In a dynamic symbol's art, Direct Selection does not pick a Shaper group's original shapes,
/// which the canvas does not draw: a click over an original shape whose face the group erased
/// picks nothing, and a click over the face it shows picks the face.
#[test]
fn a_shaper_groups_original_shapes_in_symbol_art_are_not_picked() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
sources.name = Some(crate::shaper::SOURCES.into());
sources.opacity = 0.0;
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
group.extras_mut().shaper = Some(Box::default());
let root = Node::group(d.alloc_id(), vec![Arc::new(group)]);
d.symbols.push(crate::Symbol { dynamic: true, name: "Shaped".into(), art: Arc::new(root) });
let inst = d.alloc_id();
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
let n = d.node(inst).unwrap();
let opt = HitOptions::default();
assert_eq!(symbol_part_at(&d, n, Point::new(130.0, 50.0), opt), None, "over the erased original shape");
assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "over the shown face");
}
+1 -1
View File
@@ -109,7 +109,7 @@ pub use placed_document::{PageBox, PlacedDocument};
pub use profiles::ColorProfiles;
pub use puppet::{PuppetPin, PuppetPins};
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
pub use selection::{AnchorRef, SegmentRef, Selection};
pub use selection::{AnchorRef, SegmentRef, Selection, SymbolPart};
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
+48
View File
@@ -54,6 +54,20 @@ pub struct Selection {
/// the engine then sets the document's from it. Editing state, never saved.
#[serde(skip)]
pub new_art: Arc<HashSet<NodeId>>,
/// An object of a dynamic symbol instance's art, selected with Direct Selection: the instance
/// is the one selected object, and solid fill and stroke changes go to its own appearance for
/// that object ([`crate::symbol_overrides`]). Any other selection change clears it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub symbol_part: Option<SymbolPart>,
}
/// See [`Selection::symbol_part`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolPart {
/// The symbol instance.
pub instance: NodeId,
/// The object in the symbol's art, as [`crate::symbol_overrides::part`] names it.
pub path: Vec<u32>,
}
impl Selection {
@@ -79,6 +93,13 @@ impl Selection {
self.slices.clear();
self.guides.clear();
self.key_guide = None;
self.symbol_part = None;
}
/// Select only instance `instance`, with the object at `path` of its symbol's art (see
/// [`Selection::symbol_part`]).
pub fn set_symbol_part(&mut self, instance: NodeId, path: Vec<u32>) {
self.set([instance]);
self.symbol_part = Some(SymbolPart { instance, path });
}
/// Is there a key for Align: an object or a guide?
pub fn has_key(&self) -> bool {
@@ -93,6 +114,7 @@ impl Selection {
pub fn add(&mut self, id: NodeId) {
self.target = None;
self.slices.clear();
self.symbol_part = None;
if !self.objects.contains(&id) {
self.objects.push(id);
}
@@ -100,6 +122,7 @@ impl Selection {
pub fn remove(&mut self, id: NodeId) {
self.target = None;
self.slices.clear();
self.symbol_part = None;
self.objects.retain(|x| *x != id);
self.anchors.remove(&id);
self.segments.remove(&id);
@@ -128,6 +151,7 @@ impl Selection {
/// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a
/// Shift-click on a guide).
pub fn toggle_guide(&mut self, i: usize) {
self.symbol_part = None;
if let Some(k) = self.guides.iter().position(|g| *g == i) {
self.guides.remove(k);
if self.key_guide == Some(i) {
@@ -207,6 +231,27 @@ impl Selection {
if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) {
self.key_guide = None;
}
// A selected part needs its instance selected alone, with no guides, its symbol dynamic
// and the object still in the symbol's art as a path, compound path or type, where neither
// it nor a group or layer around it is hidden, locked or a template.
let editable_part = |art: &crate::Node, path: &[u32]| {
crate::symbol_overrides::part(art, path).is_some_and(crate::symbol_overrides::takes_part_paint)
&& (0..=path.len())
.all(|k| crate::symbol_overrides::part(art, &path[..k]).is_some_and(|n| n.visible && !n.locked && !n.is_template()))
};
let part_kept = self.symbol_part.as_ref().is_some_and(|sp| {
self.objects == [sp.instance]
&& self.guides.is_empty()
&& found.get(&sp.instance).and_then(|p| doc.node_at(p)).is_some_and(|n| match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => {
doc.symbols.iter().find(|s| s.name == *symbol).is_some_and(|s| s.dynamic && editable_part(&s.art, &sp.path))
}
_ => false,
})
});
if !part_kept {
self.symbol_part = None;
}
}
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
/// around them (and so the key object when it is one): hidden or locked art can't stay
@@ -238,6 +283,9 @@ impl Selection {
}
self.target = None;
self.slices.clear();
if self.symbol_part.as_ref().is_some_and(|sp| gone.contains(&sp.instance)) {
self.symbol_part = None;
}
self.objects.retain(|o| !gone.contains(o));
self.anchors.retain(|id, _| !gone.contains(id));
self.segments.retain(|id, _| !gone.contains(id));
+76
View File
@@ -41,6 +41,82 @@ fn guides_are_selected_on_their_own() {
assert_eq!(s.guides, vec![0]);
}
/// A picked object of a dynamic symbol's instance stays picked while it and the groups and layers
/// around it in the symbol's art are visible, unlocked and not a template. An art edit that hides
/// or locks it, or one around it, or makes a layer around it a template, drops the pick.
#[test]
fn a_picked_symbol_part_is_dropped_once_it_cannot_be_edited() {
let rect = || vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let leaf = Node::path(d.alloc_id(), rect(), crate::Appearance::default_art());
let group = Node::group(d.alloc_id(), vec![Arc::new(leaf)]);
let layer = Node::layer(d.alloc_id(), "Inner", crate::LayerColor::Preset(2));
let root = Node::group(d.alloc_id(), vec![Arc::new(group), Arc::new(layer)]);
d.symbols.push(crate::Symbol { dynamic: true, name: "Parts".into(), art: Arc::new(root.clone()) });
let inst = d.alloc_id();
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Parts".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
// The layer in the art holds a path too.
let in_layer = Node::path(d.alloc_id(), rect(), crate::Appearance::default_art());
let mut art = root;
if let Some(NodeKind::Layer { children, .. }) = art.children_mut().and_then(|c| c.get_mut(1)).map(|c| &mut Arc::make_mut(c).kind) {
children.push(Arc::new(in_layer));
}
d.symbols[0].art = Arc::new(art.clone());
let picked = |d: &Document, path: &[u32]| {
let mut s = Selection::default();
s.set_symbol_part(inst, path.to_vec());
s.prune(d);
s.symbol_part.is_some()
};
assert!(picked(&d, &[0, 0]) && picked(&d, &[1, 0]), "editable parts stay picked");
assert!(!picked(&d, &[0]) && !picked(&d, &[1]) && !picked(&d, &[]), "a group or layer does not stay picked");
let edited = |change: &dyn Fn(&mut Node)| {
let mut d = d.clone();
let mut art = art.clone();
change(&mut art);
d.symbols[0].art = Arc::new(art);
d
};
let child = |art: &mut Node, path: &[usize]| -> Node {
let mut n = &*art;
for &i in path {
n = &n.children().unwrap()[i];
}
n.clone()
};
let set = |art: &mut Node, path: &[usize], n: Node| {
let mut at = art;
for &i in path {
at = Arc::make_mut(&mut at.children_mut().unwrap()[i]);
}
*at = n;
};
let hidden = edited(&|art| {
let mut n = child(art, &[0, 0]);
n.visible = false;
set(art, &[0, 0], n);
});
assert!(!picked(&hidden, &[0, 0]), "the part is hidden");
let locked_group = edited(&|art| {
let mut n = child(art, &[0]);
n.locked = true;
set(art, &[0], n);
});
assert!(!picked(&locked_group, &[0, 0]), "the group around it is locked");
assert!(picked(&locked_group, &[1, 0]), "a part outside the locked group stays");
let template = edited(&|art| {
let mut n = child(art, &[1]);
if let NodeKind::Layer { template, .. } = &mut n.kind {
*template = true;
}
set(art, &[1], n);
});
assert!(!picked(&template, &[1, 0]), "the layer around it is a template");
let hidden_root = edited(&|art| art.visible = false);
assert!(!picked(&hidden_root, &[0, 0]), "the art is hidden");
}
#[test]
fn guides_show_and_lock_with_their_layer() {
let mut d = Document::new(100.0, 100.0);
+24 -3
View File
@@ -176,7 +176,7 @@ impl Document {
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
let mut tree = |n: &Node| {
walk_with_masks(n, &mut |m| {
node_paints(m).for_each(&mut *f);
node_paints(m).chain(override_paints(m)).for_each(&mut *f);
if let NodeKind::Mesh(mesh) = &m.kind {
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
}
@@ -275,10 +275,11 @@ fn map_style_paint(attrs: &mut serde_json::Map<String, serde_json::Value>, key:
}
/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs
/// and gradient stops (with their link), then mesh points (unlinked), in paint order.
/// and gradient stops (with their link), a dynamic symbol instance's own paints
/// ([`override_paints`]), then mesh points (unlinked), in paint order.
pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) {
n.walk(&mut |m| {
for p in node_paints(m) {
for p in node_paints(m).chain(override_paints(m)) {
match p {
Paint::Solid { color, swatch, .. } => f(color, swatch.as_deref()),
Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, s.swatch.as_deref())),
@@ -315,6 +316,12 @@ pub fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])).chain(n.extras().colorize.as_deref())
}
/// The paints a dynamic symbol instance keeps for objects of its art
/// ([`crate::symbol_overrides`]), which [`node_paints`] leaves out.
pub fn override_paints(n: &Node) -> impl Iterator<Item = &Paint> {
n.extras().symbol_overrides.iter().flat_map(|o| [o.fill.as_ref(), o.stroke.as_ref()]).flatten()
}
/// Mutable [`node_paints`], in the same order.
pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
let runs = match &mut n.kind {
@@ -353,6 +360,20 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
}
out = Some(m);
}
// A dynamic symbol instance's own paints for objects of its art (`symbol_overrides`).
if !n.extras().symbol_overrides.is_empty() {
let mut overrides = n.extras().symbol_overrides.clone();
let mut k = 0;
for p in overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() {
if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) && p.map_links(f) {
k += 1;
}
}
if k > 0 {
out.get_or_insert_with(|| n.clone()).extras_mut().symbol_overrides = overrides;
count += k;
}
}
if let Some(ch) = n.children() {
for (i, c) in ch.iter().enumerate() {
if let Some((new, k)) = map_node(c, f) {
+12
View File
@@ -451,6 +451,18 @@ impl NineSliceMap {
self.most_at(art, &boxes, 0)
}
/// The affine map that [`Self::place`] and [`Self::place_unclipped`] move object `n` by as one
/// piece, with everything in it: the map of the cell its box lies in, or of the cell at the
/// origin when it has no box. `None` when its box crosses a guide.
pub fn one_cell(&self, n: &Node, doc: &Document) -> Option<Affine> {
let boxes = doc.symbol_boxes().quick();
let Some(b) = n.geometric(Some(&boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else {
return Some(self.cell_at(Point::ZERO));
};
let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1));
(cols[0] == cols[1] && rows[0] == rows[1]).then(|| self.cell_xf(cols[0], rows[0]))
}
/// [`Self::most_placed`] for [`Self::place_unclipped`]: the most objects it makes of `art`.
pub fn most_placed_unclipped(&self, art: &Node, doc: &Document) -> u64 {
Self { clips: false, ..*self }.most_placed(art, doc)
+40
View File
@@ -49,6 +49,10 @@ impl SymbolOverride {
r.style.fill = p.clone();
}
if let Some(p) = &self.stroke {
// A painted stroke on characters without a weight gets 1 pt, as a path's does.
if !p.is_none() && r.style.stroke_width == 0.0 {
r.style.stroke_width = 1.0;
}
r.style.stroke = p.clone();
}
}
@@ -57,6 +61,14 @@ impl SymbolOverride {
n.appearance.set_fill(p.clone());
}
if let Some(p) = &self.stroke {
// A stroke without paint has no weight; painting it gives it 1 pt.
if !p.is_none()
&& let Some(s) = n.appearance.stroke_mut()
&& s.paint.is_none()
&& s.width == 0.0
{
s.width = 1.0;
}
n.appearance.set_stroke(p.clone());
}
}
@@ -69,6 +81,13 @@ impl SymbolOverride {
}
}
/// Can Direct Selection select `n`, an object of a dynamic symbol's art, and change its fill and
/// stroke for one instance? Paths, compound paths and type can; groups, images and every other
/// object cannot.
pub fn takes_part_paint(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_))
}
/// An override's path read from a file: at most [`MAX_PATH_DEPTH`] indices, so a compressed file
/// of one endless path stays small once read.
fn short_path<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> {
@@ -180,6 +199,27 @@ mod tests {
assert_eq!(kept.len(), 1);
}
#[test]
fn a_stroke_on_type_without_a_weight_gets_one_point() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let style = crate::CharStyle { stroke: Paint::None, stroke_width: 0.0, ..crate::CharStyle::default() };
let text = Node::new(NodeId(2), NodeKind::Text(Box::new(crate::TextObject::point(vectorcraft_geom::Point::ZERO, "Hi", style))));
let mut a = Node::group(NodeId(1), vec![Arc::new(text)]);
apply(&mut a, &[SymbolOverride { path: vec![0], stroke: Some(red.clone()), ..Default::default() }]);
let Some(NodeKind::Text(t)) = part(&a, &[0]).map(|n| &n.kind) else { panic!("type") };
assert!(t.runs.iter().all(|r| r.style.stroke == red && r.style.stroke_width == 1.0), "the stroke draws at 1 pt");
// A path whose stroke has neither paint nor weight gets 1 pt too.
let mut ap = Appearance::default_art();
if let Some(s) = ap.stroke_mut() {
(s.paint, s.width) = (Paint::None, 0.0);
}
let path = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap);
let mut a = Node::group(NodeId(1), vec![Arc::new(path)]);
apply(&mut a, &[SymbolOverride { path: vec![0], stroke: Some(red.clone()), ..Default::default() }]);
assert_eq!(part(&a, &[0]).unwrap().appearance.stroke_width(), 1.0);
}
#[test]
fn a_path_deeper_than_the_cap_does_not_read() {
let deep = format!("{{\"path\":{:?}}}", vec![0; MAX_PATH_DEPTH + 1]);
+6 -3
View File
@@ -233,13 +233,16 @@ impl ParaList {
/// The list that the Type tool starts when `typed` and a space are typed at the start of a
/// paragraph: "1." or "1)" starts a numbered list in 1, 2, 3, "A." or "A)" starts one in A, B,
/// C, and "*", "#", ">" or "+" starts a bulleted list with the filled dot. Any other text starts
/// none.
/// C, and "*" starts a bulleted list with the filled dot, "+" one with the hollow circle, "#"
/// one with the filled square and ">" one with the dash. Any other text starts none.
pub fn typed(typed: &str) -> Option<ParaList> {
match typed {
"1." | "1)" => Some(Self::numbered(NumberStyle::Arabic)),
"A." | "A)" => Some(Self::numbered(NumberStyle::UpperLetters)),
"*" | "#" | ">" | "+" => Some(Self::bulleted(Bullet::Dot)),
"*" => Some(Self::bulleted(Bullet::Dot)),
"+" => Some(Self::bulleted(Bullet::Circle)),
"#" => Some(Self::bulleted(Bullet::Square)),
">" => Some(Self::bulleted(Bullet::Dash)),
_ => None,
}
}
+8 -7
View File
@@ -212,22 +212,23 @@ fn number_styles_write_their_numbers_and_roll_over() {
}
/// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph
/// start a numbered list, and "*", "#", ">" and "+" and a space start a bulleted list with the
/// filled dot. Other text, the same characters later in a paragraph or without the space, and
/// a paragraph already in a list start nothing.
/// start a numbered list, and "*", "+", "#" and ">" and a space start a bulleted list with the
/// filled dot, the hollow circle, the filled square and the dash. Other text, the same characters
/// later in a paragraph or without the space, and a paragraph already in a list start nothing.
#[test]
fn typed_list_starts_are_found_at_a_paragraphs_start() {
let (arabic, letters, dot) =
(ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot));
let typed = ["1.", "1)", "A.", "A)", "*", "#", ">", "+"].map(ParaList::typed);
assert_eq!(typed, [arabic, arabic, letters, letters, dot, dot, dot, dot].map(Some));
let (circle, square, dash) = (ParaList::bulleted(Bullet::Circle), ParaList::bulleted(Bullet::Square), ParaList::bulleted(Bullet::Dash));
let typed = ["1.", "1)", "A.", "A)", "*", "+", "#", ">"].map(ParaList::typed);
assert_eq!(typed, [arabic, arabic, letters, letters, dot, circle, square, dash].map(Some));
for other in ["", "2.", "a.", "a)", "I.", "1", "A", "1:", "-", "•", "1.)", " 1.", "*1", "1."] {
assert_eq!(ParaList::typed(other), None, "{other:?}");
}
let mut t = TextObject::point(Point::ZERO, "Fruit\n1. Apples\nA) x * y\n+ ", CharStyle::default());
assert_eq!(t.typed_list_at(9), Some((6, arabic)));
assert_eq!(t.typed_list_at(19), Some((16, letters)));
assert_eq!(t.typed_list_at(27), Some((25, dot)));
assert_eq!(t.typed_list_at(27), Some((25, circle)));
// Not at the paragraph's start, not right after the space, or past the end.
for at in [0, 5, 8, 10, 15, 23, 25, 26, 99] {
assert_eq!(t.typed_list_at(at), None, "{at}");
@@ -245,7 +246,7 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() {
#[test]
fn typed_list_starts_skip_a_continued_paragraph() {
let dot = ParaList::bulleted(Bullet::Dot);
let mut t = TextObject::point(Point::ZERO, "* Pears\n# ", CharStyle::default());
let mut t = TextObject::point(Point::ZERO, "* Pears\n* ", CharStyle::default());
assert_eq!(t.typed_list_at(2), Some((0, dot)));
t.first_para_continues = true;
assert_eq!(t.typed_list_at(2), None, "a continued paragraph starts no list");
+5 -6
View File
@@ -105,12 +105,11 @@ impl DataSet {
/// The variable state of a document: definitions, datasets, bindings
/// (object id → the variables bound to it) and which dataset is active.
///
/// An object may be driven by several variables — the reference app binds the selected object
/// to the selected variable, one click at a time, and never says a second binding replaces the
/// first — so a binding is a set. `dataset.select` applies the text values in the order the
/// variables are defined, which is what makes two text variables on one object deterministic:
/// the last one wins, and `applied` counts both. Of two visibility variables on one object, the
/// last one defined wins too.
/// An object may be driven by several variables, so a binding is a set. `variable.bind` and Make
/// Text Dynamic refuse a second text variable on one type object, but a document saved before
/// they did may hold two: `dataset.select` applies the text values in the order the variables
/// are defined, so the last one wins, and `applied` counts both. Of two visibility variables on
/// one object, the last one defined wins too.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Variables {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
+12 -1
View File
@@ -9,10 +9,14 @@ description = "VectorCraft engine: sessions, history, selection, the command reg
workspace = true
[features]
default = ["bundled-fonts"]
default = ["bundled-fonts", "heif"]
# The fonts vectorcraft-text bundles (see its `bundled-fonts`). Embedders that supply every font at
# run time (`vectorcraft_text::FontDb::add_font`) build with `default-features = false`.
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
# HEIC and HEIF images open and place, decoded by heic-rs (an HEVC decoder). A build without this
# feature still recognizes the files and refuses them with a message. The apps list it beside
# `bundled-fonts`.
heif = ["dep:heic-rs", "dep:rayon"]
[dependencies]
# Which paths stored in documents may be opened.
@@ -37,6 +41,8 @@ vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] }
# Pure Rust, no unsafe; pinned at the version photocraft-heif uses.
heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], optional = true }
flate2 = { workspace = true }
chrono = { workspace = true }
# The XML parts of Word documents placed as type (place/docx.rs).
@@ -54,6 +60,11 @@ log = { workspace = true }
[target.'cfg(unix)'.dependencies]
rustix = { workspace = true, features = ["std", "process"] }
# 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 }
rayon = { version = "1.12", optional = true }
[dev-dependencies]
vectorcraft-affinity = { workspace = true, features = ["synth"] }
crc32fast = { workspace = true }
+7 -2
View File
@@ -590,11 +590,16 @@ pub(crate) fn object_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Nod
}
/// Objects a command targets: its `ids`, else its `id` as `ids` of one ([`named_ids`]), else the
/// selection. An `ids` or `id` that isn't an object id is an error, never the selection.
/// selection. An `ids` or `id` that isn't an object id is an error, never the selection. The
/// selection is refused while an object of a dynamic symbol instance's art is selected, as by
/// [`edit::selected_roots`].
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match named_ids(p)? {
Some(ids) => Ok(ids),
None => Ok(s.doc()?.selection.objects.clone()),
None => {
edit::refuse_symbol_part(s)?;
Ok(s.doc()?.selection.objects.clone())
}
}
}
+37 -1
View File
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eyedropper",
[],
None,
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art). On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
has_doc,
copy_from
),
@@ -1136,6 +1136,42 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`"));
}
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
// An object of a dynamic symbol instance's art selected with Direct Selection takes the clicked
// object's solid fill and stroke, as the Eyedropper Options allow, for this instance only. A
// gradient or pattern sampled for it, a source that is not a path, compound path or type (a
// symbol instance's own appearance is empty), and options that pick up neither color are
// refused, and the paint defaults stay as they are. A path of a compound path stands for the
// compound path, which holds the paint.
if !reverse
&& !append
&& !defaults_only
&& p.get("ids").is_none()
&& let Some((id, path)) = super::brushsym::selected_part(s)?
{
let d = &s.doc()?.doc;
let src = Arc::new(d.node(super::path::compound_or_self(d, clicked)).ok_or(EngineError::NoNode(clicked))?.clone());
let (fill, stroke) = (super::paint::proxy_paint(&src, false, None), super::paint::proxy_paint(&src, true, None));
let a = attrs.appearance;
if !vectorcraft_doc::symbol_overrides::takes_part_paint(&src)
|| !(a.fill.color || a.stroke.color)
|| (a.fill.color && !super::paint::part_takes(&fill))
|| (a.stroke.color && !super::paint::part_takes(&stroke))
{
return Err(super::brushsym::part_only());
}
super::brushsym::edit_part(s, id, &path, "Eyedropper", |o| {
if a.fill.color {
o.fill = Some(fill);
}
if a.stroke.color {
o.stroke = Some(stroke);
}
})?;
// The clicked object becomes the Eyedropper's sample, and new art takes its paint.
Picked::of(&src).to_defaults(s, &attrs.appearance);
s.eyedropper_sample = Some(src);
return Ok(json!({ "ids": [id.0] }));
}
let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() {
super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b)
} else {
+11 -1
View File
@@ -36,7 +36,8 @@ use brushes::{
brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove,
brush_selection_art, brush_set_current, brush_unused, expand_brush,
};
use dynamic::{symbol_instance_reset, symbol_instance_set_appearance};
pub(crate) use dynamic::{edit_part, part_only, selected_part};
use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance};
use symbols::selected_instances;
pub(crate) use symbols::{
expand_instances_under, expandable_instances, instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol,
@@ -266,6 +267,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
symbol_instance_set_appearance
),
cmd!(
"symbol.instance.selectPart",
"Select Symbol Instance Part",
[],
None,
"{id, path: [i…] (as symbol.instance.setAppearance)} select one path, compound path or type object of a dynamic symbol instance's art, as a Direct Selection click on it does: the instance is selected alone, and until the selection changes paint.setFill and paint.setStroke with a solid color or none, paint.invert and paint.complement on a solid color (without ids, id or item; type whose characters differ in color is refused) and appearance.copyFrom with a solid fill and stroke (the Eyedropper, without ids, reverse or append; options that pick up neither color are refused) change that object's fill or stroke for this instance only; gradients and patterns are refused, and so are the commands that change the selected objects without ids (transparency.set, the gradient commands, object.group, edit.cut and edit.clear among them); edit.copy copies the instance whole, and selection exports, artboard.fitToSelection, object.liquify, object.isolate, library.add and select.save use the instance (docs/mcp.md); selectPart itself is refused for a static symbol, a path naming no object, a group, image or other kind of object, a hidden, locked or template object or one inside such a group, and a hidden or locked instance → {id, path}",
has_doc,
symbol_instance_select_part
),
cmd!(
"symbol.instance.reset",
"Reset Symbol Instance",
+57 -10
View File
@@ -61,6 +61,62 @@ fn dynamic_instance(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, Arc<No
Ok((id, sym.art.clone()))
}
/// The selected object of a dynamic symbol instance's art (Direct Selection): the instance and the
/// object's path in the symbol's art.
pub(crate) fn selected_part(s: &Session) -> Result<Option<(NodeId, Vec<u32>)>> {
Ok(s.doc()?.selection.symbol_part.as_ref().map(|sp| (sp.instance, sp.path.clone())))
}
/// Change instance `id`'s own appearance for the object at `path` of its art by `f`, as one undo
/// step named `label`; nothing is recorded when the change leaves it as it was. Replies how many
/// objects the instance changes.
pub(crate) fn edit_part(s: &mut Session, id: NodeId, path: &[u32], label: &str, f: impl FnOnce(&mut SymbolOverride)) -> Result<usize> {
let list = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.extras().symbol_overrides.clone();
let old = list.iter().find(|o| o.path == path).cloned().unwrap_or_else(|| SymbolOverride { path: path.to_vec(), ..Default::default() });
let mut new = old.clone();
f(&mut new);
if new == old {
return Ok(list.len());
}
s.edit(label, |d, _| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let mut e = n.extras_mut();
let list = &mut e.symbol_overrides;
list.retain(|o| o.path != path);
if !new.is_empty() {
list.push(new);
}
Ok(list.len())
})
}
/// The refusal of anything but a solid color fill or stroke change while an object of a symbol
/// instance's art is selected ([`selected_part`]), and of selecting any other object of the art.
pub(crate) fn part_only() -> EngineError {
EngineError::Other("Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change".into())
}
pub(super) fn symbol_instance_select_part(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "symbol.instance.selectPart";
if p.get("id").is_none() {
return Err(bad(C, "missing id"));
}
let (id, art) = dynamic_instance(s, p, C)?;
let path = path_param(p, &art, C)?;
// Direct Selection passes over hidden, locked and template objects, and over everything in a
// group or layer that is: the instance in the document, and the object in the symbol's art.
let editable = |n: &Node| n.visible && !n.locked && !n.is_template();
if !s.doc()?.doc.is_editable(id) || !(0..=path.len()).all(|k| symbol_overrides::part(&art, &path[..k]).is_some_and(editable)) {
return Err(bad(C, "the object is hidden or locked"));
}
if !symbol_overrides::part(&art, &path).is_some_and(symbol_overrides::takes_part_paint) {
return Err(part_only());
}
let shown = json!(path);
s.select(|_, sel| sel.set_symbol_part(id, path))?;
Ok(json!({ "id": id.0, "path": shown }))
}
/// The `path` param: child indices from the art's root, naming an object of `art`.
fn path_param(p: &Value, art: &Node, cmd: &str) -> Result<Vec<u32>> {
let path: Option<Vec<u32>> =
@@ -117,16 +173,7 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu
let d = &s.doc()?.doc;
let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned());
let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?;
let count = s.edit("Symbol Instance Appearance", |d, _| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let mut e = n.extras_mut();
let list = &mut e.symbol_overrides;
list.retain(|o| o.path != path);
if !new.is_empty() {
list.push(new);
}
Ok(list.len())
})?;
let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?;
Ok(json!({ "id": id.0, "overrides": count }))
}
@@ -888,6 +888,8 @@ pub(super) fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0);
let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0;
let alt = switch_or(p, "alt", false, C)?;
// Refused while an object of a dynamic instance's art is selected: the instance stays as it is.
crate::cmd::edit::refuse_symbol_part(s)?;
let st = s.doc()?;
let color = match color_param(p, "color", C)? {
Some(c) => crate::cmd::paint::new_color(s, p, c),
+3 -3
View File
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make and Expand",
["Object", "Image Trace"],
None,
"{…as imageTrace.make} same as imageTrace.make, then expand → {id, paths, anchors, colors}",
"{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, true)
),
@@ -171,7 +171,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand",
["Object", "Image Trace"],
None,
"{id?, ids?: [id…] (instead of id)} keep only the traced paths (drop the source image) → {ids}",
"{id?, ids?: [id…] (instead of id), byColor?: false (each color's paths in a group of their own, named by the swatch the color links to or as #rrggbb, gradient fills in one group named Gradients; the groups stack by their lowest path and keep their paths' order; a color whose paths overlap another color's from above and below gets a group for each part, so the art draws the same; refused for a trace made with autoGrouping and for a trace with a live Pathfinder effect)} keep only the traced paths (drop the source image) → {ids}",
has_selection_or_ids,
trace_expand
),
+346 -1
View File
@@ -4,10 +4,16 @@ use vectorcraft_color::Swatch;
use vectorcraft_doc::ImageObject;
use super::{
Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point,
Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, Rect,
Result, Session, StrokeLayer, TRACE_NAME, TraceView, Value, bad, fill_only, ids_json, json, root_ids, shape_node, str_param, switch_or, tr,
};
#[cfg(test)]
thread_local! {
static BOX_TESTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
static CANDIDATE_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
// ---------- Image Trace ----------
fn is_trace_group(n: &Node) -> bool {
@@ -16,6 +22,8 @@ fn is_trace_group(n: &Node) -> bool {
&& n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_))))
}
const LIVE_PATHFINDER_BY_COLOR: &str = "byColor can't regroup a trace with a live Pathfinder effect: expand without byColor";
/// The `view` param ([`TraceView`] id), if given.
fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) };
@@ -368,6 +376,156 @@ fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec
roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect()
}
/// The `byColor` param of an Expand (false when not given).
fn by_color_param(cmd: &str, p: &Value) -> Result<bool> {
match p.get("byColor").filter(|v| !v.is_null()) {
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "byColor must be true or false")),
None => Ok(false),
}
}
/// Expand by color: an expanded trace's `items` (bottom-most first) in a group for each paint, a
/// stroked line counted by its stroke. A solid color's group is named by the swatch it links to (a
/// tint with its percentage) or as `#rrggbb`, and gradient fills share one group, "Gradients". The
/// groups stack in the order of the lowest item in each, and each keeps its items' order. When the
/// paths meet, an item joins its paint's last group only if no item of a later group meets it
/// (by the boxes of everything they paint, effects included, within one point); otherwise it
/// starts another group of that paint, which keeps the stacking order of those items. Items
/// farther apart can still share antialiased pixels at small view or raster-export scales, so
/// their edge pixels may change. A group among `items` (Auto Grouping) is refused.
fn color_groups(cmd: &str, d: &mut Document, items: Vec<Arc<Node>>) -> Result<Vec<Arc<Node>>> {
use std::collections::{BTreeMap, HashMap};
use std::ops::Bound::{Excluded, Unbounded};
use vectorcraft_color::tint_percent;
// Grouped by the paint: unlinked (its #rrggbb), linked (the swatch and its exact tint), a
// gradient, or another paint (its label). The name is apart from it: a swatch may be named as
// a color's `#rrggbb` or as "Gradients".
type PaintKey = (u8, String, u32);
struct Run {
name: String,
members: Vec<Arc<Node>>,
}
// Boxes closer than a point count as overlapping: their antialiased edges can share pixels.
let meet = |a: &Rect, b: &Rect| {
#[cfg(test)]
BOX_TESTS.with(|n| n.set(n.get() + 1));
let b = b.inflate(1.0, 1.0);
a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
};
// Choose a cell size from the painted extent and path count. A box over more than eight cells
// across but at most eight down (a stripe) is indexed by its rows, one the other way round by
// its columns, and one over more than eight both ways stays in a separate list, keeping the
// index bounded even when a path covers most of the document.
let items: Vec<_> = items
.into_iter()
.map(|item| {
let bounds = vectorcraft_render::painted_bounds(d, &item);
(item, bounds)
})
.collect();
let extent = items.iter().filter_map(|(_, b)| *b).reduce(|a, b| a.union(b));
let cell_size = extent.map_or(1.0, |r| ((r.x1 - r.x0).abs() * (r.y1 - r.y0).abs() / items.len().max(1) as f64).sqrt().max(1.0));
// The first and last column and row of the cells `r` covers; `None` when it isn't finite.
let span = |r: Rect| -> Option<(i64, i64, i64, i64)> {
let coord = |v: f64| (v / cell_size).floor() as i64;
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then(|| (coord(r.x0), coord(r.y0), coord(r.x1), coord(r.y1)))
};
let count = |first: i64, last: i64| i128::from(last) - i128::from(first) + 1;
// A box over more cells than this is checked against every box.
const MAX_CELLS: i128 = 1024;
let mut groups: Vec<Run> = vec![];
let mut latest: HashMap<PaintKey, usize> = HashMap::new();
// An item can join an earlier group, so insertion order is not group-index order. Index
// boxes by group to skip older groups without scanning their entries.
let mut spatial: HashMap<(i64, i64), BTreeMap<usize, Vec<Rect>>> = HashMap::new();
let mut rows: HashMap<i64, BTreeMap<usize, Vec<Rect>>> = HashMap::new();
let mut columns: HashMap<i64, BTreeMap<usize, Vec<Rect>>> = HashMap::new();
let mut large: BTreeMap<usize, Vec<Rect>> = BTreeMap::new();
let mut all_boxes: BTreeMap<usize, Vec<Rect>> = BTreeMap::new();
for (item, bounds) in items {
if matches!(item.kind, NodeKind::Group { .. }) {
return Err(bad(cmd, "byColor can't split Auto Grouping groups: trace without Auto Grouping"));
}
let paint = match item.appearance.fill_paint() {
Paint::None => item.appearance.stroke_paint(),
fill => fill,
};
let (key, name) = match &paint {
Paint::Solid { swatch: Some(n), tint, .. } => {
let percent = tint_percent(*tint);
let name = if *tint < 1.0 { format!("{n} {percent}%") } else { n.clone() };
((1, n.clone(), tint.to_bits()), name)
}
Paint::Solid { color, .. } => ((0, color.to_hex(), 0), color.to_hex()),
Paint::Gradient(_) => ((2, String::new(), 0), "Gradients".to_string()),
other => ((3, other.label(), 0), other.label()),
};
// Joining an earlier group moves the item under every later group's items.
let hidden_by_later = |at: usize| {
bounds.is_some_and(|b| {
let overlaps_later = |boxes: &BTreeMap<usize, Vec<Rect>>| {
boxes.range((Excluded(at), Unbounded)).rev().any(|(_, group_boxes)| {
group_boxes.iter().rev().any(|box_bounds| {
#[cfg(test)]
CANDIDATE_VISITS.with(|n| n.set(n.get() + 1));
meet(box_bounds, &b)
})
})
};
match span(b) {
Some((x0, y0, x1, y1)) if count(x0, x1) * count(y0, y1) <= MAX_CELLS => {
(y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))).filter_map(|c| spatial.get(&c)).any(overlaps_later)
|| (y0..=y1).filter_map(|y| rows.get(&y)).any(overlaps_later)
|| (x0..=x1).filter_map(|x| columns.get(&x)).any(overlaps_later)
|| overlaps_later(&large)
}
_ => overlaps_later(&all_boxes),
}
})
};
let at = latest.get(&key).copied().filter(|&at| !hidden_by_later(at));
let mut started = Run { name, members: vec![] };
let joins = at.and_then(|at| groups.get_mut(at));
let new = joins.is_none();
let g = joins.unwrap_or(&mut started);
g.members.push(item);
if new {
groups.push(started);
latest.insert(key, groups.len() - 1);
}
if let Some(b) = bounds {
let index = if new { groups.len() - 1 } else { at.unwrap_or_default() };
all_boxes.entry(index).or_default().push(b);
match span(b.inflate(1.0, 1.0)) {
Some((x0, y0, x1, y1)) if count(x0, x1) <= 8 && count(y0, y1) <= 8 => {
for place in (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))) {
spatial.entry(place).or_default().entry(index).or_default().push(b);
}
}
Some((_, y0, _, y1)) if count(y0, y1) <= 8 => {
for y in y0..=y1 {
rows.entry(y).or_default().entry(index).or_default().push(b);
}
}
Some((x0, _, x1, _)) if count(x0, x1) <= 8 => {
for x in x0..=x1 {
columns.entry(x).or_default().entry(index).or_default().push(b);
}
}
_ => large.entry(index).or_default().push(b),
}
}
}
Ok(groups
.into_iter()
.map(|run| {
let mut g = Node::group(d.alloc_id(), run.members);
g.name = Some(run.name);
Arc::new(g)
})
.collect())
}
/// What a trace depends on: the object traced (an image, or an Image Trace object traced again),
/// its image, and the settings it is traced with. The tracer makes the same paths of the same
/// inputs, so a trace made away from the command ([`TraceRun`]) stands in for the command's own.
@@ -398,6 +556,10 @@ struct Prepared {
fn prepare(s: &Session, c: &str, p: &Value) -> Result<Prepared> {
let mut params = trace_params(s, c, p)?;
let view = view_param(c, p)?;
let by_color = c == "imageTrace.makeAndExpand" && by_color_param(c, p)?;
if by_color && params.auto_grouping {
return Err(bad(c, "byColor can't split Auto Grouping groups: trace without Auto Grouping"));
}
let params_json = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
// The preset's name if the parameters are exactly that preset's or its enhanced version's (a
// saved preset's under its saved name), else "Custom".
@@ -428,6 +590,9 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result<Prepared> {
})
.ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))?
};
if by_color && s.doc()?.doc.node(target).is_some_and(vectorcraft_render::effects::has_pathfinder) {
return Err(bad(c, LIVE_PATHFINDER_BY_COLOR));
}
let blob = s.doc()?.doc.images.get(&image.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", image.key)))?;
let bytes = Arc::clone(&blob.bytes);
let library = trace_library(c, s, &mut params)?;
@@ -533,6 +698,7 @@ impl TraceRun {
pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" };
let Prepared { inputs, trace, view, params_json, preset_label, library, bytes } = prepare(s, c, p)?;
let group_by_color = expand && by_color_param(c, p)?;
// A saved preset's settings join the journal entry, so a replay traces as this run did after
// the preset is changed, renamed or deleted.
let named = str_param(p, "preset").unwrap_or("Default");
@@ -608,6 +774,9 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
traced.push(n);
}
children.extend(grouped(d, traced, &res.parents));
if group_by_color {
children = color_groups(c, d, children)?;
}
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
@@ -706,7 +875,20 @@ pub(super) fn trace_release(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "imageTrace.expand";
let by_color = by_color_param(C, p)?;
let targets = trace_targets(s, p)?;
if by_color {
// A trace made with Auto Grouping is refused even when none of its paths were grouped.
let d = &s.doc()?.doc;
let grouped = |n: &Node| n.extras().trace.as_deref().and_then(|t| t.pointer("/params/autoGrouping")).and_then(Value::as_bool) == Some(true);
if targets.iter().any(|g| d.node(*g).is_some_and(grouped)) {
return Err(bad(C, "byColor can't split Auto Grouping groups: trace without Auto Grouping"));
}
if targets.iter().any(|g| d.node(*g).is_some_and(vectorcraft_render::effects::has_pathfinder)) {
return Err(bad(C, LIVE_PATHFINDER_BY_COLOR));
}
}
s.edit("Expand Image Trace", |d, sel| {
for g in &targets {
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
@@ -715,6 +897,15 @@ pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(ch) = n.children_mut() {
ch.retain(|c| !matches!(c.kind, NodeKind::Image(_)));
}
if by_color {
let items = d.node(*g).and_then(Node::children).cloned().unwrap_or_default();
// The paths may have been moved or reshaped since the trace was made, so any trace's
// paths are taken to overlap.
let groups = color_groups(C, d, items)?;
if let Some(ch) = d.node_mut(*g).and_then(Node::children_mut) {
*ch = groups;
}
}
}
sel.set(targets.iter().copied());
Ok(())
@@ -722,6 +913,160 @@ pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&targets) }))
}
#[cfg(test)]
mod color_group_tests {
use super::*;
use vectorcraft_geom::{PathData, SubPath};
fn area(d: &mut Document, x0: f64, y0: f64, x1: f64, y1: f64, color: Color) -> Arc<Node> {
let path = PathData::single(SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true));
Arc::new(Node::path(d.alloc_id(), path, fill_only(Paint::solid(color))))
}
#[test]
fn abutting_color_groups_preserve_order_at_shared_edges() {
let mut d = Document::new(100.0, 100.0);
let a = Color::rgb(1.0, 0.0, 0.0);
let b = Color::rgb(0.0, 0.0, 1.0);
let items = vec![area(&mut d, 0.0, 0.0, 20.0, 20.0, a), area(&mut d, 20.0, 0.0, 40.0, 20.0, b), area(&mut d, 40.0, 0.0, 60.0, 20.0, a)];
let original: Vec<_> = items.iter().map(|n| n.id).collect();
let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap();
let actual: Vec<_> = groups.iter().flat_map(|g| g.children().unwrap().iter().map(|n| n.id)).collect();
assert_eq!(actual, original, "shared-edge paths changed stacking order");
}
#[test]
fn dense_color_groups_use_bounded_box_tests() {
let mut d = Document::new(200.0, 200.0);
let a = Color::rgb(1.0, 0.0, 0.0);
let b = Color::rgb(0.0, 0.0, 1.0);
let items = (0..4000)
.map(|i| {
if i % 2 == 0 {
let x = 100.0 + (i % 40) as f64 * 0.01;
area(&mut d, x, 100.0, x + 0.1, 100.1, a)
} else {
let x = if i % 4 == 1 { 0.0 } else { 198.0 };
area(&mut d, x, x, x + 2.0, x + 2.0, b)
}
})
.collect();
BOX_TESTS.with(|n| n.set(0));
let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap();
let tests = BOX_TESTS.with(std::cell::Cell::get);
assert_eq!(groups.iter().map(|g| g.children().map_or(0, Vec::len)).sum::<usize>(), 4000);
assert!(tests < 4000 * 32, "{tests} box tests for 4000 paths");
}
#[test]
fn overlapping_alternating_colors_visit_bounded_candidates() {
const COUNT: usize = 3000;
let mut d = Document::new(100.0, 100.0);
let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)];
let items = (0..COUNT).map(|i| area(&mut d, 10.0, 10.0, 20.0, 20.0, colors[i % 2])).collect();
CANDIDATE_VISITS.with(|n| n.set(0));
let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap();
let visits = CANDIDATE_VISITS.with(std::cell::Cell::get);
assert_eq!(groups.len(), COUNT);
assert!(visits < COUNT * 16, "{visits} candidate visits for {COUNT} paths");
}
#[test]
fn separate_stripes_of_alternating_colors_visit_bounded_candidates() {
const COUNT: usize = 2000;
let mut d = Document::new(1000.0, 4000.0);
let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)];
for tall in [false, true] {
let items = (0..COUNT)
.map(|i| {
let at = i as f64 * 2.0;
if tall { area(&mut d, at, 0.0, at + 0.5, 1000.0, colors[i % 2]) } else { area(&mut d, 0.0, at, 1000.0, at + 0.5, colors[i % 2]) }
})
.collect();
CANDIDATE_VISITS.with(|n| n.set(0));
let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap();
let visits = CANDIDATE_VISITS.with(std::cell::Cell::get);
assert_eq!(groups.len(), 2, "tall {tall}");
assert!(visits < COUNT * 32, "tall {tall}: {visits} candidate visits for {COUNT} stripes");
}
}
#[test]
fn spatial_color_groups_match_simple_scan() {
let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)];
for layout in 0..6 {
let mut d = Document::new(200.0, 200.0);
let items: Vec<_> = (0..90)
.map(|i| {
let x = match layout {
0 => i as f64 * 2.0,
1 => (i % 10) as f64 * 5.0,
2 => (i % 7) as f64 * 3.0,
3 => (i % 13) as f64 * 9.0,
4 => (i % 4) as f64 * 10.0,
_ => i as f64 * 1.5,
};
let y = match layout {
0 => 0.0,
1 => (i / 10) as f64 * 5.0,
2 => (i / 7) as f64 * 3.0,
3 => (i / 13) as f64 * 9.0,
4 => i as f64 * 1.5,
_ => (i % 4) as f64 * 10.0,
};
// Layouts 4 and 5: wide and tall stripes, some meeting their neighbors.
let (w, h) = match layout {
4 => (150.0, 1.0),
5 => (1.0, 150.0),
_ => (3.0, 3.0),
};
area(&mut d, x, y, x + w, y + h, colors[i % 3])
})
.collect();
let mut expected: Vec<(String, Vec<(NodeId, Rect)>)> = vec![];
for item in &items {
let key = item.appearance.fill_paint().color().unwrap().to_hex();
let bounds = vectorcraft_render::painted_bounds(&d, item).unwrap();
let previous = expected.iter().rposition(|(k, _)| *k == key);
let can_join = previous.filter(|&at| {
!expected.iter().skip(at + 1).any(|(_, boxes)| {
boxes.iter().any(|(_, m)| {
let b = bounds.inflate(1.0, 1.0);
m.x0 < b.x1 && b.x0 < m.x1 && m.y0 < b.y1 && b.y0 < m.y1
})
})
});
match can_join.and_then(|at| expected.get_mut(at)) {
Some((_, members)) => members.push((item.id, bounds)),
None => expected.push((key, vec![(item.id, bounds)])),
}
}
let actual = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap();
let actual: Vec<Vec<_>> = actual.iter().map(|g| g.children().unwrap().iter().map(|n| n.id).collect()).collect();
let expected: Vec<Vec<_>> = expected.into_iter().map(|(_, members)| members.into_iter().map(|(id, _)| id).collect()).collect();
assert_eq!(actual, expected, "layout {layout}");
}
}
#[test]
fn an_abutting_gradient_stays_above_the_solid_area_it_borders() {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops.clone()))));
let mut d = Document::new(100.0, 100.0);
let mut area = |x0: f64, x1: f64, paint: Paint| {
let path =
PathData::single(SubPath::polyline(&[Point::new(x0, 0.0), Point::new(x1, 0.0), Point::new(x1, 60.0), Point::new(x0, 60.0)], true));
Arc::new(Node::path(d.alloc_id(), path, fill_only(paint)))
};
// A gradient, a solid area beside it and a second gradient beside that, bottom to top.
let items = vec![area(0.0, 50.0, ramp()), area(50.0, 80.0, Paint::solid(Color::rgb(0.2, 0.6, 0.2))), area(80.0, 100.0, ramp())];
let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap();
let names: Vec<_> = groups.iter().map(|g| g.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["Gradients", "#339933", "Gradients"]);
}
}
#[cfg(test)]
mod trace_limit_tests {
use super::{MAX_TRACE_ANCHORS, trace_warnings};
+9 -4
View File
@@ -13,7 +13,7 @@ use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sam
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with};
use super::edit::{duplicate_in, outermost, selected_roots};
use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots};
use super::{
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param,
matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets,
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move in Perspective",
[],
None,
"{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction; Transform Again repeats it",
"{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction, and moves every object on that plane, at any depth, by the same plane offset; Transform Again repeats it",
has_doc,
persp_move
),
@@ -730,8 +730,13 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) {
return Err(bad(C, "pins must be finite"));
}
let ids =
if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) };
// While an object of a dynamic symbol instance's art is selected, the selection is refused, as
// `selected_roots` refuses it.
let selected = || {
refuse_symbol_part(s)?;
Ok(s.doc()?.selection.objects.clone())
};
let ids = if rest { puppet_ids(p, selected)? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) };
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let angles: Vec<Option<f64>> = match p.get("angles") {
None | Some(Value::Null) => vec![None; pins.len()],
@@ -285,7 +285,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
let constrain = switch_or(p, "constrain", false, C)?;
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
check_switches(p, &["snap"], C)?;
// Snap to Grid: a correction for the objects on the first one's plane and depth.
// Shift: the constrained end point, read on the first object's plane and depth.
let snap_to = if constrain && !perp {
let doc = &s.doc()?.doc;
let g = grid_of(doc);
@@ -300,11 +300,20 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
} else {
to
};
let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, snap_to)) };
let snapped = move |pl: Plane, depth: f64, raw: vectorcraft_geom::Vec2| {
// With Snap to Grid, every object on the first one's plane, at any depth, moves by the first
// one's plane-space offset and its correction, so the objects keep their spacing on the plane.
let fix = if perp {
None
} else {
let doc = &s.doc()?.doc;
crate::cmd::perspgrid::snap_fix(doc, &ids, p, (from, snap_to))
.and_then(|(plane, depth, correction)| Some((plane, grid_of(doc).plane_delta(plane, depth, from, to)?, correction)))
};
let snapped = move |pl: Plane, raw: vectorcraft_geom::Vec2| {
let raw = fix.filter(|f| f.0 == pl).map_or(raw, |f| f.1);
let (direction, dv) =
if constrain { vectorcraft_tools::distort::perspective::constrain_move_delta(raw) } else { (vectorcraft_geom::Vec2::ZERO, raw) };
dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| {
dv + fix.filter(|f| f.0 == pl).map_or(vectorcraft_geom::Vec2::ZERO, |f| {
if !constrain {
return f.2;
}
@@ -325,7 +334,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?;
Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth))
} else {
let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?);
let dv = snapped(pl, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?);
Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth))
}
};
@@ -336,7 +345,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
if perp {
g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9)
} else {
g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
}
};
let first_still = if copy { None } else { unmoved(s, &ids, still)? };
@@ -350,9 +359,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
let again = match perp {
_ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})),
true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})),
false => {
g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy}))
}
false => g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, dv))), "copy": copy})),
};
if let Some(a) = again {
record_again(s, a)?;
+224 -18
View File
@@ -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), …, 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), …, 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
),
@@ -130,13 +130,15 @@ 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 and the rows of its Repeat objects.
/// `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.
fn sections(
d: &Document,
info: &Value,
fonts: &BTreeSet<super::fonts::UsedFont>,
images: &[ImageRow],
repeats: &[(String, String)],
pattern_objects: &[(String, String)],
category: Option<&str>,
) -> Vec<Section> {
let names = |k: &str| -> Vec<(String, String)> {
@@ -180,7 +182,7 @@ fn sections(
"graphicStyles" => names("graphicStyleNames"),
"brushes" => names("brushNames"),
"spotColors" => names("spotColors"),
"patterns" => names("patternNames"),
"patterns" => pattern_objects.to_vec(),
"gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(),
"symbols" => names("symbols"),
"fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(),
@@ -252,12 +254,67 @@ pub fn report(s: &mut Session, selection_only: bool) -> Result<String> {
Ok(v["text"].as_str().unwrap_or_default().to_string())
}
fn paint_kind(p: &Paint) -> Option<&'static str> {
match p {
Paint::Gradient(_) => Some("gradients"),
Paint::Pattern { .. } => Some("patterns"),
_ => None,
/// 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.
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 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),
});
if !own && !t.runs.iter().any(|r| is_pattern(&r.style.fill) || is_pattern(&r.style.stroke)) {
return None;
}
let db = vectorcraft_text::FontDb::global();
let resolved = d.inline_resolved(t);
let lay = vectorcraft_text::layout(db, &resolved);
let mut painted: BTreeSet<usize> = lay.glyphs.iter().chain(&lay.ruby).filter(|g| !g.outline.elements().is_empty()).map(|g| g.run).collect();
painted.extend(vectorcraft_text::decorations(&lay, db, &resolved).into_iter().map(|(run, _)| run));
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>(
n: &'a Node,
members: &HashSet<*const Node>,
painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet<usize>)>,
) -> Vec<&'a str> {
if matches!(n.kind, NodeKind::Path { guide: true, .. })
|| members.contains(&std::ptr::from_ref(n))
|| painted_runs.is_some_and(|(_, r)| r.is_empty())
{
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(n)
.into_iter()
.filter_map(|(_, _, item)| match item.paint() {
Paint::Pattern { pattern, .. } => Some(pattern.clone()),
_ => None,
})
.collect()
});
let mut out: Vec<&str> = vec![];
for item in &n.appearance.items {
let Paint::Pattern { pattern, .. } = 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);
}
}
out
}
fn info(s: &mut Session, p: &Value) -> Result<Value> {
@@ -293,9 +350,19 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
// `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order.
let mut repeats: Vec<Value> = vec![];
let mut repeat_rows: Vec<(String, String)> = vec![];
// `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![];
// 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();
// The members of the document's compound paths: the compound path's own fills and strokes paint them.
let mut members: HashSet<*const Node> = HashSet::new();
d.walk(|n| {
if let NodeKind::Compound { children, .. } = &n.kind {
members.extend(children.iter().map(|c| std::ptr::from_ref(&**c)));
}
});
for r in roots {
r.walk(&mut |n| {
if !seen.insert(std::ptr::from_ref(n)) {
@@ -345,16 +412,18 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
}
// 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(n, &members, painted_runs.as_ref()) {
patterns.insert(pattern.to_string());
used_patterns.push(pattern);
}
for item in &n.appearance.items {
let paint = match item {
vectorcraft_doc::AppearanceItem::Fill(f) => &f.paint,
vectorcraft_doc::AppearanceItem::Stroke(s) => &s.paint,
};
if let Some(k) = paint_kind(paint) {
kinds.insert(k);
}
if let Paint::Pattern { pattern, .. } = paint {
patterns.insert(pattern.clone());
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
&& d.swatch(name).is_some_and(|s| s.spot)
@@ -362,6 +431,28 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
spot.insert(name.clone());
}
}
// 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.
if let Some((t, painted)) = &painted_runs {
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);
}
}
}
}
}
if !used_patterns.is_empty() {
let name = n.display_name();
pattern_objects.push(json!({ "id": n.id.0, "name": name, "patterns": used_patterns }));
pattern_rows.push((name, used_patterns.join(", ")));
kinds.insert("patterns");
}
for k in kinds {
*counts.entry(k).or_default() += 1;
}
@@ -412,10 +503,11 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
"paragraphStyles": d.para_styles.len(),
"patterns": d.patterns.len(),
"patternNames": patterns,
"patternObjects": pattern_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, category);
let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, category);
if text {
return Ok(json!({ "text": report_text(&st.title(), selection_only, &sections) }));
}
@@ -634,6 +726,120 @@ mod tests {
assert_eq!(i["sections"][0]["rows"][0], json!(["<Grid Repeat>", "Grid Repeat, 500 rows × 500 columns, 125000 instances"]));
}
/// Document Info › Pattern Objects lists each object filled or stroked with a pattern once,
/// with the patterns it uses, one in a group and a type object whose characters use a pattern
/// included; a pattern no art uses adds no row, and Objects counts the listed objects. With
/// Selection Only it lists the patterned objects in the selection, and the report writes the
/// same rows.
#[test]
fn info_lists_the_pattern_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 tile = square(&mut s, 0.0);
s.execute("object.pattern.make", &json!({"ids": [tile], "name": "Dots", "edit": false})).unwrap();
let spare = square(&mut s, 20.0);
s.execute("object.pattern.make", &json!({"ids": [spare], "name": "Unused", "edit": false})).unwrap();
let (a, b, c) = (square(&mut s, 100.0), square(&mut s, 200.0), square(&mut s, 300.0));
s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Dots"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [a], "swatch": "Dots"})).unwrap();
s.execute("layer.setProps", &json!({"id": a, "name": "Both"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [b], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [b, c]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
// A type object whose characters are filled with the pattern.
let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [t], "swatch": "Dots"})).unwrap();
s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap();
// A hidden pattern fill and a pattern 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": "Dots"})).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], "swatch": "Dots"})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap();
// A type object with no characters paints nothing either.
let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Dots"})).unwrap();
// Its own fill (a copy of its characters' pattern fill) paints on its characters, so it paints nothing.
s.execute("appearance.addFill", &json!({"ids": [empty]})).unwrap();
// Nor does type of only spaces and a line break, or a guide made from a patterned path.
let blank = s.execute("text.create", &json!({"x": 0, "y": 320, "text": " \n"})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [blank], "swatch": "Dots"})).unwrap();
let guide = square(&mut s, 300.0);
s.execute("paint.setFill", &json!({"ids": [guide], "swatch": "Dots"})).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 pattern fill: the compound path's own fill paints it.
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);
// Nor does an image's pattern fill without an effect that gives it a shape.
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 pattern, 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": "Dots"})).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();
// A list marker set in an inline graphic's run draws in that run's pattern, so that type object is listed.
s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 10, "height": 10})).unwrap();
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let listed = s.execute("text.create", &json!({"x": 0, "y": 360, "text": ""})).unwrap()["id"].clone();
s.execute("text.insertInline", &json!({"id": listed, "at": 0, "symbol": "Mark"})).unwrap();
s.execute("text.setFormat", &json!({"ids": [listed], "list": "bullet"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [listed], "swatch": "Dots"})).unwrap();
s.execute("layer.setProps", &json!({"id": listed, "name": "Listed"})).unwrap();
let i = s.execute("document.info", &json!({})).unwrap();
assert_eq!(
i["patternObjects"],
json!([
{"id": a, "name": "Both", "patterns": ["Dots"]},
{"id": b, "name": "<Rectangle>", "patterns": ["Dots"]},
{"id": t, "name": "Type", "patterns": ["Dots"]},
{"id": bar, "name": "Bar", "patterns": ["Dots"]},
{"id": listed, "name": "Listed", "patterns": ["Dots"]},
])
);
assert_eq!(i["patternNames"], json!(["Dots"]));
// Objects counts the objects that Pattern Objects lists.
assert_eq!(i["objects"]["patterns"].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"]["patterns"].is_null() && i["patternObjects"] == json!([]), "{}", i["objects"]);
let i = s.execute("document.info", &json!({"category": "patterns"})).unwrap();
assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("patterns"), Some("Pattern Objects")));
assert_eq!(
i["sections"][0]["rows"],
json!([["Both", "Dots"], ["<Rectangle>", "Dots"], ["Type", "Dots"], ["Bar", "Dots"], ["Listed", "Dots"]])
);
s.execute("select.set", &json!({"ids": [b]})).unwrap();
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "patterns"})).unwrap();
assert_eq!(i["sections"][0]["rows"], json!([["<Rectangle>", "Dots"]]));
s.execute("select.set", &json!({"ids": [t]})).unwrap();
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "patterns"})).unwrap();
assert_eq!((i["sections"][0]["rows"].clone(), i["patternNames"].clone()), (json!([["Type", "Dots"]]), json!(["Dots"])));
let text = s.execute("document.info", &json!({"category": "patterns", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
assert!(text.contains("\nPATTERN OBJECTS\nBoth: Dots\n<Rectangle>: Dots\nType: Dots\nBar: Dots\nListed: Dots\n"), "{text}");
assert!(!text.contains("Unused"), "{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.
+193 -5
View File
@@ -41,6 +41,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find),
cmd!(
query "document.compare",
"Compare with Image",
[],
None,
"{path: an image file (PNG, JPEG, WebP, TIFF or BMP), artboard?: index=0, background?: \"#rrggbb\"=\"#ffffff\" (the render and the image are both flattened on it), threshold?: 0.08 (0–1), diffPath?: a PNG to write (each pixel's absolute R, G and B differences)} render the artboard at the image's size (the image's width in pixels over the artboard's width in points), drawn as document.export draws a PNG (linked images and placed documents from their files), and compare it with the image; an image whose height differs by more than a pixel from the artboard's height at that scale is refused (not in the browser) → {width, height, rmse: the root mean square of the R, G and B differences, 0–1 (6 decimals), over: the pixels with a channel that differs by more than threshold, total: the pixels, threshold, diffPath?, warnings?: as a PNG export's (type that doesn't fit its frame, an image that couldn't be decoded, type in a font or style that isn't available, previews drawn because a linked file can't be read)}",
has_doc,
doc_compare
),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
cmd!(
"document.setUnits",
@@ -148,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Cut",
["Edit"],
Some("Cmd+X"),
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)",
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut); refused while an object of a symbol instance's art is selected",
has_selection_guides_or_artboard_tool,
cut
),
@@ -157,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Copy",
["Edit"],
Some("Cmd+C"),
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}",
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}",
has_selection_guides_or_artboard_tool,
copy
),
@@ -211,7 +220,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clear",
["Edit"],
Some("Delete"),
"{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides)",
"{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides); without ids, refused while an object of a symbol instance's art is selected",
has_selection_or_guides,
clear
),
@@ -385,6 +394,155 @@ fn select_found(s: &mut Session, p: &Value, ids: &[NodeId]) -> Result<Value> {
})
}
/// Render an artboard at an image's size, drawn as a PNG export draws it (linked images and placed
/// documents from their files, with the export's warnings), and measure how far the
/// two are apart: both flattened on `background`, the root mean square of the R, G and B
/// differences (0–1), and the pixels with a channel that differs by more than `threshold`.
#[cfg(not(target_arch = "wasm32"))]
fn doc_compare(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, png};
const C: &str = "document.compare";
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
let diff_path = match p.get("diffPath") {
None | Some(Value::Null) => None,
Some(Value::String(v)) => Some(v.as_str()),
Some(_) => {
let key = "diffPath";
return Err(bad(C, format!("`{key}` must be a string")));
}
};
let background = match p.get("background") {
None | Some(Value::Null) => [255; 3],
Some(v) => match super::fileio::background(v) {
Ok(Some(rgb)) => rgb,
_ => return Err(bad(C, format!("background {v}: a color such as \"#808080\""))),
},
};
let threshold = match p.get("threshold") {
None | Some(Value::Null) => 0.08,
Some(v) => v.as_f64().filter(|t| (0.0..=1.0).contains(t)).ok_or_else(|| bad(C, "`threshold` must be a number from 0 to 1"))?,
};
let doc = &s.doc()?.doc;
let count = doc.artboards.len();
let i = opt_u64(p, C, "artboard")?.unwrap_or(0);
let region = usize::try_from(i)
.ok()
.and_then(|i| doc.artboards.get(i))
.map(|a| a.rect)
.ok_or_else(|| bad(C, format!("no artboard {i} (0-based; the document has {count})")))?;
if let Some(d) = diff_path {
crate::file_access::check_write(d).map_err(EngineError::Other)?;
}
let image = compare_image(path)?;
let (iw, ih) = image.dimensions();
// The image's width sets the scale; its height may differ from the artboard's at that scale by
// the pixel the rounding of a render takes.
let scale = f64::from(iw) / region.width();
let rendered_height = region.height() * scale;
if iw == 0 || ih == 0 || !rendered_height.is_finite() || (rendered_height - f64::from(ih)).abs() > 1.0 {
let (rw, rh) = (iw, rendered_height.round());
return Err(bad(
C,
format!("the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ"),
));
}
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
vectorcraft_render::raster_size(vectorcraft_geom::Rect::new(0.0, 0.0, f64::from(iw), f64::from(ih)), 1.0).map_err(|e| bad(C, e))?;
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
let opts = RasterExportOptions { background: Some(background), ..Default::default() }.render_options(RasterFormat::Png);
// As a PNG export: linked images and placed documents drawn from their files, not the
// previews saved for them.
let (full, linked) = super::place::document::full_documents(doc);
let failed = vectorcraft_render::decode_failures();
let rendered = vectorcraft_render::Renderer::new().render(&full, iw, ih, view, &opts);
// A canceled render stops drawing: the picture is not whole.
vectorcraft_doc::cancel::check()?;
// As a PNG export says, in its order: type on the artboard that doesn't fit its frame, an image
// the renderer couldn't decode (missing from the render), type in a missing font or style (drawn
// in another one), and linked files drawn from their previews.
let mut warnings: Vec<String> = super::fonts::export_overflow_warning(&full, &full.layers, Some(&[region])).into_iter().collect();
if let Some(w) = super::rasterfx::undecoded_warning(failed) {
warnings.push(w.into());
}
if let Some(w) = super::fonts::substitution_warning(&full) {
warnings.push(w);
}
warnings.extend(linked);
let rendered = rendered.to_straight();
let flat = |px: &[u8; 4]| {
let a = u32::from(px[3]);
[0, 1, 2].map(|k| ((u32::from(px[k]) * a + u32::from(background[k]) * (255 - a) + 127) / 255) as u8)
};
let row = iw as usize * 4;
let limit = threshold * 255.0;
let (mut squares, mut over) = (0.0f64, 0u64);
let mut diff = diff_path.map(|_| Vec::with_capacity(row * ih as usize));
for (a, b) in rendered.chunks_exact(row).zip(image.as_raw().chunks_exact(row)) {
vectorcraft_doc::cancel::check()?;
for (pa, pb) in a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0) {
let (fa, fb) = (flat(pa), flat(pb));
let d = [0, 1, 2].map(|k| fa[k].abs_diff(fb[k]));
squares += d.iter().map(|&x| f64::from(x) * f64::from(x)).sum::<f64>();
if d.iter().any(|&x| f64::from(x) > limit) {
over += 1;
}
if let Some(out) = &mut diff {
out.extend_from_slice(&[d[0], d[1], d[2], 255]);
}
}
}
let total = u64::from(iw) * u64::from(ih);
let rmse = (squares / (total as f64 * 3.0)).sqrt() / 255.0;
let mut out = json!({
"width": iw,
"height": ih,
"rmse": (rmse * 1e6).round() / 1e6,
"over": over,
"total": total,
"threshold": threshold,
});
if let (Some(d), Some(pixels)) = (diff_path, diff) {
let bytes = png::encode(&pixels, iw, ih, &png::PngOptions { ppi: None, interlaced: false }).map_err(EngineError::Other)?;
super::fileio::write_file(d, &bytes)?;
out["diffPath"] = json!(d);
}
if !warnings.is_empty() {
out["warnings"] = json!(warnings);
}
Ok(out)
}
/// The image `document.compare` reads from `path` (through the automation read root), as straight
/// RGBA: a PNG, JPEG, WebP, TIFF or BMP within the allocation an opened image may take.
#[cfg(not(target_arch = "wasm32"))]
fn compare_image(path: &str) -> Result<image::RgbaImage> {
use image::ImageDecoder as _;
const C: &str = "document.compare";
let bytes = super::fileio::read_file(path)?;
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(&bytes) {
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| bad(C, format!("{path}: {e}")))?;
let readable = [image::ImageFormat::Png, image::ImageFormat::Jpeg, image::ImageFormat::WebP, image::ImageFormat::Tiff, image::ImageFormat::Bmp];
if !reader.format().is_some_and(|f| readable.contains(&f)) {
return Err(bad(C, format!("{path}: not a PNG, JPEG, WebP, TIFF or BMP image")));
}
let mut limits = image::Limits::default();
limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC);
reader.limits(limits);
let decoder = reader.into_decoder().map_err(|e| bad(C, format!("{path}: {e}")))?;
let (w, h) = decoder.dimensions();
if decoder.total_bytes() > vectorcraft_doc::MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > vectorcraft_doc::MAX_RASTER_ALLOC {
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
}
Ok(image::DynamicImage::from_decoder(decoder).map_err(|e| bad(C, format!("{path}: {e}")))?.to_rgba8())
}
#[cfg(target_arch = "wasm32")]
fn doc_compare(_: &mut Session, _: &Value) -> Result<Value> {
Err(bad("document.compare", "comparing with an image file isn't available in the browser"))
}
/// Document Setup's units (also `document.setup {units}`).
fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
let u = str_param(p, "units").and_then(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?;
@@ -671,16 +829,38 @@ fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::
}
/// Selected top-level objects in paint order, dropping any whose ancestor is also selected.
///
/// Refused with [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art
/// is selected ([`super::brushsym::selected_part`]): only the paint commands change that object,
/// and only its solid fill and stroke, so a command that gathers the selection here leaves the
/// instance as it is. Copy copies the instance whole ([`copy_cut_targets`]).
pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
refuse_symbol_part(s)?;
let st = s.doc()?;
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
}
/// [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art is selected:
/// for the commands that read the selected objects without [`selected_roots`]. A command that
/// changes nothing in the document ([`Session::reading`]) reads the instance instead.
pub(crate) fn refuse_symbol_part(s: &Session) -> Result<()> {
if !s.reading && s.doc()?.selection.symbol_part.is_some() { Err(super::brushsym::part_only()) } else { Ok(()) }
}
/// [`refuse_symbol_part`] as a command's `enabled` check: the command is dimmed, and refused with
/// [`super::brushsym::part_only`]'s message, while an object of a dynamic symbol instance's art is
/// selected.
pub(crate) fn no_symbol_part(s: &Session) -> std::result::Result<(), String> {
if !s.reading && s.active().is_some_and(|st| st.selection.symbol_part.is_some()) { Err(super::brushsym::part_only().to_string()) } else { Ok(()) }
}
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a
/// path of a compound path selected on its own (Group Selection, or in the isolated compound path)
/// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the
/// rest still go to the compound path, which holds the paint.
/// rest still go to the compound path, which holds the paint. Refused while an object of a symbol
/// instance's art is selected, as [`selected_roots`] is: it moves with its instance only.
pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
refuse_symbol_part(s)?;
let st = s.doc()?;
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_, _| true))
}
@@ -689,8 +869,10 @@ pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
/// ([`super::inside_isolation`]): `root` itself and objects outside it are left out before the
/// objects held by another selected object are dropped. A selected path of a compound path stands
/// for its compound unless the compound is `root` or `members` is set (as in
/// [`selected_roots_to_transform`]).
/// [`selected_roots_to_transform`]). Refused while an object of a symbol instance's art is
/// selected, as [`selected_roots`] is.
pub(crate) fn selected_roots_inside(s: &Session, root: NodeId, members: bool) -> Result<Vec<NodeId>> {
refuse_symbol_part(s)?;
let st = s.doc()?;
let ids = st.selection.in_paint_order(&st.doc).into_iter().filter(|id| super::inside_isolation(&st.doc, root, *id)).collect();
Ok(roots_within(&st.doc, ids, |compound, _| members || compound == root))
@@ -767,6 +949,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
if s.tool_id() == "artboard" {
return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false);
}
// With an object of a dynamic symbol instance's art selected, the instance is copied.
let (direct, targets) = copy_cut_targets(s)?;
let st = s.doc()?;
// Selected ruler guides come along (#3226), as canvas guides: their layer and artboard stay
@@ -807,6 +990,7 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
if s.tool_id() == "artboard" {
return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true);
}
refuse_symbol_part(s)?;
// Ruler guides selected on their own: Copy takes them, Clear removes them.
if s.doc()?.selection.is_empty() {
copy(s, p)?;
@@ -880,6 +1064,10 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
let guides = switch_or(p, "guides", false, "edit.clear")?;
let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };
// An object of a symbol instance's art can't be removed from the instance alone.
if explicit.is_none() && s.doc()?.selection.symbol_part.is_some() {
return Err(super::brushsym::part_only());
}
// Direct-selected anchors or segments: remove those, and any objects selected whole with them.
if explicit.is_none() && (!s.doc()?.selection.anchors.is_empty() || !s.doc()?.selection.segments.is_empty()) {
return super::path::delete_anchors(s, p);
+4
View File
@@ -33,6 +33,7 @@ pub(crate) mod imagemap;
mod legacy_ai;
mod load;
pub(crate) use load::{MAX_RASTER_ALLOC, size_note};
mod heifread;
mod jp2read;
mod metafile;
mod naming;
@@ -54,6 +55,7 @@ pub mod zip;
use serde_json::{Value, json};
pub use action_batch::batch_overwrites_one_file;
pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
pub(crate) use encode::{anti_alias, background, with_single_artboard};
@@ -202,6 +204,8 @@ mod tests_psd;
#[cfg(test)]
mod tests_psdread;
#[cfg(test)]
mod tests_heifread;
#[cfg(test)]
mod tests_jp2read;
#[cfg(test)]
+302 -1
View File
@@ -95,11 +95,93 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result<Value> {
};
match source {
"folder" => batch_folder(s, C, p, override_open, &run),
"datasets" => batch_datasets(s, C, &run),
"datasets" => {
let one_file = batch_overwrites_one_file(s, p);
let mut r = batch_datasets(s, C, &run)?;
if one_file {
r["warning"] = json!(ONE_FILE_WARNING);
}
Ok(r)
}
_ => Err(bad(C, "`source` must be `folder` or `datasets`")),
}
}
/// The `warning` of a Data Sets batch for which [`batch_overwrites_one_file`] holds.
#[cfg(not(target_arch = "wasm32"))]
const ONE_FILE_WARNING: &str = "each data set saves over the same file, which keeps only the last data set";
/// Whether an `action.batch` with params `p` plays on data sets of the open document in `s` and
/// writes each data set to the same file. Save and Close saves the open document over its own file
/// after every data set, unless a Save step that Override Save points into `destinationFolder`
/// first makes each data set's file the document's own. A step that the batch does not point into `destinationFolder` writes the
/// same file every time when it writes one: a Save, Save As, Save a Copy, Save as Template, Export,
/// Export Selection, PDF, EPS, DXF, Save for Web or Save for Office Documents step that gives a
/// `path`; a Save step without a `path` when the document has a file of its own (saved, and not
/// converted from an older version or restored by Data Recovery) and the step gives no `format` or
/// the document's own; an Export for Screens step whose `folder`, given or from its
/// `exportPreset`, names a folder, whatever the overrides.
/// Other steps return their data instead of writing a file.
pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool {
if p.get("source").and_then(Value::as_str) != Some("datasets") {
return false;
}
let folder = p.get("destinationFolder").and_then(Value::as_str).is_some();
let redirected = |key: &str| folder && p.get(key).and_then(Value::as_bool).unwrap_or(true);
let (save, export) = (redirected("overrideSave"), redirected("overrideExport"));
let steps = p.get("commands").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
if p.get("destination").and_then(Value::as_str) == Some("saveAndClose") && !saves_move_to_each_file(steps, save) {
return true;
}
// The format of the open document's own file, which a Save step without a `path` writes.
let own_format = s.active().filter(|st| st.path.is_some() && !st.converted && !st.recovered).map(|st| st.format);
let names = |v: Option<&Value>| v.and_then(Value::as_str).is_some_and(|s| !s.is_empty());
steps.iter().any(|c| {
let params = c.get("params").cloned().unwrap_or_else(|| Value::Object(Default::default()));
let path = names(params.get("path"));
let own_file = || match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
None => own_format.is_some(),
Some(f) => own_format.is_some_and(|own| super::format(f).is_some_and(|f| f.id == own)),
};
match c.get("command").and_then(Value::as_str).unwrap_or("") {
"document.save" | "file.save" => !save && (path || own_file()),
"file.saveAs" => !save && path,
"file.saveCopy" | "file.saveAsTemplate" => path,
"document.export" | "document.exportSelection" => !export && path,
// The batch does not point these exports into `destinationFolder`.
"document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path,
// Override Export sets `path`, which Export for Screens does not read: its `folder` stays.
"document.exportForScreens" => super::screens::with_preset(s, &params).is_ok_and(|p| names(p.get("folder"))),
_ => false,
}
})
}
/// Whether `steps` hold a Save, Save As or `document.save` step that Override Save points at each
/// data set's own file in the destination folder (`redirected`). Such a step makes that file the
/// document's own, so Save and Close then saves each data set to its own file.
fn saves_move_to_each_file(steps: &[Value], redirected: bool) -> bool {
redirected && steps.iter().any(|c| retargeting_save(c, true))
}
/// Whether step `c` is a Save, Save As or `document.save` that writes a file (its own `path`, or one
/// in the destination folder when `redirected`) and makes that file the document's own: a save in
/// the native template format writes a copy and leaves the document's file as it was.
fn retargeting_save(c: &Value, redirected: bool) -> bool {
if !matches!(c.get("command").and_then(Value::as_str), Some("document.save" | "file.save" | "file.saveAs")) {
return false;
}
let params = c.get("params");
let path = params.and_then(|p| p.get("path")).and_then(Value::as_str).filter(|p| !p.is_empty());
let format_ok = match params.and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty()) {
Some(f) => super::format(f).is_some_and(|f| f.id != "template"),
// Override Save gives the step a native file name; else its own path names the format.
None if redirected => true,
None => path.and_then(super::format_for_name).is_none_or(|f| f.id != "template"),
};
format_ok && (redirected || path.is_some())
}
#[cfg(not(target_arch = "wasm32"))]
fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result<Value> {
use super::{absolute_path, may_read};
@@ -145,6 +227,18 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
if names.len() > MAX_BATCH_FILES {
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets")));
}
let redirected = run.override_save && run.dest_folder.is_some();
// A recorded save with a file to write gives the document a file of its own before Save and Close.
if run.destination == "saveAndClose" && !run.steps.iter().any(|c| retargeting_save(c, redirected)) {
if st.path.is_none() {
return Err(bad(cmd, "the document has never been saved"));
}
// `save_plan` writes no file for a document converted from an older version or restored by
// Data Recovery.
if st.converted || st.recovered {
return Err(bad(cmd, "save the document with Save As before Save and Close"));
}
}
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
let stems = dataset_stems(&names, &doc_stem, run.dataset_file_naming, run, &st.doc)?;
let steps = filter_steps(run.steps, true, false);
@@ -1292,6 +1386,213 @@ mod tests {
assert_eq!(file_names(&dest), ["Untitled_A-B-3.vectorcraft", "Untitled_a-b-2.vectorcraft", "Untitled_a-b.vectorcraft"], "{r}");
}
/// A saved document with a text object bound to `Name` and two data sets, `A` and `B`.
fn saved_with_two_data_sets(path: &std::path::Path) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "A"})).unwrap()["id"].as_u64().unwrap();
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [id]})).unwrap();
s.execute("dataset.capture", &json!({"name": "A"})).unwrap();
s.execute("text.setText", &json!({"ids": [id], "text": "B"})).unwrap();
s.execute("dataset.capture", &json!({"name": "B"})).unwrap();
s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap();
s
}
fn save_and_close_over_data_sets(s: &mut Session) -> Result<Value> {
s.execute(
"action.batch",
&json!({"source": "datasets", "destination": "saveAndClose", "commands": [{"command": "object.unlockAll", "params": {}}]}),
)
}
#[test]
fn batch_save_and_close_over_data_sets_saves_each_over_the_template_and_warns() {
let path = tmp("save-close-sets").join("template.vectorcraft");
let mut s = saved_with_two_data_sets(&path);
std::fs::write(&path, b"stale").unwrap();
let r = save_and_close_over_data_sets(&mut s).unwrap();
assert_eq!((r["count"].as_u64(), r["failed"].as_u64()), (Some(2), Some(0)), "{r}");
assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}");
assert_ne!(std::fs::read(&path).unwrap(), b"stale", "the template's file is saved over");
assert_eq!(file_names(path.parent().unwrap()), ["template.vectorcraft"], "{r}");
let st = s.doc().unwrap();
assert_eq!(st.doc.variables.active_dataset.as_deref(), Some("B"), "the last data set is applied");
assert_eq!(st.path.as_deref(), Some(path.to_string_lossy().as_ref()), "the template stays open");
assert!(!st.is_dirty(), "the last data set is saved");
}
#[test]
fn batch_warns_when_a_recorded_save_writes_one_file_for_every_data_set() {
let path = tmp("recorded-save-sets").join("template.vectorcraft");
let mut s = saved_with_two_data_sets(&path);
let save = json!([{"command": "document.save", "params": {}}]);
let r = s.execute("action.batch", &json!({"source": "datasets", "commands": save})).unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}");
let dest = tmp("recorded-save-dest");
let p = json!({"source": "datasets", "destination": "folder", "destinationFolder": dest.to_string_lossy(), "commands": save});
let r = s.execute("action.batch", &p).unwrap();
assert!(r.get("warning").is_none(), "Override Save points each save into the folder: {r}");
let unlock = json!({"source": "datasets", "commands": [{"command": "object.unlockAll", "params": {}}]});
let r = s.execute("action.batch", &unlock).unwrap();
assert!(r.get("warning").is_none(), "{r}");
}
#[test]
fn batch_save_and_close_after_a_redirected_save_writes_each_data_set_to_its_own_file() {
let save_close = |dest: &std::path::Path| {
json!({
"source": "datasets",
"destination": "saveAndClose",
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.save", "params": {}}]
})
};
let path = tmp("redirected-save-close").join("template.vectorcraft");
let mut s = saved_with_two_data_sets(&path);
let bytes = std::fs::read(&path).unwrap();
let dest = tmp("redirected-save-close-dest");
let p = save_close(&dest);
let mut kept = p.clone();
kept["overrideSave"] = json!(false);
assert!(batch_overwrites_one_file(&s, &kept), "without Override Save the save stays on the template");
let r = s.execute("action.batch", &p).unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert!(r.get("warning").is_none(), "{r}");
assert_eq!(file_names(&dest), ["template_A.vectorcraft", "template_B.vectorcraft"], "{r}");
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template's file stays as it was");
// A document never saved gets its files from the redirected save too.
let mut unsaved = with_data_sets(&["a", "b"]);
let dest = tmp("redirected-save-close-unsaved");
let r = unsaved.execute("action.batch", &save_close(&dest)).unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert!(r.get("warning").is_none(), "{r}");
assert_eq!(file_names(&dest), ["Untitled_a.vectorcraft", "Untitled_b.vectorcraft"], "{r}");
// A template save writes a copy: Save and Close still saves over the document's own file.
let mut template = save_close(&dest);
template["commands"] = json!([{"command": "document.save", "params": {"format": "template"}}]);
assert!(batch_overwrites_one_file(&s, &template), "{template}");
let mut never = with_data_sets(&["a", "b"]);
let msg = never.execute("action.batch", &template).unwrap_err().to_string();
assert!(msg.ends_with("the document has never been saved"), "{msg}");
// A recorded Save As with its own path gives a never-saved document its file, the same one
// for every data set.
let one = tmp("save-as-then-close").join("one.vectorcraft");
let p = json!({
"source": "datasets",
"destination": "saveAndClose",
"commands": [{"command": "file.saveAs", "params": {"path": one.to_string_lossy()}}]
});
let r = never.execute("action.batch", &p).unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}");
assert!(one.exists(), "{r}");
}
#[test]
fn one_file_rule_covers_save_and_close_and_steps_left_in_place() {
let saved = saved_with_two_data_sets(&tmp("one-file-rule").join("template.vectorcraft"));
let unsaved = with_data_sets(&["a"]);
let with = |extra: Value| {
let mut p = json!({"source": "datasets", "commands": [{"command": "document.export", "params": {"path": "/x/a.png"}}]});
for (k, v) in extra.as_object().unwrap() {
p[k] = v.clone();
}
batch_overwrites_one_file(&saved, &p)
};
assert!(with(json!({})), "an export with its own path");
assert!(!with(json!({"destination": "folder", "destinationFolder": "/out"})));
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "overrideExport": false})));
assert!(!with(json!({"destination": "folder", "destinationFolder": "/out", "overrideSave": false})));
assert!(!with(json!({"source": "folder"})), "a folder source opens a file per pass");
assert!(with(json!({"destination": "saveAndClose", "commands": [{"command": "object.unlockAll"}]})));
let saved_as = json!({"commands": [{"command": "file.saveAs", "params": {"path": "/x/a.svg"}}]});
assert!(with(saved_as.clone()));
assert!(!with(json!({"destination": "folder", "destinationFolder": "/out", "commands": saved_as["commands"]})));
assert!(!with(json!({"commands": [{"command": "file.saveAs", "params": {}}]})), "no path: the data comes back");
assert!(!with(json!({"commands": [{"command": "document.export", "params": {"format": "png"}}]})), "no path: the data comes back");
let copy = json!([{"command": "file.saveCopy", "params": {"path": "/x/a.vectorcraft"}}]);
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": copy})), "Override Save leaves copies alone");
let save = |format: Option<&str>| {
let params = format.map_or(json!({}), |f| json!({"format": f}));
json!({"source": "datasets", "commands": [{"command": "document.save", "params": params}]})
};
assert!(batch_overwrites_one_file(&saved, &save(None)), "the document's own file");
assert!(batch_overwrites_one_file(&saved, &save(Some("vectorcraft"))), "the document's own format");
assert!(!batch_overwrites_one_file(&unsaved, &save(None)), "a document never saved has no file to write");
assert!(!batch_overwrites_one_file(&saved, &save(Some("svg"))), "another format without a path: the data comes back");
assert!(!with(json!({"commands": [{"command": "file.saveCopy", "params": {"folder": "/x"}}]})), "a folder alone: the data comes back");
let screens = json!([{"command": "document.exportForScreens", "params": {"folder": "/x"}}]);
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), "Override Export leaves its folder");
assert!(!with(json!({"commands": [{"command": "document.exportForScreens", "params": {}}]})));
let preset = json!([{"command": "document.exportForScreens", "params": {"exportPreset": "Logo Package"}}]);
assert!(!with(json!({"commands": preset})), "a preset without a folder: the data comes back");
for id in ["document.exportPdf", "document.exportEps", "document.exportDxf", "document.exportForWeb", "document.exportForOffice"] {
let step = json!([{"command": id, "params": {"path": "/x/a.out"}}]);
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": step})), "{id} keeps its path");
assert!(!with(json!({"commands": [{"command": id, "params": {}}]})), "{id} without a path: the data comes back");
}
}
#[test]
fn batch_refuses_save_and_close_over_data_sets_of_a_converted_or_recovered_document() {
for converted in [true, false] {
let path = tmp(if converted { "converted-sets" } else { "recovered-sets" }).join("template.vectorcraft");
let mut s = saved_with_two_data_sets(&path);
let bytes = std::fs::read(&path).unwrap();
let (active, revision) = {
let st = s.doc_mut().unwrap();
if converted {
st.converted = true;
} else {
st.recovered = true;
}
(st.doc.variables.active_dataset.clone(), st.revision)
};
let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string();
assert!(msg.ends_with("save the document with Save As before Save and Close"), "{msg}");
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the file is not written");
let st = s.doc().unwrap();
assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected");
assert_eq!(st.revision, revision, "no step runs");
}
}
#[test]
fn batch_refuses_save_and_close_over_data_sets_of_an_unsaved_document() {
let mut s = with_data_sets(&["a", "b"]);
let (active, revision) = {
let st = s.doc().unwrap();
(st.doc.variables.active_dataset.clone(), st.revision)
};
let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string();
assert!(msg.ends_with("the document has never been saved"), "{msg}");
let st = s.doc().unwrap();
assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected");
assert_eq!(st.revision, revision, "no step runs");
assert!(st.path.is_none());
}
#[test]
fn batch_save_and_close_saves_and_closes_each_source_file() {
let src = tmp("save-close-src");
let a = write_svg(&src, "a.svg", "#f00");
let before = std::fs::read(&a).unwrap();
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({"folder": src.to_string_lossy(), "destination": "saveAndClose", "commands": [{"command": "object.unlockAll", "params": {}}]}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_ne!(std::fs::read(&a).unwrap(), before, "the source file is saved over");
assert_eq!(file_names(&src), ["a.svg"], "{r}");
assert!(s.doc().is_err(), "the source file is closed");
}
#[test]
fn batch_refuses_nesting_and_empty_commands() {
let mut s = Session::new();
+1 -1
View File
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
[],
None,
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)",
always,
action_batch::action_batch
),
+16 -3
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_cad::{ColorDepth, DxfOptions, DxfVersion, Preserve, RasterFormat};
use vectorcraft_cad::{ColorDepth, DxfOptions, DxfPreset, DxfVersion, Preserve, RasterFormat};
use vectorcraft_doc::{Document, Unit};
use vectorcraft_geom::Rect;
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export DXF",
[],
None,
"{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}",
"{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}",
has_doc,
export_dxf
)]
@@ -33,6 +33,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[
super::ARTBOARDS,
super::RANGE,
super::USE_ARTBOARDS,
FormatOption {
name: "dxfPreset",
ty: "string",
default: "\"custom\"",
description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it",
},
FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" },
FormatOption {
name: "unit",
@@ -120,6 +126,7 @@ impl Unsupported {
struct Params {
#[serde(flatten)]
boards: ArtboardPick,
dxf_preset: Option<String>,
version: Option<Value>,
unit: Option<String>,
scale: Option<f64>,
@@ -150,7 +157,8 @@ fn choice<T>(field: &str, v: Option<String>, read: impl Fn(&str) -> Option<T>, d
/// The DXF options in `p` (the region and crop are set per drawing) and the artboard choice.
fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> {
let q: Params = if p.is_object() { Params::deserialize(p).map_err(|e| bad(C, format!("DXF options: {e}")))? } else { Params::default() };
let d = DxfOptions::default();
// The options a preset sets are the defaults of those `p` leaves out.
let d = choice("dxfPreset", q.dxf_preset, DxfPreset::from_id, DxfPreset::Custom, "custom or cnc")?.options();
let scale = q.scale.unwrap_or(d.scale);
if !(scale.is_finite() && vectorcraft_cad::SCALE_RANGE.contains(&scale)) {
return Err(bad(C, format!("DXF scale must be a positive number up to {}, not {scale}", vectorcraft_cad::SCALE_RANGE.end())));
@@ -181,6 +189,11 @@ pub fn check(p: &Value) -> std::result::Result<(), String> {
parse(p).map(|_| ()).map_err(|e| e.to_string())
}
/// The DXF options in `p` (the region and crop left at their defaults).
pub fn options(p: &Value) -> std::result::Result<DxfOptions, String> {
parse(p).map(|(o, _)| o).map_err(|e| e.to_string())
}
/// Encode `doc` as DXF: one drawing whose origin is the chosen artboard's bottom-left corner
/// (default the first), or with `use_artboards` one drawing per chosen artboard (default all)
/// holding the art over it.
+3 -2
View File
@@ -214,10 +214,11 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::
}
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
/// layers are guides and are left out.
/// layers are guides and are left out. With an object of a dynamic symbol instance's art selected,
/// the instance is exported whole.
pub(crate) fn selected_artwork(s: &Session) -> Result<Vec<NodeId>> {
let ids = edit::selected_roots(s)?;
let st = s.doc()?;
let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc));
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
// On a template layer or sublayer, at any depth.
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
+11 -3
View File
@@ -503,6 +503,8 @@ pub const FORMATS: &[Format] = &[
reader("pxr", "Pixar", &["pxr"], "image/x-pixar", true),
// JP2 and raw codestreams are decoded to PNG; JPX composition is not supported.
reader("jpeg2000", "JPEG 2000", &["jpf", "jpx", "jp2", "j2k", "j2c", "jpc"], "image/jp2", true),
// HEVC-coded HEIF still images, decoded to PNG; image sequences are not read.
reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true),
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@@ -537,6 +539,8 @@ pub const OPEN_EXTS: &[&str] = &[
"j2k",
"j2c",
"jpc",
"heic",
"heif",
"psd",
"pdd",
"psb",
@@ -554,11 +558,13 @@ pub const OPEN_EXTS: &[&str] = &[
/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to
/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no
/// `.epsf`, `.ps`, Targa, PCX, Pixar or JPEG 2000 type, nor one for the `.pns`, `.dib` and `.rle` names of
/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000 or HEIC/HEIF type, nor one for the `.pns`, `.dib` and `.rle` names of
/// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS
/// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it.
pub const UNASSOCIATED_EXTS: &[&str] =
&["psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc"];
pub const UNASSOCIATED_EXTS: &[&str] = &[
"psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc",
"heic", "heif",
];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
@@ -621,6 +627,8 @@ pub const PLACE_EXTS: &[&str] = &[
"j2k",
"j2c",
"jpc",
"heic",
"heif",
"psd",
"pdd",
"psb",
+627
View File
@@ -0,0 +1,627 @@
//! HEIC and HEIF images for File › Open and File › Place, decoded with heic-rs when the engine's
//! `heif` feature is on (the default). The image is bounded before decoding and converted to RGBA
//! for the common PNG storage path. A build without the feature still recognizes the files and
//! refuses them with a message.
#[cfg(all(test, feature = "heif"))]
pub(super) use decoder::{active_sps, picture_bytes, sps_signal, sps_size};
#[cfg(feature = "heif")]
pub(super) use decoder::{decode, quarter_turned, with_exif};
/// The brands heic-rs 0.1.4 reads: HEVC-coded images and image sequences (ISO/IEC 23008-12 annex
/// B) and the general image and sequence brands.
const BRANDS: [&[u8; 4]; 7] = [b"heic", b"heix", b"heim", b"heis", b"hevc", b"mif1", b"msf1"];
/// Recognize a HEIF file by its `ftyp` box: one of [`BRANDS`] as the major brand or a compatible
/// one, and no AVIF brand (`avif`, `avis`), as heic-rs refuses a file that lists one. The box is
/// read as JP2 boxes are ([`super::jp2read::boxes`]): ISO BMFF sizes, the 64-bit one included.
pub(super) fn is_heif(bytes: &[u8]) -> bool {
let Some((b"ftyp", ftyp)) = super::jp2read::boxes(bytes).next() else { return false };
let Some(major) = ftyp.get(..4).and_then(|b| <&[u8; 4]>::try_from(b).ok()) else { return false };
if ftyp.len() < 8 {
return false;
}
let compatible = ftyp.get(8..).unwrap_or_default().as_chunks::<4>().0;
let brands = || std::iter::once(major).chain(compatible);
!brands().any(|brand| matches!(brand, b"avif" | b"avis")) && brands().any(|brand| BRANDS.contains(&brand))
}
/// Without the `heif` feature every HEIF file is refused.
#[cfg(not(feature = "heif"))]
pub(super) fn decode(_bytes: &[u8], _max_alloc: u64) -> Result<image::RgbaImage, String> {
Err("HEIC and HEIF images can't be opened in this build".into())
}
/// Without the `heif` feature no image is decoded, so none is turned.
#[cfg(not(feature = "heif"))]
pub(super) fn quarter_turned(_bytes: &[u8]) -> bool {
false
}
/// Without the `heif` feature no resolution is read.
#[cfg(not(feature = "heif"))]
pub(super) fn with_exif<T>(_bytes: &[u8], _read: impl FnOnce(&[u8]) -> Option<T>) -> Option<T> {
None
}
#[cfg(feature = "heif")]
mod decoder {
use heic_rs::context::Context;
use heic_rs::{DecodeOptions, Error, MatrixCoefficients, Nclx, PixelLayout, Range};
/// The message for a file that isn't a readable HEIF image.
const UNREADABLE: &str = "the HEIC or HEIF file can't be read";
/// The message for an image sequence, an overlay, an image that isn't HEVC-coded, a grid whose
/// tiles are cropped, turned or mirrored, or an HEVC coding tool the decoder doesn't implement.
const UNSUPPORTED: &str = "the HEIC or HEIF file holds a kind of image that can't be read";
/// Decode the primary image within `max_alloc` bytes, counting the decoder's planes, the RGBA
/// output and the copy that rotating or cropping makes, with the container's rotation, mirror
/// and crop applied. An embedded RGB ICC profile, or `nclx` Display P3 primaries with the sRGB
/// curve, is converted to sRGB; other `nclx` values are read as sRGB. A panic inside the
/// decoder is reported as an unreadable file.
pub(in super::super) fn decode(bytes: &[u8], max_alloc: u64) -> Result<image::RgbaImage, String> {
crate::guard::catch_panic(|| decode_unguarded(bytes, max_alloc)).unwrap_or_else(|_| Err(UNREADABLE.into()))
}
/// Whether the container's rotations of the primary image add up to an odd number of quarter
/// turns, so its width and height swap. The decoder applies every `irot` the item lists.
pub(in super::super) fn quarter_turned(bytes: &[u8]) -> bool {
let turned = || {
let ctx = Context::open(bytes).ok()?;
let props = ctx.props(ctx.meta.primary).ok()?;
let quarters = props.transforms.iter().filter(|t| matches!(t, heic_rs::props::Transform::Rotate(r) if r.swaps_axes())).count();
Some(quarters % 2 == 1)
};
crate::guard::catch_panic(turned).ok().flatten().unwrap_or(false)
}
/// `read` of the primary image's EXIF block, a TIFF structure inside `bytes`, for its
/// resolution tags.
pub(in super::super) fn with_exif<T>(bytes: &[u8], read: impl FnOnce(&[u8]) -> Option<T>) -> Option<T> {
let find = || {
let ctx = Context::open(bytes).ok()?;
ctx.exif(ctx.meta.primary).ok().flatten()
};
crate::guard::catch_panic(find).ok().flatten().and_then(read)
}
fn decode_unguarded(bytes: &[u8], max_alloc: u64) -> Result<image::RgbaImage, String> {
let refused = |e: Error| match e {
Error::Unsupported(_) | Error::MissingBox("hvcC" | "meta") => UNSUPPORTED.to_string(),
_ => UNREADABLE.to_string(),
};
let info = heic_rs::probe(bytes).map_err(refused)?;
let ctx = Context::open(bytes).map_err(refused)?;
if unsupported_items(&ctx).ok_or(UNREADABLE)? {
return Err(UNSUPPORTED.into());
}
// The stored size, before a crop: the size the decoder holds.
let too_large = || format!("an image of {} × {} pixels is too large to draw", info.coded_width, info.coded_height);
let declared = u64::from(info.coded_width).checked_mul(u64::from(info.coded_height)).ok_or_else(too_large)?;
// A grid is composed at the size its own data gives, which need not match `ispe`.
let grid = grid_pixels(&ctx).ok_or(UNREADABLE)?;
// A single picture is converted at the size its stream gives, which need not match `ispe`
// either.
let coded = declared.max(grid).max(stream_pixels(&ctx).ok_or(UNREADABLE)?);
let shown = u64::from(info.width).checked_mul(u64::from(info.height)).ok_or_else(too_large)?;
// The RGBA output, and a second RGBA image when the decoder rotates, mirrors or crops it,
// which is no larger than the first.
let output = coded.checked_mul(8).ok_or_else(too_large)?;
let pictures = picture_bytes_in(&ctx).ok_or(UNREADABLE)?;
// An alpha grid is composed into a frame of 16-bit planes (at most three samples a pixel),
// and so is the image's grid when it is decoded with a color description of its tiles or
// its stream. A single picture is converted in place.
let composed = if grid > 0 { coded.checked_mul(if info.has_alpha { 12 } else { 6 }).ok_or_else(too_large)? } else { 0 };
if coded == 0 || shown == 0 || output.checked_add(pictures).and_then(|n| n.checked_add(composed)).is_none_or(|n| n > max_alloc) {
return Err(too_large());
}
let max_pixels = max_alloc / 4;
let decoded = match tile_nclx(&ctx).or_else(|| stream_nclx(&ctx)) {
Some(nclx) => decode_with_nclx(&ctx, nclx, max_pixels),
None => heic_rs::decode(bytes, &DecodeOptions::default().with_layout(PixelLayout::Rgba8).with_max_pixels(Some(max_pixels))),
}
.map_err(|e| match e {
Error::PixelLimit { .. } | Error::BoxTooLarge { .. } => too_large(),
e => refused(e),
})?;
let mut img = image::RgbaImage::from_raw(decoded.width, decoded.height, decoded.data).ok_or(UNREADABLE)?;
if let Some(profile) = source_profile(&ctx) {
for pixel in img.pixels_mut() {
let [r, g, b, a] = pixel.0;
let device = [r, g, b].map(|v| f32::from(v) / 255.0);
if let Some(rgb) = profile.to_srgb_into(&device, vectorcraft_color::cms::Intent::RelativeColorimetric) {
let [r, g, b] = rgb.map(|v| (v * 255.0).round() as u8);
pixel.0 = [r, g, b, a];
}
}
}
Ok(img)
}
/// The primary image and its alpha image, when it has one.
fn primary_and_alpha(ctx: &Context<'_>) -> Option<impl Iterator<Item = u32>> {
let primary = ctx.meta.primary;
Some(std::iter::once(primary).chain(ctx.alpha_item(primary).ok()?))
}
/// Whether the primary image or its alpha image is neither an HEVC-coded item (`hvc1`, `hev1`)
/// nor a grid of them, or is a grid with a tile that carries a crop, rotation or mirror. heic-rs
/// 0.1.4 decodes any item with an `hvcC` whatever its type, and composes a grid from its tiles
/// as coded, without their transforms: an encoder that pads tiles smaller than its coding block
/// (64 × 64 for x265) and crops them back with `clap` gets the wrong pixels.
fn unsupported_items(ctx: &Context<'_>) -> Option<bool> {
let hevc = |id: u32| ctx.meta.item(id).is_some_and(|info| info.is_hevc());
for item in primary_and_alpha(ctx)? {
let Some((_, tiles)) = ctx.grid(item).ok()? else {
if !hevc(item) {
return Some(true);
}
continue;
};
for tile in tiles {
if !hevc(tile) || !ctx.props(tile).ok()?.transforms.is_empty() {
return Some(true);
}
}
}
Some(false)
}
/// The pixels of the larger of the primary image's grid and its alpha image's grid, at the
/// output size each grid gives; 0 when neither is a grid.
fn grid_pixels(ctx: &Context<'_>) -> Option<u64> {
let mut largest = 0u64;
for item in primary_and_alpha(ctx)? {
if let Some((grid, _)) = ctx.grid(item).ok()? {
largest = largest.max(u64::from(grid.output_width) * u64::from(grid.output_height));
}
}
Some(largest)
}
/// The pixels of the larger of the primary image and its alpha image when either is a single
/// picture, at the size of its largest sequence parameter set (before its conformance window);
/// 0 when both are grids. `None` when a size can't be read.
fn stream_pixels(ctx: &Context<'_>) -> Option<u64> {
let mut largest = 0u64;
for item in primary_and_alpha(ctx)? {
if ctx.grid(item).ok()?.is_some() {
continue;
}
// An item without `hvcC` isn't decoded: heic-rs refuses the file.
let Some(hvcc) = ctx.props(item).ok()?.hvcc else { continue };
for nal in hvcc.parameter_sets().into_iter().filter(|nal| nal_type(nal) == Some(SPS)) {
let (width, height, _) = sps_size(nal)?;
largest = largest.max(width.checked_mul(height)?);
}
}
Some(largest)
}
/// The first tile's `nclx` when the primary image is a grid without an `nclx` of its own and
/// its tiles carry one. heic-rs 0.1.4 converts a grid's samples to RGB with the grid item's
/// `nclx` alone, and with its BT.709 limited-range default when the grid has none
/// (tbraun96/heic-rs#6).
fn tile_nclx(ctx: &Context<'_>) -> Option<Nclx> {
let primary = ctx.meta.primary;
if ctx.props(primary).ok()?.nclx.is_some() {
return None;
}
let (_, tiles) = ctx.grid(primary).ok()??;
ctx.props(*tiles.first()?).ok()?.nclx
}
/// The color description in the HEVC stream's video usability information (ITU-T H.265
/// E.2.1) of the primary image, or of its grid's first tile, when neither carries an `nclx`
/// (a file whose only color box is an ICC profile, or one without a color box). heic-rs 0.1.4
/// reads only the container's `nclx` and otherwise converts with BT.709 limited range.
fn stream_nclx(ctx: &Context<'_>) -> Option<Nclx> {
let primary = ctx.meta.primary;
if ctx.props(primary).ok()?.nclx.is_some() {
return None;
}
let coded = match ctx.grid(primary).ok()? {
Some((_, tiles)) => *tiles.first()?,
None => primary,
};
let props = ctx.props(coded).ok()?;
if props.nclx.is_some() {
return None;
}
let hvcc = props.hvcc?;
let data = ctx.item_data(coded).ok()?;
let slices = hvcc.split_nals(&data).ok()?;
active_sps(&hvcc.parameter_sets(), &slices).and_then(sps_signal)
}
/// The SPS the first slice of `slices` uses: the one its PPS names (ITU-T H.265 7.4.3.3.1).
pub(in super::super) fn active_sps<'a>(parameter_sets: &[&'a [u8]], slices: &[&[u8]]) -> Option<&'a [u8]> {
let slice = slices.iter().find(|nal| nal_type(nal).is_some_and(|t| t < 32))?;
let bits = &mut Bits { data: &payload(slice), at: 0 };
// first_slice_segment_in_pic_flag, no_output_of_prior_pics_flag in an IRAP picture (types 16
// to 23), slice_pic_parameter_set_id.
bits.skip(1)?;
if nal_type(slice).is_some_and(|t| (16..=23).contains(&t)) {
bits.skip(1)?;
}
let pps_id = bits.exp_golomb()?;
// pps_pic_parameter_set_id, pps_seq_parameter_set_id.
let pps_ids = |nal: &[u8]| {
let bits = &mut Bits { data: &payload(nal), at: 0 };
Some((bits.exp_golomb()?, bits.exp_golomb()?))
};
let sps_id = parameter_sets.iter().filter(|nal| nal_type(nal) == Some(PPS)).find_map(|nal| pps_ids(nal).filter(|(id, _)| *id == pps_id))?.1;
let sps_ids = |nal: &[u8]| {
let rbsp = sps_payload(nal)?;
sps_head(&mut Bits { data: &rbsp, at: 0 }).map(|(_, id, ..)| id)
};
parameter_sets.iter().copied().find(|nal| sps_ids(nal) == Some(sps_id))
}
/// The primary image decoded as heic-rs 0.1.4 decodes it (a grid's tiles one after another),
/// with its samples converted to RGBA with `nclx`, the alpha image applied and the image's
/// rotation, mirror and crop applied.
fn decode_with_nclx(ctx: &Context<'_>, nclx: Nclx, max_pixels: u64) -> heic_rs::Result<heic_rs::Image> {
use heic_rs::hevc::Frame;
fn coded(ctx: &Context<'_>, id: u32) -> heic_rs::Result<Frame> {
let props = ctx.props(id)?;
let hvcc = props.hvcc.as_ref().ok_or(Error::MissingBox("hvcC"))?;
let data = ctx.item_data(id)?;
let frame = heic_rs::hevc::decode_still(&hvcc.parameter_sets(), &hvcc.split_nals(&data)?)?;
frame.validate()?;
Ok(frame)
}
fn item(ctx: &Context<'_>, id: u32, max_pixels: u64) -> heic_rs::Result<Frame> {
match ctx.grid(id)? {
Some((grid, tiles)) => {
// The tiles decode in parallel on native, as heic-rs's own decode does.
#[cfg(not(target_arch = "wasm32"))]
let frames = {
use rayon::prelude::*;
tiles.par_iter().map(|&t| coded(ctx, t)).collect::<heic_rs::Result<Vec<_>>>()?
};
#[cfg(target_arch = "wasm32")]
let frames = tiles.iter().map(|&t| coded(ctx, t)).collect::<heic_rs::Result<Vec<_>>>()?;
heic_rs::grid::compose(&grid, &frames, max_pixels)
}
None => coded(ctx, id),
}
}
let primary = ctx.meta.primary;
let props = ctx.props(primary)?;
let frame = item(ctx, primary, max_pixels)?;
let alpha = ctx.alpha_item(primary)?.map(|a| item(ctx, a, max_pixels)).transpose()?;
// `None`: rayon's global pool on native, where heic-rs is built with `parallel`.
let image = heic_rs::color::convert(&frame, alpha.as_ref(), nclx, PixelLayout::Rgba8, max_pixels, None)?;
heic_rs::transform::apply_all(image, &props.transforms)
}
/// The bytes heic-rs 0.1.4 allocates for the coded pictures of `bytes` it decodes (see
/// [`picture_bytes_in`]); `None` when they can't be read.
#[cfg(test)]
pub(in super::super) fn picture_bytes(bytes: &[u8]) -> Option<u64> {
picture_bytes_in(&Context::open(bytes).ok()?)
}
/// The bytes heic-rs 0.1.4 allocates for the coded pictures it decodes: the primary image's,
/// or each tile of its grid, and its alpha image's the same way. Each picture is sized by the
/// largest of its sequence parameter sets (the size the decoder allocates, rounded up to whole
/// 64-sample coding blocks), not by the container, and takes three sets of 16-bit planes: the
/// picture, a filtered copy and the frame returned. `None` when an item's sizes can't be read.
fn picture_bytes_in(ctx: &Context<'_>) -> Option<u64> {
let mut total = 0u64;
for item in primary_and_alpha(ctx)? {
let coded = match ctx.grid(item).ok()? {
Some((_, tiles)) => tiles,
None => vec![item],
};
for id in coded {
let props = ctx.props(id).ok()?;
// An item without `hvcC` isn't decoded: heic-rs refuses the file.
let Some(hvcc) = props.hvcc else { continue };
// The slices pick their SPS through the PPS, so the largest one is counted. Every
// SPS must be read.
let mut largest = None;
for nal in hvcc.parameter_sets().into_iter().filter(|nal| nal_type(nal) == Some(SPS)) {
let (width, height, chroma) = sps_size(nal)?;
let blocks = |n: u64| n.div_ceil(64) * 64;
// Bytes a sample position takes in one set of planes: luma and the two chroma
// planes.
let planes = match chroma {
0 => 2,
1 => 3,
2 => 4,
_ => 6,
};
let picture = blocks(width).checked_mul(blocks(height))?.checked_mul(planes * 3)?;
largest = largest.max(Some(picture));
}
total = total.checked_add(largest?)?;
}
}
Some(total)
}
/// The NAL unit type of an HEVC sequence parameter set.
const SPS: u8 = 33;
/// The type of an HEVC NAL unit, from its header.
fn nal_type(nal: &[u8]) -> Option<u8> {
nal.first().map(|b| (b >> 1) & 0x3f)
}
/// The NAL unit type of an HEVC picture parameter set.
const PPS: u8 = 34;
/// A NAL unit's payload, without its two-byte header and the emulation prevention bytes (the 03
/// of 00 00 03), up to its first 4,096 bytes.
fn payload(nal: &[u8]) -> Vec<u8> {
let mut rbsp = Vec::with_capacity(256);
let mut zeros = 0;
for &b in nal.get(2..).unwrap_or_default().iter().take(4096) {
if b == 3 && zeros >= 2 {
zeros = 0;
continue;
}
zeros = if b == 0 { zeros + 1 } else { 0 };
rbsp.push(b);
}
rbsp
}
/// An SPS NAL unit's [`payload`]; `None` for another NAL unit.
fn sps_payload(nal: &[u8]) -> Option<Vec<u8>> {
(nal_type(nal) == Some(SPS)).then(|| payload(nal))
}
/// The fields of an SPS (ITU-T H.265 7.3.2.2) up to the picture height:
/// `sps_max_sub_layers_minus1`, `sps_seq_parameter_set_id`, the width, the height and
/// `chroma_format_idc`.
fn sps_head(bits: &mut Bits<'_>) -> Option<(u64, u64, u64, u64, u64)> {
// sps_video_parameter_set_id, sps_max_sub_layers_minus1, sps_temporal_id_nesting_flag.
bits.skip(4)?;
let sub_layers = bits.read(3)?;
bits.skip(1)?;
// profile_tier_level (7.3.3): the general profile, tier and level, then the sub-layers'.
bits.skip(96)?;
let mut present = Vec::new();
for _ in 0..sub_layers {
present.push((bits.read(1)?, bits.read(1)?));
}
if sub_layers > 0 {
bits.skip(2 * (8 - sub_layers as usize))?;
}
for (profile, level) in present {
bits.skip(88 * profile as usize + 8 * level as usize)?;
}
let id = bits.exp_golomb()?;
let chroma = bits.exp_golomb()?;
if chroma == 3 {
bits.skip(1)?;
}
let width = bits.exp_golomb()?;
let height = bits.exp_golomb()?;
Some((sub_layers, id, width, height, chroma))
}
/// The picture width, height and `chroma_format_idc` of an HEVC sequence parameter set NAL
/// unit, before its conformance window; `None` for another NAL unit.
pub(in super::super) fn sps_size(nal: &[u8]) -> Option<(u64, u64, u64)> {
let rbsp = sps_payload(nal)?;
let (_, _, width, height, chroma) = sps_head(&mut Bits { data: &rbsp, at: 0 })?;
Some((width, height, chroma))
}
/// The video signal type of an HEVC sequence parameter set NAL unit's VUI (ITU-T H.265 E.2.1)
/// as an `nclx`: the full-range flag and, when the VUI describes them, the color primaries,
/// transfer characteristics and matrix coefficients (otherwise 2, unspecified). `None` when the
/// SPS has no VUI, the VUI has no video signal type, or the SPS can't be read that far.
pub(in super::super) fn sps_signal(nal: &[u8]) -> Option<Nclx> {
let rbsp = sps_payload(nal)?;
let bits = &mut Bits { data: &rbsp, at: 0 };
let (sub_layers, ..) = sps_head(bits)?;
skip_to_vui(bits, sub_layers)?;
// vui_parameters_present_flag, then aspect_ratio_info_present_flag (an Extended_SAR
// aspect_ratio_idc of 255 adds its 32-bit ratio) and overscan_info_present_flag.
if bits.read(1)? == 0 {
return None;
}
if bits.read(1)? == 1 && bits.read(8)? == 255 {
bits.skip(32)?;
}
if bits.read(1)? == 1 {
bits.skip(1)?;
}
// video_signal_type_present_flag: video_format, video_full_range_flag and the color
// description.
if bits.read(1)? == 0 {
return None;
}
bits.skip(3)?;
let range = if bits.read(1)? == 1 { Range::Full } else { Range::Limited };
let (primaries, transfer, matrix) = if bits.read(1)? == 1 { (bits.read(8)?, bits.read(8)?, bits.read(8)?) } else { (2, 2, 2) };
let code = |v: u64| u16::try_from(v).ok();
let matrix_code = code(matrix)?;
Some(Nclx { primaries: code(primaries)?, transfer: code(transfer)?, matrix: MatrixCoefficients::from_code(matrix_code), matrix_code, range })
}
/// Skips the fields of an SPS after [`sps_head`], up to `vui_parameters_present_flag`.
fn skip_to_vui(bits: &mut Bits<'_>, sub_layers: u64) -> Option<()> {
// conformance_window_flag and its four offsets.
if bits.read(1)? == 1 {
for _ in 0..4 {
bits.exp_golomb()?;
}
}
// bit_depth_luma_minus8, bit_depth_chroma_minus8.
bits.exp_golomb()?;
bits.exp_golomb()?;
let poc_bits = bits.exp_golomb()?.checked_add(4).filter(|&n| n <= 16)?;
// sps_sub_layer_ordering_info_present_flag, then three values for each sub-layer given.
let ordered = if bits.read(1)? == 1 { sub_layers + 1 } else { 1 };
for _ in 0..ordered * 3 {
bits.exp_golomb()?;
}
// The coding and transform block sizes and the transform hierarchy depths.
for _ in 0..6 {
bits.exp_golomb()?;
}
// scaling_list_enabled_flag, sps_scaling_list_data_present_flag.
if bits.read(1)? == 1 && bits.read(1)? == 1 {
skip_scaling_lists(bits)?;
}
// amp_enabled_flag, sample_adaptive_offset_enabled_flag.
bits.skip(2)?;
// pcm_enabled_flag: the PCM sample depths, block sizes and loop filter flag.
if bits.read(1)? == 1 {
bits.skip(8)?;
bits.exp_golomb()?;
bits.exp_golomb()?;
bits.skip(1)?;
}
skip_reference_picture_sets(bits)?;
// long_term_ref_pics_present_flag: each picture's POC bits and used flag.
if bits.read(1)? == 1 {
let count = bits.exp_golomb()?;
if count > 32 {
return None;
}
for _ in 0..count {
bits.skip(poc_bits as usize + 1)?;
}
}
// sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag.
bits.skip(2)
}
/// Skips `scaling_list_data()` (ITU-T H.265 7.3.4).
fn skip_scaling_lists(bits: &mut Bits<'_>) -> Option<()> {
for size in 0..4 {
// The 32 × 32 lists are given for matrices 0 and 3 only.
for _ in 0..if size == 3 { 2 } else { 6 } {
if bits.read(1)? == 0 {
// scaling_list_pred_matrix_id_delta.
bits.exp_golomb()?;
} else {
// scaling_list_dc_coef_minus8, then each scaling_list_delta_coef (se(v) codes,
// as long as ue(v) codes).
if size > 1 {
bits.exp_golomb()?;
}
for _ in 0..if size == 0 { 16 } else { 64 } {
bits.exp_golomb()?;
}
}
}
}
Some(())
}
/// Skips `num_short_term_ref_pic_sets` and each `st_ref_pic_set()` (ITU-T H.265 7.3.7) of an
/// SPS. A set predicted from the one before it has one or two flags for each of that set's
/// pictures and one more.
fn skip_reference_picture_sets(bits: &mut Bits<'_>) -> Option<()> {
let sets = bits.exp_golomb()?;
if sets > 64 {
return None;
}
// NumDeltaPocs of each set read so far.
let mut pictures: Vec<u64> = Vec::new();
for index in 0..sets {
let predicted = index != 0 && bits.read(1)? == 1;
let count = if predicted {
// delta_rps_sign, abs_delta_rps_minus1.
bits.skip(1)?;
bits.exp_golomb()?;
let mut count = 0;
for _ in 0..=*pictures.last()? {
// used_by_curr_pic_flag, and use_delta_flag when it is 0.
if bits.read(1)? == 1 || bits.read(1)? == 1 {
count += 1;
}
}
count
} else {
let (negative, positive) = (bits.exp_golomb()?, bits.exp_golomb()?);
if negative > 16 || positive > 16 {
return None;
}
// Each picture's delta_poc_s0_minus1 or delta_poc_s1_minus1 and its used flag.
for _ in 0..negative + positive {
bits.exp_golomb()?;
bits.skip(1)?;
}
negative + positive
};
pictures.push(count);
}
Some(())
}
/// A big-endian bit reader over an RBSP.
struct Bits<'a> {
data: &'a [u8],
at: usize,
}
impl Bits<'_> {
fn bit(&mut self) -> Option<u64> {
let byte = self.data.get(self.at / 8)?;
let bit = (byte >> (7 - self.at % 8)) & 1;
self.at += 1;
Some(u64::from(bit))
}
fn read(&mut self, n: u32) -> Option<u64> {
(0..n).try_fold(0u64, |v, _| Some(v << 1 | self.bit()?))
}
fn skip(&mut self, n: usize) -> Option<()> {
self.at = self.at.checked_add(n)?;
(self.at <= self.data.len() * 8).then_some(())
}
/// An unsigned Exp-Golomb code, ue(v), of at most 32 leading zeros.
fn exp_golomb(&mut self) -> Option<u64> {
let mut zeros = 0;
while self.bit()? == 0 {
zeros += 1;
if zeros > 32 {
return None;
}
}
Some((1u64 << zeros) - 1 + self.read(zeros)?)
}
}
/// `nclx` color primaries for Display P3 (SMPTE EG 432-1) and transfer characteristics for
/// the sRGB curve (IEC 61966-2-1), ITU-T H.273.
const P3_PRIMARIES: u16 = 12;
const SRGB_TRANSFER: u16 = 13;
/// The profile the decoded RGB values are in when it isn't sRGB: the primary item's RGB ICC
/// profile, else Display P3 when its `nclx` names P3 primaries with the sRGB curve. A grid
/// without its own `colr` takes its first tile's, as cameras write it on the tiles. A profile
/// that can't be read, or isn't RGB, is left out and the values are read as sRGB. Without any
/// `colr`, the HEVC stream's color description ([`stream_nclx`]) is read the same way.
fn source_profile(ctx: &Context<'_>) -> Option<vectorcraft_color::cms::IccProfile> {
use vectorcraft_color::cms::{self, IccProfile, ProfileKind};
let id = ctx.meta.primary;
let own = ctx.props(id).ok()?;
let described = |p: &heic_rs::props::ItemProps<'_>| p.icc.is_some() || p.nclx.is_some();
let tile = || ctx.grid(id).ok().flatten().and_then(|(_, tiles)| tiles.first().and_then(|&t| ctx.props(t).ok())).filter(described);
let props = if described(&own) { Some(own) } else { tile() };
if let Some(icc) = props.as_ref().and_then(|p| p.icc) {
return IccProfile::from_bytes(None, icc).ok().filter(|p| p.kind == ProfileKind::Rgb);
}
let nclx = match props {
Some(props) => props.nclx?,
None => stream_nclx(ctx)?,
};
(nclx.primaries == P3_PRIMARIES && nclx.transfer == SRGB_TRANSFER).then(|| cms::icc::builtin_rgb(cms::DISPLAY_P3)).flatten()
}
}
+24 -3
View File
@@ -29,6 +29,8 @@ pub struct Loaded {
/// A PDF, or a `.ai` or `.ait` that holds a PDF, read through the editor's own copy of its art
/// that it carries (its layers), not from its PDF part alone.
pub editing_data: bool,
/// Whether the native source declared a PDF; other formats carry no native save preference.
pub native_pdf_compatible: Option<bool>,
}
impl Loaded {
@@ -103,6 +105,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
if super::jp2read::is_jpeg2000(bytes) {
return format("jpeg2000");
}
if super::heifread::is_heif(bytes) {
return format("heif");
}
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
return Some(f);
}
@@ -338,6 +343,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let mut converted = false;
let mut preview_only = false;
let mut editing_data = false;
let mut native_pdf_compatible = None;
let (mut doc, mut warnings, restored) = match format.id {
// EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read.
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
@@ -350,6 +356,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
}
"vectorcraft" | "template" => {
let (d, info, warnings) = native_file(bytes)?;
native_pdf_compatible = Some(info.pdf_compatible);
converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION;
(d, warnings, false)
}
@@ -475,11 +482,15 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
}
// Threaded type read from a file flows through its frames (the threaded type of a restored
// document keeps its text as saved), and every frame of a thread takes its first frame's
// Optical Margin Alignment setting.
// Optical Margin Alignment setting and writing direction. When that changes the direction of a
// frame of a restored document, the story flows through that frame's thread again. Threaded
// frames keep their size: Auto Size is off for them.
if !doc.text_threads.is_empty() && !restored {
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
} else if !doc.text_threads.is_empty() {
crate::cmd::threads::keep_frame_sizes(&mut doc);
crate::cmd::threads::share_optical_margin(&mut doc);
crate::cmd::threads::share_orientation(&mut doc);
// A frame whose first paragraph continues from the frame before draws it without the
// paragraph's first-line indent and list marker.
crate::cmd::threads::mark_continued(&mut doc);
@@ -493,7 +504,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
doc.title = title;
}
warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes)));
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data, native_pdf_compatible })
}
/// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's
@@ -586,7 +597,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
/// Make a loaded file the new active document; `links` is what became of its linked files, and
/// `opts` are the options it was read with.
fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs);
let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs);
// EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing
// fallbacks must ask before overwriting the richer source they could not fully read.
let losses: Vec<String> = warnings
@@ -623,6 +634,9 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
let converted = saves_back && converted && s.prefs.append_converted;
let index = s.add_document(doc, path);
let st = s.doc_mut()?;
if let Some(pdf) = native_pdf_compatible {
st.save_options.insert("pdfCompatible".into(), json!(pdf));
}
if saves_back {
st.format = format.id;
// An SVG that reopened as the full document saves as one again: Save keeps Preserve
@@ -722,6 +736,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option<String> {
/// [`super::pcxread::decode`], which refuses an image past [`MAX_RASTER_ALLOC`] before decoding.
/// A Pixar picture file is recognized by its magic number ([`super::pxrread::is_pxr`]) and read by
/// [`super::pxrread::decode`], which does the same.
/// A HEIC or HEIF file is recognized by its `ftyp` brands ([`super::heifread::is_heif`]) and read
/// by [`super::heifread::decode`], which does the same.
/// A Targa file, which starts with no signature, is recognized by its header
/// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores
/// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels
@@ -740,6 +756,11 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
if super::jp2read::is_jpeg2000(bytes) {
return as_png(super::jp2read::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
}
if super::heifread::is_heif(bytes) {
// The EXIF resolution describes the stored pixels; a quarter turn swaps its axes.
let ppi = ppi.map(|(x, y)| if super::heifread::quarter_turned(bytes) { (y, x) } else { (x, y) });
return as_png(super::heifread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
}
if super::pxrread::is_pxr(bytes) {
return as_png(super::pxrread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
}
+10 -6
View File
@@ -48,7 +48,7 @@ pub const OPTIONS: &[FormatOption] = &[
FormatOption {
name: "pdfCompatible",
ty: "boolean",
default: "false",
default: "true",
description: "also carry a PDF of every artboard, for apps that read PDF (a larger file)",
},
];
@@ -146,7 +146,7 @@ struct NativeOptions {
precision: Option<Value>,
include_linked: bool,
embed_profiles: Option<bool>,
pdf_compatible: bool,
pdf_compatible: Option<bool>,
}
/// What [`save_options`] leaves to [`encode`] (rendering and encoding take time).
@@ -171,7 +171,9 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->
so.profiles = embedded_profiles(doc);
}
so.check().map_err(|e| bad(cmd, e.to_string()))?;
Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible }))
let pdf = o.pdf_compatible.unwrap_or(true);
so.pdf_compatible = Some(pdf);
Ok((so, Extras { preview: o.preview, pdf }))
}
/// The `precision` option `v`: a whole number of decimal places, 0 to
@@ -213,9 +215,11 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp
if extras.preview {
so.preview = preview_png(&drawn)?;
}
if extras.pdf {
// Every artboard as PDF pages (no editing data: the file is the native document).
so.pdf = Some(super::pdf::encode(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }))?.0);
if extras.pdf && !drawn.artboards.is_empty() {
// Every artboard as PDF pages (no editing data: the file is the native document), dated by
// the document's File Info, so the same document and dates give the same bytes.
let at = drawn.metadata.modified.or(drawn.metadata.created);
so.pdf = Some(super::pdf::encode_at(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }), at)?.0);
}
drop(drawn);
let native = match full {
+25 -3
View File
@@ -240,9 +240,13 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
/// time of writing. On the web, which has no clock, the creation date is the modification date too,
/// and a document without one gets neither.
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
options_at(cmd, doc, p, vectorcraft_doc::metadata::output_time())
}
/// [`options`] modified at `now` (Unix seconds) instead of the time of writing.
fn options_at(cmd: &str, doc: &Document, p: &Value, now: Option<i64>) -> Result<PdfOptions> {
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
let now = vectorcraft_doc::metadata::output_time();
Ok(PdfOptions {
settings: settings(cmd, p)?,
artboards,
@@ -265,9 +269,14 @@ pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file
/// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself
/// when the PDF has every artboard (a PDF of some reopens as just those).
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
encode_at(cmd, doc, p, None)
}
/// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing.
pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option<i64>) -> Result<(Vec<u8>, Vec<String>)> {
// Keep the editable object tree independent of preview fallbacks used by the drawn pages.
let (full, mut warnings) = crate::cmd::place::document::full_documents(doc);
let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?;
let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?;
warnings.extend(drawn_warnings);
warnings.extend(overflow_warning(cmd, &full, p)?);
Ok((bytes, warnings))
@@ -294,7 +303,20 @@ pub(super) fn encode_carrying(
native: impl FnOnce() -> Result<Vec<u8>>,
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
) -> Result<(Vec<u8>, Vec<String>)> {
let mut opts = options(cmd, doc, p)?;
encode_carrying_at(cmd, doc, p, None, native, private)
}
/// [`encode_carrying`] modified at `modified` (Unix seconds) when given, instead of the time of
/// writing.
fn encode_carrying_at(
cmd: &str,
doc: &Document,
p: &Value,
modified: Option<i64>,
native: impl FnOnce() -> Result<Vec<u8>>,
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
) -> Result<(Vec<u8>, Vec<String>)> {
let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?;
// The pages' background colors, behind the art (the editing data keeps them as colors).
let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect());
let filled = super::export::with_artboard_fills(doc, p, pages).map_err(|e| bad(cmd, e))?;
+4 -1
View File
@@ -1,6 +1,7 @@
//! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and
//! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX
//! header's HDpi and VDpi, or JP2 capture and display resolution boxes. Place
//! header's HDpi and VDpi, JP2 capture and display resolution boxes, or a HEIF image's EXIF
//! resolution tags. Place
//! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it
//! as a `pHYs` chunk.
@@ -26,6 +27,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
super::pcxread::resolution(bytes)
} else if super::jp2read::is_jpeg2000(bytes) {
jp2(bytes)
} else if super::heifread::is_heif(bytes) {
super::heifread::with_exif(bytes, tiff)
} else {
None
};
+32 -3
View File
@@ -270,7 +270,18 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
}
}
// None given: the ones the document was last saved with in this format.
let options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
// Remember the effective choice, including the encoder's default, so another document's
// later save cannot change what this one writes. Invalid non-null values still reach validation.
if matches!(format.id, "vectorcraft" | "template") {
let pdf = match options.get("pdfCompatible") {
None | Some(Value::Null) => Some(native_pdf_preference(s, Some(st)).unwrap_or(true)),
_ => None,
};
if let Some(pdf) = pdf {
options.insert("pdfCompatible".into(), json!(pdf));
}
}
// Save writes the document's own file; the other modes only where they are told to.
let path =
path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten());
@@ -336,6 +347,15 @@ fn same_value(a: &Value, b: &Value) -> bool {
}
}
/// Native documents and templates share only their PDF choice. Other source formats use the
/// application's last native choice, keeping an AI file's separate PDF option out of it.
fn native_pdf_preference(s: &Session, st: Option<&DocState>) -> Option<bool> {
st.filter(|d| matches!(d.format, "vectorcraft" | "template"))
.and_then(|d| d.save_options.get("pdfCompatible"))
.and_then(Value::as_bool)
.or_else(|| s.prefs.native_save_options.get("pdfCompatible").and_then(Value::as_bool))
}
/// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`)
/// following the artboards they named ([`DocState::save_artboards`]): an artboard deleted since is
/// left out, and one that moved is named by its new number. When none of the artboards the save
@@ -1028,12 +1048,15 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> {
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
let index = s.active_index().ok_or(crate::EngineError::NoDocument)?;
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs);
let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs);
doc.template = false;
// Back to the saved file: its recovery copy has nothing left to recover.
let uid = s.doc()?.uid;
crate::cmd::recovery::forget(s, uid);
s.replace_document(index, doc);
if let Some(pdf) = native_pdf_compatible {
s.doc_mut()?.save_options.insert("pdfCompatible".into(), json!(pdf));
}
Ok(json!({ "path": path }))
}
@@ -1053,11 +1076,17 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
.or_else(|| (f.id == "vectorcraft" && !s.prefs.native_save_options.is_empty()).then(|| s.prefs.native_save_options.clone()));
// A native save compresses as Use Compression says, unless told otherwise.
let prefs = with_compression_pref(&s.prefs, &json!({}));
let native_pdf = matches!(f.id, "vectorcraft" | "template").then(|| native_pdf_preference(s, st)).flatten();
let mut v = f.to_json();
if let Some(options) = v["options"].as_object_mut() {
for (name, o) in options.iter_mut() {
let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]);
o["value"] = saved.as_ref().and_then(|m| m.get(name)).unwrap_or(default).clone();
if name == "pdfCompatible"
&& let Some(pdf) = native_pdf
{
o["value"] = json!(pdf);
}
}
}
v["saveFormats"] =
@@ -1072,7 +1101,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
+1 -1
View File
@@ -670,7 +670,7 @@ fn find_preset(s: &Session, name: &str) -> Option<ScreenExportPreset> {
}
/// `p` over the settings of its `exportPreset`, when it names one (its own keys win).
fn with_preset(s: &Session, p: &Value) -> Result<Value> {
pub(super) fn with_preset(s: &Session, p: &Value) -> Result<Value> {
let Some(name) = str_param(p, "exportPreset") else { return Ok(p.clone()) };
let preset = find_preset(s, name).ok_or_else(|| bad(C, format!("no export preset `{name}` (see document.exportPresets.list)")))?;
let mut merged = preset.settings;
+14 -1
View File
@@ -680,7 +680,9 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
for (name, compress) in [("a.vectorcraft", false), ("b.vectorcraft", true), ("c.vctemplate", true)] {
let path = d.join(name).to_string_lossy().to_string();
let p = json!({"path": path, "compress": compress});
// A PDF-compatible file's PDF is dated to the second it is written, so the byte comparison
// uses files without one.
let p = json!({"path": path, "compress": compress, "pdfCompatible": false});
let r = s.execute("file.saveCopy", &p).unwrap();
let written = std::fs::read(&path).unwrap();
assert_eq!(r["bytes"], written.len(), "{r}");
@@ -690,6 +692,17 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
assert_eq!(written, job.encode().unwrap().files[0].1, "{name}");
assert_eq!(vectorcraft_format::load(&written).unwrap().template, name.ends_with("vctemplate"));
}
// A save with the default options streams a file that carries its PDF.
let path = d.join("d.vectorcraft").to_string_lossy().to_string();
let p = json!({"path": path});
let r = s.execute("file.saveCopy", &p).unwrap();
let written = std::fs::read(&path).unwrap();
assert_eq!(r["bytes"], written.len(), "{r}");
assert!(vectorcraft_format::pdf_content(&written).is_some());
let plan = save_plan(&s, SaveMode::Copy, &p).unwrap();
let job = save_job(&mut s, plan).unwrap();
assert!(job.streams());
assert!(vectorcraft_format::pdf_content(&job.encode().unwrap().files[0].1).is_some());
// A writer that fails: the save fails with its error.
let plan = save_plan(&s, SaveMode::Copy, &json!({"path": "/x/y.vectorcraft"})).unwrap();
let job = save_job(&mut s, plan).unwrap();
+26
View File
@@ -63,6 +63,32 @@ fn export_writes_dxf_of_the_chosen_version() {
assert_eq!(header(&text(&ser), "$INSUNITS").as_deref(), Some("1"));
}
#[test]
fn the_cnc_preset_sets_the_options_that_are_not_given() {
let mut s = two_boards();
let cnc = text(&s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap());
assert_eq!(header(&cnc, "$ACADVER").as_deref(), Some("AC1015"));
assert_eq!(header(&cnc, "$INSUNITS").as_deref(), Some("4"));
// document.export takes it too, beside a PDF preset's name.
let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc"})).unwrap();
assert_eq!(header(&text(&r), "$ACADVER").as_deref(), Some("AC1015"));
let o = dxf::options(&json!({"dxfPreset": "CNC", "unit": "in"})).unwrap();
assert_eq!(
(o.version, o.unit, o.colors, o.preserve, o.alter_paths, o.outline_text),
(
vectorcraft_cad::DxfVersion::R2000,
vectorcraft_doc::Unit::Inches,
vectorcraft_cad::ColorDepth::Aci256,
vectorcraft_cad::Preserve::Editability,
false,
true
)
);
assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default");
let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string();
assert!(e.contains("custom or cnc"), "{e}");
}
#[test]
fn use_artboards_writes_one_drawing_per_artboard() {
let mut s = two_boards();
@@ -0,0 +1,620 @@
//! HEIC and HEIF Open, Place, pixel, color and resolution regressions using original synthetic
//! fixtures (quadrants of four colors, lossy HEVC, so colors are compared within a tolerance).
#[cfg(feature = "heif")]
use serde_json::json;
use super::heifread::is_heif;
use super::*;
const QUADRANTS: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants.heic");
const ALPHA: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-alpha.heic");
const TEN_BIT: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-10bit.heic");
const GRID: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-grid.heic");
const GRID16: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-grid16.heic");
const PPI300: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-300ppi.heic");
const ROTATED: &[u8] = include_bytes!("../../../tests/fixtures/heif/wide-rotated.heic");
const ROTATED_300X150: &[u8] = include_bytes!("../../../tests/fixtures/heif/wide-rotated-300x150.heic");
const TONES: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones.heic");
const TONES_P3_NCLX: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-nclx.heic");
const TONES_P3_ICC: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-icc.heic");
const TONES_P3_ICC_ONLY: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-icc-only.heic");
const EVERY_FIXTURE: [&[u8]; 12] =
[QUADRANTS, ALPHA, TEN_BIT, GRID, GRID16, PPI300, ROTATED, ROTATED_300X150, TONES, TONES_P3_NCLX, TONES_P3_ICC, TONES_P3_ICC_ONLY];
/// Red, green, blue and white: the top-left, top-right, bottom-left and bottom-right quadrants.
#[cfg(feature = "heif")]
const COLORS: [[u8; 4]; 4] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255; 4]];
/// The centers of the four quadrants of a square image `side` pixels wide.
#[cfg(feature = "heif")]
fn centers(side: u32) -> [(u32, u32); 4] {
let (a, b) = (side / 4, side * 3 / 4);
[(a, a), (b, a), (a, b), (b, b)]
}
#[cfg(feature = "heif")]
fn decoded(bytes: &[u8]) -> image::RgbaImage {
let raster = load::raster_image(bytes).unwrap();
image::load_from_memory(&raster.blob.bytes).unwrap().to_rgba8()
}
#[cfg(feature = "heif")]
fn close(got: [u8; 4], want: [u8; 4], tolerance: u8) -> bool {
got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= tolerance)
}
#[cfg(feature = "heif")]
fn assert_quadrants(img: &image::RgbaImage, side: u32, what: &str) {
assert_eq!(img.dimensions(), (side, side), "{what}");
for ((x, y), want) in centers(side).into_iter().zip(COLORS) {
let got = img.get_pixel(x, y).0;
assert!(close(got, want, 8), "{what} at ({x}, {y}): {got:?} != {want:?}");
}
}
#[cfg(feature = "heif")]
#[test]
fn quadrants_open_and_place_as_their_pixels() {
assert_quadrants(&decoded(QUADRANTS), 32, "quadrants.heic");
for name in ["photo.heic", "photo.heif", "unknown.bin"] {
let mut s = Session::new();
let encoded = vectorcraft_format::base64_encode(QUADRANTS);
let opened = s.execute("document.open", &json!({"name": name, "dataBase64": encoded})).unwrap();
assert_eq!(opened["format"], "heif", "{name}");
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(32.0, 32.0));
let blob = s.doc().unwrap().doc.images.values().next().unwrap();
assert_quadrants(&image::load_from_memory(&blob.bytes).unwrap().to_rgba8(), 32, name);
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let placed = s.execute("file.place", &json!({"name": name, "dataBase64": encoded})).unwrap();
assert_eq!(placed["format"], "heif", "{name}");
}
assert!(load::texture_image(QUADRANTS).is_ok());
// The general brand alone, without `heic`.
let mut general = QUADRANTS.to_vec();
assert_eq!(&QUADRANTS[8..28], b"heic\0\0\0\0mif1heicmiaf");
general[8..12].copy_from_slice(b"mif1");
general[20..24].copy_from_slice(b"miaf");
assert_quadrants(&decoded(&general), 32, "mif1 only");
}
#[test]
fn signatures_and_every_extension() {
for bytes in EVERY_FIXTURE {
assert!(is_heif(bytes));
assert_eq!(load::detect("unknown.bin", bytes).map(|f| f.id), Some("heif"));
}
for ext in ["heic", "heif"] {
assert_eq!(load::detect(&format!("a.{ext}"), b"invalid").map(|f| f.id), Some("heif"));
assert!(OPEN_EXTS.contains(&ext) && PLACE_EXTS.contains(&ext) && UNASSOCIATED_EXTS.contains(&ext), "{ext}");
}
// A brand only among the compatible ones counts, and so do the general brands alone.
assert!(is_heif(b"\0\0\0\x14ftypabcd\0\0\0\0heic"));
assert!(is_heif(b"\0\0\0\x18ftypmif1\0\0\0\0mif1heix"));
assert!(is_heif(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc"));
assert!(is_heif(b"\0\0\0\x18ftypmif1\0\0\0\0mif1miaf"));
// A 64-bit box size (ISO/IEC 14496-12 4.2).
assert!(is_heif(b"\0\0\0\x01ftyp\0\0\0\0\0\0\0\x18heic\0\0\0\0"));
let mut extended = b"\0\0\0\x01ftyp\0\0\0\0\0\0\0\x18heic\0\0\0\0".to_vec();
extended.extend_from_slice(&QUADRANTS[QUADRANTS.windows(4).position(|w| w == b"meta").unwrap() - 4..]);
assert_eq!(load::detect("unknown.bin", &extended).map(|f| f.id), Some("heif"));
// AVIF, with its brand major or compatible, and brands heic-rs doesn't read.
assert!(!is_heif(b"\0\0\0\x18ftypavif\0\0\0\0mif1miaf"));
assert!(!is_heif(b"\0\0\0\x1cftypmif1\0\0\0\0mif1avifmiaf"));
assert!(!is_heif(b"\0\0\0\x1cftypheic\0\0\0\0mif1heicavis"));
assert!(!is_heif(b"\0\0\0\x18ftyphevx\0\0\0\0hevxiso8"));
assert!(!is_heif(b"\0\0\0\x18ftypisom\0\0\0\0isomavc1"));
// A box shorter than its brands, longer than the file, or of another type.
assert!(!is_heif(b"\0\0\0\x0cftypheic"));
assert!(!is_heif(b"\0\0\0\x40ftypheic\0\0\0\0mif1"));
assert!(!is_heif(b"\0\0\0\x14styphei\0\0\0\0\0heic"));
assert!(!is_heif(b""));
}
#[cfg(feature = "heif")]
#[test]
fn alpha_10_bit_and_grid_images_keep_their_pixels() {
let alpha = decoded(ALPHA);
assert_eq!(alpha.dimensions(), (32, 32));
for ((x, y), want) in centers(32).into_iter().zip(COLORS) {
let got = alpha.get_pixel(x, y).0;
// The left half is opaque; the right half is transparent.
if x < 16 {
assert!(close(got, want, 8), "({x}, {y}): {got:?} != {want:?}");
} else {
assert!(got[3] <= 8, "({x}, {y}): {got:?}");
}
}
assert_quadrants(&decoded(TEN_BIT), 32, "10-bit");
// Four 64 × 64 tiles, one for each quadrant.
assert_quadrants(&decoded(GRID), 128, "2 × 2 grid");
}
#[cfg(feature = "heif")]
#[test]
fn a_grid_of_cropped_tiles_is_refused() {
// 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each coded 64 × 64 and cropped back
// with its own clap, which heic-rs 0.1.4 leaves out when it composes the grid.
let error = load::raster_image(GRID16).err().unwrap().to_string();
assert!(error.contains("kind of image that can't be read"), "{error}");
}
#[cfg(feature = "heif")]
#[test]
fn a_grid_without_its_own_color_box_uses_its_tiles() {
// The grid item's associations (ipma) list ispe (1), pixi (5) and colr (3); the tiles carry
// the same colr. Pointing the grid's third association at its ispe leaves the grid without one.
let mut tiles_only = GRID.to_vec();
assert_eq!(&GRID[565..571], &[0, 1, 3, 1, 0x85, 3], "item 1 with three associations, the last colr");
tiles_only[570] = 1;
assert_quadrants(&decoded(&tiles_only), 128, "colr on the tiles only");
}
#[cfg(feature = "heif")]
#[test]
fn the_containers_rotation_is_applied() {
// A 32 × 16 image, red then blue, stored with a quarter turn clockwise.
let img = decoded(ROTATED);
assert_eq!(img.dimensions(), (16, 32));
assert!(close(img.get_pixel(8, 4).0, [255, 0, 0, 255], 8), "{:?}", img.get_pixel(8, 4));
assert!(close(img.get_pixel(8, 27).0, [0, 0, 255, 255], 8), "{:?}", img.get_pixel(8, 27));
let mut s = Session::new();
s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(ROTATED)})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(16.0, 32.0));
}
#[cfg(feature = "heif")]
#[test]
fn the_containers_mirror_and_crop_are_applied() {
// The rotated image's irot (property 6) turned to no rotation, and to an imir of the same
// size: mirrored left to right (ISO/IEC 23008-12 6.5.12, axis 0).
assert_eq!(&ROTATED[384..393], b"\0\0\0\x09irot\x03");
let mut stored = ROTATED.to_vec();
stored[392] = 0;
let mut mirrored = ROTATED.to_vec();
mirrored[388..393].copy_from_slice(b"imir\0");
let (stored, mirrored) = (decoded(&stored), decoded(&mirrored));
assert_eq!((stored.dimensions(), mirrored.dimensions()), ((32, 16), (32, 16)));
let red = [255, 0, 0, 255];
assert!(close(stored.get_pixel(8, 8).0, red, 8) && !close(stored.get_pixel(24, 8).0, red, 8), "{:?}", stored.get_pixel(8, 8));
for (x, y) in [(4, 4), (8, 8), (20, 12), (27, 3)] {
assert!(close(mirrored.get_pixel(x, y).0, stored.get_pixel(31 - x, y).0, 8), "({x}, {y})");
}
// The quadrants' clap (property 4) takes the top-left 32 × 32 of the 64 × 64 coded picture.
// Rewritten to its top-left 16 × 16: a center offset of -24 (ISO/IEC 14496-12 12.1.4) leaves
// the red quadrant alone. Its edge next to green is left out, as lossy chroma blends there.
assert_eq!(&QUADRANTS[329..337], b"\0\0\0\x28clap");
let mut cropped = QUADRANTS.to_vec();
let clap: Vec<u8> = [16u32, 1, 16, 1, (-48i32).cast_unsigned(), 2, (-48i32).cast_unsigned(), 2].iter().flat_map(|v| v.to_be_bytes()).collect();
cropped[337..369].copy_from_slice(&clap);
let cropped = decoded(&cropped);
assert_eq!(cropped.dimensions(), (16, 16));
for (x, y) in [(0, 0), (8, 8), (12, 0), (0, 12), (12, 12)] {
assert!(close(cropped.get_pixel(x, y).0, red, 8), "({x}, {y}): {:?}", cropped.get_pixel(x, y));
}
}
#[cfg(feature = "heif")]
#[test]
fn exif_resolution_sizes_the_image_and_72_ppi_without_it() {
let got = ppi::resolution(PPI300).unwrap();
assert!((got.0 - 300.0).abs() < 0.01 && (got.1 - 300.0).abs() < 0.01, "{got:?}");
assert_eq!(ppi::resolution(QUADRANTS), None);
let mut s = Session::new();
s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(PPI300)})).unwrap();
let size = s.doc().unwrap().doc.artboards[0].rect.size();
assert!((size.width - 7.68).abs() < 0.001 && (size.height - 7.68).abs() < 0.001, "{size:?}");
let blob = s.doc().unwrap().doc.images.values().next().unwrap();
let stored = ppi::resolution(&blob.bytes).unwrap();
assert!((stored.0 - 300.0).abs() < 0.01 && (stored.1 - 300.0).abs() < 0.01);
// 32 × 16 pixels at 300 ppi across and 150 down, turned a quarter: 16 × 32 pixels at 150 ppi
// across and 300 down, the same 7.68 × 7.68 pt either way.
assert_eq!(ppi::resolution(ROTATED_300X150), Some((300.0, 150.0)), "as stored");
let img = load::raster_image(ROTATED_300X150).unwrap();
assert_eq!((img.width, img.height, img.ppi), (16, 32, Some((150.0, 300.0))));
let mut s = Session::new();
s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(ROTATED_300X150)})).unwrap();
let size = s.doc().unwrap().doc.artboards[0].rect.size();
assert!((size.width - 7.68).abs() < 0.001 && (size.height - 7.68).abs() < 0.001, "{size:?}");
}
#[cfg(feature = "heif")]
#[test]
fn display_p3_images_are_converted_to_srgb() {
use vectorcraft_color::cms::{DISPLAY_P3, Intent, icc::builtin_rgb};
let p3 = builtin_rgb(DISPLAY_P3).unwrap();
let plain = decoded(TONES);
// The ICC-only file's samples are full-range BT.601, which only its HEVC stream says.
for (what, bytes) in [("nclx", TONES_P3_NCLX), ("ICC", TONES_P3_ICC), ("ICC only", TONES_P3_ICC_ONLY)] {
let converted = decoded(bytes);
for (x, y) in centers(32) {
let [r, g, b, a] = plain.get_pixel(x, y).0;
let rgb = p3.to_srgb_into(&[r, g, b].map(|v| f32::from(v) / 255.0), Intent::RelativeColorimetric).unwrap();
let [r, g, b] = rgb.map(|v| (v * 255.0).round() as u8);
let got = converted.get_pixel(x, y).0;
assert!(close(got, [r, g, b, a], 2), "{what} at ({x}, {y}): {got:?} != {:?}", [r, g, b, a]);
}
}
// The orange quadrant changes, so the conversion ran.
assert!(!close(decoded(TONES_P3_NCLX).get_pixel(8, 8).0, plain.get_pixel(8, 8).0, 4));
}
#[cfg(feature = "heif")]
#[test]
fn display_p3_named_by_the_stream_alone_is_converted_to_srgb() {
// The nclx file's item associates hvcC, colr (property 2), ispe, pixi and clap. Pointing the
// colr association at the ispe leaves the item without a color box, and its HEVC stream's
// video signal type, which names Display P3 with the sRGB curve, describes the samples.
let ipma = TONES_P3_NCLX.windows(4).position(|w| w == b"ipma").unwrap();
let at = ipma + 4 + 4 + 4 + 2 + 1 + 1;
assert_eq!(&TONES_P3_NCLX[at - 4..at + 4], &[0, 1, 5, 0x81, 2, 3, 5, 0x84], "item 1 with five associations, the second colr");
let mut stream_only = TONES_P3_NCLX.to_vec();
stream_only[at] = 3;
let want = decoded(TONES_P3_NCLX);
let got = decoded(&stream_only);
for (x, y) in centers(32) {
assert!(close(got.get_pixel(x, y).0, want.get_pixel(x, y).0, 2), "at ({x}, {y}): {:?} != {:?}", got.get_pixel(x, y), want.get_pixel(x, y));
}
}
#[cfg(feature = "heif")]
#[test]
fn two_quarter_turns_keep_the_resolution_axes() {
// The rotated image's ipma lists its irot once; a second association with the same irot turns
// it a half turn, so the EXIF resolution keeps its axes.
let ipma = ROTATED_300X150.windows(4).position(|w| w == b"ipma").unwrap();
let count = ipma + 4 + 4 + 4 + 2;
let irot = ROTATED_300X150.windows(4).position(|w| w == b"irot").unwrap();
let ipco = ROTATED_300X150.windows(4).position(|w| w == b"ipco").unwrap();
// The irot's property index: its place among the boxes of ipco, counted from 1.
let mut index = 1u8;
let mut at = ipco + 4;
while at < irot - 4 {
index += 1;
at += u32::from_be_bytes(ROTATED_300X150[at..at + 4].try_into().unwrap()) as usize;
}
assert_eq!(at, irot - 4, "irot is a box of ipco");
let n = usize::from(ROTATED_300X150[count]);
assert!(ROTATED_300X150[count + 1..count + 1 + n].contains(&(0x80 | index)), "the item's irot association, essential");
let mut twice = ROTATED_300X150.to_vec();
twice[count] += 1;
twice.insert(count + 1 + n, 0x80 | index);
// The ipma, iprp and meta boxes each grow by one byte, and the iloc offsets move with them.
let grow = |bytes: &mut Vec<u8>, name: &[u8; 4]| {
let at = bytes.windows(4).position(|w| w == name).unwrap() - 4;
let size = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) + 1;
bytes[at..at + 4].copy_from_slice(&size.to_be_bytes());
};
for name in [b"ipma", b"iprp", b"meta"] {
grow(&mut twice, name);
}
let iloc = twice.windows(4).position(|w| w == b"iloc").unwrap();
assert_eq!(&twice[iloc + 4..iloc + 8], &[0, 0, 0, 0], "iloc version 0");
let sizes = twice[iloc + 8];
assert_eq!((sizes >> 4, sizes & 0xf), (4, 4), "4-byte offsets and lengths");
assert_eq!(twice[iloc + 9] >> 4, 4, "4-byte base offsets");
let items = u16::from_be_bytes([twice[iloc + 10], twice[iloc + 11]]);
// Each item: its id, data reference index and base offset, which moves, then its extents.
let mut at = iloc + 12;
for _ in 0..items {
at += 2 + 2;
let offset = u32::from_be_bytes(twice[at..at + 4].try_into().unwrap()) + 1;
twice[at..at + 4].copy_from_slice(&offset.to_be_bytes());
at += 4;
let extents = u16::from_be_bytes([twice[at], twice[at + 1]]);
at += 2 + 8 * usize::from(extents);
}
let img = load::raster_image(&twice).unwrap();
assert_eq!((img.width, img.height, img.ppi), (32, 16, Some((300.0, 150.0))), "a half turn keeps the stored axes");
}
#[cfg(feature = "heif")]
#[test]
fn damaged_unsupported_and_oversized_files_are_refused() {
let error = |bytes: &[u8]| load::raster_image(bytes).err().unwrap().to_string();
for cut in [16, 40, QUADRANTS.len() / 2, QUADRANTS.len() - 5] {
assert!(load::raster_image(&QUADRANTS[..cut]).is_err(), "{cut}");
}
// An ftyp and nothing else, as an image sequence starts (its `moov` would follow).
assert!(error(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc").contains("kind of image that can't be read"));
assert!(error(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic\0\0\0\x08free").contains("HEIC or HEIF file"));
// The primary item's type (infe) turned from HEVC to AV1, with its hvcC kept, and with its
// hvcC turned into an av1C.
let mut av1 = QUADRANTS.to_vec();
assert_eq!((&QUADRANTS[137..141], &QUADRANTS[176..180]), (&b"hvc1"[..], &b"hvcC"[..]));
av1[137..141].copy_from_slice(b"av01");
assert!(error(&av1).contains("kind of image that can't be read"), "{}", error(&av1));
av1[176..180].copy_from_slice(b"av1C");
assert!(error(&av1).contains("kind of image that can't be read"), "{}", error(&av1));
// The image spatial extents (`ispe`) claim 60,000 × 60,000 pixels.
let mut huge = QUADRANTS.to_vec();
let at = huge.windows(4).position(|w| w == b"ispe").unwrap() + 8;
huge[at..at + 4].copy_from_slice(&60_000u32.to_be_bytes());
huge[at + 4..at + 8].copy_from_slice(&60_000u32.to_be_bytes());
assert!(error(&huge).contains("60000 × 60000 pixels is too large to draw"), "{}", error(&huge));
// A single 64 × 64 coded picture (cropped to 32 × 32) takes its RGBA output and copy,
// 64 × 64 × 8, and its planes, 64 × 64 × 9: no composed frame is counted.
assert!(super::heifread::decode(QUADRANTS, 64 * 64 * 17).is_ok());
assert!(super::heifread::decode(QUADRANTS, 64 * 64 * 17 - 1).unwrap_err().contains("too large to draw"));
// The same picture with an ispe of 32 × 32 (its crop's size): its stream still decodes
// 64 × 64, and that size is counted.
let mut small = QUADRANTS.to_vec();
let at = small.windows(4).position(|w| w == b"ispe").unwrap() + 8;
assert_eq!(&small[at..at + 8], b"\0\0\0\x40\0\0\0\x40");
small[at..at + 4].copy_from_slice(&32u32.to_be_bytes());
small[at + 4..at + 8].copy_from_slice(&32u32.to_be_bytes());
let refused = super::heifread::decode(&small, 64 * 64 * 17 - 1).map(|img| img.dimensions());
assert!(refused.as_ref().is_err_and(|e| e.contains("too large to draw")), "{refused:?}");
// A 128 × 128 grid of four 64 × 64 tiles adds its composed frame, 128 × 128 × 6.
let grid = 128 * 128 * (8 + 6) + 4 * 64 * 64 * 9;
assert!(super::heifread::decode(GRID, grid).is_ok());
assert!(super::heifread::decode(GRID, grid - 1).unwrap_err().contains("too large to draw"));
// The grid's ispe (property 1) claims 16 × 16 pixels; its grid data still composes 128 × 128,
// and that size is counted.
let mut understated = GRID.to_vec();
assert_eq!(&GRID[358..370], b"ispe\0\0\0\0\0\0\0\x80", "property 1 is the grid's 128 × 128 ispe");
understated[366..370].copy_from_slice(&16u32.to_be_bytes());
understated[370..374].copy_from_slice(&16u32.to_be_bytes());
assert!(super::heifread::decode(&understated, grid - 1).unwrap_err().contains("too large to draw"));
}
/// Appends `value` as `n` bits, most significant first.
#[cfg(feature = "heif")]
fn put(bits: &mut Vec<bool>, value: u64, n: u32) {
bits.extend((0..n).rev().map(|i| i < 64 && value >> i & 1 == 1));
}
/// Appends `value` as an unsigned Exp-Golomb code.
#[cfg(feature = "heif")]
fn put_ue(bits: &mut Vec<bool>, value: u64) {
let len = 64 - (value + 1).leading_zeros();
put(bits, 0, len - 1);
put(bits, value + 1, len);
}
/// The fields of SPS 0 of `width` × `height` 4:2:0 samples with `sub_layers` sub-layers, each
/// with its profile and level, up to its height.
#[cfg(feature = "heif")]
fn sps_head(width: u64, height: u64, sub_layers: u64) -> Vec<bool> {
sps_head_with_id(width, height, sub_layers, 0)
}
/// [`sps_head`] for SPS `id`.
#[cfg(feature = "heif")]
fn sps_head_with_id(width: u64, height: u64, sub_layers: u64, id: u64) -> Vec<bool> {
let mut bits = Vec::new();
put(&mut bits, 0, 4);
put(&mut bits, sub_layers, 3);
put(&mut bits, 1, 1);
put(&mut bits, 0, 96);
for _ in 0..sub_layers {
put(&mut bits, 0b11, 2);
}
if sub_layers > 0 {
put(&mut bits, 0, 2 * (8 - sub_layers as u32));
}
for _ in 0..sub_layers {
put(&mut bits, 0, 88 + 8);
}
put_ue(&mut bits, id);
put_ue(&mut bits, 1);
put_ue(&mut bits, width);
put_ue(&mut bits, height);
bits
}
/// An SPS NAL unit of `bits` (see [`nal`]).
#[cfg(feature = "heif")]
fn sps_nal(bits: Vec<bool>) -> Vec<u8> {
nal(33, bits)
}
/// A NAL unit of type `kind` holding `bits`, with its stop bit, and emulation prevention bytes
/// inserted as a stream carries them.
#[cfg(feature = "heif")]
fn nal(kind: u8, mut bits: Vec<bool>) -> Vec<u8> {
put(&mut bits, 1, 1);
while !bits.len().is_multiple_of(8) {
bits.push(false);
}
let rbsp: Vec<u8> = bits.chunks(8).map(|b| b.iter().fold(0u8, |v, &bit| v << 1 | u8::from(bit))).collect();
let mut nal = vec![kind << 1, 1];
let mut zeros = 0;
for b in rbsp {
if zeros >= 2 && b <= 3 {
nal.push(3);
zeros = 0;
}
zeros = if b == 0 { zeros + 1 } else { 0 };
nal.push(b);
}
nal
}
#[cfg(feature = "heif")]
fn sps(width: u64, height: u64, sub_layers: u64) -> Vec<u8> {
sps_nal(sps_head(width, height, sub_layers))
}
/// A 64 × 64 SPS that uses every optional part before the VUI's video signal type: a conformance
/// window, sub-layer ordering for two sub-layers, explicit scaling lists, PCM, a reference picture
/// set predicted from another and a long-term picture, then an Extended_SAR aspect ratio and
/// overscan information. `signal` is the full-range flag and the color description, if any.
#[cfg(feature = "heif")]
fn sps_with_vui(signal: Option<(u64, Option<[u64; 3]>)>) -> Vec<u8> {
sps_with_vui_and_id(signal, 0)
}
/// [`sps_with_vui`] for SPS `id`.
#[cfg(feature = "heif")]
fn sps_with_vui_and_id(signal: Option<(u64, Option<[u64; 3]>)>, id: u64) -> Vec<u8> {
let mut bits = sps_head_with_id(64, 64, 1, id);
put(&mut bits, 1, 1);
for offset in [0, 2, 0, 2] {
put_ue(&mut bits, offset);
}
put_ue(&mut bits, 0);
put_ue(&mut bits, 0);
// 8 bits of picture order count.
put_ue(&mut bits, 4);
put(&mut bits, 1, 1);
for _ in 0..2 * 3 {
put_ue(&mut bits, 1);
}
for value in [0, 3, 0, 3, 1, 1] {
put_ue(&mut bits, value);
}
// Scaling lists: matrix 1 of each size given explicitly, the others copied.
put(&mut bits, 0b11, 2);
for size in 0..4 {
for matrix in 0..if size == 3 { 2 } else { 6 } {
if matrix == 1 {
put(&mut bits, 1, 1);
if size > 1 {
put_ue(&mut bits, 5);
}
for _ in 0..if size == 0 { 16 } else { 64 } {
put_ue(&mut bits, 3);
}
} else {
put(&mut bits, 0, 1);
put_ue(&mut bits, 0);
}
}
}
// AMP off, SAO on, PCM with its depths, block sizes and loop filter flag.
put(&mut bits, 0b01, 2);
put(&mut bits, 1, 1);
put(&mut bits, 0x77, 8);
put_ue(&mut bits, 0);
put_ue(&mut bits, 1);
put(&mut bits, 0, 1);
// Two short-term sets: one negative and one positive picture, then a set predicted from it
// with three entries (unused with use_delta_flag, used, unused).
put_ue(&mut bits, 2);
put_ue(&mut bits, 1);
put_ue(&mut bits, 1);
for _ in 0..2 {
put_ue(&mut bits, 0);
put(&mut bits, 1, 1);
}
put(&mut bits, 1, 1);
put(&mut bits, 0, 1);
put_ue(&mut bits, 0);
put(&mut bits, 0b01100, 5);
// One long-term picture: 8 bits of picture order count and its used flag.
put(&mut bits, 1, 1);
put_ue(&mut bits, 1);
put(&mut bits, 0xab, 8);
put(&mut bits, 1, 1);
put(&mut bits, 0b11, 2);
// VUI: Extended_SAR 1:1 and overscan information.
put(&mut bits, 1, 1);
put(&mut bits, 1, 1);
put(&mut bits, 255, 8);
put(&mut bits, 0x0001_0001, 32);
put(&mut bits, 1, 1);
put(&mut bits, 0, 1);
match signal {
Some((full, description)) => {
put(&mut bits, 1, 1);
put(&mut bits, 5, 3);
put(&mut bits, full, 1);
put(&mut bits, u64::from(description.is_some()), 1);
for code in description.into_iter().flatten() {
put(&mut bits, code, 8);
}
}
None => put(&mut bits, 0, 1),
}
// No chroma location, field or display window information, timing or restrictions.
put(&mut bits, 0, 8);
sps_nal(bits)
}
#[cfg(feature = "heif")]
#[test]
fn picture_sizes_come_from_the_sequence_parameter_sets() {
use super::heifread::{picture_bytes, sps_size};
for sub_layers in [0, 1, 6] {
for (w, h) in [(32, 32), (4032, 3024), (65535, 65535)] {
let nal = sps(w, h, sub_layers);
assert!(nal.windows(3).any(|w| w == [0, 0, 3]), "the profile's zeros need emulation prevention");
assert_eq!(sps_size(&nal), Some((w, h, 1)), "{w} × {h}, {sub_layers} sub-layers");
}
}
let mut vps = sps(32, 32, 0);
vps[0] = 32 << 1;
assert_eq!(sps_size(&vps), None);
assert_eq!(sps_size(&sps(32, 32, 0)[..8]), None);
// 4:2:0 is 3 bytes a sample position in each of three sets of planes, in 64-sample blocks.
assert_eq!(picture_bytes(QUADRANTS), Some(64 * 64 * 9));
assert_eq!(picture_bytes(GRID), Some(4 * 64 * 64 * 9));
assert!(picture_bytes(ALPHA).unwrap() > 64 * 64 * 9, "the alpha image is counted");
}
#[cfg(feature = "heif")]
#[test]
fn the_color_description_comes_from_the_streams_video_signal_type() {
use super::heifread::{sps_signal, sps_size};
use heic_rs::{MatrixCoefficients, Range};
let signal = |nal: &[u8]| sps_signal(nal).map(|n| (n.primaries, n.transfer, n.matrix, n.matrix_code, n.range));
let described = sps_with_vui(Some((1, Some([12, 13, 6]))));
assert_eq!(sps_size(&described), Some((64, 64, 1)));
assert_eq!(signal(&described), Some((12, 13, MatrixCoefficients::Bt601, 6, Range::Full)));
assert_eq!(signal(&sps_with_vui(Some((0, None)))), Some((2, 2, MatrixCoefficients::Unspecified, 2, Range::Limited)));
assert_eq!(signal(&sps_with_vui(None)), None);
assert_eq!(signal(&sps(64, 64, 0)), None, "cut short before its VUI");
// The ICC-only fixture's stream: heif-enc writes full-range BT.601.
let ctx = heic_rs::context::Context::open(TONES_P3_ICC_ONLY).unwrap();
let props = ctx.props(ctx.meta.primary).unwrap();
assert!(props.nclx.is_none() && props.icc.is_some());
let sets = props.hvcc.unwrap().parameter_sets();
let nal = sets.into_iter().find(|nal| nal[0] >> 1 & 0x3f == 33).unwrap();
assert_eq!(sps_signal(nal).map(|n| (n.matrix, n.range)), Some((MatrixCoefficients::Bt601, Range::Full)));
}
#[cfg(feature = "heif")]
#[test]
fn the_slices_picture_parameter_set_picks_the_sps() {
use super::heifread::{active_sps, sps_signal};
use heic_rs::Range;
let full = sps_with_vui_and_id(Some((1, Some([1, 13, 6]))), 0);
let limited = sps_with_vui_and_id(Some((0, Some([1, 13, 1]))), 1);
let pps = |id: u64, sps: u64| {
let mut bits = Vec::new();
put_ue(&mut bits, id);
put_ue(&mut bits, sps);
nal(34, bits)
};
// first_slice_segment_in_pic_flag, no_output_of_prior_pics_flag for an IDR picture (type 19),
// slice_pic_parameter_set_id.
let slice = |kind: u8, pps: u64| {
let mut bits = vec![true];
if kind == 19 {
bits.push(false);
}
put_ue(&mut bits, pps);
nal(kind, bits)
};
let (pps0, pps3) = (pps(0, 0), pps(3, 1));
let sets = [full.as_slice(), limited.as_slice(), pps0.as_slice(), pps3.as_slice()];
let (idr3, trail0, idr5) = (slice(19, 3), slice(1, 0), slice(19, 5));
assert_eq!(active_sps(&sets, &[&idr3]), Some(limited.as_slice()));
assert_eq!(active_sps(&sets, &[&trail0]), Some(full.as_slice()));
assert_eq!(active_sps(&sets, &[&idr5]), None, "no PPS 5");
assert_eq!(active_sps(&sets, &[&pps0]), None, "no slice");
assert_eq!(active_sps(&sets, &[&idr3]).and_then(sps_signal).map(|n| (n.matrix_code, n.range)), Some((1, Range::Limited)));
}
#[cfg(not(feature = "heif"))]
#[test]
fn a_build_without_the_decoder_refuses_heif_files_with_a_message() {
for bytes in EVERY_FIXTURE {
assert_eq!(load::detect("unknown.bin", bytes).map(|f| f.id), Some("heif"));
let error = load::raster_image(bytes).err().unwrap().to_string();
assert!(error.contains("can't be opened in this build"), "{error}");
}
assert_eq!(ppi::resolution(PPI300), None);
}
+5 -1
View File
@@ -224,7 +224,11 @@ fn save_writes_svg_and_remembers_its_options() {
let native = dir.join("doc.vectorcraft").to_string_lossy().to_string();
s.execute("document.save", &json!({"path": native})).unwrap();
assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap()));
assert!(s.doc().unwrap().save_options.is_empty(), "a native save remembers no options");
assert_eq!(
Value::Object(s.doc().unwrap().save_options.clone()),
json!({"pdfCompatible":true}),
"a native save retains its PDF choice without SVG options"
);
let _ = std::fs::remove_dir_all(dir);
}
+5 -1
View File
@@ -92,8 +92,12 @@ impl Session {
/// The freeform gradient behind the Fill (`stroke` false) or Stroke proxy, its points in
/// document coordinates, and the length its spreads are fractions of there (see
/// [`Node::proxy_freeform`]).
/// [`Node::proxy_freeform`]). `None` while an object of a symbol instance is selected
/// ([`vectorcraft_doc::Selection::symbol_part`]): its points are in the symbol's coordinates.
pub fn proxy_freeform(&self, stroke: bool) -> Option<(Freeform, f64)> {
if self.active()?.selection.symbol_part.is_some() {
return None;
}
let (n, item) = self.proxy_source(stroke)?;
n.proxy_freeform(stroke, item)
}
+4 -1
View File
@@ -354,8 +354,10 @@ fn close(a: f64, b: f64) -> bool {
}
/// The members a Global Edit started on the one selected object would take, with `o`: that
/// object first, then the others in paint order.
/// object first, then the others in paint order. Refused while an object of a dynamic symbol
/// instance's art is selected.
fn matches(s: &Session, o: &Options) -> Result<Vec<(NodeId, Affine)>> {
super::edit::refuse_symbol_part(s)?;
let st = s.doc()?;
let d = &st.doc;
let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) };
@@ -423,6 +425,7 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> {
if st.global_edit.is_some() {
return Ok(());
}
super::edit::no_symbol_part(s)?;
let [id] = st.selection.objects.as_slice() else {
return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into());
};
+5 -1
View File
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Gradient Stop",
[],
None,
"{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on → {index}",
"{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on; refused while an object of a symbol instance's art is selected → {index}",
always,
select_stop
),
@@ -59,6 +59,10 @@ pub(crate) fn active_gradient(s: &Session) -> Option<GradientPaint> {
fn select_stop(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "gradient.selectStop";
// No document is needed for the default paint's gradient, so this does not use `refuse_symbol_part`.
if s.active().is_some_and(|st| st.selection.symbol_part.is_some()) {
return Err(super::brushsym::part_only());
}
let index = match p.get("index") {
None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")),
Some(Value::Null) => None,
+6 -2
View File
@@ -58,12 +58,16 @@ pub(super) fn create(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The graph `id` names (or the graph containing it), else the selected graph (or the graph
/// containing the selection).
/// containing the selection). Refused while an object of a dynamic symbol instance's art is
/// selected.
pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result<NodeId> {
let st = s.doc()?;
let start = match named_id(p)? {
Some(id) => vec![id],
None => st.selection.objects.clone(),
None => {
crate::cmd::edit::refuse_symbol_part(s)?;
st.selection.objects.clone()
}
};
for id in start {
let mut cur = Some(id);
+11 -6
View File
@@ -76,8 +76,9 @@ pub fn specs() -> Vec<CommandSpec> {
// ---------- predicates ----------
/// The Intertwine groups of the selection: selected ones, and those around a selected object.
/// None while an object of a dynamic symbol instance's art is selected.
fn selected_groups(s: &Session) -> Vec<NodeId> {
let Some(st) = s.active() else { return vec![] };
let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] };
let mut out = vec![];
for id in &st.selection.objects {
let group = st.doc.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| st.doc.node(*a).is_some_and(is_intertwine));
@@ -92,6 +93,7 @@ fn selected_groups(s: &Session) -> Vec<NodeId> {
fn has_intertwine(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
super::edit::no_symbol_part(s)?;
if selected_groups(s).is_empty() { Err("select an Intertwine group".into()) } else { Ok(()) }
}
@@ -378,11 +380,14 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
}
id
}
None => match selected_groups(s).as_slice() {
[g] => *g,
[] => return Err(bad(C, "select an Intertwine group")),
_ => return Err(bad(C, "select one Intertwine group")),
},
None => {
super::edit::refuse_symbol_part(s)?;
match selected_groups(s).as_slice() {
[g] => *g,
[] => return Err(bad(C, "select an Intertwine group")),
_ => return Err(bad(C, "select one Intertwine group")),
}
}
};
let at = match p.get("at") {
Some(_) => Some(point_param(p, "at").filter(|q| q.x.is_finite() && q.y.is_finite()).ok_or_else(|| bad(C, "at must be [x, y]"))?),
+6 -2
View File
@@ -33,9 +33,10 @@ pub(super) fn roots_param(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
selected_roots(s)
}
/// Selected nodes of a kind (the selection itself or an ancestor).
/// Selected nodes of a kind (the selection itself or an ancestor). None while an object of a
/// dynamic symbol instance's art is selected.
pub(super) fn selected_of(s: &Session, pred: fn(&Node) -> bool) -> Vec<NodeId> {
let Some(st) = s.active() else { return vec![] };
let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] };
let mut out = vec![];
for id in &st.selection.objects {
for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() {
@@ -64,16 +65,19 @@ pub(super) fn is_mesh(n: &Node) -> bool {
pub(super) fn has_blend(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
crate::cmd::edit::no_symbol_part(s)?;
if selected_of(s, is_blend).is_empty() { Err("select a blend".into()) } else { Ok(()) }
}
pub(super) fn has_envelope(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
crate::cmd::edit::no_symbol_part(s)?;
if selected_of(s, is_envelope).is_empty() { Err("select an envelope".into()) } else { Ok(()) }
}
pub(super) fn has_mesh(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
crate::cmd::edit::no_symbol_part(s)?;
if selected_of(s, is_mesh).is_empty() { Err("select a gradient mesh".into()) } else { Ok(()) }
}
+18 -3
View File
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
["Window", "Transparency"],
None,
"{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode and in pattern editing mode → {layer}",
can_edit_mask,
can_enter,
enter
),
cmd!(
@@ -79,6 +79,13 @@ pub(crate) fn can_edit_mask(s: &Session) -> std::result::Result<(), String> {
mask_editing_refused(st).map_or(Ok(()), |why| Err(why.into()))
}
/// Edit Opacity Mask: mask editing can be entered ([`can_edit_mask`]), and outside it no object of a
/// dynamic symbol instance's art is selected, since its instance's mask is not edited from it.
fn can_enter(s: &Session) -> std::result::Result<(), String> {
can_edit_mask(s)?;
if s.active().is_some_and(|st| st.doc.mask_edit.is_some()) { Ok(()) } else { super::edit::no_symbol_part(s) }
}
/// A document is open and no opacity mask is being edited: isolation mode and pattern editing mode
/// are not entered while an opacity mask is edited.
pub(crate) fn outside_mask_editing(s: &Session) -> std::result::Result<(), String> {
@@ -117,12 +124,17 @@ fn enter(s: &mut Session, p: &Value) -> Result<Value> {
if st.doc.mask_edit.is_some() {
return Err(bad(C, "already editing an opacity mask"));
}
let id = object_param(p, "id", C)?.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
let given = object_param(p, "id", C)?;
if given.is_none() {
super::edit::refuse_symbol_part(s)?;
}
let id = given.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
let layer = start(s, id)?;
Ok(json!({ "layer": layer.0 }))
}
/// The first selected object with an opacity mask.
/// The first selected object with an opacity mask. Callers refuse first while an object of a
/// dynamic symbol instance's art is selected ([`super::edit::refuse_symbol_part`]).
fn first_masked(st: &crate::DocState) -> Option<NodeId> {
st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.extras().mask.is_some()))
}
@@ -145,6 +157,9 @@ fn view_mask(s: &mut Session, p: &Value) -> Result<Value> {
st.revision += 1;
return Ok(json!({ "on": false, "id": id.map(|i| i.0) }));
}
if given.is_none() && editing.is_none() {
super::edit::refuse_symbol_part(s)?;
}
let id = given.or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
match editing {
Some(e) if e != id => return Err(bad(C, "already editing another object's opacity mask")),
@@ -242,6 +242,11 @@ pub(super) fn transform(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?;
check_switches(p, &["anchors", "releaseOutside"], "object.transform")?;
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
// An object selected in a symbol instance's art stays where the symbol puts it: the selection
// is left as it is, and the reply's warning goes to the status bar.
if p.get("ids").is_none() && crate::cmd::brushsym::selected_part(s)?.is_some() {
return Ok(json!({ "warning": crate::cmd::brushsym::part_only().to_string() }));
}
let (ids, anchors) = match ids_param(p, "ids") {
Some(v) => (outermost(&s.doc()?.doc, v), false),
None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)),
+4 -2
View File
@@ -82,9 +82,10 @@ fn groups_of(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
out
}
/// The Objects on Path groups of the selection ([`groups_of`]).
/// The Objects on Path groups of the selection ([`groups_of`]). None while an object of a dynamic
/// symbol instance's art is selected.
fn selected_groups(s: &Session) -> Vec<NodeId> {
s.active().map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default()
s.active().filter(|st| st.selection.symbol_part.is_none()).map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default()
}
/// The groups a command acts on: those of `ids` when given, otherwise those of the selection.
@@ -102,6 +103,7 @@ fn target_groups(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
fn has_objects_on_path(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
super::edit::no_symbol_part(s)?;
if selected_groups(s).is_empty() { Err("select an Objects on Path group".into()) } else { Ok(()) }
}
+28 -12
View File
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
["Window", "Transparency"],
None,
"{id?|ids?} put the mask art back above each masked object (default: the selection, or the object whose mask is being edited, which leaves mask editing)",
has_doc,
can_change_mask,
release
),
cmd!(
@@ -43,25 +43,22 @@ pub fn specs() -> Vec<CommandSpec> {
["Window", "Transparency"],
None,
"{id?|ids?} keep the mask but stop applying it",
has_doc,
can_change_mask,
|s, p| set_flags(s, p, "Disable Opacity Mask", &json!({ "disabled": true }))
),
cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags(
s,
p,
"Enable Opacity Mask",
&json!({ "disabled": false })
)),
cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", can_change_mask, |s, p| {
set_flags(s, p, "Enable Opacity Mask", &json!({ "disabled": false }))
}),
cmd!(
"transparency.unlinkOpacityMask",
"Unlink Opacity Mask",
["Window", "Transparency"],
None,
"{id?|ids?} the object moves without its mask",
has_doc,
can_change_mask,
|s, p| set_flags(s, p, "Unlink Opacity Mask", &json!({ "linked": false }))
),
cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags(
cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", can_change_mask, |s, p| set_flags(
s,
p,
"Link Opacity Mask",
@@ -73,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
[],
None,
"{id?|ids?, clip?, invert?, disabled?, linked?} change the opacity masks of `ids`, the selection, or the object whose mask is being edited",
has_doc,
can_change_mask,
|s, p| set_flags(s, p, "Opacity Mask Options", p)
),
cmd!(
@@ -152,9 +149,21 @@ pub(crate) fn percent(o: f64) -> f32 {
(o / 100.0).clamp(0.0, 1.0) as f32
}
/// The [`transparency_targets`] that have an opacity mask.
/// Refuses a command that would change [`transparency_targets`] when they are the selection, without
/// `id` or `ids` and outside mask editing, while an object of a dynamic symbol instance's art is
/// selected: the instance's transparency and mask change only when the instance is selected.
pub(crate) fn refuse_symbol_part_unnamed(s: &Session, p: &Value) -> Result<()> {
if p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_none() {
super::edit::refuse_symbol_part(s)?;
}
Ok(())
}
/// The [`transparency_targets`] that have an opacity mask, refused as
/// [`refuse_symbol_part_unnamed`] says.
fn masked_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
refuse_symbol_part_unnamed(s, p)?;
let ids: Vec<NodeId> = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.extras().mask.is_some())).collect();
if ids.is_empty() {
return Err(EngineError::Other("no target object has an opacity mask: select one or give `id`".into()));
@@ -162,6 +171,13 @@ fn masked_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
Ok(ids)
}
/// The commands that change an existing opacity mask: a document is open, and outside mask editing
/// no object of a dynamic symbol instance's art is selected ([`masked_targets`]).
fn can_change_mask(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|st| st.doc.mask_edit.is_some()) { Ok(()) } else { super::edit::no_symbol_part(s) }
}
/// Make Opacity Mask: a document is open, and one selected object, which [`make`] gives an empty
/// mask and takes into mask editing, is where mask editing can be entered
/// ([`super::maskedit::can_edit_mask`]: not in isolation mode or pattern editing mode). The
+112 -18
View File
@@ -1,5 +1,7 @@
//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel.
use std::borrow::Cow;
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind};
@@ -19,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, 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) → {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|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; 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, a solid color or `none` changes that object for this instance only, and a gradient or pattern is refused → {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)
),
@@ -28,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke",
[],
None,
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke",
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, a solid color or `none` changes that object for this instance only, and a gradient or pattern is refused",
has_doc,
|s, p| { set_paint(s, p, false) }
),
@@ -86,7 +88,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Invert",
[],
None,
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with nothing selected, invert the default → {changed}",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default → {changed}",
has_doc,
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
),
@@ -95,7 +97,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Complement",
[],
None,
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item as paint.invert does; with nothing selected, the default → {changed}",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default → {changed}",
has_doc,
|s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model())
),
@@ -172,7 +174,7 @@ impl Session {
/// new art.
pub fn proxy_paints(&self) -> (Paint, Paint) {
let show = |stroke: bool| match self.proxy_source(stroke) {
Some((n, item)) => proxy_paint(n, stroke, item),
Some((n, item)) => proxy_paint(&n, stroke, item),
None if stroke => self.paint.stroke.clone(),
None => self.paint.fill.clone(),
};
@@ -180,17 +182,22 @@ impl Session {
}
/// The object whose fill (or stroke) the proxy shows, with the Appearance panel's active item
/// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art.
pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(&Node, Option<usize>)> {
/// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. With
/// an object of a dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that
/// object as the instance draws it.
pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(Cow<'_, Node>, Option<usize>)> {
let st = self.active()?;
if let Some(part) = st.selected_symbol_part() {
return Some((Cow::Owned(part), None));
}
let first = st.doc.node(*st.selection.subjects().first()?)?;
let item = self.appearance_item();
if first.appearance.item_of_kind(item, !stroke).is_some() {
return Some((first, item));
return Some((Cow::Borrowed(first), item));
}
let mut nodes = vec![];
painted(first, true, &mut nodes);
nodes.first().map(|n| (*n, None))
nodes.first().map(|n| (Cow::Borrowed(*n), None))
}
pub(crate) fn remember_paint_now(&mut self, p: &Paint) {
@@ -257,6 +264,9 @@ impl DocState {
if self.selection.is_empty() {
return (false, false);
}
if self.selection.symbol_part.is_some() {
return (false, false);
}
let ids: std::collections::HashSet<NodeId> = self.selection.objects.iter().copied().collect();
let (mut selected, mut nodes) = (vec![], vec![]);
self.doc.walk(|n| {
@@ -275,6 +285,18 @@ impl DocState {
};
(differ(false), differ(true))
}
/// The object of a dynamic symbol instance's art selected with Direct Selection
/// ([`vectorcraft_doc::Selection::symbol_part`]), with the instance's own appearance changes
/// applied, in the symbol's coordinates. `None` without one.
pub(crate) fn selected_symbol_part(&self) -> Option<Node> {
let sp = self.selection.symbol_part.as_ref()?;
let inst = self.doc.node(sp.instance)?;
let NodeKind::SymbolInstance { symbol, .. } = &inst.kind else { return None };
let sym = self.doc.symbols.iter().find(|s| s.name == *symbol)?;
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst);
vectorcraft_doc::symbol_overrides::part(&art, &sp.path).cloned()
}
}
impl Session {
@@ -465,6 +487,26 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
s.remember_paint(&paint);
return Ok(json!({"objects": ids.len()}));
}
// An object of a dynamic symbol instance's art selected with Direct Selection: a solid color or
// none changes that object for this instance only, and anything else is refused.
if let Some((id, path)) = selected_part_target(s, p, cmd)? {
if !part_takes(&paint) {
return Err(super::brushsym::part_only());
}
super::brushsym::edit_part(s, id, &path, if fill { "Fill Color" } else { "Stroke Color" }, |o| {
if fill {
o.fill = Some(paint.clone());
} else {
o.stroke = Some(paint.clone());
}
})?;
paint_new_art(s, &paint, fill);
if focus {
s.fill_active = fill;
}
s.remember_paint(&paint);
return Ok(json!({"objects": 1}));
}
// Resolve the requested targets before mutating defaults or the active proxy.
let item = item_target(s, p, cmd)?.of_kind(s, fill);
let selected = item.targets(s, p)?;
@@ -553,15 +595,7 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
},
)?;
// Rejected edits leave the next shape's appearance and active proxy untouched.
// New art takes an unplaced gradient so that it fits the new object's own bounds.
if fill {
s.paint.fill = unplaced(&paint);
} else {
if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 {
s.paint.stroke_width = 1.0;
}
s.paint.stroke = unplaced(&paint);
}
paint_new_art(s, &paint, fill);
if focus {
s.fill_active = fill;
}
@@ -569,6 +603,39 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
Ok(json!({"objects": ids.iter().collect::<std::collections::BTreeSet<_>>().len()}))
}
/// The instance whose object is selected with Direct Selection, and that object's path
/// ([`super::brushsym::selected_part`]), when `p` names no `ids`, `id` or `item`: the paint commands
/// then change that object. A bad `target` or `aligned` is refused as it is for any other selection.
fn selected_part_target(s: &Session, p: &Value, cmd: &str) -> Result<Option<(NodeId, Vec<u32>)>> {
if p.get("ids").is_some() || p.get("id").is_some() || p.get("item").is_some() {
return Ok(None);
}
let part = super::brushsym::selected_part(s)?;
if part.is_some() {
item_target(s, p, cmd)?;
}
Ok(part)
}
/// Can an object of a dynamic symbol instance's art selected with Direct Selection take `paint`? A
/// solid color or none can; a gradient or pattern cannot.
pub(crate) fn part_takes(paint: &Paint) -> bool {
matches!(paint, Paint::Solid { .. } | Paint::None)
}
/// Make `paint` the fill or stroke of new art. New art takes an unplaced gradient so that it fits
/// the new object's own bounds, and a painted stroke on art that had none gets the 1 pt default.
fn paint_new_art(s: &mut Session, paint: &Paint, fill: bool) {
if fill {
s.paint.fill = unplaced(paint);
} else {
if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 {
s.paint.stroke_width = 1.0;
}
s.paint.stroke = unplaced(paint);
}
}
/// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial 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.
@@ -824,6 +891,32 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
/// active item of the proxy's kind is recoloured in each selected object's own stack instead.
fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
let stroke = stroke_param(s, p, cmd)?;
// An object of a dynamic symbol instance's art selected with Direct Selection: the solid color
// it shows is recolored for this instance only. A gradient or pattern is refused, and so is type
// whose characters differ in that paint, since one change holds one paint.
if let Some((id, path)) = selected_part_target(s, p, cmd)? {
let part = s.doc()?.selected_symbol_part();
let mut paint = part.as_ref().map_or(Paint::None, |n| proxy_paint(n, stroke, None));
let runs_differ = match part.as_ref().map(|n| &n.kind) {
Some(NodeKind::Text(t)) => t.runs.iter().any(|r| !same_in_proxy(if stroke { &r.style.stroke } else { &r.style.fill }, &paint)),
_ => false,
};
if runs_differ || !part_takes(&paint) {
return Err(super::brushsym::part_only());
}
let changed = super::colorcmds::map_paint(&mut paint, f);
if changed {
super::brushsym::edit_part(s, id, &path, label, |o| {
if stroke {
o.stroke = Some(paint.clone());
} else {
o.fill = Some(paint.clone());
}
})?;
s.remember_paint(&paint);
}
return Ok(json!({ "changed": changed as usize }));
}
let ids = match super::given_objects(s, p, cmd)? {
Some(ids) => ids,
None => super::edit::selected_roots(s)?,
@@ -863,6 +956,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(
fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "transparency.set";
super::opacitymask::refuse_symbol_part_unnamed(s, p)?;
let ids = super::opacitymask::transparency_targets(s, p)?;
if ids.is_empty() {
return Err(bad(C, "select objects or give ids"));
+4 -2
View File
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Anchors",
[],
None,
"{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected)",
"{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected); refused while an object of a symbol instance's art is selected",
has_selection,
move_anchors
),
@@ -226,6 +226,8 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?);
check_switches(p, &["patterns"], "path.moveAnchors")?;
// An object selected in a symbol instance's art stays where the symbol puts it.
super::edit::refuse_symbol_part(s)?;
// Objects selected whole move as objects, and their pattern tiles move with them when
// `patterns` (default: General › Transform Pattern Tiles) is on.
let whole = s.doc()?.selection.whole();
@@ -341,7 +343,7 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The compound path enclosing `id`, or `id` itself if it is not inside a compound path.
pub(super) fn compound_or_self(d: &Document, id: NodeId) -> NodeId {
pub(crate) fn compound_or_self(d: &Document, id: NodeId) -> NodeId {
d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id)
}
+4 -1
View File
@@ -189,8 +189,10 @@ fn has_selection_or_ids(s: &Session) -> std::result::Result<(), String> {
has_doc(s)
}
/// The repeats of the selection: selected ones, and those around a selected object. None while
/// an object of a dynamic symbol instance's art is selected.
fn selected_repeats(s: &Session) -> Vec<NodeId> {
let Some(st) = s.active() else { return vec![] };
let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] };
let mut out = vec![];
for id in &st.selection.objects {
for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() {
@@ -207,6 +209,7 @@ fn selected_repeats(s: &Session) -> Vec<NodeId> {
fn has_repeat(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
super::edit::no_symbol_part(s)?;
if selected_repeats(s).is_empty() { Err("select a repeat".into()) } else { Ok(()) }
}
+1 -1
View File
@@ -428,7 +428,7 @@ fn rulers(s: &mut Session, p: &Value) -> Result<Value> {
/// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`):
/// the plane and depth of the first one and the plane-space correction that lands the edge of the
/// joint bounds of the objects there nearer a gridline on it.
/// joint bounds of the objects on that plane, each measured at its own depth, nearer a gridline on it.
pub(crate) fn snap_fix(doc: &Document, ids: &[NodeId], p: &Value, (from, to): (Point, Point)) -> Option<(Plane, f64, Vec2)> {
let g = grid_of(doc);
if !bool_or(p, "snap", g.snap) {
+6 -2
View File
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
has_doc,
place
),
@@ -534,7 +534,7 @@ impl Session {
if st.doc.node(old) != read.job.replaced.as_ref() {
return Err(bad(PLACE, "replace: the object to replace was changed or deleted"));
}
if st.selection.objects != [old] {
if st.selection.objects != [old] || st.selection.symbol_part.is_some() {
self.execute("select.set", &json!({ "ids": [old.0] }))?;
}
}
@@ -565,6 +565,10 @@ fn prepare(s: &Session, p: &Value) -> Result<PlaceJob> {
if corners.iter().flatten().any(|c| !(c.x.abs() <= MAX_COORD && c.y.abs() <= MAX_COORD)) {
return Err(bad(PLACE, "at/rect lie outside the canvas"));
}
// An object selected in a symbol instance's art can't be swapped, and its instance stays.
if replace {
super::edit::refuse_symbol_part(s)?;
}
let st = s.doc()?;
let old = match (replace, &st.selection.objects[..]) {
(false, _) => None,
+9 -1
View File
@@ -139,7 +139,15 @@ impl FilterRun {
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
let may = m.permissions();
let st = s.doc()?;
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
// While an object of a dynamic symbol instance's art is selected, the selection is refused,
// as `selected_roots` refuses it.
let selected = match ids_param(p, "ids") {
Some(ids) => ids,
None => {
super::edit::refuse_symbol_part(s)?;
st.selection.objects.clone()
}
};
// Without `doc.read` a filter is given no objects: it can only add new ones.
let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() };
let parent = st.insertion_parent();
+9
View File
@@ -150,11 +150,19 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("scaleCorners", "General", "Options", "Scale Corners", bool),
p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool),
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
p!("ctrlWheelScrollsSideways", "General", "Options", "Scroll Sideways with Cmd/Ctrl + Wheel", bool),
p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool),
p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool),
p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool),
// Selection & Anchor Display
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
p!(
"marqueeSelection",
"Selection & Anchor Display",
"Selection",
"Marquee Selection",
choice(&[("touching", "Touching"), ("enclosed", "Fully Enclosed"), ("directional", "Drag Direction")])
),
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
p!("snapToPointTolerance", "Selection & Anchor Display", "Selection", "Snap to Point", num(1.0, 8.0, "px")),
p!("ctrlClickSelectsBehind", "Selection & Anchor Display", "Selection", "Command Click to Select Objects Behind", bool),
@@ -207,6 +215,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool),
p!("highlightSubstitutedFonts", "Type", "Options", "Highlight Substituted Fonts", bool),
p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool),
p!("typedLists", "Type", "Options", "Start Lists From Typed Markers", bool),
p!("fontsFolder", "Type", "Options", "Additional Fonts Folder", text),
// Units
p!("unitsGeneral", "Units", "", "General", choice(UNITS)),
+5 -2
View File
@@ -9,7 +9,7 @@ use std::collections::{BTreeSet, HashMap};
use serde_json::{Value, json};
use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster, same_ink};
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::swatches::{node_colors, node_paints};
use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::colorcmds::{Recolor, Scope};
@@ -99,7 +99,7 @@ fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet<NodeId>>) ->
read(n, only, &mut nodes);
}
for m in nodes {
for p in node_paints(m) {
for p in node_paints(m).chain(override_paints(m)) {
match p {
Paint::Solid { color, swatch, .. } => count(color, swatch.as_deref()),
Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| count(&s.color, s.swatch.as_deref())),
@@ -522,6 +522,9 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
};
let row_swatches: Vec<Option<Swatch>> =
rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect();
// While an object of a dynamic symbol instance's art is selected, the selection is refused, as
// `selected_roots` refuses it.
super::edit::refuse_symbol_part(s)?;
let ids = s.doc()?.selection.objects.clone();
let group = text_param(p, "group", C)?;
if ids.is_empty() && group.is_none() {
+13 -1
View File
@@ -565,6 +565,10 @@ struct Meta {
path: Option<String>,
/// The format Save writes for it ([`SAVE_FORMATS`]).
format: String,
/// A document's own native PDF choice before the recovery copy omits its PDF. None keeps
/// inheritance from the current application preference. Older copies did not record a choice.
#[serde(skip_serializing_if = "Option::is_none")]
native_pdf_compatible: Option<bool>,
/// When the copy was written (Unix seconds; none without a clock).
saved: Option<i64>,
/// The file it was read from when that import left things out of the document, and what
@@ -821,7 +825,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
mode: SaveMode::Copy,
path: st.path.clone(),
format: native,
options: Default::default(),
options: serde_json::Map::from_iter([("pdfCompatible".into(), json!(false))]),
name: format!("{file}{DOC_EXT}"),
folder: None,
modified: None,
@@ -832,6 +836,11 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
title: title.clone(),
path: st.path.clone(),
format: st.format.to_string(),
native_pdf_compatible: if matches!(st.format, "vectorcraft" | "template") {
st.save_options.get("pdfCompatible").and_then(Value::as_bool)
} else {
None
},
saved,
imported_from: st.imported_from.clone(),
import_losses: st.import_losses.clone(),
@@ -1060,6 +1069,9 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
st.imported_from = meta.imported_from.clone();
st.import_losses = meta.import_losses.clone();
st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft");
if let Some(pdf) = meta.native_pdf_compatible {
st.save_options.insert("pdfCompatible".into(), json!(pdf));
}
st.mark_unsaved();
// The copy stays until the document is saved or closed.
st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() });
+18 -7
View File
@@ -343,10 +343,15 @@ impl Session {
/// The stroke the Stroke panel, the Control bar and the Properties panel show, picked as the
/// Stroke proxy picks its paint: the Appearance panel's active item when it is a stroke, else
/// the first selected object's (a group's first painted object's) top stroke, type's first
/// run's character stroke; with nothing selected, the next object drawn's
/// ([`Session::new_art_stroke`]). None without a document or the selection's stroke.
/// run's character stroke; with an object of a dynamic symbol instance selected
/// ([`DocState::selected_symbol_part`]), that object's as the instance draws it; with nothing
/// selected, the next object drawn's ([`Session::new_art_stroke`]). None without a document or
/// the selection's stroke.
pub fn shown_stroke(&self) -> Option<StrokeLayer> {
let st = self.active()?;
if let Some(part) = st.selected_symbol_part() {
return node_stroke(&part);
}
let Some(first) = st.selection.objects.first() else { return Some(self.new_art_stroke()) };
let first = st.doc.node(*first)?;
if let Some(i) = first.appearance.item_of_kind(self.appearance_item(), false) {
@@ -360,13 +365,19 @@ impl Session {
impl DocState {
/// Which Stroke panel values the selected objects' strokes (type: every run's) don't share,
/// and whether Align Stroke applies to them (no open path among them). One walk of the
/// selection, so callers that ask every frame cache it by revision.
/// and whether Align Stroke applies to them (no open path among them). With an object of a
/// dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that object alone is
/// read. One walk of the selection, so callers that ask every frame cache it by revision.
pub fn stroke_mixed(&self) -> StrokeMixed {
let part = self.selected_symbol_part();
let mut nodes = vec![];
for id in &self.selection.objects {
if let Some(n) = self.doc.node(*id) {
painted(n, true, &mut nodes);
if let Some(part) = &part {
painted(part, true, &mut nodes);
} else {
for id in &self.selection.objects {
if let Some(n) = self.doc.node(*id) {
painted(n, true, &mut nodes);
}
}
}
let mut strokes = vec![];
+3
View File
@@ -156,6 +156,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let (apply, add) = (switch_or(p, "apply", false, C)?, switch_or(p, "add", false, C)?);
if p.get("ids").is_some_and(|v| !v.is_null()) {
checked_ids_param(s, p, "ids", C)?;
} else if apply {
// A selection that can't take a style is refused before any style is added.
super::appearance::appearance_targets(s, &json!({}))?;
}
let mut names = names_param(p, C)?;
names.extend(text_param(p, "name", C)?.map(str::to_string));
+4
View File
@@ -78,6 +78,10 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result<Value> {
(name, xf, ids.first().copied())
}
None => {
// Refused while an object of a dynamic instance's art is selected, unless `name` is given.
if str_param(p, "name").is_none() && st.selection.symbol_part.is_some() {
return Err(super::brushsym::part_only());
}
let picked = if str_param(p, "name").is_some() { vec![] } else { selected_instances(st) };
let symbols: Vec<(String, Affine)> = picked.iter().filter_map(|id| instance_of(&st.doc, *id)).collect();
match symbols.first() {
+1 -1
View File
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Bullets and Numbering",
[],
None,
"{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), or \"* \", \"# \", \"> \" or \"+ \" (a bulleted list with the filled dot), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}",
"{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), \"* \" (a bulleted list with the filled dot), \"+ \" (the hollow circle), \"# \" (the filled square) or \"> \" (the dash), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}",
has_doc,
start_list
),
+61 -3
View File
@@ -168,7 +168,17 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
}
}
let optical = if live.len() >= 2 { optical_margin(before, doc, &live) } else { None };
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
let vertical = if live.len() >= 2 { thread_setting(before, doc, &live, |t| t.vertical) } else { None };
// The story is measured in each frame with the settings the frame takes.
let frames: Vec<TextObject> = live
.iter()
.filter_map(|id| text_of(doc, *id).cloned())
.map(|mut t| {
t.optical_margin = optical.unwrap_or(t.optical_margin);
t.vertical = vertical.unwrap_or(t.vertical);
t
})
.collect();
let refs: Vec<&TextObject> = frames.iter().collect();
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, &paras);
for (id, part) in live.iter().zip(parts) {
@@ -177,11 +187,13 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
next.runs = part.runs;
next.set_paragraph_styles(part.paras);
let optical = optical.unwrap_or(t.optical_margin);
let vertical = vertical.unwrap_or(t.vertical);
let continues = part.continues;
if next.runs != t.runs
|| next.para != t.para
|| next.paras != t.paras
|| optical != t.optical_margin
|| vertical != t.vertical
|| part.list_count != t.list_count
|| continues != t.first_para_continues
{
@@ -189,6 +201,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
t.para = next.para;
t.paras = next.paras;
t.optical_margin = optical;
t.vertical = vertical;
t.list_count = part.list_count;
t.first_para_continues = continues;
refresh_bounds(t);
@@ -235,11 +248,37 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
/// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes:
/// that of the first frame whose setting changed between `before` and `doc`, else the first frame's.
fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option<bool> {
let now = |id: &NodeId| text_of(doc, *id).map(|t| t.optical_margin);
let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.optical_margin) != now(id)));
thread_setting(before, doc, live, |t| t.optical_margin)
}
/// The value of the frame setting `get` that every frame of a thread with the frames `live` takes:
/// that of the first frame whose value changed between `before` and `doc`, or the first frame's value
/// when none changed.
fn thread_setting<T: PartialEq>(before: &Document, doc: &Document, live: &[NodeId], get: impl Fn(&TextObject) -> T) -> Option<T> {
let now = |id: &NodeId| text_of(doc, *id).map(&get);
let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(get(t)) != now(id)));
changed.or(live.first()).and_then(now)
}
/// Turn Auto Size off for the frames of each thread of two or more frames, as [`reflow`] does,
/// without re-flowing the story: for a document restored from a file as it was saved, so a later
/// change to a frame's settings keeps the frame's size.
pub(crate) fn keep_frame_sizes(doc: &mut Document) {
for thread in doc.text_threads.clone() {
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
if live.len() < 2 {
continue;
}
for id in live {
if text_of(doc, id).is_some_and(|t| t.area.fit == vectorcraft_doc::AreaFit::AutoHeight)
&& let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind)
{
t.area.fit = vectorcraft_doc::AreaFit::None;
}
}
}
}
/// Give every frame of each thread the first frame's Optical Margin Alignment setting, without
/// re-flowing the story: for a document restored from a file as it was saved.
pub(crate) fn share_optical_margin(doc: &mut Document) {
@@ -257,6 +296,25 @@ pub(crate) fn share_optical_margin(doc: &mut Document) {
}
}
/// Give every frame of each thread the first frame's writing direction, and re-flow each thread in
/// which that changes a frame: for a document restored from a file as it was saved.
pub(crate) fn share_orientation(doc: &mut Document) {
let before = doc.clone();
for thread in doc.text_threads.clone() {
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
let Some(vertical) = thread_setting(doc, doc, &live, |t| t.vertical) else { continue };
// Only the frames whose direction changes are copied, so only their threads re-flow.
let turn: Vec<NodeId> = live.into_iter().filter(|id| text_of(doc, *id).is_some_and(|t| t.vertical != vertical)).collect();
for id in turn {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.vertical = vertical;
refresh_bounds(t);
}
}
}
reflow(&before, doc);
}
/// Mark the frames of each thread whose first paragraph continues from the frame before
/// ([`TextObject::first_para_continues`]), without re-flowing the story: for a document restored
/// from a file as it was saved.
+171
View File
@@ -1090,6 +1090,177 @@ fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() {
}
}
/// The writing direction of each of the frames `ids` (true: vertical).
fn vertical<const N: usize>(s: &Session, ids: [u64; N]) -> [bool; N] {
ids.map(|id| {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
t.vertical
})
}
/// Whether each frame of the thread `ids` holds the part of the story that its layout, in its
/// writing direction, has room for: the parts that [`vectorcraft_text::thread::distribute`]
/// gives the frames as they are now.
fn flows_as_laid_out(s: &Session, ids: &[u64]) -> bool {
let d = &s.doc().unwrap().doc;
let frames: Vec<&TextObject> = ids.iter().map(|id| text_of(d, NodeId(*id)).unwrap()).collect();
let story = vectorcraft_text::thread::story(&frames);
let paras = vectorcraft_text::thread::story_paras(&frames, &[]);
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &frames, &story, &paras);
let texts = |runs: &[TextRun]| runs.iter().map(|r| r.text.as_str()).collect::<String>();
parts.iter().zip(&frames).all(|(part, f)| texts(&part.runs) == f.plain_text())
}
/// Type Orientation is a setting of the whole thread: Type › Type Orientation in any frame sets
/// it in every frame of the thread, in one undo step, and the story flows through the frames in
/// that direction. Typing keeps it, and frames taken out of the thread keep it.
#[test]
fn type_orientation_is_set_for_every_frame_of_a_thread() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let r = s.execute("type.orientation.vertical", &json!({})).unwrap();
assert_eq!(r, json!({"vertical": true, "ids": [a, b]}));
assert_eq!(vertical(&s, [a, b]), [true, true], "the second frame is vertical too");
assert!(flows_as_laid_out(&s, &[a, b]));
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4));
// Set back from the second frame, both are horizontal, and one undo makes both vertical.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
assert_eq!(s.execute("type.orientation.horizontal", &json!({})).unwrap()["ids"], json!([b, a]));
assert_eq!(vertical(&s, [a, b]), [false, false]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(vertical(&s, [a, b]), [true, true]);
// Typing in the first frame re-flows the story and keeps the direction.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap();
assert_eq!(vertical(&s, [a, b]), [true, true]);
assert!(flows_as_laid_out(&s, &[a, b]));
// Any edit that sets one frame sets the thread.
s.edit("Set", |d, _| {
let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") };
t.vertical = false;
refresh_bounds(t);
Ok(())
})
.unwrap();
assert_eq!(vertical(&s, [a, b]), [false, false]);
assert!(flows_as_laid_out(&s, &[a, b]));
s.execute("edit.undo", &json!({})).unwrap();
// Released or unthreaded, each frame keeps it.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert_eq!(vertical(&s, [a, b]), [true, true]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("text.thread.remove", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!(vertical(&s, [a, b]), [true, true]);
}
/// Threading type gives the joined thread the writing direction of its first frame, and the
/// story flows through every frame in that direction.
#[test]
fn a_thread_takes_its_first_frames_type_orientation() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let area = json!({"width": 150, "height": 60});
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": area, "vertical": true})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 60, "height": 200})).unwrap());
let c = new_id(s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert_eq!(vertical(&s, [a, b, c]), [true; 3]);
assert!(!text(&s, b).is_empty() && !text(&s, c).is_empty());
assert!(flows_as_laid_out(&s, &[a, b, c]), "the middle frame holds what fits it as vertical type");
// From `c`'s out port into horizontal area type `d`: `d` joins the thread and becomes vertical.
let d = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap());
s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap();
assert_eq!(threads(&s), [[a, b, c, d]]);
assert_eq!(vertical(&s, [a, b, c, d]), [true; 4]);
assert!(flows_as_laid_out(&s, &[a, b, c, d]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(vertical(&s, [d]), [false], "undo gives `d` its own direction back");
}
/// A file saved with the frames of one thread in different writing directions opens with the
/// first frame's direction in every frame, and the story flows through them in it: a native
/// file, and an EPS file restored from the document it carries.
#[test]
fn a_thread_saved_with_differing_orientations_opens_with_the_first_frames() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] {
for (id, on) in [(a, first), (b, !first)] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.vertical = on;
refresh_bounds(t);
}
let bytes = if name.ends_with(".eps") {
let r = s.execute("document.exportEps", &json!({})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
} else {
vectorcraft_format::save(&s.doc().unwrap().doc, false)
};
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], name.ends_with(".eps"), "{r}");
assert_eq!(threads(&s), [[a, b]], "{name}");
assert_eq!(vertical(&s, [a, b]), [first, first], "{name}, first frame vertical: {first}");
assert!(flows_as_laid_out(&s, &[a, b]), "{name}, first frame vertical: {first}");
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4), "{name}");
}
}
/// A thread restored from an EPS file whose frames share one writing direction keeps its text
/// in its frames as saved.
#[test]
fn a_restored_thread_in_one_direction_keeps_its_text_as_saved() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
let story = TEXT.repeat(4);
for (id, part) in [(a, &story[..10]), (b, &story[10..])] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.runs = vec![TextRun::new(part.to_string(), t.runs[0].style.clone())];
refresh_bounds(t);
}
let r = s.execute("document.exportEps", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let r = s.execute("document.open", &json!({"name": "saved.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], true, "{r}");
assert_eq!((text(&s, a), text(&s, b)), (story[..10].to_string(), story[10..].to_string()));
}
/// A threaded frame restored from a file with Auto Size on keeps its size when it turns from
/// vertical to horizontal: as the file opens, when the first frame is horizontal, and with Type
/// Orientation, when the whole thread is vertical.
#[test]
fn a_restored_threaded_frame_with_auto_size_keeps_its_size_when_it_turns_horizontal() {
for first_vertical in [false, true] {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
for (id, on) in [(a, first_vertical), (b, true)] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.vertical = on;
if id == b {
t.area.fit = vectorcraft_doc::AreaFit::AutoHeight;
}
refresh_bounds(t);
}
let saved = frame_box(&s, b);
let r = s.execute("document.exportEps", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let r = s.execute("document.open", &json!({"name": "saved.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], true, "{r}");
if first_vertical {
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("type.orientation.horizontal", &json!({})).unwrap();
}
assert_eq!(vertical(&s, [a, b]), [false, false], "first frame vertical: {first_vertical}");
assert_eq!(frame_box(&s, b), saved, "first frame vertical: {first_vertical}");
assert!(flows_as_laid_out(&s, &[a, b]), "first frame vertical: {first_vertical}");
}
}
/// One count runs through a thread: the second frame's numbered paragraphs go on from the
/// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a
/// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks
+13 -5
View File
@@ -16,15 +16,21 @@ use super::{
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("type.orientation.vertical", "Vertical", ["Type", "Orientation"], None, "{ids?|id?} set vertical writing", has_selection, |s, p| {
orientation(s, p, true)
}),
cmd!(
"type.orientation.vertical",
"Vertical",
["Type", "Orientation"],
None,
"{ids?|id?} set vertical writing for the selected type and every frame of its threads → {vertical, ids}",
has_selection,
|s, p| orientation(s, p, true)
),
cmd!(
"type.orientation.horizontal",
"Horizontal",
["Type", "Orientation"],
None,
"{ids?|id?} set horizontal writing",
"{ids?|id?} set horizontal writing for the selected type and every frame of its threads → {vertical, ids}",
has_selection,
|s, p| orientation(s, p, false)
),
@@ -139,8 +145,10 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Type › Type Orientation: set vertical or horizontal writing on every selected text object and
/// every frame of their threads, in one undo step.
fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
let ids = text_targets(s, p, "Text Orientation")?;
let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Text Orientation")?);
s.edit("Text Orientation", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
+22 -1
View File
@@ -181,6 +181,21 @@ fn not_a_target(doc: &vectorcraft_doc::Document, id: NodeId) -> String {
}
}
/// Refuse to bind the text variable `name` to type that already has a different text variable:
/// a type object holds one text variable. The error names the one it has (the first defined, in
/// a document that holds two). An object already bound to `name` is not refused, and variables
/// of other kinds are not counted.
fn check_one_text_variable(doc: &vectorcraft_doc::Document, ids: &[NodeId], name: &str, cmd: &str) -> Result<()> {
for id in ids {
let Some(bound) = doc.variables.bindings.get(id).filter(|b| !b.contains(name)) else { continue };
let held = doc.variables.variables.iter().find(|v| v.kind == VariableKind::Text && bound.contains(&v.name));
if let Some(v) = held {
return Err(bad(cmd, format!("node {} already has the text variable `{}`", id.0, v.name)));
}
}
Ok(())
}
fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result<DataValue> {
match kind {
VariableKind::Text => v.as_str().map(|t| DataValue::Text(t.to_string())),
@@ -284,6 +299,9 @@ fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result<Value
name = format!("{base} {n}");
n += 1;
}
if kind == VariableKind::Text {
check_one_text_variable(doc, &ids, &name, c)?;
}
s.edit("Make Dynamic", |d, _| {
d.variables.variables.push(Variable { name: name.clone(), kind });
for id in &ids {
@@ -345,6 +363,9 @@ fn bind(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let kind = doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?;
check_bindable(doc, &ids, kind, C)?;
if kind == VariableKind::Text {
check_one_text_variable(doc, &ids, &name, C)?;
}
// Binding again is not a second binding, and says so rather than counting as a change.
let already: Vec<u64> = ids.iter().filter(|id| doc.variables.objects_of(&name).contains(id)).map(|id| id.0).collect();
let n = edit_variables(s, "Bind Variable", |v| Ok(ids.iter().filter(|id| v.bind(**id, &name)).count()))?;
@@ -738,7 +759,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Bind Variable",
["Window", "Variables"],
None,
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}",
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, locked art is refused. A type object holds one text variable: binding it to another text variable is refused, and the error names the one it has. Variables of other kinds can share an object, and binding an object to a variable it already has changes nothing → {bound, already}",
has_doc,
bind
),
+5
View File
@@ -73,6 +73,11 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) {
}
}
/// Journaled commands that write a file and change nothing in the document: with an object of a
/// dynamic symbol instance's art selected, they read the instance as queries do
/// ([`crate::Session::execute`]).
pub(crate) const JOURNALED_READS: &[&str] = &["css.export"];
/// Commands that act on the selection and take `ids` by selecting them first (`select.set`, with
/// its checks), then running as on the selection: the result and the selection afterwards are the
/// menu command's (#2130).
+4
View File
@@ -164,6 +164,10 @@ pub struct Session {
/// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest
/// history step takes the colors the batch finally left.
pub(crate) batch_paint_docs: Vec<u64>,
/// While a command that changes nothing in the document runs (a query, or one of
/// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol
/// instance's art.
pub(crate) reading: bool,
/// While [`Session::place_read`] runs `file.place`: the file read for it, which the command
/// places instead of reading the file; `None` otherwise.
pub(crate) place_hand_off: Option<cmd::place::PlaceRead>,
+10
View File
@@ -54,6 +54,8 @@ pub struct Prefs {
/// Scale Strokes & Effects.
pub scale_strokes: bool,
pub zoom_with_mouse_wheel: bool,
/// While Zoom with Mouse Wheel is off, Cmd/Ctrl-wheel scrolls sideways instead of zooming.
pub ctrl_wheel_scrolls_sideways: bool,
/// A horizontal drag on a numeric field or its label steps its value (#400).
pub scrub_numeric_fields: bool,
/// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°.
@@ -65,6 +67,8 @@ pub struct Prefs {
pub paste_offset: f64,
// Selection & Anchor Display
pub selection_tolerance: f64,
/// Selection-tool marquee: touching (default), enclosed, or screen drag direction.
pub marquee_selection: String,
pub object_selection_by_path_only: bool,
pub snap_to_point_tolerance: f64,
pub ctrl_click_selects_behind: bool,
@@ -95,6 +99,9 @@ pub struct Prefs {
/// font is highlighted, in every document (on by default).
pub highlight_substituted_fonts: bool,
pub placeholder_text: bool,
/// Type › Start Lists From Typed Markers: a list marker and a space typed at the start of a
/// paragraph start a list (on by default). Off, they stay as typed text.
pub typed_lists: bool,
// Units
pub units_general: String,
pub units_stroke: String,
@@ -367,11 +374,13 @@ impl Default for Prefs {
scale_corners: false,
scale_strokes: false,
zoom_with_mouse_wheel: false,
ctrl_wheel_scrolls_sideways: true,
scrub_numeric_fields: true,
trackpad_rotate_view: true,
hold_tool_keys: true,
paste_offset: 10.0,
selection_tolerance: 3.0,
marquee_selection: "touching".into(),
object_selection_by_path_only: false,
snap_to_point_tolerance: 2.0,
ctrl_click_selects_behind: true,
@@ -399,6 +408,7 @@ impl Default for Prefs {
highlight_alternate_glyphs: false,
highlight_substituted_fonts: true,
placeholder_text: true,
typed_lists: true,
units_general: s("points"),
units_stroke: s("points"),
units_type: s("points"),
+1 -1
View File
@@ -1,4 +1,4 @@
use super::command_sets::{SELECTS_GIVEN_IDS, given_ids, strip_replay_only};
use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only};
use super::doc_state::DocState;
use super::error::{EngineError, Result};
use super::history::NamedStep;
+1
View File
@@ -81,6 +81,7 @@ impl Session {
batch_stash: None,
batch_steps: None,
batch_paint_docs: Vec::new(),
reading: false,
place_hand_off: None,
queue_hand_off: None,
place_queue_roots: None,
+1 -1
View File
@@ -340,7 +340,7 @@ impl Session {
preview.1.get("dy").and_then(Value::as_f64).unwrap_or(0.0),
);
st.keep_interaction(it);
if preview.0 == "object.transform" {
if preview.0 == "object.transform" && changed {
let m = cmd::matrix_param(&preview.1, "matrix");
if let Some(m) = m {
// The options are the last preview's params with the values it noted, without the
+9 -1
View File
@@ -1,5 +1,6 @@
use super::{
CommandSpec, EngineError, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, strip_replay_only,
CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard,
strip_replay_only,
};
impl Session {
@@ -35,6 +36,13 @@ impl Session {
/// Execute a command by id. This is THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
let outer = std::mem::replace(&mut self.reading, !spec.journal || JOURNALED_READS.contains(&id));
let r = self.execute_spec(spec, id, params);
self.reading = outer;
r
}
fn execute_spec(&mut self, spec: &'static CommandSpec, id: &str, params: &Value) -> Result<Value> {
// A key the command's params description doesn't name is a parameter error (#1442), before
// anything runs, for the commands other commands run too. A `command.batch` or an
// `action.batch` checks its steps before the first one runs, and each step is checked again
+1
View File
@@ -31,6 +31,7 @@ mod colorize;
pub(super) mod colormgmt;
mod colorthemes;
mod colourfonts;
mod compare;
mod compound_shape;
mod compoundpaint;
mod constrain_new;
+347
View File
@@ -3221,6 +3221,316 @@ fn image_trace_release_and_expand() {
assert!(s.execute("imageTrace.release", &json!({})).is_err());
}
/// Expand by color: each color's paths in a group of their own, named by the color, the groups
/// stacked by the lowest path in each and the art drawn as before; Release to Layers then gives a
/// sublayer for each color.
#[test]
#[allow(clippy::cognitive_complexity)] // Exercises several color and stacking cases in one trace fixture.
fn image_trace_expand_by_color_groups_each_colors_paths() {
// A light field with a black ring and a red square on it.
let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| {
let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 75.0);
if (34.0..=40.0).contains(&r) {
[0, 0, 0, 255]
} else if (130..170).contains(&x) && (55..95).contains(&y) {
[200, 40, 50, 255]
} else {
[240, 240, 220, 255]
}
});
let names = |n: &Node| n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect::<Vec<_>>();
let hex = |n: &Node| n.appearance.fill_paint().color().unwrap().to_hex();
let shot = |s: &Session| {
let img = render_doc(s, false);
(0..200).flat_map(|x| (0..150).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).collect::<Vec<_>>()
};
for method in ["abutting", "overlapping"] {
let params = json!({"mode": "color", "palette": "limited", "colors": 3, "method": method});
let expand = |by_color: bool| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params, "byColor": by_color})).unwrap();
let g = NodeId(r["id"].as_u64().unwrap());
(s, g)
};
let (flat, fg) = expand(false);
let (mut s, g) = expand(true);
let paths = node(&flat, fg).children().unwrap().clone();
// Without byColor (the default) the paths sit in the trace's group, with no group of their own.
assert!(paths.iter().all(|p| !matches!(p.kind, NodeKind::Group { .. })), "{method}");
let (default, dg) = {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap();
(s, NodeId(r["id"].as_u64().unwrap()))
};
assert!(*node(&default, dg).children().unwrap() == paths, "{method}: byColor is false when not given");
// The colors in the order they first come in, bottom to top.
let mut order: Vec<String> = vec![];
for p in &paths {
if !order.contains(&hex(p)) {
order.push(hex(p));
}
}
let mut sorted = order.clone();
sorted.sort();
assert_eq!(sorted, ["#000000", "#c82832", "#f0f0dc"], "{method}");
let n = node(&s, g);
assert_eq!(names(&n), order, "{method}");
// Each group holds its color's paths, and in turn they are the paths of the trace without
// byColor, in their order.
let groups = n.children().unwrap();
for c in groups {
assert!(matches!(c.kind, NodeKind::Group { .. }), "{method}");
assert!(c.children().unwrap().iter().all(|m| Some(hex(m)) == c.name), "{method}: {:?}", c.name);
}
let members: Vec<Arc<Node>> = groups.iter().flat_map(|c| c.children().unwrap().iter().cloned()).collect();
assert!(members == paths, "{method}");
assert!(shot(&s) == shot(&flat), "{method}: the art changed");
// Release to Layers: a sublayer for each color, in the same order.
let r = s.execute("layer.releaseToLayers", &json!({"id": g.0})).unwrap();
let subs = ids(&r);
assert_eq!(subs.len(), 3, "{method}");
for (sub, name) in subs.iter().zip(&order) {
let l = node(&s, *sub);
assert!(matches!(l.kind, NodeKind::Layer { .. }), "{method}");
assert_eq!(names(&l), std::slice::from_ref(name), "{method}");
}
}
// imageTrace.expand takes it too, as one undo step.
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let params = json!({"mode": "color", "palette": "limited", "colors": 3});
let g = NodeId(s.execute("imageTrace.make", &json!({"params": params})).unwrap()["id"].as_u64().unwrap());
let steps = s.doc().unwrap().history.undo.len();
s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
let mut got = names(&node(&s, g));
got.sort();
assert_eq!(got, ["#000000", "#c82832", "#f0f0dc"]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace"));
// A trace of one color gives one group holding every path.
let mut one = session();
let bar =
vectorcraft_trace::Raster::from_fn(80, 40, |x, y| if (10..70).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [255; 4] });
add_image(&mut one, &bar, Affine::IDENTITY);
let r = one.execute("imageTrace.makeAndExpand", &json!({"params": {"ignoreWhite": true}, "byColor": true})).unwrap();
let og = node(&one, NodeId(r["id"].as_u64().unwrap()));
assert_eq!(names(&og), ["#000000"]);
let only = &og.children().unwrap()[0];
assert!(!only.children().unwrap().is_empty() && only.children().unwrap().iter().all(|m| hex(m) == "#000000"));
// Auto Grouping's groups are not split: refused, and the trace stays as it was.
let mut grouped = params.clone();
grouped["autoGrouping"] = json!(true);
let e = s.execute("imageTrace.makeAndExpand", &json!({"id": g.0, "params": grouped, "byColor": true})).unwrap_err().to_string();
assert!(e.contains("Auto Grouping"), "{e}");
let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "params": grouped})).unwrap()["id"].as_u64().unwrap());
let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string();
assert!(e.contains("Auto Grouping"), "{e}");
assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace"));
let e = s.execute("imageTrace.expand", &json!({"byColor": "yes"})).unwrap_err().to_string();
assert!(e.contains("true or false"), "{e}");
// Refused too when Auto Grouping grouped nothing, as with side-by-side stripes.
let mut s = session();
let stripes = vectorcraft_trace::Raster::from_fn(60, 60, |x, _| match x / 20 {
0 => [200, 40, 40, 255],
1 => [40, 160, 60, 255],
_ => [40, 70, 200, 255],
});
add_image(&mut s, &stripes, Affine::IDENTITY);
let g = NodeId(s.execute("imageTrace.make", &json!({"params": grouped})).unwrap()["id"].as_u64().unwrap());
assert!(node(&s, g).children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Group { .. })));
let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string();
assert!(e.contains("Auto Grouping"), "{e}");
assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace"));
// A color linked to a global swatch goes by the swatch's name, a tint with its percentage.
let mut s = session();
s.execute("swatch.new", &json!({"name": "Ink", "color": "#c82828", "global": true})).unwrap();
s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap();
let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) {
(true, _) => [200, 40, 40, 255],
(false, true) => [40, 160, 60, 255],
(false, false) => [40, 70, 200, 255],
});
add_image(&mut s, &r, Affine::IDENTITY);
let limited = json!({"mode": "color", "palette": "limited", "colors": 3});
let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap());
let paths: Vec<(u64, String)> =
node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| (c.id.0, hex(c))).collect();
let mut colors: Vec<&str> = paths.iter().map(|(_, h)| h.as_str()).collect();
colors.sort();
colors.dedup();
assert_eq!(colors, ["#2846c8", "#28a03c", "#c82828"]);
// The red paths link to Ink and the blue ones to Sky at 50%; green stays a plain color.
let pick = |want: &str| paths.iter().filter(|(_, h)| h == want).map(|(id, _)| *id).collect::<Vec<_>>();
s.execute("paint.setFill", &json!({"ids": pick("#c82828"), "swatch": "Ink"})).unwrap();
s.execute("paint.setFill", &json!({"ids": pick("#2846c8"), "swatch": "Sky", "tint": 50})).unwrap();
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
let mut got = names(&node(&s, g));
got.sort();
assert_eq!(got, ["#28a03c", "Ink", "Sky 50%"]);
// A swatch named as another color's #rrggbb still gets a group of its own.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("swatch.edit", &json!({"name": "Ink", "newName": "#28a03c"})).unwrap();
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
let mut got = names(&node(&s, g));
got.sort();
assert_eq!(got, ["#28a03c", "#28a03c", "Sky 50%"]);
// Gradient fills share one group, and a stroked line goes by its stroke.
let ramp = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| {
let t = (f64::from(x) + 0.5) / 100.0;
[(20.0 + 200.0 * t) as u8, 40, (220.0 - 180.0 * t) as u8, 255]
});
let line =
vectorcraft_trace::Raster::from_fn(100, 100, |x, y| if (10..90).contains(&x) && (49..52).contains(&y) { [0, 0, 0, 255] } else { [255; 4] });
for (art, params, want) in [
(ramp, json!({"preset": "6 Colors", "params": {"gradients": true}, "byColor": true}), "Gradients"),
(line, json!({"params": {"ignoreWhite": true, "strokes": true, "strokeWidth": 5}, "byColor": true}), "#000000"),
] {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let res = s.execute("imageTrace.makeAndExpand", &params).unwrap();
assert_eq!(names(&node(&s, NodeId(res["id"].as_u64().unwrap()))), [want]);
}
// Flat Logo stacks its shapes biggest first without cutting holes under white: a black ring's
// outline, a white disc on it and a black dot on that. Black keeps a group below white and one
// above it, and the art draws as before, through Make and Expand and through Expand.
let target = vectorcraft_trace::Raster::from_fn(120, 120, |x, y| {
let r = (f64::from(x) + 0.5 - 60.0).hypot(f64::from(y) + 0.5 - 60.0);
if r < 15.0 || (30.0..50.0).contains(&r) {
[0, 0, 0, 255]
} else if r < 30.0 {
[255, 255, 255, 255]
} else {
[0, 0, 0, 0]
}
});
let logo = json!({"mode": "logo", "logoColors": ["#000000"]});
let traced = |by_color: bool, expand_after: bool| {
let mut s = session();
add_image(&mut s, &target, Affine::IDENTITY);
let g = if expand_after {
let g = s.execute("imageTrace.make", &json!({"params": logo})).unwrap()["id"].as_u64().unwrap();
s.execute("imageTrace.expand", &json!({"byColor": by_color})).unwrap();
g
} else {
s.execute("imageTrace.makeAndExpand", &json!({"params": logo, "byColor": by_color})).unwrap()["id"].as_u64().unwrap()
};
(s, NodeId(g))
};
for expand_after in [false, true] {
let (flat, fg) = traced(false, expand_after);
let (s, g) = traced(true, expand_after);
let paths = node(&flat, fg).children().unwrap().clone();
assert_eq!(paths.iter().map(|p| hex(p)).collect::<Vec<_>>(), ["#000000", "#ffffff", "#000000"]);
assert_eq!(names(&node(&s, g)), ["#000000", "#ffffff", "#000000"], "expand after: {expand_after}");
let members: Vec<Arc<Node>> = node(&s, g).children().unwrap().iter().flat_map(|c| c.children().unwrap().iter().cloned()).collect();
assert!(members == paths, "expand after: {expand_after}");
assert!(shot(&s) == shot(&flat), "expand after: {expand_after}: the art changed");
}
// An Abutting trace's top path, given the bottom path's color and moved onto the middle one (by
// a move or by a Transform effect), moved clear of it with a drop shadow cast onto it, or moved
// to share a pixel at its edge, before Expand, still draws on top of it.
let stripes = vectorcraft_trace::Raster::from_fn(90, 30, |x, _| match x / 30 {
0 => [200, 40, 40, 255],
1 => [40, 160, 60, 255],
_ => [40, 70, 200, 255],
});
let edited = |by_color: bool, how: &str| {
let mut s = session();
add_image(&mut s, &stripes, Affine::IDENTITY);
let limited = json!({"mode": "color", "palette": "limited", "colors": 3, "method": "abutting"});
let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap());
let paths: Vec<Arc<Node>> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect();
assert_eq!(paths.len(), 3);
let (first, middle, last) = (&paths[0], &paths[1], &paths[2]);
s.execute("paint.setFill", &json!({"ids": [last.id.0], "color": hex(first)})).unwrap();
let (to, from) = (middle.visual_bounds().unwrap(), last.visual_bounds().unwrap());
let (dx, dy) = (to.x0 - from.x0, to.y0 - from.y0);
match how {
"beside" => {
// The middle path and the moved top path end and start inside one pixel.
s.execute("object.move", &json!({"ids": [middle.id.0], "dx": -0.5, "dy": 0})).unwrap();
s.execute("object.move", &json!({"ids": [last.id.0], "dx": -0.3, "dy": 0})).unwrap()
}
"move" => s.execute("object.move", &json!({"ids": [last.id.0], "dx": dx, "dy": dy})).unwrap(),
"transform" => {
s.execute("effect.apply", &json!({"ids": [last.id.0], "effect": "distort.transform", "params": {"moveH": dx, "moveV": dy}})).unwrap()
}
_ => {
s.execute("object.move", &json!({"ids": [last.id.0], "dx": 40, "dy": 0})).unwrap();
s.execute("effect.apply", &json!({"ids": [last.id.0], "effect": "stylize.dropShadow", "params": {"x": -70, "y": 0, "blur": 0}}))
.unwrap()
}
};
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": by_color})).unwrap();
(s, g)
};
for how in ["move", "transform", "shadow", "beside"] {
let (flat, _) = edited(false, how);
let (s, g) = edited(true, how);
assert_eq!(names(&node(&s, g)).len(), 3, "{how}");
assert!(shot(&s) == shot(&flat), "{how}: the edited art changed");
}
// Two tints of one swatch that round to the same percentage are still two colors.
let mut s = session();
s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap();
add_image(&mut s, &stripes, Affine::IDENTITY);
let limited = json!({"mode": "color", "palette": "limited", "colors": 3});
let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap());
let paths: Vec<u64> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| c.id.0).collect();
assert_eq!(paths.len(), 3);
s.execute("paint.setFill", &json!({"ids": [paths[0]], "swatch": "Sky", "tint": 50.1})).unwrap();
s.execute("paint.setFill", &json!({"ids": [paths[1], paths[2]], "swatch": "Sky", "tint": 50.4})).unwrap();
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
let groups = node(&s, g).children().unwrap().clone();
assert_eq!(names(&node(&s, g)), ["Sky 50%", "Sky 50%"]);
assert_eq!(groups.iter().map(|c| c.children().unwrap().len()).collect::<Vec<_>>(), [1, 2]);
}
#[test]
fn image_trace_expand_by_color_preserves_live_pathfinder_art() {
let mut s = session();
let art = vectorcraft_trace::Raster::from_fn(100, 60, |x, y| {
if (10..40).contains(&y) && ((10..35).contains(&x) || (65..90).contains(&x)) { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
});
add_image(&mut s, &art, Affine::IDENTITY);
let g = NodeId(s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap()["id"].as_u64().unwrap());
let paths: Vec<NodeId> = node(&s, g).children().unwrap().iter().filter(|c| c.path_data().is_some()).map(|c| c.id).collect();
assert_eq!(paths.len(), 2);
s.execute("select.set", &json!({"ids": [paths[1].0]})).unwrap();
s.execute("object.move", &json!({"dx": -45, "dy": 0})).unwrap();
s.execute("effect.apply", &json!({"ids": [g.0], "effect": "pathfinder.subtract"})).unwrap();
let before = render_doc(&s, false);
let before_pixels: Vec<_> = (0..100).flat_map(|x| (0..60).map(move |y| (x, y))).map(|(x, y)| before.pixel(x, y)).collect();
let original = node(&s, g);
let steps = s.doc().unwrap().history.undo.len();
let result = s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true}));
if let Err(e) = &result {
assert!(e.to_string().contains("Pathfinder"), "{e}");
assert_eq!(node(&s, g), original);
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
}
let after = render_doc(&s, false);
let after_pixels: Vec<_> = (0..100).flat_map(|x| (0..60).map(move |y| (x, y))).map(|(x, y)| after.pixel(x, y)).collect();
let changed = after_pixels.iter().zip(&before_pixels).filter(|(a, b)| a != b).count();
assert_eq!(changed, 0, "byColor changed {changed} live Pathfinder pixels: {result:?}");
assert!(result.is_err(), "byColor must refuse a live Pathfinder operand group");
let error = s.execute("imageTrace.makeAndExpand", &json!({"id": g.0, "params": {"ignoreWhite": true}, "byColor": true})).unwrap_err();
assert!(error.to_string().contains("Pathfinder"), "{error}");
assert_eq!(node(&s, g), original);
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
}
#[test]
fn image_trace_make_and_expand_and_color_presets() {
let mut s = session();
@@ -3825,6 +4135,43 @@ fn image_trace_flat_logo_refuses_a_gradient_and_a_bad_palette() {
assert_eq!(r["colors"], 1);
}
#[test]
fn image_trace_flat_logo_can_trace_only_the_named_colors() {
// A light disc in a black ring, a blue square on it, and a white spot.
let art = vectorcraft_trace::Raster::from_fn(120, 120, |x, y| {
let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
let d = (fx - 60.0).hypot(fy - 60.0);
if (fx - 32.0).hypot(fy - 40.0) <= 6.0 {
[255, 255, 255, 255]
} else if (45..75).contains(&x) && (45..75).contains(&y) {
[40, 120, 240, 255]
} else if d <= 46.0 {
[240, 240, 220, 255]
} else if d <= 50.0 {
[0, 0, 0, 255]
} else {
[0, 0, 0, 0]
}
});
let fills = |logo_white: bool| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let params = json!({"params": {"mode": "logo", "logoColors": ["#000000", "#F0F0DC", "#2878F0"], "logoWhite": logo_white}});
let r = s.execute("imageTrace.makeAndExpand", &params).unwrap();
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
let mut hex: Vec<String> = g.children().unwrap().iter().filter_map(|n| n.appearance.fill_paint().color()).map(|c| c.to_hex()).collect();
hex.sort();
hex.dedup();
(r["colors"].clone(), hex)
};
let (colors, hex) = fills(true);
assert_eq!(colors, 4);
assert!(hex.contains(&"#ffffff".to_string()), "{hex:?}");
let (colors, hex) = fills(false);
assert_eq!(colors, 3);
assert_eq!(hex, ["#000000", "#2878f0", "#f0f0dc"]);
}
#[test]
fn image_trace_object_remembers_its_settings() {
let mut s = session();
+276
View File
@@ -0,0 +1,276 @@
//! `document.compare`: an artboard rendered at an image's size, as a PNG export draws it, and
//! measured against the image (RMSE, the pixels over a threshold, a difference image), with the
//! image and the render flattened on one background; images of other proportions, files that
//! can't be read and paths outside the automation roots are errors.
use serde_json::{Value, json};
use crate::Session;
/// A 40 × 20 pt document with a black 10 × 10 pt square at (10, 5), no stroke.
fn session() -> (Session, Value) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 20})).unwrap();
s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 5, "width": 10, "height": 10})).unwrap()["id"].clone();
(s, id)
}
fn path_in(dir: &std::path::Path, name: &str) -> String {
dir.join(name).to_string_lossy().into_owned()
}
/// The artboard exported as a PNG at 2 pixels a point on white.
fn export(s: &mut Session, path: &str) {
s.execute("document.export", &json!({"path": path, "format": "png", "scale": 2, "background": "white"})).unwrap();
}
fn compare(s: &mut Session, p: Value) -> Value {
s.execute("document.compare", &p).unwrap_or_else(|e| panic!("document.compare {p}: {e}"))
}
fn error(s: &mut Session, p: Value) -> String {
s.execute("document.compare", &p).map(|v| panic!("document.compare {p} answered {v}")).unwrap_err().to_string()
}
#[test]
fn an_export_compares_equal_and_a_moved_square_counts_the_pixels_it_changed() {
let dir = vectorcraft_testkit::temp_dir("compare-moved");
let png = path_in(&dir, "art.png");
let (mut s, id) = session();
export(&mut s, &png);
let before = (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len());
let r = compare(&mut s, json!({"path": png}));
assert_eq!(r, json!({"width": 80, "height": 40, "rmse": 0.0, "over": 0, "total": 3200, "threshold": 0.08}));
assert_eq!((s.doc().unwrap().revision, s.doc().unwrap().history.undo.len()), before, "comparing doesn't edit the document");
// 4 pt is 8 pixels: 8 columns of the square's 20 rows turn white and 8 turn black, every
// channel by 255.
s.execute("object.move", &json!({"ids": [id], "dx": 4, "dy": 0})).unwrap();
let r = compare(&mut s, json!({"path": png}));
let over = 2 * 8 * 20;
assert_eq!((r["over"].as_u64(), r["total"].as_u64()), (Some(over), Some(3200)), "{r}");
let rmse = ((over as f64 / 3200.0).sqrt() * 1e6).round() / 1e6;
assert_eq!(r["rmse"].as_f64(), Some(rmse), "{r}");
// Above every difference, nothing is over.
assert_eq!(compare(&mut s, json!({"path": png, "threshold": 1}))["over"], json!(0));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_image_and_the_render_are_flattened_on_the_background() {
let dir = vectorcraft_testkit::temp_dir("compare-background");
let png = path_in(&dir, "clear.png");
// Transparent everywhere, with red in the color channels that the alpha hides.
image::RgbaImage::from_pixel(80, 40, image::Rgba([255, 0, 0, 0])).save(&png).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 20})).unwrap();
// Nothing drawn: both are the background alone.
for background in ["#808080", "#ffffff"] {
let r = compare(&mut s, json!({"path": png, "background": background}));
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(0.0), Some(0)), "{background}: {r}");
}
// A black left half: 128 from gray in every channel of half the pixels, 255 from white.
s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
let r = compare(&mut s, json!({"path": png, "background": "#808080"}));
let rmse = |d: f64| ((0.5f64).sqrt() * d / 255.0 * 1e6).round() / 1e6;
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(128.0)), Some(1600)), "{r}");
let r = compare(&mut s, json!({"path": png}));
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(255.0)), Some(1600)), "{r}");
// A threshold just above 128/255 leaves the gray differences out.
assert_eq!(compare(&mut s, json!({"path": png, "background": "#808080", "threshold": 0.51}))["over"], json!(0));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_image_of_other_proportions_is_refused_and_one_pixel_is_allowed() {
let dir = vectorcraft_testkit::temp_dir("compare-size");
let (mut s, _) = session();
let square = path_in(&dir, "square.png");
image::RgbaImage::from_pixel(30, 30, image::Rgba([255; 4])).save(&square).unwrap();
let e = error(&mut s, json!({"path": square}));
assert!(e.contains("30 × 30 pixels") && e.contains("30 × 15 pixels"), "{e}");
let taller = path_in(&dir, "taller.png");
image::RgbaImage::from_pixel(80, 41, image::Rgba([255; 4])).save(&taller).unwrap();
let r = compare(&mut s, json!({"path": taller}));
assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["total"].as_u64()), (Some(80), Some(41), Some(3280)), "{r}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_export_whose_height_was_rounded_compares_equal() {
let dir = vectorcraft_testkit::temp_dir("compare-rounded");
let png = path_in(&dir, "art.png");
// 20.25 pt at 2 pixels a point is 40.5 rows, which the export rounds to 41.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 20.25})).unwrap();
s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 5, "width": 10, "height": 10})).unwrap();
export(&mut s, &png);
let r = compare(&mut s, json!({"path": png}));
assert_eq!(r, json!({"width": 80, "height": 41, "rmse": 0.0, "over": 0, "total": 3280, "threshold": 0.08}));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_linked_image_is_drawn_from_its_file_as_a_png_export_draws_it() {
use crate::tests_links::{BLUE, Folder, RED, open, place, png, save, session, write};
let dir = Folder::new("compare-linked");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
place(&mut s, &pic);
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
// The file changes after the save: the reopened document shows the red preview saved for it,
// and a PNG export reads the blue file.
write(&pic, &png(600, 300, BLUE));
open(&mut s, &doc_path);
let out = dir.file("export.png");
s.execute("document.export", &json!({"path": out, "format": "png", "background": "white"})).unwrap();
let r = compare(&mut s, json!({"path": out}));
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64(), r.get("warnings")), (Some(0.0), Some(0), None), "{r}");
// Without the file the preview is drawn, and the reply says so.
std::fs::remove_file(&pic).unwrap();
let r = compare(&mut s, json!({"path": out}));
assert_eq!(r["over"].as_u64(), r["total"].as_u64(), "{r}");
let warnings = r["warnings"].as_array().unwrap();
assert!(warnings.len() == 1 && warnings[0].as_str().unwrap().contains("1 linked image output as its preview"), "{r}");
// With type that doesn't fit its frame as well, the warnings come in the export's order.
let overset =
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}})).unwrap();
assert_eq!(overset["overflow"], json!([overset["id"]]), "{overset}");
let exported = s.execute("document.export", &json!({"path": dir.file("now.png"), "format": "png", "background": "white"})).unwrap();
let r = compare(&mut s, json!({"path": out}));
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(2), "{r}");
assert_eq!(r["warnings"], exported["warnings"], "{r}");
}
#[test]
fn an_image_that_cannot_be_decoded_and_a_missing_font_warn_as_a_png_export_does() {
let dir = vectorcraft_testkit::temp_dir("compare-warnings");
let png = path_in(&dir, "art.png");
let (mut s, _) = session();
export(&mut s, &png);
assert_eq!(compare(&mut s, json!({"path": png})).get("warnings"), None);
crate::tests_menucmds::place_undecodable_png(&mut s);
s.execute("text.create", &json!({"x": 2, "y": 18, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
let overset = s.execute("text.create", &json!({"x": 22, "y": 2, "text": "Ab\nCd\nEf", "size": 6, "area": {"width": 15, "height": 8}})).unwrap();
assert_eq!(overset["overflow"], json!([overset["id"]]), "{overset}");
let exported = s.execute("document.export", &json!({"path": path_in(&dir, "now.png"), "format": "png", "scale": 2})).unwrap();
let r = compare(&mut s, json!({"path": png}));
assert_eq!(r["warnings"], exported["warnings"], "{r}");
let warnings = r["warnings"].as_array().unwrap();
assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{r}");
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("Nonexistent Sans Probe")), "{r}");
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("doesn't fit its frame")), "{r}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn diff_path_writes_each_pixels_differences() {
let dir = vectorcraft_testkit::temp_dir("compare-diff");
let png = path_in(&dir, "art.png");
let diff = path_in(&dir, "diff.png");
let (mut s, id) = session();
export(&mut s, &png);
s.execute("object.move", &json!({"ids": [id], "dx": 4, "dy": 0})).unwrap();
let r = compare(&mut s, json!({"path": png, "diffPath": diff}));
assert_eq!(r["diffPath"], json!(diff));
let img = image::open(&diff).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (80, 40));
// The square spanned pixels 20–39 and now spans 28–47, rows 10–29.
for (x, y, want) in [(24, 20, 255), (44, 20, 255), (30, 20, 0), (24, 5, 0), (60, 20, 0)] {
assert_eq!(img.get_pixel(x, y).0, [want, want, want, 255], "({x}, {y})");
}
let changed = img.pixels().filter(|p| p.0[0] != 0).count();
assert_eq!(changed as u64, r["over"].as_u64().unwrap());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn missing_unreadable_and_malformed_inputs_are_errors() {
let dir = vectorcraft_testkit::temp_dir("compare-errors");
let (mut s, _) = session();
let png = path_in(&dir, "art.png");
export(&mut s, &png);
let missing = path_in(&dir, "missing.png");
assert!(error(&mut s, json!({"path": missing})).contains(&missing));
let text = path_in(&dir, "notes.png");
std::fs::write(&text, "not an image\n").unwrap();
assert!(error(&mut s, json!({"path": text})).contains("not a PNG, JPEG, WebP, TIFF or BMP image"));
let gif = path_in(&dir, "art.gif");
image::RgbaImage::from_pixel(80, 40, image::Rgba([255; 4])).save(&gif).unwrap();
assert!(error(&mut s, json!({"path": gif})).contains("not a PNG, JPEG, WebP, TIFF or BMP image"));
for (p, want) in [
(json!({}), "missing `path`"),
(json!({"path": png, "artboard": 3}), "no artboard 3"),
(json!({"path": png, "threshold": 2}), "`threshold` must be a number from 0 to 1"),
(json!({"path": png, "threshold": "low"}), "`threshold` must be a number from 0 to 1"),
(json!({"path": png, "background": "transparent"}), "a color such as"),
(json!({"path": png, "diffPath": 5}), "`diffPath` must be a string"),
] {
let e = error(&mut s, p.clone());
assert!(e.contains(want), "{p}: {e}");
}
let _ = std::fs::remove_dir_all(dir);
}
/// A planar TIFF whose header claims more strips than the decoder can count (2^31 + 1 rows a strip
/// each, times 5 planes) is refused as too large to draw, without a panic.
#[test]
fn a_tiff_with_more_strips_than_can_be_counted_is_too_large() {
let dir = vectorcraft_testkit::temp_dir("compare-strips");
let (mut s, _) = session();
let plane = |v: u16| vec![v; 6];
let channels = [plane(200), plane(40), plane(10), plane(0), plane(90)];
let t = vectorcraft_testkit::tiff::Tiff {
width: 3,
height: 2,
photometric: 5,
bits: 8,
extra: &[0],
planar: true,
ppi: Some(150),
channels: &channels,
};
let mut file = vectorcraft_testkit::tiff::tiff(&t);
// ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the
// eighth, at offset 102).
file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes());
file[102..106].copy_from_slice(&1u32.to_le_bytes());
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
let tif = path_in(&dir, "strips.tif");
std::fs::write(&tif, &file).unwrap();
let e = error(&mut s, json!({"path": tif}));
assert_eq!(e, "invalid parameters for `document.compare`: an image of 3 × 2147483649 pixels is too large to draw");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_image_and_the_difference_image_stay_inside_the_automation_roots() {
let dir = vectorcraft_testkit::temp_dir("compare-roots");
for d in ["in", "out", "elsewhere"] {
std::fs::create_dir_all(dir.join(d)).unwrap();
}
let (mut s, _) = session();
let inside = path_in(&dir.join("in"), "art.png");
let outside = path_in(&dir.join("elsewhere"), "art.png");
export(&mut s, &inside);
export(&mut s, &outside);
let roots = crate::file_access::AutomationRoots::new(&[&dir.join("in")], &[&dir.join("out")]).unwrap().unwrap();
let mut run = |p: Value| crate::file_access::confine(Some(&roots), || s.execute("document.compare", &p));
assert!(run(json!({"path": inside})).is_ok());
let e = run(json!({"path": outside})).unwrap_err().to_string();
assert!(e.contains("outside the read root"), "{e}");
let stray = path_in(&dir.join("elsewhere"), "diff.png");
let e = run(json!({"path": inside, "diffPath": stray})).unwrap_err().to_string();
assert!(e.contains("outside the write root"), "{e}");
assert!(!std::path::Path::new(&stray).exists());
let kept = path_in(&dir.join("out"), "diff.png");
assert_eq!(run(json!({"path": inside, "diffPath": kept})).unwrap()["diffPath"], json!(kept));
let _ = std::fs::remove_dir_all(dir);
}
+870 -1
View File
@@ -1,10 +1,14 @@
//! Dynamic symbols (#4938): an instance changes the appearance of objects in its symbol's art and
//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them.
//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them. Direct
//! Selection picks one path, compound path or type object of an instance's art, and a solid color
//! Fill or Stroke changes it.
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use crate::tooling::{UiRequest, ViewInfo};
use crate::*;
fn session() -> Session {
@@ -46,6 +50,9 @@ fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] {
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
/// The refusal of anything but a solid fill or stroke on a picked object says so.
const REFUSED: &str = "solid color fill and stroke";
#[test]
fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() {
let mut s = session();
@@ -58,6 +65,10 @@ fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() {
assert_eq!(pixel(&s, 80, 30), RED, "the changed object");
assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look");
assert_eq!(overrides(&s, other), 0, "other instances keep the symbol's look");
// The same value again records nothing.
let undo = undo_steps(&s);
assert_eq!(s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap()["overrides"], 1);
assert_eq!(undo_steps(&s), undo, "no undo step");
// A later change to the same object merges with the first; null takes one back.
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "opacity": 50})).unwrap();
assert_eq!(node(&s, inst).extras().symbol_overrides[0].opacity, Some(0.5));
@@ -176,6 +187,666 @@ fn break_link_bakes_the_changes_and_edit_symbol_shows_the_symbols_own_art() {
assert_eq!(overrides(&s, other), 1, "the instance keeps its changes");
}
/// Press the Direct Selection tool at (`x`, `y`), drag to `to` and release, with `m` held: what the
/// drag asked of the UI.
fn direct(s: &mut Session, (x, y): (f64, f64), to: (f64, f64), m: Mods) -> Vec<UiRequest> {
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("directSelection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, x, y).with_mods(m), v).unwrap();
let mut out = vec![];
if to != (x, y) {
out = s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(m), v).unwrap();
out
}
fn undo_steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// The fills of the symbol's own art.
fn symbol_fills(s: &Session) -> Vec<String> {
let art = s.doc().unwrap().doc.symbols[0].art.clone();
art.children().unwrap().iter().map(|c| c.appearance.fill_paint().color().unwrap().to_hex()).collect()
}
#[test]
fn direct_selection_paints_one_object_of_one_dynamic_instance() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap());
direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default());
let sel = s.doc().unwrap().selection.clone();
assert_eq!(sel.objects, [inst]);
assert_eq!(sel.symbol_part, Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![1] }));
let undo = undo_steps(&s);
// The Swatches panel, the Color panel and the Control bar set the fill with paint.setFill.
assert_eq!(s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap()["objects"], 1);
assert_eq!(undo_steps(&s), undo + 1, "one undo step");
assert_eq!(pixel(&s, 80, 30), RED, "the picked object");
assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look");
assert_eq!(overrides(&s, other), 0, "the other instance keeps the symbol's look");
assert_eq!(symbol_fills(&s), ["#0000ff", "#0000ff"], "the symbol's art is unchanged");
assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#ff0000".into()), "new art takes the fill");
// The same paint again records nothing.
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
assert_eq!(undo_steps(&s), undo + 1);
// Stroke goes to the same object.
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
assert_eq!(s.paint.stroke.color().map(|c| c.to_hex()), Some("#00ff00".into()), "new art takes the stroke");
assert!(s.paint.stroke_width > 0.0, "a painted stroke has a weight");
let o = node(&s, inst).extras().symbol_overrides[0].clone();
assert_eq!(o.path, vec![1]);
assert!(o.fill.is_some() && o.stroke.is_some());
// The Eyedropper's click on another object gives the selected object its paint.
let green = square(&mut s, 150.0);
s.execute("paint.setFill", &json!({"ids": [green.0], "color": "#00ff00"})).unwrap();
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
assert_eq!(s.execute("appearance.copyFrom", &json!({"source": green.0})).unwrap()["ids"], json!([inst.0]));
assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255], "the eyedropper's paint");
assert_eq!(symbol_fills(&s), ["#0000ff", "#0000ff"]);
for _ in 0..7 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(overrides(&s, inst), 0, "undo takes each change back");
assert_eq!(pixel(&s, 80, 30), BLUE);
}
#[test]
fn a_drag_of_a_picked_object_moves_nothing_and_says_why() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default());
let (before, undo) = (node(&s, inst).kind.clone(), undo_steps(&s));
let ui = direct(&mut s, (80.0, 30.0), (120.0, 60.0), Mods::default());
assert!(ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("symbol instance"))), "{ui:?}");
assert_eq!(node(&s, inst).kind, before, "the instance stays put");
assert_eq!(undo_steps(&s), undo, "no undo step");
assert!(s.execute("object.nudge", &json!({"dx": 1})).is_err(), "the arrow keys too");
assert!(s.execute("object.rotate", &json!({"angle": 10})).is_err());
// The Selection tool picks the instance whole again, and it moves.
s.select_tool("selection", ViewInfo::default()).unwrap();
assert!(s.doc().unwrap().selection.symbol_part.is_none());
s.execute("object.nudge", &json!({"dx": 1})).unwrap();
assert_ne!(node(&s, inst).kind, before);
}
/// The Stroke panel and the Control bar show the stroke of the object picked in a dynamic instance.
#[test]
fn the_stroke_panel_shows_a_picked_objects_stroke() {
let mut s = session();
let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0));
s.execute("paint.setStroke", &json!({"ids": [a.0, b.0], "color": "#00ff00"})).unwrap();
s.execute("stroke.set", &json!({"ids": [a.0], "weight": 2})).unwrap();
s.execute("stroke.set", &json!({"ids": [b.0], "weight": 7})).unwrap();
let inst = id(&s.execute("symbol.new", &json!({"name": "Pair", "ids": [a.0, b.0], "type": "dynamic"})).unwrap());
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
assert_eq!(s.shown_stroke().map(|st| st.width), Some(7.0), "the picked object's weight");
assert!(!s.doc().unwrap().stroke_mixed().weight, "the instance's art has two weights, the object one");
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [0]})).unwrap();
assert_eq!(s.shown_stroke().map(|st| st.width), Some(2.0));
}
#[test]
fn delete_and_cut_of_a_picked_object_remove_nothing() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
let undo = undo_steps(&s);
for cmd in ["edit.cut", "edit.clear"] {
let e = s.execute(cmd, &json!({})).unwrap_err().to_string();
assert!(e.contains("symbol instance"), "{cmd}: {e}");
assert!(s.doc().unwrap().doc.node(inst).is_some(), "{cmd} keeps the instance");
}
assert_eq!(undo_steps(&s), undo, "no undo step");
// Named by id, the instance is removed.
s.execute("edit.clear", &json!({"ids": [inst.0]})).unwrap();
assert!(s.doc().unwrap().doc.node(inst).is_none());
}
#[test]
fn commands_on_the_selection_leave_a_picked_objects_instance_alone() {
// A dynamic instance with an opacity mask, a plain square and the Desaturate plug-in, with an
// object of the instance picked.
let setup = || {
let mut s = session();
let inst = pair(&mut s, "dynamic");
let other = square(&mut s, 150.0);
let desaturate = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm");
let plugin = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(desaturate)})).unwrap();
let mask = square(&mut s, 20.0);
s.execute("transparency.makeOpacityMask", &json!({"ids": [inst.0, mask.0]})).unwrap();
assert!(node(&s, inst).extras().mask.is_some());
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
(s, inst, other, plugin["id"].clone())
};
let (mut s, inst, other, plugin) = setup();
let doc = s.doc().unwrap().doc.clone();
// Each command runs on its own copy of the setup, compared with that copy as it was, so every
// command that fails is reported.
let mut failures = Vec::new();
for (cmd, p) in [
("object.puppetWarp", json!({"pins": [[80, 30]], "moved": [[90, 40]], "rest": true})),
("recolor.apply", json!({"map": {"#0000ff": "#00ff00"}})),
("plugin.run", json!({"id": plugin, "params": {"amount": 100}})),
("object.group", json!({})),
("object.ungroup", json!({})),
("object.hide", json!({})),
("object.lock", json!({})),
("object.arrange.bringToFront", json!({})),
("object.expand", json!({})),
("object.rotate", json!({"angle": 45})),
("symbol.instance.reset", json!({})),
("symbol.instance.setAppearance", json!({"path": [1], "opacity": 50})),
("symbol.adjust", json!({"tool": "shift", "points": [[80, 30], [95, 30]], "radius": 200})),
("symbol.edit", json!({})),
("select.globalEdit", json!({})),
("object.blend.release", json!({})),
("object.repeat.release", json!({})),
("object.intertwine.release", json!({})),
("object.objectsOnPath.expand", json!({})),
("transparency.set", json!({"opacity": 50})),
("transparency.set", json!({"blend": "multiply"})),
("transparency.releaseOpacityMask", json!({})),
("transparency.setOpacityMask", json!({"invert": true, "clip": false})),
("transparency.disableOpacityMask", json!({})),
("transparency.unlinkOpacityMask", json!({})),
("transparency.editOpacityMask", json!({})),
("transparency.viewOpacityMask", json!({"on": true})),
("paint.editGradient", json!({"angle": 30})),
("paint.setGradientGeom", json!({"start": [62, 30], "end": [98, 30]})),
("paint.swap", json!({})),
("stroke.set", json!({"weight": 3})),
("path.moveAnchors", json!({"dx": 5, "dy": 5})),
(
"file.place",
json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(b"<svg xmlns='http://www.w3.org/2000/svg' width='9' height='9'><rect width='9' height='9'/></svg>"), "replace": true}),
),
("object.intertwine.arrange", json!({"at": [80, 30]})),
("transparency.editOpacityMask", json!({"id": null})),
("transparency.viewOpacityMask", json!({"on": true, "id": null})),
] {
let (mut t, ..) = setup();
let (doc, undo) = (t.doc().unwrap().doc.clone(), undo_steps(&t));
match t.execute(cmd, &p) {
Ok(r) => failures.push(format!("{cmd}: ran ({r})")),
Err(e) if !e.to_string().contains("symbol instance") => failures.push(format!("{cmd}: {e}")),
Err(_) => {}
}
if t.doc().unwrap().selection.symbol_part.is_none() {
failures.push(format!("{cmd}: the object is no longer picked"));
}
if t.doc().unwrap().doc != doc {
failures.push(format!("{cmd}: the document changed"));
}
if undo_steps(&t) != undo {
failures.push(format!("{cmd}: an undo step was added"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
// Copy copies the instance whole.
s.execute("edit.copy", &json!({})).unwrap();
assert!(matches!(&s.clipboard.nodes[..], [n] if matches!(n.kind, NodeKind::SymbolInstance { .. })));
// The picked object's paint still changes.
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_some());
// A command given ids acts on them.
s.execute("object.rotate", &json!({"ids": [other.0], "angle": 45})).unwrap();
assert_ne!(node(&s, other).kind, doc.node(other).unwrap().kind, "the named object turns");
s.execute("transparency.setOpacityMask", &json!({"id": inst.0, "invert": true})).unwrap();
assert!(node(&s, inst).extras().mask.as_ref().is_some_and(|m| m.invert), "the named instance's mask changes");
}
#[test]
fn the_commands_without_a_check_for_a_picked_object_use_its_instance() {
let setup = || {
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap();
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
(s, inst)
};
// Export Selection: the whole instance, x 10..100, and not the object at 60..100.
let (mut s, _) = setup();
let r = s.execute("document.exportSelection", &json!({"format": "svg"})).unwrap();
assert_eq!(r["bounds"], json!([10.0, 10.0, 90.0, 40.0]));
// Fit to Selection: the whole instance too.
let (mut s, _) = setup();
s.execute("artboard.fitToSelection", &json!({})).unwrap();
let r = s.doc().unwrap().doc.artboards[0].rect;
assert_eq!((r.x0, r.x1), (10.0, 100.0));
// Liquify: the instance is its target, skipped and left as it is.
let (mut s, inst) = setup();
let doc = s.doc().unwrap().doc.clone();
let r = s.execute("object.liquify", &json!({"tool": "pucker", "points": [[80, 30]], "diameter": 150, "intensity": 1})).unwrap();
assert_eq!((&r["ids"], &r["skipped"]), (&json!([]), &json!([inst.0])));
assert!(s.doc().unwrap().doc == doc);
// Isolate: the instance, with the selection cleared.
let (mut s, inst) = setup();
s.execute("object.isolate", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().isolation, Some(inst));
assert!(s.doc().unwrap().selection.is_empty());
// Add to Library: a copy of the whole instance, 90 pt wide, and not the object's paint.
let (mut s, _) = setup();
let lib = s.execute("library.add", &json!({"kind": "graphic"})).unwrap()["library"].clone();
let got = s.execute("library.get", &json!({"library": lib})).unwrap();
assert_eq!(got["graphics"][0]["width"], 90.0);
assert_ne!(s.execute("library.add", &json!({"kind": "fillColor"})).unwrap()["name"], "#ff0000");
// Save Selection: the instance, which Recall selects whole.
let (mut s, inst) = setup();
s.execute("select.save", &json!({"name": "Saved"})).unwrap();
assert_eq!(s.doc().unwrap().doc.saved_selections[0].objects, vec![inst]);
s.execute("select.recall", &json!({"name": "Saved"})).unwrap();
let sel = &s.doc().unwrap().selection;
assert_eq!((&sel.objects, &sel.symbol_part), (&vec![inst], &None));
}
#[test]
fn a_blend_around_a_picked_objects_instance_is_left_alone() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
let other = square(&mut s, 150.0);
let blend = id(&s.execute("object.blend.make", &json!({"ids": [inst.0, other.0], "steps": 1})).unwrap());
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
let (doc, undo) = (s.doc().unwrap().doc.clone(), undo_steps(&s));
let e = s.execute("object.blend.release", &json!({})).unwrap_err().to_string();
assert!(e.contains("symbol instance"), "{e}");
assert!(s.doc().unwrap().doc == doc, "the blend stays");
assert_eq!(undo_steps(&s), undo, "no undo step");
// With the blend selected, it is released.
s.execute("select.set", &json!({"ids": [blend.0]})).unwrap();
s.execute("object.blend.release", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(blend).is_none());
}
/// Dynamic symbol "Photo" of a gray 2 × 1 image and a blue square, made in place: its instance and
/// the image's path in the art.
fn image_symbol(s: &mut Session) -> (NodeId, Value) {
let mut png = Vec::new();
let pixels = image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap());
pixels.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let im = s
.edit("Place", |d, _| {
d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png));
let id = d.alloc_id();
let im = vectorcraft_doc::ImageObject {
key: "gray".into(),
width: 40,
height: 40,
xf: vectorcraft_geom::Affine::translate((10.0, 10.0)),
link: None,
placement: Default::default(),
pixels: None,
};
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
Ok(id)
})
.unwrap();
let sq = square(s, 60.0);
let inst = id(&s.execute("symbol.new", &json!({"name": "Photo", "ids": [im.0, sq.0], "type": "dynamic"})).unwrap());
let art = s.doc().unwrap().doc.symbols[0].art.clone();
let i = art.children().unwrap().iter().position(|c| matches!(c.kind, NodeKind::Image(_))).unwrap();
(inst, json!([i]))
}
#[test]
fn only_paths_compound_paths_and_type_of_the_art_are_picked() {
let mut s = session();
// A compound path, type, a blend and a static symbol's instance beside a path, in a group.
let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0));
let compound = id(&s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap());
let text = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap());
let (c, d) = (square(&mut s, 60.0), square(&mut s, 150.0));
let blend = id(&s.execute("object.blend.make", &json!({"ids": [c.0, d.0], "steps": 1})).unwrap());
let inner = pair(&mut s, "static");
let plain = square(&mut s, 100.0);
let group = id(&s.execute("object.group", &json!({"ids": [plain.0]})).unwrap());
let ids = [compound.0, text.0, blend.0, inner.0, group.0];
let inst = id(&s.execute("symbol.new", &json!({"name": "Mixed", "ids": ids, "type": "dynamic"})).unwrap());
let art = s.doc().unwrap().doc.symbols.iter().find(|y| y.name == "Mixed").unwrap().art.clone();
let at = |kind: &dyn Fn(&NodeKind) -> bool| art.children().unwrap().iter().position(|c| kind(&c.kind)).unwrap();
let compound = at(&|k| matches!(k, NodeKind::Compound { .. }));
let text = at(&|k| matches!(k, NodeKind::Text(_)));
let blend = at(&|k| matches!(k, NodeKind::Blend { .. }));
let inner = at(&|k| matches!(k, NodeKind::SymbolInstance { .. }));
let group = at(&|k| matches!(k, NodeKind::Group { .. }));
for path in [json!([compound]), json!([text]), json!([group, 0])] {
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
}
assert_eq!(overrides(&s, inst), 3);
let undo = undo_steps(&s);
s.execute("select.none", &json!({})).unwrap();
for path in [json!([]), json!([group]), json!([blend]), json!([inner])] {
let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string();
assert!(e.contains(REFUSED), "{path}: {e}");
}
assert!(s.doc().unwrap().selection.symbol_part.is_none(), "nothing is picked");
assert_eq!((overrides(&s, inst), undo_steps(&s)), (3, undo), "nothing changes");
}
/// A Direct Selection press on an image of the art is refused with the status message, and a drag
/// from it does not move what was selected.
#[test]
fn a_press_on_an_image_of_the_art_is_refused_and_moves_nothing() {
let mut s = session();
let (inst, path) = image_symbol(&mut s);
let other = square(&mut s, 150.0);
let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string();
assert!(e.contains(REFUSED), "{e}");
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
let before = node(&s, other).kind.clone();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("directSelection", v).unwrap();
let e = s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 30.0), v).unwrap_err().to_string();
assert!(e.contains(REFUSED), "{e}");
s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 60.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 60.0), v).unwrap();
assert_eq!(node(&s, other).kind, before, "what was selected stays put");
assert_eq!(s.doc().unwrap().selection.objects, [other], "and stays selected");
assert_eq!(overrides(&s, inst), 0);
}
/// A gradient or pattern for a picked object is refused and changes nothing, new art's paint
/// included. An object that shows a gradient takes a solid color.
#[test]
fn gradients_and_patterns_on_a_picked_object_are_refused() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
let g = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]});
s.execute("swatch.new", &json!({"name": "Ramp", "gradient": g})).unwrap();
let tile = square(&mut s, 150.0);
s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap();
// An object to sample a gradient from.
let far = square(&mut s, 150.0);
s.execute("paint.setFill", &json!({"ids": [far.0], "gradient": g})).unwrap();
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
let undo = undo_steps(&s);
let (fill, stroke) = (s.paint.fill.clone(), s.paint.stroke.clone());
for (cmd, p) in [
("paint.setFill", json!({"gradient": g})),
("paint.setStroke", json!({"gradient": g})),
("paint.setFill", json!({"gradient": g, "span": "selection"})),
("paint.setFill", json!({"swatch": "Ramp"})),
("paint.setFill", json!({"swatch": "Dots"})),
("appearance.copyFrom", json!({"source": far.0})),
] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains(REFUSED), "{cmd} {p}: {e}");
}
assert_eq!((overrides(&s, inst), undo_steps(&s)), (0, undo), "nothing changes");
assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &stroke), "new art keeps its paint");
// An object whose art has a gradient takes a solid color; Invert, Complement and selecting one
// of its stops are refused.
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": {"gradient": g}})).unwrap();
for cmd in ["paint.invert", "paint.complement"] {
let e = s.execute(cmd, &json!({"stroke": false})).unwrap_err().to_string();
assert!(e.contains(REFUSED), "{cmd}: {e}");
}
s.fill_active = true;
for index in [json!(1), Value::Null] {
let e = s.execute("gradient.selectStop", &json!({"index": index})).unwrap_err().to_string();
assert!(e.contains(REFUSED), "gradient.selectStop {index}: {e}");
}
assert_eq!(s.selected_stop(), None);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
assert_eq!(pixel(&s, 80, 30), RED);
// The Eyedropper's options can leave the gradient out: the fill stays.
s.execute("appearance.copyFrom", &json!({"source": far.0, "pickUp": {"appearance": {"fill": {"color": false}}}})).unwrap();
assert_eq!(pixel(&s, 80, 30), RED);
// Options that pick up neither color leave nothing to change, so the click is refused.
let (undo, fill) = (undo_steps(&s), s.paint.fill.clone());
let neither = json!({"source": far.0, "pickUp": {"appearance": {"fill": {"color": false}, "stroke": {"color": false}}}});
let e = s.execute("appearance.copyFrom", &neither).unwrap_err().to_string();
assert!(e.contains(REFUSED), "{e}");
assert_eq!((undo_steps(&s), &s.paint.fill), (undo, &fill), "nothing changes");
}
#[test]
fn select_part_refuses_hidden_and_locked_objects_of_the_art() {
let mut s = session();
let (a, b, c) = (square(&mut s, 10.0), square(&mut s, 60.0), square(&mut s, 110.0));
let group = id(&s.execute("object.group", &json!({"ids": [b.0, c.0]})).unwrap());
let inst = id(&s.execute("symbol.new", &json!({"name": "Three", "ids": [a.0, group.0], "type": "dynamic"})).unwrap());
// The art's first square is hidden and its group locked.
let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone();
let children = art.children_mut().unwrap();
std::sync::Arc::make_mut(&mut children[0]).visible = false;
std::sync::Arc::make_mut(&mut children[1]).locked = true;
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art);
for path in [json!([0]), json!([1]), json!([1, 0])] {
let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string();
assert!(e.contains("hidden or locked"), "{path}: {e}");
}
assert!(s.doc().unwrap().selection.symbol_part.is_none(), "nothing is picked");
// With the art's group unlocked, a locked instance's objects are still not picked.
let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone();
std::sync::Arc::make_mut(&mut art.children_mut().unwrap()[1]).locked = false;
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art);
s.execute("object.lock", &json!({"ids": [inst.0]})).unwrap();
let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1, 1]})).unwrap_err().to_string();
assert!(e.contains("hidden or locked"), "locked instance: {e}");
s.execute("object.unlockAll", &json!({})).unwrap();
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1, 1]})).unwrap();
}
#[test]
fn choosing_the_selection_tool_again_picks_the_instance_whole() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.select_tool("selection", ViewInfo::default()).unwrap();
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
assert!(s.doc().unwrap().selection.symbol_part.is_some());
let before = node(&s, inst).kind.clone();
let revision = s.doc().unwrap().revision;
s.select_tool("selection", ViewInfo::default()).unwrap();
assert!(s.doc().unwrap().selection.symbol_part.is_none());
assert_ne!(s.doc().unwrap().revision, revision, "panels cached by revision read the instance");
s.execute("object.nudge", &json!({"dx": 1})).unwrap();
assert_ne!(node(&s, inst).kind, before, "the instance moves");
}
#[test]
fn the_eyedropper_sample_taken_for_a_picked_object_is_kept() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
let green = square(&mut s, 150.0);
s.execute("paint.setFill", &json!({"ids": [green.0], "color": "#00ff00"})).unwrap();
let target = square(&mut s, 150.0);
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": green.0})).unwrap();
assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#00ff00".into()), "new art takes the sample's fill");
// A reverse Eyedropper click with nothing selected applies that sample.
s.execute("select.none", &json!({})).unwrap();
assert_eq!(s.execute("appearance.copyFrom", &json!({"source": target.0, "reverse": true})).unwrap()["ids"], json!([target.0]));
assert_eq!(node(&s, target).appearance.fill_paint().color().unwrap().to_hex(), "#00ff00");
}
#[test]
fn invert_and_complement_recolor_a_picked_object_for_its_instance_only() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap());
let art = s.doc().unwrap().doc.symbols[0].art.clone();
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
let undo = undo_steps(&s);
let r = s.execute("paint.invert", &json!({"stroke": false})).unwrap();
assert_eq!(r["changed"], 1, "{r}");
assert_eq!(undo_steps(&s), undo + 1, "one undo step");
assert_eq!(symbol_part_fill(&s, inst), "#ffff00", "the picked object's own blue, inverted");
assert_eq!(overrides(&s, inst), 1, "only the picked object changes");
assert_eq!(overrides(&s, other), 0, "the other instance keeps the symbol's look");
assert!(*s.doc().unwrap().doc.symbols[0].art == *art, "the symbol's art stays");
assert_eq!(pixel(&s, 80, 30), [255, 255, 0, 255], "the changed object");
assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look");
s.execute("paint.complement", &json!({"stroke": false})).unwrap();
assert_eq!(symbol_part_fill(&s, inst), "#0000ff", "the complement of yellow");
assert_eq!(undo_steps(&s), undo + 2, "one undo step");
}
/// One change holds one paint, so Invert and Complement are refused for type whose characters
/// differ in color, and each character's color stays. A fill gives all of them one color.
#[test]
fn invert_and_complement_are_refused_for_picked_type_whose_characters_differ() {
let mut s = session();
let t = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap());
s.execute("paint.setFill", &json!({"ids": [t.0], "color": "#ff0000"})).unwrap();
let inst = id(&s.execute("symbol.new", &json!({"name": "Word", "ids": [t.0], "type": "dynamic"})).unwrap());
let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone();
let NodeKind::Text(tx) = &mut art.kind else { panic!("type: {art:?}") };
let mut second = tx.runs[0].clone();
second.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0));
tx.runs.push(second);
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art);
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": []})).unwrap();
let undo = undo_steps(&s);
for cmd in ["paint.invert", "paint.complement"] {
let e = s.execute(cmd, &json!({"stroke": false})).unwrap_err().to_string();
assert!(e.contains(REFUSED), "{cmd}: {e}");
}
assert_eq!((overrides(&s, inst), undo_steps(&s)), (0, undo), "nothing changes");
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
s.execute("paint.invert", &json!({"stroke": false})).unwrap();
assert_eq!(symbol_part_fill(&s, inst), "#ff00ff", "one color inverts");
}
/// The color of the fill instance `inst`'s first change sets.
fn symbol_part_fill(s: &Session, inst: NodeId) -> String {
let o = node(s, inst).extras().symbol_overrides[0].clone();
o.fill.as_ref().and_then(|p| p.color()).map(|c| c.to_hex()).unwrap_or_else(|| panic!("a solid fill: {o:?}"))
}
#[test]
fn a_stroke_on_a_picked_type_object_without_a_weight_draws() {
let mut s = session();
let t = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap());
s.execute("paint.setStroke", &json!({"ids": [t.0], "none": true})).unwrap();
let inst = id(&s.execute("symbol.new", &json!({"name": "Word", "ids": [t.0], "type": "dynamic"})).unwrap());
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": []})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
let part = s.doc().unwrap().selected_symbol_part().unwrap();
let NodeKind::Text(tx) = &part.kind else { panic!("type: {part:?}") };
assert!(tx.runs.iter().all(|r| r.style.stroke_width == 1.0 && !r.style.stroke.is_none()), "{:?}", tx.runs);
}
/// The colors the Fill and Stroke proxies show, as `paint.proxies` replies them.
fn proxy_colors(s: &mut Session) -> (Option<String>, Option<String>) {
let r = s.execute("paint.proxies", &json!({})).unwrap();
assert_eq!((r["fillMixed"].clone(), r["strokeMixed"].clone()), (json!(false), json!(false)), "{r}");
let (fill, stroke) = s.proxy_paints();
assert_eq!((r["fill"].clone(), r["stroke"].clone()), (json!(fill), json!(stroke)));
(fill.color().map(|c| c.to_hex()), stroke.color().map(|c| c.to_hex()))
}
#[test]
fn the_fill_and_stroke_proxies_show_the_picked_object() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
assert_eq!(proxy_colors(&mut s), (Some("#0000ff".into()), None), "the symbol's own paint");
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
assert_eq!(proxy_colors(&mut s), (Some("#ff0000".into()), Some("#00ff00".into())), "the object's own paint");
// The other object of the same instance shows the symbol's paint.
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [0]})).unwrap();
assert_eq!(proxy_colors(&mut s), (Some("#0000ff".into()), None));
}
#[test]
fn selecting_a_guide_too_lets_go_of_the_picked_object() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
let g = s.execute("guide.add", &json!({"vertical": true, "pos": 150})).unwrap()["index"].as_u64().unwrap();
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
s.execute("guide.select", &json!({"indexes": [g], "toggle": true})).unwrap();
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.guides.len(), sel.symbol_part), (vec![inst], 1, None));
// Paint now goes to the instance, not to the object.
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
assert_eq!(overrides(&s, inst), 0);
// Pruning a selection with a guide in it lets go of the object as well.
let mut sel = s.doc().unwrap().selection.clone();
sel.symbol_part = Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![1] });
sel.prune(&s.doc().unwrap().doc);
assert_eq!(sel.symbol_part, None);
}
/// A dynamic symbol with 9-slice guides at 25%: a 100 × 100 frame at (50, 50), a 10 × 10 square
/// in its top-left corner and one in its center; its instance resized to 300 × 100.
fn sliced_panel() -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let rect =
|s: &mut Session, x: f64, y: f64, w: f64| id(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": w})).unwrap()).0;
let ids = [rect(&mut s, 50.0, 50.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0)];
let inst = id(&s.execute("symbol.new", &json!({"name": "Panel", "ids": ids, "type": "dynamic", "nineSlice": true})).unwrap());
s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 300, "height": 100, "reference": 0})).unwrap();
(s, inst)
}
#[test]
fn direct_selection_picks_and_outlines_the_object_where_a_9_slice_instance_draws_it() {
let (mut s, inst) = sliced_panel();
let part_box = |s: &Session, path: &[u32]| {
let d = &s.doc().unwrap().doc;
let o = vectorcraft_doc::hit::symbol_part_outline(d, d.node(inst).unwrap(), path).unwrap();
vectorcraft_geom::kurbo::Shape::bounding_box(&o)
};
let close = |a: Rect, b: Rect| [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6);
// The center cell (75..125 of the art) is drawn at 75..325, so the center square at 175..225;
// placed by the instance's transform alone it would be at 185..215.
assert!(close(part_box(&s, &[2]), Rect::new(175.0, 95.0, 225.0, 105.0)), "{:?}", part_box(&s, &[2]));
assert!(close(part_box(&s, &[1]), Rect::new(50.0, 50.0, 60.0, 60.0)), "the corner keeps its size: {:?}", part_box(&s, &[1]));
direct(&mut s, (177.0, 100.0), (177.0, 100.0), Mods::default());
assert_eq!(s.doc().unwrap().selection.symbol_part, Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![2] }));
direct(&mut s, (165.0, 100.0), (165.0, 100.0), Mods::default());
let picked = s.doc().unwrap().selection.symbol_part.clone().map(|p| p.path);
assert_ne!(picked, Some(vec![2]), "where the square is not drawn");
}
/// An object that is drawn once in each 9-slice cell it reaches, rather than cut at the guides
/// (here area type), is outlined where its copies are drawn.
#[test]
fn the_outline_of_an_object_drawn_in_each_9_slice_cell_matches_what_is_drawn() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let frame = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()).0;
let area = json!({"width": 80, "height": 10});
let text = id(&s.execute("text.create", &json!({"x": 60, "y": 95, "area": area, "size": 6, "text": "Hi"})).unwrap()).0;
let ids = [frame, text];
let inst = id(&s.execute("symbol.new", &json!({"name": "Bar", "ids": ids, "type": "dynamic", "nineSlice": true})).unwrap());
s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 300, "height": 100, "reference": 0})).unwrap();
let d = &s.doc().unwrap().doc;
let o = vectorcraft_doc::hit::symbol_part_outline(d, d.node(inst).unwrap(), &[1]).unwrap();
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&o);
// The left copy is drawn at 60..75, the center one (placed whole it reaches 0..400) at 75..325
// and the right one at 325..340.
let want = Rect::new(60.0, 95.0, 340.0, 105.0);
assert!([(b.x0, want.x0), (b.y0, want.y0), (b.x1, want.x1), (b.y1, want.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6), "{b:?}");
}
#[test]
fn direct_selection_picks_a_static_instance_whole_as_before() {
let mut s = session();
let inst = pair(&mut s, "static");
direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default());
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.symbol_part), (vec![inst], None));
assert!(s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).is_err(), "a static symbol");
// A dynamic one: Alt picks the instance whole, and a path naming no object is refused.
s.execute("symbol.options", &json!({"name": "Pair", "type": "dynamic"})).unwrap();
direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods { alt: true, ..Mods::default() });
assert!(s.doc().unwrap().selection.symbol_part.is_none());
assert!(s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [2]})).is_err());
assert!(s.execute("symbol.instance.selectPart", &json!({"path": [1]})).is_err(), "no id");
// Making the symbol static again lets go of the picked object.
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
s.execute("symbol.options", &json!({"name": "Pair", "type": "static"})).unwrap();
assert!(s.doc().unwrap().selection.symbol_part.is_none());
}
#[test]
fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before() {
let mut s = session();
@@ -191,3 +862,201 @@ fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before
assert_eq!(back.node(inst).unwrap().extras().symbol_overrides, doc.node(inst).unwrap().extras().symbol_overrides);
assert_eq!(back.symbols, doc.symbols);
}
#[test]
fn a_swatch_edit_recolors_a_picked_objects_own_paints() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
let inst = pair(&mut s, "dynamic");
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
s.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap();
assert_eq!(pixel(&s, 80, 30), RED);
s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap();
let fill = s.doc().unwrap().selected_symbol_part().unwrap().appearance.fill_paint();
assert_eq!(fill.color().map(|c| c.to_hex()), Some("#00ff00".into()), "{fill:?}");
assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255]);
}
#[test]
fn a_swatch_or_color_only_a_picked_objects_paint_uses_counts_as_used() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
let inst = pair(&mut s, "dynamic");
let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].clone();
assert!(unused(&mut s).as_array().unwrap().contains(&json!("Brand")));
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
s.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap();
assert!(!unused(&mut s).as_array().unwrap().contains(&json!("Brand")), "only the object's own paint uses it");
let colors = s.execute("recolor.colors", &json!({"ids": [inst.0]})).unwrap();
assert!(colors["colors"].as_array().unwrap().iter().any(|c| c["hex"] == "#ff0000" && c["swatch"] == "Brand"), "{colors}");
// A swatch edit maps the same paint once.
s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap();
assert_eq!(symbol_part_fill(&s, inst), "#00ff00");
}
/// The Eyedropper takes a picked object's paints only from a path, compound path or type.
#[test]
fn the_eyedropper_refuses_a_source_that_is_not_painted_art() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap());
let (a, b) = (square(&mut s, 150.0), square(&mut s, 150.0));
let group = id(&s.execute("object.group", &json!({"ids": [a.0, b.0]})).unwrap());
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let (fill, stroke, own, undo) = (s.paint.fill.clone(), s.paint.stroke.clone(), node(&s, inst).extras().symbol_overrides.clone(), undo_steps(&s));
s.eyedropper_sample = None;
for source in [inst, other, group] {
let e = s.execute("appearance.copyFrom", &json!({"source": source.0})).unwrap_err().to_string();
assert!(e.contains(REFUSED), "{e}");
}
// A click with the tool on an instance, the picked one included.
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("eyedropper", v).unwrap();
let e = s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 30.0), v).unwrap_err().to_string();
assert!(e.contains(REFUSED), "{e}");
s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 30.0), v).unwrap();
assert_eq!(node(&s, inst).extras().symbol_overrides, own, "the object keeps its paints");
assert_eq!(undo_steps(&s), undo, "no undo step");
assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &stroke), "the defaults for new art stay");
assert!(s.eyedropper_sample.is_none(), "nothing was sampled");
assert_eq!(pixel(&s, 80, 30), RED);
}
/// A path of a compound path gives the paint of the compound path, which holds it.
#[test]
fn the_eyedropper_samples_a_compound_path_through_its_member() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
let (a, b) = (square(&mut s, 120.0), square(&mut s, 170.0));
let compound = id(&s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap());
s.execute("paint.setFill", &json!({"ids": [compound.0], "color": "#00ff00"})).unwrap();
let member = node(&s, compound).children().unwrap()[0].id;
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": member.0})).unwrap();
assert_eq!(symbol_part_fill(&s, inst), "#00ff00");
assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255]);
}
/// A Cmd-drag lends the Selection tool, or with Alt Group Selection, which act on the whole instance.
#[test]
fn a_lent_selection_tool_moves_the_instance_of_a_picked_object() {
let cmd = Mods { cmd: true, ..Mods::default() };
let mut s = session();
let inst = pair(&mut s, "dynamic");
direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default());
assert!(s.doc().unwrap().selection.symbol_part.is_some());
let before = node(&s, inst).kind.clone();
direct(&mut s, (80.0, 30.0), (100.0, 40.0), cmd);
assert!(s.doc().unwrap().selection.symbol_part.is_none());
assert_ne!(node(&s, inst).kind, before, "the instance moves");
let mut s = session();
let inst = pair(&mut s, "dynamic");
direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default());
direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods { alt: true, ..cmd });
assert!(s.doc().unwrap().selection.symbol_part.is_none(), "Group Selection selects the instance whole");
assert_eq!(s.doc().unwrap().selection.objects, [inst]);
// A plain Direct Selection click picks the object again once the tool is back.
direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default());
assert!(s.doc().unwrap().selection.symbol_part.is_some());
}
/// The commands that change nothing in the document read the instance of a picked object, so the
/// Attributes and CSS Properties panels and the others keep showing it.
#[test]
fn read_only_commands_read_the_instance_of_a_picked_object() {
let mut failures = Vec::new();
for spec in crate::cmd::command_specs().iter().filter(|c| !c.journal) {
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
let doc = s.doc().unwrap().doc.clone();
if let Err(e) = s.execute(spec.id, &json!({}))
&& e.to_string().contains("symbol instance")
{
failures.push(format!("{}: {e}", spec.id));
}
if s.doc().is_ok_and(|st| st.doc != doc) {
failures.push(format!("{}: the document changed", spec.id));
}
// A command run after one of these is refused as before.
if s.doc().is_ok_and(|st| st.selection.symbol_part.is_some()) && s.execute("object.hide", &json!({})).is_ok() {
failures.push(format!("{}: the refusal stays off afterward", spec.id));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
s.execute("css.selection", &json!({})).unwrap();
s.execute("attributes.info", &json!({})).unwrap();
}
/// Export CSS writes a file and changes nothing in the document, so it reads the instance of a
/// picked object as it reads an instance selected whole, and the next edit is refused as before.
#[test]
fn css_export_reads_the_instance_of_a_picked_object() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.execute("select.set", &json!({"ids": [inst.0]})).unwrap();
let whole = s.execute("css.export", &json!({})).unwrap();
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let picked = s.execute("css.export", &json!({})).unwrap();
assert_eq!(picked["rules"], 1);
assert_eq!(picked["data"], whole["data"]);
assert!(s.doc().unwrap().doc == doc, "the document changed");
assert!(s.doc().unwrap().selection.symbol_part.is_some());
assert!(s.execute("object.hide", &json!({})).is_err());
}
/// A drag that moved nothing leaves Transform Again repeating the last real transformation.
#[test]
fn a_refused_drag_of_a_picked_object_is_not_kept_for_transform_again() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default());
assert!(s.doc().unwrap().selection.symbol_part.is_some());
direct(&mut s, (80.0, 30.0), (120.0, 60.0), Mods::default());
assert!(s.doc().unwrap().last_transform.is_none(), "{:?}", s.doc().unwrap().last_transform);
assert_eq!(s.doc().unwrap().selection.objects, [inst]);
}
/// A bad `target` or `aligned` on a paint command is refused with an object picked too.
#[test]
fn bad_paint_parameters_leave_a_picked_object_alone() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
let undo = undo_steps(&s);
for (cmd, p) in [
("paint.setFill", json!({"color": "#ff0000", "target": "bogus"})),
("paint.setStroke", json!({"color": "#ff0000", "target": "bogus"})),
("paint.invert", json!({"target": "bogus"})),
("paint.complement", json!({"target": "bogus"})),
("paint.setFill", json!({"color": "#ff0000", "aligned": "yes"})),
] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("`target` must") || e.contains("aligned"), "{cmd} {p}: {e}");
assert_eq!(overrides(&s, inst), 0, "{cmd} {p}");
}
assert_eq!(undo_steps(&s), undo, "no undo step");
}
/// An object picked while Replace read its file: the instance selected when Place started is
/// selected again first, so a replay of the journal replaces it.
#[test]
fn a_replace_that_finishes_after_an_object_was_picked_selects_the_instance_again() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.execute("select.set", &json!({"ids": [inst.0]})).unwrap();
let svg = "<svg xmlns='http://www.w3.org/2000/svg' width='9' height='9'><rect width='9' height='9'/></svg>";
let p = json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes()), "replace": true});
let read = s.prepare_place(&p).unwrap().read().unwrap();
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
s.place_read(read).unwrap();
assert!(s.doc().unwrap().doc.node(inst).is_none());
let last: Vec<_> = s.journal.iter().rev().take(2).rev().map(|(id, _)| id.clone()).collect();
assert_eq!(last, ["select.set", "file.place"]);
}
+5 -1
View File
@@ -222,7 +222,8 @@ fn a_version_1_documents_twist_turns_as_it_was_saved() {
/// A save records the date the document was made in, so the same commands twice gave different
/// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and
/// `SOURCE_DATE_EPOCH` for an export that takes no date param.
/// `SOURCE_DATE_EPOCH` for an export that takes no date param. The PDF a native file carries by
/// default is dated by the document's dates, not by the time of writing.
#[test]
fn a_pinned_date_makes_saves_byte_identical() {
let once = |t: i64| {
@@ -232,6 +233,9 @@ fn a_pinned_date_makes_saves_byte_identical() {
saved(&mut s, json!({}))
};
let at = 1_791_208_980;
let pdf = vectorcraft_format::pdf_content(&once(at)).expect("a native save carries a PDF by default");
let info = vectorcraft_pdf::file_info(&pdf, None).unwrap();
assert_eq!((info.created, info.modified), (Some(at), Some(at)), "the PDF has the document's dates");
assert_eq!(once(at), once(at), "the same document and date give the same bytes");
// The date really is in the bytes, so the comparison above is not vacuous.
assert_ne!(once(at), once(at + 1), "another date shows in the bytes");
+34
View File
@@ -228,6 +228,40 @@ fn diagonal_shift_snap_lands_an_edge_on_a_gridline() {
assert!(edge.iter().any(|y| ((y / g.cell).round() * g.cell - y).abs() < 1e-6), "{edge:?}, correction {correction:?}");
}
#[test]
fn snap_moves_objects_at_different_depths_by_one_plane_offset() {
let (mut s, _) = persp_session();
let rect = |s: &mut Session, x: f64| {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 380, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap())
};
let near = rect(&mut s, 240.0);
s.execute("perspective.attach", &json!({"ids": [near.0], "plane": "left"})).unwrap();
s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 50})).unwrap();
let far = rect(&mut s, 150.0);
s.execute("perspective.attach", &json!({"ids": [far.0], "plane": "left"})).unwrap();
s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 0})).unwrap();
assert_eq!(attached(&s, near), Some((Plane::Left, 0.0)));
assert_eq!(attached(&s, far), Some((Plane::Left, 50.0)));
s.execute("perspective.grid.snap", &json!({"on": true})).unwrap();
let g = grid(&s);
let (near_before, far_before) = (plane_pts(&s, near, Plane::Left, 0.0), plane_pts(&s, far, Plane::Left, 50.0));
let b = bounds_of(&near_before).union(bounds_of(&far_before));
// A move that ends 0.3 pt past a gridline crossing on the near object's plane.
let past = |v: f64| (v / g.cell).ceil() * g.cell + 0.3 - v;
let start = bounds_of(&near_before).center();
let h = g.homography_at(Plane::Left, 0.0).unwrap();
let from = h.apply(start).unwrap();
let to = h.apply(start + Vec2::new(past(b.x0), past(b.y0))).unwrap();
s.execute("perspective.move", &json!({"ids": [near.0, far.0], "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap();
let near_delta = plane_pts(&s, near, Plane::Left, 0.0)[0] - near_before[0];
let far_delta = plane_pts(&s, far, Plane::Left, 50.0)[0] - far_before[0];
assert!((near_delta - far_delta).hypot() < 1e-6, "near {near_delta:?}, far {far_delta:?}");
let on_line = |v: f64| ((v / g.cell).round() * g.cell - v).abs() < 1e-6;
let moved = b + near_delta;
assert!(on_line(moved.x0) || on_line(moved.x1), "{moved:?}");
assert!(on_line(moved.y0) || on_line(moved.y1), "{moved:?}");
}
#[test]
fn constrained_move_uses_each_objects_plane() {
let (mut s, right) = persp_session();
+40
View File
@@ -7,6 +7,32 @@ use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate};
use crate::*;
/// The saved preference reaches the real tool; older preferences keep Touching and invalid
/// values fail without changing the current setting.
#[test]
fn marquee_selection_preference_drives_the_selection_tool() {
assert_eq!(serde_json::from_value::<Prefs>(json!({})).unwrap().marquee_selection, "touching");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
for (mode, selected) in [("touching", true), ("enclosed", false), ("directional", false)] {
set_pref(&mut s, "marqueeSelection", json!(mode));
s.execute("select.none", &json!({})).unwrap();
for (k, x, y) in [(PointerKind::Down, 50.0, 50.0), (PointerKind::Drag, 150.0, 150.0), (PointerKind::Up, 150.0, 150.0)] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
assert_eq!(!s.doc().unwrap().selection.objects.is_empty(), selected, "{mode}");
let persisted = s.prefs.to_json();
assert_eq!(serde_json::from_value::<Prefs>(persisted).unwrap().marquee_selection, mode);
}
assert!(s.execute("prefs.set", &json!({"key": "marqueeSelection", "value": "unknown"})).is_err());
assert_eq!(s.prefs.marquee_selection, "directional");
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.prefs.marquee_selection, "touching");
}
#[test]
fn every_spec_matches_a_prefs_field_and_back() {
let all = Prefs::default().to_json();
@@ -2511,6 +2537,20 @@ fn a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide() {
}
}
/// General › Scroll Sideways with Cmd/Ctrl + Wheel starts on: in a new session, in a preferences
/// file saved before the option existed and after a reset.
#[test]
fn scroll_sideways_with_cmd_wheel_is_on_by_default() {
assert!(Prefs::default().ctrl_wheel_scrolls_sideways);
let old: Prefs = serde_json::from_value(json!({"zoomWithMouseWheel": false})).unwrap();
assert!(old.ctrl_wheel_scrolls_sideways);
let mut s = Session::new();
s.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": false})).unwrap();
assert!(!s.prefs.ctrl_wheel_scrolls_sideways);
s.execute("prefs.reset", &json!({})).unwrap();
assert!(s.prefs.ctrl_wheel_scrolls_sideways);
}
/// The Transform panel menu's choice starts as `default` (the panels follow General › Transform
/// Pattern Tiles): in a new session, in a preferences file saved before the choice existed and
/// after a reset; `prefs.set` takes it and the three menu choices, and nothing else.
+51
View File
@@ -59,6 +59,57 @@ fn restored(r: serde_json::Value) -> usize {
r["restored"].as_array().unwrap().len()
}
#[test]
fn native_recovery_copies_leave_out_the_pdf() {
let (mut s, store) = session();
let r = s.execute("file.recovery.save", &json!({})).unwrap();
let file = format!("{}.vectorcraft", r["saved"][0]["file"].as_str().unwrap());
let bytes = store.read(&file).unwrap();
assert!(vectorcraft_format::pdf_content(&bytes).is_none());
assert_eq!(vectorcraft_format::load(&bytes).unwrap().node_count(), s.doc().unwrap().doc.node_count());
}
#[test]
fn restored_recovery_copies_keep_the_original_pdf_preference() {
let dir = crate::tests_links::Folder::new("recovered-pdf-preference");
for pdf in [false, true] {
let (mut s, store) = session();
s.execute("file.saveAs", &json!({"path": dir.file(&format!("original-{pdf}.vectorcraft")), "pdfCompatible": pdf})).unwrap();
s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap();
let copied = s.execute("file.recovery.save", &json!({})).unwrap();
let file = format!("{}.vectorcraft", copied["saved"][0]["file"].as_str().unwrap());
assert!(vectorcraft_format::pdf_content(&store.read(&file).unwrap()).is_none());
drop(s);
let mut s = relaunch(&store);
assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1);
assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(pdf)));
let saved = s.execute("document.save", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
assert_eq!(vectorcraft_format::pdf_content(&bytes).is_some(), pdf);
}
}
#[test]
fn recovered_unsaved_documents_keep_inheriting_the_current_pdf_preference() {
let dir = crate::tests_links::Folder::new("recovered-inherited-pdf-choice");
let (mut s, store) = session();
s.execute("file.recovery.save", &json!({})).unwrap();
assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none());
s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap();
s.execute("file.saveAs", &json!({"path":dir.file("other.vectorcraft"),"pdfCompatible":false})).unwrap();
assert!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"].as_array().unwrap().is_empty());
let latest_preferences = s.prefs.clone();
drop(s);
let mut s = relaunch(&store);
// The host loads its latest preferences before offering recovery.
s.prefs = latest_preferences;
assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1);
assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none());
let saved = s.execute("document.save", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
assert!(vectorcraft_format::pdf_content(&bytes).is_none());
}
#[test]
fn recovery_save_writes_modified_documents_once() {
let (mut s, store) = session();
+141 -2
View File
@@ -157,7 +157,146 @@ fn pdf_compatible_files_carry_a_pdf() {
assert!(pdf.starts_with(b"%PDF"));
let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap();
assert_eq!(info["pages"], 3);
assert!(vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({})).unwrap()["dataBase64"])).is_none());
assert!(
vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({"pdfCompatible": false})).unwrap()["dataBase64"])).is_none()
);
}
/// A native save is portable to the other apps by default. The option is still an explicit
/// opt-out, and a remembered opt-out survives later saves of the document.
#[test]
fn native_saves_carry_all_artboards_as_pdf_by_default() {
let mut s = three_boards();
for compress in [false, true] {
let result = s.execute("file.saveCopy", &json!({"compress": compress})).unwrap();
let file = bytes_of(&result["dataBase64"]);
let pdf = vectorcraft_format::pdf_content(&file).expect("the default native save carries a PDF");
let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap();
assert_eq!(info["pages"], 3);
assert_eq!(vectorcraft_format::load(&file).unwrap().artboards.len(), 3);
}
let dir = Folder::new("pdf-opt-out");
let path = dir.file("compact.vectorcraft");
s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap();
s.execute("shape.rectangle", &json!({"x": 60, "y": 10, "width": 10, "height": 10})).unwrap();
s.execute("document.save", &json!({})).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none());
let options = s.execute("file.formatOptions", &json!({"format":"vectorcraft"})).unwrap();
assert_eq!(options["options"]["pdfCompatible"]["value"], false);
s.execute("document.save", &json!({"compress": true})).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none());
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], false);
let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap();
assert_eq!(options["options"]["pdfCompatible"]["value"], false);
s.execute("document.save", &json!({})).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none());
s.execute("document.save", &json!({"pdfCompatible": true})).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some());
}
#[test]
fn native_saves_without_artboards_keep_the_editable_document() {
let mut s = three_boards();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards.clear();
let result = s.execute("file.saveCopy", &json!({})).unwrap();
let file = bytes_of(&result["dataBase64"]);
let restored = vectorcraft_format::load(&file).unwrap();
assert!(restored.artboards.is_empty());
assert_eq!(restored.node_count(), s.doc().unwrap().doc.node_count());
assert!(vectorcraft_format::pdf_content(&file).is_none());
s.execute("document.open", &json!({"name": "no-artboards.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], true);
s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap();
let result = s.execute("file.saveCopy", &json!({})).unwrap();
assert!(vectorcraft_format::pdf_content(&bytes_of(&result["dataBase64"])).is_some());
}
#[test]
fn native_template_saves_keep_the_document_pdf_preference() {
let mut s = three_boards();
let dir = Folder::new("template-pdf-preference");
s.execute("file.saveAs", &json!({"path": dir.file("compact.vectorcraft"), "pdfCompatible": false, "version": 3})).unwrap();
let options = s.execute("file.formatOptions", &json!({"format": "template"})).unwrap();
assert_eq!(options["options"]["pdfCompatible"]["value"], false, "the save dialog must offer the same retained choice");
let r = s.execute("file.saveAsTemplate", &json!({"format": "template"})).unwrap();
let bytes = bytes_of(&r["dataBase64"]);
assert!(vectorcraft_format::pdf_content(&bytes).is_none());
assert_eq!(
vectorcraft_format::load_file(&bytes).unwrap().info.version,
vectorcraft_format::VERSION,
"unrelated format options are not carried over"
);
let r = s.execute("file.saveAsTemplate", &json!({"format": "template", "pdfCompatible": true})).unwrap();
assert!(vectorcraft_format::pdf_content(&bytes_of(&r["dataBase64"])).is_some());
}
#[test]
fn a_default_native_save_keeps_its_pdf_after_another_document_opts_out() {
let dir = Folder::new("default-native-pdf-choice");
for (index, options) in [json!({}), json!({"options":{"pdfCompatible":null}})].into_iter().enumerate() {
let mut s = three_boards();
let path = dir.file(&format!("first-{index}.vectorcraft"));
let mut params = options.as_object().unwrap().clone();
params.insert("path".into(), json!(path));
s.execute("file.saveAs", &Value::Object(params)).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some());
s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap();
s.execute("file.saveAs", &json!({"path":dir.file(&format!("other-{index}.vectorcraft")),"pdfCompatible":false})).unwrap();
s.execute("document.activate", &json!({"index":0})).unwrap();
s.execute("document.save", &json!({})).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), "the first document keeps the PDF it was saved with");
assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(true)));
}
}
#[test]
fn a_null_nested_pdf_option_keeps_the_saved_opt_out() {
let dir = Folder::new("null-nested-pdf-option");
let mut s = three_boards();
let path = dir.file("compact.vectorcraft");
s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap();
s.execute("document.save", &json!({"options": {"pdfCompatible": null}})).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none(), "null leaves the saved choice");
assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(false)));
}
#[test]
fn reopening_native_files_keeps_each_files_pdf_compatibility() {
let dir = Folder::new("pdf-reopened-options");
let mut s = three_boards();
let pdf = dir.file("portable.vectorcraft");
let compact = dir.file("compact.vectorcraft");
s.execute("file.saveAs", &json!({"path": pdf, "pdfCompatible": true})).unwrap();
s.execute("file.saveAs", &json!({"path": compact, "pdfCompatible": false})).unwrap();
s.execute("file.new", &json!({"width": 20, "height": 20})).unwrap();
let copy = s.execute("file.saveCopy", &json!({})).unwrap();
assert!(vectorcraft_format::pdf_content(&bytes_of(&copy["dataBase64"])).is_none(), "new files use the last native preference");
for (path, expected) in [(&pdf, true), (&compact, false)] {
s.execute("document.open", &json!({"path": path})).unwrap();
let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap();
assert_eq!(options["options"]["pdfCompatible"]["value"], expected);
s.execute("document.save", &json!({})).unwrap();
assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(path).unwrap()).is_some(), expected);
}
}
#[test]
fn reverting_native_files_updates_their_pdf_compatibility() {
let dir = Folder::new("pdf-revert-options");
for saved in [false, true] {
let mut s = three_boards();
let path = dir.file(if saved { "with-pdf.vectorcraft" } else { "compact.vectorcraft" });
s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": saved})).unwrap();
let replacement = s.execute("file.saveCopy", &json!({"pdfCompatible": !saved})).unwrap();
std::fs::write(&path, bytes_of(&replacement["dataBase64"])).unwrap();
s.execute("shape.rectangle", &json!({"x": 70, "y": 20, "width": 8, "height": 8})).unwrap();
s.execute("file.revert", &json!({})).unwrap();
let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap();
assert_eq!(options["options"]["pdfCompatible"]["value"], !saved);
s.execute("document.save", &json!({})).unwrap();
assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), !saved);
}
}
#[test]
@@ -429,7 +568,7 @@ fn format_options_list_the_save_options() {
assert_eq!(v["options"][k]["value"], value, "{k}");
}
let v = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap();
assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(false), true));
assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(true), true));
// Saved with them, a document remembers them for Save.
let dir = Folder::new("remember");
let path = dir.file("r.vectorcraft");
+92 -16
View File
@@ -1047,18 +1047,20 @@ fn list_markers(s: &Session, id: NodeId) -> Vec<String> {
}
/// Typed list detection: at the start of a paragraph, "1.", "1)", "A." or "A)" and a space start
/// a numbered list, and "*", "#", ">" or "+" and a space start a bulleted list with the filled dot.
/// The typed characters go, the paragraph takes the hanging indent, and the text typed next is the
/// list item's. One Undo puts the typed characters back with no list, and typing goes on after
/// them, also after a Backspace that deleted nothing.
/// a numbered list, and "*", "+", "#" or ">" and a space start a bulleted list with the filled dot,
/// the hollow circle, the filled square or the dash. The typed characters go, the paragraph takes
/// the hanging indent, and the text typed next is the list item's. One Undo puts the typed
/// characters back with no list, and typing goes on after them, also after a Backspace that
/// deleted nothing.
#[test]
fn typing_a_list_start_and_a_space_starts_a_list() {
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
let v = ViewInfo::default();
let (arabic, letters, dot) =
(ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot));
let (arabic, letters) = (ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters));
let cases = [("1.", arabic, "1."), ("1)", arabic, "1."), ("A.", letters, "A."), ("A)", letters, "A.")];
let bullets = ["*", "#", ">", "+"].map(|b| (b, dot, "\u{2022}"));
let bullets =
[("*", Bullet::Dot, "\u{2022}"), ("+", Bullet::Circle, "\u{25E6}"), ("#", Bullet::Square, "\u{25AA}"), (">", Bullet::Dash, "\u{2013}")]
.map(|(b, bullet, marker)| (b, ParaList::bulleted(bullet), marker));
for (typed, list, marker) in cases.into_iter().chain(bullets) {
let mut s = session();
s.select_tool("type", v).unwrap();
@@ -1132,11 +1134,65 @@ fn the_caret_follows_undo_and_redo_of_a_typed_list() {
}
}
/// Type › Start Lists From Typed Markers off: a list marker and a space typed at the start of a
/// paragraph stay as text and start no list; `text.startList` still starts one.
#[test]
fn typed_lists_off_leaves_typed_markers_as_text() {
let v = ViewInfo::default();
for typed in ["1.", "1)", "A.", "A)", "*", "#", ">", "+"] {
let mut s = session();
s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
for c in typed.chars().chain(" a".chars()) {
s.tool_text(&c.to_string(), v).unwrap();
}
let t = obj(&s, id);
assert_eq!((t.plain_text(), t.para_at(0).list), (format!("{typed} a"), None), "{typed}");
assert!(!undo_labels(&s).iter().any(|l| l == "Bullets and Numbering"), "{typed}");
// Turned back on, the same typing in a new paragraph starts a list.
s.execute("prefs.set", &json!({"key": "typedLists", "value": true})).unwrap();
key(&mut s, ToolKey::Enter, Mods::default());
for c in typed.chars().chain(" ".chars()) {
s.tool_text(&c.to_string(), v).unwrap();
}
let t = obj(&s, id);
assert_eq!((t.plain_text(), t.para_at(0).list, t.para_at(1).list.is_some()), (format!("{typed} a\n"), None, true), "on: {typed}");
assert_eq!(undo_labels(&s).last().map(String::as_str), Some("Bullets and Numbering"), "on: {typed}");
}
let mut s = session();
s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap();
let id = text(&mut s, "1. a");
let r = s.execute("text.startList", &json!({"id": id.0, "at": 3})).unwrap();
assert_eq!(r["started"], true);
assert_eq!(obj(&s, id).plain_text(), "a");
assert!(obj(&s, id).para_at(0).list.is_some());
}
/// `typedLists` is a Type preference, on by default: `prefs.list` lists it as a bool, and
/// `prefs.reset` of the Type category turns it back on.
#[test]
fn typed_lists_is_a_type_preference_on_by_default() {
let mut s = Session::new();
assert!(s.prefs.typed_lists);
let l = s.execute("prefs.list", &json!({})).unwrap();
let row = l.as_array().unwrap().iter().find(|e| e["key"] == "typedLists").unwrap().clone();
assert_eq!(
(&row["category"], &row["kind"], &row["label"], &row["value"]),
(&json!("Type"), &json!("bool"), &json!("Start Lists From Typed Markers"), &json!(true))
);
s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap();
assert!(!s.prefs.typed_lists);
s.execute("prefs.reset", &json!({"category": "Type"})).unwrap();
assert!(s.prefs.typed_lists);
}
/// The same characters later in a paragraph, without the space, typed in one piece with the space
/// (a paste), or at the start of a paragraph that is already in a list start nothing.
#[test]
fn list_starts_typed_elsewhere_or_pasted_start_no_list() {
use vectorcraft_doc::text::{NumberStyle, ParaList};
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
let v = ViewInfo::default();
let mut s = session();
s.select_tool("type", v).unwrap();
@@ -1151,12 +1207,26 @@ fn list_starts_typed_elsewhere_or_pasted_start_no_list() {
for c in "1.Pears".chars() {
s.tool_text(&c.to_string(), v).unwrap();
}
key(&mut s, ToolKey::Enter, Mods::default());
s.tool_text("* ", v).unwrap();
for pasted in ["* ", "+ ", "# ", "> "] {
key(&mut s, ToolKey::Enter, Mods::default());
s.tool_text(pasted, v).unwrap();
}
let t = obj(&s, id);
assert_eq!(t.plain_text(), "Fruit 1. * A) x\n1. Apples\n1.Pears\n* ");
assert_eq!((0..4).map(|i| t.para_at(i).list).collect::<Vec<_>>(), [None, None, None, None]);
assert_eq!(t.plain_text(), "Fruit 1. * A) x\n1. Apples\n1.Pears\n* \n+ \n# \n> ");
assert!((0..7).all(|i| t.para_at(i).list.is_none()));
assert!(!undo_labels(&s).iter().any(|l| l == "Bullets and Numbering"));
// A paragraph in a bulleted list keeps its bullet when another list start is typed before it.
let id = text(&mut s, "Item");
s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet", "bullet": "dot"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
click(&mut s, 101.0, 95.0);
s.set_tool_option("select", &json!({"start": 0, "end": 0}));
for c in "+ ".chars() {
s.tool_text(&c.to_string(), v).unwrap();
}
let t = obj(&s, id);
assert_eq!((t.plain_text().as_str(), t.para_at(0).list), ("+ Item", Some(ParaList::bulleted(Bullet::Dot))));
assert_eq!(list_markers(&s, id), ["\u{2022}"]);
// A paragraph in a list keeps its list, and the typed characters stay as text.
let id = text(&mut s, "Item");
s.execute("text.setFormat", &json!({"ids": [id.0], "list": "number"})).unwrap();
@@ -1188,10 +1258,16 @@ fn start_list_command_removes_the_typed_characters_and_starts_the_list() {
assert_eq!(list_markers(&s, id), ["1."]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((obj(&s, id).plain_text().as_str(), obj(&s, id).para_at(1).list), ("Fruit\n1) Apples", None));
let id = text(&mut s, "x\n# ");
let r = s.execute("text.startList", &json!({"id": id.0})).unwrap();
assert_eq!((r["started"].clone(), obj(&s, id).plain_text()), (json!(true), "x\n".to_string()));
assert_eq!(obj(&s, id).para_at(1).list, Some(ParaList::bulleted(Bullet::Dot)));
for (typed, bullet, marker) in
[("* ", Bullet::Dot, "\u{2022}"), ("+ ", Bullet::Circle, "\u{25E6}"), ("# ", Bullet::Square, "\u{25AA}"), ("> ", Bullet::Dash, "\u{2013}")]
{
let id = text(&mut s, &format!("x\n{typed}"));
let r = s.execute("text.startList", &json!({"id": id.0})).unwrap();
assert_eq!((r["started"].clone(), obj(&s, id).plain_text()), (json!(true), "x\n".to_string()), "{typed:?}");
assert_eq!(obj(&s, id).para_at(1).list, Some(ParaList::bulleted(bullet)), "{typed:?}");
s.execute("text.editRange", &json!({"id": id.0, "start": 2, "end": 2, "insert": "Item"})).unwrap();
assert_eq!(list_markers(&s, id), [marker], "{typed:?}");
}
let steps = undo_labels(&s).len();
let id = text(&mut s, "1.x");
assert_eq!(s.execute("text.startList", &json!({"id": id.0, "at": 3})).unwrap()["started"], json!(false));
+19
View File
@@ -91,6 +91,25 @@ fn a_gesture_keeps_what_it_changed_and_a_document_switch_keeps_options() {
assert_eq!(s.tool_options()["segments"], json!(14));
}
/// Escape on a grid drag puts back the dividers its arrows changed, in the saved options too,
/// while an option set during the drag stays.
#[test]
fn escape_on_a_drag_saves_the_options_it_put_back() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let mut s = session();
select(&mut s, "rectangularGrid");
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap();
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
// Saves every option, the rows the arrow changed included.
s.set_tool_option("columns", &json!(8));
assert_eq!(s.prefs.tool_settings["rectangularGrid"]["rows"], json!(6));
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
let saved = &s.prefs.tool_settings["rectangularGrid"];
assert_eq!((saved["rows"].clone(), saved["columns"].clone()), (json!(5), json!(8)));
}
/// A dropped press (a long touch, a canceled touch, a second finger) keeps nothing it began: the
/// tool ends at rest with the options it had before the press, and the options the drag changed
/// are not stored. A release stores them.
+115 -16
View File
@@ -97,7 +97,9 @@ fn applying_a_dataset_reports_the_area_type_it_overflows() {
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [a.0, p.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Title", "ids": [a.0]})).unwrap();
// `variable.bind` refuses a second text variable on one type object; a document saved
// before it did can hold two.
Arc::make_mut(&mut s.doc_mut().unwrap().doc).variables.bind(a, "Title");
s.execute("dataset.new", &json!({"name": "Short", "values": {"Name": "Ann", "Title": "Ann"}})).unwrap();
s.execute("dataset.new", &json!({"name": "Long", "values": {"Name": long, "Title": long}})).unwrap();
@@ -263,6 +265,7 @@ fn a_row_with_no_value_for_a_bound_variable_warns_that_nothing_was_applied() {
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64(), &v["warning"]), (Some(0), Some(1), &json!(warning(b))), "{v}");
// Bound to a variable with a value, the row applies it and gives no warning.
s.execute("variable.unbind", &json!({"variable": "Other", "ids": [t.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
let v = s.execute("dataset.select", &json!({"name": a})).unwrap();
assert_eq!(v["applied"].as_u64(), Some(1), "{v}");
@@ -361,50 +364,146 @@ fn one_object_can_be_driven_by_several_variables() {
let t = text(&mut s, "x");
let r = rect(&mut s);
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Shown", "kind": "visibility"})).unwrap();
s.execute("variable.define", &json!({"name": "Badge", "kind": "visibility"})).unwrap();
// A text object driven by two text variables keeps both.
// A text object driven by a text variable and a visibility variable keeps both.
assert_eq!(s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap()["bound"], 1);
let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
let v = s.execute("variable.bind", &json!({"variable": "Shown", "ids": [t.0]})).unwrap();
assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(1), json!([])), "Title is untouched");
// A rectangle driven by a visibility variable as well as nothing else yet.
s.execute("variable.bind", &json!({"variable": "Badge", "ids": [r.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "D", "values": {"Title": "Hat", "Price": "9.99", "Badge": false}})).unwrap();
s.execute("dataset.new", &json!({"name": "D", "values": {"Title": "Hat", "Shown": true, "Badge": false}})).unwrap();
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(3), Some(0)), "every binding applied");
// Both text variables are counted; the later one in definition order is what is left.
assert_eq!(plain(&s, t), "9.99");
assert_eq!(plain(&s, t), "Hat");
assert!(s.doc().unwrap().doc.node(t).unwrap().visible);
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
let vars = s.execute("variable.list", &json!({})).unwrap();
let of = |n: &str| vars["variables"].as_array().unwrap().iter().find(|v| v["name"] == n).unwrap()["bindings"].as_u64();
assert_eq!((of("Title"), of("Price"), of("Badge")), (Some(1), Some(1), Some(1)));
assert_eq!((of("Title"), of("Shown"), of("Badge")), (Some(1), Some(1), Some(1)));
// Binding again to one it already has is not a change, and says so.
let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
let v = s.execute("variable.bind", &json!({"variable": "Shown", "ids": [t.0]})).unwrap();
assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(0), json!([t.0])));
// Unbinding one variable leaves the object's others alone.
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Price"})).unwrap()["unbound"], 1);
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Shown"})).unwrap()["unbound"], 1);
s.execute("edit.undo", &json!({})).unwrap();
let vars = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(vars["variables"][1]["bindings"], 1, "the other binding is still there");
assert!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Ghost"})).is_err());
// Deleting a variable drops only its own binding.
s.execute("variable.delete", &json!({"names": ["Price"]})).unwrap();
s.execute("variable.delete", &json!({"names": ["Shown"]})).unwrap();
let vars = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(vars["variables"][0]["bindings"], 1, "Title still drives the text");
}
/// A type object holds one text variable: binding another text variable to it is refused, the
/// error names the one it has, and nothing is bound, not even the other objects of the call.
/// Variables of other kinds still share the object.
#[test]
fn a_type_object_holds_one_text_variable() {
let mut s = session();
let t = text(&mut s, "x");
let u = text(&mut s, "y");
for (name, kind) in [("Title", "text"), ("Price", "text"), ("Shown", "visibility")] {
s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap();
}
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
let bindings = |s: &Session, name: &str| s.doc().unwrap().doc.variables.objects_of(name);
let e = s.execute("variable.bind", &json!({"variable": "Price", "ids": [u.0, t.0]})).unwrap_err().to_string();
assert!(e.contains("`variable.bind`") && e.contains(&format!("node {} already has the text variable `Title`", t.0)), "{e}");
assert_eq!((bindings(&s, "Title"), bindings(&s, "Price")), (vec![t], vec![]), "nothing was bound");
// The same variable again is not a second one, and another type object takes Price.
assert_eq!(s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap()["already"], json!([t.0]));
s.execute("variable.bind", &json!({"variable": "Price", "ids": [u.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Shown", "ids": [t.0, u.0]})).unwrap();
assert_eq!(bindings(&s, "Shown"), vec![t, u]);
// After Title is unbound from it, the object takes Price.
s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
assert_eq!(bindings(&s, "Price"), vec![t, u]);
}
/// Make Text Dynamic refuses type that already has a text variable and defines no variable;
/// Make Visibility Dynamic still binds it.
#[test]
fn make_text_dynamic_refuses_type_that_has_a_text_variable() {
let mut s = session();
let t = text(&mut s, "Alice");
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
s.execute("variable.makeTextDynamic", &json!({})).unwrap();
let e = s.execute("variable.makeTextDynamic", &json!({})).unwrap_err().to_string();
assert!(e.contains("`variable.makeTextDynamic`") && e.contains(&format!("node {} already has the text variable `Alice`", t.0)), "{e}");
let vars = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(vars["variables"].as_array().map(Vec::len), Some(1), "{vars}");
let v = s.execute("variable.makeVisibilityDynamic", &json!({})).unwrap();
assert_eq!(v["ids"], json!([t.0]));
}
/// With unbound type selected before type that has a text variable, Make Text Dynamic is still
/// refused: it defines no variable and binds neither object.
#[test]
fn make_text_dynamic_refuses_a_selection_where_a_later_object_has_a_text_variable() {
let mut s = session();
let u = text(&mut s, "Bob");
let t = text(&mut s, "Alice");
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
s.execute("variable.makeTextDynamic", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [u.0, t.0]})).unwrap();
let e = s.execute("variable.makeTextDynamic", &json!({})).unwrap_err().to_string();
assert!(e.contains(&format!("node {} already has the text variable `Alice`", t.0)), "{e}");
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.variables.variables.len(), 1, "no variable was defined");
assert!(!doc.variables.bindings.contains_key(&u), "the unbound object stays unbound");
assert_eq!(doc.variables.bindings[&t].iter().collect::<Vec<_>>(), ["Alice"]);
}
/// A document saved with two text variables on one type object opens with both bindings, and
/// applying a row still writes both, the later one defined last. Binding either again changes
/// nothing, and binding a third is refused with the first one named.
#[test]
fn a_document_with_two_text_variables_on_one_type_object_opens_unchanged() {
let mut s = session();
let t = text(&mut s, "x");
let u = text(&mut s, "y");
for name in ["Title", "Price", "Note"] {
s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
}
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
Arc::make_mut(&mut s.doc_mut().unwrap().doc).variables.bind(t, "Price");
s.execute("dataset.new", &json!({"name": "D", "values": {"Title": "Hat", "Price": "9.99"}})).unwrap();
let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
let mut o = Session::new();
o.execute("document.open", &json!({"name": "two.vectorcraft", "dataBase64": saved})).unwrap();
let held: Vec<String> = o.doc().unwrap().doc.variables.bindings[&t].iter().cloned().collect();
assert_eq!(held, ["Price", "Title"]);
let v = o.execute("dataset.select", &json!({"name": "D"})).unwrap();
assert_eq!(v["applied"], json!(2), "{v}");
assert_eq!(plain(&o, t), "9.99");
for name in ["Title", "Price"] {
let v = o.execute("variable.bind", &json!({"variable": name, "ids": [t.0, u.0]})).unwrap();
assert_eq!((&v["bound"], &v["already"]), (&json!(1), &json!([t.0])), "{name}: {v}");
o.execute("edit.undo", &json!({})).unwrap();
}
let e = o.execute("variable.bind", &json!({"variable": "Note", "ids": [t.0]})).unwrap_err().to_string();
assert!(e.contains(&format!("node {} already has the text variable `Title`", t.0)), "{e}");
}
/// `unbound` counts what was actually dropped, like `variable.delete` does.
#[test]
fn unbind_reports_what_it_actually_removed() {
let mut s = session();
let t = text(&mut s, "x");
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Price", "kind": "visibility"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap()["unbound"], 1);
@@ -552,8 +651,9 @@ fn making_a_selection_dynamic_names_and_binds_it() {
let v = s.execute("variable.makeVisibilityDynamic", &json!({})).unwrap();
assert_eq!(v["kind"], "visibility");
// The same object twice takes a number rather than colliding with the first variable.
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
// Type with the same name takes a number rather than colliding with the first variable.
let twin = text(&mut s, "Alice");
s.execute("select.set", &json!({"ids": [twin.0]})).unwrap();
let again = s.execute("variable.makeTextDynamic", &json!({})).unwrap();
assert_eq!(again["name"], "Alice 2");
let vars = s.execute("variable.list", &json!({})).unwrap();
@@ -1254,8 +1354,7 @@ fn a_row_writes_type_inside_a_group_it_shows_or_hides() {
assert!(!s.doc().unwrap().doc.node(g).unwrap().visible);
}
/// Of two visibility variables on one object, the one defined later decides whether it shows,
/// as with two text variables.
/// Of two visibility variables on one object, the one defined later decides whether it shows.
#[test]
fn the_later_visibility_variable_on_an_object_wins() {
let mut s = session();
+29
View File
@@ -117,6 +117,8 @@ impl Session {
// `select_tool` calls its [`Tool::chosen_again`] (the Blend tool finishes its blend).
let again = self.lender.as_ref().unwrap_or(&self.tool).id() == id;
self.switch_tool(id, view)?;
// `switch_tool` returns early for the tool already active.
self.select_whole_instance(id);
if again {
let acts = self.with_tool_cx(view, |t, cx| t.chosen_again(cx));
self.apply_actions(acts)?;
@@ -147,10 +149,24 @@ impl Session {
}
if vectorcraft_tools::catalog::is_selection_tool(id) {
self.last_selection_tool = Some(self.tool.id());
self.select_whole_instance(id);
}
Ok(())
}
/// The selection tools other than Direct Selection pick whole objects: with one of them chosen,
/// an object selected in a symbol instance's art gives way to the instance. The revision
/// changes with it, so panels that cache by revision show the instance.
fn select_whole_instance(&mut self, id: &str) {
if id != "directSelection"
&& vectorcraft_tools::catalog::is_selection_tool(id)
&& let Some(st) = self.active_mut()
&& st.selection.symbol_part.take().is_some()
{
st.revision += 1;
}
}
/// The selection tool Cmd lends tool `id` for a drag: the one chosen last; until one is,
/// Direct Selection to the tools that edit anchors (the Pen) and Selection to the others.
/// Cmd+Alt lends Group Selection while Direct Selection is active. Cmd still lends the last
@@ -200,6 +216,8 @@ impl Session {
// Fails only without a document, and then there is no typing to keep.
let _ = crate::cmd::settle_interaction(self);
}
// The Selection and Group Selection tools act on the instance whole.
self.select_whole_instance(id);
let lent = self.make_tool(id);
self.lender = Some(std::mem::replace(&mut self.tool, lent));
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
@@ -319,6 +337,11 @@ impl Session {
slices_locked: self.menu.slices_locked,
auto_add_delete: !self.prefs.disable_auto_add_delete,
selection_tolerance: self.prefs.selection_tolerance,
marquee_mode: match self.prefs.marquee_selection.as_str() {
"enclosed" => vectorcraft_tools::MarqueeMode::Enclosed,
"directional" => vectorcraft_tools::MarqueeMode::Directional,
_ => vectorcraft_tools::MarqueeMode::Touching,
},
anchor_size: self.prefs.anchor_size,
path_only: self.prefs.object_selection_by_path_only,
type_path_only: self.prefs.type_selection_by_path_only,
@@ -332,6 +355,7 @@ impl Session {
pen_rubber_band: self.prefs.pen_rubber_band,
curvature_rubber_band: self.prefs.curvature_rubber_band,
placeholder_text: self.prefs.placeholder_text,
typed_lists: self.prefs.typed_lists,
smart_guide_color: rgb(&self.prefs.smart_guide_color, vectorcraft_tools::guides::MAGENTA),
alignment_guides: self.prefs.alignment_guides,
anchor_path_labels: self.prefs.anchor_path_labels,
@@ -577,6 +601,11 @@ impl Session {
}
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
self.take_tool_panic()?;
// A key that cancels a gesture may put back options the gesture changed (Escape on an
// arc, spiral or grid drag): keep them.
if acts.contains(&Action::Cancel) {
self.keep_tool_settings();
}
self.apply_actions(acts)
}
Binary file not shown.
Binary file not shown.

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