mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 21:14:18 +00:00
Merge remote-tracking branch 'upstream/main' into fix/ai-import-brushes
# Conflicts: # crates/eps/src/import/ai/brush.rs # docs/file-format-parity.md # docs/target-app-parity.md
This commit is contained in:
@@ -1,5 +1,21 @@
|
||||
# Asset attribution
|
||||
|
||||
## Indic hyphenation patterns
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `crates/text/patterns/hyph-hi.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-hi.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-mr.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-mr.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-gu.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-gu.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-bn.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-bn.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-pa.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pa.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-ta.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-ta.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-te.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-te.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-kn.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-kn.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-ml.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-ml.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-or.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-or.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-as.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-as.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
|
||||
Every non-code asset in this repository (icons, images, fonts, example art, colour profiles, presets) is listed here with its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset file is missing from this table.
|
||||
|
||||
**Policy (mandatory):** VectorCraft contains **no Adobe iconography, images, artwork, presets, swatch/brush/symbol/pattern libraries or ICC profiles**. Every asset is either original work by VectorCraft contributors, or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution), and it is attributed below. Screenshots of Adobe software are never committed. The one exception is the ArtCraft name, wordmark and logos in `docs/brand/`: ArtCraft trademarks, not open source, usable only under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Attribution
|
||||
|
||||
The complete historical asset manifest is [ASSETS.md](ASSETS.md). These hyphenation assets use the MIT option, with the complete copyright and permission notices preserved in each file.
|
||||
|
||||
| Asset | Author | Source | SPDX license | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `crates/text/patterns/hyph-hi.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-hi.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-mr.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-mr.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-gu.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-gu.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-bn.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-bn.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-pa.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pa.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-ta.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-ta.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-te.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-te.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-kn.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-kn.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-ml.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-ml.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-or.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-or.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-as.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-as.tex | MIT | Unmodified; full license in file |
|
||||
Generated
+12
@@ -1093,6 +1093,17 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -6594,6 +6605,7 @@ dependencies = [
|
||||
name = "vectorcraft-web"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
|
||||
@@ -136,3 +136,8 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Indic hyphenation patterns (crates/text/patterns/):
|
||||
Copyright (C) 2016 Santhosh Thottingal, used under the MIT license option.
|
||||
Original copyright and full license notices are preserved in each hyph-*.tex file.
|
||||
Source: hyphenation/tex-hyphen, commit 5684c0f51c0b81133db2efbe60a408b4155a3ff5.
|
||||
|
||||
@@ -253,7 +253,7 @@ The scores are self-assessed from our code against public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
|
||||
- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes leaves out live effects and writes symbols and live objects as their art);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in);
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ and the work list in [`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
**Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance,
|
||||
type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's
|
||||
own `.ai` files open with a wrong layer structure in real cases and can't be saved back with Illustrator's
|
||||
editing data, the interaction details have never been checked side by side, and some machines fail to start the
|
||||
own `.ai` files open with a wrong layer structure in real cases and are saved back with an editing copy that
|
||||
leaves out live effects and writes symbols and live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the
|
||||
app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow
|
||||
gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print
|
||||
production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without
|
||||
|
||||
@@ -16,6 +16,8 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
|
||||
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" }
|
||||
vectorcraft-format = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
//! `window.vectorcraft`: the control channel for the page, as promises.
|
||||
//!
|
||||
//! `request`, `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot` come from
|
||||
//! `craft-web-api` (storytold/craft-libs#413), the same in every Craft app; VectorCraft adds its own
|
||||
//! functions on the same object.
|
||||
//!
|
||||
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
|
||||
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
|
||||
//! the CLI and the MCP server:
|
||||
//!
|
||||
//! ```js
|
||||
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
|
||||
//! await vectorcraft.execute("shape.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
|
||||
//! await vectorcraft.request("ui.inspect") // control method → its result
|
||||
//! await vectorcraft.commands() // command list
|
||||
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! const {data} = await vectorcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
|
||||
//! vectorcraft.captureDownloads(true) // saves stay in the page…
|
||||
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
|
||||
@@ -17,21 +21,24 @@
|
||||
//! Results resolve with the method's `result`; errors reject with an `Error`.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
use vectorcraft_ui_egui::{ControlRequest, ControlTicket as Ticket};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{JsFuture, future_to_promise};
|
||||
|
||||
use crate::saved_files::SavedFiles;
|
||||
|
||||
use crate::web::Inbox;
|
||||
|
||||
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
|
||||
|
||||
thread_local! {
|
||||
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
|
||||
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
|
||||
static CAPTURE: Cell<bool> = const { Cell::new(false) };
|
||||
@@ -69,77 +76,96 @@ fn to_js(v: &Value) -> JsValue {
|
||||
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
fn from_js(v: &JsValue) -> Value {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return json!({});
|
||||
/// A reply as a promise outcome: the `result`, or an `Error` carrying the `error`.
|
||||
fn outcome(reply: &Value) -> Result<JsValue, JsValue> {
|
||||
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
|
||||
} else {
|
||||
Err(js_sys::Error::new(reply.get("error").and_then(Value::as_str).unwrap_or("error")).into())
|
||||
}
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Settle the promises of answered requests (called every frame).
|
||||
pub fn poll_replies() {
|
||||
WAITING.with(|w| {
|
||||
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
|
||||
Ok(v) => {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
|
||||
} else {
|
||||
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
|
||||
}
|
||||
false
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
|
||||
/// timeout fired) gives the request up if the app has not started it, so the app never runs it late.
|
||||
struct Waiting {
|
||||
reply: Receiver<Value>,
|
||||
ticket: Ticket,
|
||||
answered: bool,
|
||||
}
|
||||
|
||||
impl Waiting {
|
||||
/// The app's reply once it has answered or gone away; `None` while the request is waiting.
|
||||
fn poll(&mut self) -> Option<Value> {
|
||||
match self.reply.try_recv() {
|
||||
Ok(reply) => {
|
||||
self.answered = true;
|
||||
Some(reply)
|
||||
}
|
||||
Err(TryRecvError::Empty) => true,
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
|
||||
false
|
||||
self.answered = true;
|
||||
Some(json!({"ok": false, "error": "request dropped"}))
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request(method: &str, params: Value) -> js_sys::Promise {
|
||||
let method = method.to_string();
|
||||
let p = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
|
||||
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
|
||||
if sent {
|
||||
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
|
||||
} else {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
|
||||
impl Drop for Waiting {
|
||||
fn drop(&mut self) {
|
||||
if !self.answered {
|
||||
self.ticket.give_up();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a control request for the app and wait for its reply, as the protocol's `{ok, result}` /
|
||||
/// `{ok: false, error}` object.
|
||||
fn send(method: &str, params: Value) -> Pin<Box<dyn Future<Output = Value>>> {
|
||||
let (req, rx) = ControlRequest::new(method, params);
|
||||
let ticket = req.ticket();
|
||||
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
|
||||
if !sent {
|
||||
return Box::pin(async { json!({"ok": false, "error": "VectorCraft is not running yet"}) });
|
||||
}
|
||||
repaint();
|
||||
let mut waiting = Waiting { reply: rx, ticket, answered: false };
|
||||
Box::pin(async move {
|
||||
loop {
|
||||
if let Some(reply) = waiting.poll() {
|
||||
return reply;
|
||||
}
|
||||
sleep(POLL_MS).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
repaint();
|
||||
p
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
|
||||
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
|
||||
}
|
||||
|
||||
/// Install `window.vectorcraft`.
|
||||
/// Install `window.vectorcraft`: the crate's functions, then VectorCraft's own (`inspect` is its
|
||||
/// `ui.inspect`).
|
||||
pub fn install() -> Result<(), JsValue> {
|
||||
let obj = js_sys::Object::new();
|
||||
let request: Request = Rc::new(|method, params| send(&method, params));
|
||||
Rc::new(PageApi::new("vectorcraft", Methods::default(), request)).install()?;
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let obj: js_sys::Object = js_sys::Reflect::get(&window, &"vectorcraft".into())?.unchecked_into();
|
||||
set_fn(
|
||||
&obj,
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(¶ms)))
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
|
||||
request("engine.execute", json!({"command": command, "params": from_js(¶ms)}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
|
||||
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"screenshot",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(¶ms))).into_js_value(),
|
||||
"inspect",
|
||||
Closure::<dyn Fn() -> js_sys::Promise>::new(|| future_to_promise(async { outcome(&send("ui.inspect", json!({})).await) })).into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
@@ -178,7 +204,5 @@ pub fn install() -> Result<(), JsValue> {
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -370,7 +370,6 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
let Some(app) = &mut self.app else { return };
|
||||
app.tick(ctx);
|
||||
crate::api::poll_replies();
|
||||
app.finish_ime_logic(ctx);
|
||||
}
|
||||
|
||||
@@ -423,7 +422,6 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
app.ui(ui);
|
||||
}
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ impl eframe::App for App {
|
||||
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
|
||||
#[cfg(windows)]
|
||||
windows_stylus::raw_input_hook(raw);
|
||||
// The characters Alt + keypad codes typed (#5108).
|
||||
#[cfg(windows)]
|
||||
super::windows_alt_codes::raw_input_hook(raw);
|
||||
file_access::unconfined(|| self.app.raw_input_hook(ctx, raw));
|
||||
// A mouse-up that never reached the window (#4326) is delivered once AppKit reports
|
||||
// that no mouse button is down.
|
||||
|
||||
@@ -56,6 +56,7 @@ mod system_fonts;
|
||||
mod tablet;
|
||||
mod window;
|
||||
mod window_frame;
|
||||
mod windows_alt_codes;
|
||||
#[cfg(windows)]
|
||||
mod windows_cores;
|
||||
mod windows_stylus;
|
||||
@@ -314,13 +315,18 @@ fn main() -> std::process::ExitCode {
|
||||
};
|
||||
// With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end
|
||||
// is down; the message hook notes each pointer contact and whether it starts with the barrel
|
||||
// button held, and writes the pen's end into the pen feed (`windows_stylus`).
|
||||
// button held, and writes the pen's end into the pen feed (`windows_stylus`). It also notes the
|
||||
// characters Alt + keypad codes type (`windows_alt_codes`).
|
||||
#[cfg(windows)]
|
||||
let options = {
|
||||
use winit::platform::windows::EventLoopBuilderExtWindows as _;
|
||||
let mut options = options;
|
||||
options.event_loop_builder = Some(Box::new(|builder| {
|
||||
builder.with_msg_hook(windows_stylus::msg_hook);
|
||||
builder.with_msg_hook(|msg| {
|
||||
// Alt + keypad codes (#5108), which winit drops; neither hook consumes a message.
|
||||
windows_alt_codes::msg_hook(msg);
|
||||
windows_stylus::msg_hook(msg)
|
||||
});
|
||||
}));
|
||||
options
|
||||
};
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Alt + numeric-keypad codes on Windows (#5108): holding Alt, typing a decimal code on the
|
||||
//! numeric keypad (NumLock on) and releasing Alt types that character (`Alt+0176` → `°`).
|
||||
//!
|
||||
//! Windows does the work: when Alt comes up after keypad digits, `TranslateMessage` posts a
|
||||
//! `WM_CHAR` holding the character the code stands for (in the system's ANSI code page for a code
|
||||
//! with a leading 0, else its OEM code page), the convention every Windows text field follows
|
||||
//! (<https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input>). winit
|
||||
//! 0.30 reads a `WM_CHAR` only as part of the key press before it, so it drops this one, which
|
||||
//! follows a key release, and egui never sees the text. winit's message hook ([`msg_hook`]) sees
|
||||
//! the message before winit handles it: [`AltCodes`] notes keypad digits typed with Alt held, takes
|
||||
//! the character that follows Alt's release, and [`raw_input_hook`] hands it to the frame as typed
|
||||
//! text, so the Type tool (or a panel's text field) types it like any other character, in the
|
||||
//! same undo step as the typing round it. The keypad digits themselves are taken out of the frame,
|
||||
//! so they don't run a shortcut bound to Alt and a digit.
|
||||
//!
|
||||
//! egui reports a keypad digit as the same key as the digit above the letters, so the other
|
||||
//! platforms, whose systems have no such convention, can't tell them apart either.
|
||||
|
||||
#![cfg_attr(not(windows), allow(dead_code))]
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use egui::{Event, Key};
|
||||
|
||||
const WM_KEYDOWN: u32 = 0x0100;
|
||||
const WM_KEYUP: u32 = 0x0101;
|
||||
const WM_CHAR: u32 = 0x0102;
|
||||
const WM_SYSKEYDOWN: u32 = 0x0104;
|
||||
const WM_SYSKEYUP: u32 = 0x0105;
|
||||
const WM_SYSCHAR: u32 = 0x0106;
|
||||
/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys
|
||||
/// are Insert, End, … and type no code.
|
||||
const VK_NUMPAD0: usize = 0x60;
|
||||
const VK_NUMPAD9: usize = 0x69;
|
||||
/// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt.
|
||||
const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5];
|
||||
|
||||
thread_local! {
|
||||
/// The UI thread's sequence: [`msg_hook`] and [`raw_input_hook`] both run on it.
|
||||
static CODES: RefCell<AltCodes> = RefCell::new(AltCodes::default());
|
||||
}
|
||||
|
||||
/// Where an Alt + keypad code stands, from the keyboard messages of the thread's queue.
|
||||
#[derive(Default)]
|
||||
struct AltCodes {
|
||||
/// Keypad digits typed while Alt has been held.
|
||||
digits: usize,
|
||||
/// Alt came up after keypad digits: the next character message is the code's character.
|
||||
armed: bool,
|
||||
/// A high surrogate waiting for its low half.
|
||||
high: Option<u16>,
|
||||
/// Characters the codes typed, for the next frame.
|
||||
typed: String,
|
||||
/// Keypad digit presses and releases with Alt held since the last frame, which egui reports
|
||||
/// as Alt and a digit: taken out of the frame.
|
||||
keypad_events: usize,
|
||||
}
|
||||
|
||||
impl AltCodes {
|
||||
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit).
|
||||
fn message(&mut self, message: u32, wparam: usize) {
|
||||
let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam);
|
||||
match message {
|
||||
// Windows sends a key pressed with Alt held as a system key.
|
||||
WM_SYSKEYDOWN if keypad => {
|
||||
self.digits += 1;
|
||||
self.keypad_events += 1;
|
||||
self.armed = false;
|
||||
}
|
||||
WM_SYSKEYUP if keypad => self.keypad_events += 1,
|
||||
WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {}
|
||||
// Any other key ends the sequence and keeps its own meaning.
|
||||
WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(),
|
||||
WM_KEYUP | WM_SYSKEYUP if ALT_KEYS.contains(&wparam) => {
|
||||
self.armed = self.digits > 0;
|
||||
self.digits = 0;
|
||||
}
|
||||
WM_CHAR | WM_SYSCHAR if self.armed => {
|
||||
let unit = (wparam & 0xffff) as u16;
|
||||
if (0xd800..0xdc00).contains(&unit) {
|
||||
self.high = Some(unit);
|
||||
return;
|
||||
}
|
||||
let units: Vec<u16> = self.high.take().into_iter().chain([unit]).collect();
|
||||
self.typed.extend(char::decode_utf16(units).filter_map(Result::ok).filter(|c| !c.is_control()));
|
||||
self.armed = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.digits = 0;
|
||||
self.armed = false;
|
||||
self.high = None;
|
||||
}
|
||||
|
||||
/// Hand the frame `events` the characters typed since the last frame, as typed text, and take
|
||||
/// out the keypad digits typed with Alt held.
|
||||
fn drain(&mut self, events: &mut Vec<Event>) {
|
||||
let mut keypad = std::mem::take(&mut self.keypad_events);
|
||||
if keypad > 0 {
|
||||
events.retain(|e| {
|
||||
let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt);
|
||||
if digit && keypad > 0 {
|
||||
keypad -= 1;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
if !self.typed.is_empty() {
|
||||
events.push(Event::Text(std::mem::take(&mut self.typed)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// winit's message hook for the keyboard: notes Alt + keypad codes and the characters they type.
|
||||
/// It never consumes the message.
|
||||
#[cfg(windows)]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn msg_hook(msg: *const std::ffi::c_void) {
|
||||
use windows::Win32::UI::WindowsAndMessaging::MSG;
|
||||
// SAFETY: winit passes the `MSG` that `PeekMessageW` has just filled, which lives for this
|
||||
// call; `as_ref` gives `None` for a null pointer.
|
||||
let Some(msg) = (unsafe { msg.cast::<MSG>().as_ref() }) else {
|
||||
return;
|
||||
};
|
||||
if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) {
|
||||
CODES.with(|c| {
|
||||
if let Ok(mut c) = c.try_borrow_mut() {
|
||||
c.message(msg.message, msg.wParam.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Before egui reads the main window's frame: the characters Alt + keypad codes typed, as text.
|
||||
pub fn raw_input_hook(raw: &mut egui::RawInput) {
|
||||
CODES.with(|c| {
|
||||
if let Ok(mut c) = c.try_borrow_mut() {
|
||||
c.drain(&mut raw.events);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALT: usize = 0x12;
|
||||
|
||||
/// Alt held, keypad digits `code`, Alt released, and the character Windows then posts.
|
||||
fn alt_code(c: &mut AltCodes, code: &str, posted: u16) {
|
||||
c.message(WM_SYSKEYDOWN, ALT);
|
||||
for d in code.bytes() {
|
||||
let vk = VK_NUMPAD0 + usize::from(d - b'0');
|
||||
c.message(WM_SYSKEYDOWN, vk);
|
||||
c.message(WM_SYSKEYUP, vk);
|
||||
}
|
||||
c.message(WM_KEYUP, ALT);
|
||||
c.message(WM_CHAR, usize::from(posted));
|
||||
}
|
||||
|
||||
fn key(key: Key, alt: bool, pressed: bool) -> Event {
|
||||
let modifiers = egui::Modifiers { alt, ..Default::default() };
|
||||
Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_code_types_its_character_when_alt_comes_up() {
|
||||
let mut c = AltCodes::default();
|
||||
alt_code(&mut c, "0176", 0xB0);
|
||||
alt_code(&mut c, "0151", 0x2014);
|
||||
let mut events = vec![key(Key::Num0, true, true), key(Key::Num0, true, false), key(Key::Num1, false, true)];
|
||||
c.drain(&mut events);
|
||||
// The keypad digits typed with Alt are gone (eight presses and eight releases, two here);
|
||||
// the digit typed without Alt stays, and the characters follow.
|
||||
assert_eq!(events, vec![key(Key::Num1, false, true), Event::Text("°—".into())]);
|
||||
let mut events = vec![key(Key::Num0, true, true)];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events.len(), 1, "counts don't carry over to a later frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_character_without_a_code_before_it_is_left_to_winit() {
|
||||
let mut c = AltCodes::default();
|
||||
// A plain key press's character (winit types it), and Alt pressed and released alone.
|
||||
c.message(WM_KEYDOWN, 0x41);
|
||||
c.message(WM_CHAR, usize::from(b'a'));
|
||||
c.message(WM_SYSKEYDOWN, ALT);
|
||||
c.message(WM_KEYUP, ALT);
|
||||
c.message(WM_CHAR, usize::from(b'b'));
|
||||
let mut events = vec![];
|
||||
c.drain(&mut events);
|
||||
assert!(events.is_empty(), "{events:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_key_while_alt_is_held_cancels_the_code() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_SYSKEYDOWN, ALT);
|
||||
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
|
||||
// Alt+F: a shortcut, not a code.
|
||||
c.message(WM_SYSKEYDOWN, 0x46);
|
||||
c.message(WM_KEYUP, ALT);
|
||||
c.message(WM_CHAR, 0x41);
|
||||
let mut events = vec![];
|
||||
c.drain(&mut events);
|
||||
assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_code_above_the_basic_plane_arrives_as_two_halves() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_SYSKEYDOWN, ALT);
|
||||
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
|
||||
c.message(WM_KEYUP, ALT);
|
||||
c.message(WM_CHAR, 0xD83D);
|
||||
c.message(WM_CHAR, 0xDE00);
|
||||
let mut events = vec![];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![Event::Text("😀".into())]);
|
||||
}
|
||||
}
|
||||
@@ -208,8 +208,15 @@ sync_github_name = true
|
||||
[people.sadkotheguest]
|
||||
real_name = "Alexander Buraga"
|
||||
display_name = "Alexander Buraga"
|
||||
sync_github_name = false
|
||||
sync_github_name = false
|
||||
|
||||
[people.adikshan11]
|
||||
real_name = "Adithya Shankaran"
|
||||
sync_github_name = true
|
||||
|
||||
[people.slafebre]
|
||||
real_name = "Sven Lafebre"
|
||||
|
||||
[people.kylekanouse]
|
||||
real_name = "Kyle Kanouse"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -13,15 +13,26 @@ fn luminance(c: &Color) -> f32 {
|
||||
}
|
||||
|
||||
fn map_color(c: &Color, mode: Colorization, key: &Color) -> Color {
|
||||
let lum = luminance(c);
|
||||
// Black ink alone is its own tint: its amount is the tint, not how light it displays (100% K
|
||||
// displays lighter than black).
|
||||
let light = match *c {
|
||||
Color::Cmyk { c, m, y, k } if c == 0.0 && m == 0.0 && y == 0.0 => 1.0 - k,
|
||||
_ => luminance(c),
|
||||
};
|
||||
// A process or gray stroke color's tints and shades stay in its model.
|
||||
let (white, black) = match key {
|
||||
Color::Cmyk { .. } => (Color::cmyk(0.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)),
|
||||
Color::Gray { .. } => (Color::gray(0.0), Color::gray(1.0)),
|
||||
_ => (Color::WHITE, Color::BLACK),
|
||||
};
|
||||
match mode {
|
||||
Colorization::None => *c,
|
||||
Colorization::Tints => key.lerp(&Color::WHITE, lum),
|
||||
Colorization::Tints => key.lerp_keep_model(&white, light),
|
||||
Colorization::TintsAndShades => {
|
||||
if lum < 0.5 {
|
||||
Color::BLACK.lerp(key, lum * 2.0)
|
||||
if light < 0.5 {
|
||||
key.lerp_keep_model(&black, 1.0 - light * 2.0)
|
||||
} else {
|
||||
key.lerp(&Color::WHITE, (lum - 0.5) * 2.0)
|
||||
key.lerp_keep_model(&white, (light - 0.5) * 2.0)
|
||||
}
|
||||
}
|
||||
Colorization::HueShift { key: art_key } => {
|
||||
|
||||
@@ -701,6 +701,55 @@ fn tints_colorization_uses_stroke_colour() {
|
||||
assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
/// Black ink alone is a tint by its amount: a process black stroke leaves it as it is, and another
|
||||
/// process stroke color takes that share of its inks. Tints of a process color stay process.
|
||||
#[test]
|
||||
fn tints_of_black_ink_are_its_amount_of_the_stroke_color() {
|
||||
let tinted = |art: Color, stroke: Color| {
|
||||
let mut n = rect_art(1.0, 1.0);
|
||||
n.appearance.set_fill(Paint::solid(art));
|
||||
colorize(&mut n, Colorization::Tints, &Paint::solid(stroke));
|
||||
n.appearance.fill_paint().color().unwrap()
|
||||
};
|
||||
let ink = Color::cmyk(0.0, 0.0, 0.0, 0.6);
|
||||
let close = |a: Color, b: Color| {
|
||||
let (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) = (a, b) else { panic!("{a:?} {b:?}") };
|
||||
(c - c2).abs() < 1e-5 && (m - m2).abs() < 1e-5 && (y - y2).abs() < 1e-5 && (k - k2).abs() < 1e-5
|
||||
};
|
||||
assert!(close(tinted(ink, Color::cmyk(0.0, 0.0, 0.0, 1.0)), ink));
|
||||
assert!(close(tinted(ink, Color::cmyk(0.0, 1.0, 1.0, 0.0)), Color::cmyk(0.0, 0.6, 0.6, 0.0)));
|
||||
let [r, g, b] = tinted(ink, Color::rgb(1.0, 0.0, 0.0)).to_rgb();
|
||||
assert!((r - 1.0).abs() < 1e-5 && (g - 0.4).abs() < 1e-5 && (b - 0.4).abs() < 1e-5, "{r} {g} {b}");
|
||||
let Color::Gray { k } = tinted(ink, Color::gray(0.5)) else { panic!("tints of a gray stroke color stay gray") };
|
||||
assert!((k - 0.3).abs() < 1e-5, "{k}");
|
||||
}
|
||||
|
||||
/// Under Tints and Shades black ink stays black, however light 100% K displays, and a band of black
|
||||
/// ink is its amount: K 0.5 is the stroke color. A process or gray stroke color stays in its model.
|
||||
#[test]
|
||||
fn tints_and_shades_of_black_ink_go_by_its_amount() {
|
||||
let mapped = |k: f32, stroke: Color| {
|
||||
let mut n = rect_art(1.0, 1.0);
|
||||
n.appearance.set_fill(Paint::solid(Color::cmyk(0.0, 0.0, 0.0, k)));
|
||||
colorize(&mut n, Colorization::TintsAndShades, &Paint::solid(stroke));
|
||||
n.appearance.fill_paint().color().unwrap()
|
||||
};
|
||||
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-5);
|
||||
let inks = |c: Color| {
|
||||
let Color::Cmyk { c, m, y, k } = c else { panic!("a process color: {c:?}") };
|
||||
[c, m, y, k]
|
||||
};
|
||||
let orange = Color::cmyk(0.0, 0.6, 1.0, 0.0);
|
||||
assert!(close(inks(mapped(1.0, orange)), [0.0, 0.0, 0.0, 1.0]), "black stays black");
|
||||
assert!(close(inks(mapped(0.5, orange)), [0.0, 0.6, 1.0, 0.0]), "the stroke color");
|
||||
assert!(close(inks(mapped(0.25, orange)), [0.0, 0.3, 0.5, 0.0]), "a tint");
|
||||
assert!(close(inks(mapped(0.75, orange)), [0.0, 0.3, 0.5, 0.5]), "a shade");
|
||||
let Color::Gray { k } = mapped(0.75, Color::gray(0.4)) else { panic!("a gray stroke color stays gray") };
|
||||
assert!((k - 0.7).abs() < 1e-5, "{k}");
|
||||
let [r, g, b] = mapped(1.0, Color::rgb(1.0, 0.0, 0.0)).to_rgb();
|
||||
assert!(r < 1e-5 && g < 1e-5 && b < 1e-5, "black stays black: {r} {g} {b}");
|
||||
}
|
||||
|
||||
/// Point type at (0, 0) whose characters are painted `fill` and `stroke` (1 pt wide).
|
||||
fn type_art(fill: Paint, stroke: Paint) -> Node {
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
|
||||
@@ -22,11 +22,15 @@ pub struct ProfilePreset {
|
||||
}
|
||||
|
||||
impl WidthProfile {
|
||||
/// (left, right) width factors at `t`, linear between points.
|
||||
/// (left, right) width factors at `t`: a smooth curve through the points ([`Self::at_points`]).
|
||||
pub fn at(&self, t: f64) -> (f64, f64) {
|
||||
Self::at_points(&self.points, t)
|
||||
}
|
||||
/// [`Self::at`] of a profile's points (a preset's or a saved profile's).
|
||||
/// [`Self::at`] of a profile's points (a preset's or a saved profile's). Each side runs through
|
||||
/// its points on a monotone cubic curve (Fritsch–Carlson): it flows through a width point
|
||||
/// without a kink, turns flat at a point wider or narrower than both neighbours, and never
|
||||
/// goes past either point of the span it is in (#5714). Points on one straight line stay on
|
||||
/// it. A step (two points at one position, [`Self::around`]) ends the curve on each side of it.
|
||||
pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) {
|
||||
if p.is_empty() {
|
||||
return (1.0, 1.0);
|
||||
@@ -36,9 +40,21 @@ impl WidthProfile {
|
||||
}
|
||||
// (Positions go forward; found in log time, so a long profile read from a file is cheap to ask.)
|
||||
let i = p.partition_point(|q| q.0 < t);
|
||||
if let (Some(a), Some(b)) = (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
|
||||
let u = (t - a.0) / (b.0 - a.0).max(1e-9);
|
||||
return (a.1 + (b.1 - a.1) * u, a.2 + (b.2 - a.2) * u);
|
||||
if let (Some(j), Some(b)) = (i.checked_sub(1), p.get(i))
|
||||
&& let Some(a) = p.get(j)
|
||||
{
|
||||
let h = b.0 - a.0;
|
||||
let u = (t - a.0) / h.max(1e-9);
|
||||
let linear = (a.1 + (b.1 - a.1) * u, a.2 + (b.2 - a.2) * u);
|
||||
if h <= STEP {
|
||||
return linear;
|
||||
}
|
||||
let side = |v: fn(&(f64, f64, f64)) -> f64, straight: f64| {
|
||||
let y = hermite(v(a), v(b), tangent(p, j, v) * h, tangent(p, i, v) * h, u);
|
||||
// Widths read from a file may be huge: where the curve overflows, the straight line.
|
||||
if y.is_finite() { y } else { straight }
|
||||
};
|
||||
return (side(|q| q.1, linear.0), side(|q| q.2, linear.1));
|
||||
}
|
||||
p.last().map_or((1.0, 1.0), |l| (l.1, l.2))
|
||||
}
|
||||
@@ -55,14 +71,17 @@ impl WidthProfile {
|
||||
// Two swells between narrow necks.
|
||||
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
|
||||
];
|
||||
/// The widest the stroke gets, as a multiple of its weight: the largest total of the left and
|
||||
/// right factors at any of the points (between them the profile is straight), 1 for a profile
|
||||
/// without points.
|
||||
/// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. Each
|
||||
/// side stays between the widths of the two points of each span ([`Self::at_points`]), but the
|
||||
/// two sides needn't peak together, so a span counts its wider left and its wider right.
|
||||
pub fn widest(&self) -> f64 {
|
||||
if self.points.is_empty() {
|
||||
let p = &self.points;
|
||||
if p.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
self.points.iter().map(|&(_, l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max)
|
||||
let total = |l: f64, r: f64| (l.max(0.0) + r.max(0.0)) / 2.0;
|
||||
let spans = p.windows(2).map(|w| total(w[0].1.max(w[1].1), w[0].2.max(w[1].2)));
|
||||
p.iter().map(|&(_, l, r)| total(l, r)).chain(spans).fold(0.0, f64::max)
|
||||
}
|
||||
/// The built-in profile with this id.
|
||||
pub fn preset(id: &str) -> Option<Self> {
|
||||
@@ -227,6 +246,41 @@ impl AngleProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// How close two width points' positions are to make a step ([`WidthProfile::around`]).
|
||||
const STEP: f64 = 1e-7;
|
||||
|
||||
/// The slope of one side (`v` reads it from a point) of width profile `p` at its point `j`, in
|
||||
/// width per unit of position: Fritsch–Carlson's choice for a monotone cubic curve. It is 0 where
|
||||
/// the point is wider or narrower than both neighbours, the weighted harmonic mean of the slopes
|
||||
/// of the spans on either side elsewhere (never more than three times either, so no span
|
||||
/// overshoots its points), and the span's own slope at an end of the profile or beside a step.
|
||||
fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f64 {
|
||||
let Some(q) = p.get(j) else { return 0.0 };
|
||||
let before = j.checked_sub(1).and_then(|k| p.get(k)).filter(|a| q.0 - a.0 > STEP);
|
||||
let after = p.get(j + 1).filter(|b| b.0 - q.0 > STEP);
|
||||
let slope = |a: &(f64, f64, f64), b: &(f64, f64, f64)| (v(b) - v(a)) / (b.0 - a.0);
|
||||
match (before, after) {
|
||||
(Some(a), Some(b)) => {
|
||||
let (d0, d1) = (slope(a, q), slope(q, b));
|
||||
if d0 * d1 <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (h0, h1) = (q.0 - a.0, b.0 - q.0);
|
||||
3.0 * (h0 + h1) / ((2.0 * h1 + h0) / d0 + (h1 + 2.0 * h0) / d1)
|
||||
}
|
||||
(Some(a), None) => slope(a, q),
|
||||
(None, Some(b)) => slope(q, b),
|
||||
(None, None) => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cubic Hermite curve from `y0` to `y1` at `u` (0..1), with end slopes `m0` and `m1` per
|
||||
/// whole span.
|
||||
fn hermite(y0: f64, y1: f64, m0: f64, m1: f64, u: f64) -> f64 {
|
||||
let (u2, u3) = (u * u, u * u * u);
|
||||
(2.0 * u3 - 3.0 * u2 + 1.0) * y0 + (u3 - 2.0 * u2 + u) * m0 + (-2.0 * u3 + 3.0 * u2) * y1 + (u3 - u2) * m1
|
||||
}
|
||||
|
||||
/// The value of samples `p` (position, value) at `t`, linear between them; `None` without any.
|
||||
fn interpolated(p: &[(f64, f64)], t: f64) -> Option<f64> {
|
||||
let (first, last) = (p.first()?, p.last()?);
|
||||
@@ -341,6 +395,7 @@ pub struct SavedProfile {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Widths and steps are found by search, at the ends, between points and at a step.
|
||||
#[test]
|
||||
fn width_profile_lookups_by_search() {
|
||||
let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25), (1.0, 0.0, 0.0)] };
|
||||
@@ -358,4 +413,93 @@ mod tests {
|
||||
let near = WidthProfile { points: vec![(0.4999999, 2.0, 2.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25)] };
|
||||
assert_eq!(near.around(0.5), ((1.0, 1.0), (0.5, 0.25)));
|
||||
}
|
||||
|
||||
/// The slope of one side of `p` just before and just after `t`.
|
||||
fn slopes(p: &WidthProfile, t: f64) -> (f64, f64) {
|
||||
let e = 1e-6;
|
||||
((p.at(t).0 - p.at(t - e).0) / e, (p.at(t + e).0 - p.at(t).0) / e)
|
||||
}
|
||||
|
||||
/// #5714: the width flows through a width point instead of turning a corner there.
|
||||
#[test]
|
||||
fn widths_run_smoothly_through_width_points() {
|
||||
// A swell in the middle: flat at its widest, and a quarter of the way along above the
|
||||
// straight ramp from 1 to 3 (2).
|
||||
let swell = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 2.0), (1.0, 1.0, 1.0)] };
|
||||
let (before, after) = slopes(&swell, 0.5);
|
||||
assert!(before.abs() < 1e-4 && after.abs() < 1e-4, "{before} {after}");
|
||||
let (l, r) = swell.at(0.25);
|
||||
assert!((l - 2.25).abs() < 1e-12 && (r - 1.625).abs() < 1e-12, "{l} {r}");
|
||||
// Rising on both sides of a point: the slope is the same either side of it.
|
||||
let rise = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.3, 1.0, 1.0), (1.0, 4.0, 4.0)] };
|
||||
let (before, after) = slopes(&rise, 0.3);
|
||||
assert!(before > 0.0 && (before - after).abs() < 1e-3 * before, "{before} {after}");
|
||||
// Points on one line stay on it.
|
||||
let straight = WidthProfile { points: vec![(0.0, 0.0, 1.0), (0.25, 0.5, 1.0), (1.0, 2.0, 1.0)] };
|
||||
for t in [0.1, 0.25, 0.6, 0.9] {
|
||||
let (l, r) = straight.at(t);
|
||||
assert!((l - 2.0 * t).abs() < 1e-12 && (r - 1.0).abs() < 1e-12, "{t}: {l} {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Neither side of a profile goes wider or narrower than the two points of the span it is in.
|
||||
#[test]
|
||||
fn widths_never_pass_the_points_either_side() {
|
||||
for preset in WidthProfile::PRESETS {
|
||||
let p = WidthProfile { points: preset.points.to_vec() };
|
||||
for w in p.points.windows(2) {
|
||||
let (lo, hi) = (w[0].1.min(w[1].1), w[0].1.max(w[1].1));
|
||||
for k in 0..=100 {
|
||||
let t = w[0].0 + (w[1].0 - w[0].0) * k as f64 / 100.0;
|
||||
let l = p.at(t).0;
|
||||
assert!(l >= lo - 1e-12 && l <= hi + 1e-12, "{}: {l} at {t} outside {lo}..{hi}", preset.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Uneven spacing and a steep jump next to a gentle rise.
|
||||
let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.05, 1.0, 1.0), (0.9, 1.1, 1.1), (1.0, 5.0, 5.0)] };
|
||||
for k in 0..=1000 {
|
||||
let t = k as f64 / 1000.0;
|
||||
let l = p.at(t).0;
|
||||
let span = p.points.windows(2).find(|w| t >= w[0].0 && t <= w[1].0).map(|w| (w[0].1.min(w[1].1), w[0].1.max(w[1].1)));
|
||||
let (lo, hi) = span.unwrap();
|
||||
assert!(l >= lo - 1e-12 && l <= hi + 1e-12, "{l} at {t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A step still breaks the width sharply, and the curve on either side ends at it.
|
||||
#[test]
|
||||
fn a_step_ends_the_curve_on_each_side() {
|
||||
let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (0.5, 1.0, 1.0), (1.0, 2.0, 2.0)] };
|
||||
assert_eq!(p.around(0.5), ((3.0, 3.0), (1.0, 1.0)));
|
||||
// Two points between ends: each side of the step is a straight ramp.
|
||||
assert!((p.at(0.25).0 - 2.0).abs() < 1e-12 && (p.at(0.75).0 - 1.5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
/// `widest()` is at least the stroke's width anywhere along the profile, even where the sides peak apart.
|
||||
#[test]
|
||||
fn widest_covers_every_width_along_the_profile() {
|
||||
let profiles = [
|
||||
vec![(0.0, 1.0, 0.0), (0.5, 0.2, 1.0), (1.0, 1.0, 0.0)],
|
||||
vec![(0.0, 0.0, 1.0), (0.3, 1.0, 0.0), (0.6, 0.0, 1.0), (1.0, 1.0, 1.0)],
|
||||
vec![(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)],
|
||||
];
|
||||
for points in profiles {
|
||||
let p = WidthProfile { points };
|
||||
let most = (0..=1000).map(|k| p.at(k as f64 / 1000.0)).map(|(l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max);
|
||||
assert!(p.widest() >= most - 1e-12, "{} < {most}: {:?}", p.widest(), p.points);
|
||||
}
|
||||
assert_eq!(WidthProfile::lens().widest(), 1.0);
|
||||
assert_eq!(WidthProfile::default().widest(), 1.0);
|
||||
}
|
||||
|
||||
/// Widths read from a file may be absurd: the curve stays finite or falls back to the line.
|
||||
#[test]
|
||||
fn huge_widths_stay_finite() {
|
||||
let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, f64::MAX, 1.0), (1.0, -f64::MAX, 0.0)] };
|
||||
for k in 0..=10 {
|
||||
let (l, r) = p.at(k as f64 / 10.0);
|
||||
assert!(!l.is_nan() && r.is_finite(), "{l} {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,11 +192,14 @@ fn strokes_without_pressure_round_trip_unchanged() {
|
||||
assert_eq!((st.pressure, st.tilt, st.bearing, st.rotation), (None, None, None, None));
|
||||
}
|
||||
|
||||
/// The Lens profile's widths at its middle, a quarter of the way along and past its end.
|
||||
#[test]
|
||||
fn profile_interp() {
|
||||
let p = WidthProfile::lens();
|
||||
assert_eq!(p.at(0.5), (1.0, 1.0));
|
||||
assert_eq!(p.at(0.25), (0.5, 0.5));
|
||||
// Half way up the lens the width curves above the straight ramp (0.5), flattening into its
|
||||
// widest point (#5714).
|
||||
assert_eq!(p.at(0.25), (0.625, 0.625));
|
||||
assert_eq!(p.at(2.0), (0.0, 0.0));
|
||||
}
|
||||
|
||||
|
||||
@@ -326,8 +326,8 @@ pub struct Document {
|
||||
#[serde(skip)]
|
||||
pub retired_artboard_id: RetiredArtboardId,
|
||||
/// The id of the artboard an imported file names as its active one (a `.ai` file's
|
||||
/// `CropAreaActive`): the document opens with that artboard active. `None`: the first. Never
|
||||
/// saved.
|
||||
/// `CropAreaActive`): the document opens with that artboard active. A `.ai` save or export
|
||||
/// names it as the file's active artboard. `None`: the first. Native files do not keep it.
|
||||
#[serde(skip)]
|
||||
pub opened_artboard: Option<u32>,
|
||||
/// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
|
||||
|
||||
+60
-2
@@ -53,8 +53,8 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation,
|
||||
#[cfg(test)]
|
||||
use blend_spec::{blend_color_shift, blend_ramp};
|
||||
pub use envelope::{
|
||||
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_expand, envelope_frame,
|
||||
envelope_grid, envelope_mapper, envelope_of, envelope_overlay, smooth_handles,
|
||||
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_content_boxes,
|
||||
envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, smooth_handles,
|
||||
};
|
||||
pub(crate) use expand::max_outset;
|
||||
pub use expand::{
|
||||
@@ -233,6 +233,64 @@ mod tests {
|
||||
assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p);
|
||||
}
|
||||
|
||||
/// `bounds_margin` grows the box an envelope maps from, only while Distort Appearance is on. It
|
||||
/// holds shares of the box, and an envelope with an uneven frame maps from the same box in that
|
||||
/// frame. A native file writes it only when it isn't zero.
|
||||
#[test]
|
||||
fn an_envelope_maps_from_its_box_with_its_margins() {
|
||||
let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art());
|
||||
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
|
||||
let content = [Arc::new(rect)];
|
||||
let src = |content: &[Arc<Node>], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src();
|
||||
// Top, bottom, left and right.
|
||||
let on = EnvelopeOptions { bounds_margin: [0.125, 0.25, 0.0625, 0.5], ..EnvelopeOptions::NEW };
|
||||
let geometric = Rect::new(10.0, 20.0, 110.0, 70.0);
|
||||
let grown = Rect::new(3.75, 13.75, 160.0, 82.5);
|
||||
assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions::default()), geometric);
|
||||
assert_eq!(src(&content, Affine::IDENTITY, on), grown);
|
||||
assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions { distort_appearance: false, ..on }), geometric);
|
||||
for bad in [f32::NAN, f32::INFINITY, -0.05] {
|
||||
assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions { bounds_margin: [bad, 0.25, 0.0625, 0.5], ..on }), geometric);
|
||||
}
|
||||
// A finite share of a box too wide to grow leaves the box as it is.
|
||||
let wide = Node::path(NodeId(3), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 1e308, 10.0)), crate::Appearance::default_art());
|
||||
let wide = [Arc::new(wide)];
|
||||
let huge = EnvelopeOptions { bounds_margin: [0.0, 0.0, f32::MAX, 0.0], ..on };
|
||||
assert_eq!(src(&wide, Affine::IDENTITY, huge), src(&wide, Affine::IDENTITY, EnvelopeOptions::NEW));
|
||||
// Bounds follow the box: a warp that doesn't bend covers it.
|
||||
let b = envelope_bounds(&content, &kind, Affine::IDENTITY, on).unwrap();
|
||||
assert!((b.x0 - 3.75).abs() < 1e-6 && (b.y1 - 82.5).abs() < 1e-6, "{b:?}");
|
||||
// Art taken through an uneven frame maps from the same box in that frame.
|
||||
let frame = Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale_non_uniform(2.0, 0.5);
|
||||
let mut placed = (*content[0]).clone();
|
||||
placed.transform(frame, true);
|
||||
let framed = src(&[Arc::new(placed)], frame, on);
|
||||
assert!((framed.x0 - 3.75).abs() + (framed.y0 - 13.75).abs() + (framed.x1 - 160.0).abs() + (framed.y1 - 82.5).abs() < 1e-6, "{framed:?}");
|
||||
|
||||
// A frame that turns and scales gives the same margins as the art had before it took them.
|
||||
let mut stroked = (*content[0]).clone();
|
||||
if let Some(s) = stroked.appearance.stroke_mut() {
|
||||
s.width = 10.0;
|
||||
}
|
||||
let margin = |n: &Node, frame: Affine| {
|
||||
let (with, plain) = envelope_content_boxes(&[Arc::new(n.clone())], frame).unwrap();
|
||||
envelope_stroke_margin(with, plain).unwrap()
|
||||
};
|
||||
let before = margin(&stroked, Affine::IDENTITY);
|
||||
assert_eq!(before.map(|m| (m * 1000.0).round()), [100.0, 100.0, 50.0, 50.0]);
|
||||
let frame = Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale(2.0);
|
||||
let mut turned = stroked.clone();
|
||||
turned.transform(frame, true);
|
||||
assert_eq!(margin(&turned, frame).map(|m| (m * 1000.0).round()), before.map(|m| (m * 1000.0).round()));
|
||||
// Margins an `f32` can't hold are none.
|
||||
assert_eq!(envelope_stroke_margin(Rect::new(-5e41, 0.0, 100.0, 100.0), Rect::new(0.0, 0.0, 100.0, 100.0)), None);
|
||||
|
||||
assert!(serde_json::to_value(EnvelopeOptions::default()).unwrap().get("boundsMargin").is_none());
|
||||
let saved = serde_json::to_value(on).unwrap();
|
||||
assert_eq!(saved["boundsMargin"], serde_json::json!([0.125, 0.25, 0.0625, 0.5]));
|
||||
assert_eq!(serde_json::from_value::<EnvelopeOptions>(saved).unwrap(), on);
|
||||
}
|
||||
|
||||
/// How far the border of `m` strays from the ellipse in `r`, in points (measured across the
|
||||
/// ellipse's short radius), checked at 20 places along every border edge.
|
||||
fn mesh_border_off_ellipse(m: &GradientMesh, r: Rect) -> f64 {
|
||||
|
||||
@@ -76,6 +76,12 @@ pub struct EnvelopeOptions {
|
||||
pub distort_linear_gradients: bool,
|
||||
/// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art.
|
||||
pub distort_pattern_fills: bool,
|
||||
/// With Distort Appearance, how far the box the content maps from reaches past its geometric
|
||||
/// bounds at the top, bottom, left and right, as shares of their height and width. An envelope
|
||||
/// opened from a `.ai` whose saved size includes its strokes has these margins. New envelopes
|
||||
/// have none. Leaving Edit Contents measures them again when the strokes reach differently.
|
||||
#[serde(skip_serializing_if = "crate::skip::is_default")]
|
||||
pub bounds_margin: [f32; 4],
|
||||
}
|
||||
|
||||
impl Default for EnvelopeOptions {
|
||||
@@ -86,6 +92,7 @@ impl Default for EnvelopeOptions {
|
||||
distort_appearance: false,
|
||||
distort_linear_gradients: false,
|
||||
distort_pattern_fills: false,
|
||||
bounds_margin: [0.0; 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +105,7 @@ impl EnvelopeOptions {
|
||||
distort_appearance: true,
|
||||
distort_linear_gradients: false,
|
||||
distort_pattern_fills: false,
|
||||
bounds_margin: [0.0; 4],
|
||||
};
|
||||
/// Do gradients bend with the art?
|
||||
pub fn gradients(&self) -> bool {
|
||||
@@ -107,6 +115,45 @@ impl EnvelopeOptions {
|
||||
pub fn patterns(&self) -> bool {
|
||||
self.distort_appearance && self.distort_pattern_fills
|
||||
}
|
||||
/// The box content with geometric bounds `r` maps from. Margins that aren't finite, are
|
||||
/// negative or give a box that isn't finite count as none.
|
||||
fn source_box(&self, r: Rect) -> Rect {
|
||||
let [top, bottom, left, right] = self.bounds_margin.map(f64::from);
|
||||
if !self.distort_appearance || !self.bounds_margin.iter().all(|m| m.is_finite() && *m >= 0.0) {
|
||||
return r;
|
||||
}
|
||||
let grown = crate::marks::outset(r, [top * r.height(), bottom * r.height(), left * r.width(), right * r.width()]);
|
||||
if grown.is_finite() { grown } else { r }
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds of `content` in the axes of `frame`, with its strokes and geometric. Strokes scale
|
||||
/// with the frame as [`Node::transform`] scales them.
|
||||
pub fn envelope_content_boxes(content: &[Arc<Node>], frame: Affine) -> Option<(Rect, Rect)> {
|
||||
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
|
||||
let to_frame = frame.inverse();
|
||||
let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| {
|
||||
let mut m = (**c).clone();
|
||||
m.transform(to_frame, true);
|
||||
(vectorcraft_geom::union_opt(stroked, m.visual_bounds()), vectorcraft_geom::union_opt(plain, m.geometric_bounds()))
|
||||
});
|
||||
Some((stroked?, plain?))
|
||||
}
|
||||
|
||||
/// The [`EnvelopeOptions::bounds_margin`] of content with bounds `stroked` and `plain`. `None` for
|
||||
/// content with no width or no height, or for margins an `f32` can't hold.
|
||||
pub fn envelope_stroke_margin(stroked: Rect, plain: Rect) -> Option<[f32; 4]> {
|
||||
if plain.width() < 1e-6 || plain.height() < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let margin = [
|
||||
(plain.y0 - stroked.y0) / plain.height(),
|
||||
(stroked.y1 - plain.y1) / plain.height(),
|
||||
(plain.x0 - stroked.x0) / plain.width(),
|
||||
(stroked.x1 - plain.x1) / plain.width(),
|
||||
]
|
||||
.map(|m| m as f32);
|
||||
margin.iter().all(|m| m.is_finite()).then_some(margin)
|
||||
}
|
||||
|
||||
/// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row.
|
||||
@@ -218,9 +265,9 @@ pub struct EnvelopeMap<'a> {
|
||||
}
|
||||
|
||||
impl<'a> EnvelopeMap<'a> {
|
||||
/// The map of an envelope of `kind` around `content` with `frame`. `None` when the content
|
||||
/// has no bounds.
|
||||
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine) -> Option<Self> {
|
||||
/// The map of an envelope of `kind` around `content` with `frame` and `options`. `None` when
|
||||
/// the content has no bounds.
|
||||
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option<Self> {
|
||||
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
|
||||
let to_frame = frame.inverse();
|
||||
let src = if frame == Affine::IDENTITY {
|
||||
@@ -232,13 +279,14 @@ impl<'a> EnvelopeMap<'a> {
|
||||
Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds()))
|
||||
})??
|
||||
};
|
||||
let src = options.source_box(src);
|
||||
Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) })
|
||||
}
|
||||
|
||||
/// The map of envelope node `n` (`None` for other nodes).
|
||||
pub fn of(n: &'a Node) -> Option<Self> {
|
||||
match &n.kind {
|
||||
NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame),
|
||||
NodeKind::Envelope { content, kind, frame, options, .. } => Self::new(content, kind, **frame, *options),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -302,7 +350,7 @@ pub(super) fn expand_envelope(
|
||||
frame: Affine,
|
||||
hooks: Hooks,
|
||||
) -> Vec<Node> {
|
||||
let Some(map) = EnvelopeMap::new(content, kind, frame) else { return content.iter().map(|c| (**c).clone()).collect() };
|
||||
let Some(map) = EnvelopeMap::new(content, kind, frame, options) else { return content.iter().map(|c| (**c).clone()).collect() };
|
||||
let src = map.src();
|
||||
let fid = if fidelity.is_finite() { fidelity.clamp(0.0, 100.0) / 100.0 } else { 0.5 };
|
||||
let f = |p: Point| map.map(p);
|
||||
@@ -319,7 +367,7 @@ pub(super) fn expand_envelope(
|
||||
}
|
||||
|
||||
/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled).
|
||||
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine) -> Option<Rect> {
|
||||
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option<Rect> {
|
||||
match kind {
|
||||
EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(),
|
||||
EnvelopeKind::Mesh { points, .. } => {
|
||||
@@ -327,6 +375,6 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
|
||||
Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)))
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => path.bounds(),
|
||||
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame)?.bounds(),
|
||||
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@ pub struct NodeExtras {
|
||||
/// As an instance of a dynamic symbol, its own appearance changes to objects of the symbol's
|
||||
/// art (see [`crate::symbol_overrides`]); empty for none.
|
||||
pub symbol_overrides: Vec<crate::SymbolOverride>,
|
||||
/// As an envelope with margins in Edit Contents, the margins its strokes gave when Edit Contents
|
||||
/// began ([`crate::live::EnvelopeOptions::bounds_margin`]).
|
||||
pub envelope_edit_margin: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -91,6 +94,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
compound_shape: false,
|
||||
shape_mode: crate::ShapeMode::Add,
|
||||
symbol_overrides: Vec::new(),
|
||||
envelope_edit_margin: None,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
|
||||
@@ -300,7 +300,7 @@ impl Node {
|
||||
self.projected(Some(xf.transform_rect_bbox(art)))
|
||||
}
|
||||
NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())),
|
||||
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame),
|
||||
NodeKind::Envelope { content, kind, frame, options, .. } => crate::live::envelope_bounds(content, kind, **frame, *options),
|
||||
NodeKind::Mesh(m) => m.bounds(),
|
||||
// Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the
|
||||
// copies out by the source's bounds measured without the document.
|
||||
@@ -332,6 +332,14 @@ impl Node {
|
||||
self.visual(Some(symbols))
|
||||
}
|
||||
|
||||
/// The visual box of a group or layer without a clipping path, from the visual boxes of its
|
||||
/// [`Node::editing_children`]: for a walk that measures the members as well.
|
||||
pub fn visual_of_members(&self, members: impl Iterator<Item = Option<Rect>>) -> Option<Rect> {
|
||||
// The container's own strokes paint around its members.
|
||||
let o = self.appearance.outset();
|
||||
members.fold(None, vectorcraft_geom::union_opt).map(|b| b.inflate(o, o))
|
||||
}
|
||||
|
||||
/// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is
|
||||
/// given.
|
||||
pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
|
||||
@@ -346,11 +354,7 @@ impl Node {
|
||||
}
|
||||
}
|
||||
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))),
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
|
||||
// The container's own strokes paint around its members.
|
||||
let o = self.appearance.outset();
|
||||
self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => self.visual_of_members(self.editing_children().iter().map(|c| c.visual(symbols))),
|
||||
NodeKind::Blend { children, spec } => {
|
||||
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)));
|
||||
let o = crate::live::max_outset(children, symbols);
|
||||
|
||||
@@ -21,6 +21,7 @@ fn extras_are_written_flat_in_order() {
|
||||
e.live_paint_gaps = Some(Box::default());
|
||||
e.compound_shape = true;
|
||||
e.shape_mode = crate::ShapeMode::Exclude;
|
||||
e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]);
|
||||
}
|
||||
/// The top-level keys of a JSON object, in the order written.
|
||||
struct Keys(Vec<String>);
|
||||
@@ -65,6 +66,7 @@ fn extras_are_written_flat_in_order() {
|
||||
"live_paint_gaps",
|
||||
"compound_shape",
|
||||
"shape_mode",
|
||||
"envelope_edit_margin",
|
||||
];
|
||||
assert_eq!(keys, order);
|
||||
assert_eq!(v["shape_mode"], "exclude");
|
||||
@@ -443,6 +445,20 @@ fn visual_bounds_include_stroke() {
|
||||
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
|
||||
}
|
||||
|
||||
/// A group's box from its members' boxes grows by the group's own strokes, leaves out members
|
||||
/// without a box, and is `None` when none has one.
|
||||
#[test]
|
||||
fn visual_of_members_unions_the_members_and_grows_by_the_group_stroke() {
|
||||
let mut g = Node::group(NodeId(1), vec![]);
|
||||
g.appearance = Appearance::default_art().into();
|
||||
let (a, b) = (Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(20.0, 5.0, 30.0, 8.0));
|
||||
let o = g.appearance.outset();
|
||||
assert!(o > 0.0);
|
||||
assert_eq!(g.visual_of_members([Some(a), None, Some(b)].into_iter()), Some(Rect::new(0.0, 0.0, 30.0, 10.0).inflate(o, o)));
|
||||
assert_eq!(g.visual_of_members([None, None].into_iter()), None);
|
||||
assert_eq!(g.visual_of_members([].into_iter()), None);
|
||||
}
|
||||
|
||||
/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the
|
||||
/// weight centred, the whole weight outside, nothing inside; scaled with the type.
|
||||
#[test]
|
||||
|
||||
@@ -53,6 +53,7 @@ impl Serialize for Node {
|
||||
is_default(&e.compound_shape),
|
||||
is_default(&e.shape_mode),
|
||||
e.symbol_overrides.is_empty(),
|
||||
e.envelope_edit_margin.is_none(),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -94,6 +95,7 @@ impl Serialize for Node {
|
||||
field!("compound_shape", &e.compound_shape);
|
||||
field!("shape_mode", &e.shape_mode);
|
||||
field!("symbol_overrides", &e.symbol_overrides);
|
||||
field!("envelope_edit_margin", &e.envelope_edit_margin);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -232,6 +234,8 @@ pub(super) struct NodeWire {
|
||||
shape_mode: crate::ShapeMode,
|
||||
#[serde(default, deserialize_with = "counted_list")]
|
||||
symbol_overrides: Vec<crate::SymbolOverride>,
|
||||
#[serde(default)]
|
||||
envelope_edit_margin: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -258,6 +262,7 @@ impl From<NodeWire> for Node {
|
||||
compound_shape: w.compound_shape,
|
||||
shape_mode: w.shape_mode,
|
||||
symbol_overrides: w.symbol_overrides,
|
||||
envelope_edit_margin: w.envelope_edit_margin,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
|
||||
@@ -164,6 +164,9 @@ pub const LANGUAGES: &[(&str, &str, Quotes)] = &[
|
||||
("Polish", "pl", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
|
||||
("Czech", "cs", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
|
||||
("Russian", "ru", Quotes { double: ['«', '»'], single: ['„', '“'] }),
|
||||
// „…“ at either level: CLDR (https://github.com/unicode-org/cldr/blob/main/common/main/bg.xml,
|
||||
// `delimiters`). Shaped as `bg`, fonts draw their Bulgarian forms (`locl` for `BGR`, #5945).
|
||||
("Bulgarian", "bg", Quotes { double: ['„', '“'], single: ['„', '“'] }),
|
||||
// „…” outside, ’…’ inside: Pravopis srpskoga jezika (Matica srpska, 2010, ISBN
|
||||
// 978-86-7946-079-0, т. 168, pp. 133–135) and CLDR
|
||||
// (https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml, `delimiters`).
|
||||
@@ -185,6 +188,8 @@ pub const LANGUAGES: &[(&str, &str, Quotes)] = &[
|
||||
("Kannada", "kn", Quotes::ENGLISH),
|
||||
("Malayalam", "ml", Quotes::ENGLISH),
|
||||
("Odia", "or", Quotes::ENGLISH),
|
||||
("Assamese", "as", Quotes::ENGLISH),
|
||||
("Sanskrit", "sa", Quotes::ENGLISH),
|
||||
("Urdu", "ur", Quotes::ENGLISH),
|
||||
];
|
||||
|
||||
@@ -374,6 +379,8 @@ mod tests {
|
||||
fn char_languages_are_stored_by_tag() {
|
||||
assert_eq!(char_language("Turkish"), Ok(Some("tr")));
|
||||
assert_eq!(char_language("MR"), Ok(Some("mr")));
|
||||
assert_eq!(char_language("Assamese"), Ok(Some("as")));
|
||||
assert_eq!(char_language("Sanskrit"), Ok(Some("sa")));
|
||||
assert_eq!(char_language("english: usa"), Ok(None));
|
||||
assert_eq!(char_language("en-US"), Ok(None));
|
||||
assert!(char_language("Klingon").is_err() && char_language("").is_err());
|
||||
@@ -385,6 +392,10 @@ mod tests {
|
||||
assert_eq!(char_language("Serbian: Cyrillic"), Ok(Some("sr-Cyrl")));
|
||||
assert_eq!(char_language("sr-latn"), Ok(Some("sr-Latn")));
|
||||
assert_eq!(char_language("Croatian"), Ok(Some("hr")));
|
||||
// Bulgarian (#5945), by name or tag.
|
||||
assert_eq!(char_language("Bulgarian"), Ok(Some("bg")));
|
||||
assert_eq!(char_language("BG"), Ok(Some("bg")));
|
||||
assert_eq!(char_language_name(Some("bg")), Some("Bulgarian"));
|
||||
assert_eq!(char_language_name(Some("sr-Cyrl")), Some("Serbian: Cyrillic"));
|
||||
// Every entry has a tag of its own, and a name of its own.
|
||||
for (i, (n, t, _)) in LANGUAGES.iter().enumerate() {
|
||||
@@ -409,6 +420,9 @@ mod tests {
|
||||
}
|
||||
let mut prev = None;
|
||||
assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“");
|
||||
// Bulgarian: „…“ at either level, as CLDR has it.
|
||||
let mut prev = None;
|
||||
assert_eq!(language_quotes("bulgarian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a „b“ c“");
|
||||
// Converting in pieces keeps the context.
|
||||
let mut prev = None;
|
||||
let a = Quotes::ENGLISH.apply("\"", &mut prev);
|
||||
|
||||
@@ -42,6 +42,31 @@ pub fn has_gradient_copies(n: &Node) -> bool {
|
||||
(n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient()) && has_transform_copies(n)
|
||||
}
|
||||
|
||||
/// Is `n` a path or compound path with a visible Transform effect that makes copies and fills and
|
||||
/// strokes that paint the copies differently as one path than as separate objects
|
||||
/// ([`paints_copies_apart`])? Such an object is drawn through its art ([`outline_art`]), each copy
|
||||
/// an object of its own above the one before ([`copy_leaf`]), as Expand Appearance makes it
|
||||
/// (#5920).
|
||||
pub(crate) fn has_layered_copies(n: &Node) -> bool {
|
||||
leaf_geometry(n).is_some_and(|(_, rule)| paints_copies_apart(&n.appearance, rule)) && has_transform_copies(n)
|
||||
}
|
||||
|
||||
/// Do the fills and strokes `a` of a path with fill rule `rule` paint a Transform effect's copies
|
||||
/// differently when the copies are one path than when each is an object of its own? They do
|
||||
/// unless they paint once, in full and in Normal mode, with a stroke or a nonzero fill: then the
|
||||
/// one path paints what the copies paint together. A fill and a stroke paint each copy's stroke
|
||||
/// under the fills of the copies above it, a second fill or stroke likewise, the even-odd rule
|
||||
/// leaves holes where copies overlap, and a partly transparent or blended fill or stroke paints
|
||||
/// overlapping copies twice (#5920).
|
||||
fn paints_copies_apart(a: &Appearance, rule: FillRule) -> bool {
|
||||
let mut painting = a.items.iter().filter(|i| crate::paints(i));
|
||||
let (Some(one), None) = (painting.next(), painting.next()) else {
|
||||
// None paints (nothing to draw either way), or several do.
|
||||
return a.items.iter().any(crate::paints);
|
||||
};
|
||||
one.opacity() < 1.0 || one.blend() != Default::default() || (one.is_fill() && rule != FillRule::NonZero)
|
||||
}
|
||||
|
||||
/// Is `n` a path or compound path with a visible Transform effect that makes copies? Expand
|
||||
/// Appearance makes each copy an object of its own ([`expand_outlined`]).
|
||||
pub fn has_transform_copies(n: &Node) -> bool {
|
||||
@@ -77,10 +102,10 @@ fn reaches_appearance(e: &Effect) -> bool {
|
||||
}
|
||||
|
||||
/// Is `n` a path or compound path that its Transform effects make into art of separate objects
|
||||
/// ([`has_gradient_copies`], [`has_transform_options`])? It is drawn, baked and expanded through
|
||||
/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`])? It is drawn, baked and expanded through
|
||||
/// that art ([`reshape`]).
|
||||
pub fn has_transform_art(n: &Node) -> bool {
|
||||
has_gradient_copies(n) || has_transform_options(n)
|
||||
has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n)
|
||||
}
|
||||
|
||||
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
|
||||
@@ -636,7 +661,12 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
// Transform Patterns and Scale Strokes & Effects change each copy's fills and
|
||||
// strokes; a gradient paints each copy by itself, not the one piece the copies
|
||||
// make together; and Expand Appearance (`apart`) makes each copy an object.
|
||||
let separate = copies > 0 && (apart || leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient());
|
||||
// Fills and strokes that stack paint each copy above the one before (#5920).
|
||||
let separate = copies > 0
|
||||
&& (apart
|
||||
|| leaf.appearance.has_unplaced_gradient()
|
||||
|| leaf.appearance.has_placed_gradient()
|
||||
|| paints_copies_apart(&leaf.appearance, rule));
|
||||
if options.reach_appearance() {
|
||||
let made = transform_leaf(leaf, &path, map, copies, options, room);
|
||||
used = used.minus(before).plus(made);
|
||||
|
||||
+137
-35
@@ -155,18 +155,87 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) ->
|
||||
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
crate::lighting::validate(p)?;
|
||||
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
let (path, rule) =
|
||||
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
|
||||
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
||||
if let Some(v) = p.get("bevel")
|
||||
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
|
||||
{
|
||||
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
|
||||
return Err(format!("bevel must be none or one of {}", names.join(", ")));
|
||||
}
|
||||
if is_container(n) {
|
||||
let shapes = members(n, label)?;
|
||||
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
|
||||
return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()));
|
||||
}
|
||||
let (path, rule) = profile(n).ok_or_else(|| {
|
||||
format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.")
|
||||
})?;
|
||||
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
||||
facets(id, &path, rule, b, p).map(|_| ())
|
||||
}
|
||||
|
||||
/// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint
|
||||
/// it, and the opacity of the objects it is in (1 for the object with the effect itself).
|
||||
struct Profile {
|
||||
path: PathData,
|
||||
rule: FillRule,
|
||||
items: Vec<AppearanceItem>,
|
||||
opacity: f32,
|
||||
}
|
||||
|
||||
/// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)?
|
||||
fn is_container(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })
|
||||
}
|
||||
|
||||
/// The bounds of every shape together: the centre a group's members turn about.
|
||||
fn union_bounds(shapes: &[Profile]) -> Option<Rect> {
|
||||
shapes.iter().filter_map(|s| s.path.bounds()).reduce(|a, c| a.union(c))
|
||||
}
|
||||
|
||||
/// The most members a 3D effect on a group builds solids from.
|
||||
const MAX_MEMBERS: usize = 4096;
|
||||
|
||||
/// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path,
|
||||
/// compound path and type in it, at any depth of its groups, each through its own geometry
|
||||
/// effects and painted with its own fills and strokes. Like the effect on a group in the
|
||||
/// reference app, they turn about one shared axis (the centre of their bounds together) rather
|
||||
/// than each about its own. An error names what the group holds that can't become a solid.
|
||||
fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
|
||||
fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec<Profile>) -> Result<(), String> {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) {
|
||||
continue;
|
||||
}
|
||||
let opacity = opacity * c.opacity;
|
||||
if is_container(c) {
|
||||
walk(c, opacity, label, out)?;
|
||||
continue;
|
||||
}
|
||||
let Some((path, rule)) = profile(c) else {
|
||||
let what = match &c.kind {
|
||||
NodeKind::Group { clip: true, .. } => "clipping groups",
|
||||
NodeKind::Image(_) => "images",
|
||||
NodeKind::SymbolInstance { .. } => "symbol instances",
|
||||
NodeKind::Path { clipping: true, .. } => "clipping paths",
|
||||
_ => "objects other than paths, compound paths and type",
|
||||
};
|
||||
return Err(format!("{label} on a group needs paths, compound paths and type: release or expand its {what} first."));
|
||||
};
|
||||
if out.len() >= MAX_MEMBERS {
|
||||
return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects"));
|
||||
}
|
||||
let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c));
|
||||
if path.bounds().is_some() {
|
||||
out.push(Profile { path, rule, items: solid_paints(c), opacity });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut out = vec![];
|
||||
walk(n, 1.0, label, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked
|
||||
/// before a command changes the document: Revolve by [`crate::validate_revolve`], the others by
|
||||
/// [`validate_solid`].
|
||||
@@ -226,10 +295,11 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
|
||||
group
|
||||
}
|
||||
|
||||
fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
|
||||
/// `item`'s paint, its opacity times `member` (the opacity of the objects it is in) and its blend.
|
||||
fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
|
||||
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend),
|
||||
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity * member, f.blend),
|
||||
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity * member, s.blend),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,46 +319,50 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
|
||||
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
|
||||
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
|
||||
/// otherwise each solid is a surface of its own, in that order.
|
||||
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
|
||||
let b = path.bounds()?;
|
||||
fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
|
||||
let b = union_bounds(shapes)?;
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
let colored = view_options(&p).has_colored_lights();
|
||||
if fx.id == ROTATE {
|
||||
// Every part turns about the centre of the object's own bounds, so they stay together.
|
||||
// Every part turns about the centre of the object's own bounds (a group's members about
|
||||
// the centre of theirs), so they stay together.
|
||||
let mut out = vec![];
|
||||
for item in items {
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let (area, rule) = region(path, rule, item);
|
||||
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored)));
|
||||
for s in shapes {
|
||||
for item in &s.items {
|
||||
let (paint, opacity, blend) = paint_of(item, s.opacity);
|
||||
let (area, rule) = region(&s.path, s.rule, item);
|
||||
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored)));
|
||||
}
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
let parts: Vec<(&AppearanceItem, PathData, FillRule)> = items
|
||||
// Each shape's top fill and its strokes, a group's members from the back up.
|
||||
let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_)))
|
||||
.map(|(_, i)| {
|
||||
let (area, rule) = region(path, rule, i);
|
||||
(*i, area, rule)
|
||||
.flat_map(|s| {
|
||||
let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| {
|
||||
let (area, rule) = region(&s.path, s.rule, i);
|
||||
(i, s.opacity, area, rule)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// The solids share the bounds of all their shapes: one center to turn, twist and taper about,
|
||||
// and Inflate's grid reaches over the strokes' outlines.
|
||||
let bounds = parts.iter().filter_map(|(_, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b);
|
||||
let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b);
|
||||
let built = if fx.id == INFLATE {
|
||||
// One eye for every puff, as far away as the highest needs.
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, area, rule)| (area, *rule)).collect();
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
|
||||
} else {
|
||||
parts.iter().map(|(_, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
|
||||
parts.iter().map(|(_, _, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
|
||||
};
|
||||
let mut solids = vec![];
|
||||
for ((item, _, _), f) in parts.iter().zip(built) {
|
||||
for ((item, opacity, _, _), f) in parts.iter().zip(built) {
|
||||
let item: &AppearanceItem = item;
|
||||
match f {
|
||||
Ok(f) => solids.push((item, f)),
|
||||
Ok(f) => solids.push((item, *opacity, f)),
|
||||
// A stroke's outline past the limits is left out; the fill's solid still draws.
|
||||
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path
|
||||
@@ -300,15 +374,15 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
if solids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let opaque = solids.iter().all(|(item, _)| {
|
||||
let (_, opacity, blend) = paint_of(item);
|
||||
let opaque = solids.iter().all(|(item, member, _)| {
|
||||
let (_, opacity, blend) = paint_of(item, *member);
|
||||
opacity >= 1.0 && blend == BlendMode::Normal
|
||||
});
|
||||
let surfaces: Vec<(Vec<f64>, Node)> = solids
|
||||
.into_iter()
|
||||
.map(|(item, f)| {
|
||||
.map(|(item, member, f)| {
|
||||
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let (paint, opacity, blend) = paint_of(item, member);
|
||||
(depths, surface(n, f, &paint, opacity, blend, colored))
|
||||
})
|
||||
.collect();
|
||||
@@ -338,19 +412,23 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
|
||||
/// `n` drawn with its Extrude & Bevel, Inflate or Rotate effect: a group of shaded faces keeping
|
||||
/// the object's id and transparency, the source path untouched. `None` without such an effect.
|
||||
/// On a group or layer the effect builds a solid from each path, compound path and type in it,
|
||||
/// all turned about one shared axis ([`members`]).
|
||||
pub fn solid_art(n: &Node) -> Option<Node> {
|
||||
if !has_solid(n) {
|
||||
return None;
|
||||
}
|
||||
if is_container(n) {
|
||||
return group_solid_art(n);
|
||||
}
|
||||
let (path, rule) = profile(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let paints = solid_paints(n);
|
||||
let painted: Vec<&AppearanceItem> = paints.iter().collect();
|
||||
let mut children = vec![];
|
||||
let remaining: Vec<Effect>;
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
|
||||
children.extend(art_for(n, fx, &path, rule, &painted)?);
|
||||
children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0 }])?);
|
||||
// Later geometry effects act on the projected art as a whole.
|
||||
remaining = n
|
||||
.appearance
|
||||
@@ -363,16 +441,16 @@ pub fn solid_art(n: &Node) -> Option<Node> {
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
for item in &painted {
|
||||
for item in &paints {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
|
||||
for mut art in art_for(n, fx, &p, rule, &[*item])? {
|
||||
for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }])? {
|
||||
Arc::make_mut(&mut art).appearance.effects =
|
||||
item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
|
||||
children.push(art);
|
||||
}
|
||||
} else {
|
||||
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![(*item).clone()], ..Default::default() })));
|
||||
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,5 +460,29 @@ pub fn solid_art(n: &Node) -> Option<Node> {
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// [`solid_art`] of group or layer `n`: its object-level 3D effect on its members together. The
|
||||
/// geometry effects before it reshape the members, those after it bend the result as a whole; the
|
||||
/// group's own fills and strokes are not drawn. `None` when the effect is only on one of the
|
||||
/// group's own fills or strokes, or when its members can't become solids.
|
||||
fn group_solid_art(n: &Node) -> Option<Node> {
|
||||
let (index, fx) = solid(&n.appearance.effects)?;
|
||||
let mut shapes = members(n, "").ok()?;
|
||||
let before = n.appearance.effects.get(..index)?;
|
||||
if before.iter().any(|e| e.visible && is_geometry(&e.id)) {
|
||||
let b = union_bounds(&shapes)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
for s in &mut shapes {
|
||||
s.path = apply_geometry_with(before, &s.path, b, &ctx);
|
||||
}
|
||||
}
|
||||
let children = art_for(n, fx, &shapes)?;
|
||||
let remaining =
|
||||
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
|
||||
let mut art = n.clone();
|
||||
art.kind = NodeKind::Group { children, clip: false };
|
||||
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
|
||||
Some(art)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -462,8 +462,8 @@ fn parameters_are_validated_and_lengths_scale_with_the_object() {
|
||||
let n = square_node();
|
||||
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "rounded", "depth": 80})).is_ok());
|
||||
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "fancy"})).unwrap_err().contains("bevel must be"));
|
||||
let text = Node::group(NodeId(2), vec![]);
|
||||
assert!(validate_solid(&text, INFLATE, &json!({})).unwrap_err().contains("requires a path"));
|
||||
let empty = Node::group(NodeId(2), vec![]);
|
||||
assert!(validate_solid(&empty, INFLATE, &json!({})).unwrap_err().contains("requires a group holding a shape"));
|
||||
for (id, key) in [(EXTRUDE, "depth"), (EXTRUDE, "bevelWidth"), (EXTRUDE, "bevelHeight"), (INFLATE, "depth")] {
|
||||
assert!(crate::is_length(id, key, false), "{id} {key}");
|
||||
}
|
||||
@@ -540,3 +540,58 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
|
||||
assert_eq!(clips, surfaces);
|
||||
assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count");
|
||||
}
|
||||
|
||||
/// `square_node` moved right by `dx`, with id `id` and fill `fill`.
|
||||
fn square_at(id: u64, dx: f64, fill: Color) -> Node {
|
||||
let mut n = square_node();
|
||||
n.id = NodeId(id);
|
||||
n.transform(Affine::translate((dx, 0.0)), false);
|
||||
n.appearance.items = Appearance::basic(Paint::solid(fill), Paint::None, 0.0).items;
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() {
|
||||
// Two squares side by side, 100 pt apart, in a group: 100–200 and 300–400 across.
|
||||
let group = |id: &str, params: Value| {
|
||||
let mut g = Node::group(NodeId(9), vec![Arc::new(square_at(1, 0.0, RED)), Arc::new(square_at(2, 200.0, BLUE))]);
|
||||
g.appearance.effects.push(fx(id, params));
|
||||
g
|
||||
};
|
||||
let turned = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
|
||||
for id in SOLIDS {
|
||||
let g = group(id, json!({}));
|
||||
assert!(validate_solid(&g, id, &json!({})).is_ok(), "{id}");
|
||||
let art = solid_art(&g).unwrap_or_else(|| panic!("{id}"));
|
||||
assert_eq!(art.id, NodeId(9), "{id}: the art is the group's");
|
||||
assert!(art.appearance.effects.iter().all(|e| !is_three_d(&e.id)), "{id}");
|
||||
// Both members draw, each in its own fill.
|
||||
let mut colours = vec![];
|
||||
art.walk(&mut |c| {
|
||||
if c.path_data().is_some() {
|
||||
colours.push(fill_rgb(c));
|
||||
}
|
||||
});
|
||||
assert!(colours.iter().any(|c| c[2] > c[0]) && colours.iter().any(|c| c[0] > c[2]), "{id}: red and blue faces");
|
||||
}
|
||||
// Turned 60° about y, the members turn together about the group's centre (x = 250): the
|
||||
// pair spans half its width; turned each about its own centre they would stay 100 pt apart.
|
||||
let b = solid_art(&group(ROTATE, turned.clone())).unwrap().visual_bounds().unwrap();
|
||||
assert!((b.center().x - 250.0).abs() < 1e-3, "{b:?}");
|
||||
assert!((b.width() - 300.0 * 0.5).abs() < 1.0, "{b:?}");
|
||||
let mut alone = square_at(2, 200.0, BLUE);
|
||||
alone.appearance.effects.push(fx(ROTATE, turned));
|
||||
let own = solid_art(&alone).unwrap().visual_bounds().unwrap();
|
||||
assert!((own.center().x - 350.0).abs() < 1e-3, "{own:?}");
|
||||
// Hidden members and guides are left out; a group holding what can't become a solid is
|
||||
// refused with what to do.
|
||||
let mut g = group(EXTRUDE, json!({}));
|
||||
let mut clip = Node::group(NodeId(5), vec![Arc::new(square_at(6, 0.0, RED))]);
|
||||
clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true };
|
||||
if let NodeKind::Group { children, .. } = &mut g.kind {
|
||||
children.push(Arc::new(clip));
|
||||
}
|
||||
let err = validate_solid(&g, EXTRUDE, &json!({})).unwrap_err();
|
||||
assert!(err.contains("clipping groups"), "{err}");
|
||||
assert!(solid_art(&g).is_none(), "drawn as the group it is");
|
||||
}
|
||||
|
||||
@@ -174,10 +174,21 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
|
||||
}
|
||||
let steps = if max_dt < 1.0 { (1.0 / max_dt).ceil() as usize } else { 1 };
|
||||
let profile = st.profile.iter().flat_map(|p| p.points.iter().map(|q| q.0));
|
||||
let at: Vec<f64> =
|
||||
marks.iter().copied().chain(profile).chain((1..steps).map(|i| i as f64 / steps as f64)).map(|t| t * total).collect();
|
||||
let heads = if closed { (0.0, 0.0) } else { heads };
|
||||
// Where an arrowhead's reach ends gets a rib of its own, so the ribs that reach
|
||||
// across a head run all the way there instead of narrowing towards the next corner
|
||||
// from the head's tip, which leaves the corners of its base outside (#5964).
|
||||
let head_ends = [heads.0, total - heads.1].into_iter().filter(|&s| s > 0.0 && s < total);
|
||||
let at: Vec<f64> = marks
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(profile)
|
||||
.chain((1..steps).map(|i| i as f64 / steps as f64))
|
||||
.map(|t| t * total)
|
||||
.chain(head_ends)
|
||||
.collect();
|
||||
let (pts, _) = split_at(&pts, &corner, closed, &at);
|
||||
strip(&lay, &pts, closed, total, side, if closed { (0.0, 0.0) } else { heads })
|
||||
strip(&lay, &pts, closed, total, side, heads)
|
||||
}
|
||||
};
|
||||
out.push(ribs);
|
||||
|
||||
@@ -225,20 +225,23 @@ fn expand_stroke_region_of_a_round_line_stays_compact() {
|
||||
assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors");
|
||||
}
|
||||
|
||||
/// A line's outline takes its width profile, its dashes (each reading the profile where it sits) or neither.
|
||||
#[test]
|
||||
fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
|
||||
let l = line(0.0, 100.0);
|
||||
let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3);
|
||||
assert!(near(plain.area().abs(), 1000.0, 1e-6));
|
||||
let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3);
|
||||
assert!(near(lens.area().abs(), 500.0, 1e-6));
|
||||
// Each dash takes its widths from where it sits along the lens (0 → 1 → 0): the dash over
|
||||
// 0..25 averages a quarter of the width, the one over 50..75 three quarters.
|
||||
// The lens swells on a cubic, flat at the middle: 7/12 of the full-width band (#5714).
|
||||
assert!(near(lens.area().abs(), 1000.0 * 7.0 / 12.0, 0.05));
|
||||
// Each dash takes its widths from where it sits along the lens (0 → 1 → 0). Half of the lens
|
||||
// rises as w(u) = u + u² − u³ (u from 0 to 1 across that half): the dash over 0..25 covers
|
||||
// its first half, ∫₀^½ w = 29/192, the one over 50..75 the wider other half, 83/192.
|
||||
let st = stroke(10.0, |s| {
|
||||
s.profile = Some(WidthProfile::lens());
|
||||
s.dash = Some(dashed(&[25.0, 25.0], 0.0));
|
||||
});
|
||||
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 62.5 + 187.5, 1e-6));
|
||||
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0 * (29.0 + 83.0) / 192.0, 0.05));
|
||||
assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ fn along_keeps_a_corners_colour_round_its_outside() {
|
||||
assert!((param(&sl, Point::new(95.0, 8.0)).unwrap() - 0.54).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// An Across stroke gradient runs from the stroke's left edge to its right edge, also where a width profile moves them.
|
||||
#[test]
|
||||
fn across_runs_from_the_left_edge_to_the_right() {
|
||||
let st = stroke(10.0, StrokeGradientMode::Across);
|
||||
@@ -137,8 +138,9 @@ fn across_runs_from_the_left_edge_to_the_right() {
|
||||
let mut lens = st.clone();
|
||||
lens.profile = Some(WidthProfile::lens());
|
||||
let sl = slices(&line, &lens);
|
||||
// A quarter of the way along, the lens is half as wide: its top edge is 2.5 up.
|
||||
assert!(param(&sl, Point::new(25.0, -2.4)).unwrap() < 0.05);
|
||||
// A quarter of the way along, the lens is 0.625 as wide (its curve, #5714): its top edge is
|
||||
// 3.125 up.
|
||||
assert!(param(&sl, Point::new(25.0, -3.0)).unwrap() < 0.05);
|
||||
assert!((param(&sl, Point::new(50.0, -2.5)).unwrap() - 0.25).abs() < 0.02);
|
||||
}
|
||||
|
||||
@@ -162,6 +164,7 @@ fn an_aligned_stroke_spans_the_gradient_across_the_side_it_shows_on() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The gradient slices cover every point the stroke paints, with each join, caps, arrowheads, a profile and dashes.
|
||||
#[test]
|
||||
fn slices_cover_everything_the_stroke_paints() {
|
||||
let zig = poly(&[(0.0, 0.0), (60.0, 40.0), (0.0, 80.0), (70.0, 90.0), (75.0, 20.0)], false);
|
||||
@@ -182,7 +185,9 @@ fn slices_cover_everything_the_stroke_paints() {
|
||||
assert_covered(&zig, &st);
|
||||
st.arrow_align = ArrowAlign::Tip;
|
||||
assert_covered(&curve, &st);
|
||||
st.profile = WidthProfile::preset("wave");
|
||||
// Teardrop, not Wave: with most profiles a dash across the zigzag's sharp corner
|
||||
// paints outside every slice (#5964). Back to Wave once that is fixed.
|
||||
st.profile = WidthProfile::preset("teardrop");
|
||||
st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() });
|
||||
assert_covered(&zig, &st);
|
||||
st.align = StrokeAlign::Outside;
|
||||
@@ -211,3 +216,24 @@ fn meshes_colour_the_stroke_as_the_slices_do() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #5964: with ribs only at the path's corners (an Along gradient), an arrowhead whose tip sits on
|
||||
/// the end of a long straight leg is covered all the way to where it can reach: the ribs that
|
||||
/// reach across it run to the end of its reach, rather than narrowing from its tip towards the
|
||||
/// next corner and leaving the corners of its base outside.
|
||||
#[test]
|
||||
fn slices_cover_an_arrowhead_on_a_long_straight_leg() {
|
||||
let zig = poly(&[(0.0, 0.0), (60.0, 40.0), (0.0, 80.0), (70.0, 90.0), (75.0, 20.0)], false);
|
||||
for profile in [None, WidthProfile::preset("lens"), WidthProfile::preset("taperStart")] {
|
||||
for align in [ArrowAlign::Tip, ArrowAlign::Extend] {
|
||||
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
|
||||
let mut st = stroke(12.0, mode);
|
||||
st.start_arrow = Some(Arrowhead::Triangle);
|
||||
st.end_arrow = Some(Arrowhead::Triangle);
|
||||
st.arrow_align = align;
|
||||
st.profile = profile.clone();
|
||||
assert_covered(&zig, &st);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,17 +150,56 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec<Point>,
|
||||
(pts, corner)
|
||||
}
|
||||
|
||||
/// Insert samples where the profile has width points (so the piecewise-linear profile is exact
|
||||
/// along straight runs; the two points of a discontinuous point share one sample). `span` maps the
|
||||
/// subpath onto the profile (see [`outline_spans`]).
|
||||
fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec<Point>, Vec<bool>) {
|
||||
/// The most samples added between two width points.
|
||||
const MAX_SPAN_SAMPLES: usize = 64;
|
||||
/// The most samples [`densify`] adds between width points along one piece of a subpath.
|
||||
const MAX_CURVE_SAMPLES: usize = 4096;
|
||||
|
||||
/// Insert samples where the profile has width points (the two points of a discontinuous point
|
||||
/// share one sample), and between them where the profile's curve ([`WidthProfile::at`]) bends, so
|
||||
/// that a straight run follows it to within `tol` with a stroke of half width `half`. `span` maps
|
||||
/// the subpath onto the profile (see [`outline_spans`]).
|
||||
fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64), half: f64, tol: f64) -> (Vec<Point>, Vec<bool>) {
|
||||
let total = length(pts, closed);
|
||||
let range = span.1 - span.0;
|
||||
if total <= 1e-12 || range <= 1e-12 {
|
||||
return (pts.to_vec(), corner.to_vec());
|
||||
}
|
||||
let along = |t: f64| (t - span.0) / range * total;
|
||||
// Where the profile's points fall along this piece (a span past 1 wraps round a closed path).
|
||||
let marks: Vec<f64> = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(|t| (t - span.0) / range * total).collect();
|
||||
let mut marks: Vec<f64> = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(along).collect();
|
||||
let mut added = 0;
|
||||
for shift in [0.0, 1.0] {
|
||||
for w in profile.points.windows(2) {
|
||||
let (a, b) = (&w[0], &w[1]);
|
||||
let (t0, t1) = (a.0 + shift, b.0 + shift);
|
||||
if t1 - t0 <= 1e-7 || t1 <= span.0 || t0 >= span.1 {
|
||||
continue;
|
||||
}
|
||||
// A span whose curve is the straight line between its points (a two-point taper,
|
||||
// points on one line) needs nothing between them. A cubic off the line is off it at a
|
||||
// quarter or three quarters of the way.
|
||||
let off_line = |u: f64| {
|
||||
let (l, r) = profile.at(a.0 + (b.0 - a.0) * u);
|
||||
half * (l - (a.1 + (b.1 - a.1) * u)).abs().max((r - (a.2 + (b.2 - a.2) * u)).abs())
|
||||
};
|
||||
if off_line(0.25) <= tol * 1e-3 && off_line(0.75) <= tol * 1e-3 {
|
||||
continue;
|
||||
}
|
||||
// A monotone cubic span bends by at most 12 times its change in width over the span
|
||||
// squared, so the chords of `n` equal parts stray at most 1.5 · change / n² from it.
|
||||
let change = half * (b.1 - a.1).abs().max((b.2 - a.2).abs());
|
||||
let n = (1.5 * change / tol).sqrt().ceil();
|
||||
let n = if n.is_finite() { n.clamp(1.0, MAX_SPAN_SAMPLES as f64) as usize } else { MAX_SPAN_SAMPLES };
|
||||
for k in 1..n {
|
||||
if added >= MAX_CURVE_SAMPLES {
|
||||
break;
|
||||
}
|
||||
marks.push(along(t0 + (t1 - t0) * k as f64 / n as f64));
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
split_at(pts, corner, closed, &marks)
|
||||
}
|
||||
|
||||
@@ -225,7 +264,7 @@ struct Vertex {
|
||||
|
||||
impl Ctx<'_> {
|
||||
fn subpath(&self, out: &mut BezPath, pts: &[Point], corner: &[bool], closed: bool, span: (f64, f64)) {
|
||||
let (pts, corner) = densify(pts, corner, closed, self.profile, span);
|
||||
let (pts, corner) = densify(pts, corner, closed, self.profile, span, self.half, self.tol);
|
||||
let n = pts.len();
|
||||
let seg_count = if closed { n } else { n - 1 };
|
||||
let seg_dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect();
|
||||
@@ -384,10 +423,13 @@ mod tests {
|
||||
assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Square), 0.01)) - 1100.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The Lens outline swells on its curve, and a profile with one side at zero paints only the other side.
|
||||
#[test]
|
||||
fn lens_profile_is_a_diamond_and_one_sided_profiles_stay_on_their_side() {
|
||||
fn lens_profile_swells_smoothly_and_one_sided_profiles_stay_on_their_side() {
|
||||
// Each half of the lens rises on a cubic from 0 to full width, flat at the middle: it
|
||||
// covers 7/12 of the full-width band (a diamond would cover half).
|
||||
let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01));
|
||||
assert!((a - 500.0).abs() < 1e-6, "{a}");
|
||||
assert!((a - 1000.0 * 7.0 / 12.0).abs() < 0.5, "{a}");
|
||||
let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] };
|
||||
let o = width_outline(&line(), 10.0, &one, &capped(LineCap::Butt), 0.01);
|
||||
assert!((area(&o) - 500.0).abs() < 1e-6);
|
||||
@@ -411,6 +453,43 @@ mod tests {
|
||||
assert!(a.is_finite() && (a - 3000.0).abs() < 150.0, "{a}");
|
||||
}
|
||||
|
||||
/// #5714: between width points a straight run follows the profile's smooth curve, not a
|
||||
/// straight line from one width to the next.
|
||||
#[test]
|
||||
fn a_straight_run_follows_the_curve_between_width_points() {
|
||||
// 5 pt either side at the ends, 15 pt at the middle: a quarter of the way along the curve
|
||||
// is at 2.25 × 5 = 11.25 pt (a straight ramp would be at 10 pt).
|
||||
let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (1.0, 1.0, 1.0)] };
|
||||
let o = width_outline(&line(), 10.0, &p, &capped(LineCap::Butt), 0.01);
|
||||
for y in [-11.0, 11.0] {
|
||||
assert!(o.contains(kurbo::Point::new(25.0, y)), "inside at y {y}");
|
||||
}
|
||||
for y in [-11.5, 11.5] {
|
||||
assert!(!o.contains(kurbo::Point::new(25.0, y)), "outside at y {y}");
|
||||
}
|
||||
// Round the widest point the side is flat: no corner sticks out above 15 pt.
|
||||
let bb = o.bounding_box();
|
||||
assert!((bb.y0 + 15.0).abs() < 1e-6 && (bb.y1 - 15.0).abs() < 1e-6, "{bb:?}");
|
||||
}
|
||||
|
||||
/// A taper is a straight ramp: its outline gets no samples between the width points.
|
||||
#[test]
|
||||
fn a_straight_taper_gets_no_samples_between_its_points() {
|
||||
let o = width_outline(&line(), 10.0, &WidthProfile::taper_end(), &capped(LineCap::Butt), 0.01);
|
||||
assert_eq!(o.elements().len(), 5, "{:?}", o.elements());
|
||||
}
|
||||
|
||||
/// A profile read from a file may have thousands of points and absurd widths: the samples added
|
||||
/// between them stay bounded and the outline finite.
|
||||
#[test]
|
||||
fn samples_between_width_points_are_capped() {
|
||||
let points = (0..=10_000).map(|i| (i as f64 / 10_000.0, if i % 2 == 0 { 0.0 } else { 1e12 }, 1.0)).collect();
|
||||
let o = width_outline(&line(), 10.0, &WidthProfile { points }, &capped(LineCap::Butt), 0.01);
|
||||
let n = o.elements().len();
|
||||
assert!(n < 2 * (2 * 10_001 + MAX_CURVE_SAMPLES) + 64, "{n}");
|
||||
assert!(o.bounding_box().area().is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_paths_make_a_ring() {
|
||||
let mut sq = BezPath::new();
|
||||
|
||||
@@ -384,3 +384,46 @@ fn a_strokes_own_transform_leaves_room_for_a_fill_above_it() {
|
||||
assert_eq!(stroked_leaves(&art).len(), 2, "{cost:?}");
|
||||
assert!(Budget::EFFECTS.fits(cost), "{cost:?}");
|
||||
}
|
||||
|
||||
/// A 100 pt square at the origin painted with `fill` and a 1 pt white stroke, with the Transform
|
||||
/// effect `params`.
|
||||
fn filled_and_stroked(fill: Paint, params: Value) -> Node {
|
||||
let mut n = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill, Paint::solid(Color::WHITE), 1.0));
|
||||
n.appearance.effects.push(transform(params));
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() {
|
||||
// Black fill, white stroke, each copy scaled and turned (#5920): each copy is an object of its
|
||||
// own above the one before, so its fill covers the strokes of the copies below.
|
||||
let params = json!({"scaleH": 97, "scaleV": 97, "rotate": 10, "copies": 25});
|
||||
let n = filled_and_stroked(Paint::solid(Color::BLACK), params.clone());
|
||||
assert!(has_transform_art(&n));
|
||||
let art = reshape(None, &n, None).unwrap();
|
||||
let paths = leaves(&art);
|
||||
assert_eq!(paths.len(), 26, "the original and 25 copies");
|
||||
for p in &paths {
|
||||
assert!(matches!(p.kind, NodeKind::Path { .. }), "one shape each, not one path of every copy");
|
||||
assert!(!p.appearance.fill_paint().is_none() && p.appearance.stroke().is_some());
|
||||
}
|
||||
// The original is at the bottom, the smallest copy on top.
|
||||
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let top = paths[25].geometric_bounds().unwrap();
|
||||
assert!(top.width() < 100.0 && top.height() < 100.0, "{top:?}");
|
||||
// Drawn as Expand Appearance makes it.
|
||||
assert_eq!(leaves(&expand_outlined(None, &n, None).unwrap()).len(), 26);
|
||||
|
||||
// One opaque stroke, or one nonzero fill, paints the copies alike as one path: no art.
|
||||
assert!(!has_transform_art(&filled_and_stroked(Paint::None, params.clone())));
|
||||
let mut fill_only = filled_and_stroked(Paint::solid(Color::BLACK), params.clone());
|
||||
fill_only.appearance.items.retain(AppearanceItem::is_fill);
|
||||
assert!(!has_transform_art(&fill_only));
|
||||
// A partly transparent fill paints where copies overlap twice.
|
||||
if let Some(AppearanceItem::Fill(f)) = fill_only.appearance.items.first_mut() {
|
||||
f.opacity = 0.5;
|
||||
}
|
||||
assert!(has_transform_art(&fill_only));
|
||||
// No copies: nothing to stack.
|
||||
assert!(!has_transform_art(&filled_and_stroked(Paint::solid(Color::BLACK), json!({"rotate": 10}))));
|
||||
}
|
||||
|
||||
@@ -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, 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) → {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) → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -1103,14 +1103,17 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !reverse && !defaults_only {
|
||||
s.eyedropper_sample = Some(node);
|
||||
}
|
||||
let range = range.filter(|(id, _, _)| targets.contains(id));
|
||||
// The highlighted characters' type objects and ranges: after Select All in threaded text, all
|
||||
// the text of every frame of the story.
|
||||
// the text of every frame of the story, except a frame the attributes come from, in place of
|
||||
// the object selection.
|
||||
let story = super::textedit::editing_story(s);
|
||||
let ranges: Vec<(NodeId, Option<(usize, usize)>)> = match range {
|
||||
Some((id, _, _)) if story.contains(&id) => story.iter().map(|f| (*f, None)).collect(),
|
||||
Some((id, start, end)) => vec![(id, Some((start, end)))],
|
||||
None => vec![],
|
||||
Some((id, _, _)) if story.contains(&id) => {
|
||||
targets.clear();
|
||||
story.iter().copied().filter(|f| Some(*f) != source).map(|f| (f, None)).collect()
|
||||
}
|
||||
Some((id, start, end)) if targets.contains(&id) => vec![(id, Some((start, end)))],
|
||||
_ => vec![],
|
||||
};
|
||||
// Character styles cannot represent object transparency. Refuse before even changing
|
||||
// paint defaults; applying it to the object would also alter the unselected characters.
|
||||
@@ -1128,7 +1131,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !reverse {
|
||||
picked.to_defaults(s, &attrs.appearance);
|
||||
}
|
||||
if targets.is_empty() || attrs.is_empty() {
|
||||
if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
}
|
||||
if !ranges.is_empty() {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
mod flavours;
|
||||
pub(crate) mod resources;
|
||||
mod table;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -191,7 +191,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load Text into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{text, center?: [x, y]} replace the clipboard with point text of `text` (one paragraph per line; trailing line breaks dropped) in the default type style, centred on `center` (default: the first artboard) → {count}; then run edit.paste",
|
||||
"{text, center?: [x, y], table?: true} replace the clipboard with point text of `text` (one paragraph per line; trailing line breaks dropped) in the default type style, centred on `center` (default: the first artboard) → {count}; then run edit.paste. Cells copied from a spreadsheet (tab-separated, at least two columns, every row with the same number of cells, cells in double quotes may hold tabs and line breaks; at most 1000 rows, 256 columns and 20,000 cells) become a group \"Table\": point type for each cell that holds text, in columns as wide as their widest cell and rows as tall as their tallest, over a group \"Gridlines\" of 0.5 pt black lines around every cell → {count, rows, columns}; table: false pastes them as plain text",
|
||||
has_doc,
|
||||
import_text
|
||||
),
|
||||
@@ -491,9 +491,24 @@ fn import_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if text.trim().is_empty() {
|
||||
return Err(bad(C, "the text is empty"));
|
||||
}
|
||||
let mut t = TextObject::point(Point::ZERO, text, super::super::create::new_type_style(s, &Value::Null));
|
||||
t.system_layout = s.doc()?.doc.setup.system_layout;
|
||||
crate::cmd::typecmd::refresh_bounds(&mut t);
|
||||
let style = super::super::create::new_type_style(s, &Value::Null);
|
||||
let system_layout = s.doc()?.doc.setup.system_layout;
|
||||
let layout = |t: &mut TextObject| {
|
||||
t.system_layout = system_layout;
|
||||
crate::cmd::typecmd::refresh_bounds(t);
|
||||
};
|
||||
let table = match p.get("table").filter(|v| !v.is_null()) {
|
||||
None => true,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(C, "table must be true or false"))?,
|
||||
};
|
||||
// Cells copied from a spreadsheet paste as a table of type over gridlines.
|
||||
if let Some(rows) = table.then(|| super::table::parse(text)).flatten() {
|
||||
let n = super::table::table(&rows, &style, layout);
|
||||
let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?;
|
||||
return Ok(json!({ "count": count, "rows": rows.len(), "columns": rows.first().map_or(0, Vec::len) }));
|
||||
}
|
||||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||||
layout(&mut t);
|
||||
let n = Node::new(NodeId(1), NodeKind::Text(Box::new(t)));
|
||||
let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?;
|
||||
Ok(json!({ "count": count }))
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Cells copied from a spreadsheet (Excel, Google Sheets, LibreOffice Calc, Numbers): the plain
|
||||
//! text they put on the clipboard is tab-separated values, one row a line, every row with the
|
||||
//! same number of cells. `clipboard.importText` pastes such text as a table: a group of point
|
||||
//! type, one object for each cell that holds text, laid out in columns as wide as their widest
|
||||
//! cell and rows as tall as their tallest, over gridlines around every cell.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect};
|
||||
|
||||
/// Most rows a pasted table takes; more pastes as plain text.
|
||||
pub(crate) const MAX_ROWS: usize = 1000;
|
||||
/// Most columns a pasted table takes; more pastes as plain text.
|
||||
pub(crate) const MAX_COLUMNS: usize = 256;
|
||||
/// Most cells (rows × columns) a pasted table takes; more pastes as plain text.
|
||||
pub(crate) const MAX_CELLS: usize = 20_000;
|
||||
/// Space between a cell's text and its gridlines, in points.
|
||||
const PAD: f64 = 4.0;
|
||||
/// The gridlines' stroke weight, in points.
|
||||
const GRIDLINE: f64 = 0.5;
|
||||
|
||||
/// The rows of `text` (line breaks already `\n`, trailing ones dropped) when it is a table copied
|
||||
/// from a spreadsheet: tab-separated cells, at least two columns, every row with the same number
|
||||
/// of cells, within the caps above. A cell in double quotes may hold tabs, line breaks and
|
||||
/// doubled quotes (`""`), as the spreadsheets write them. `None` for any other text, which pastes
|
||||
/// as it always has.
|
||||
pub(crate) fn parse(text: &str) -> Option<Vec<Vec<String>>> {
|
||||
if !text.contains('\t') {
|
||||
return None;
|
||||
}
|
||||
let mut rows: Vec<Vec<String>> = vec![];
|
||||
let mut row: Vec<String> = vec![];
|
||||
let mut cell = String::new();
|
||||
let mut chars = text.chars().peekable();
|
||||
// At the start of a cell, a quote opens a quoted cell.
|
||||
let mut start = true;
|
||||
let mut quoted = false;
|
||||
let mut cells = 0usize;
|
||||
while let Some(c) = chars.next() {
|
||||
if quoted {
|
||||
if c == '"' {
|
||||
if chars.peek() == Some(&'"') {
|
||||
chars.next();
|
||||
cell.push('"');
|
||||
} else {
|
||||
quoted = false;
|
||||
}
|
||||
} else {
|
||||
cell.push(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' if start => {
|
||||
quoted = true;
|
||||
start = false;
|
||||
}
|
||||
'\t' | '\n' => {
|
||||
row.push(std::mem::take(&mut cell));
|
||||
cells += 1;
|
||||
if row.len() > MAX_COLUMNS || cells > MAX_CELLS {
|
||||
return None;
|
||||
}
|
||||
if c == '\n' {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
if rows.len() > MAX_ROWS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
start = true;
|
||||
}
|
||||
c => {
|
||||
cell.push(c);
|
||||
start = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A quote left open is not a spreadsheet's.
|
||||
if quoted {
|
||||
return None;
|
||||
}
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
if rows.iter().any(|r| r.len() > MAX_COLUMNS) || cells + 1 > MAX_CELLS {
|
||||
return None;
|
||||
}
|
||||
let columns = rows.first()?.len();
|
||||
let fits = columns >= 2 && rows.len() <= MAX_ROWS && rows.iter().all(|r| r.len() == columns);
|
||||
// The first and last columns hold something: text indented with tabs is not a table.
|
||||
let used = |k: usize| rows.iter().any(|r| r.get(k).is_some_and(|c| !c.trim().is_empty()));
|
||||
(fits && used(0) && used(columns - 1)).then_some(rows)
|
||||
}
|
||||
|
||||
/// `rows` laid out as a table (see the module docs) with its top left at the origin: one group
|
||||
/// holding a group of gridlines and the cells' type. `style` is the type's style; `layout`
|
||||
/// refreshes a type object's bounds.
|
||||
pub(crate) fn table(rows: &[Vec<String>], style: &CharStyle, layout: impl Fn(&mut TextObject)) -> Node {
|
||||
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let mut next = 0u64;
|
||||
let mut id = || {
|
||||
next += 1;
|
||||
NodeId(next)
|
||||
};
|
||||
// Each cell's type, at the origin, with its bounds.
|
||||
let mut cells: Vec<Vec<Option<(Node, Rect)>>> = vec![];
|
||||
for row in rows {
|
||||
let mut out = vec![];
|
||||
for text in row {
|
||||
if text.trim().is_empty() {
|
||||
out.push(None);
|
||||
continue;
|
||||
}
|
||||
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let mut t = TextObject::point(Point::ZERO, &text, style.clone());
|
||||
layout(&mut t);
|
||||
let n = Node::new(id(), NodeKind::Text(Box::new(t)));
|
||||
out.push(n.geometric_bounds().filter(|b| b.is_finite()).map(|b| (n, b)));
|
||||
}
|
||||
cells.push(out);
|
||||
}
|
||||
// A row with nothing in it is as tall as one line of the type.
|
||||
let line = style.size.max(1.0) * 1.2;
|
||||
let widths: Vec<f64> =
|
||||
(0..columns).map(|k| cells.iter().filter_map(|r| r.get(k)?.as_ref()).map(|(_, b)| b.width()).fold(line, f64::max) + 2.0 * PAD).collect();
|
||||
let heights: Vec<f64> = cells.iter().map(|r| r.iter().flatten().map(|(_, b)| b.height()).fold(line, f64::max) + 2.0 * PAD).collect();
|
||||
let xs: Vec<f64> = std::iter::once(0.0)
|
||||
.chain(widths.iter().scan(0.0, |x, w| {
|
||||
*x += w;
|
||||
Some(*x)
|
||||
}))
|
||||
.collect();
|
||||
let ys: Vec<f64> = std::iter::once(0.0)
|
||||
.chain(heights.iter().scan(0.0, |y, h| {
|
||||
*y += h;
|
||||
Some(*y)
|
||||
}))
|
||||
.collect();
|
||||
let (right, bottom) = (xs.last().copied().unwrap_or(0.0), ys.last().copied().unwrap_or(0.0));
|
||||
let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), GRIDLINE);
|
||||
let mut lines = vec![];
|
||||
for y in &ys {
|
||||
lines.push(segment(id(), (0.0, *y), (right, *y), &stroke));
|
||||
}
|
||||
for x in &xs {
|
||||
lines.push(segment(id(), (*x, 0.0), (*x, bottom), &stroke));
|
||||
}
|
||||
let mut grid = Node::group(id(), lines);
|
||||
grid.name = Some("Gridlines".into());
|
||||
let mut children = vec![Arc::new(grid)];
|
||||
for (r, row) in cells.into_iter().enumerate() {
|
||||
for (k, cell) in row.into_iter().enumerate() {
|
||||
let (Some((mut n, b)), Some(x), Some(y)) = (cell, xs.get(k), ys.get(r)) else { continue };
|
||||
// The text's top left a pad inside the cell's.
|
||||
n.transform(Affine::translate((x + PAD - b.x0, y + PAD - b.y0)), false);
|
||||
children.push(Arc::new(n));
|
||||
}
|
||||
}
|
||||
let mut group = Node::group(id(), children);
|
||||
group.name = Some("Table".into());
|
||||
group
|
||||
}
|
||||
|
||||
/// A straight stroked line from `a` to `b`.
|
||||
fn segment(id: NodeId, a: (f64, f64), b: (f64, f64), stroke: &Appearance) -> Arc<Node> {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to(a);
|
||||
p.line_to(b);
|
||||
Arc::new(Node::path(id, PathData::from_bezpath(&p), stroke.clone()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn spreadsheet_cells_are_told_from_other_text() {
|
||||
let rows = parse("Name\tQty\nApples\t3\nPears\t").unwrap();
|
||||
assert_eq!(rows, vec![vec!["Name", "Qty"], vec!["Apples", "3"], vec!["Pears", ""]]);
|
||||
// Quoted cells hold line breaks, tabs and quotes.
|
||||
let rows = parse("\"Two\nlines\"\t\"a\tb\"\n\"say \"\"hi\"\"\"\tx").unwrap();
|
||||
assert_eq!(rows, vec![vec!["Two\nlines", "a\tb"], vec!["say \"hi\"", "x"]]);
|
||||
// Not tables: no tabs, ragged rows, indented text, an unclosed quote.
|
||||
for t in ["plain words", "one\ntwo", "a\tb\nc", "\tindented\n\tcode", "a\t\"open", "x\t"] {
|
||||
assert_eq!(parse(t), None, "{t:?}");
|
||||
}
|
||||
// A quote inside a cell is just a character.
|
||||
assert_eq!(parse("5\" pipe\t2").unwrap(), vec![vec!["5\" pipe", "2"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_past_the_caps_paste_as_text() {
|
||||
let wide = vec!["x"; MAX_COLUMNS + 1].join("\t");
|
||||
assert_eq!(parse(&wide), None);
|
||||
let tall = vec!["a\tb"; MAX_ROWS + 1].join("\n");
|
||||
assert_eq!(parse(&tall), None);
|
||||
let many = vec![vec!["c"; 100].join("\t"); MAX_CELLS / 100 + 1].join("\n");
|
||||
assert_eq!(parse(&many), None);
|
||||
let fits = vec!["a\tb"; MAX_ROWS].join("\n");
|
||||
assert_eq!(parse(&fits).map(|r| r.len()), Some(MAX_ROWS));
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste",
|
||||
["Edit"],
|
||||
Some("Cmd+V"),
|
||||
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
"{center?: [x, y], artboardsAt?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay, or centered on `artboardsAt` (the Artboard tool's canvas menu passes the right-click point; a value that is not [x, y] is an error) (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Offset)
|
||||
),
|
||||
@@ -962,19 +962,28 @@ fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine {
|
||||
/// Insert clipboard resources atomically in the active editable context using the requested placement mode.
|
||||
fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> {
|
||||
let off = s.prefs.paste_offset;
|
||||
let boards_at = match p.get("artboardsAt") {
|
||||
Some(v) if mode == PasteMode::Offset => Some(
|
||||
point_param(p, "artboardsAt")
|
||||
.filter(|c| v.as_array().is_some_and(|a| a.len() == 2) && c.x.is_finite() && c.y.is_finite())
|
||||
.ok_or_else(|| bad("edit.paste", "`artboardsAt` must be [x, y]"))?,
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let same_doc = clip.source_doc == Some(st.uid);
|
||||
let parent = st.target_parent_for_path()?;
|
||||
// Copied artboards come back with their art: together right of the last artboard, as they lay,
|
||||
// or where they were (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the
|
||||
// art.
|
||||
let boards = clip
|
||||
.artboards
|
||||
.iter()
|
||||
.map(|a| a.rect)
|
||||
.reduce(|a, b| a.union(b))
|
||||
.filter(|_| mode != PasteMode::AllArtboards)
|
||||
.map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO }));
|
||||
// or centered on `artboardsAt`, or where they were (Paste in Place, in Front, in Back). Paste on
|
||||
// All Artboards pastes only the art.
|
||||
let boards = clip.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).filter(|_| mode != PasteMode::AllArtboards).map(|all| {
|
||||
let dv = match (mode, boards_at) {
|
||||
(PasteMode::Offset, Some(c)) => c - all.center(),
|
||||
(PasteMode::Offset, None) => super::panelcmds::beside_artboards(&st.doc, all),
|
||||
_ => Vec2::ZERO,
|
||||
};
|
||||
(&clip.artboards, dv)
|
||||
});
|
||||
// Drawing modes (#3705): Draw Inside puts what Paste, Paste in Place and Paste on All
|
||||
// Artboards bring inside the object it draws into; Draw Behind puts what Paste in Place and
|
||||
// Paste on All Artboards bring behind the selection, as Paste in Back does. Paste in Front and
|
||||
|
||||
@@ -754,7 +754,10 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
|
||||
// keeps it as its only member).
|
||||
Some((image, false)) => Some(rasterfx::replace_with_image(v, image)),
|
||||
image => {
|
||||
let m = if container {
|
||||
let m = if container && effects::has_solid(&v) {
|
||||
// A group's or layer's 3D effect: the shaded faces of its members' solids.
|
||||
effects::expand_leaf(d, &v, &mut stroke_art)
|
||||
} else if container {
|
||||
// A group's or layer's own fills and strokes become art among its members.
|
||||
effects::evaluate_container(Some(&*d), &v).map(|mut m| {
|
||||
members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art);
|
||||
|
||||
@@ -79,6 +79,41 @@ fn solid_effects_apply_save_edit_export_expand_and_undo() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #4506: a 3D effect on a group builds a solid from each member, turned about one shared axis;
|
||||
/// the members stay editable, and a group holding what can't become a solid is refused.
|
||||
#[test]
|
||||
fn solid_effects_apply_to_a_group_and_its_members_stay_editable() {
|
||||
for effect in effects::SOLIDS {
|
||||
let (mut s, a) = session_with_rect();
|
||||
let b = s.execute("shape.ellipse", &json!({"x": 250, "y": 120, "width": 80, "height": 60})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect": effect, "ids": [g.0], "item": null})).unwrap();
|
||||
let art = effects::solid_art(&node(&s, g)).unwrap_or_else(|| panic!("{effect}"));
|
||||
assert_eq!(node(&s, g).children().unwrap().len(), 2, "{effect}: the members stay as they were");
|
||||
// A member moved, the solid follows.
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": -40, "dy": 0})).unwrap();
|
||||
assert_ne!(effects::solid_art(&node(&s, g)).unwrap(), art, "{effect}");
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string());
|
||||
assert!(vectorcraft_testkit::svg_shapes(&svg) >= 4, "{effect}: both members exported as vector faces");
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap();
|
||||
assert!(!effects::has_solid(&node(&s, g)), "{effect}: expanded");
|
||||
}
|
||||
// A clipping group in the group: refused, nothing changed.
|
||||
let (mut s, a) = session_with_rect();
|
||||
let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
|
||||
let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone();
|
||||
let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [clip, c]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("clipping groups"), "{err}");
|
||||
assert!(node(&s, g).appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
/// #4715: type extrudes as its glyph outlines, painted with its characters' colour, and stays
|
||||
/// type: editing its text reshapes the solid.
|
||||
#[test]
|
||||
|
||||
@@ -57,9 +57,9 @@ use serde_json::{Value, json};
|
||||
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};
|
||||
pub use export::export_source;
|
||||
pub(crate) use export::with_artboard_fills;
|
||||
pub(crate) use export::{export_crop, isolated};
|
||||
pub use export::{export_doc, export_source};
|
||||
pub(crate) use load::check_not_lossy_overwrite;
|
||||
use load::err;
|
||||
pub(crate) use load::import_svg;
|
||||
|
||||
@@ -72,6 +72,17 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
|
||||
}
|
||||
|
||||
/// The document an export of `st` in format `f` writes ([`export_source`]): a `.ai` or `.ait`
|
||||
/// file names the view's active artboard ([`DocState::active_artboard`]) as its active one, as a
|
||||
/// save does. The open document is unchanged.
|
||||
pub fn export_doc<'a>(st: &'a DocState, f: &Format, p: &Value) -> Result<Cow<'a, Document>> {
|
||||
let mut doc = export_source(st, p)?;
|
||||
if let Some(id) = super::save::moved_active_artboard(st, &doc, f) {
|
||||
doc.to_mut().opened_artboard = Some(id);
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// The document an export of `st` writes: the whole document, or with `selectedOnly` just the
|
||||
/// selected objects, each in its layers and groups (a clipping group keeps its clipping path).
|
||||
pub fn export_source<'a>(st: &'a DocState, p: &Value) -> Result<Cow<'a, Document>> {
|
||||
@@ -190,7 +201,7 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C
|
||||
return Ok((Cow::Owned(doc), display_doc_title));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let doc = export_source(st, p)?;
|
||||
let doc = export_doc(st, f, p)?;
|
||||
let display_doc_title = super::has_own_title(&doc);
|
||||
Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
//! Images are the exception (`transparentImages`): an image with see-through pixels, opacity or a
|
||||
//! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha
|
||||
//! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art
|
||||
//! under it, rendered from the document as it is, clipped to the area where it changes what shows;
|
||||
//! under it, rendered from the document as it is, clipped to the area where it changes what shows
|
||||
//! (a smooth outline of its rendered pixels, not a staircase of them);
|
||||
//! the art under it stays as it was. An image in a group with opacity or a blending mode is
|
||||
//! flattened the same way, and a pattern fill that paints a transparent image (see-through
|
||||
//! pixels, or opacity or a blending mode on the image, the fill, the shape or a group it is in) is
|
||||
@@ -253,8 +254,11 @@ const MAX_PPI: f64 = 600.0;
|
||||
const MIN_PPI: f64 = 72.0;
|
||||
/// A rendered pixel channel changes when it changes by more than this.
|
||||
const CHANGED: u8 = 2;
|
||||
/// The clip outline follows cells of about this many points.
|
||||
const CELL: f64 = 2.0;
|
||||
/// The clip outline follows the rendered pixels, in blocks of them when there would be more than
|
||||
/// this many: a huge image's clip would have millions of anchors.
|
||||
const MAX_CELLS: usize = 4 << 20;
|
||||
/// How far (in cells) the clip outline may stray from the staircase it smooths.
|
||||
const SMOOTH: f64 = 0.35;
|
||||
|
||||
/// The image `id` (the `at`th node in paint order of `src`) flattened: `src` drawn up to and
|
||||
/// including it, over white, cropped and clipped to the cells where drawing it changes the
|
||||
@@ -288,18 +292,21 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result<Option<Flat>> {
|
||||
if w == 0 || h == 0 || shown.pixels.len() != w * h * 4 || under.pixels.len() != shown.pixels.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
// The cells where it changes anything.
|
||||
let c = (CELL * scale).round().max(1.0) as usize;
|
||||
// The cells where it changes anything: its pixels, or blocks of them for a huge image.
|
||||
let c = if w * h <= MAX_CELLS { 1 } else { ((w * h) as f64 / MAX_CELLS as f64).sqrt().ceil() as usize };
|
||||
let (cw, ch) = (w.div_ceil(c), h.div_ceil(c));
|
||||
let mut cells = vec![false; cw * ch];
|
||||
let mut changed = vec![false; cw * ch];
|
||||
for (k, (a, b)) in shown.pixels.as_chunks::<4>().0.iter().zip(under.pixels.as_chunks::<4>().0).enumerate() {
|
||||
if a.iter().zip(b).any(|(a, b)| a.abs_diff(*b) > CHANGED) {
|
||||
let (x, y) = (k % w, k / w);
|
||||
if let Some(cell) = cells.get_mut(y / c * cw + x / c) {
|
||||
if let Some(cell) = changed.get_mut(y / c * cw + x / c) {
|
||||
*cell = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Grown by a cell, so the clip takes in the whole anti-aliased edge: where the image changes
|
||||
// nothing it is the art under it, so a clip a little too large shows nothing more.
|
||||
let cells = grown(&changed, cw, ch);
|
||||
let marked = || cells.iter().enumerate().filter(|(_, on)| **on).map(|(k, _)| (k % cw, k / cw));
|
||||
let (Some(x0), Some(x1), Some(y0), Some(y1)) =
|
||||
(marked().map(|p| p.0).min(), marked().map(|p| p.0).max(), marked().map(|p| p.1).min(), marked().map(|p| p.1).max())
|
||||
@@ -316,12 +323,16 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result<Option<Flat>> {
|
||||
let crop = vectorcraft_render::Rendered { width: (px1 - px0) as u32, height: (py1 - py0) as u32, pixels };
|
||||
let png = crop.to_png().map_err(EngineError::Other)?;
|
||||
let at = |x: usize, y: usize| Point::new(region.x0 + (x.min(w)) as f64 / scale, region.y0 + (y.min(h)) as f64 / scale);
|
||||
// A cell corner, in cells, as a point of the page.
|
||||
let (wf, hf, cf) = (w as f64, h as f64, c as f64);
|
||||
let page = |(x, y): (f64, f64)| Point::new(region.x0 + (x * cf).clamp(0.0, wf) / scale, region.y0 + (y * cf).clamp(0.0, hf) / scale);
|
||||
let mut clip = PathData::new(vec![]);
|
||||
for comp in vectorcraft_trace::trace_mask(&cells, cw, ch) {
|
||||
for lp in std::iter::once(&comp.outer).chain(&comp.holes) {
|
||||
let pts: Vec<(i32, i32)> = corners(&lp.pts);
|
||||
let anchors = pts.iter().map(|&(x, y)| Anchor::corner(at(x.max(0) as usize * c, y.max(0) as usize * c))).collect();
|
||||
clip.subpaths.push(SubPath::new(anchors, true));
|
||||
let anchors: Vec<Anchor> = smoothed(&lp.pts).into_iter().map(|p| Anchor::corner(page(p))).collect();
|
||||
if anchors.len() >= 3 {
|
||||
clip.subpaths.push(SubPath::new(anchors, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
let xf = Affine::translate(at(px0, py0).to_vec2()) * Affine::scale(1.0 / scale);
|
||||
@@ -343,17 +354,64 @@ fn up_to(n: &Node, last: usize, i: &mut usize) -> Option<Node> {
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// A loop's corners: its points without those in the middle of a straight run.
|
||||
fn corners(pts: &[(i32, i32)]) -> Vec<(i32, i32)> {
|
||||
/// `mask` (`w × h` cells) with every cell next to a set one set too.
|
||||
fn grown(mask: &[bool], w: usize, h: usize) -> Vec<bool> {
|
||||
let set = |x: usize, y: usize| mask.get(y * w + x).copied().unwrap_or(false);
|
||||
let mut out = vec![false; mask.len()];
|
||||
for (k, cell) in out.iter_mut().enumerate() {
|
||||
let (x, y) = (k % w, k / w);
|
||||
*cell = (y.saturating_sub(1)..(y + 2).min(h)).any(|y| (x.saturating_sub(1)..(x + 2).min(w)).any(|x| set(x, y)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A loop of cell corners (a staircase) as a smooth outline, in cells: the middle of each cell
|
||||
/// edge along it, so that a run of one-cell steps becomes the sloped line it stands for, without
|
||||
/// the points that lie (within [`SMOOTH`]) on a straight line between their neighbours.
|
||||
fn smoothed(pts: &[(i32, i32)]) -> Vec<(f64, f64)> {
|
||||
let mut mids = Vec::with_capacity(pts.len());
|
||||
for (k, &(ax, ay)) in pts.iter().enumerate() {
|
||||
let Some(&(bx, by)) = pts.get(k + 1).or(pts.first()) else { break };
|
||||
let (dx, dy) = ((bx - ax).signum(), (by - ay).signum());
|
||||
let steps = (bx - ax).unsigned_abs().max((by - ay).unsigned_abs());
|
||||
for i in 0..steps as i32 {
|
||||
let (x, y) = (ax + dx * i, ay + dy * i);
|
||||
mids.push((f64::from(x) + 0.5 * f64::from(dx), f64::from(y) + 0.5 * f64::from(dy)));
|
||||
}
|
||||
}
|
||||
simplify_closed(&mids, SMOOTH)
|
||||
}
|
||||
|
||||
/// The points of closed polyline `pts` that Douglas–Peucker keeps with tolerance `tol`.
|
||||
fn simplify_closed(pts: &[(f64, f64)], tol: f64) -> Vec<(f64, f64)> {
|
||||
let n = pts.len();
|
||||
let get = |k: usize| pts.get(k % n.max(1)).copied();
|
||||
(0..n)
|
||||
.filter_map(|k| {
|
||||
let (a, b, c) = (get(k + n - 1)?, get(k)?, get(k + 1)?);
|
||||
let straight = (a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1);
|
||||
(!straight).then_some(b)
|
||||
})
|
||||
.collect()
|
||||
if n < 4 {
|
||||
return pts.to_vec();
|
||||
}
|
||||
let d2 = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2);
|
||||
let Some(&first) = pts.first() else { return vec![] };
|
||||
// Split at the first point and the point farthest from it: two open runs.
|
||||
let far = (1..n).max_by(|&i, &j| d2(first, pts[i]).total_cmp(&d2(first, pts[j]))).unwrap_or(n / 2);
|
||||
let mut keep = vec![false; n];
|
||||
keep[0] = true;
|
||||
keep[far] = true;
|
||||
let mut todo = vec![(0, far), (far, n)];
|
||||
while let Some((a, b)) = todo.pop() {
|
||||
if b <= a + 1 {
|
||||
continue;
|
||||
}
|
||||
let (p, q) = (pts[a], pts[b % n]);
|
||||
let (ux, uy) = (q.0 - p.0, q.1 - p.1);
|
||||
let len = ux.hypot(uy);
|
||||
let dist = |r: (f64, f64)| if len > 0.0 { ((r.0 - p.0) * uy - (r.1 - p.1) * ux).abs() / len } else { d2(p, r).sqrt() };
|
||||
let Some((m, dm)) = (a + 1..b).map(|i| (i, dist(pts[i]))).max_by(|x, y| x.1.total_cmp(&y.1)) else { continue };
|
||||
if dm > tol {
|
||||
keep[m] = true;
|
||||
todo.push((a, m));
|
||||
todo.push((m, b));
|
||||
}
|
||||
}
|
||||
pts.iter().zip(&keep).filter(|(_, k)| **k).map(|(p, _)| *p).collect()
|
||||
}
|
||||
|
||||
fn note(warnings: &mut Vec<String>, w: &str) {
|
||||
|
||||
@@ -109,7 +109,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
|
||||
name: "editingData",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, and linked images as placed files with a preview, with type, other patterns, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)",
|
||||
description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, and linked images as placed files with a preview, with type, other patterns, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "aiVersion",
|
||||
|
||||
@@ -96,7 +96,8 @@ pub struct SavePlan {
|
||||
/// stamps; `None`: the time it is written.
|
||||
pub modified: Option<Option<i64>>,
|
||||
/// Warnings about the params themselves: each native option (`compress`, `version`,
|
||||
/// `preview`) given beside the path that the format doesn't read.
|
||||
/// `preview`, `precision`) given beside the path, and each key of `options`, that the format
|
||||
/// doesn't read, naming the file the save writes.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -289,9 +290,11 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
super::check_not_lossy_overwrite(st, path, p, cmd)?;
|
||||
}
|
||||
let written = written_as(cmd, format, &Value::Object(options.clone()));
|
||||
let notes = NATIVE_OPTIONS
|
||||
.iter()
|
||||
.filter(|k| p.get(**k).is_some_and(|v| !v.is_null()) && !reads_option(format, k))
|
||||
let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string());
|
||||
let nested = p.get("options").and_then(Value::as_object).into_iter().flatten().filter(|(_, v)| !v.is_null()).map(|(k, _)| k);
|
||||
let notes = top
|
||||
.filter(|k| !reads_option(format, k))
|
||||
.chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}")))
|
||||
.map(|k| format!("`{k}` was not used: {} files don't read it", written.label))
|
||||
.collect();
|
||||
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes })
|
||||
@@ -447,22 +450,34 @@ fn is_svg(f: &Format) -> bool {
|
||||
}
|
||||
|
||||
/// The document as written: native files carry the view to reopen at and the Layers panel's open
|
||||
/// rows and transparency grid visibility.
|
||||
/// rows and transparency grid visibility; a `.ai` file names the view's active artboard
|
||||
/// ([`DocState::active_artboard`]) as its active one.
|
||||
fn doc_to_save(st: &DocState, f: &Format) -> Arc<Document> {
|
||||
if !is_native(f) {
|
||||
return st.doc.clone();
|
||||
}
|
||||
let open = st.layers_open.saved(&st.doc);
|
||||
if st.doc.last_view == st.view && st.doc.layers_open == open && st.doc.transparency_grid == st.transparency_grid {
|
||||
let active = moved_active_artboard(st, &st.doc, f);
|
||||
if st.doc.last_view == st.view && st.doc.layers_open == open && st.doc.transparency_grid == st.transparency_grid && active.is_none() {
|
||||
return st.doc.clone();
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
if active.is_some() {
|
||||
d.opened_artboard = active;
|
||||
}
|
||||
d.last_view = st.view.clone();
|
||||
d.transparency_grid = st.transparency_grid;
|
||||
d.layers_open = open;
|
||||
Arc::new(d)
|
||||
}
|
||||
|
||||
/// The artboard a `.ai` or `.ait` file written from `st` (`doc`, the document it writes) names as
|
||||
/// its active one, when that is not already the one `doc` opened on: the view's active artboard
|
||||
/// ([`DocState::active_artboard`]), if `doc` has it.
|
||||
pub(super) fn moved_active_artboard(st: &DocState, doc: &Document, f: &Format) -> Option<u32> {
|
||||
st.active_artboard.filter(|id| matches!(f.id, "ai" | "ait") && doc.artboards.iter().any(|a| a.id == *id) && doc.opened_artboard != Some(*id))
|
||||
}
|
||||
|
||||
/// What a format loses against a native file (reported whenever a save writes it).
|
||||
fn fidelity_warning(f: &Format) -> Option<String> {
|
||||
(!is_native(f)).then(|| {
|
||||
@@ -1057,7 +1072,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), 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; 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?: 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; 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)
|
||||
),
|
||||
|
||||
@@ -148,6 +148,61 @@ fn an_ai_file_opens_with_its_active_artboard() {
|
||||
assert_eq!(d.opened_artboard, Some(d.artboards[1].id), "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` save names the view's active artboard in its editing copy, else the one the document
|
||||
/// opened on, else the first; the file read from that copy opens on it.
|
||||
#[test]
|
||||
fn an_ai_save_names_its_active_artboard() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
let ids: Vec<u32> = s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect();
|
||||
let opts = LoadOptions { native: false, ..Default::default() };
|
||||
let active = |s: &mut Session, format: &str| {
|
||||
let cmd = if format == "ait" { "file.saveAsTemplate" } else { "file.saveCopy" };
|
||||
let saved = s.execute(cmd, &json!({"format": format})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let l = load::load_with(&format!("a.{format}"), &bytes, &opts).unwrap();
|
||||
assert!(l.editing_data && !l.restored, "{:?}", l.warnings);
|
||||
assert_eq!(l.doc.artboards.len(), 3);
|
||||
l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0)
|
||||
};
|
||||
assert_eq!(active(&mut s, "ai"), 0);
|
||||
// The view's.
|
||||
s.doc_mut().unwrap().active_artboard = Some(ids[2]);
|
||||
assert_eq!((active(&mut s, "ai"), active(&mut s, "ait")), (2, 2));
|
||||
// The one the document opened on, when the view names none or an artboard it doesn't have.
|
||||
Arc::make_mut(&mut s.doc_mut().unwrap().doc).opened_artboard = Some(ids[1]);
|
||||
for view in [None, Some(99)] {
|
||||
s.doc_mut().unwrap().active_artboard = view;
|
||||
assert_eq!(active(&mut s, "ai"), 1, "{view:?}");
|
||||
}
|
||||
// The view's first artboard over the one the document opened on.
|
||||
s.doc_mut().unwrap().active_artboard = Some(ids[0]);
|
||||
assert_eq!(active(&mut s, "ai"), 0);
|
||||
assert_eq!(s.doc().unwrap().doc.opened_artboard, Some(ids[1]), "the document is unchanged");
|
||||
}
|
||||
|
||||
/// A `.ai` export (`document.serialize`, `document.export`, with or without `selectedOnly`) names
|
||||
/// the view's active artboard in its editing copy, as a save does.
|
||||
#[test]
|
||||
fn an_ai_export_names_its_active_artboard() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
let ids: Vec<u32> = s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect();
|
||||
s.doc_mut().unwrap().active_artboard = Some(ids[2]);
|
||||
let opts = LoadOptions { native: false, ..Default::default() };
|
||||
for (cmd, selected) in [("document.serialize", false), ("document.serialize", true), ("document.export", false)] {
|
||||
let out = s.execute(cmd, &json!({"format": "ai", "selectedOnly": selected})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(out["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let l = load::load_with("a.ai", &bytes, &opts).unwrap();
|
||||
assert!(l.editing_data && !l.restored, "{cmd}: {:?}", l.warnings);
|
||||
let active = l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id));
|
||||
assert_eq!(active, Some(2), "{cmd} {selected}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.opened_artboard, None, "the document is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_unread_group_keeps_its_layers_and_live_neighbor() {
|
||||
let live = format!("0 0 1 0 k\n{}f", ai::rect(10.0, 10.0, 14.0, 14.0));
|
||||
|
||||
@@ -598,36 +598,17 @@ fn editing_copy(bytes: &[u8]) -> vectorcraft_doc::Document {
|
||||
l.doc
|
||||
}
|
||||
|
||||
/// `nodes` as the editing copy reads them back: an opacity mask's art that isn't a group comes
|
||||
/// back as a plain group holding it.
|
||||
fn mask_art_grouped(nodes: &mut [std::sync::Arc<vectorcraft_doc::Node>]) {
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
for n in nodes {
|
||||
let n = std::sync::Arc::make_mut(n);
|
||||
if let Some(m) = n.extras_mut().mask.as_mut()
|
||||
&& !matches!(m.art.kind, NodeKind::Group { .. })
|
||||
{
|
||||
let art = m.art.clone();
|
||||
m.art = std::sync::Arc::new(Node::new(art.id, NodeKind::Group { children: vec![art], clip: false }));
|
||||
}
|
||||
if let Some(c) = n.children_mut() {
|
||||
mask_art_grouped(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparency, a compound path, a clipping group, an opacity mask and a clipping layer come back
|
||||
/// from the editing copy for other apps alone, its page matching it, with every save option that
|
||||
/// writes the copy; the version 7 `.ai` keeps the compound path and the clipping group.
|
||||
#[test]
|
||||
fn transparency_clipping_and_opacity_masks_come_back_from_the_editing_copy() {
|
||||
let mut s = see_through_session();
|
||||
let mut before = (*s.doc().unwrap().doc).clone();
|
||||
// The mask's art is one path; it reads back as a group holding the path.
|
||||
let before = (*s.doc().unwrap().doc).clone();
|
||||
// The mask's art is one path, and it reads back as that path.
|
||||
let mut arts = vec![];
|
||||
before.walk(|n| arts.extend(n.extras().mask.as_ref().map(|m| matches!(m.art.kind, vectorcraft_doc::NodeKind::Path { .. }))));
|
||||
assert_eq!(arts, [true]);
|
||||
mask_art_grouped(&mut before.layers);
|
||||
let want = see_through_outline(&before);
|
||||
for opts in [json!({"format": "ai"}), json!({"format": "ai", "compress": false}), json!({"format": "ai", "pdfCompatible": true})] {
|
||||
let bytes = b64(&s.execute("document.export", &opts).unwrap());
|
||||
@@ -803,6 +784,65 @@ fn images_come_back_from_the_editing_copy() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of `doc`'s art, one line each: its kind, point type's matrix or area type's frame on the
|
||||
/// document (rounded to hundredths), its characters, and each run's font, size and fill.
|
||||
fn type_outline(doc: &vectorcraft_doc::Document) -> Vec<String> {
|
||||
use vectorcraft_doc::{NodeKind, TextKind};
|
||||
let mut out = vec![];
|
||||
let round = |v: f64| (v * 100.0).round() / 100.0 + 0.0;
|
||||
doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let (kind, xf) = match &t.kind {
|
||||
TextKind::Point => ("point", t.xf.as_coeffs().map(round).to_vec()),
|
||||
TextKind::Area { frame } => {
|
||||
let b = frame.transformed(t.xf).bounds().unwrap();
|
||||
("area", vec![round(b.x0), round(b.y0), round(b.x1), round(b.y1)])
|
||||
}
|
||||
TextKind::OnPath { .. } => ("path", vec![]),
|
||||
};
|
||||
let runs: Vec<String> = t
|
||||
.runs
|
||||
.iter()
|
||||
.map(|r| format!("{:?} {} {} {} {:?}", r.text, r.style.font_family, r.style.font_style, round(r.style.size), r.style.fill))
|
||||
.collect();
|
||||
out.push(format!("{kind} {xf:?} {runs:?}"));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Point type of two paragraphs, turned point type and area type come back from the editing copy
|
||||
/// for other apps alone as type, with every save option that writes the copy.
|
||||
#[test]
|
||||
fn type_comes_back_from_the_editing_copy() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 20, "y": 40, "text": "Hello\nWorld", "size": 24})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 150, "y": 100, "text": "Turned"})).unwrap();
|
||||
// The new type is selected.
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 20, "y": 120, "text": "Area type in a frame", "area": {"width": 120, "height": 60}})).unwrap();
|
||||
let path = tmp("type.ai");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
let before = type_outline(&s.doc().unwrap().doc);
|
||||
assert_eq!(before.len(), 3, "{before:#?}");
|
||||
for opts in [
|
||||
json!({"format": "ai"}),
|
||||
json!({"format": "ai", "compress": false}),
|
||||
json!({"format": "ai", "embedProfiles": false}),
|
||||
json!({"format": "ai", "includeLinked": true}),
|
||||
json!({"format": "ai", "range": "all"}),
|
||||
json!({"format": "ai", "pdfCompatible": false}),
|
||||
] {
|
||||
let r = s.execute("document.export", &opts).unwrap();
|
||||
assert!(!r["warnings"].to_string().contains("type"), "{opts} {}", r["warnings"]);
|
||||
let copy = load::load_with(&path, &b64(&r), &LoadOptions { native: false, ..Default::default() }).unwrap();
|
||||
assert!(copy.editing_data && !copy.restored, "{opts} {:?}", copy.warnings);
|
||||
assert!(!copy.warnings.iter().any(|w| w.contains("editing data")), "{opts} {:?}", copy.warnings);
|
||||
assert_eq!(type_outline(©.doc), before, "{opts}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A document of two artboards (the art on the first): a rectangle filled with a linear gradient of
|
||||
/// three stops placed across part of it, an ellipse filled with an elliptical radial gradient whose
|
||||
/// focal point is off its center, a rectangle stroked with a gradient, rectangles filled and
|
||||
|
||||
@@ -164,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fit to Artwork Bounds",
|
||||
["Object", "Artboards"],
|
||||
None,
|
||||
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art, or a locked one, stays as it is, and the command fails when every artboard it names is locked or none has art",
|
||||
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step (none when every artboard already fits): an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art, or a locked one, stays as it is, and the command fails when every artboard it names is locked or none has art",
|
||||
has_doc,
|
||||
artboard_fit_art
|
||||
),
|
||||
@@ -1151,6 +1151,11 @@ fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if fits.is_empty() {
|
||||
return Err(EngineError::Other("no artwork".into()));
|
||||
}
|
||||
// An artboard that already fits its art changes nothing, and no undo step is recorded for it.
|
||||
fits.retain(|(_, was, now)| [now.x0 - was.x0, now.y0 - was.y0, now.x1 - was.x1, now.y1 - was.y1].iter().any(|d| d.abs() > 1e-6));
|
||||
if fits.is_empty() {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Artboard Options", |d, _| {
|
||||
for &(i, was, now) in &fits {
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
|
||||
@@ -1036,6 +1036,11 @@ fn selected_paint(s: &Session, p: &Value, stroke: bool) -> Result<Paint> {
|
||||
None => st.selection.objects.clone(),
|
||||
};
|
||||
let from_art = ids.iter().filter_map(|id| st.doc.node(*id)).find_map(|n| {
|
||||
// Only a gradient needs the object measured for its placement.
|
||||
let paint = n.proxy_paint_only(stroke, None)?;
|
||||
if !matches!(paint, Paint::Gradient(_)) {
|
||||
return Some(paint.clone());
|
||||
}
|
||||
let (paint, to_doc, bounds) = n.proxy_paint(stroke, None)?;
|
||||
// Type's characters paint in its own space: a gradient there takes its placement in the
|
||||
// document, so that it keeps the direction it shows.
|
||||
|
||||
@@ -181,7 +181,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Envelope Info",
|
||||
[],
|
||||
None,
|
||||
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, editing}",
|
||||
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
|
||||
has_doc,
|
||||
env_info
|
||||
),
|
||||
@@ -199,7 +199,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Envelope Options…",
|
||||
["Object", "Envelope Distort"],
|
||||
None,
|
||||
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
|
||||
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
|
||||
has_doc,
|
||||
env_options
|
||||
),
|
||||
@@ -217,7 +217,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Contents",
|
||||
["Object", "Envelope Distort"],
|
||||
None,
|
||||
"{editing?: bool} toggle Edit Contents / Edit Envelope → {editing}",
|
||||
"{editing?: bool} toggle Edit Contents / Edit Envelope; leaving Edit Contents measures an envelope's boundsMargin again when the content's strokes reach differently than when Edit Contents began → {editing}",
|
||||
has_envelope,
|
||||
env_edit_contents
|
||||
),
|
||||
|
||||
@@ -177,6 +177,9 @@ fn envelope_options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Va
|
||||
v["distortAppearance"] = json!(o.distort_appearance);
|
||||
v["distortLinearGradients"] = json!(o.distort_linear_gradients);
|
||||
v["distortPatternFills"] = json!(o.distort_pattern_fills);
|
||||
if o.bounds_margin != [0.0; 4] {
|
||||
v["boundsMargin"] = json!(o.bounds_margin);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -192,6 +195,7 @@ fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result<EnvelopeO
|
||||
distort_appearance: switch_or(p, "distortAppearance", cur.distort_appearance, cmd)?,
|
||||
distort_linear_gradients: switch_or(p, "distortLinearGradients", cur.distort_linear_gradients, cmd)?,
|
||||
distort_pattern_fills: switch_or(p, "distortPatternFills", cur.distort_pattern_fills, cmd)?,
|
||||
bounds_margin: cur.bounds_margin,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -369,6 +373,12 @@ pub(super) fn env_expand(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
/// The margins the strokes of `content` give in the axes of `frame` (none for content without
|
||||
/// width or height).
|
||||
fn stroke_margin(content: &[Arc<Node>], frame: Affine) -> [f32; 4] {
|
||||
live::envelope_content_boxes(content, frame).and_then(|(stroked, plain)| live::envelope_stroke_margin(stroked, plain)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.editContents";
|
||||
let envs = selected_of(s, is_envelope);
|
||||
@@ -377,12 +387,26 @@ pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(if want { "Edit Contents" } else { "Edit Envelope" }, |d, sel| {
|
||||
let mut content_ids = vec![];
|
||||
for e in &envs {
|
||||
if let Some(n) = d.node_mut(*e)
|
||||
&& let NodeKind::Envelope { editing, content, .. } = &mut n.kind
|
||||
{
|
||||
let Some(n) = d.node_mut(*e) else { continue };
|
||||
let start = n.extras().envelope_edit_margin;
|
||||
let mut began = None;
|
||||
if let NodeKind::Envelope { editing, content, options, frame, .. } = &mut n.kind {
|
||||
// Margins stay when the strokes reach as far as when Edit Contents began, whatever
|
||||
// transforms the envelope took before that.
|
||||
if options.bounds_margin != [0.0; 4] {
|
||||
let now = stroke_margin(content, **frame);
|
||||
if want {
|
||||
began = start.filter(|_| *editing).or(Some(now));
|
||||
} else if *editing && start != Some(now) {
|
||||
options.bounds_margin = now;
|
||||
}
|
||||
}
|
||||
*editing = want;
|
||||
content_ids.extend(content.iter().map(|c| c.id));
|
||||
}
|
||||
if began != start {
|
||||
n.extras_mut().envelope_edit_margin = began;
|
||||
}
|
||||
}
|
||||
if want {
|
||||
sel.set(content_ids);
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}",
|
||||
"{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}",
|
||||
has_doc,
|
||||
artboard_duplicate
|
||||
),
|
||||
@@ -25,18 +25,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index? | indices?, art?} (defaults as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
"{index? | indices?, selectedArtboards?, art?} (as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
has_doc,
|
||||
|s, p| artboard_copy(s, p, false)
|
||||
|s, p| {
|
||||
boards_and_art_noted(s, p, "artboard.copy")?;
|
||||
artboard_copy(s, p, false)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"artboard.cut",
|
||||
"Cut Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
"{index? | indices?, selectedArtboards?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
has_doc,
|
||||
|s, p| artboard_copy(s, p, true)
|
||||
|s, p| {
|
||||
boards_and_art_noted(s, p, "artboard.cut")?;
|
||||
artboard_copy(s, p, true)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"artboard.rename",
|
||||
"Rename Artboards",
|
||||
[],
|
||||
None,
|
||||
"{index? | indices?, selectedArtboards?: [n…] (as artboard.duplicate), name} (default: the artboards the Artboard tool has selected, else its active one) rename the artboards in one undo step: one takes `name` (trimmed), and several take it numbered in artboard order, `name-1`, `name-2` and so on; a locked artboard is renamed too. An empty name is an error, and when every artboard already has its new name nothing changes and no undo step is recorded → {renamed: [index…]}",
|
||||
has_doc,
|
||||
artboard_rename
|
||||
),
|
||||
cmd!(
|
||||
"artboard.switchOrientation",
|
||||
"Switch Orientation",
|
||||
[],
|
||||
None,
|
||||
"{index? | indices?, selectedArtboards?: [n…] (as artboard.duplicate), scaleArt?: bool (default: the Artboard tool's Scale Artwork with Artboard option), patterns?, lockedAndHidden?: bool (as artboard.setProps)} (default: the artboards the Artboard tool has selected, else its active one) each artboard swaps its width and height and keeps its top-left corner, in one undo step; a square artboard and a locked one stay as they are, the call fails with `the artboard is locked` when every artboard it names is locked, and when nothing else changes no undo step is recorded → {switched: [index…]}",
|
||||
has_doc,
|
||||
artboard_switch_orientation
|
||||
),
|
||||
cmd!(
|
||||
"text.setFormat",
|
||||
@@ -82,24 +106,120 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// of them is in the document) and its Move/Copy Artwork with Artboard option.
|
||||
fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<usize>, bool)> {
|
||||
check_switches(p, &["art"], cmd)?;
|
||||
let indices = named_or_selected(s, p, cmd)?;
|
||||
Ok((indices, bool_or(p, "art", s.tool_options_of("artboard")["moveArt"].as_bool().unwrap_or(true))))
|
||||
}
|
||||
|
||||
/// [`boards_and_art`], with both values added to the running command's journal entry, so a replay
|
||||
/// acts on the same artboards and art whatever the Artboard tool's state is then. `edit.cut` and
|
||||
/// `edit.copy` call [`artboard_copy`] without this and take no such params.
|
||||
fn boards_and_art_noted(s: &mut Session, p: &Value, cmd: &str) -> Result<(Vec<usize>, bool)> {
|
||||
let (indices, art) = boards_and_art(s, p, cmd)?;
|
||||
note_artboards(s, p, &indices);
|
||||
s.note_journal("art", json!(art));
|
||||
Ok((indices, art))
|
||||
}
|
||||
|
||||
/// Add `selectedArtboards` to the running command's journal entry when `p` names no artboard, so a
|
||||
/// replay acts on the artboards the Artboard tool had selected. An action leaves it out
|
||||
/// (`REPLAY_ONLY`) and keeps an `index` or `indices` the call gave.
|
||||
fn note_artboards(s: &mut Session, p: &Value, indices: &[usize]) {
|
||||
if p.get("index").is_none() && p.get("indices").is_none() {
|
||||
s.note_journal("selectedArtboards", json!(indices));
|
||||
}
|
||||
}
|
||||
|
||||
/// The artboards `cmd` acts on: `index` or `indices`, else `selectedArtboards` (a replay of the
|
||||
/// journal), else those the Artboard tool has selected, else its active one.
|
||||
fn named_or_selected(s: &Session, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
let t = s.tool_options_of("artboard");
|
||||
let d = &s.doc()?.doc;
|
||||
let indices = if p.get("index").is_some() || p.get("indices").is_some() {
|
||||
artboard_indices(d, p, cmd)?
|
||||
} else {
|
||||
let mut picked: Vec<u64> = t["selected"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_u64)
|
||||
.filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len()))
|
||||
.collect();
|
||||
if picked.is_empty() {
|
||||
picked.push(t["active"].as_u64().unwrap_or(0));
|
||||
}
|
||||
artboard_indices(d, &json!({ "indices": picked }), cmd)?
|
||||
if p.get("index").is_some() || p.get("indices").is_some() {
|
||||
return artboard_indices(d, p, cmd);
|
||||
}
|
||||
if let Some(recorded) = p.get("selectedArtboards") {
|
||||
return artboard_indices(d, &json!({ "indices": recorded }), cmd);
|
||||
}
|
||||
let mut picked: Vec<u64> = t["selected"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_u64)
|
||||
.filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len()))
|
||||
.collect();
|
||||
if picked.is_empty() {
|
||||
picked.push(t["active"].as_u64().unwrap_or(0));
|
||||
}
|
||||
artboard_indices(d, &json!({ "indices": picked }), cmd)
|
||||
}
|
||||
|
||||
/// Rename Artboards: one artboard takes the name, and several take it numbered in artboard order, in
|
||||
/// one undo step. Nothing runs when each already has its new name.
|
||||
fn artboard_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "artboard.rename";
|
||||
let name = match p.get("name") {
|
||||
Some(Value::String(n)) if !n.trim().is_empty() => n.trim(),
|
||||
Some(Value::String(_)) => return Err(bad(C, "`name` must not be empty")),
|
||||
_ => return Err(bad(C, "`name` must be a string")),
|
||||
};
|
||||
Ok((indices, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true))))
|
||||
let indices = named_or_selected(s, p, C)?;
|
||||
note_artboards(s, p, &indices);
|
||||
let d = &s.doc()?.doc;
|
||||
let several = indices.len() > 1;
|
||||
let entries: Vec<(usize, String)> = indices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, i)| (*i, if several { format!("{name}-{}", k + 1) } else { name.to_string() }))
|
||||
.filter(|(i, new)| d.artboards.get(*i).is_some_and(|a| a.name != *new))
|
||||
.collect();
|
||||
let renamed: Vec<usize> = entries.iter().map(|(i, _)| *i).collect();
|
||||
if !entries.is_empty() {
|
||||
let list: Vec<Value> = entries.iter().map(|(i, new)| json!({"index": i, "name": new})).collect();
|
||||
s.execute("artboard.setProps", &json!({ "artboards": list }))?;
|
||||
}
|
||||
Ok(json!({ "renamed": renamed }))
|
||||
}
|
||||
|
||||
/// Switch Orientation: each artboard that is neither square nor locked swaps its width and height
|
||||
/// and keeps its top-left corner, in one undo step. Nothing runs when none of them changes.
|
||||
fn artboard_switch_orientation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "artboard.switchOrientation";
|
||||
// Swapping width and height keeps the area, so `artboard.setProps` scales no strokes or corners.
|
||||
const FROM_PREFS: [&str; 2] = ["patterns", "lockedAndHidden"];
|
||||
check_switches(p, &["scaleArt"], C)?;
|
||||
check_switches(p, &FROM_PREFS, C)?;
|
||||
let indices = named_or_selected(s, p, C)?;
|
||||
note_artboards(s, p, &indices);
|
||||
let d = &s.doc()?.doc;
|
||||
if indices.iter().all(|i| d.artboards.get(*i).is_some_and(|a| a.locked)) {
|
||||
refuse_locked(d, &indices)?;
|
||||
}
|
||||
let turned: Vec<(usize, Rect)> = indices
|
||||
.iter()
|
||||
.filter_map(|i| d.artboards.get(*i).map(|a| (*i, a)))
|
||||
.filter(|(_, a)| !a.locked && (a.rect.width() - a.rect.height()).abs() > 1e-6)
|
||||
.map(|(i, a)| (i, a.rect))
|
||||
.collect();
|
||||
let switched: Vec<usize> = turned.iter().map(|(i, _)| *i).collect();
|
||||
if !turned.is_empty() {
|
||||
let list: Vec<Value> = turned.iter().map(|(i, r)| json!({"index": i, "width": r.height(), "height": r.width()})).collect();
|
||||
let scale = bool_or(p, "scaleArt", s.tool_options_of("artboard")["scaleArt"].as_bool().unwrap_or(false));
|
||||
s.note_journal("scaleArt", json!(scale));
|
||||
let mut set = json!({ "artboards": list, "scaleArt": scale });
|
||||
for key in FROM_PREFS {
|
||||
if let Some(v) = p.get(key) {
|
||||
set[key] = v.clone();
|
||||
}
|
||||
}
|
||||
// What the step takes from the preferences joins this command's journal entry.
|
||||
let (_, noted) = s.execute_step("artboard.setProps", &set)?;
|
||||
for key in FROM_PREFS {
|
||||
if let Some(v) = noted.get(key) {
|
||||
s.note_journal(key, v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "switched": switched }))
|
||||
}
|
||||
|
||||
/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of
|
||||
@@ -170,7 +290,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
|
||||
/// their places relative to each other, together right of the last artboard.
|
||||
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["patterns"], "artboard.duplicate")?;
|
||||
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
|
||||
let (indices, art) = boards_and_art_noted(s, p, "artboard.duplicate")?;
|
||||
let arts = artboards_art(s, &indices, art, false)?;
|
||||
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() };
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Perspective Grid",
|
||||
[],
|
||||
None,
|
||||
"{} → {grid: the model (as perspective.grid.set takes it), define: the Define Grid fields (as perspective.grid.define takes them), station: {x, y, distance} (the viewer: centre of vision on the horizon, distance to the picture plane, pt), defined: false while the document uses the default grid}",
|
||||
"{} → {grid: the model (as perspective.grid.set takes it), define: the Define Grid fields (as perspective.grid.define takes them; name is the preset only while the grid still has its fields, else \"\" for custom: dragging the horizon, a vanishing point or the station point makes it custom, moving a plane does not), station: {x, y, distance} (the viewer: centre of vision on the horizon, distance to the picture plane, pt), defined: false while the document uses the default grid}",
|
||||
has_doc,
|
||||
grid_get
|
||||
),
|
||||
@@ -222,7 +222,13 @@ pub(crate) fn grid_info(doc: &vectorcraft_doc::Document, g: &PerspectiveGrid) ->
|
||||
|
||||
fn grid_get(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
Ok(grid_info(doc, &grid_of(doc)))
|
||||
let g = grid_of(doc);
|
||||
let mut info = grid_info(doc, &g);
|
||||
// The grid is its preset only while it has the preset's fields: a station point or vanishing
|
||||
// point dragged since makes it a custom grid.
|
||||
let def = g.definition();
|
||||
info["define"]["name"] = json!(s.preset_named(&def, &[&def.name]));
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
fn grid_define(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -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 and swatches 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), 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
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Resources placed art brings along: the images, symbols, pattern swatches and colour swatches it
|
||||
//! uses (also through symbol and pattern art and through tint swatches) join the document. A name
|
||||
//! the document already uses for something different gets a free variant (`Star 2`,
|
||||
//! Resources placed art brings along: the images, symbols, pattern swatches, colour swatches and
|
||||
//! brushes it uses (also through symbol, pattern and brush art and through tint swatches) join the
|
||||
//! document. A name the document already uses for something different gets a free variant (`Star 2`,
|
||||
//! `pdf-image-1 2`…) and the art's references follow; a colour linked to a swatch the document
|
||||
//! defines differently keeps its colour, unlinked. Text styles are left as they are: the art carries
|
||||
//! its own formatting.
|
||||
@@ -8,6 +8,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_brush::{Brush, BrushKind};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind};
|
||||
|
||||
@@ -31,6 +32,7 @@ enum Ref<'a> {
|
||||
Image(&'a mut String),
|
||||
Symbol(&'a mut String),
|
||||
Pattern(&'a mut String),
|
||||
Brush(&'a mut String),
|
||||
/// A colour's (or gradient's) swatch link; renaming it to `None` unlinks it.
|
||||
Swatch(&'a mut Option<String>),
|
||||
}
|
||||
@@ -56,7 +58,12 @@ fn refs(n: &mut Node, f: &mut impl FnMut(Ref)) {
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(fl) => paint_refs(&mut fl.paint, f),
|
||||
AppearanceItem::Stroke(st) => paint_refs(&mut st.paint, f),
|
||||
AppearanceItem::Stroke(st) => {
|
||||
paint_refs(&mut st.paint, f);
|
||||
if let Some(brush) = &mut st.brush {
|
||||
f(Ref::Brush(brush));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match &mut n.kind {
|
||||
@@ -80,6 +87,7 @@ struct Used {
|
||||
images: Vec<String>,
|
||||
symbols: Vec<String>,
|
||||
patterns: Vec<String>,
|
||||
brushes: Vec<String>,
|
||||
swatches: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -89,6 +97,7 @@ impl Used {
|
||||
Ref::Image(k) => (&mut self.images, &*k),
|
||||
Ref::Symbol(k) => (&mut self.symbols, &*k),
|
||||
Ref::Pattern(k) => (&mut self.patterns, &*k),
|
||||
Ref::Brush(k) => (&mut self.brushes, &*k),
|
||||
Ref::Swatch(Some(k)) => (&mut self.swatches, &*k),
|
||||
Ref::Swatch(None) => return,
|
||||
};
|
||||
@@ -116,7 +125,9 @@ fn names<T: PartialEq>(
|
||||
let entry = match existing(name) {
|
||||
None => (Some(name.clone()), true),
|
||||
Some(e) if e == d => (Some(name.clone()), false),
|
||||
Some(_) => (Some(unique_name(name, |c| taken(c) || out.values().any(|(n, _)| n.as_deref() == Some(c)))), true),
|
||||
Some(_) => {
|
||||
(Some(unique_name(name, |c| taken(c) || used.iter().any(|u| u == c) || out.values().any(|(n, _)| n.as_deref() == Some(c)))), true)
|
||||
}
|
||||
};
|
||||
out.insert(name.clone(), entry);
|
||||
}
|
||||
@@ -128,6 +139,18 @@ fn copies(m: &Renames) -> Vec<(&str, &str)> {
|
||||
m.iter().filter(|(_, (_, copy))| *copy).filter_map(|(old, (new, _))| Some((old.as_str(), new.as_deref()?))).collect()
|
||||
}
|
||||
|
||||
/// The art a brush holds.
|
||||
fn brush_art(b: &mut Brush) -> Vec<&mut Node> {
|
||||
match &mut b.kind {
|
||||
BrushKind::Scatter(s) => vec![&mut s.art],
|
||||
BrushKind::Art(a) => vec![&mut a.art],
|
||||
BrushKind::Pattern(p) => {
|
||||
[Some(&mut p.side), p.outer_corner.as_mut(), p.inner_corner.as_mut(), p.start.as_mut(), p.end.as_mut()].into_iter().flatten().collect()
|
||||
}
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the resources `nodes` (art from `src`) use into `dst`, renaming on conflict, and point
|
||||
/// `nodes` at the names they got.
|
||||
pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
|
||||
@@ -135,9 +158,10 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
|
||||
for n in nodes.iter_mut() {
|
||||
refs(n, &mut |r| used.note(r));
|
||||
}
|
||||
// Symbol and pattern art and tint swatches can use more resources (the lists grow while they
|
||||
// are walked).
|
||||
let (mut si, mut pi, mut wi) = (0, 0, 0);
|
||||
let src_brushes = vectorcraft_brush::library(src);
|
||||
// Symbol, pattern and brush art and tint swatches can use more resources (the lists grow while
|
||||
// they are walked).
|
||||
let (mut si, mut pi, mut wi, mut bi) = (0, 0, 0, 0);
|
||||
loop {
|
||||
let mut art: Vec<Node> = vec![];
|
||||
if let Some(name) = used.symbols.get(si) {
|
||||
@@ -151,6 +175,11 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
|
||||
if let Some(mut paint) = src.swatch(name).map(|w| w.paint.clone()) {
|
||||
paint_refs(&mut paint, &mut |r| used.note(r));
|
||||
}
|
||||
} else if let Some(name) = used.brushes.get(bi) {
|
||||
bi += 1;
|
||||
if let Some(mut b) = src_brushes.iter().find(|b| b.name == *name).cloned() {
|
||||
art.extend(brush_art(&mut b).into_iter().map(|a| a.clone()));
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -182,12 +211,38 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
|
||||
Some((n.clone(), entry))
|
||||
})
|
||||
.collect();
|
||||
// A brush whose art uses a resource that moves to another name isn't the document's brush of
|
||||
// that name, whatever it looks like.
|
||||
let moved = |map: &Renames, k: &str| map.get(k).is_some_and(|(new, _)| new.as_deref() != Some(k));
|
||||
let moves_art = |b: &Brush| {
|
||||
let (mut b, mut any) = (b.clone(), false);
|
||||
for a in brush_art(&mut b) {
|
||||
refs(a, &mut |r| {
|
||||
any |= match r {
|
||||
Ref::Image(k) => moved(&images, k),
|
||||
Ref::Symbol(k) => moved(&symbols, k),
|
||||
Ref::Pattern(k) => moved(&patterns, k),
|
||||
Ref::Swatch(link) => link.as_deref().is_some_and(|k| moved(&swatches, k)),
|
||||
Ref::Brush(_) => false,
|
||||
}
|
||||
});
|
||||
}
|
||||
any
|
||||
};
|
||||
let mut dst_brushes = vectorcraft_brush::library(dst);
|
||||
let brushes = names(
|
||||
&used.brushes,
|
||||
|n| src_brushes.iter().find(|b| b.name == n).map(|b| (b, moves_art(b))),
|
||||
|n| dst_brushes.iter().find(|b| b.name == n).map(|b| (b, false)),
|
||||
|c| dst_brushes.iter().any(|b| b.name == c),
|
||||
);
|
||||
|
||||
let mut rename = |r: Ref| {
|
||||
let (map, k) = match r {
|
||||
Ref::Image(k) => (&images, k),
|
||||
Ref::Symbol(k) => (&symbols, k),
|
||||
Ref::Pattern(k) => (&patterns, k),
|
||||
Ref::Brush(k) => (&brushes, k),
|
||||
Ref::Swatch(link) => {
|
||||
if let Some((new, _)) = link.as_deref().and_then(|k| swatches.get(k)) {
|
||||
*link = new.clone();
|
||||
@@ -223,6 +278,19 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
|
||||
dst.patterns.push(def);
|
||||
}
|
||||
}
|
||||
let new_brushes = copies(&brushes);
|
||||
if !new_brushes.is_empty() {
|
||||
for (old, new) in new_brushes {
|
||||
if let Some(mut def) = src_brushes.iter().find(|b| b.name == old).cloned() {
|
||||
def.name = new.to_string();
|
||||
for a in brush_art(&mut def) {
|
||||
refs(a, &mut rename);
|
||||
}
|
||||
dst_brushes.push(def);
|
||||
}
|
||||
}
|
||||
vectorcraft_brush::store(dst, &dst_brushes);
|
||||
}
|
||||
for (old, _) in copies(&swatches) {
|
||||
if let Some(mut w) = src.swatch(old).cloned() {
|
||||
paint_refs(&mut w.paint, &mut rename);
|
||||
|
||||
@@ -277,6 +277,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
("pl", "Polish"),
|
||||
("cs", "Czech"),
|
||||
("ru", "Russian"),
|
||||
("bg", "Bulgarian"),
|
||||
("sr-Cyrl", "Serbian: Cyrillic"),
|
||||
("sr-Latn", "Serbian: Latin"),
|
||||
("hr", "Croatian"),
|
||||
@@ -293,6 +294,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
("kn", "Kannada"),
|
||||
("ml", "Malayalam"),
|
||||
("or", "Odia"),
|
||||
("as", "Assamese"),
|
||||
("sa", "Sanskrit"),
|
||||
("ur", "Urdu")
|
||||
])
|
||||
),
|
||||
@@ -517,6 +520,50 @@ fn in_range(key: &str, x: Option<f64>, min: f64, max: f64) -> std::result::Resul
|
||||
}
|
||||
}
|
||||
|
||||
/// A check of a folder path: `Err` says why the folder can't be the preference's value.
|
||||
pub type FolderCheck = fn(&str) -> std::result::Result<(), String>;
|
||||
|
||||
/// The check a folder preference with a Choose… button gives a folder before it becomes the
|
||||
/// preference's value: Data Recovery keeps its copies in its folder, and the Additional Plug-ins
|
||||
/// Folder is read for plug-ins. `None` for any other preference.
|
||||
pub fn folder_check(key: &str) -> Option<FolderCheck> {
|
||||
match key {
|
||||
"recoveryFolder" => Some(writable_folder),
|
||||
"pluginsFolder" => Some(plugins_folder),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ok` when `path` is a folder whose contents can be listed, as the plug-ins in it are found by
|
||||
/// listing it. A folder outside the automation roots in force is refused before it is looked at.
|
||||
fn plugins_folder(path: &str) -> std::result::Result<(), String> {
|
||||
crate::file_access::check_read(path)?;
|
||||
let dir = std::path::Path::new(path);
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("{path} is not a folder"));
|
||||
}
|
||||
std::fs::read_dir(dir).map(drop).map_err(|e| format!("Plug-ins can't be loaded from {path}: {e}"))
|
||||
}
|
||||
|
||||
/// `Ok` when `path` is a folder that can be listed and that a folder can be created in, as the
|
||||
/// recovery store lists its folders there and makes its own: the folder is listed, and an empty
|
||||
/// folder is made there and removed. A folder that can't be removed again is an error too. A
|
||||
/// folder outside the automation roots in force is refused before it is looked at.
|
||||
fn writable_folder(path: &str) -> std::result::Result<(), String> {
|
||||
crate::file_access::check_write(path)?;
|
||||
crate::file_access::check_read(path)?;
|
||||
let dir = std::path::Path::new(path);
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("{path} is not a folder"));
|
||||
}
|
||||
let refused = |e: std::io::Error| format!("Data Recovery can't keep copies in {path}: {e}");
|
||||
std::fs::read_dir(dir).map_err(refused)?;
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |t| t.as_nanos());
|
||||
let check = dir.join(format!(".vectorcraft-write-check-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir(&check).map_err(refused)?;
|
||||
std::fs::remove_dir(&check).map_err(refused)
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
pub fn to_json(&self) -> Value {
|
||||
serde_json::to_value(self).unwrap_or(Value::Null)
|
||||
@@ -565,6 +612,25 @@ impl Prefs {
|
||||
refused
|
||||
}
|
||||
|
||||
/// The [`folder_check`] of each folder preference in `values`. An empty value (the default
|
||||
/// folder) and a folder the preference already holds are not checked. The web has no folders to
|
||||
/// check.
|
||||
fn check_folders(&self, values: &Map<String, Value>) -> std::result::Result<(), String> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Ok(());
|
||||
}
|
||||
let saved = self.to_json();
|
||||
for (key, v) in values {
|
||||
let (Some(check), Some(path)) = (folder_check(key), v.as_str()) else { continue };
|
||||
let path = path.trim();
|
||||
if path.is_empty() || saved[key.as_str()].as_str().is_some_and(|s| s.trim() == path) {
|
||||
continue;
|
||||
}
|
||||
check(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Values of one category (`None` = all) reset to defaults.
|
||||
pub fn reset(&mut self, category: Option<&str>) {
|
||||
let d = Prefs::default().to_json();
|
||||
@@ -666,7 +732,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Preference",
|
||||
[],
|
||||
None,
|
||||
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step; the eyedropper group takes a partial object, as eyedropper.setOptions)",
|
||||
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step; the eyedropper group takes a partial object, as eyedropper.setOptions; a new recoveryFolder must be a folder that can be listed and written in, and a new pluginsFolder a folder that can be listed: otherwise an error, and nothing changes; an empty value is not checked)",
|
||||
always,
|
||||
set
|
||||
),
|
||||
@@ -697,6 +763,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = s.prefs.keep_folders(&mut next).into_iter().next() {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
s.prefs.check_folders(&values).map_err(EngineError::Other)?;
|
||||
s.apply_prefs(next);
|
||||
// Units ▸ General is also the open document's units (as Document Setup sets them).
|
||||
if values.contains_key("unitsGeneral") && s.active().is_some() {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! pattern) into the document; `swatch.library.save` writes the document's ([`palette_io`]).
|
||||
//! `swatch.library.copyToUser` copies a loaded library into the user library folder.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -104,6 +104,19 @@ pub struct Libraries<L = SwatchLibrary> {
|
||||
/// Loaded libraries copied into the user library folder ([`Libraries::copy_to_user`]): the
|
||||
/// loaded id and the User Defined id [`Libraries::get`] finds for it.
|
||||
copied: Vec<(String, String)>,
|
||||
/// The name each User Defined file was saved under ([`Libraries::write`]), lower case id first,
|
||||
/// with a hash of the bytes written. A format that keeps no title (`.ase`) reads its name from
|
||||
/// the file name instead. [`Libraries::rescan`] drops an entry whose file no longer holds those
|
||||
/// bytes, and keeps one whose file is missing so a file put back can be checked.
|
||||
saved_as: HashMap<String, SavedAs>,
|
||||
}
|
||||
|
||||
/// A User Defined file this session wrote: the library's name, and [`vectorcraft_doc::links::hash_bytes`]
|
||||
/// of what was written, so a file replaced since then is no longer that library's.
|
||||
#[derive(Clone, Debug)]
|
||||
struct SavedAs {
|
||||
name: String,
|
||||
hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -152,20 +165,30 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
/// Set the user library folder and list its libraries.
|
||||
pub fn set_user_dir(&mut self, dir: Option<String>) {
|
||||
self.user_dir = dir;
|
||||
self.saved_as.clear();
|
||||
self.rescan();
|
||||
}
|
||||
|
||||
/// Re-read the library files of the user library folder (unreadable ones are skipped). The
|
||||
/// folder is the app's own: automation roots don't apply to it ([`crate::file_access`]). Each
|
||||
/// file is read inside [`crate::guard::catch_panic`], and a file whose reading panics is
|
||||
/// skipped too.
|
||||
/// skipped too. A file this session saved, whose bytes are no longer the ones written, is
|
||||
/// forgotten ([`Libraries::write`]); a missing file is left remembered until it is back.
|
||||
pub fn rescan(&mut self) {
|
||||
self.extra.retain(|e| e.info.category != "user");
|
||||
let Some(dir) = self.user_dir.clone() else { return };
|
||||
for path in library_files(&dir, L::EXTS) {
|
||||
let file = file_name(&path);
|
||||
let read = || crate::file_access::unconfined(|| read_file(&path)).ok().and_then(|b| L::read(&b, stem(&file)).ok());
|
||||
let Some(lib) = crate::guard::catch_panic(read).ok().flatten() else { continue };
|
||||
let read = || crate::file_access::unconfined(|| read_file(&path)).ok();
|
||||
let Some(bytes) = crate::guard::catch_panic(read).ok().flatten() else { continue };
|
||||
let id = format!("user/{file}").to_lowercase();
|
||||
// Compared before parsing, so a file that no longer reads as a library is forgotten too.
|
||||
let hash = vectorcraft_doc::links::hash_bytes(&bytes);
|
||||
if self.saved_as.get(&id).is_some_and(|saved| saved.hash != hash) {
|
||||
self.saved_as.remove(&id);
|
||||
}
|
||||
let parsed = || L::read(&bytes, stem(&file)).ok();
|
||||
let Some(lib) = crate::guard::catch_panic(parsed).ok().flatten() else { continue };
|
||||
let info = LibraryInfo { id: format!("user/{file}"), name: library_tab_name(&file, lib.name()), category: "user" };
|
||||
self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) });
|
||||
}
|
||||
@@ -214,17 +237,36 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
let mut file = library_file_name(name, ext);
|
||||
// `COM¹` is saved as `COM¹-`, which can be another library's own name, and a library put
|
||||
// under a numbered name holds a file another name can ask for: such a file is kept.
|
||||
// `saved_as` is that memory for this run, and only while the file still holds what was
|
||||
// written. A file that could not be read has no name to trust: one a device name could
|
||||
// make is kept too. Any other unreadable file is replaced, as it was before.
|
||||
self.rescan();
|
||||
let device = crate::file_access::is_device_name;
|
||||
let id = format!("user/{file}").to_lowercase();
|
||||
let fallback = |other: &str| library_file_name(other, ext).to_lowercase() != file.to_lowercase();
|
||||
if self
|
||||
.extra
|
||||
.iter()
|
||||
.any(|e| e.info.id.to_lowercase() == id && e.lib.name() != name && (device(name) || device(e.lib.name()) || fallback(e.lib.name())))
|
||||
{
|
||||
// A format with no title reads its name from the file name, so a file a device name could
|
||||
// have made has no known owner until it is saved here.
|
||||
let untitled = L::read(bytes, "\0").is_ok_and(|lib| lib.name() == "\0");
|
||||
let owner = |e: &Extra<L>| match self.saved_as.get(&e.info.id.to_lowercase()) {
|
||||
Some(saved) => Some(saved.name.clone()),
|
||||
None if untitled && device_file_name(e.lib.name()) => None,
|
||||
None => Some(e.lib.name().to_string()),
|
||||
};
|
||||
let file_key = file.to_lowercase();
|
||||
let on_disk = library_files(&dir, L::EXTS).iter().any(|p| file_name(p).to_lowercase() == file_key);
|
||||
let listed = self.extra.iter().any(|e| e.info.category == "user" && e.info.id.to_lowercase() == id);
|
||||
let unread = on_disk && !listed && (device(name) || device_file_name(stem(&file)));
|
||||
let held = unread
|
||||
|| self.extra.iter().any(|e| {
|
||||
e.info.id.to_lowercase() == id
|
||||
&& owner(e).is_none_or(|other| other != name && (device(name) || device(&other) || fallback(&other)))
|
||||
});
|
||||
if held {
|
||||
// Saved again, it goes back to the file it was given the first time.
|
||||
let own = self.extra.iter().find(|e| e.info.category == "user" && e.lib.name() == name && extension(&e.info.id) == extension(&file));
|
||||
let own = self
|
||||
.extra
|
||||
.iter()
|
||||
.find(|e| e.info.category == "user" && owner(e).as_deref() == Some(name) && extension(&e.info.id) == extension(&file));
|
||||
file = match own.and_then(|e| e.info.id.strip_prefix("user/")) {
|
||||
Some(own) => own.to_string(),
|
||||
None => {
|
||||
@@ -236,6 +278,8 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
|
||||
// The app's own folder, under a name made safe: no root applies.
|
||||
crate::file_access::unconfined(|| create_dir(&dir).and_then(|()| write_file(&path, bytes)))?;
|
||||
self.saved_as
|
||||
.insert(format!("user/{file}").to_lowercase(), SavedAs { name: name.to_string(), hash: vectorcraft_doc::links::hash_bytes(bytes) });
|
||||
self.rescan();
|
||||
out["path"] = json!(path);
|
||||
out["library"] = json!(format!("user/{file}"));
|
||||
@@ -373,6 +417,13 @@ pub fn library_file_name(name: &str, ext: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `stem` a file name [`library_file_name`] gives a device name (`COM¹-`, `COM¹-.x`), or one of
|
||||
/// those with the number [`free_name`] adds (`COM¹- 2`)?
|
||||
fn device_file_name(stem: &str) -> bool {
|
||||
let base = stem.rsplit_once(' ').filter(|(_, n)| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).map_or(stem, |(base, _)| base);
|
||||
base.split('.').next().and_then(|first| first.strip_suffix('-')).is_some_and(crate::file_access::is_device_name)
|
||||
}
|
||||
|
||||
/// The library files with extensions `exts` in folder `dir`, sorted by name.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn library_files(dir: &str, exts: &[&str]) -> Vec<String> {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, Document, Justify, LockedArtboards, Node, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::typecmd::refresh_bounds;
|
||||
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Insert Character",
|
||||
["Type"],
|
||||
None,
|
||||
"{char: \"bullet\"|\"copyright\"|\"ellipsis\"|\"paragraph\"|\"registered\"|\"section\"|\"trademark\"|\"emDash\"|\"enDash\"|\"discretionaryHyphen\"|\"nonBreakingHyphen\"|\"doubleLeftQuote\"|\"doubleRightQuote\"|\"singleLeftQuote\"|\"singleRightQuote\"|\"emSpace\"|\"enSpace\"|\"hairSpace\"|\"sixthSpace\"|\"thinSpace\"|\"nonBreakingSpace\"|\"figureSpace\"|\"punctuationSpace\"|\"thirdSpace\"|\"quarterSpace\"|\"tab\"|\"forcedLineBreak\"|\"paragraphReturn\" | text: string, ids?|id?: object ids (default: the selection)} append to the selected text objects",
|
||||
"{char: \"bullet\"|\"copyright\"|\"ellipsis\"|\"paragraph\"|\"registered\"|\"section\"|\"trademark\"|\"emDash\"|\"enDash\"|\"discretionaryHyphen\"|\"nonBreakingHyphen\"|\"doubleLeftQuote\"|\"doubleRightQuote\"|\"singleLeftQuote\"|\"singleRightQuote\"|\"emSpace\"|\"enSpace\"|\"hairSpace\"|\"sixthSpace\"|\"thinSpace\"|\"nonBreakingSpace\"|\"figureSpace\"|\"punctuationSpace\"|\"thirdSpace\"|\"quarterSpace\"|\"tab\"|\"forcedLineBreak\"|\"paragraphReturn\" | text: string | code: Windows Alt+0 code (0–255 in Windows-1252, a Unicode code point above: 176 → °, 151 → —) | unicode: hex code point (\"00B0\" or \"U+00B0\"), ids?|id?: object ids (default: the selection)} append to the selected text objects",
|
||||
has_selection,
|
||||
insert_char
|
||||
),
|
||||
@@ -580,12 +580,42 @@ pub(crate) fn special_char(name: &str) -> Option<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The character a Windows Alt + keypad code with a leading 0 types (`Alt+0176` → `°`), as
|
||||
/// `type.insert {code}` takes it: 128–159 in the Windows-1252 code page (`0151` → `—`), the
|
||||
/// Unicode code point otherwise (`8364` → `€`). Control characters type nothing.
|
||||
/// <https://www.unicode.org/Public/MAPPINGS/VENDORS/MICSFT/WINDOWS/CP1252.TXT>
|
||||
pub(crate) fn alt_code_char(code: u64) -> Option<char> {
|
||||
const CP1252: [u16; 32] = [
|
||||
0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C,
|
||||
0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178,
|
||||
];
|
||||
let cp = match code {
|
||||
128..=159 => u32::from(*CP1252.get(usize::try_from(code - 128).ok()?)?),
|
||||
_ => u32::try_from(code).ok()?,
|
||||
};
|
||||
char::from_u32(cp).filter(|c| !c.is_control() || *c == '\t')
|
||||
}
|
||||
|
||||
/// The character `type.insert {unicode}` names: its code point in hex, as `00B0`, `U+00B0` or
|
||||
/// `0x00B0`.
|
||||
fn unicode_char(u: &str) -> Option<char> {
|
||||
let u = u.trim();
|
||||
let hex = u.strip_prefix("U+").or_else(|| u.strip_prefix("u+")).or_else(|| u.strip_prefix("0x")).unwrap_or(u);
|
||||
char::from_u32(u32::from_str_radix(hex, 16).ok()?).filter(|c| !c.is_control() || *c == '\t')
|
||||
}
|
||||
|
||||
fn insert_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.insert";
|
||||
let text = match (str_param(p, "char"), str_param(p, "text")) {
|
||||
(Some(c), _) => special_char(c).ok_or_else(|| bad(C, format!("unknown character `{c}`")))?.to_string(),
|
||||
(None, Some(t)) => t.to_string(),
|
||||
_ => return Err(bad(C, "give `char` or `text`")),
|
||||
_ => match (p.get("code"), str_param(p, "unicode")) {
|
||||
(Some(code), _) => alt_code_char(code.as_u64().ok_or_else(|| bad(C, "`code` must be a whole number"))?)
|
||||
.ok_or_else(|| bad(C, format!("no character has code {code}")))?
|
||||
.to_string(),
|
||||
(None, Some(u)) => unicode_char(u).ok_or_else(|| bad(C, format!("`{u}` is no Unicode character")))?.to_string(),
|
||||
_ => return Err(bad(C, "give `char`, `text`, `code` or `unicode`")),
|
||||
},
|
||||
};
|
||||
let ids = texts(s, p, C)?;
|
||||
// When `text` is empty and every object ends with a run of text, the document and its undo
|
||||
@@ -625,6 +655,22 @@ fn is_word(c: char) -> bool {
|
||||
|
||||
/// Byte ranges of `needle` in `hay`.
|
||||
pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bool) -> Vec<(usize, usize)> {
|
||||
find_all_between(hay, needle, match_case, whole_word, None, None)
|
||||
}
|
||||
|
||||
/// [`find_all`] in run `i` of `runs`: a whole word looks past the run's ends at the characters
|
||||
/// next to it, so a change of style inside a word is not a word boundary (#5901). Matches stay
|
||||
/// inside the run.
|
||||
fn find_in_run(runs: &[TextRun], i: usize, needle: &str, match_case: bool, whole_word: bool) -> Vec<(usize, usize)> {
|
||||
let Some(run) = runs.get(i) else { return vec![] };
|
||||
let before = runs.get(..i).and_then(|r| r.iter().rev().find_map(|r| r.text.chars().next_back()));
|
||||
let after = runs.get(i + 1..).and_then(|r| r.iter().find_map(|r| r.text.chars().next()));
|
||||
find_all_between(&run.text, needle, match_case, whole_word, before, after)
|
||||
}
|
||||
|
||||
/// [`find_all`], with `before` and `after` the characters on either side of `hay` (none at the
|
||||
/// text's ends), which a whole word must not touch either.
|
||||
fn find_all_between(hay: &str, needle: &str, match_case: bool, whole_word: bool, before: Option<char>, after: Option<char>) -> Vec<(usize, usize)> {
|
||||
let nc: Vec<char> = needle.chars().collect();
|
||||
if nc.is_empty() {
|
||||
return vec![];
|
||||
@@ -635,9 +681,9 @@ pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bo
|
||||
let mut i = 0;
|
||||
while i + nc.len() <= hc.len() {
|
||||
if (0..nc.len()).all(|k| eq(hc[i + k].1, nc[k])) {
|
||||
let before = i.checked_sub(1).map(|j| hc[j].1);
|
||||
let after = hc.get(i + nc.len()).map(|x| x.1);
|
||||
if !whole_word || (!before.is_some_and(is_word) && !after.is_some_and(is_word)) {
|
||||
let prev = i.checked_sub(1).map_or(before, |j| hc.get(j).map(|x| x.1));
|
||||
let next = hc.get(i + nc.len()).map_or(after, |x| Some(x.1));
|
||||
if !whole_word || (!prev.is_some_and(is_word) && !next.is_some_and(is_word)) {
|
||||
let start = hc[i].0;
|
||||
let end = hc.get(i + nc.len()).map(|x| x.0).unwrap_or(hay.len());
|
||||
out.push((start, end));
|
||||
@@ -650,15 +696,24 @@ pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bo
|
||||
out
|
||||
}
|
||||
|
||||
/// The text objects Find and Replace and Find Next reach: on visible, unlocked layers, and not
|
||||
/// inside a hidden or locked group, which hides or locks what it holds as a layer does (#5900).
|
||||
fn all_texts(d: &Document) -> Vec<NodeId> {
|
||||
fn visit(n: &Node, locks: &LockedArtboards<'_>, v: &mut Vec<NodeId>) {
|
||||
if !n.visible || n.locked {
|
||||
return;
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Text(_)) && !locks.holds_id(n.id) {
|
||||
v.push(n.id);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, locks, v);
|
||||
}
|
||||
}
|
||||
let mut v = vec![];
|
||||
let locks = d.locked_artboards();
|
||||
for l in d.layers.iter().filter(|l| l.visible && !l.locked) {
|
||||
l.walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Text(_)) && n.visible && !n.locked && !locks.holds_id(n.id) {
|
||||
v.push(n.id);
|
||||
}
|
||||
});
|
||||
for l in &d.layers {
|
||||
visit(l, &locks, &mut v);
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -676,7 +731,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let total: usize = ids
|
||||
.iter()
|
||||
.filter_map(|id| match &st.doc.node(*id)?.kind {
|
||||
NodeKind::Text(t) => Some(t.runs.iter().map(|r| find_all(&r.text, &find, mc, ww).len()).sum::<usize>()),
|
||||
NodeKind::Text(t) => Some((0..t.runs.len()).map(|i| find_in_run(&t.runs, i, &find, mc, ww).len()).sum::<usize>()),
|
||||
_ => None,
|
||||
})
|
||||
.sum();
|
||||
@@ -693,7 +748,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut base = 0;
|
||||
for (ri, r) in t.runs.iter().enumerate() {
|
||||
if r.inline.is_none() {
|
||||
for (a, b) in find_all(&r.text, &find, mc, ww) {
|
||||
for (a, b) in find_in_run(&t.runs, ri, &find, mc, ww) {
|
||||
if r.text.get(a..b) != Some(rep.as_str()) {
|
||||
hits.push((ri, base + a, base + b, a, b));
|
||||
}
|
||||
|
||||
@@ -24,9 +24,10 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo
|
||||
|
||||
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
|
||||
/// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the
|
||||
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the view's center
|
||||
/// the app passes to Edit Symbol); an action leaves them out ([`Session::journal_for_action`]).
|
||||
const REPLAY_ONLY: [(&str, &str); 16] = [
|
||||
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the
|
||||
/// Artboard tool had selected for its commands; the view's center the app passes to Edit Symbol);
|
||||
/// an action leaves them out ([`Session::journal_for_action`]).
|
||||
const REPLAY_ONLY: [(&str, &str); 21] = [
|
||||
("file.new", "created"),
|
||||
("document.save", "modified"),
|
||||
("file.saveAs", "modified"),
|
||||
@@ -39,6 +40,11 @@ const REPLAY_ONLY: [(&str, &str); 16] = [
|
||||
("artboard.fitToArt", "index"),
|
||||
("artboard.fitToArt", "indices"),
|
||||
("artboard.fitToSelection", "index"),
|
||||
("artboard.duplicate", "selectedArtboards"),
|
||||
("artboard.copy", "selectedArtboards"),
|
||||
("artboard.cut", "selectedArtboards"),
|
||||
("artboard.rename", "selectedArtboards"),
|
||||
("artboard.switchOrientation", "selectedArtboards"),
|
||||
("select.allOnArtboard", "artboard"),
|
||||
("symbol.edit", "artboard"),
|
||||
("symbol.edit", "viewCenter"),
|
||||
|
||||
@@ -76,6 +76,11 @@ pub struct DocState {
|
||||
/// The view saved into native files (`Document::last_view`); the UI keeps it current before a
|
||||
/// save and restores it when the document opens.
|
||||
pub view: Option<vectorcraft_doc::SavedView>,
|
||||
/// The id of the view's active artboard, which the UI keeps current before each command it
|
||||
/// runs: a `.ai` save or export names that artboard as the file's active one. `None`, or an
|
||||
/// artboard the document no longer has: the artboard the document opened on
|
||||
/// ([`vectorcraft_doc::Document::opened_artboard`]), else the first.
|
||||
pub active_artboard: Option<u32>,
|
||||
/// Restored by Data Recovery: the title says "[Recovered]" and Save asks where to save it
|
||||
/// (suggesting `path`, the file it was copied from) instead of overwriting that file.
|
||||
pub recovered: bool,
|
||||
@@ -266,6 +271,7 @@ impl DocState {
|
||||
save_artboards: vec![],
|
||||
converted: false,
|
||||
view,
|
||||
active_artboard: None,
|
||||
recovered: false,
|
||||
recovery: None,
|
||||
print_tiling: false,
|
||||
|
||||
@@ -33,7 +33,7 @@ pub type LinkScan = Arc<(Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>
|
||||
|
||||
/// Held by a look's worker: marks the look as ended and wakes [`Session::wait_link_scan`] when
|
||||
/// dropped, whether the worker returned or unwound.
|
||||
struct Ended(LinkScan);
|
||||
pub(crate) struct Ended(pub(crate) LinkScan);
|
||||
|
||||
impl Drop for Ended {
|
||||
fn drop(&mut self) {
|
||||
@@ -44,6 +44,14 @@ impl Drop for Ended {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a look's worker ended without storing stamps, with `read_ended` reading the flag. The
|
||||
/// worker stores its stamps and marks the look as ended under the slot's lock, and this holds that
|
||||
/// lock across both reads, so a look whose worker is storing its stamps never counts as ended without them.
|
||||
pub(crate) fn ended_without_stamps(scan: &LinkScan, read_ended: impl FnOnce(&AtomicBool) -> bool) -> bool {
|
||||
let stamps = scan.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
stamps.is_none() && read_ended(&scan.2)
|
||||
}
|
||||
|
||||
/// The stamp of the linked file stored as `path` in the document saved at `doc_path`; `None` when
|
||||
/// there is no file, its path isn't followed ([`crate::cmd::fileio::document_path`]) or
|
||||
/// automation may not read it.
|
||||
@@ -108,8 +116,10 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Apply a finished look (`None` while there is none, or it is still running): see
|
||||
/// [`Session::apply_link_stamps`].
|
||||
/// [`Session::apply_link_stamps`]. A look whose worker ended without stamps is cleared, also
|
||||
/// with `None`, so the next one can start.
|
||||
pub fn poll_link_scan(&mut self) -> Option<Result<Value>> {
|
||||
self.link_scan.take_if(|scan| ended_without_stamps(scan, |ended| ended.load(Ordering::SeqCst)));
|
||||
let (uid, stamps) = self.link_scan.as_ref()?.0.lock().unwrap_or_else(PoisonError::into_inner).take()?;
|
||||
self.link_scan = None;
|
||||
Some(self.apply_link_stamps(uid, stamps, false))
|
||||
@@ -117,7 +127,8 @@ impl Session {
|
||||
|
||||
/// Block until the look in flight has ended (at once when none is running), so that the next
|
||||
/// [`Session::poll_link_scan`] applies its stamps. A look whose worker stopped without stamps
|
||||
/// ends too, and the poll then returns `None`. For tests that drive the look step by step.
|
||||
/// ends too, and the poll then clears it and returns `None`. For tests that drive the look step
|
||||
/// by step.
|
||||
pub fn wait_link_scan(&self) {
|
||||
let Some(scan) = &self.link_scan else { return };
|
||||
let (done, finished, ended) = &**scan;
|
||||
|
||||
@@ -147,6 +147,7 @@ mod scalestrokes;
|
||||
mod scripts;
|
||||
mod select_ids_first;
|
||||
mod shaper;
|
||||
mod shapetools;
|
||||
mod shortcutset;
|
||||
mod simplify_part;
|
||||
mod slices;
|
||||
|
||||
@@ -333,6 +333,41 @@ fn import_text_then_paste_gives_point_text() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spreadsheet_cells_paste_as_a_table_of_type_over_gridlines() {
|
||||
let mut s = session();
|
||||
// As Excel puts three rows of two columns on the clipboard, an empty cell included.
|
||||
let r = run(&mut s, "clipboard.importText", json!({"text": "Fruit\tQty\r\nApples\t3\r\nPears\t\r\n"}));
|
||||
assert_eq!((r["count"].clone(), r["rows"].clone(), r["columns"].clone()), (json!(1), json!(3), json!(2)));
|
||||
run(&mut s, "edit.paste", json!({"center": [200, 150]}));
|
||||
let first = pasted(&s);
|
||||
assert_eq!(first.len(), 1);
|
||||
let table = &first[0];
|
||||
assert_eq!(table.name.as_deref(), Some("Table"));
|
||||
let kids = table.children().unwrap();
|
||||
let grid = &kids[0];
|
||||
assert_eq!(grid.name.as_deref(), Some("Gridlines"));
|
||||
assert_eq!(grid.children().unwrap().len(), 4 + 3, "four row lines, three column lines");
|
||||
// One type object a cell that holds text, each inside its own cell.
|
||||
let cells: Vec<_> = kids[1..].iter().map(|n| (text_of(n).plain_text(), n.geometric_bounds().unwrap())).collect();
|
||||
let texts: Vec<&str> = cells.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(texts, ["Fruit", "Qty", "Apples", "3", "Pears"]);
|
||||
let at = |t: &str| cells.iter().find(|c| c.0 == t).unwrap().1;
|
||||
assert!(at("Qty").x0 > at("Apples").x1, "the second column starts past the widest of the first");
|
||||
assert!((at("Qty").x0 - at("3").x0).abs() < 1e-6 && (at("Fruit").x0 - at("Pears").x0).abs() < 1e-6, "columns line up");
|
||||
assert!(at("Apples").y0 > at("Fruit").y1 && at("Pears").y0 > at("Apples").y1, "rows go down: {cells:?}");
|
||||
let b = table.geometric_bounds().unwrap();
|
||||
assert!((b.center().x - 200.0).abs() < 1.0 && (b.center().y - 150.0).abs() < 1.0, "{b:?}");
|
||||
// table: false, and text that isn't a spreadsheet's, paste as one point type object.
|
||||
for (text, table) in [("a\tb\nc\td", json!(false)), ("\tindented\n\tcode", Value::Null), ("x\ty\nz", Value::Null)] {
|
||||
run(&mut s, "clipboard.importText", json!({"text": text, "table": table}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
let p = pasted(&s);
|
||||
assert_eq!((p.len(), text_of(&p[0]).plain_text()), (1, text.to_string()), "{text:?}");
|
||||
}
|
||||
assert!(s.execute("clipboard.importText", &json!({"text": "a\tb", "table": "yes"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_only_copies_offer_their_text() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -113,6 +113,9 @@ fn constrain_angle_turns_the_base_orientation_of_polygons_and_stars() {
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let draw = |s: &mut Session, tool: &str, deg: f64| {
|
||||
set_angle(s, deg);
|
||||
// Nothing selected: the press draws, rather than moving the shape drawn before it by the
|
||||
// centre point it shows (#5477).
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
gesture(s, shift, &[(Down, 100.0, 100.0), (Drag, 130.0, 140.0), (Drag, 130.0, 140.0), (Up, 130.0, 140.0)]);
|
||||
last_id(s)
|
||||
|
||||
@@ -221,6 +221,131 @@ fn envelope_options_round_trip_natively_and_old_files_load() {
|
||||
assert!(serde_json::to_value(&legacy).unwrap()["kind"].get("options").is_none());
|
||||
}
|
||||
|
||||
/// Envelope Options change the options they name: the margins an envelope opens with from a file
|
||||
/// stay, and they survive a native save.
|
||||
#[test]
|
||||
fn envelope_options_keep_the_margins_of_an_opened_file() {
|
||||
let mut s = session();
|
||||
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
|
||||
assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]);
|
||||
let margin = [0.01, 0.02, 0.01, 0.02];
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
if let Some(NodeKind::Envelope { options, .. }) = doc.node_mut(e).map(|n| &mut n.kind) {
|
||||
options.bounds_margin = margin;
|
||||
}
|
||||
s.execute("object.envelope.options", &json!({"antiAlias": false, "fidelity": 75})).unwrap();
|
||||
assert_eq!(options(&s, e).0.bounds_margin, margin);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
|
||||
assert_eq!(back.node(e), d.node(e));
|
||||
assert_eq!(s.execute("object.envelope.info", &json!({})).unwrap()["boundsMargin"], json!(margin));
|
||||
s.execute("object.envelope.options", &json!({"distortAppearance": false})).unwrap();
|
||||
assert_eq!(options(&s, e).0.bounds_margin, margin);
|
||||
}
|
||||
|
||||
/// `object.envelope.info` leaves out margins that are zero.
|
||||
#[test]
|
||||
fn envelope_info_reports_margins_only_when_it_has_them() {
|
||||
let mut s = session();
|
||||
enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
|
||||
assert!(s.execute("object.envelope.info", &json!({})).unwrap().get("boundsMargin").is_none());
|
||||
}
|
||||
|
||||
/// Leaving Edit Contents measures the margins again when a stroke changed: a wider stroke gives
|
||||
/// larger ones and no stroke gives none, after which a stroke adds none, as in a new envelope. A
|
||||
/// new envelope's margins stay zero.
|
||||
#[test]
|
||||
fn leaving_edit_contents_measures_the_margins_again() {
|
||||
let mut s = session();
|
||||
let (a, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
|
||||
let set_stroke = |s: &mut Session, weight: f64| {
|
||||
sel(s, &[e]);
|
||||
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
|
||||
sel(s, &[a]);
|
||||
s.execute("stroke.set", &json!({"weight": weight})).unwrap();
|
||||
sel(s, &[e]);
|
||||
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
|
||||
};
|
||||
set_stroke(&mut s, 10.0);
|
||||
assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]);
|
||||
|
||||
let margin = [0.05, 0.05, 0.025, 0.025];
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
if let Some(NodeKind::Envelope { options, .. }) = doc.node_mut(e).map(|n| &mut n.kind) {
|
||||
options.bounds_margin = [0.5; 4];
|
||||
}
|
||||
set_stroke(&mut s, 20.0);
|
||||
assert_eq!(options(&s, e).0.bounds_margin, margin.map(|m| m * 2.0));
|
||||
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
|
||||
assert_eq!(options(&s, e).0.bounds_margin, margin.map(|m| m * 2.0));
|
||||
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
|
||||
assert_eq!(options(&s, e).0.bounds_margin, margin.map(|m| m * 2.0));
|
||||
set_stroke(&mut s, 10.0);
|
||||
assert_eq!(options(&s, e).0.bounds_margin, margin);
|
||||
// A stroke change, then a second entry, undone back to the first: leaving measures the change.
|
||||
sel(&mut s, &[e]);
|
||||
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("stroke.set", &json!({"weight": 30.0})).unwrap();
|
||||
sel(&mut s, &[e]);
|
||||
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
|
||||
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
sel(&mut s, &[e]);
|
||||
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
|
||||
assert_eq!(options(&s, e).0.bounds_margin, margin.map(|m| m * 3.0));
|
||||
set_stroke(&mut s, 0.0);
|
||||
assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]);
|
||||
set_stroke(&mut s, 10.0);
|
||||
assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]);
|
||||
}
|
||||
|
||||
/// After an uneven scale, Edit Contents without a stroke change leaves the margins and the box the
|
||||
/// content maps from, and a stroke change measures them.
|
||||
#[test]
|
||||
fn edit_contents_keeps_the_margins_of_a_scaled_envelope_until_a_stroke_changes() {
|
||||
let mut s = session();
|
||||
let (a, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 10.0})).unwrap();
|
||||
sel(&mut s, &[e]);
|
||||
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
|
||||
let margin = [0.05, 0.05, 0.025, 0.025];
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
if let Some(NodeKind::Envelope { options, .. }) = doc.node_mut(e).map(|n| &mut n.kind) {
|
||||
options.bounds_margin = margin;
|
||||
}
|
||||
s.execute("object.transform", &json!({"ids": [e.0], "matrix": [2, 0, 0, 0.5, 0, 0]})).unwrap();
|
||||
let src = |s: &Session| live::EnvelopeMap::of(s.doc().unwrap().doc.node(e).unwrap()).unwrap().src();
|
||||
let box_before = src(&s);
|
||||
sel(&mut s, &[e]);
|
||||
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
|
||||
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
|
||||
assert_eq!(options(&s, e).0.bounds_margin, margin);
|
||||
assert_eq!(src(&s), box_before);
|
||||
// Undoing that step and leaving again gives the same.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
sel(&mut s, &[e]);
|
||||
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
|
||||
assert_eq!(options(&s, e).0.bounds_margin, margin);
|
||||
// So does saving natively in Edit Contents, reopening and leaving.
|
||||
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(back.node(e), s.doc().unwrap().doc.node(e));
|
||||
assert!(back.node(e).unwrap().extras().envelope_edit_margin.is_some());
|
||||
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
|
||||
assert!(node(&s, e).extras().envelope_edit_margin.is_none());
|
||||
|
||||
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("stroke.set", &json!({"weight": 0.0})).unwrap();
|
||||
sel(&mut s, &[e]);
|
||||
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
|
||||
assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]);
|
||||
}
|
||||
|
||||
/// Regression: every exporter wrote type inside an envelope undistorted (no text outliner), so a
|
||||
/// warped line of type exported the same whatever its bend.
|
||||
#[test]
|
||||
|
||||
@@ -247,6 +247,149 @@ fn a_batch_step_records_what_it_took_from_the_preferences() {
|
||||
assert_eq!(doc(&r), doc(&s));
|
||||
}
|
||||
|
||||
/// A document of three artboards with a rectangle on the second, the Artboard tool chosen and that
|
||||
/// artboard selected → the journal's length, where the entries under test start.
|
||||
fn second_artboard_selected(s: &mut Session) -> usize {
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 100})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 100, "height": 300})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 1010, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 2})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("selected", &json!([1]));
|
||||
s.journal.len()
|
||||
}
|
||||
|
||||
/// A new session's Artboard tool has nothing selected, so a replay of a command that took its
|
||||
/// artboards from the selection acts on the artboards it recorded, and an action does not keep them.
|
||||
#[test]
|
||||
fn artboard_commands_record_the_selected_artboards_for_a_replay_and_not_for_an_action() {
|
||||
for (id, params, recorded, action) in [
|
||||
("artboard.switchOrientation", json!({}), json!({"selectedArtboards": [1], "scaleArt": false}), json!({"scaleArt": false})),
|
||||
("artboard.rename", json!({"name": "Back"}), json!({"name": "Back", "selectedArtboards": [1]}), json!({"name": "Back"})),
|
||||
("artboard.duplicate", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})),
|
||||
("artboard.cut", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})),
|
||||
("artboard.copy", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
let start = second_artboard_selected(&mut s);
|
||||
s.execute(id, ¶ms).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), recorded), "{id}");
|
||||
assert_eq!(s.journal_for_action(start), [(id.to_string(), action)], "{id}");
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of the artboard the tool had selected replays as a copy of that artboard: what it pastes
|
||||
/// is the same.
|
||||
#[test]
|
||||
fn a_replayed_artboard_copy_pastes_the_selected_artboard() {
|
||||
let mut s = Session::new();
|
||||
second_artboard_selected(&mut s);
|
||||
s.execute("artboard.copy", &json!({})).unwrap();
|
||||
s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 4);
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s));
|
||||
}
|
||||
|
||||
/// The artboards a call names are recorded as given and stay in an action, and a batch step records
|
||||
/// the selected ones in its own params.
|
||||
#[test]
|
||||
fn artboard_commands_keep_a_named_artboard_and_note_a_batch_steps_artboards() {
|
||||
let mut s = Session::new();
|
||||
let start = second_artboard_selected(&mut s);
|
||||
s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1, json!({"index": 0, "scaleArt": false}));
|
||||
s.execute("artboard.duplicate", &json!({"indices": [0]})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1, json!({"indices": [0], "art": true}));
|
||||
let steps = json!([{"command": "artboard.switchOrientation", "params": {}}]);
|
||||
s.execute("command.batch", &json!({ "commands": steps })).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1["commands"][0]["params"], json!({"selectedArtboards": [1], "scaleArt": false}));
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s));
|
||||
let action = s.journal_for_action(start);
|
||||
assert_eq!(action[0].1, json!({"index": 0, "scaleArt": false}));
|
||||
assert_eq!(action[1].1, json!({"indices": [0], "art": true}));
|
||||
assert_eq!(action[2].1["commands"][0]["params"], json!({"scaleArt": false}));
|
||||
}
|
||||
|
||||
/// A recorded `selectedArtboards` goes ahead of the tool's selection, gives way to a named artboard,
|
||||
/// and is checked as `indices` is.
|
||||
#[test]
|
||||
fn artboard_commands_read_the_recorded_selected_artboards() {
|
||||
let mut s = Session::new();
|
||||
second_artboard_selected(&mut s);
|
||||
let run = |s: &mut Session, p| s.execute("artboard.switchOrientation", &p).map(|r| r["switched"].clone());
|
||||
assert_eq!(run(&mut s, json!({"selectedArtboards": [2]})).unwrap(), json!([2]));
|
||||
assert_eq!(run(&mut s, json!({"selectedArtboards": [2], "index": 0})).unwrap(), json!([0]));
|
||||
for bad in [json!([9]), json!([]), json!("1"), json!(null)] {
|
||||
assert!(run(&mut s, json!({ "selectedArtboards": bad })).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `edit.copy` and `edit.cut` run the Artboard tool's copy and take no artboards, so their entries
|
||||
/// stay as given.
|
||||
#[test]
|
||||
fn edit_copy_and_cut_with_the_artboard_tool_record_their_params_as_given() {
|
||||
for id in ["edit.copy", "edit.cut"] {
|
||||
let mut s = Session::new();
|
||||
second_artboard_selected(&mut s);
|
||||
s.execute(id, &json!({})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({})), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `artboard.duplicate` and the cut and copy record the tool's Move/Copy Artwork option: replayed in
|
||||
/// a new session, where the option is on, the art stays behind as it did.
|
||||
#[test]
|
||||
fn artboard_commands_record_the_tools_move_art_option() {
|
||||
for id in ["artboard.duplicate", "artboard.cut", "artboard.copy"] {
|
||||
let mut s = Session::new();
|
||||
second_artboard_selected(&mut s);
|
||||
s.set_tool_option("moveArt", &json!(false));
|
||||
s.execute(id, &json!({})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1["art"], json!(false), "{id}");
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `artboard.switchOrientation` records the tool's Scale Artwork option: replayed in a new session,
|
||||
/// where it is off, the art scales as it did.
|
||||
#[test]
|
||||
fn artboard_switch_orientation_records_the_tools_scale_art_option() {
|
||||
let mut s = Session::new();
|
||||
second_artboard_selected(&mut s);
|
||||
s.set_tool_option("scaleArt", &json!(true));
|
||||
s.execute("artboard.switchOrientation", &json!({})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1["scaleArt"], json!(true));
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s));
|
||||
}
|
||||
|
||||
/// `artboard.switchOrientation` records whether locked and hidden art goes along: replayed in a new
|
||||
/// session, where the preference is off, the locked art scales as it did.
|
||||
#[test]
|
||||
fn artboard_switch_orientation_records_whether_locked_art_goes_along() {
|
||||
let mut s = Session::new();
|
||||
second_artboard_selected(&mut s);
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 1150, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"ids": [r["id"]], "locked": true})).unwrap();
|
||||
s.prefs.move_locked_with_artboard = true;
|
||||
s.set_tool_option("scaleArt", &json!(true));
|
||||
s.execute("artboard.switchOrientation", &json!({})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1["lockedAndHidden"], json!(true));
|
||||
let r = replayed(&s);
|
||||
assert!(!r.prefs.move_locked_with_artboard);
|
||||
assert_eq!(documents(&r), documents(&s));
|
||||
// The params' own values win over the preferences.
|
||||
let mut t = Session::new();
|
||||
second_artboard_selected(&mut t);
|
||||
t.prefs.move_locked_with_artboard = true;
|
||||
t.execute("artboard.switchOrientation", &json!({"scaleArt": true, "lockedAndHidden": false})).unwrap();
|
||||
assert_eq!(t.journal.last().unwrap().1["lockedAndHidden"], json!(false));
|
||||
assert!(t.execute("artboard.switchOrientation", &json!({"strokes": "yes"})).is_err());
|
||||
}
|
||||
|
||||
/// Every open document of `s`, as JSON.
|
||||
fn documents(s: &Session) -> Vec<serde_json::Value> {
|
||||
s.documents().iter().map(|d| serde_json::to_value(&*d.doc).unwrap()).collect()
|
||||
|
||||
@@ -368,6 +368,54 @@ fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() {
|
||||
assert_eq!(colour_at(page, 10.0, 20.0), [255, 0, 0], "the square outside the image");
|
||||
}
|
||||
|
||||
/// A `size`×`size` PNG of a black disk of radius `r` pixels with an anti-aliased edge, clear
|
||||
/// around it, 72 ppi.
|
||||
fn disk(size: u32, r: f64) -> Vec<u8> {
|
||||
let c = f64::from(size) / 2.0;
|
||||
let img = image::RgbaImage::from_fn(size, size, |x, y| {
|
||||
let d = (f64::from(x) + 0.5 - c).hypot(f64::from(y) + 0.5 - c);
|
||||
image::Rgba([0, 0, 0, ((r + 0.5 - d).clamp(0.0, 1.0) * 255.0).round() as u8])
|
||||
});
|
||||
let mut out = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// #5264: the clip around a flattened image follows its edge smoothly: a disk's clip is a ring of
|
||||
/// sloped segments close to the disk's edge, not a staircase of horizontal and vertical steps.
|
||||
#[test]
|
||||
fn a_flattened_images_clip_follows_its_edge_smoothly() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 180, "height": 80})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let p = json!({"name": "disk.png", "dataBase64": vectorcraft_format::base64_encode(&disk(64, 25.0)), "at": [100, 50]});
|
||||
s.execute("file.place", &p).unwrap();
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||||
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut clips = vec![];
|
||||
d.walk(|n| {
|
||||
if let (vectorcraft_doc::NodeKind::Path { clipping: true, .. }, Some(pd)) = (&n.kind, n.path_data()) {
|
||||
clips.push(pd.clone());
|
||||
}
|
||||
});
|
||||
assert_eq!(clips.len(), 1, "{clips:?}");
|
||||
let sub = &clips[0].subpaths;
|
||||
assert_eq!(sub.len(), 1);
|
||||
let pts: Vec<_> = sub[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert!(pts.len() >= 16, "{pts:?}");
|
||||
// Its centre: the file's page may sit elsewhere than the artboard.
|
||||
let n = pts.len() as f64;
|
||||
let (cx, cy) = (pts.iter().map(|p| p.x).sum::<f64>() / n, pts.iter().map(|p| p.y).sum::<f64>() / n);
|
||||
for p in &pts {
|
||||
let r = (p.x - cx).hypot(p.y - cy);
|
||||
assert!((24.5..=27.5).contains(&r), "an anchor {r} pt from the centre: {pts:?}");
|
||||
}
|
||||
let sloped = pts.iter().zip(pts.iter().cycle().skip(1)).filter(|(a, b)| (a.x - b.x).abs() > 0.1 && (a.y - b.y).abs() > 0.1).count();
|
||||
assert!(sloped * 2 >= pts.len(), "{sloped} of {} segments are sloped: {pts:?}", pts.len());
|
||||
}
|
||||
|
||||
/// How many of reply `r`'s warnings say that an image couldn't be decoded, as raster exports do.
|
||||
fn undecoded_notes(r: &Value) -> usize {
|
||||
warnings(r).iter().filter(|w| *w == "an image couldn't be decoded and is missing from the picture").count()
|
||||
|
||||
@@ -230,6 +230,66 @@ fn a_gradient_goes_into_a_library_and_paints_another_object() {
|
||||
assert!(s.execute("library.use", &json!({"library": lib, "kind": "fillGradient", "item": "Nope"})).is_err());
|
||||
}
|
||||
|
||||
/// Compound paths, groups and colorized objects add the paint their Fill proxy shows: a color by
|
||||
/// its hex, a gradient with its stops.
|
||||
#[test]
|
||||
fn colors_and_gradients_come_from_compound_paths_groups_and_colorized_objects() {
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
let solid = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
|
||||
let graded = run(&mut s, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20}))["id"].clone();
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 0.5, "color": "#00ff00"}, {"offset": 1, "color": "#0000ff"}]);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#123456", "ids": [solid]}));
|
||||
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}, "ids": [graded]}));
|
||||
let sources = [NodeId(solid.as_u64().unwrap()), NodeId(graded.as_u64().unwrap())];
|
||||
// For each source: a compound path and a group painted as it is, and a gray rectangle
|
||||
// colorized with its fill.
|
||||
let mut made: Vec<(NodeId, bool)> = vec![];
|
||||
s.edit("Make", |d, _| {
|
||||
let l = d.layers[0].id;
|
||||
for (k, src) in sources.into_iter().enumerate() {
|
||||
let source = d.node(src).unwrap().clone();
|
||||
let fill = source.appearance.fill().unwrap().paint.clone();
|
||||
let gradient = matches!(fill, Paint::Gradient(_));
|
||||
let member = |d: &mut vectorcraft_doc::Document| {
|
||||
let mut m = source.clone();
|
||||
(m.id, m.appearance) = (d.alloc_id(), Default::default());
|
||||
Arc::new(m)
|
||||
};
|
||||
let (a, b) = (member(d), member(d));
|
||||
let mut compound = Node::new(d.alloc_id(), NodeKind::Compound { children: vec![a], rule: Default::default() });
|
||||
let mut group = Node::group(d.alloc_id(), vec![b]);
|
||||
compound.appearance = source.appearance.clone();
|
||||
group.appearance = source.appearance.clone();
|
||||
let mut colorized = source.clone();
|
||||
colorized.id = d.alloc_id();
|
||||
colorized.appearance.set_fill(Paint::solid(Color::rgb8(128, 128, 128)));
|
||||
colorized.extras_mut().colorize = Some(Box::new(fill));
|
||||
for (j, n) in [compound, group, colorized].into_iter().enumerate() {
|
||||
made.push((n.id, gradient));
|
||||
d.insert(Some(l), 2 + 3 * k + j, n).unwrap();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
for (id, gradient) in made {
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
if gradient {
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "fillGradient"}));
|
||||
let got = run(&mut s, "library.get", json!({"library": lib}));
|
||||
let last = &got["gradients"].as_array().unwrap().last().unwrap()["gradient"]["gradient"]["stops"];
|
||||
assert_eq!(last.as_array().map(Vec::len), Some(3), "{id:?}: {got}");
|
||||
} else {
|
||||
let added = run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}));
|
||||
assert_eq!(added["name"], "#123456", "{id:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient added from an object that was rotated keeps the direction it shows there.
|
||||
#[test]
|
||||
fn a_gradient_from_rotated_art_keeps_its_direction() {
|
||||
|
||||
@@ -502,6 +502,38 @@ fn a_file_read_for_opening_has_its_links_read_and_its_type_laid_out() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opening lays out each type object with the plans the objects before it left on the font's
|
||||
/// face: its bounds and baselines are those of a layout in a font database of its own, which holds
|
||||
/// no plans. The objects share a script and direction and differ in features and language. Only
|
||||
/// bundled Latin glyphs are set, which every font database holds.
|
||||
#[test]
|
||||
fn type_read_for_opening_has_the_layout_of_a_font_database_with_no_plans() {
|
||||
let dir = Folder::new("read-for-open-plans");
|
||||
let style = vectorcraft_doc::CharStyle { size: 100.0, ..Default::default() };
|
||||
let manual = vectorcraft_doc::CharStyle { kerning: Some(0.0), ..style.clone() };
|
||||
let turkish = vectorcraft_doc::CharStyle { language: Some("tr".into()), ..style.clone() };
|
||||
let mut doc = Document::new(800.0, 600.0);
|
||||
let layer = doc.layers[0].id;
|
||||
for (text, style) in [("AVAV", &style), ("AVAV", &manual), ("office", &turkish), ("To Wa", &style), ("To Wa", &manual)] {
|
||||
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(20.0, 120.0), text, style.clone());
|
||||
let id = doc.alloc_id();
|
||||
doc.insert(Some(layer), usize::MAX, vectorcraft_doc::Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
|
||||
}
|
||||
let doc_path = dir.file("type.vectorcraft");
|
||||
let bytes = vectorcraft_format::save_file(&doc);
|
||||
let file = cmd::fileio::read_for_open(&doc_path, &bytes, Some(doc_path.clone()), &Value::Null, false, false).unwrap();
|
||||
let mut seen = 0;
|
||||
file.document().walk(|node| {
|
||||
if let NodeKind::Text(t) = &node.kind {
|
||||
let fresh = vectorcraft_text::layout(&vectorcraft_text::FontDb::with_font_dirs(vec![]), t);
|
||||
assert_eq!(t.cached_bounds, Some(fresh.bounds), "{:?}", t.plain_text());
|
||||
assert_eq!(t.cached_baselines, fresh.baselines(), "{:?}", t.plain_text());
|
||||
seen += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(seen, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_from_older_files_load_and_keep_their_pixels() {
|
||||
let dir = Folder::new("legacy");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Linked files changing while their document is open (`link_watch`): `links.updateChanged`,
|
||||
//! and the background look following Preferences → File Handling → Update Links.
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tests_links::{BLUE, Folder, RED, centre_colour, place, png, session};
|
||||
@@ -84,6 +86,67 @@ fn a_file_still_being_written_is_tried_again() {
|
||||
assert_eq!(colour(&s), BLUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_look_whose_worker_ended_without_stamps_does_not_stop_later_looks() {
|
||||
let dir = Folder::new("watch-unwound");
|
||||
let path = dir.file("art.png");
|
||||
std::fs::write(&path, png(40, 40, RED)).unwrap();
|
||||
let mut s = session();
|
||||
s.prefs.update_links = "automatically".into();
|
||||
let id = place(&mut s, &path);
|
||||
look(&mut s);
|
||||
let scan = link_watch::LinkScan::default();
|
||||
s.link_scan = Some(scan.clone());
|
||||
// Still running: the look stays in flight.
|
||||
assert!(s.poll_link_scan().is_none());
|
||||
assert!(s.link_scan.is_some(), "a look still running stays");
|
||||
let worker = std::thread::spawn(move || {
|
||||
let _ended = link_watch::Ended(scan);
|
||||
panic!("the look's worker unwinds");
|
||||
});
|
||||
assert!(worker.join().is_err());
|
||||
assert!(s.poll_link_scan().is_none(), "there are no stamps to apply");
|
||||
assert!(s.link_scan.is_none(), "the look ended");
|
||||
save(&path, &png(40, 40, BLUE));
|
||||
assert_eq!(look(&mut s)["updated"], json!([id.0]));
|
||||
assert_eq!(colour(&s), BLUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_look_that_stores_its_stamps_between_the_polls_reads_keeps_them() {
|
||||
let dir = Folder::new("watch-stored");
|
||||
let path = dir.file("art.png");
|
||||
std::fs::write(&path, png(40, 40, RED)).unwrap();
|
||||
let mut s = session();
|
||||
s.prefs.update_links = "automatically".into();
|
||||
let id = place(&mut s, &path);
|
||||
look(&mut s);
|
||||
let uid = s.doc().unwrap().uid;
|
||||
let stamps: Vec<_> = link_watch::linked_files(&s.doc().unwrap().doc).into_iter().map(|(p, ids)| (p, ids, Some((1, 1)))).collect();
|
||||
let scan = link_watch::LinkScan::default();
|
||||
s.link_scan = Some(scan.clone());
|
||||
// The worker takes its turn after the slot is read and before the flag is, if the slot is free.
|
||||
let ended = link_watch::ended_without_stamps(&scan, |flag| {
|
||||
if let Ok(mut slot) = scan.0.try_lock() {
|
||||
*slot = Some((uid, stamps.clone()));
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
flag.load(Ordering::SeqCst)
|
||||
});
|
||||
assert!(!ended, "the worker waits for the poll, so the look has not ended without stamps");
|
||||
// The worker's turn comes once the poll has let go of the slot.
|
||||
let worker = std::thread::spawn({
|
||||
let scan = scan.clone();
|
||||
move || {
|
||||
let _ended = link_watch::Ended(scan.clone());
|
||||
*scan.0.lock().unwrap() = Some((uid, stamps));
|
||||
}
|
||||
});
|
||||
worker.join().unwrap();
|
||||
assert_eq!(s.poll_link_scan().expect("the stamps are applied").unwrap()["updated"], json!([id.0]));
|
||||
assert!(s.link_scan.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_during_a_drag_waits_for_the_drag_to_end() {
|
||||
let dir = Folder::new("watch-drag");
|
||||
|
||||
@@ -1989,6 +1989,36 @@ fn find_and_replace_options() {
|
||||
assert!(s.execute("edit.findReplace", &json!({"find": ""})).is_err());
|
||||
}
|
||||
|
||||
/// Whole Word looks past a change of style: `cat` coloured red inside `catfish`, or at the end of
|
||||
/// `bobcat`, is part of a word, not a word of its own (#5901). A real word next to a run's end
|
||||
/// still matches, and without Whole Word the part is replaced as before.
|
||||
#[test]
|
||||
fn find_whole_word_is_not_fooled_by_a_style_change_inside_a_word() {
|
||||
let styled = |s: &mut Session, t: &str, a: usize, b: usize| {
|
||||
let id = text(s, t);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": a, "end": b, "fill": "#ff0000"})).unwrap();
|
||||
let NodeKind::Text(tt) = &node(s, id).kind else { panic!() };
|
||||
assert!(tt.runs.len() >= 2, "{t}: two runs");
|
||||
id
|
||||
};
|
||||
let mut s = session();
|
||||
let fish = styled(&mut s, "catfish", 0, 3);
|
||||
let bob = styled(&mut s, "bobcat", 3, 6);
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "DOG", "wholeWord": true})).unwrap();
|
||||
assert_eq!((r["count"].as_u64(), plain(&s, fish), plain(&s, bob)), (Some(0), "catfish".to_string(), "bobcat".to_string()));
|
||||
// A word that ends where its run does, at a space or the text's end, is still a word.
|
||||
let mut s = session();
|
||||
let spaced = styled(&mut s, "cat fish", 0, 3);
|
||||
let tail = styled(&mut s, "a cat", 2, 5);
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "DOG", "wholeWord": true})).unwrap();
|
||||
assert_eq!((r["count"].as_u64(), plain(&s, spaced), plain(&s, tail)), (Some(2), "DOG fish".to_string(), "a DOG".to_string()));
|
||||
// Without Whole Word, the part inside the word is found as before.
|
||||
let mut s = session();
|
||||
let fish = styled(&mut s, "catfish", 0, 3);
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "DOG"})).unwrap();
|
||||
assert_eq!((r["count"].as_u64(), plain(&s, fish)), (Some(1), "DOGfish".to_string()));
|
||||
}
|
||||
|
||||
/// Find and Replace with no ids leaves the type on a locked artboard as it is.
|
||||
#[test]
|
||||
fn find_and_replace_leaves_type_on_a_locked_artboard() {
|
||||
@@ -2017,6 +2047,32 @@ fn find_and_replace_that_changes_nothing_records_no_undo_step() {
|
||||
assert_eq!((history(&s), plain(&s, a)), ((before.0 + 1, 0, true), "dog dog".to_string()));
|
||||
}
|
||||
|
||||
/// Find and Replace with no ids leaves the type inside a locked or hidden group as it is, however
|
||||
/// deep, as it does for type locked or hidden itself or by its layer (#5900). Find Next skips it
|
||||
/// too. Unlocked and shown again, the group's type is found and replaced.
|
||||
#[test]
|
||||
fn find_and_replace_leaves_type_in_a_locked_or_hidden_group() {
|
||||
for prop in ["locked", "hidden"] {
|
||||
let mut s = session();
|
||||
let free = text(&mut s, "Audit cat");
|
||||
let inner = text(&mut s, "Audit cat");
|
||||
sel(&mut s, &[inner]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
sel(&mut s, &[g]);
|
||||
let outer = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let props = |on: bool| if prop == "locked" { json!({"id": outer.0, "locked": on}) } else { json!({"id": outer.0, "visible": !on}) };
|
||||
s.execute("object.setProps", &props(true)).unwrap();
|
||||
assert!(node(&s, inner).visible && !node(&s, inner).locked, "{prop}: the type itself is shown and unlocked");
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "dog"})).unwrap();
|
||||
assert_eq!((r["count"].as_u64(), plain(&s, free), plain(&s, inner)), (Some(1), "Audit dog".to_string(), "Audit cat".to_string()), "{prop}");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("edit.findNext", &json!({"find": "cat"})).unwrap()["id"].is_null(), "{prop}: Find Next skips it");
|
||||
s.execute("object.setProps", &props(false)).unwrap();
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "dog"})).unwrap();
|
||||
assert_eq!((r["count"].as_u64(), plain(&s, inner)), (Some(1), "Audit dog".to_string()), "{prop}: once the group is {prop} no more");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_next_cycles_through_text_objects() {
|
||||
let mut s = session();
|
||||
@@ -3406,6 +3462,39 @@ fn placeholder_and_insert_characters() {
|
||||
assert!(s.execute("type.insert", &json!({"char": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// `type.insert` by code (#5108): `code` as a Windows Alt+0 code types (Windows-1252 for
|
||||
/// 128–159, the code point otherwise), `unicode` as a hex code point.
|
||||
#[test]
|
||||
fn insert_a_character_by_its_code() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "");
|
||||
sel(&mut s, &[t]);
|
||||
for p in [
|
||||
json!({"code": 176}),
|
||||
json!({"code": 177}),
|
||||
json!({"code": 151}),
|
||||
json!({"code": 215}),
|
||||
json!({"code": 8364}),
|
||||
json!({"unicode": "00B0"}),
|
||||
json!({"unicode": "U+2014"}),
|
||||
] {
|
||||
s.execute("type.insert", &p).unwrap();
|
||||
}
|
||||
assert_eq!(plain(&s, t), "°±—×€°—");
|
||||
for p in [
|
||||
json!({"code": 129}),
|
||||
json!({"code": 7}),
|
||||
json!({"code": -1}),
|
||||
json!({"code": 1_114_112}),
|
||||
json!({"unicode": "D800"}),
|
||||
json!({"unicode": "zz"}),
|
||||
json!({}),
|
||||
] {
|
||||
assert!(s.execute("type.insert", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(plain(&s, t), "°±—×€°—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_placeholder_fills_frame() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -71,12 +71,14 @@ fn arrowheads_are_part_of_the_outline() {
|
||||
assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape");
|
||||
}
|
||||
|
||||
/// Outline Stroke of a Lens-profile line covers 7/12 of the full-width rectangle.
|
||||
#[test]
|
||||
fn a_lens_profile_outlines_to_half_the_rectangle() {
|
||||
fn a_lens_profile_outlines_to_seven_twelfths_of_the_rectangle() {
|
||||
let mut s = session();
|
||||
line(&mut s, json!({"weight": 10, "profile": "lens"}));
|
||||
let n = outline(&mut s);
|
||||
assert!(near(area(&n), 0.5 * 10.0 * 100.0, 2.0), "{}", area(&n));
|
||||
// Each half of the lens swells on a cubic, flat at the middle (#5714).
|
||||
assert!(near(area(&n), 7.0 / 12.0 * 10.0 * 100.0, 2.0), "{}", area(&n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -241,6 +241,150 @@ fn artboard_reorder_past_u32_goes_last() {
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.last().map(|a| a.name.as_str()), Some("Second"));
|
||||
}
|
||||
|
||||
/// `artboard.rename`: one artboard takes the name trimmed, several take it numbered in artboard
|
||||
/// order in one undo step, and a locked artboard is renamed too.
|
||||
#[test]
|
||||
fn artboard_rename_numbers_several_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap();
|
||||
let names = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect::<Vec<_>>();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("artboard.rename", &json!({"index": 0, "name": " Cover "})).unwrap()["renamed"], json!([0]));
|
||||
assert_eq!(names(&s)[0], "Cover");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
// Listed out of order and twice: artboard order, once each.
|
||||
assert_eq!(s.execute("artboard.rename", &json!({"indices": [2, 1, 2], "name": "Page"})).unwrap()["renamed"], json!([1, 2]));
|
||||
assert_eq!(names(&s)[1..], ["Page-1", "Page-2"]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 2, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_ne!(names(&s)[1], "Page-1", "one undo restores both");
|
||||
assert_ne!(names(&s)[2], "Page-2");
|
||||
}
|
||||
|
||||
/// `artboard.rename` with nothing to change records no undo step, and without `index` or `indices`
|
||||
/// acts on the artboards the Artboard tool has selected.
|
||||
#[test]
|
||||
fn artboard_rename_records_nothing_when_nothing_changes() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
s.execute("artboard.rename", &json!({"indices": [0, 1], "name": "Page"})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("artboard.rename", &json!({"indices": [0, 1], "name": "Page"})).unwrap()["renamed"], json!([]));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("selected", &json!([1]));
|
||||
assert_eq!(s.execute("artboard.rename", &json!({"name": "Back"})).unwrap()["renamed"], json!([1]));
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[1].name, "Back");
|
||||
}
|
||||
|
||||
/// `artboard.rename` refuses an empty or missing name, a name that is not a string and an unknown
|
||||
/// artboard, and leaves the document and its undo history as they were.
|
||||
#[test]
|
||||
fn artboard_rename_refuses_bad_params() {
|
||||
let mut s = session();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
for p in [
|
||||
json!({"index": 0, "name": " "}),
|
||||
json!({"index": 0}),
|
||||
json!({"index": 0, "name": 7}),
|
||||
json!({"index": 9, "name": "A"}),
|
||||
json!({"indices": [], "name": "A"}),
|
||||
json!({"indices": [0, 9], "name": "A"}),
|
||||
json!({"index": "0", "name": "A"}),
|
||||
] {
|
||||
assert!(s.execute("artboard.rename", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Artboard 1");
|
||||
}
|
||||
|
||||
/// `artboard.switchOrientation` swaps width and height of each artboard named in one undo step,
|
||||
/// each top-left corner staying put; a square one and a locked one stay as they are.
|
||||
#[test]
|
||||
fn artboard_switch_orientation_swaps_each_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 10, "width": 300, "height": 100})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 2000, "y": 20, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 3000, "y": 30, "width": 40, "height": 60})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 3, "locked": true})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("artboard.switchOrientation", &json!({"indices": [0, 1, 2, 3]})).unwrap();
|
||||
assert_eq!(r["switched"], json!([0, 1]));
|
||||
let rects: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height())).collect();
|
||||
assert_eq!(rects, [(0.0, 0.0, 600.0, 800.0), (1000.0, 10.0, 100.0, 300.0), (2000.0, 20.0, 50.0, 50.0), (3000.0, 30.0, 40.0, 60.0)]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[1].rect.width(), 300.0);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 800.0, "one undo restores both");
|
||||
}
|
||||
|
||||
/// `artboard.switchOrientation`: a square artboard records no undo step, every artboard locked is an
|
||||
/// error, and bad params change nothing.
|
||||
#[test]
|
||||
fn artboard_switch_orientation_refuses_and_records_nothing() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("artboard.switchOrientation", &json!({"index": 1})).unwrap()["switched"], json!([]));
|
||||
assert_eq!(s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap_err().to_string(), "the artboard is locked");
|
||||
for p in [json!({"index": 9}), json!({"indices": []}), json!({"index": "1"}), json!({"index": 1, "scaleArt": "yes"})] {
|
||||
assert!(s.execute("artboard.switchOrientation", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// `artboard.switchOrientation` takes the art along with `scaleArt`, and by default follows the
|
||||
/// Artboard tool's Scale Artwork with Artboard option.
|
||||
#[test]
|
||||
fn artboard_switch_orientation_scales_art_with_the_tools_option() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s);
|
||||
let width = |s: &Session| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap().width();
|
||||
let was = width(&s);
|
||||
s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(width(&s), was, "the art stays by default");
|
||||
s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("scaleArt", &json!(true));
|
||||
s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap();
|
||||
assert_ne!(width(&s), was, "the tool's option scales the art");
|
||||
}
|
||||
|
||||
/// `artboard.fitToArt` records no undo step when every artboard already fits its art.
|
||||
#[test]
|
||||
fn artboard_fit_to_art_records_nothing_when_it_already_fits() {
|
||||
let mut s = session();
|
||||
rect(&mut s);
|
||||
s.execute("artboard.fitToArt", &json!({"index": 0})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let was = s.doc().unwrap().doc.artboards[0].rect;
|
||||
s.execute("artboard.fitToArt", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, was);
|
||||
}
|
||||
|
||||
/// `edit.paste {artboardsAt}` centers the copied artboards on the point as they lay. A value that is
|
||||
/// not a point is an error.
|
||||
#[test]
|
||||
fn paste_centers_copied_artboards_on_artboards_at() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("artboard.copy", &json!({"indices": [0, 1]})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
for bad in [json!("here"), json!([1]), json!(["1", "2"]), json!([1, 2, 3]), json!(null)] {
|
||||
assert!(s.execute("edit.paste", &json!({"artboardsAt": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
let r = s.execute("edit.paste", &json!({"artboardsAt": [5000.0, 7000.0]})).unwrap();
|
||||
assert_eq!(r["artboards"], json!([2, 3]));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let together = d.artboards[2].rect.union(d.artboards[3].rect).center();
|
||||
assert_eq!((together.x, together.y), (5000.0, 7000.0));
|
||||
assert_eq!(d.artboards[3].rect.x0 - d.artboards[2].rect.x0, 1000.0, "as they lay");
|
||||
}
|
||||
|
||||
/// The artboards' left edges and names.
|
||||
fn boards(s: &Session) -> Vec<(f64, String)> {
|
||||
s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.name.clone())).collect()
|
||||
|
||||
@@ -342,6 +342,23 @@ fn lock_station_point_swings_the_other_vanishing_point() {
|
||||
assert!(n.viewing_angle() < 45.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grid_dragged_off_its_preset_reports_no_preset_name() {
|
||||
let mut s = session();
|
||||
run(&mut s, "perspective.grid.preset", json!({"name": "[1P-Normal View]"}));
|
||||
let name = |s: &mut Session| run(s, "perspective.grid.get", json!({}))["define"]["name"].clone();
|
||||
assert_eq!(name(&mut s), json!("[1P-Normal View]"));
|
||||
// Moving a plane keeps the definition: still the preset.
|
||||
run(&mut s, "perspective.plane.move", json!({"plane": "ground", "by": 50}));
|
||||
assert_eq!(name(&mut s), json!("[1P-Normal View]"));
|
||||
let g = grid(&s);
|
||||
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 40.0, g.horizon - 60.0));
|
||||
assert!(!near(grid(&s).horizon, g.horizon), "the horizon moved");
|
||||
assert_eq!(name(&mut s), json!(""), "a custom grid now");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(name(&mut s), json!("[1P-Normal View]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_to_grid_lands_drawn_and_moved_art_on_gridlines() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -662,6 +662,98 @@ fn placed_art_brings_its_symbols_and_swatches_renaming_on_conflict() {
|
||||
assert!(linked, "still linked to the swatch it brought");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_art_brings_its_brushes_renaming_on_conflict() {
|
||||
// The file: a path with its own brush, and one with a brush the defaults hold.
|
||||
let mut src = session();
|
||||
src.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib", "params": {"angle": 20, "roundness": 30, "size": 8}})).unwrap();
|
||||
src.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
src.execute("brush.apply", &json!({"name": "Nib"})).unwrap();
|
||||
src.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
src.execute("brush.apply", &json!({"name": "Charcoal"})).unwrap();
|
||||
let bytes = serialize(&mut src, "vectorcraft");
|
||||
let brushes = |n: &Node| {
|
||||
let mut names = vec![];
|
||||
n.walk(&mut |c| names.extend(c.appearance.stroke().and_then(|st| st.brush.clone())));
|
||||
names
|
||||
};
|
||||
// A document with a different "Nib".
|
||||
let mut s = session();
|
||||
s.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib", "params": {"angle": 90, "roundness": 100, "size": 2}})).unwrap();
|
||||
let before = s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().len();
|
||||
let r = place(&mut s, "art.vectorcraft", &bytes, json!({"crop": "bounding"}));
|
||||
assert_eq!(brushes(&node(&s, &r["ids"][0])), ["Nib 2", "Charcoal"]);
|
||||
assert_eq!(s.execute("brush.get", &json!({"name": "Nib"})).unwrap()["size"], json!(2.0), "its own stays");
|
||||
assert_eq!(s.execute("brush.get", &json!({"name": "Nib 2"})).unwrap()["size"], json!(8.0));
|
||||
assert_eq!(s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().len(), before + 1, "the same Charcoal is shared");
|
||||
// A document without it gets it as it is.
|
||||
let mut fresh = session();
|
||||
let r = place(&mut fresh, "art.vectorcraft", &bytes, json!({"crop": "bounding"}));
|
||||
assert_eq!(brushes(&node(&fresh, &r["ids"][0])), ["Nib", "Charcoal"]);
|
||||
assert_eq!(fresh.execute("brush.get", &json!({"name": "Nib"})).unwrap()["size"], json!(8.0));
|
||||
// The name a conflict would take is not one the art already uses.
|
||||
src.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib 2", "params": {"angle": 40, "roundness": 50, "size": 5}})).unwrap();
|
||||
src.execute("shape.rectangle", &json!({"x": 90, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
src.execute("brush.apply", &json!({"name": "Nib 2"})).unwrap();
|
||||
let bytes = serialize(&mut src, "vectorcraft");
|
||||
let mut s = session();
|
||||
s.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib", "params": {"angle": 90, "roundness": 100, "size": 2}})).unwrap();
|
||||
let r = place(&mut s, "art.vectorcraft", &bytes, json!({"crop": "bounding"}));
|
||||
assert_eq!(brushes(&node(&s, &r["ids"][0])), ["Nib 3", "Charcoal", "Nib 2"]);
|
||||
assert_eq!(s.execute("brush.get", &json!({"name": "Nib 3"})).unwrap()["size"], json!(8.0));
|
||||
assert_eq!(s.execute("brush.get", &json!({"name": "Nib 2"})).unwrap()["size"], json!(5.0));
|
||||
}
|
||||
|
||||
/// A placed brush whose art uses an image the document holds under the same key, with other pixels,
|
||||
/// isn't the document's brush of that name, though the two brushes read the same.
|
||||
#[test]
|
||||
fn a_placed_brush_whose_art_uses_a_renamed_image_is_copied() {
|
||||
let file = |pixel: u8| {
|
||||
let mut png = vec![];
|
||||
image::GrayImage::from_pixel(2, 1, image::Luma([pixel])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
s.edit("Brush", |d, _| {
|
||||
d.images.insert("k".into(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None });
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "k".into(),
|
||||
width: 2,
|
||||
height: 1,
|
||||
xf: Default::default(),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let art = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
let brush = vectorcraft_brush::Scatter { art, ..Default::default() };
|
||||
vectorcraft_brush::store(d, &[vectorcraft_brush::Brush { name: "Speck".into(), kind: vectorcraft_brush::BrushKind::Scatter(brush) }]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("brush.apply", &json!({"name": "Speck"})).unwrap();
|
||||
serialize(&mut s, "vectorcraft")
|
||||
};
|
||||
let mut s = session();
|
||||
let first = place(&mut s, "one.vectorcraft", &file(0), json!({"crop": "bounding"}));
|
||||
let second = place(&mut s, "two.vectorcraft", &file(255), json!({"crop": "bounding"}));
|
||||
let brush_of = |r: &Value| {
|
||||
let mut name = None;
|
||||
node(&s, &r["ids"][0]).walk(&mut |c| name = name.take().or_else(|| c.appearance.stroke().and_then(|st| st.brush.clone())));
|
||||
name
|
||||
};
|
||||
assert_eq!(brush_of(&first).as_deref(), Some("Speck"));
|
||||
assert_eq!(brush_of(&second).as_deref(), Some("Speck 2"));
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let key = |name: &str| {
|
||||
let Some(vectorcraft_brush::Brush { kind: vectorcraft_brush::BrushKind::Scatter(b), .. }) = vectorcraft_brush::find(doc, name) else {
|
||||
panic!("{name}")
|
||||
};
|
||||
let NodeKind::Image(im) = &b.art.kind else { panic!("{name}: {:?}", b.art.kind_label()) };
|
||||
im.key.clone()
|
||||
};
|
||||
assert_eq!((key("Speck"), key("Speck 2")), ("k".to_string(), "k 2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_reports_size_resolution_colour_mode_and_link() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -173,6 +173,98 @@ fn reset_category_and_all() {
|
||||
assert!(s.execute("prefs.reset", &json!({"category": "Bogus"})).is_err());
|
||||
}
|
||||
|
||||
/// `prefs.set` refuses a recovery or plug-ins folder that is missing or is a file. Nothing changes,
|
||||
/// other values in the same call included, and the document gets no undo step.
|
||||
#[test]
|
||||
fn set_refuses_a_missing_folder_or_a_file_as_a_recovery_or_plugins_folder() {
|
||||
let folder = crate::tests_links::Folder::new("prefs-set-refused");
|
||||
let file = folder.file("not-a-folder.txt");
|
||||
std::fs::write(&file, b"x").unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let before = s.prefs.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for key in ["recoveryFolder", "pluginsFolder"] {
|
||||
for path in [file.clone(), folder.file("missing")] {
|
||||
let e = s.execute("prefs.set", &json!({"key": key, "value": path})).unwrap_err();
|
||||
assert_eq!(e.to_string(), format!("{path} is not a folder"), "{key}");
|
||||
let e = s.execute("prefs.set", &json!({"values": {key: path, "keyboardIncrement": 7, "unitsGeneral": "millimeters"}})).unwrap_err();
|
||||
assert_eq!(e.to_string(), format!("{path} is not a folder"), "{key}");
|
||||
assert_eq!(s.prefs, before, "{key}: no preference changes");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{key}: no undo step");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `prefs.set` refuses a recovery folder that no folder can be created in or that can't be listed,
|
||||
/// and a plug-ins folder that can't be listed.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn set_refuses_a_recovery_or_plugins_folder_it_cannot_use() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
/// Gives a folder mode 0o755 again when dropped, also when an assertion fails, so the test
|
||||
/// folder can be removed.
|
||||
struct Unlock(std::path::PathBuf);
|
||||
impl Drop for Unlock {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
}
|
||||
let folder = crate::tests_links::Folder::new("prefs-set-unusable");
|
||||
let mut s = Session::new();
|
||||
let before = s.prefs.clone();
|
||||
let locked = folder.0.join("locked");
|
||||
std::fs::create_dir(&locked).unwrap();
|
||||
let unlock_locked = Unlock(locked.clone());
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// An account that may write anyway (root) has nothing to refuse.
|
||||
if std::fs::create_dir(locked.join("probe")).is_err() {
|
||||
let e = s.execute("prefs.set", &json!({"key": "recoveryFolder", "value": locked.to_string_lossy()})).unwrap_err();
|
||||
assert!(e.to_string().starts_with("Data Recovery can't keep copies in "), "{e}");
|
||||
}
|
||||
drop(unlock_locked);
|
||||
let unlisted = folder.0.join("unlisted");
|
||||
std::fs::create_dir(&unlisted).unwrap();
|
||||
let unlock_unlisted = Unlock(unlisted.clone());
|
||||
std::fs::set_permissions(&unlisted, std::fs::Permissions::from_mode(0o333)).unwrap();
|
||||
// An account that may list anyway (root) has nothing to refuse.
|
||||
if std::fs::read_dir(&unlisted).is_err() {
|
||||
let e = s.execute("prefs.set", &json!({"key": "recoveryFolder", "value": unlisted.to_string_lossy()})).unwrap_err();
|
||||
assert!(e.to_string().starts_with("Data Recovery can't keep copies in "), "{e}");
|
||||
let e = s.execute("prefs.set", &json!({"key": "pluginsFolder", "value": unlisted.to_string_lossy()})).unwrap_err();
|
||||
assert!(e.to_string().starts_with("Plug-ins can't be loaded from "), "{e}");
|
||||
}
|
||||
drop(unlock_unlisted);
|
||||
assert_eq!(s.prefs, before);
|
||||
}
|
||||
|
||||
/// `prefs.set` saves a folder that passes as given and leaves nothing behind in it. An empty value
|
||||
/// and a folder the preference already holds are not checked, nor is a held folder when the call
|
||||
/// leaves it out, so they work while the folder is unavailable.
|
||||
#[test]
|
||||
fn set_takes_a_usable_empty_or_already_held_recovery_folder() {
|
||||
let folder = crate::tests_links::Folder::new("prefs-set-usable");
|
||||
let mut s = Session::new();
|
||||
let typed = format!(" {} ", folder.0.display());
|
||||
s.execute("prefs.set", &json!({"key": "recoveryFolder", "value": typed})).unwrap();
|
||||
assert_eq!(s.prefs.recovery_folder, typed);
|
||||
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 0, "the write check leaves nothing behind");
|
||||
|
||||
let gone = folder.0.join("gone");
|
||||
std::fs::create_dir(&gone).unwrap();
|
||||
let gone = gone.to_string_lossy().into_owned();
|
||||
s.execute("prefs.set", &json!({"key": "recoveryFolder", "value": gone})).unwrap();
|
||||
std::fs::remove_dir(&gone).unwrap();
|
||||
s.execute("prefs.set", &json!({"values": {"recoveryFolder": gone, "keyboardIncrement": 7}})).unwrap();
|
||||
s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": 3})).unwrap();
|
||||
assert_eq!((s.prefs.recovery_folder.as_str(), s.prefs.keyboard_increment), (gone.as_str(), 3.0));
|
||||
|
||||
for key in ["recoveryFolder", "pluginsFolder"] {
|
||||
s.execute("prefs.set", &json!({"key": key, "value": ""})).unwrap();
|
||||
assert_eq!(s.prefs.to_json()[key], json!(""), "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_describes_every_pref() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Shape tools: the selection they keep on show moves by its centre point (Attributes › Show
|
||||
//! Center) and turns from the rotate zone just outside a corner of its box, as the Selection
|
||||
//! tool's do, through the whole engine (#5477).
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{LiveShape, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A new 400 × 400 pt document.
|
||||
fn new_doc() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The view the gestures run in: page coordinates, with Smart Guides off so nothing snaps.
|
||||
fn view() -> ViewInfo {
|
||||
ViewInfo { smart_guides: false, ..ViewInfo::default() }
|
||||
}
|
||||
|
||||
/// Feed the pointer events `(kind, x, y)`, all with `mods` held, to the active tool.
|
||||
fn gesture(s: &mut Session, mods: Mods, events: &[(PointerKind, f64, f64)]) {
|
||||
for (k, x, y) in events {
|
||||
s.pointer(&PointerEvent::new(*k, *x, *y).with_mods(mods), view()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// No modifier held.
|
||||
const NONE: Mods = Mods { shift: false, alt: false, cmd: false, ctrl: false, space: false };
|
||||
|
||||
/// A 100 × 80 pt rectangle drawn from (100, 100) to (200, 180) with the Rectangle tool, still
|
||||
/// selected: its centre point is at (150, 140).
|
||||
fn drawn_rect(s: &mut Session) -> NodeId {
|
||||
s.select_tool("rectangle", view()).unwrap();
|
||||
gesture(s, NONE, &[(PointerKind::Down, 100.0, 100.0), (PointerKind::Drag, 200.0, 180.0), (PointerKind::Up, 200.0, 180.0)]);
|
||||
*s.doc().unwrap().selection.objects.first().expect("the drawn rectangle is selected")
|
||||
}
|
||||
|
||||
/// The geometric bounds of `id`.
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
s.doc().unwrap().doc.bounds_of(&[id], false).expect("bounds")
|
||||
}
|
||||
|
||||
/// The angle of `id`'s bounding box, in degrees counter-clockwise: 0 for art square to the page.
|
||||
fn angle(s: &Session, id: NodeId) -> f64 {
|
||||
s.doc().unwrap().doc.oriented_bounds(&[id], false).map_or(0.0, |b| b.angle)
|
||||
}
|
||||
|
||||
/// How many undo steps the active document's history holds.
|
||||
fn undo_steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// With the Rectangle tool still active, a drag from the shape's centre point moves it, in one
|
||||
/// undo step; Alt-dragging it copies it; Escape turns a drag back.
|
||||
#[test]
|
||||
fn a_shape_tool_moves_the_shape_from_its_centre_point() {
|
||||
let mut s = new_doc();
|
||||
let id = drawn_rect(&mut s);
|
||||
let before = bounds(&s, id);
|
||||
let steps = undo_steps(&s);
|
||||
gesture(
|
||||
&mut s,
|
||||
NONE,
|
||||
&[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 170.0, 160.0), (PointerKind::Drag, 180.0, 170.0), (PointerKind::Up, 180.0, 170.0)],
|
||||
);
|
||||
assert_eq!(bounds(&s, id), before + Vec2::new(30.0, 30.0), "moved by the drag");
|
||||
assert_eq!(undo_steps(&s), steps + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(bounds(&s, id), before);
|
||||
// Alt-dragging from the centre leaves a copy where the original was, in one undo step.
|
||||
let steps = undo_steps(&s);
|
||||
gesture(
|
||||
&mut s,
|
||||
Mods { alt: true, ..NONE },
|
||||
&[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 200.0, 140.0), (PointerKind::Up, 200.0, 140.0)],
|
||||
);
|
||||
let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map(|c| c.len()).unwrap_or_default();
|
||||
assert_eq!(objects(&s), 2, "a copy");
|
||||
assert_eq!(undo_steps(&s), steps + 1, "one undo step for the copy");
|
||||
let copy = *s.doc().unwrap().selection.objects.first().expect("the copy is selected");
|
||||
assert_eq!(bounds(&s, id), before, "the original stays");
|
||||
assert_eq!(bounds(&s, copy), before + Vec2::new(50.0, 0.0), "the copy moved");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(objects(&s), 1, "undone");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(objects(&s), 2, "redone");
|
||||
// Escape turns the drag back, and the release leaves the art alone.
|
||||
let steps = undo_steps(&s);
|
||||
let moved = s.doc().unwrap().selection.objects.clone();
|
||||
let copy = *moved.first().expect("the copy is selected");
|
||||
let at = bounds(&s, copy);
|
||||
gesture(&mut s, NONE, &[(PointerKind::Down, 200.0, 140.0), (PointerKind::Drag, 250.0, 190.0)]);
|
||||
s.tool_key(ToolKey::Escape, NONE, view()).unwrap();
|
||||
gesture(&mut s, NONE, &[(PointerKind::Up, 250.0, 190.0)]);
|
||||
assert_eq!(undo_steps(&s), steps, "no step");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, moved);
|
||||
assert_eq!(bounds(&s, copy), at, "back where it was");
|
||||
}
|
||||
|
||||
/// Alt pressed once the drag has begun turns the move into a copy, as the Selection tool's.
|
||||
#[test]
|
||||
fn alt_pressed_mid_drag_copies_the_shape() {
|
||||
let mut s = new_doc();
|
||||
let id = drawn_rect(&mut s);
|
||||
let before = bounds(&s, id);
|
||||
gesture(&mut s, NONE, &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 170.0, 140.0)]);
|
||||
gesture(&mut s, Mods { alt: true, ..NONE }, &[(PointerKind::Drag, 200.0, 140.0), (PointerKind::Up, 200.0, 140.0)]);
|
||||
assert_eq!(s.doc().unwrap().doc.layers[0].children().map(|c| c.len()).unwrap_or_default(), 2, "a copy");
|
||||
assert_eq!(bounds(&s, id), before, "the original stays");
|
||||
let copy = *s.doc().unwrap().selection.objects.first().expect("the copy is selected");
|
||||
assert_eq!(bounds(&s, copy), before + Vec2::new(50.0, 0.0), "the copy moved by the whole drag");
|
||||
}
|
||||
|
||||
/// A press in the rotate zone just outside a corner of the shape's box turns it about that box's
|
||||
/// centre, as the Selection tool's does; a press on the corner itself still rounds it.
|
||||
#[test]
|
||||
fn a_shape_tool_turns_the_shape_from_just_outside_a_corner() {
|
||||
let mut s = new_doc();
|
||||
let id = drawn_rect(&mut s);
|
||||
// 8.49 pt outside the top-left corner (100, 100), past the handle's 5 pt square.
|
||||
gesture(
|
||||
&mut s,
|
||||
NONE,
|
||||
&[(PointerKind::Down, 94.0, 94.0), (PointerKind::Drag, 150.0, 70.0), (PointerKind::Drag, 150.0, 70.0), (PointerKind::Up, 150.0, 70.0)],
|
||||
);
|
||||
let turned = angle(&s, id);
|
||||
assert!(turned.abs() > 1.0, "turned: {turned}");
|
||||
let centre = bounds(&s, id).center();
|
||||
assert!((centre - Point::new(150.0, 140.0)).hypot() < 1e-6, "about its centre: {centre:?}");
|
||||
// One undo step for the turn, and Redo turns it again.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(angle(&s, id), 0.0, "undone");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!((angle(&s, id) - turned).abs() < 1e-9, "redone");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// A press on a Live Corners widget rounds it, as before (#5477): the widget keeps the corner,
|
||||
// and sits 10 pt inside it.
|
||||
let mut s = new_doc();
|
||||
let id = drawn_rect(&mut s);
|
||||
gesture(&mut s, NONE, &[(PointerKind::Down, 110.0, 110.0), (PointerKind::Drag, 125.0, 125.0), (PointerKind::Up, 125.0, 125.0)]);
|
||||
let n = s.doc().unwrap().doc.node(id).expect("the rectangle");
|
||||
let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("a live shape: {:?}", n.kind) };
|
||||
let LiveShape::Rectangle { radii, .. } = &**live else { panic!("a rectangle") };
|
||||
assert!(radii[0] > 0.0, "the corner rounded: {radii:?}");
|
||||
assert_eq!(angle(&s, id), 0.0, "rounding does not turn it");
|
||||
}
|
||||
|
||||
/// The Selection tool takes the centre point of an unfilled shape too: a drag there moves it
|
||||
/// instead of starting a marquee.
|
||||
#[test]
|
||||
fn the_selection_tool_moves_an_unfilled_shape_from_its_centre_point() {
|
||||
let mut s = new_doc();
|
||||
let id = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"none": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.select_tool("selection", view()).unwrap();
|
||||
let before = bounds(&s, id);
|
||||
gesture(&mut s, NONE, &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 170.0, 160.0), (PointerKind::Up, 170.0, 160.0)]);
|
||||
assert_eq!(bounds(&s, id), before + Vec2::new(20.0, 20.0));
|
||||
// Away from it, a drag is still a marquee.
|
||||
let steps = undo_steps(&s);
|
||||
gesture(&mut s, NONE, &[(PointerKind::Down, 300.0, 300.0), (PointerKind::Drag, 350.0, 350.0), (PointerKind::Up, 350.0, 350.0)]);
|
||||
assert_eq!(undo_steps(&s), steps, "a marquee changes no art");
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty(), "the marquee took nothing");
|
||||
}
|
||||
@@ -792,6 +792,134 @@ fn a_library_named_like_a_device_keeps_the_library_with_its_file_name() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `.ase` keeps no title, so a library's name comes back from its file name. Whichever is saved
|
||||
/// first, `COM¹` and `COM¹-` keep a file each, and saving either again replaces only itself. After
|
||||
/// a restart neither can be told from the other, so each gets a new file and replaces neither.
|
||||
#[test]
|
||||
fn device_named_libraries_without_a_title_keep_their_own_files() {
|
||||
for (first, second) in [("COM¹", "COM¹-"), ("COM¹-", "COM¹")] {
|
||||
let dir = temp_dir("device-ase");
|
||||
let mut s = session();
|
||||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||||
let save = |s: &mut Session, name: &str| {
|
||||
run(s, "swatch.library.save", json!({"user": true, "name": name, "format": "ase"}))["library"].as_str().unwrap().to_string()
|
||||
};
|
||||
let a = save(&mut s, first);
|
||||
assert_eq!(save(&mut s, first), a, "saving {first} again replaces {first}");
|
||||
let b = save(&mut s, second);
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(save(&mut s, first), a, "saving {first} again replaces {first}");
|
||||
assert_eq!(save(&mut s, second), b, "saving {second} again replaces {second}");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
|
||||
// The next time the app starts.
|
||||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||||
for (name, count) in [(first, 3), (second, 4)] {
|
||||
let again = save(&mut s, name);
|
||||
assert!(again != a && again != b, "{name}: {again}");
|
||||
assert_eq!(save(&mut s, name), again, "saving {name} again replaces {name}");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), count);
|
||||
}
|
||||
// The numbered file is as unknown as the first.
|
||||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||||
let numbered = b.strip_prefix("user/").unwrap().strip_suffix(".ase").unwrap().to_string();
|
||||
assert_ne!(save(&mut s, &numbered), b);
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5);
|
||||
// Any other name goes on replacing its own file after a restart.
|
||||
let other = save(&mut s, "Greys");
|
||||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||||
assert_eq!(save(&mut s, "Greys"), other);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// A file this session saved for `COM¹`, then replaced, is not written over. The next save goes to
|
||||
/// a new file and stays there. That holds for `.ase` and `.gpl`, for bytes that still read as a
|
||||
/// library and for bytes that don't, including the file the library moved to. A file removed and
|
||||
/// put back is still its library's when the bytes are the ones written, and is kept when they are
|
||||
/// not. A name that is not a device name still replaces a file that cannot be read.
|
||||
#[test]
|
||||
fn a_replaced_device_library_file_is_kept() {
|
||||
for format in ["ase", "gpl"] {
|
||||
let other: Vec<u8> = if format == "ase" {
|
||||
vectorcraft_testkit::ase::ase(&[vectorcraft_testkit::ase::Block::Color("Only", b"RGB ", &[1.0, 0.0, 0.0], 2)])
|
||||
} else {
|
||||
b"GIMP Palette\nName: Other\n0 0 0 Black\n".to_vec()
|
||||
};
|
||||
let save = |s: &mut Session| run(s, "swatch.library.save", json!({"user": true, "name": "COM¹", "format": format}));
|
||||
let open = |name: &str| {
|
||||
let dir = temp_dir(name);
|
||||
let mut s = session();
|
||||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||||
(dir, s)
|
||||
};
|
||||
|
||||
// Replaced with another library.
|
||||
let (dir, mut s) = open(&format!("replaced-lib-{format}"));
|
||||
let first = save(&mut s);
|
||||
let first_id = first["library"].as_str().unwrap().to_string();
|
||||
let first_path = first["path"].as_str().unwrap().to_string();
|
||||
assert_ne!(std::fs::read(&first_path).unwrap(), other);
|
||||
std::fs::write(&first_path, &other).unwrap();
|
||||
let moved = save(&mut s);
|
||||
let moved_id = moved["library"].as_str().unwrap().to_string();
|
||||
assert_ne!(moved_id, first_id);
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), other, "{format}: the replaced file is left as it is");
|
||||
assert_eq!(save(&mut s)["library"].as_str(), Some(moved_id.as_str()), "{format}: saving again replaces the new file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// Replaced with bytes that are not a library, first the file it had, then the one it moved to.
|
||||
let (dir, mut s) = open(&format!("replaced-unread-{format}"));
|
||||
let first = save(&mut s);
|
||||
let first_path = first["path"].as_str().unwrap().to_string();
|
||||
std::fs::write(&first_path, b"not a library").unwrap();
|
||||
let moved = save(&mut s);
|
||||
let moved_id = moved["library"].as_str().unwrap().to_string();
|
||||
let first_id = first["library"].as_str().unwrap();
|
||||
assert_ne!(moved_id, first_id);
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), b"not a library");
|
||||
assert_eq!(save(&mut s)["library"].as_str(), Some(moved_id.as_str()), "{format}: saving again stays on the new file");
|
||||
let moved_path = moved["path"].as_str().unwrap().to_string();
|
||||
std::fs::write(&moved_path, b"still not").unwrap();
|
||||
let again = save(&mut s);
|
||||
let again_id = again["library"].as_str().unwrap().to_string();
|
||||
assert!(again_id != moved_id && again_id != first_id);
|
||||
assert_eq!(std::fs::read(&moved_path).unwrap(), b"still not");
|
||||
assert_eq!(save(&mut s)["library"].as_str(), Some(again_id.as_str()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// Removed, the folder listed again, then put back: the hash is checked when it reappears.
|
||||
let (dir, mut s) = open(&format!("replaced-reappear-{format}"));
|
||||
let first = save(&mut s);
|
||||
let first_id = first["library"].as_str().unwrap().to_string();
|
||||
let first_path = first["path"].as_str().unwrap().to_string();
|
||||
let written = std::fs::read(&first_path).unwrap();
|
||||
std::fs::remove_file(&first_path).unwrap();
|
||||
run(&mut s, "swatch.library.list", json!({}));
|
||||
std::fs::write(&first_path, &written).unwrap();
|
||||
assert_eq!(save(&mut s)["library"].as_str(), Some(first_id.as_str()), "{format}: the same bytes are still this library");
|
||||
std::fs::remove_file(&first_path).unwrap();
|
||||
run(&mut s, "swatch.library.list", json!({}));
|
||||
std::fs::write(&first_path, &other).unwrap();
|
||||
let moved = save(&mut s);
|
||||
let moved_id = moved["library"].as_str().unwrap().to_string();
|
||||
assert_ne!(moved_id, first_id);
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), other, "{format}: different bytes put back are kept");
|
||||
assert_eq!(save(&mut s)["library"].as_str(), Some(moved_id.as_str()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
let dir = temp_dir("replaced-ordinary");
|
||||
let mut s = session();
|
||||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||||
let first = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Greys", "format": "gpl"}));
|
||||
let path = first["path"].as_str().unwrap().to_string();
|
||||
std::fs::write(&path, b"not a library").unwrap();
|
||||
let again = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Greys", "format": "gpl"}));
|
||||
assert_eq!(again["library"], first["library"], "a name that is not a device name replaces its own unreadable file");
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"GIMP Palette"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3602: a library titled with a Windows device name is saved under a name that is not one.
|
||||
#[test]
|
||||
fn a_library_file_name_is_never_a_windows_device_name() {
|
||||
|
||||
@@ -1613,6 +1613,269 @@ fn the_eyedropper_styles_the_whole_story_that_select_all_selected() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A red rectangle outside the story of [`threaded_story_edited_in_its_second_frame`], to pick up
|
||||
/// from at (525, 325), with the Type tool editing frame `b` at its start again.
|
||||
fn red_source(s: &mut Session, b: NodeId) {
|
||||
let source = s.execute("shape.rectangle", &json!({"x": 500, "y": 300, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [source], "color": "#ff0000"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
s.set_tool_option("textSelection", &json!({"editing": b.0, "anchor": 0, "caret": 0}));
|
||||
}
|
||||
|
||||
fn all_red(s: &Session, id: NodeId) -> bool {
|
||||
obj(s, id).runs.iter().all(|r| r.style.fill.color().is_some_and(|c| c.to_hex() == "#ff0000"))
|
||||
}
|
||||
|
||||
fn any_red(s: &Session, id: NodeId) -> bool {
|
||||
obj(s, id).runs.iter().any(|r| r.style.fill.color().is_some_and(|c| c.to_hex() == "#ff0000"))
|
||||
}
|
||||
|
||||
/// A click and a Shift-click of the Eyedropper on the rectangle of [`red_source`].
|
||||
fn eyedropper_picks() -> [Mods; 2] {
|
||||
[Mods::default(), Mods { shift: true, ..Default::default() }]
|
||||
}
|
||||
|
||||
fn pick_red_source(s: &mut Session, mods: Mods) {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 525.0, 325.0).with_mods(mods), ViewInfo::default()).unwrap();
|
||||
}
|
||||
|
||||
fn highlights(s: &mut Session) -> usize {
|
||||
s.overlays(ViewInfo::default()).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Highlight { .. })).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_to_the_eyedropper_keeps_the_whole_story_selected() {
|
||||
for mods in eyedropper_picks() {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
red_source(&mut s, b);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let highlighted = highlights(&mut s);
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
assert_eq!(s.tool_options()["story"], json!([a.0, b.0]));
|
||||
assert!(highlights(&mut s) >= highlighted, "the Eyedropper highlights the text of both frames");
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
pick_red_source(&mut s, mods);
|
||||
assert!(all_red(&s, a) && all_red(&s, b), "every frame takes the pick");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!any_red(&s, a) && !any_red(&s, b), "one undo takes the pick back from every frame");
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
let o = s.tool_options();
|
||||
assert_eq!((&o["editing"], &o["story"]), (&json!(a.0), &json!([a.0, b.0])), "back in the Type tool the story stays selected");
|
||||
s.tool_text("x", ViewInfo::default()).unwrap();
|
||||
assert_eq!(format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text()), "x", "typing replaces the whole story");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_inside_one_threaded_frame_stays_in_that_frame_under_the_eyedropper() {
|
||||
for mods in eyedropper_picks() {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
red_source(&mut s, b);
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 4}));
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
assert!(s.tool_options().get("story").is_none());
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
pick_red_source(&mut s, mods);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(obj(&s, b).runs[0].text.len(), 4, "only the selected characters take the pick");
|
||||
assert!(any_red(&s, b) && !all_red(&s, b) && !any_red(&s, a));
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
let o = s.tool_options();
|
||||
assert_eq!((o["editing"].as_u64(), o["start"].as_u64(), o["end"].as_u64()), (Some(b.0), Some(0), Some(4)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_eyedropper_pick_on_the_whole_story_styles_locked_and_hidden_frames() {
|
||||
for command in ["object.lock", "object.hide"] {
|
||||
for held_first in [false, true] {
|
||||
for mods in eyedropper_picks() {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
let (held, other) = if held_first { (a, b) } else { (b, a) };
|
||||
s.execute(command, &json!({"ids": [held.0]})).unwrap();
|
||||
red_source(&mut s, other);
|
||||
assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0]));
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
pick_red_source(&mut s, mods);
|
||||
assert!(all_red(&s, other), "{command}: the other frame takes the pick");
|
||||
assert!(all_red(&s, held), "{command}: the held frame takes the pick");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}: one undo step");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_eyedropper_pick_from_a_frame_of_the_selected_story_styles_the_other_frames() {
|
||||
for source_first in [false, true] {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
let (source, other) = if source_first { (a, b) } else { (b, a) };
|
||||
s.execute("text.setStyle", &json!({"ids": [source.0], "fill": "#ff0000"})).unwrap();
|
||||
assert!(all_red(&s, source) && !any_red(&s, other));
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
s.set_tool_option("textSelection", &json!({"editing": b.0, "anchor": 0, "caret": 0}));
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("appearance.copyFrom", &json!({"source": source.0})).unwrap();
|
||||
assert_eq!(r, json!({"ids": [other.0]}));
|
||||
assert!(all_red(&s, other) && all_red(&s, source));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a character in the frame that `text.thread.add` places at [`THREADED_AT`] is highlighted.
|
||||
fn third_frame_highlighted(s: &mut Session) -> bool {
|
||||
let in_frame = |o: &vectorcraft_tools::Overlay| matches!(o, vectorcraft_tools::Overlay::Highlight { quad, color: [60, 120, 255, 90] } if quad.iter().all(|p| p.x > 690.0));
|
||||
s.overlays(ViewInfo::default()).iter().any(in_frame)
|
||||
}
|
||||
|
||||
/// Where a third frame of the story of [`threaded_story_edited_in_its_second_frame`] goes, clear
|
||||
/// of its other frames.
|
||||
const THREADED_AT: [f64; 2] = [700.0, 10.0];
|
||||
|
||||
#[test]
|
||||
fn a_frame_threaded_into_the_selected_story_is_highlighted_and_takes_the_edit() {
|
||||
for mods in eyedropper_picks() {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
red_source(&mut s, b);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
let c = s.execute("text.thread.add", &json!({"from": a.0, "before": true, "at": THREADED_AT})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = NodeId(c);
|
||||
assert!(!obj(&s, c).plain_text().is_empty(), "the story starts in the new frame");
|
||||
assert!(third_frame_highlighted(&mut s), "the new frame's text is highlighted");
|
||||
pick_red_source(&mut s, mods);
|
||||
assert!(all_red(&s, a) && all_red(&s, b) && all_red(&s, c), "every frame takes the pick");
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
assert!(third_frame_highlighted(&mut s), "back in the Type tool too");
|
||||
s.tool_text("x", ViewInfo::default()).unwrap();
|
||||
let text = [a, b, c].map(|f| obj(&s, f).plain_text()).concat();
|
||||
assert_eq!(text, "x", "typing replaces the text of every frame");
|
||||
}
|
||||
}
|
||||
|
||||
/// After Select All in the story of [`threaded_story_edited_in_its_second_frame`], a frame threaded
|
||||
/// in before the edited one takes the whole story, and the edited frame holds no text.
|
||||
fn selected_story_moved_into_a_new_frame(s: &mut Session, a: NodeId, b: NodeId) -> NodeId {
|
||||
let rect = [THREADED_AT[0], THREADED_AT[1], 400.0, 600.0];
|
||||
let c = NodeId(s.execute("text.thread.add", &json!({"from": a.0, "before": true, "rect": rect})).unwrap()["id"].as_u64().unwrap());
|
||||
assert!(obj(s, a).plain_text().is_empty() && obj(s, b).plain_text().is_empty(), "the new frame holds the whole story");
|
||||
assert!(!obj(s, c).plain_text().is_empty());
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_story_that_left_the_edited_frame_is_highlighted_and_typing_replaces_it() {
|
||||
for through_eyedropper in [false, true] {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
if through_eyedropper {
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
}
|
||||
let c = selected_story_moved_into_a_new_frame(&mut s, a, b);
|
||||
assert!(third_frame_highlighted(&mut s), "through the Eyedropper: {through_eyedropper}");
|
||||
if through_eyedropper {
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
assert!(third_frame_highlighted(&mut s), "back in the Type tool");
|
||||
}
|
||||
s.tool_text("x", ViewInfo::default()).unwrap();
|
||||
assert_eq!([a, b, c].map(|f| obj(&s, f).plain_text()).concat(), "x", "through the Eyedropper: {through_eyedropper}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_replaces_a_story_that_left_the_edited_frame() {
|
||||
for k in [ToolKey::Backspace, ToolKey::Delete] {
|
||||
let (mut s, a, b, story) = threaded_story_edited_in_its_second_frame();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let c = selected_story_moved_into_a_new_frame(&mut s, a, b);
|
||||
let steps = undo_labels(&s).len();
|
||||
key(&mut s, k, Mods::default());
|
||||
key(&mut s, ToolKey::Escape, Mods::default());
|
||||
assert_eq!([a, b, c].map(|f| obj(&s, f).plain_text()).concat(), "", "{k:?} empties every frame");
|
||||
assert_eq!(undo_labels(&s).len(), steps + 1, "{k:?}: one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!([a, b, c].map(|f| obj(&s, f).plain_text()).concat(), story);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_in_a_story_that_holds_no_text_records_no_undo_step() {
|
||||
for k in [ToolKey::Backspace, ToolKey::Delete] {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
key(&mut s, ToolKey::Delete, Mods::default());
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
assert_eq!(s.tool_options()["story"], json!([a.0, b.0]));
|
||||
let steps = undo_labels(&s).len();
|
||||
key(&mut s, k, Mods::default());
|
||||
key(&mut s, ToolKey::Escape, Mods::default());
|
||||
assert_eq!(undo_labels(&s).len(), steps, "{k:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_type_step_keeps_a_story_that_left_the_edited_frame_selected() {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let c = selected_story_moved_into_a_new_frame(&mut s, a, b);
|
||||
// Alt+Right steps the tracking of the selected text, not the kerning at a caret.
|
||||
key(&mut s, ToolKey::Right, Mods { alt: true, ..Mods::default() });
|
||||
assert!(obj(&s, c).runs.iter().all(|r| r.style.tracking > 0.0), "every character of the story takes the step");
|
||||
// A key that does nothing leaves it selected as well.
|
||||
key(&mut s, ToolKey::BracketLeft, Mods::default());
|
||||
assert!(third_frame_highlighted(&mut s));
|
||||
// The story stays the target of a fill and of a switch to the Eyedropper.
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
assert!(all_red(&s, c), "the fill reaches the story");
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
assert!(third_frame_highlighted(&mut s), "the Eyedropper keeps the story");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_that_left_the_selected_story_is_not_highlighted_and_leaves_the_edit() {
|
||||
for mods in eyedropper_picks() {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
let c = s.execute("text.thread.add", &json!({"from": b.0, "at": THREADED_AT})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = NodeId(c);
|
||||
red_source(&mut s, b);
|
||||
assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0, c.0]));
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
s.execute("text.thread.break", &json!({"id": b.0, "port": "out"})).unwrap();
|
||||
s.execute("text.setText", &json!({"id": c.0, "text": "Kept"})).unwrap();
|
||||
assert!(!third_frame_highlighted(&mut s), "the frame that left has no highlight");
|
||||
pick_red_source(&mut s, mods);
|
||||
assert!(all_red(&s, a) && all_red(&s, b), "the frames still threaded take the pick");
|
||||
assert!(!any_red(&s, c), "the frame that left stays as it is");
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
s.tool_text("x", ViewInfo::default()).unwrap();
|
||||
assert_eq!(obj(&s, c).plain_text(), "Kept", "typing leaves the frame that left");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_after_a_thread_is_removed_leaves_the_frame_that_left_the_story() {
|
||||
for through_eyedropper in [false, true] {
|
||||
let (mut s, _, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
if through_eyedropper {
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
}
|
||||
s.execute("text.thread.remove", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("text.thread.info", &json!({})).unwrap(), json!([]));
|
||||
if through_eyedropper {
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
}
|
||||
let left = obj(&s, b).plain_text();
|
||||
assert!(!left.is_empty());
|
||||
s.tool_text("x", ViewInfo::default()).unwrap();
|
||||
assert_eq!(obj(&s, b).plain_text(), left, "through the Eyedropper: {through_eyedropper}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_over_the_whole_story_replaces_every_frames_text_in_one_step() {
|
||||
let (mut s, a, b, story) = threaded_story_edited_in_its_second_frame();
|
||||
|
||||
@@ -214,20 +214,27 @@ fn new_width_point_without_adjoining_preserves_neighbours() {
|
||||
new_point_adjoining(false);
|
||||
}
|
||||
|
||||
/// A new width point with Adjust Adjoining scales only its nearest neighbours, by the widths sampled where it goes.
|
||||
#[test]
|
||||
fn new_width_point_adjust_adjoining_uses_sampled_widths() {
|
||||
for indexed in [false, true] {
|
||||
let (mut s, id) = line_session();
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[0, 9, 9], [0.25, 1, 0], [0.75, 3, 0], [1, 7, 7]]})).unwrap();
|
||||
// At t=0.5 the old sides are 10pt and 0pt. Indexed editing of that sampled
|
||||
// point is the existing convention: scale the nonzero side, add from zero.
|
||||
let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 0}))) } else { None };
|
||||
let original = vec![(0.0, 9.0, 9.0), (0.25, 1.0, 0.0), (0.75, 3.0, 0.0), (1.0, 7.0, 7.0)];
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": original})).unwrap();
|
||||
// At t=0.5 the old left side is where the profile's curve is there (#5714) and the right
|
||||
// side is 0pt. Indexed editing of that sampled point is the existing convention: scale the
|
||||
// nonzero side, add from zero.
|
||||
let sampled = vectorcraft_doc::WidthProfile { points: original }.at(0.5).0;
|
||||
let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": sampled * 5.0, "right": 0}))) } else { None };
|
||||
set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": 20, "right": 5, "adjustAdjoining": true}));
|
||||
assert_eq!(
|
||||
points(&s, id),
|
||||
vec![(0.0, 9.0, 9.0), (0.25, 2.0, 1.0), (0.5, 4.0, 1.0), (0.75, 6.0, 1.0), (1.0, 7.0, 7.0)],
|
||||
"only nearest neighbours change, using the sampled sides; indexed={indexed}"
|
||||
);
|
||||
let scale = 4.0 / sampled;
|
||||
let got = points(&s, id);
|
||||
let want = [(0.0, 9.0, 9.0), (0.25, scale, 1.0), (0.5, 4.0, 1.0), (0.75, 3.0 * scale, 1.0), (1.0, 7.0, 7.0)];
|
||||
assert_eq!(got.len(), want.len(), "{got:?}; indexed={indexed}");
|
||||
for (g, w) in got.iter().zip(want) {
|
||||
let close = (g.0 - w.0).abs() < 1e-9 && (g.1 - w.1).abs() < 1e-9 && (g.2 - w.2).abs() < 1e-9;
|
||||
assert!(close, "only nearest neighbours change, using the sampled sides; indexed={indexed}: {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,8 +131,9 @@ impl Session {
|
||||
if self.tool.id() == id {
|
||||
return Ok(());
|
||||
}
|
||||
// Sampling type keeps its highlighted characters as the target. Capture before the
|
||||
// Type tool deactivates (which commits typing and clears its editing state).
|
||||
// Sampling type keeps its highlighted characters as the target, and a whole story that
|
||||
// Select All selected. Capture before the Type tool deactivates (which commits typing and
|
||||
// clears its editing state).
|
||||
let is_type = |id: &str| matches!(id, "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath");
|
||||
let text_selection = ((id == "eyedropper" && is_type(self.tool.id())) || (self.tool.id() == "eyedropper" && is_type(id)))
|
||||
.then(|| self.tool.options())
|
||||
|
||||
@@ -3219,6 +3219,9 @@ proptest! {
|
||||
fidelity in prop::sample::select(vec!["50", "0", "100", "101", "-1", "1e308", "nan"]),
|
||||
size in prop::sample::select(vec!["[1 1]", "[3 2]", "[0 0]", "[51 1]", "[100000 100000]", "[-1 2]", "[1e308 1]"]),
|
||||
corner in prop::sample::select(vec!["10 10", "1e308 -1e308", "nan 0", "10 10 10 10", "0"]),
|
||||
art_width in prop::sample::select(vec!["81", "80", "0", "-5", "1e308", "nan"]),
|
||||
art_height in prop::sample::select(vec!["81", "80", "0", "-5", "1e308", "nan"]),
|
||||
weight in prop::sample::select(vec!["1", "0", "1e308", "-1"]),
|
||||
edit in 0i64..2,
|
||||
cut in 0usize..2_000,
|
||||
edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['0', '9', '-', ' ', '\n', '(', ')', '/', ':', ';', ',', '[', ']', '%', '_', 'X', 'u', 'U'])), 0..8),
|
||||
@@ -3231,9 +3234,9 @@ proptest! {
|
||||
n("10", "90"),
|
||||
n("90", "90")
|
||||
);
|
||||
let content = "%_u\n%_1 0 0 1 0 0 Xa\n%_10 10 m\n%_10 90 L\n%_90 90 L\n%_90 10 L\n%_f\n%_U\n";
|
||||
let content = format!("%_u\n%_1 0 0 1 0 0 Xa\n%_0 1 1 0 1 0 0 XA\n%_{weight} w\n%_10 10 m\n%_10 90 L\n%_90 90 L\n%_90 10 L\n%_b\n%_U\n");
|
||||
let keys = format!(
|
||||
"%_{word} /Int (AIDeformOptionsKey) ,\n%_{fidelity} /Int (AIDeformFidelityKey) ,\n%_{edit} /Bool (AIDeformEditModeKey) ,\n%_{style} /Int (DeformStyle) ,\n%_0.5 /Real (DeformValue) ,\n%_1 /Bool (Rotate) ,\n"
|
||||
"%_{word} /Int (AIDeformOptionsKey) ,\n%_{fidelity} /Int (AIDeformFidelityKey) ,\n%_{edit} /Bool (AIDeformEditModeKey) ,\n%_{style} /Int (DeformStyle) ,\n%_0.5 /Real (DeformValue) ,\n%_1 /Bool (Rotate) ,\n%_{art_width} /Real (AIDeformArtWidthKey) ,\n%_{art_height} /Real (AIDeformArtHeightKey) ,\n"
|
||||
);
|
||||
let art = format!(
|
||||
"%_u\n{mesh}%_U\n%_/ArtDictionary :\n{keys}%_X=\n{content}%_X+\n%_ (AIDeformArtKey) ,\n%_{kind} /Int (AIDeformStyle) ,\n%_;\n%_\nu\n1 0 0 1 0 0 Xa\n12 12 m\n12 88 L\n88 88 L\nf\nU\n(Adobe Deform Plugin) 1 0 0 XP\n0 0 1 1 0 0 Xa\n0 0 m\n5 5 L\nf\n"
|
||||
@@ -3410,6 +3413,54 @@ proptest! {
|
||||
survive("hostile .ai images and placed files", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// A `.ai` whose type (point type turned, vertical type, area type, threaded area type and type
|
||||
/// on a path, their stubs with the frames' paths, and the text document they name, as the `.ai`
|
||||
/// writer writes them) is damaged: read from its editing data alone and beside a page.
|
||||
#[test]
|
||||
fn ai_text_documents_never_panic(
|
||||
cut in 0usize..30_000,
|
||||
edits in prop::collection::vec((0usize..30_000, prop::sample::select(vec!['0', '1', '9', ' ', '\n', '(', ')', '[', ']', '<', '>', '/', '%', '_', 'n', 'f', 'z', '~'])), 0..16),
|
||||
) {
|
||||
use vectorcraft_doc::{CharStyle, Node, NodeKind, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut point = TextObject::point(Point::new(10.0, 30.0), "Turned\ntype", CharStyle::default());
|
||||
point.xf *= Affine::rotate(0.4);
|
||||
let mut area = TextObject::point(Point::ZERO, "Area type", CharStyle::default());
|
||||
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(20.0, 40.0, 90.0, 90.0)) };
|
||||
area.xf = Affine::IDENTITY;
|
||||
let mut on_path = TextObject::point(Point::ZERO, "On a path", CharStyle::default());
|
||||
on_path.kind = TextKind::OnPath { path: shapes::ellipse(Rect::new(100.0, 20.0, 180.0, 80.0)), start: 0.25, end: None };
|
||||
on_path.xf = Affine::IDENTITY;
|
||||
let mut down = TextObject::point(Point::new(190.0, 10.0), "Down", CharStyle::default());
|
||||
down.vertical = true;
|
||||
let mut down_area = area.clone();
|
||||
down_area.vertical = true;
|
||||
down_area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(150.0, 40.0, 180.0, 90.0)) };
|
||||
let mut first = area.clone();
|
||||
first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(20.0, 5.0, 50.0, 35.0)) };
|
||||
first.runs = vec![vectorcraft_doc::TextRun::new("A story through two frames", CharStyle::default())];
|
||||
let mut second = first.clone();
|
||||
second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(60.0, 5.0, 90.0, 35.0)) };
|
||||
second.runs = vec![vectorcraft_doc::TextRun::new("", CharStyle::default())];
|
||||
for t in [point, area, on_path, down, down_area] {
|
||||
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let (first, second) = (Node::new(doc.alloc_id(), NodeKind::Text(Box::new(first))), Node::new(doc.alloc_id(), NodeKind::Text(Box::new(second))));
|
||||
doc.text_threads.push(vec![first.id, second.id]);
|
||||
doc.insert(Some(layer), usize::MAX, first).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, second).unwrap();
|
||||
let program = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap().program;
|
||||
let written = String::from_utf8_lossy(&program);
|
||||
prop_assert_eq!(written.matches("/AI11Text :").count(), 7, "every text object is written as text");
|
||||
let text = mutate_text(&written, cut, &edits);
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
|
||||
survive("hostile .ai type", || vectorcraft_eps::ai_alone(&private).ok().map(|r| r.0))?;
|
||||
survive("hostile .ai type beside a page", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// A `.ai` whose variable library is damaged or hostile: the library is counted for the warning
|
||||
/// whether the layers are read or not (an inserted `Z` can make an operator the reader doesn't
|
||||
/// handle, and then the layers aren't read).
|
||||
@@ -3584,6 +3635,15 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.ai` whose Art brushed path is damaged or hostile: the brush's object and art, the style's
|
||||
/// entries and the path, cut short or edited, read and drawn.
|
||||
#[test]
|
||||
fn ai_art_brushed_paths_never_panic(cut in 0usize..1_600, edits in prop::collection::vec((0usize..1_600, prop::sample::select(vec!['0', '9', '-', '.', 'e', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', 'u', 'U', 'A', 'Z'])), 0..12)) {
|
||||
let text = mutate_text(&art_brushed_text(), cut, &edits);
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
|
||||
survive("mutated .ai Art brushed path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// Brush objects of every kind with any parameters, the brush manager's order with any names,
|
||||
/// and brush art sections nested or cut short: read with the layers, or as a library without
|
||||
/// them, the brushes kept within their ranges.
|
||||
@@ -3633,8 +3693,8 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// Editing data with two symbols (S2's art holds an instance of S1), the symbol list and a layer of
|
||||
/// instances, as the format writes them.
|
||||
/// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the
|
||||
/// symbol list and a layer of instances, as the format writes them.
|
||||
fn symbols_text() -> String {
|
||||
use vectorcraft_testkit::ai;
|
||||
let def = |name: &str, art: &str, reg: u32| {
|
||||
@@ -3643,7 +3703,12 @@ fn symbols_text() -> String {
|
||||
)
|
||||
};
|
||||
let inst = |name: &str, m: &str| format!("/SymbolInstance :\n({name}) /SymbolRef ,\n{m} /RTransformMatrix\n;\n9 () XW\n");
|
||||
let defs = def("S1", "0 1 1 0 1 0 0 Xa\n-2 -2 m\n2 -2 L\n2 2 L\n-2 2 L\nf\n", 5) + &def("S2", &inst("S1", "1 0 0 -1 -8081 8237"), 1);
|
||||
// Non-native art keeps ASCII85 data in comment lines, some of them `%_` lines holding a `(`.
|
||||
let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n\
|
||||
%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@<P2`V<S,pbuU7L]\\~>\n;\n";
|
||||
let defs = def("F", &format!("{foreign}-2 -2 m\n2 -2 L\n2 2 L\nf\n"), 5)
|
||||
+ &def("S1", "0 1 1 0 1 0 0 Xa\n-2 -2 m\n2 -2 L\n2 2 L\n-2 2 L\nf\n", 5)
|
||||
+ &def("S2", &inst("S1", "1 0 0 -1 -8081 8237"), 1);
|
||||
let list = "%AI24_BeginSymbolList\n(S2)\n(S1)\n%AI24_EndSymbolList\n";
|
||||
let page = ai::layer(
|
||||
"L",
|
||||
@@ -3668,7 +3733,7 @@ fn the_symbols_fuzz_data_opens_live() {
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&symbols_text())].concat();
|
||||
let (doc, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0);
|
||||
assert!(layered, "{warnings:?}");
|
||||
let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect();
|
||||
let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).filter(|n| *n != "F").collect();
|
||||
assert_eq!(names, ["S2", "S1"]);
|
||||
let mut instances = 0;
|
||||
doc.walk(|n| instances += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })));
|
||||
@@ -3742,6 +3807,36 @@ fn the_objects_on_path_fuzz_data_opens_live() {
|
||||
assert!(g.extras().objects_on_path.is_some(), "{g:?}");
|
||||
}
|
||||
|
||||
/// Editing data with an Art brush, its art, and a path with its drawn look and the brush in its art
|
||||
/// style, as the format writes them.
|
||||
fn art_brushed_text() -> String {
|
||||
use vectorcraft_testkit::ai;
|
||||
let tool = "Adobe ArtOnPath Brush Tool";
|
||||
let object = format!(
|
||||
"%AI8_BeginPluginObject\n({tool})\n(R)\n(2 / A/ / / / / 0 0/ 5 0 0 0 / 1) -\n( 1 1 0 1 0 0 0 1 1 0 0 0 0 1) .\n%AI8_EndPluginObject\n"
|
||||
);
|
||||
let art = "%AI8_BeginBrushPattern\n(A)\n0 A\n0 g\n0 0 m\n40 0 L\n40 8 L\nf\n%AI8_EndBrushPattern\n";
|
||||
let style = format!(
|
||||
"%AI9_BeginArtStyles\n/KnownStyle :\n(S) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n({tool}) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
|
||||
0 /Bool (Proportional) ,\n0 /Bool (Segmented) ,\n1 /Real (Scaling) ,\n1 /Real (MinScaling) ,\n1 /Real (MaxScaling) ,\n1 /Int (Adjustment) ,\n1 /Bool (FoldAdjustment) ,\n1 /Int (Colorization) ,\n\
|
||||
/StrokeStyle : 0 R\n0 0 0 1 K\n0 1 0 0 0 Xy\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n/GObjRef : (R) ({tool}) /PluginObject ; (Brush) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n"
|
||||
);
|
||||
let path = "0 Ae\nu\n0 g\n10 10 m\n90 10 L\n90 30 L\nf\nU\n0 0 Xd\n6 () XW\n%_10 20 m\n%_90 20 L\n%_90 80 L\n%_n\n1 (S) XW\n";
|
||||
ai::editing_data(100.0, 100.0, &format!("{object}{art}{style}{}", ai::layer("L", path)))
|
||||
}
|
||||
|
||||
/// The data [`ai_art_brushed_paths_never_panic`] damages, as it is, opens with its path live and the
|
||||
/// brush on its stroke.
|
||||
#[test]
|
||||
fn the_art_brushed_fuzz_data_opens_live() {
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&art_brushed_text())].concat();
|
||||
// Trusted: the empty page it is read beside isn't compared with its art.
|
||||
let (doc, warnings, _) = vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0);
|
||||
let n = doc.layers.first().and_then(|l| l.children()).and_then(|c| c.first()).unwrap_or_else(|| panic!("{warnings:?}"));
|
||||
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("R"), "{warnings:?} {n:?}");
|
||||
}
|
||||
|
||||
/// The ids of the objects of an Objects on Path group in the fuzz cases.
|
||||
const ON_PATH_IDS: [&str; 5] = ["f-0001", "f-0002", "f-0003", "f-0004", "f-0005"];
|
||||
|
||||
@@ -4165,6 +4260,7 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\n",
|
||||
"/SymbolInstance :",
|
||||
"/Binary : /ASCII85Decode ,",
|
||||
"/AI11TextDocument : /ASCII85Decode ,",
|
||||
"~>",
|
||||
"p",
|
||||
"P",
|
||||
@@ -4219,6 +4315,14 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"/x 100 200 TW",
|
||||
"1 Tq",
|
||||
"7 Tq",
|
||||
"1 TV",
|
||||
"7 TV",
|
||||
"1 Tv",
|
||||
"/x Tv",
|
||||
"1 Ty",
|
||||
"1e308 Ty",
|
||||
"1 TY",
|
||||
"(x) TY",
|
||||
"1 1 1e308 -1e308 4e9 Th",
|
||||
"1 1 (2) 3 4 Th",
|
||||
"36 2 Xb",
|
||||
@@ -4564,11 +4668,15 @@ proptest! {
|
||||
|
||||
/// A group with an Effect › Pathfinder style whose dictionary is hostile: any `Command`, keys of
|
||||
/// any type and value (missing, repeated, unknown, not finite), shown or hidden, the style cut
|
||||
/// short anywhere; in editing data, an EPS and a `.ai`. A `Command` other than 0 never becomes
|
||||
/// a live effect.
|
||||
/// short anywhere; in editing data, an EPS and a `.ai`. The dictionary may also start with every
|
||||
/// option at the value read and any `Mix`. A `Command` other than 0 to 11 never becomes a live
|
||||
/// effect, and a live Soft Mix's Mixing Rate is a number from 1 to 100.
|
||||
#[test]
|
||||
fn ai_pathfinder_styles_never_panic(
|
||||
command in prop_oneof![(-3i64..16).prop_map(|c| c.to_string()), arb_num()],
|
||||
command in prop_oneof![(-3i64..16).prop_map(|c| c.to_string()), Just("11".to_string()), arb_num()],
|
||||
defaults in any::<bool>(),
|
||||
bare in any::<bool>(),
|
||||
mix in prop_oneof![arb_num(), prop::sample::select(vec!["0.5", "0.3", "0.01", "0.0099", "1", "1.0001", "NaN", "1e999"]).prop_map(str::to_string)],
|
||||
keys in prop::collection::vec(
|
||||
(
|
||||
prop::sample::select(vec!["Mix", "Precision", "RemovePoints", "ExtractUnpainted", "ConvertCustom", "TrapTint", "TrapThickness", "Command", "Extra"]),
|
||||
@@ -4581,7 +4689,19 @@ proptest! {
|
||||
cut in 0usize..4_000,
|
||||
) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let options = if defaults {
|
||||
format!(
|
||||
"1 /Bool (ConvertCustom) ,\n1 /Bool (ExtractUnpainted) ,\n{mix} /Real (Mix) ,\n10 /Real (Precision) ,\n0 /Bool (RemovePoints) ,\n\
|
||||
1 /Real (TrapAspect) ,\n0 /Bool (TrapConvertCustom) ,\n1 /Real (TrapMaxTint) ,\n0 /Bool (TrapReverse) ,\n0.25 /Real (TrapThickness) ,\n\
|
||||
0.4 /Real (TrapTint) ,\n0.05 /Real (TrapTintTolerance) ,\n"
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
// With every option there, the random keys are left out half the time, so the style can be read.
|
||||
let keys = if defaults && bare { &keys[..0] } else { &keys[..] };
|
||||
let dict: String = keys.iter().map(|(k, kind, v)| format!("{v} /{kind} ({k}) ,\n")).collect();
|
||||
let dict = format!("{options}{dict}");
|
||||
let styles = format!(
|
||||
"%AI9_BeginArtStyles\n/KnownStyle :\n(PF) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Adobe Pathfinder) 1 0 /Filter ,\n{} /Visible ,\n/Dictionary : /NotRecorded ,\n{command} /Int (Command) ,\n{dict}; /Dict ;\n /Part ,\n\
|
||||
@@ -4593,12 +4713,17 @@ proptest! {
|
||||
let group = "0 Ae\nu\n1 0 0 1 0 0 Xa\n10 10 m\n10 60 L\n80 60 L\n80 10 L\nf\nU\n\
|
||||
%_0 Ae\n%_u\n%_1 0 0 1 0 0 Xa\n%_10 10 m\n%_10 50 L\n%_50 50 L\n%_50 10 L\n%_f\n%_0 0 1 0 0 0 1 Xa\n%_40 20 m\n%_40 60 L\n%_80 60 L\n%_80 20 L\n%_f\n%_U\n1 (PF) XW\n";
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", group)));
|
||||
let live = command.parse::<f64>().is_ok_and(|c| c == 0.0);
|
||||
let live = command.parse::<f64>().is_ok_and(|c| (0.0..=11.0).contains(&c) && c.fract() == 0.0);
|
||||
let check = |d: Option<Document>| {
|
||||
if let Some(d) = &d
|
||||
&& !live
|
||||
{
|
||||
d.walk(|n| assert!(!n.appearance.effects.iter().any(|e| e.id.starts_with("pathfinder.")), "Command {command}: {:?}", n.appearance.effects));
|
||||
if let Some(d) = &d {
|
||||
d.walk(|n| {
|
||||
let effects = &n.appearance.effects;
|
||||
assert!(live || !effects.iter().any(|e| e.id.starts_with("pathfinder.")), "Command {command}: {effects:?}");
|
||||
for e in effects.iter().filter(|e| e.id == "pathfinder.softMix") {
|
||||
let rate = e.params.get("mixingRate").and_then(Value::as_f64);
|
||||
assert!(rate.is_some_and(|r| (1.0..=100.0).contains(&r)), "Mix {mix}: {effects:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
d
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! A document of type opened with the shaping plans font faces keep, compared with the same file
|
||||
//! opened while every shaped segment makes a plan of its own: the nodes and the first frame's
|
||||
//! pixels are the same. Its own test binary: the switch for kept plans applies to the whole process.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::{Mutex, MutexGuard, Once};
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject, TextRun};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
use vectorcraft_render::{RenderOptions, Renderer};
|
||||
use vectorcraft_text::FontDb;
|
||||
use vectorcraft_text::test_fonts::{VARIABLE_FAMILY, keep_shape_plans, variable_font};
|
||||
|
||||
const WIDTH: u32 = 900;
|
||||
const HEIGHT: u32 = 1300;
|
||||
|
||||
/// Taken by each test: the switch for kept plans applies to the whole process.
|
||||
static SWITCH: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn switch() -> MutexGuard<'static, ()> {
|
||||
SWITCH.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn style() -> CharStyle {
|
||||
CharStyle { size: 60.0, ..CharStyle::default() }
|
||||
}
|
||||
|
||||
/// A saved document of type objects whose shaping differs in script, direction, language, features
|
||||
/// and variable font instance, the object in the plain style first.
|
||||
fn file() -> Vec<u8> {
|
||||
static ADD_FONT: Once = Once::new();
|
||||
ADD_FONT.call_once(|| {
|
||||
FontDb::global().add_font(variable_font().unwrap());
|
||||
});
|
||||
let manual = CharStyle { kerning: Some(0.0), ..style() };
|
||||
let variable = |name: &str| CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: name.into(), ..style() };
|
||||
let mut mixed = TextObject::point(Point::ZERO, "AVAV", style());
|
||||
mixed.runs = vec![TextRun::new("AV", style()), TextRun::new("AV", manual.clone())];
|
||||
let mut vertical = TextObject::point(Point::ZERO, "AB\u{65e5}\u{672c}", style());
|
||||
vertical.vertical = true;
|
||||
let objects = [
|
||||
TextObject::point(Point::ZERO, "AVATAR office", style()),
|
||||
TextObject::point(Point::ZERO, "To Wa fi", style()),
|
||||
TextObject::point(Point::ZERO, "123 ... 456", style()),
|
||||
TextObject::point(Point::ZERO, "AVAV", manual),
|
||||
mixed,
|
||||
TextObject::point(Point::ZERO, "fi office", CharStyle { language: Some("tr".into()), ..style() }),
|
||||
TextObject::point(Point::ZERO, "AVAV", CharStyle { optical_kerning: true, ..style() }),
|
||||
TextObject::point(Point::ZERO, "Small Caps", CharStyle { small_caps: Some(70.0), ..style() }),
|
||||
TextObject::point(Point::ZERO, "\u{645}\u{631}\u{62d}\u{628}\u{627}", style()),
|
||||
TextObject::point(Point::ZERO, "\u{5e9}\u{5dc}\u{5d5}\u{5dd}", style()),
|
||||
TextObject::point(Point::ZERO, "lIn lIn", variable("Regular")),
|
||||
TextObject::point(Point::ZERO, "lIn lIn", variable("Bold")),
|
||||
vertical,
|
||||
];
|
||||
let mut doc = Document::new(WIDTH as f64, HEIGHT as f64);
|
||||
let layer = doc.layers[0].id;
|
||||
let count = objects.len();
|
||||
for (i, mut t) in objects.into_iter().enumerate() {
|
||||
// Rows down the page; the vertical object, last, stands in the right margin.
|
||||
t.xf = Affine::translate(if i + 1 == count { (800.0, 60.0) } else { (20.0, 90.0 * (i + 1) as f64) });
|
||||
let id = doc.alloc_id();
|
||||
doc.insert(Some(layer), usize::MAX, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
|
||||
}
|
||||
vectorcraft_format::save_file(&doc)
|
||||
}
|
||||
|
||||
/// The session `bytes` opened in, as `document.open` reads a file and then opens it.
|
||||
fn open(bytes: &[u8]) -> Session {
|
||||
let name = "plans.vectorcraft";
|
||||
let mut s = Session::new();
|
||||
let read = fileio::read_for_open(name, bytes, Some(name.into()), &Value::Null, false, false).unwrap();
|
||||
fileio::open_read(&mut s, read).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn first_frame(doc: &Document) -> Vec<u8> {
|
||||
Renderer::new().render(doc, WIDTH, HEIGHT, Affine::IDENTITY, &RenderOptions::default()).pixels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opened_document_has_the_nodes_of_one_laid_out_with_plans_of_their_own() {
|
||||
let _switch = switch();
|
||||
let bytes = file();
|
||||
keep_shape_plans(false);
|
||||
let own = open(&bytes);
|
||||
keep_shape_plans(true);
|
||||
// The second open shapes every object with plans a face kept.
|
||||
open(&bytes);
|
||||
let kept = open(&bytes);
|
||||
let (own, kept) = (own.doc().unwrap(), kept.doc().unwrap());
|
||||
let mut laid_out = 0;
|
||||
kept.doc.walk(|node| {
|
||||
if let NodeKind::Text(t) = &node.kind {
|
||||
assert!(t.cached_bounds.is_some(), "{:?}", t.plain_text());
|
||||
laid_out += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(laid_out, 13);
|
||||
assert_eq!(kept.doc.layers, own.doc.layers);
|
||||
assert!(*kept.doc == *own.doc, "the whole document is the same");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opened_document_draws_the_first_frame_of_one_shaped_with_plans_of_its_own() {
|
||||
let _switch = switch();
|
||||
let bytes = file();
|
||||
let opened = open(&bytes);
|
||||
let doc = &opened.doc().unwrap().doc;
|
||||
keep_shape_plans(false);
|
||||
let own = first_frame(doc);
|
||||
keep_shape_plans(true);
|
||||
// Lays out each object with plans a face kept: the first frame lays type out again.
|
||||
first_frame(doc);
|
||||
let kept = first_frame(doc);
|
||||
assert_ne!(kept, first_frame(&Document::new(WIDTH as f64, HEIGHT as f64)), "the frame shows the type");
|
||||
let differing = kept.iter().zip(&own).filter(|(a, b)| a != b).count();
|
||||
assert_eq!((kept.len(), differing), (own.len(), 0), "bytes of the frame that differ");
|
||||
}
|
||||
@@ -78,12 +78,23 @@
|
||||
//! (`w h rowBytes 32 17 `, then rows of premultiplied ARGB), each in versioned content, `~`,
|
||||
//! `%AI5_EndPlace`. The box is the file's pixels at art space's origin on the canvas; the matrix
|
||||
//! takes it into art space. In opacity masks and pattern tiles, linked images are embedded.
|
||||
//! - Type: a text object `/AI11Text :` naming its story and frame (`/StoryIndex`, `/FrameIndex`),
|
||||
//! for area type and type on a path with its frame's path (`/Art :` … `; /ConfiningPath ,`), and
|
||||
//! the text document after the document data (`%AI11_BeginTextDocument`, see
|
||||
//! `writer::texts`), in the form the reader's `ate.rs` reads: the characters, the fonts, sizes,
|
||||
//! leading, tracking, baseline shift, scales, solid fills and strokes and capitals of each run,
|
||||
//! the alignment, indents, spacing, hyphenation and direction of each paragraph, and the frame:
|
||||
//! point type's point and matrix, area type's frame, rows, columns, gutters and first baseline,
|
||||
//! type on a path's path and span, vertical type, and the frames a threaded story flows through.
|
||||
//!
|
||||
//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at
|
||||
//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the
|
||||
//! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and
|
||||
//! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such
|
||||
//! loss a warning: type as its outlines and its inline graphics' art, freeform gradients, gradients
|
||||
//! loss a warning: type the text document can't hold (in opacity masks, pattern tiles and art cut to
|
||||
//! a clipping object, mirrored, turned or unevenly scaled area type and type on a path, inline graphics, ruby,
|
||||
//! gradient or pattern paint, an appearance of its own) as its outlines and its inline graphics'
|
||||
//! art, the character and paragraph attributes the text document doesn't hold, freeform gradients, gradients
|
||||
//! along or across a stroke and gradients without stops, with more than 256 stops or without extent
|
||||
//! as their average color
|
||||
//! (gradients that dither or mix perceptually are written as classic ones, and gradient stops linked
|
||||
@@ -225,6 +236,9 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
|
||||
w.definitions(&mut s);
|
||||
s.push_str(&palette);
|
||||
artboards(&mut s, doc, to_art, origin);
|
||||
if !w.texts.is_empty() {
|
||||
s.push_str(&w.texts.section(doc));
|
||||
}
|
||||
s.push_str("%%EndSetup\n");
|
||||
s.push_str(&w.body);
|
||||
s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n");
|
||||
@@ -431,10 +445,12 @@ fn ruler_units(u: Unit) -> u8 {
|
||||
/// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`), its top-left corner on
|
||||
/// the canvas (`RulerOrigin`, `origin` being art space's origin there), its pixel aspect ratio
|
||||
/// (`PAR`), `DisplayMark` 0 (as in the files users sent), whether it is the selected one (the
|
||||
/// first) and its name; then the canvas's size. Every record holds all of these entries. The
|
||||
/// artboards and the canvas size are written in the `/Recorded` part of the `/Document`
|
||||
/// dictionary, followed by an empty `/NotRecorded` part.
|
||||
/// active one) and its name; then the index of the active artboard (`CropAreaActive`: the one
|
||||
/// [`Document::opened_artboard`] names, else the first) and the canvas's size. Every record holds
|
||||
/// all of these entries. The artboards, the active artboard and the canvas size are written in the
|
||||
/// `/Recorded` part of the `/Document` dictionary, followed by an empty `/NotRecorded` part.
|
||||
fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) {
|
||||
let active = doc.opened_artboard.and_then(|id| doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0);
|
||||
s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n");
|
||||
for (i, ab) in doc.artboards.iter().enumerate() {
|
||||
let r = to_art.transform_rect_bbox(ab.rect);
|
||||
@@ -449,10 +465,11 @@ fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64))
|
||||
ps::push_num(s, if par.is_finite() && par > 0.0 { par } else { 1.0 });
|
||||
// Display marks: 1 the center mark, 2 cross hairs, 4 video safe areas.
|
||||
let mark = u8::from(ab.show_center_mark) | u8::from(ab.show_cross_hairs) << 1 | u8::from(ab.show_video_safe_areas) << 2;
|
||||
let _ = writeln!(s, " /Real (PAR) ,\n%_{mark} /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == 0));
|
||||
let _ = writeln!(s, " /Real (PAR) ,\n%_{mark} /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == active));
|
||||
let _ = writeln!(s, "%_{} /UnicodeString (Name) ,\n%_; ,", ps::bytes(ab.name.as_bytes()));
|
||||
}
|
||||
s.push_str("%_; (ArtboardArray) ,\n");
|
||||
let _ = writeln!(s, "%_{active} /Int (CropAreaActive) ,");
|
||||
let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,");
|
||||
s.push_str("%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n");
|
||||
}
|
||||
|
||||
+1132
-44
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
use vectorcraft_color::cms::Cms;
|
||||
use vectorcraft_color::swatch::{REGISTRATION, Swatch};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeKind, OpacityMask, StrokeLayer};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
|
||||
use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath};
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
@@ -17,19 +17,22 @@ use crate::ps;
|
||||
|
||||
mod images;
|
||||
mod paints;
|
||||
mod texts;
|
||||
|
||||
pub(super) use images::PAYLOAD;
|
||||
#[cfg(test)]
|
||||
pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED};
|
||||
#[cfg(test)]
|
||||
pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name};
|
||||
#[cfg(test)]
|
||||
pub(super) use texts::TYPE_ATTRIBUTES;
|
||||
|
||||
/// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left
|
||||
/// out). Groups and layers need no limit of their own: a document is never deeper than
|
||||
/// [`vectorcraft_doc::MAX_DEPTH`].
|
||||
const MAX_NEST: u32 = 8;
|
||||
|
||||
pub(super) const TYPE: &str = "type is written as its outlines in the editing data for other apps";
|
||||
pub(super) const TYPE: &str = "type in opacity masks, pattern tiles and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps";
|
||||
pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps";
|
||||
pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fill places are written filled gray in the editing data for other apps";
|
||||
pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose clipping object isn't a path, and the clipping groups inside them, are written as their art cut to that object in the editing data for other apps";
|
||||
@@ -157,6 +160,8 @@ pub(super) struct Writer<'a> {
|
||||
/// How many parts of the data written on `%_` lines (opacity masks' art, patterns' tiles) the
|
||||
/// art being written is in.
|
||||
hidden: u32,
|
||||
/// The text document: the stories of the type written as text.
|
||||
pub(super) texts: texts::TextDoc,
|
||||
}
|
||||
|
||||
impl<'a> Writer<'a> {
|
||||
@@ -176,6 +181,7 @@ impl<'a> Writer<'a> {
|
||||
canvas_origin: origin,
|
||||
payloads: vec![],
|
||||
hidden: 0,
|
||||
texts: texts::TextDoc::default(),
|
||||
cms: vectorcraft_color::cms::active(),
|
||||
body: String::new(),
|
||||
warnings: vec![],
|
||||
@@ -332,6 +338,16 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::set_xy`] for an object that writes no paint before its transparency (type, an image,
|
||||
/// a guide): `XR` comes first, as in [`Self::paint_state`], because an `Xy` right after a compound
|
||||
/// path or a text object would be theirs.
|
||||
fn set_object_xy(&mut self, xy: Xy) {
|
||||
if self.gs != Some(xy) {
|
||||
self.line("0 XR");
|
||||
self.write_xy(xy);
|
||||
}
|
||||
}
|
||||
|
||||
/// `mode opacity isolate knockout shape Xy`.
|
||||
fn write_xy(&mut self, xy: Xy) {
|
||||
let knockout = match xy.knockout {
|
||||
@@ -350,6 +366,11 @@ impl<'a> Writer<'a> {
|
||||
/// dictionary goes between the two ([`Self::node`]).
|
||||
fn end_group(&mut self, close: &str, xy: Xy) {
|
||||
self.line(close);
|
||||
self.state_block(xy);
|
||||
}
|
||||
|
||||
/// The state block of the group or text object just written ([`Self::end_group`]).
|
||||
fn state_block(&mut self, xy: Xy) {
|
||||
self.block_start = self.body.len();
|
||||
if xy == Xy::PLAIN {
|
||||
self.line("9 () XW");
|
||||
@@ -391,7 +412,7 @@ impl<'a> Writer<'a> {
|
||||
if *guide {
|
||||
// Its own transparency, not the one the object before it set.
|
||||
if path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) {
|
||||
self.set_xy(xy.object());
|
||||
self.set_object_xy(xy.object());
|
||||
}
|
||||
ai_ops::guide(&mut self.body, self.to_art, path, true);
|
||||
} else {
|
||||
@@ -522,8 +543,19 @@ impl<'a> Writer<'a> {
|
||||
let outer = std::mem::take(&mut self.body);
|
||||
let (state, clip) = (self.state, self.clip.take());
|
||||
(self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE));
|
||||
// A plain group holding the art is the group the art is read into.
|
||||
let art = &m.art;
|
||||
// The canvas draws the art's root even when it is hidden, and the reader puts a hidden object
|
||||
// in a group that doesn't draw it, so a hidden root is written shown.
|
||||
let shown;
|
||||
let art = if m.art.visible {
|
||||
&m.art
|
||||
} else {
|
||||
let mut a = (*m.art).clone();
|
||||
a.visible = true;
|
||||
shown = Arc::new(a);
|
||||
&shown
|
||||
};
|
||||
// A plain group of two or more objects, or of none, is the group the art is read into. A group
|
||||
// of one object is written as a group, because the reader makes one shown object the art.
|
||||
let plain = matches!(art.kind, NodeKind::Group { clip: false, .. })
|
||||
&& Xy::of(art) == Xy::PLAIN
|
||||
&& art.visible
|
||||
@@ -531,7 +563,7 @@ impl<'a> Writer<'a> {
|
||||
&& art.name.as_deref().is_none_or(str::is_empty)
|
||||
&& art.extras().mask.is_none();
|
||||
let members = match art.children() {
|
||||
Some(c) if plain => c.as_slice(),
|
||||
Some(c) if plain && c.len() != 1 => c.as_slice(),
|
||||
_ => std::slice::from_ref(art),
|
||||
};
|
||||
self.nest += 1;
|
||||
@@ -830,7 +862,7 @@ impl<'a> Writer<'a> {
|
||||
|
||||
/// The paint of a path of transparency `xy` filled by `rule`: its fill color and `O`, its stroke
|
||||
/// color and `R`, `Xy`, the line style, `XR`. Without paint, `XR` comes first: an `Xy` right
|
||||
/// after a group or compound path would be theirs.
|
||||
/// after a compound path or a text object would be theirs.
|
||||
fn paint_state(&mut self, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>, xy: Xy, rule: FillRule) {
|
||||
let rule = if rule == FillRule::EvenOdd { "1 XR" } else { "0 XR" };
|
||||
if let Some(f) = fill {
|
||||
@@ -880,12 +912,43 @@ impl<'a> Writer<'a> {
|
||||
|
||||
// ---------- type ----------
|
||||
|
||||
/// Type `n`: a text object stub naming its story in the text document ([`texts`]) when the
|
||||
/// text document can hold it, outside opacity masks, pattern tiles and art cut to a clipping
|
||||
/// object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type names the
|
||||
/// story of its thread, which its first frame holds.
|
||||
fn text(&mut self, n: &Node, xy: Xy) {
|
||||
let NodeKind::Text(t) = &n.kind else { return };
|
||||
let doc = self.doc;
|
||||
let placed = if self.hidden > 0 || self.clip.is_some() {
|
||||
None
|
||||
} else if let Some(thread) = doc.text_threads.iter().find(|th| th.contains(&n.id)) {
|
||||
// A thread is checked for what it loses, in every frame, when its story is written.
|
||||
let placed = self.texts.thread(doc, thread, n.id, self.canvas, &|id| !cut_to_clipping_object(doc, id));
|
||||
placed.map(|p| (p, self.texts.take_lost()))
|
||||
} else if texts::holds(n, t) {
|
||||
self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(((story, frame), lost)) = placed else { return self.outlined_text(n, xy) };
|
||||
if lost {
|
||||
self.warn(texts::TYPE_ATTRIBUTES);
|
||||
}
|
||||
// The stub takes the transparency set before it, as a path does. Its isolation and knockout
|
||||
// are read only from a state block after it, as a group's are.
|
||||
self.set_object_xy(xy.object());
|
||||
texts::stub(&mut self.body, story, frame, t, self.to_art);
|
||||
if xy.isolate || xy.knockout != Knockout::Neutral {
|
||||
self.state_block(xy);
|
||||
}
|
||||
}
|
||||
|
||||
/// Type `n` as a group of what Type › Create Outlines makes of it
|
||||
/// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and
|
||||
/// strikethrough, its color glyphs, and the object's own fills and strokes; then its inline
|
||||
/// graphics as their symbols' art where the layout places them, as the PDF page draws them;
|
||||
/// the group has the type's transparency `xy`.
|
||||
fn text(&mut self, n: &Node, xy: Xy) {
|
||||
fn outlined_text(&mut self, n: &Node, xy: Xy) {
|
||||
self.warn(TYPE);
|
||||
self.line("u");
|
||||
let doc = self.doc;
|
||||
@@ -1150,6 +1213,18 @@ fn compound_path(n: &Node) -> PathData {
|
||||
PathData::new(subpaths)
|
||||
}
|
||||
|
||||
/// Is object `id` of `doc` in (or inside) a clipping group or clipping layer whose clipping object
|
||||
/// isn't a path or compound path (its art is written cut to that object, [`Writer::clip_group`])?
|
||||
/// Type that is a clipping object is one of these: it isn't a path.
|
||||
fn cut_to_clipping_object(doc: &Document, id: NodeId) -> bool {
|
||||
let ancestry = doc.ancestry(id).unwrap_or_default();
|
||||
let above = ancestry.split_last().map(|(_, a)| a).unwrap_or_default();
|
||||
above.iter().filter_map(|a| doc.node(*a)).any(|p| {
|
||||
matches!(p.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })
|
||||
&& p.children().and_then(|c| c.first()).is_none_or(|c| clip_path(c).is_none())
|
||||
})
|
||||
}
|
||||
|
||||
/// The outline and fill rule a clipping path or compound path `n` clips by; `None` for other
|
||||
/// clipping objects and for a path without points (it clips everything away, see
|
||||
/// [`Writer::clip_contents`]).
|
||||
|
||||
@@ -115,7 +115,7 @@ impl Writer<'_> {
|
||||
if cut.is_some() {
|
||||
self.line("q");
|
||||
}
|
||||
self.set_xy(xy.object());
|
||||
self.set_object_xy(xy.object());
|
||||
// The object places the image's own pixel grid (its width × height) wherever the file's is.
|
||||
let fit =
|
||||
Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(samples.width), f64::from(im.height.max(1)) / f64::from(samples.height));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,8 +71,9 @@ use interp::{Fault, Interp};
|
||||
use obj::PsError;
|
||||
|
||||
pub(crate) use ai::MAX_GRADIENT_STOPS;
|
||||
pub(crate) use native::scale_document;
|
||||
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES};
|
||||
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from};
|
||||
pub(crate) use native::{MAX_TEXT, scale_document};
|
||||
pub use text::family_style;
|
||||
|
||||
/// The page of a PostScript file without a bounding box (US Letter).
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! and the spine, each object's `/ArtDictionary` naming its part (`ShapeBlendPathType`: `K` a key,
|
||||
//! `S` the spine) and a key's id (`ShapeBlendPathID`); then the steps drawn, a group on plain
|
||||
//! lines; then `(Adobe Path Blends) 1 0 n XP` and `n` bytes as hex on `%` lines. Those bytes
|
||||
//! start `DCBA`.
|
||||
//! start `DCBA`. A blend whose spine was never edited has no spine part: only its keys.
|
||||
//!
|
||||
//! The reader reads the words after `DCBA` (little endian) this way. Word 1 is the spacing: 0 is
|
||||
//! Smooth Color, 1 is Specified Steps and 2 is Specified Distance. For Specified Distance, word 2
|
||||
@@ -28,7 +28,7 @@ use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::blend::{blend_expand, blend_steps};
|
||||
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec};
|
||||
use vectorcraft_doc::{AppearanceItem, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{PathData, Point};
|
||||
use vectorcraft_geom::{PathData, Point, SubPath};
|
||||
|
||||
/// The plugin that writes blends.
|
||||
pub(super) const PLUGIN: &str = "Adobe Path Blends"; // brand-ok: the plugin name the files use
|
||||
@@ -149,7 +149,12 @@ pub(super) fn live_blend(edit: &Node, drawn: &Node, record: &Record, roles: &Rol
|
||||
Arc::new(k)
|
||||
})
|
||||
.collect();
|
||||
let spine = spine?;
|
||||
// A blend whose spine was never edited has none written: its spine is the straight lines
|
||||
// between the keys' centres, in the record's order.
|
||||
let spine = match spine {
|
||||
Some(spine) => spine,
|
||||
None => straight_spine(&keys, ids, roles)?,
|
||||
};
|
||||
let key_anchors = on_spine(&keys, &spine)?;
|
||||
let spacing = match record.spacing {
|
||||
// Specified Steps: the steps between two keys are the drawn objects that aren't keys,
|
||||
@@ -204,6 +209,20 @@ fn on_spine(keys: &[Arc<Node>], spine: &PathData) -> Option<Vec<u32>> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The straight lines between the centres of `keys`, taken in the order of the record's `ids`: a
|
||||
/// blend's spine until it is edited or replaced, as [`vectorcraft_doc::blend::blend_spine`] lays
|
||||
/// out a blend with no spine of its own.
|
||||
fn straight_spine(keys: &[Arc<Node>], ids: &[String], roles: &Roles) -> Option<PathData> {
|
||||
let centres = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let key = keys.iter().find(|k| roles.get(&k.id).and_then(|(_, named)| named.as_ref()) == Some(id))?;
|
||||
centre(key)
|
||||
})
|
||||
.collect::<Option<Vec<Point>>>()?;
|
||||
Some(PathData::single(SubPath::polyline(¢res, false)))
|
||||
}
|
||||
|
||||
fn centre(n: &Node) -> Option<Point> {
|
||||
n.geometric_bounds().map(|b| b.center()).filter(|p| p.x.is_finite() && p.y.is_finite())
|
||||
}
|
||||
|
||||
@@ -19,14 +19,23 @@
|
||||
//! /PluginObject`), and the brush's settings for this path as entries: `Diameter`, `Roundness`,
|
||||
//! `Angle`, their `…Variation` either side and `…DependsOn` codes, and `AngleRelativeTo` (0 the
|
||||
//! page).
|
||||
//! - A path with an Art brush is a stroke in its art style too: the filter
|
||||
//! `(Adobe ArtOnPath Brush Tool)`, its stroke's paint and weight (the weight scales the art across
|
||||
//! the path), the brush by name, and its settings for this path as entries: `Scaling`,
|
||||
//! `MinScaling` and `MaxScaling` (the width as a factor), `Colorization`, `Proportional`,
|
||||
//! `Segmented` with `StartSegmentLength` and `EndSegmentLength`, `UpsideDown`, `Counterclockwise`,
|
||||
//! `ScaleYDependsOn`, `Spacing`, `Adjustment` and `FoldAdjustment` (1 is
|
||||
//! [`vectorcraft_brush::ArtBrush`]'s `overlap: false`).
|
||||
//! - A Scatter, Art or Pattern brush's parameters are fields split by `/`: a number, the art's
|
||||
//! section names (one for Scatter, five for Art and Pattern, empty where there is none, each
|
||||
//! after a space for Art and Pattern), more fields, and last 15 numbers. Brushes whose first
|
||||
//! number is 1, as older files write them, have one field fewer before the numbers and 8 numbers;
|
||||
//! an Art brush is read from its art's name alone, so either form reads.
|
||||
//! an Art brush in that form is read from its art's name alone (its numbers aren't known, so it
|
||||
//! keeps the defaults and its paths their drawn look).
|
||||
//! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the
|
||||
//! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the
|
||||
//! rotation follows the path (14).
|
||||
//! - Art: 1 Tints (2).
|
||||
//! - Pattern: its scale as a factor (3).
|
||||
//! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle
|
||||
//! thickness, density, paint opacity and bristle length as fractions and the size in
|
||||
@@ -34,14 +43,16 @@
|
||||
//! - The art is a `%AI8_BeginBrushPattern` section: its name as a string, then the art (read by
|
||||
//! [`super::read`]).
|
||||
//!
|
||||
//! What isn't read keeps its default: the Art brush's options, the pattern brush's tiles (which
|
||||
//! slot is which tile isn't known) and every brush's colorization, which is left at None so the
|
||||
//! art keeps its own colors.
|
||||
//! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles
|
||||
//! (which slot is which tile isn't known) and the colorization of the other brushes, which is left
|
||||
//! at None so the art keeps its own colors.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use kurbo::Affine;
|
||||
use vectorcraft_brush::{ArtBrush, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
|
||||
use vectorcraft_doc::Node;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
|
||||
|
||||
use super::lex::{Lexer, Tok};
|
||||
use super::obj;
|
||||
@@ -102,10 +113,13 @@ pub(super) enum DraftKind {
|
||||
brush: Scatter,
|
||||
art: String,
|
||||
},
|
||||
/// The art is the section named `art`.
|
||||
/// The art is the section named `art`. `plain`: the brush stretches its art along the path,
|
||||
/// unflipped, at 100% in Tints (see [`art_is_plain`]), so [`Self::Art`]'s `brush` draws as the
|
||||
/// file's brush does.
|
||||
Art {
|
||||
brush: ArtBrush,
|
||||
art: String,
|
||||
plain: bool,
|
||||
},
|
||||
/// The five art sections the brush names, in its order (which is which tile isn't known), and
|
||||
/// its scale (%).
|
||||
@@ -206,17 +220,65 @@ pub(super) fn calligraphic(values: [f64; 3], variation: [f64; 3], codes: [f64; 3
|
||||
Calligraphic { angle, roundness, size, variation, modes: Some(modes) }
|
||||
}
|
||||
|
||||
/// Does the brush `draft` draw as `c`, the settings a path's art style gives it? A path whose
|
||||
/// The settings a path's art style gives its stroke's brush.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum PathBrush {
|
||||
Calligraphic(Calligraphic),
|
||||
/// An Art brush whose art stretches along the path, at the brush's own width, in Tints.
|
||||
Art,
|
||||
}
|
||||
|
||||
/// Does an Art brush draw `art` as the file does? It bends paths in solid colors: other objects
|
||||
/// are left out of the pieces, a gradient or pattern stays where it was and takes no color from the
|
||||
/// stroke, an effect isn't bent with its path, a stroke's outline isn't stretched along it, and art
|
||||
/// with no length along the path draws nothing.
|
||||
pub(super) fn art_bends(art: &Node) -> bool {
|
||||
let mut bends = art.geometric_bounds().is_some_and(|b| b.width() > 1e-9);
|
||||
art.walk(&mut |n| {
|
||||
bends &= matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. })
|
||||
&& n.extras().mask.is_none()
|
||||
&& n.appearance.effects.is_empty()
|
||||
&& n.appearance.items.iter().all(|i| i.effects().is_empty() && !matches!(i, AppearanceItem::Stroke(s) if !s.paint.is_none()))
|
||||
&& vectorcraft_doc::swatches::node_paints(n).all(|p| matches!(p, Paint::Solid { .. } | Paint::None));
|
||||
});
|
||||
bends
|
||||
}
|
||||
|
||||
/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does.
|
||||
pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap<String, Node>) -> HashSet<String> {
|
||||
let names: HashSet<&String> = drafts
|
||||
.iter()
|
||||
.filter_map(|d| match &d.kind {
|
||||
DraftKind::Art { art, .. } => Some(art),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
names.into_iter().filter(|n| art.get(*n).is_some_and(art_bends)).cloned().collect()
|
||||
}
|
||||
|
||||
/// Does the brush `draft` draw as `settings`, the settings a path's art style gives it? A path whose
|
||||
/// settings differ (Options of Selected Object) keeps its drawn look: a stroke names a brush, and
|
||||
/// has no settings of its own.
|
||||
pub(super) fn draws_as(draft: &BrushDraft, c: &Calligraphic) -> bool {
|
||||
let DraftKind::Calligraphic(d) = &draft.kind else { return false };
|
||||
/// has no settings of its own. `bending`: [`bending_art`].
|
||||
pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashSet<String>) -> bool {
|
||||
let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0);
|
||||
close(d.angle, c.angle)
|
||||
&& close(d.roundness, c.roundness)
|
||||
&& close(d.size, c.size)
|
||||
&& d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b))
|
||||
&& d.modes() == c.modes()
|
||||
match (&draft.kind, settings) {
|
||||
(DraftKind::Calligraphic(d), PathBrush::Calligraphic(c)) => {
|
||||
close(d.angle, c.angle)
|
||||
&& close(d.roundness, c.roundness)
|
||||
&& close(d.size, c.size)
|
||||
&& d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b))
|
||||
&& d.modes() == c.modes()
|
||||
}
|
||||
(DraftKind::Art { plain: true, art: name, .. }, PathBrush::Art) => bending.contains(name),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Are an Art brush's last 15 numbers those of a brush that stretches its art along the path,
|
||||
/// unflipped, at 100% in Tints? The numbers aren't read otherwise: any other value keeps its paths'
|
||||
/// drawn look.
|
||||
fn art_is_plain(numbers: &[f64; 15]) -> bool {
|
||||
*numbers == [1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]
|
||||
}
|
||||
|
||||
/// The brush named `name` of kind `kind` from its parameters `params`.
|
||||
@@ -270,7 +332,15 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
|
||||
};
|
||||
DraftKind::Scatter { brush, art: art()? }
|
||||
}
|
||||
Tool::Art => DraftKind::Art { brush: ArtBrush { colorization: Colorization::None, ..ArtBrush::default() }, art: art()? },
|
||||
Tool::Art => {
|
||||
// The older form's 8 numbers aren't known: its brush keeps the defaults and its
|
||||
// paths their drawn look.
|
||||
let (colorization, plain) = match <&[f64; 15]>::try_from(n.as_slice()) {
|
||||
Ok(n15) => (if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15)),
|
||||
Err(_) => (Colorization::None, false),
|
||||
};
|
||||
DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain }
|
||||
}
|
||||
_ => {
|
||||
let [_, _, p3, ..] = n.as_slice() else { return Err(BrushError::Values) };
|
||||
let tiles: [Option<String>; 5] = tiles.try_into().map_err(|_| BrushError::Values)?;
|
||||
@@ -317,6 +387,43 @@ pub(super) fn art_weight(art: &Node) -> usize {
|
||||
weight
|
||||
}
|
||||
|
||||
/// Which of `drafts` [`finish_brushes`] makes into brushes, with [`MAX_BRUSH_ART_NODES`] of art.
|
||||
pub(super) fn kept(drafts: &[BrushDraft], art: &HashMap<String, Node>) -> Vec<bool> {
|
||||
kept_brushes(drafts, art, MAX_BRUSH_ART_NODES)
|
||||
}
|
||||
|
||||
/// Which of `drafts` become brushes, with at most `budget` of [`art_weight`] copied into them: not
|
||||
/// a pattern brush, a brush whose art isn't read, a brush whose art would go past `budget`, or a
|
||||
/// brush named as one kept before it.
|
||||
pub(super) fn kept_brushes(drafts: &[BrushDraft], art: &HashMap<String, Node>, budget: usize) -> Vec<bool> {
|
||||
let mut copied = 0usize;
|
||||
let mut weights: HashMap<&str, usize> = HashMap::new();
|
||||
let mut names: HashSet<&str> = HashSet::new();
|
||||
drafts
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let fits = match &d.kind {
|
||||
_ if names.contains(d.name.as_str()) => false,
|
||||
DraftKind::Calligraphic(_) | DraftKind::Bristle(_) => true,
|
||||
DraftKind::Pattern { .. } => false,
|
||||
DraftKind::Scatter { art: name, .. } | DraftKind::Art { art: name, .. } => art.get_key_value(name).is_some_and(|(key, a)| {
|
||||
let weight = *weights.entry(key.as_str()).or_insert_with(|| art_weight(a));
|
||||
let total = copied.saturating_add(weight);
|
||||
let fits = total <= budget;
|
||||
if fits {
|
||||
copied = total;
|
||||
}
|
||||
fits
|
||||
}),
|
||||
};
|
||||
if fits {
|
||||
names.insert(&d.name);
|
||||
}
|
||||
fits
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`finish_brushes`] with at most `budget` of [`art_weight`] copied into the brushes (each brush
|
||||
/// that uses a section holds its own copy): a brush whose art would go past it is left out.
|
||||
pub(super) fn finish_brushes_within(
|
||||
@@ -325,27 +432,22 @@ pub(super) fn finish_brushes_within(
|
||||
order: Option<&[(Tool, String)]>,
|
||||
budget: usize,
|
||||
) -> (Vec<Brush>, usize) {
|
||||
let kept = kept_brushes(&drafts, art, budget);
|
||||
let copy = |name: &str| art.get(name).cloned();
|
||||
let mut left_out = 0;
|
||||
let mut copied = 0usize;
|
||||
let mut weights: HashMap<&str, usize> = HashMap::new();
|
||||
let mut copy = |name: &str| -> Option<Node> {
|
||||
let (key, a) = art.get_key_value(name)?;
|
||||
let weight = *weights.entry(key.as_str()).or_insert_with(|| art_weight(a));
|
||||
let total = copied.saturating_add(weight);
|
||||
(total <= budget).then(|| {
|
||||
copied = total;
|
||||
a.clone()
|
||||
})
|
||||
};
|
||||
let mut made: Vec<Option<(Tool, Brush)>> = vec![];
|
||||
for d in drafts {
|
||||
for (d, kept) in drafts.into_iter().zip(kept) {
|
||||
let tool = d.kind.tool();
|
||||
let repeated = made.iter().flatten().any(|(_, b)| b.name == d.name);
|
||||
let kind = match d.kind {
|
||||
_ if repeated => None,
|
||||
_ if !kept => None,
|
||||
DraftKind::Calligraphic(c) => Some(BrushKind::Calligraphic(c)),
|
||||
DraftKind::Scatter { brush, art: name } => copy(&name).map(|a| BrushKind::Scatter(Scatter { art: a, ..brush })),
|
||||
DraftKind::Art { brush, art: name } => copy(&name).map(|a| BrushKind::Art(ArtBrush { art: a, ..brush })),
|
||||
DraftKind::Art { brush, art: name, .. } => copy(&name).map(|mut a| {
|
||||
// The side of the section with y up draws on the right of the path, and a brush
|
||||
// draws art's y (down here) on the left of the path.
|
||||
a.transform(Affine::scale_non_uniform(1.0, -1.0), false);
|
||||
BrushKind::Art(ArtBrush { art: a, ..brush })
|
||||
}),
|
||||
DraftKind::Pattern { .. } => None,
|
||||
DraftKind::Bristle(b) => Some(BrushKind::Bristle(b)),
|
||||
};
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
//! then its `/ArtDictionary`: Envelope Options as a bit word (`AIDeformOptionsKey`), Fidelity
|
||||
//! (`AIDeformFidelityKey`), Edit Contents (`AIDeformEditModeKey`), a warp's style (`DeformStyle`,
|
||||
//! numbered as Effect › Warp lists them), bend and distortions (`DeformValue`, `DeformHoriz`,
|
||||
//! `DeformVert`, fractions) and orientation (`Rotate`), the contents (`X=` … `X+`, then
|
||||
//! `DeformVert`, fractions) and orientation (`Rotate`), the size of the contents, read as the size
|
||||
//! of the box they map from with Distort Appearance on when it matches their bounds with strokes
|
||||
//! (`AIDeformArtWidthKey`, `AIDeformArtHeightKey`), the contents (`X=` … `X+`, then
|
||||
//! `(AIDeformArtKey)`) and the kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object). Then the
|
||||
//! distorted art on plain lines, and the plugin's `XP`. While the contents are edited, the group
|
||||
//! holds the contents and the dictionary the mesh. A top object's outline is the edge of its
|
||||
@@ -24,8 +26,11 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{BezPath, Point, Rect, Vec2};
|
||||
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, default_fidelity};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
use vectorcraft_doc::live::{
|
||||
EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, default_fidelity, envelope_content_boxes,
|
||||
envelope_stroke_margin,
|
||||
};
|
||||
use vectorcraft_doc::{Document, ImageBlob, LayerColor, Node, NodeId, NodeKind, PatternDef};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
@@ -42,6 +47,9 @@ pub(super) const MAX_PENDING: usize = 64;
|
||||
pub(super) const MAX_CHECKED: usize = 256;
|
||||
/// The most rows or columns of a mesh read (Make with Mesh's own limit).
|
||||
const MAX_GRID: u32 = 50;
|
||||
/// How far, in points over width and height together, a saved size may lie from the contents'
|
||||
/// bounds with their strokes and still count as that box.
|
||||
const SAVED_SIZE_SLACK: f64 = 0.5;
|
||||
/// The bits of the options word that are read: Distort Appearance, Distort Pattern Fills, Distort
|
||||
/// Linear Gradients, Anti-Alias (set: off) and Preserve Shape Using (set: Clipping Mask).
|
||||
const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17;
|
||||
@@ -88,10 +96,29 @@ pub(super) fn options(o: &Obj) -> Option<(EnvelopeOptions, f64)> {
|
||||
distort_appearance: bit(0),
|
||||
distort_pattern_fills: bit(1),
|
||||
distort_linear_gradients: bit(2),
|
||||
bounds_margin: [0.0; 4],
|
||||
};
|
||||
Some((options, fidelity))
|
||||
}
|
||||
|
||||
/// The margins of `content` when the size the file saves for it (`AIDeformArtWidthKey`,
|
||||
/// `AIDeformArtHeightKey`) matches its bounds with its strokes within [`SAVED_SIZE_SLACK`] and lies
|
||||
/// nearer them than its geometric bounds. None without that size, or for contents with no width or
|
||||
/// no height.
|
||||
fn saved_margin(o: &Obj, content: &[Arc<Node>]) -> Option<[f32; 4]> {
|
||||
let saved = |key: &str| match o.nums(key).as_slice() {
|
||||
[v] if v.is_finite() => Some(*v),
|
||||
_ => None,
|
||||
};
|
||||
let (w, h) = (saved("AIDeformArtWidthKey")?, saved("AIDeformArtHeightKey")?);
|
||||
let (stroked, plain) = envelope_content_boxes(content, Affine::IDENTITY)?;
|
||||
let off = |r: Rect| (r.width() - w).abs() + (r.height() - h).abs();
|
||||
if off(stroked) > SAVED_SIZE_SLACK || off(stroked) >= off(plain) {
|
||||
return None;
|
||||
}
|
||||
envelope_stroke_margin(stroked, plain)
|
||||
}
|
||||
|
||||
/// A warp: its style, bend, distortions and orientation.
|
||||
fn warp(o: &Obj) -> Option<EnvelopeKind> {
|
||||
let style = whole(o, "DeformStyle")??;
|
||||
@@ -232,7 +259,7 @@ fn unwrapped(mut nodes: Vec<Node>) -> Vec<Arc<Node>> {
|
||||
/// in its `AIDeformArtKey` block (`inner`); `None` where they don't make one.
|
||||
pub(super) fn live(o: &Obj, before: Option<Node>, inner: Vec<Node>) -> Option<NodeKind> {
|
||||
let editing = switch(o, "AIDeformEditModeKey")?;
|
||||
let (options, fidelity) = options(o)?;
|
||||
let (mut options, fidelity) = options(o)?;
|
||||
let before = before?;
|
||||
// While the contents are edited, they are the group before the dictionary and the mesh is in it.
|
||||
let (content, shape): (Vec<Arc<Node>>, Option<&GradientMesh>) = if editing {
|
||||
@@ -249,6 +276,9 @@ pub(super) fn live(o: &Obj, before: Option<Node>, inner: Vec<Node>) -> Option<No
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if options.distort_appearance {
|
||||
options.bounds_margin = saved_margin(o, &content).unwrap_or_default();
|
||||
}
|
||||
let kind = match whole(o, "AIDeformStyle")?? {
|
||||
1 => warp(o)?,
|
||||
2 => {
|
||||
|
||||
@@ -66,8 +66,8 @@ fn number_before(head: &[u8], key: &[u8]) -> Option<f64> {
|
||||
number(words(head.get(..k)?).last()?)
|
||||
}
|
||||
|
||||
/// A path in the format's path operators (`m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`; any other
|
||||
/// operator ends the numbers before it).
|
||||
/// A path in the format's path operators (`m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, closed by `n`,
|
||||
/// `f`, `s`, `b` or `h`; any other operator ends the numbers before it).
|
||||
fn path(text: &[u8]) -> Option<BezPath> {
|
||||
let mut bp = BezPath::new();
|
||||
let mut nums: Vec<f64> = vec![];
|
||||
@@ -106,6 +106,12 @@ fn path(text: &[u8]) -> Option<BezPath> {
|
||||
bp.curve_to(p(n - 4)?, q, q);
|
||||
cur = Some(q);
|
||||
}
|
||||
// The painting operators that close the path first (lower case), and `h`: an area's frame
|
||||
// is closed, its last point the first again.
|
||||
b"n" | b"f" | b"s" | b"b" | b"h" if cur.is_some() => {
|
||||
bp.close_path();
|
||||
cur = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
nums.clear();
|
||||
@@ -135,4 +141,17 @@ mod tests {
|
||||
assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(1.0, 2.0, 3.0, 4.0));
|
||||
assert!(text_frames(b"/AI11Text : garbage").is_empty());
|
||||
}
|
||||
|
||||
/// An area's frame painted with `n` (as the format writes it, the last point the first again)
|
||||
/// is a closed path of its corners; one painted with `N` stays open.
|
||||
#[test]
|
||||
fn a_frame_painted_closed_is_closed() {
|
||||
let doc = b"/AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r3 /StoryIndex ,\r/Art :\rX=\r40 130 m\r40 250 L\r240 250 L\r240 130 L\r40 130 L\rn\rX+\r; /ConfiningPath ,\r;\r\
|
||||
/AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r4 /StoryIndex ,\r/Art :\rX=\r40 130 m\r40 250 L\r240 250 L\rN\rX+\r; /ConfiningPath ,\r;\r";
|
||||
let f = text_frames(doc);
|
||||
let closed = vectorcraft_geom::PathData::from_bezpath(&f[&(3, 0)]);
|
||||
assert!(closed.subpaths[0].closed);
|
||||
assert_eq!(closed.subpaths[0].anchors.len(), 4, "the last point merges with the first");
|
||||
assert!(!vectorcraft_geom::PathData::from_bezpath(&f[&(4, 0)]).subpaths[0].closed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! text paths (`a b c d tx ty startPt Tp`, an area's frame, one for each linked area, or the path
|
||||
//! type runs along, `TP`), the modal character and paragraph attributes
|
||||
//! (`Tr Tf Ts Tz Tt TA Tl Ta Ti`, and §15.4.7 and §22's letter and word spacing `TC TW`, hanging
|
||||
//! punctuation `Tq`, hyphenation `Th` and tab stops `Xb Xe XB`), the paint style, and the characters (`Tx`, `Tj`,
|
||||
//! punctuation `Tq`, hyphenation `Th` and tab stops `Xb Xe XB`, and §15.3.4's writing direction `TV`,
|
||||
//! Roman character rotation `Tv` and Kumi blocks `Ty TY`), the paint style, and the characters (`Tx`, `Tj`,
|
||||
//! overflow `TX`; `\r` ends a paragraph). Type set in rendering modes 4 to 7 is a mask (§5.7): the
|
||||
//! text object clips the objects of its clipping group. Final-form operators (the
|
||||
//! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the
|
||||
@@ -51,6 +52,9 @@ const LINKED_AREAS_LEFT_OUT: &str = "linked type areas past VectorCraft's limits
|
||||
/// The warning when type set in a mask rendering mode can't be a clipping path: type in linked
|
||||
/// areas (several frames), in wraparound text or in a compound path doesn't clip.
|
||||
const MASK_NOT_CLIPPED: &str = "type used as a mask in linked areas, wraparound text or a compound path doesn't clip";
|
||||
/// The warning when `TV` changes after a text object's first character: the object keeps the
|
||||
/// direction of that character.
|
||||
const DIRECTION_CHANGE_LEFT_OUT: &str = "type that changes writing direction inside a text object opens in the direction of its first character";
|
||||
|
||||
/// A text path (`Tp` … `TP`).
|
||||
#[derive(Debug, Default)]
|
||||
@@ -101,6 +105,10 @@ pub(super) struct LegacyText {
|
||||
/// Characters were set in a mask rendering mode (`Tr` 4 to 7): the text object is a clipping
|
||||
/// path (§5.7).
|
||||
clips: bool,
|
||||
/// `TV` at its first character: VectorCraft keeps one writing direction for each text object.
|
||||
vertical: bool,
|
||||
/// A later character was set in the other direction.
|
||||
direction_changed: bool,
|
||||
/// The state when it began (lock, visibility), and whether it began on a `%_` line.
|
||||
gs: GState,
|
||||
hidden: bool,
|
||||
@@ -176,6 +184,10 @@ pub(super) struct TypeState {
|
||||
tabs_version: u64,
|
||||
/// `Tr`.
|
||||
render: i64,
|
||||
/// `TV`: vertical writing.
|
||||
vertical: bool,
|
||||
/// `TY`: a Kumi block recorded for later use, which horizontal type keeps as its mark.
|
||||
kumi_recorded: bool,
|
||||
/// The encoding strings are in, by code (`TE`); None where it says nothing.
|
||||
encoding: Vec<Option<char>>,
|
||||
/// Re-encoded fonts' original names (`%AI3_BeginEncoding: _new old`, or `TZ`'s).
|
||||
@@ -197,6 +209,8 @@ impl Default for TypeState {
|
||||
para: ParaStyle::default(),
|
||||
tabs_version: 0,
|
||||
render: 0,
|
||||
vertical: false,
|
||||
kumi_recorded: false,
|
||||
encoding: vec![None; 256],
|
||||
fonts: vec![],
|
||||
font_encodings: vec![],
|
||||
@@ -546,6 +560,31 @@ impl Reader<'_> {
|
||||
h.before_last = letters(tail, 1.0, 15.0);
|
||||
h.hyphen_limit = if limit { letters(max, 0.0, 25.0) } else { 0 };
|
||||
}
|
||||
// `direction TV` (§15.3.4): 0 horizontal, 1 vertical.
|
||||
"TV" => {
|
||||
if let Some(on) = n0.and_then(flag) {
|
||||
st.vertical = on;
|
||||
}
|
||||
}
|
||||
// `rotation Tv`: in vertical writing, 0 rotates Roman characters 90 degrees clockwise and
|
||||
// 1 keeps them upright.
|
||||
"Tv" => {
|
||||
if let Some(on) = n0.and_then(flag) {
|
||||
st.style.standard_vertical_roman = on;
|
||||
}
|
||||
}
|
||||
// `kumi Ty`: 1 begins a Kumi block and 0 ends it.
|
||||
"Ty" => {
|
||||
if let Some(on) = n0.and_then(flag) {
|
||||
st.style.tate_chu_yoko = on;
|
||||
}
|
||||
}
|
||||
// `kumi TY`: as `Ty`, for a block that does not appear in the set text.
|
||||
"TY" => {
|
||||
if let Some(on) = n0.and_then(flag) {
|
||||
st.kumi_recorded = on;
|
||||
}
|
||||
}
|
||||
// A tab definition (§22.6): `numDots numTabs Xb` begins it, each
|
||||
// `leader decimal tabType tabPosition Xe` describes one tab stop, and `XB` ends it. The
|
||||
// stops it describes replace the paragraph's tab stops, sorted by position, one stop at
|
||||
@@ -603,8 +642,13 @@ impl Reader<'_> {
|
||||
}
|
||||
let decoded: String = decoded.chars().take(room).collect();
|
||||
st.chars += decoded.chars().count();
|
||||
if t.runs.is_empty() {
|
||||
t.vertical = st.vertical;
|
||||
}
|
||||
t.direction_changed |= st.vertical != t.vertical;
|
||||
let gs = &self.gs;
|
||||
let mut style = st.style.clone();
|
||||
style.tate_chu_yoko |= st.kumi_recorded && !t.vertical;
|
||||
// Characters in modes 4 to 7 make the whole text object the mask, not each run (§5.7); 7
|
||||
// paints nothing, nor does 3 (invisible).
|
||||
t.clips |= (4..=7).contains(&st.render);
|
||||
@@ -669,6 +713,9 @@ impl Reader<'_> {
|
||||
if t.kind == 1 && (t.past_limit || all_areas > placed.len()) {
|
||||
self.warn(LINKED_AREAS_LEFT_OUT);
|
||||
}
|
||||
if t.direction_changed {
|
||||
self.warn(DIRECTION_CHANGE_LEFT_OUT);
|
||||
}
|
||||
let mut paras = std::mem::take(&mut t.paras);
|
||||
if paras.is_empty() {
|
||||
paras.push(ParaRun { style: self.type_state.para.clone(), count: 1, tabs_version: 0 });
|
||||
@@ -682,6 +729,7 @@ impl Reader<'_> {
|
||||
for (m, kind) in placed {
|
||||
let mut obj = TextObject::point(kurbo::Point::ZERO, "", last_style.clone());
|
||||
obj.kind = kind;
|
||||
obj.vertical = t.vertical;
|
||||
obj.xf = to_doc * m * FLIP;
|
||||
match story.take() {
|
||||
Some((runs, paras)) => {
|
||||
|
||||
@@ -133,7 +133,8 @@ impl<'a> Lexer<'a> {
|
||||
self.src.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn rest(&self) -> &'a [u8] {
|
||||
/// The data from where the next token is looked for.
|
||||
pub fn rest(&self) -> &'a [u8] {
|
||||
self.src.get(self.pos..).unwrap_or_default()
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8]) -> Result<Structu
|
||||
/// content, the prolog, resources, sections it skips (such as symbols and the text document) or
|
||||
/// what follows the trailer. The palette the reader keeps is the last one outside those and the
|
||||
/// sections it reads on their own.
|
||||
fn swatch_names(data: &[u8]) -> ShownNames {
|
||||
fn swatch_names(data: &[u8]) -> (ShownNames, Option<&[u8]>) {
|
||||
/// The sections the reader reads on their own whose definitions and colors it keeps.
|
||||
const READ_APART: &[&str] = &["BeginPattern", "BeginBrushPattern", "BeginPluginObject", "BeginArtStyles", "BeginArtStyleList"];
|
||||
/// Whether the marker `m` ends what began with `begin` and ends with `end`, `depth` of its kind
|
||||
@@ -277,8 +277,14 @@ fn swatch_names(data: &[u8]) -> ShownNames {
|
||||
let mut skip: Option<(&str, &str, usize, Option<usize>)> = None;
|
||||
let mut section: Option<(&str, &str, usize, bool)> = None;
|
||||
let (mut palette_at, mut palette) = (None, None);
|
||||
// Where the first art styles section starts, and that section.
|
||||
let (mut styles_at, mut styles) = (None, None);
|
||||
// A pattern's name is the first string of its definition.
|
||||
let mut head = false;
|
||||
// ASCII85 data in comment lines is skipped past, as the reader skips it.
|
||||
let mut dictionaries = ops::Dictionaries::default();
|
||||
// The scan's state where the versioned content being skipped starts, to go back to.
|
||||
let mut versioned = None;
|
||||
loop {
|
||||
let before = lex.offset();
|
||||
let Some(t) = lex.next_token() else {
|
||||
@@ -286,11 +292,14 @@ fn swatch_names(data: &[u8]) -> ShownNames {
|
||||
match skip.take() {
|
||||
Some((.., Some(at))) => {
|
||||
lex.seek(at);
|
||||
dictionaries = versioned.take().unwrap_or_default();
|
||||
continue;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
};
|
||||
let markers = skip.map(|s| (s.0, s.1)).or(palette_at.map(|_| ("", "EndPalette"))).or(section.map(|s| (s.0, s.1)));
|
||||
dictionaries.pass(&t.tok, &mut lex, markers);
|
||||
match &t.tok {
|
||||
Tok::Comment(c) => {
|
||||
let line = String::from_utf8_lossy(c);
|
||||
@@ -306,12 +315,16 @@ fn swatch_names(data: &[u8]) -> ShownNames {
|
||||
}
|
||||
} else if m.starts_with("Alternate_Content") && section.is_none_or(|s| s.3) {
|
||||
skip = Some(("Begin_Content_if_version_gt", "End_Versioned_Content", 0, Some(lex.offset())));
|
||||
versioned = Some(dictionaries.clone());
|
||||
} else if m == "%BeginProlog" {
|
||||
skip = Some(("", "%EndProlog", 0, None));
|
||||
} else if m == "%BeginResource" {
|
||||
skip = Some(("", "%EndResource", 0, None));
|
||||
} else if let Some((begin, end, depth, _)) = section.as_mut() {
|
||||
if closes(begin, end, depth, m) {
|
||||
if let Some(at) = styles_at.take().filter(|_| *begin == "BeginArtStyles") {
|
||||
styles = Some(lex.slice(at, before));
|
||||
}
|
||||
section = None;
|
||||
}
|
||||
} else if m == "BeginPalette" {
|
||||
@@ -319,6 +332,9 @@ fn swatch_names(data: &[u8]) -> ShownNames {
|
||||
} else if let Some((begin, end)) = types::SKIPPED.iter().find(|(b, _)| *b == m) {
|
||||
section = Some((begin, end, 0, READ_APART.contains(begin)));
|
||||
head = m == "BeginPattern";
|
||||
if m == "BeginArtStyles" && styles.is_none() {
|
||||
styles_at = Some(lex.offset());
|
||||
}
|
||||
} else if matches!(m, "%PageTrailer" | "%Trailer") {
|
||||
break;
|
||||
}
|
||||
@@ -350,7 +366,18 @@ fn swatch_names(data: &[u8]) -> ShownNames {
|
||||
}
|
||||
palette.own_names().for_each(|n| names.reserve(n));
|
||||
colors.names.iter().for_each(|n| names.reserve(n));
|
||||
names
|
||||
(names, styles)
|
||||
}
|
||||
|
||||
/// What the brushes and brush art read so far give a brushed stroke.
|
||||
#[derive(Default)]
|
||||
struct BrushesKept {
|
||||
/// The counts of brushes and art sections when this was worked out.
|
||||
read: (usize, usize),
|
||||
/// Which of [`Reader::brush_drafts`] are kept as brushes.
|
||||
kept: Vec<bool>,
|
||||
/// The art an Art brush draws as the file does.
|
||||
bending: HashSet<String>,
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
@@ -448,6 +475,12 @@ struct Reader<'a> {
|
||||
warnings: Vec<String>,
|
||||
/// Objects read as their drawn look (several fills or strokes, effects, brushes).
|
||||
drawn_looks: usize,
|
||||
/// The objects read as their drawn look in the art of [`Reader::brush_art`], by name: counted in
|
||||
/// [`Reader::drawn_looks`] once a brush that is kept uses the art.
|
||||
brush_art_looks: HashMap<String, usize>,
|
||||
/// The objects read as their drawn look in the art of the patterns kept, by the name the document
|
||||
/// has the pattern by: a pattern defined again counts only its last definition.
|
||||
pattern_looks: HashMap<String, usize>,
|
||||
/// Nodes read from `%_` lines.
|
||||
hidden_ids: HashSet<NodeId>,
|
||||
/// The blend part each object's dictionary names (`ShapeBlendPathType`, `ShapeBlendPathID`).
|
||||
@@ -476,7 +509,7 @@ struct Reader<'a> {
|
||||
layer_state: Option<(f32, BlendMode, bool, Knockout, bool)>,
|
||||
/// The state a mesh or blend had before an `Xy` right after its end, which is the layer's
|
||||
/// instead when the layer's `LB` follows rather than their own `6 () XW`.
|
||||
mesh_undo: Option<(f32, BlendMode, bool, Knockout)>,
|
||||
mesh_undo: Option<(f32, BlendMode, bool, Knockout, bool)>,
|
||||
/// The state a layer's last group or compound path had before an `Xy` right after its end,
|
||||
/// which is the layer's instead when the layer's own `7 () XW` follows.
|
||||
container_undo: Option<(f32, BlendMode, bool, Knockout, bool)>,
|
||||
@@ -554,6 +587,8 @@ struct Reader<'a> {
|
||||
brush_art: HashMap<String, Node>,
|
||||
/// The brush art sections read so far, repeated names included.
|
||||
brush_art_sections: usize,
|
||||
/// What the brushes and brush art read so far give a brushed stroke.
|
||||
brushes_kept: BrushesKept,
|
||||
/// Brush objects that weren't read.
|
||||
brushes_unread: usize,
|
||||
/// The graph being read (see [`Reader::graph_record`]).
|
||||
@@ -576,10 +611,15 @@ pub(crate) fn is_look_slot(name: Option<&str>) -> bool {
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
/// A reader of the editing data `data` as a whole, with the names its gradients and patterns
|
||||
/// get in the document found first ([`swatch_names`]).
|
||||
/// get in the document and its art styles found first ([`swatch_names`]): the symbols defined
|
||||
/// before the art styles section use them too.
|
||||
fn with_names(data: &'a [u8]) -> Self {
|
||||
let mut r = Self::new(data);
|
||||
r.global_names = swatch_names(data);
|
||||
let (names, styles) = swatch_names(data);
|
||||
r.global_names = names;
|
||||
if let Some(section) = styles {
|
||||
r.styles = super::style::art_styles(section);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
@@ -645,6 +685,8 @@ impl<'a> Reader<'a> {
|
||||
color_model: None,
|
||||
warnings: Vec::new(),
|
||||
drawn_looks: 0,
|
||||
brush_art_looks: HashMap::new(),
|
||||
pattern_looks: HashMap::new(),
|
||||
hidden_ids: HashSet::new(),
|
||||
blend_roles: HashMap::new(),
|
||||
blend_record: None,
|
||||
@@ -694,6 +736,7 @@ impl<'a> Reader<'a> {
|
||||
brush_drafts: Vec::new(),
|
||||
brush_art: HashMap::new(),
|
||||
brush_art_sections: 0,
|
||||
brushes_kept: BrushesKept::default(),
|
||||
brushes_unread: 0,
|
||||
graph: None,
|
||||
multi_mask: None,
|
||||
|
||||
@@ -35,6 +35,9 @@ impl<'a> Reader<'a> {
|
||||
// The type takes the state's transparency, as a path does; an `Xy` after it sets the slot's own.
|
||||
n.opacity = gs.opacity;
|
||||
n.blend = gs.blend;
|
||||
n.knockout_shape = gs.knockout_shape;
|
||||
// An `Xy` before the text object was the object's, not its layer's, when the layer's `LB` follows.
|
||||
self.layer_state = None;
|
||||
let hidden = self.obj_hidden;
|
||||
self.add(n, hidden)
|
||||
}
|
||||
@@ -316,6 +319,7 @@ impl<'a> Reader<'a> {
|
||||
let mut node = self.new_node(NodeKind::Mesh(one), &gs);
|
||||
node.opacity = gs.opacity;
|
||||
node.blend = gs.blend;
|
||||
node.knockout_shape = gs.knockout_shape;
|
||||
self.add(node, hidden)?;
|
||||
self.mesh_ended = true;
|
||||
return Ok(());
|
||||
@@ -332,6 +336,7 @@ impl<'a> Reader<'a> {
|
||||
};
|
||||
node.opacity = gs.opacity;
|
||||
node.blend = gs.blend;
|
||||
node.knockout_shape = gs.knockout_shape;
|
||||
self.add(node, hidden)?;
|
||||
self.mesh_ended = true;
|
||||
Ok(())
|
||||
@@ -698,6 +703,7 @@ impl<'a> Reader<'a> {
|
||||
let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs);
|
||||
node.opacity = gs.opacity;
|
||||
node.blend = gs.blend;
|
||||
node.knockout_shape = gs.knockout_shape;
|
||||
node.extras_mut().mask = mask.map(Box::new);
|
||||
node.extras_mut().colorize = colorize.map(Box::new);
|
||||
self.add(node, hidden)?;
|
||||
|
||||
@@ -98,7 +98,14 @@ impl<'a> Reader<'a> {
|
||||
..
|
||||
} = f;
|
||||
if matches!(kind, Kind::Clip) {
|
||||
// `Q` restores the painting state, not the transparency: the writer sets `Xy` only when
|
||||
// the next object's differs from the last one written, wherever that was, so a state set
|
||||
// inside a clipping group stays after it (a glow set to Screen, then a clipping path set
|
||||
// back to Normal: the type after the group has no `Xy` and is Normal).
|
||||
let now = &self.gs;
|
||||
let transparency = (now.opacity, now.blend, now.isolate, now.knockout, now.knockout_shape);
|
||||
self.gs = gs.clone();
|
||||
(self.gs.opacity, self.gs.blend, self.gs.isolate, self.gs.knockout, self.gs.knockout_shape) = transparency;
|
||||
self.pending_unread_fill = pending_unread_fill;
|
||||
self.pending_unread_stroke = pending_unread_stroke;
|
||||
}
|
||||
@@ -281,7 +288,8 @@ impl<'a> Reader<'a> {
|
||||
_ => {
|
||||
let rule = clip.first().map_or(FillRule::NonZero, |(_, n)| path_rule(n));
|
||||
// The compound path keeps the clipping paths' stroke, opacity and blend mode when they all have the same
|
||||
// ones: it has one appearance for all its paths.
|
||||
// ones, and the knockout shape flag when they all have it: it has one appearance for all its paths.
|
||||
let shape = clip.iter().all(|(_, n)| n.knockout_shape);
|
||||
let look = |n: &Node| (n.appearance.clone(), n.opacity, n.blend);
|
||||
let shared =
|
||||
clip.split_first().and_then(|((_, first), others)| others.iter().all(|(_, n)| look(n) == look(first)).then(|| look(first)));
|
||||
@@ -292,6 +300,7 @@ impl<'a> Reader<'a> {
|
||||
n.opacity = opacity;
|
||||
n.blend = blend;
|
||||
}
|
||||
n.knockout_shape = shape;
|
||||
Some(n)
|
||||
}
|
||||
};
|
||||
@@ -312,7 +321,7 @@ impl<'a> Reader<'a> {
|
||||
if let (Some(_), Some(before)) = (state, self.mesh_undo.take())
|
||||
&& let Some(mesh) = f.children.last_mut().filter(|n| matches!(n.kind, NodeKind::Mesh(_) | NodeKind::Blend { .. }))
|
||||
{
|
||||
(mesh.opacity, mesh.blend, mesh.isolate, mesh.knockout) = before;
|
||||
(mesh.opacity, mesh.blend, mesh.isolate, mesh.knockout, mesh.knockout_shape) = before;
|
||||
}
|
||||
let Kind::Layer(a) = f.kind else { return Ok(()) };
|
||||
let (children, clip) = self.clip_first(f.children, &f.clips, false, self.nesting() + 1)?;
|
||||
@@ -379,7 +388,12 @@ impl<'a> Reader<'a> {
|
||||
let f = self.close("X+", |k| matches!(k, Kind::Mask))?;
|
||||
// The mask's art is one level under the object it masks.
|
||||
let (children, clip) = self.clip_first(f.children, &f.clips, false, self.nesting() + 2)?;
|
||||
let art = self.new_node(NodeKind::Group { children, clip }, &f.gs);
|
||||
// One shown object is the art itself. A hidden object or a guide stays in a group: the canvas draws the art's root
|
||||
// whether it is shown or not and whether guides show, and a group doesn't draw a hidden member or a left-out guide.
|
||||
let art = match children.as_slice() {
|
||||
[only] if !clip && only.visible && !matches!(only.kind, NodeKind::Path { guide: true, .. }) => only.clone(),
|
||||
_ => Arc::new(self.new_node(NodeKind::Group { children, clip }, &f.gs)),
|
||||
};
|
||||
self.gs = f.gs;
|
||||
self.pending_unread_fill = f.pending_unread_fill;
|
||||
self.pending_unread_stroke = f.pending_unread_stroke;
|
||||
@@ -388,7 +402,7 @@ impl<'a> Reader<'a> {
|
||||
let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else {
|
||||
return Err("mask art ends outside a mask dictionary".into());
|
||||
};
|
||||
if o.art.replace(Arc::new(art)).is_some() {
|
||||
if o.art.replace(art).is_some() {
|
||||
return Err("a mask has more than one art block".into());
|
||||
}
|
||||
o.unread = f.unread;
|
||||
@@ -760,7 +774,8 @@ impl<'a> Reader<'a> {
|
||||
// did (they were counted as read).
|
||||
let mut blend = Node::new(self.doc.alloc_id(), kind);
|
||||
(blend.name, blend.visible, blend.locked) = (steps.name.take(), steps.visible, steps.locked);
|
||||
(blend.opacity, blend.blend, blend.isolate, blend.knockout) = (steps.opacity, steps.blend, steps.isolate, steps.knockout);
|
||||
(blend.opacity, blend.blend, blend.isolate, blend.knockout, blend.knockout_shape) =
|
||||
(steps.opacity, steps.blend, steps.isolate, steps.knockout, steps.knockout_shape);
|
||||
// Its note, URL and data (object tags) too.
|
||||
blend.extras_mut().attrs = steps.extras_mut().attrs.take();
|
||||
f.children.push(blend);
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
||||
|
||||
use super::super::brush;
|
||||
use super::art::{four, sane, two};
|
||||
use super::ops::Dictionaries;
|
||||
use super::types::{
|
||||
FLIP, GState, Kind, MAX_DEPTH, MAX_HEX, MAX_LISTED, MAX_NODES, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, MAX_SYMBOLS,
|
||||
PatternHead, Place, Raster, SKIPPED, SymbolHead, VERSIONED_UNENDED,
|
||||
@@ -330,15 +331,18 @@ impl<'a> Reader<'a> {
|
||||
self.section(end).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`Self::skip_to`] → what was skipped, up to the comment that ends it.
|
||||
/// [`Self::skip_to`] → what was skipped, up to the comment that ends it. ASCII85 data in comment
|
||||
/// lines is skipped past as [`Self::add_object_entry`] skips it.
|
||||
fn section(&mut self, end: &str) -> Result<&'a [u8], String> {
|
||||
let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b);
|
||||
let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin };
|
||||
let start = self.lex.offset();
|
||||
let mut depth = 0usize;
|
||||
let mut dictionaries = Dictionaries::default();
|
||||
loop {
|
||||
let before = self.lex.offset();
|
||||
let Some(t) = self.lex.next_token() else { break };
|
||||
dictionaries.pass(&t.tok, &mut self.lex, Some((begin, end)));
|
||||
if let Tok::Comment(c) = t.tok {
|
||||
let line = String::from_utf8_lossy(c);
|
||||
let m = marker(&line);
|
||||
@@ -503,6 +507,7 @@ impl<'a> Reader<'a> {
|
||||
} else {
|
||||
self.v7_patterns.remove(&head.name);
|
||||
}
|
||||
self.pattern_looks.insert(def.name.clone(), sub.drawn_looks);
|
||||
self.doc.patterns.push(def);
|
||||
for (key, blob) in sub.doc.images {
|
||||
self.doc.images.entry(key).or_insert(blob);
|
||||
@@ -748,6 +753,7 @@ impl<'a> Reader<'a> {
|
||||
&& sub.frames.len() == 1
|
||||
&& sub.doc.images.is_empty();
|
||||
let Some(frame) = sub.frames.pop().filter(|f| whole && !f.children.is_empty()) else { return };
|
||||
self.brush_art_looks.insert(name.clone(), sub.drawn_looks);
|
||||
let mut art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect();
|
||||
let node = match art.len() {
|
||||
1 => art.pop().map(Arc::unwrap_or_clone),
|
||||
@@ -1103,7 +1109,7 @@ mod tests {
|
||||
assert!(nodes > 0);
|
||||
let draft = |i: usize| brush::BrushDraft {
|
||||
name: format!("Brush {i}"),
|
||||
kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string() },
|
||||
kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), plain: false },
|
||||
};
|
||||
let drafts: Vec<_> = (0..5).map(draft).collect();
|
||||
let (kept, left_out) = brush::finish_brushes_within(drafts.clone(), &reader.brush_art, None, 3 * nodes);
|
||||
@@ -1113,6 +1119,7 @@ mod tests {
|
||||
// A second brush of the same name is left out without using the budget.
|
||||
let mut repeated: Vec<_> = (0..2).map(draft).collect();
|
||||
repeated.insert(1, draft(0));
|
||||
assert_eq!(brush::kept_brushes(&repeated, &reader.brush_art, 2 * nodes), [true, false, true]);
|
||||
let (kept, left_out) = brush::finish_brushes_within(repeated, &reader.brush_art, None, 2 * nodes);
|
||||
assert_eq!((kept.len(), left_out), (2, 1));
|
||||
}
|
||||
|
||||
@@ -914,13 +914,20 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
|
||||
/// The brushes read ([`brush::finish_brushes`]) and how many weren't; `None` when the file has
|
||||
/// no brush objects (the document keeps the default brushes).
|
||||
/// no brush objects (the document keeps the default brushes). The objects read as their drawn
|
||||
/// look in the art the brushes use count toward the note on drawn looks, once for each art.
|
||||
fn brush_library(&mut self) -> Option<(Vec<vectorcraft_brush::Brush>, usize)> {
|
||||
if !self.brush_objects {
|
||||
return None;
|
||||
}
|
||||
let drafts = std::mem::take(&mut self.brush_drafts);
|
||||
let (lib, left_out) = brush::finish_brushes(drafts, &self.brush_art, self.brush_order.as_deref());
|
||||
let used: std::collections::HashSet<NodeId> = lib.iter().flat_map(|b| b.art()).map(|a| a.id).collect();
|
||||
for (name, art) in &self.brush_art {
|
||||
if used.contains(&art.id) {
|
||||
self.drawn_looks += self.brush_art_looks.get(name).copied().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
Some((lib, left_out + self.brushes_unread))
|
||||
}
|
||||
|
||||
@@ -1087,6 +1094,7 @@ impl<'a> Reader<'a> {
|
||||
warnings.push(brush::left_out_note(unread));
|
||||
}
|
||||
}
|
||||
self.drawn_looks += self.pattern_looks.values().sum::<usize>();
|
||||
if self.drawn_looks > 0 {
|
||||
warnings.push(format!(
|
||||
"objects with several fills or strokes, effects or brushes came in as their drawn look ({}): their appearance isn't editable yet",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user