Merge remote-tracking branch 'upstream/main' into feat/ai-import-stacked-fills

# Conflicts:
#	crates/eps/src/import/ai/tests.rs
#	docs/file-format-parity.md
This commit is contained in:
Don Omondi
2026-11-07 00:12:40 -05:00
255 changed files with 15904 additions and 1072 deletions
+16
View File
@@ -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).
+17
View File
@@ -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
View File
@@ -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",
+5
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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 }
+84 -60
View File
@@ -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(&params)))
.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(&params)}))
})
.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(&params))).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(())
}
-2
View File
@@ -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();
}
}
+3
View File
@@ -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.
+8 -2
View File
@@ -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
};
+225
View File
@@ -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())]);
}
}
+8 -1
View File
@@ -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
+16 -5
View File
@@ -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 } => {
+49
View File
@@ -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};
+154 -10
View File
@@ -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}");
}
}
}
+4 -1
View File
@@ -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));
}
+2 -2
View File
@@ -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
View File
@@ -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 {
+55 -7
View File
@@ -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(),
}
}
+4
View File
@@ -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
+10 -6
View File
@@ -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);
+16
View File
@@ -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]
+5
View File
@@ -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,
+14
View File
@@ -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);
+137 -35
View File
@@ -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;
+57 -2
View File
@@ -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");
}
+14 -3
View File
@@ -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);
+7 -4
View File
@@ -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());
}
+29 -3
View File
@@ -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);
}
}
}
}
+87 -8
View File
@@ -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();
+10 -7
View File
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eyedropper",
[],
None,
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, 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() {
+1
View File
@@ -11,6 +11,7 @@
mod flavours;
pub(crate) mod resources;
mod table;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
+19 -4
View File
@@ -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 }))
+203
View File
@@ -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));
}
}
+19 -10
View File
@@ -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
+4 -1
View File
@@ -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);
+35
View File
@@ -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]
+1 -1
View File
@@ -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;
+12 -1
View File
@@ -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))
}
+78 -20
View File
@@ -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) {
+1 -1
View File
@@ -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",
+22 -7
View File
@@ -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));
+61 -21
View File
@@ -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(&copy.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
+6 -1
View File
@@ -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()))?;
+5
View File
@@ -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.
+3 -3
View File
@@ -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
),
+27 -3
View File
@@ -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);
+141 -21
View File
@@ -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() };
+1 -1
View File
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns 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
),
+76 -8
View File
@@ -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);
+68 -1
View File
@@ -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() {
+32 -2
View File
@@ -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
+9 -3
View File
@@ -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"),
+6
View File
@@ -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,
+14 -3
View File
@@ -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;
+1
View File
@@ -147,6 +147,7 @@ mod scalestrokes;
mod scripts;
mod select_ids_first;
mod shaper;
mod shapetools;
mod shortcutset;
mod simplify_part;
mod slices;
+35
View File
@@ -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();
+3
View File
@@ -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)
+125
View File
@@ -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]
+143
View File
@@ -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, &params).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()
+48
View File
@@ -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()
+60
View File
@@ -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() {
+32
View File
@@ -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");
+63
View File
@@ -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");
+33
View File
@@ -3462,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();
+4 -2
View File
@@ -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]
+144
View File
@@ -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()
+92
View File
@@ -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();
+92
View File
@@ -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();
+169
View File
@@ -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");
}
+263
View File
@@ -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();
+16 -9
View File
@@ -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:?}");
}
}
}
+3 -2
View File
@@ -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())
+139 -14
View File
@@ -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
};
+123
View File
@@ -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");
}
+22 -5
View File
@@ -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");
}
File diff suppressed because it is too large Load Diff
+83 -8
View File
@@ -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`]).
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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).
+22 -3
View File
@@ -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(&centres, false)))
}
fn centre(n: &Node) -> Option<Point> {
n.geometric_bounds().map(|b| b.center()).filter(|p| p.x.is_finite() && p.y.is_finite())
}
+129 -32
View File
@@ -19,12 +19,20 @@
//! /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.
//! - 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
@@ -32,14 +40,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;
@@ -100,10 +110,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 (%).
@@ -204,17 +217,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`.
@@ -247,7 +308,8 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
})
.collect();
let n = fields.last().and_then(|f| numbers(f)).ok_or(BrushError::Values)?;
let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n.as_slice() else { return Err(BrushError::Values) };
let n: &[f64; 15] = n.as_slice().try_into().map_err(|_| BrushError::Values)?;
let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n;
let art = || tiles.first().cloned().flatten().ok_or(BrushError::NoArt);
match kind {
Tool::Scatter => {
@@ -268,7 +330,10 @@ 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 => {
let colorization = if *p2 == 1.0 { Colorization::Tints } else { Colorization::None };
DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain: art_is_plain(n) }
}
_ => {
let tiles: [Option<String>; 5] = tiles.try_into().map_err(|_| BrushError::Values)?;
DraftKind::Pattern { tiles, scale: p3 * 100.0 }
@@ -314,6 +379,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(
@@ -322,27 +424,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)),
};
+34 -4
View File
@@ -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 => {
+21 -2
View File
@@ -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);
}
}
+49 -1
View File
@@ -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)) => {
+2 -1
View File
@@ -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()
}
+48 -5
View File
@@ -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,
+6
View File
@@ -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)?;
+20 -5
View File
@@ -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);
+9 -2
View File
@@ -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));
}
+9 -1
View File
@@ -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",
+205 -23
View File
@@ -5,8 +5,8 @@ use super::types::{
line_width,
};
use super::{
Affine, Appearance, AppearanceItem, Arc, BlendMode, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, Graph, HashMap, HashSet, Instance,
Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint,
Affine, Appearance, AppearanceItem, Arc, BlendMode, BrushesKept, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, Graph, HashMap,
HashSet, Instance, Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint,
};
impl<'a> Reader<'a> {
@@ -34,10 +34,11 @@ impl<'a> Reader<'a> {
// of it may start with `%_` and read as hidden tokens: a `(` there would open
// a string that swallows the rest of the layer.
let key = o.entries.last().and_then(|(k, _)| k.as_deref());
if o.ty == "Binary" && key == Some("ASCII85Decode") {
self.lex.skip_past(b"~>");
} else if o.ty == "ForeignObject" && key == Some("Data") {
self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec());
if ascii85_follows(&o.ty, key) {
let data = self.lex.skip_past(b"~>");
if o.ty == "ForeignObject" {
self.foreign_data = Some(data.to_vec());
}
}
}
}
@@ -359,10 +360,13 @@ impl<'a> Reader<'a> {
"TO" => self.end_text(hidden)?,
// `(text) justified TS`, special characters (§22.6): the reader adds its characters like `Tx`.
"Tx" | "Tj" | "TX" | "TS" => self.text_body(w, &vals),
"Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" | "Xb" | "Xe" | "XB" => self.text_attr(w, &vals),
// Attributes the reader doesn't keep (kerning pairs, Far-Eastern type) and final-form
// positions (§15.3).
"Tk" | "TK" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg" | "Tu" | "Xu" => {}
"Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" | "TV" | "Tv" | "Ty" | "TY" | "Xb" | "Xe"
| "XB" => {
self.text_attr(w, &vals);
}
// Attributes the reader doesn't keep (kerning pairs, line breaking, binding punctuation
// and Japanese layout rules) and final-form positions (§15.3).
"Tk" | "TK" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TG" | "Tg" | "Tu" | "Xu" => {}
// `TM` (pop text matrix) and `Te` (end render), listed in §22.6, keep nothing either; the
// reader takes `Te` with any number of operands.
"TM" | "Te" => {}
@@ -622,8 +626,8 @@ impl<'a> Reader<'a> {
self.last_object().filter(|n| (mesh_ended && matches!(n.kind, NodeKind::Mesh(_))) || matches!(n.kind, NodeKind::Blend { .. }))
{
// The mesh's or blend's, or the layer's when it is the layer's last object: kept for either.
let before = (n.opacity, n.blend, n.isolate, n.knockout);
(n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout);
let before = (n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape);
(n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape) = (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape);
self.mesh_undo = Some(before);
self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape));
} else if let Some(n) = self.last_object().filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) {
@@ -668,6 +672,19 @@ impl<'a> Reader<'a> {
self.note_state();
}
/// Brings [`Reader::brushes_kept`] up to date with the brushes and brush art read so far.
fn note_brushes_kept(&mut self) {
use super::super::brush;
let read = (self.brush_drafts.len(), self.brush_art_sections);
if self.brushes_kept.read != read {
self.brushes_kept = BrushesKept {
read,
kept: brush::kept(&self.brush_drafts, &self.brush_art),
bending: brush::bending_art(&self.brush_drafts, &self.brush_art),
};
}
}
/// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its
/// drawn look before it (all on `%_` lines inside another such object). Other markers end what
/// follows an object.
@@ -704,6 +721,7 @@ impl<'a> Reader<'a> {
return;
}
let space = self.space();
self.note_brushes_kept();
let Some(f) = self.frames.last_mut() else { return };
let n = f.children.len();
let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else {
@@ -729,6 +747,8 @@ impl<'a> Reader<'a> {
names: &self.global_names,
v7_patterns: &self.v7_patterns,
brushes: &self.brush_drafts,
kept: &self.brushes_kept.kept,
bending: &self.brushes_kept.bending,
space,
},
)
@@ -746,7 +766,8 @@ impl<'a> Reader<'a> {
}
object.appearance = appearance.into();
// The object's transparency is written on its look, after the look's group ends.
(object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout);
(object.opacity, object.blend, object.isolate, object.knockout, object.knockout_shape) =
(look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape);
// Now the object itself, not a copy an enclosing look's `strip` would remove.
self.hidden_ids.remove(&object.id);
// Linked to the style when the style list has its name ([`Reader::finish`]).
@@ -767,8 +788,9 @@ impl<'a> Reader<'a> {
// A text object stays the type it is: over what else its look draws, or in place of a
// look that was only its type. Its transparency is written on its look.
let empty = look.children().is_some_and(|c| c.is_empty());
if empty || (look.opacity >= 1.0 && look.blend == BlendMode::Normal && !look.isolate) {
(object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout);
if empty || (look.opacity >= 1.0 && look.blend == BlendMode::Normal && !look.isolate && !look.knockout_shape) {
(object.opacity, object.blend, object.isolate, object.knockout, object.knockout_shape) =
(look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape);
if empty {
*look = object;
} else {
@@ -778,11 +800,11 @@ impl<'a> Reader<'a> {
}
// The look and the type go into a group that has the transparency.
let Some(mut look) = f.children.pop() else { return };
let state = (look.opacity, look.blend, look.isolate, look.knockout);
(look.opacity, look.blend, look.isolate, look.knockout) = (1.0, BlendMode::Normal, false, Knockout::Neutral);
(object.opacity, object.blend, object.isolate) = (1.0, BlendMode::Normal, false);
let state = (look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape);
(look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape) = (1.0, BlendMode::Normal, false, Knockout::Neutral, false);
(object.opacity, object.blend, object.isolate, object.knockout_shape) = (1.0, BlendMode::Normal, false, false);
let mut group = Node::group(self.doc.alloc_id(), vec![Arc::new(look), Arc::new(object)]);
(group.opacity, group.blend, group.isolate, group.knockout) = state;
(group.opacity, group.blend, group.isolate, group.knockout, group.knockout_shape) = state;
self.text_looks.insert(group.id);
if let Some(f) = self.frames.last_mut() {
f.children.push(group);
@@ -873,6 +895,10 @@ struct StylePaints<'a> {
v7_patterns: &'a HashSet<String>,
/// The brushes the file defines, which a brushed stroke names.
brushes: &'a [super::super::brush::BrushDraft],
/// Which of [`Self::brushes`] are kept as brushes ([`super::super::brush::kept`]).
kept: &'a [bool],
/// The art an Art brush draws as the file does ([`super::super::brush::bending_art`]).
bending: &'a HashSet<String>,
space: Affine,
}
@@ -1052,11 +1078,21 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
}
},
StyleItem::Stroke { brush, .. } => {
// A brushed stroke draws as its look only with the brush the file defines (the
// first of its name, as the Brushes panel keeps it) at the object's settings.
// A brushed stroke draws as its look only with the brush the Brushes panel keeps
// under its name, at the object's settings, on a path, a compound path or type (an
// image draws no stroke). An Art brush is read on a path or a compound path with
// no effects.
if let Some((name, settings)) = brush {
let draft = paints.brushes.iter().find(|d| &d.name == name)?;
if !super::super::brush::draws_as(draft, settings) {
let (draft, _) = paints.brushes.iter().zip(paints.kept).find(|(d, kept)| **kept && &d.name == name)?;
if !super::super::brush::draws_as(draft, settings, paints.bending) {
return None;
}
let art = matches!(settings, super::super::brush::PathBrush::Art);
let on_a_path = matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
if !on_a_path && (art || !slot) {
return None;
}
if art && !st.effects.is_empty() {
return None;
}
}
@@ -1137,6 +1173,101 @@ fn strip(n: &mut Node, hidden: &HashSet<NodeId>) {
}
}
/// Whether ASCII85 data in comment lines, up to its `~>`, follows the entry keyed `key` of a
/// dictionary of type `ty`: the `/ASCII85Decode` of a `/Binary` dictionary or a text document, or a
/// non-native art object's (`/ForeignObject`'s) `/Data`.
pub(super) fn ascii85_follows(ty: &str, key: Option<&str>) -> bool {
matches!((ty, key), ("Binary" | "AI11TextDocument" | "AI11UndoFreeTextDocument", Some("ASCII85Decode")) | ("ForeignObject", Some("Data")))
}
/// The dictionaries (`/Type :` … `;`) a scan of tokens passes that the reader doesn't read, kept to
/// tell where an entry's ASCII85 data follows ([`ascii85_follows`]), so the scan can skip past it
/// as the reader does: a `(` on a `%_` line of the data would otherwise open a string.
#[derive(Clone, Default)]
pub(super) struct Dictionaries<'s> {
/// The open dictionaries' types, innermost last, up to [`MAX_DEPTH`].
types: Vec<&'s [u8]>,
/// How many more are open inside the last of `types`.
deeper: usize,
/// The name just before the token taken.
name: Option<&'s [u8]>,
/// Whether the string just before the token taken is a key that ASCII85 data follows.
string_key: bool,
/// The end markers of the sections being scanned (`None`: no section) where the rest of the data
/// had no `~>` or marker to stop at, so they aren't searched for again in those sections.
no_end: Vec<Option<String>>,
}
/// Where the ASCII85 data at the start of `rest` stops: just past its `~>`, or at the comment that
/// comes first with a marker of `section`, the begin and end markers of the section being scanned
/// (no begin marker when it's empty). A line of the data that reads as one of them counts as the
/// marker. `None` when `rest` has neither.
fn data_end(rest: &[u8], section: Option<(&str, &str)>) -> Option<usize> {
let mut at = 0;
for line in rest.split(|b| matches!(b, b'\r' | b'\n')) {
// The first line is what is left of the key's line, where a `%` starts no marker.
if at > 0
&& let Some((comment, (begin, end))) = line.strip_prefix(b"%").zip(section)
{
let comment = String::from_utf8_lossy(comment);
let marker = super::marker(&comment);
if marker == end || (!begin.is_empty() && marker == begin) {
return Some(at);
}
}
if let Some(i) = line.windows(2).position(|w| w == b"~>") {
return Some(at + i + 2);
}
at += line.len() + 1;
}
None
}
impl<'s> Dictionaries<'s> {
/// Take `tok`, the token `lex` just gave, and skip the ASCII85 data that follows it, up to where
/// [`data_end`] stops for `section`, so the scan still finds that section's markers.
pub(super) fn pass(&mut self, tok: &super::Tok<'s>, lex: &mut super::Lexer<'s>, section: Option<(&str, &str)>) {
let end = section.map(|(_, end)| end);
if self.data_follows(tok) && !self.no_end.iter().any(|e| e.as_deref() == end) {
match data_end(lex.rest(), section) {
Some(stop) => lex.seek(lex.offset() + stop),
None => self.no_end.push(end.map(str::to_owned)),
}
}
}
/// Take the next token → whether ASCII85 data follows it.
fn data_follows(&mut self, tok: &super::Tok<'s>) -> bool {
// Comments between a name and what follows it are passed over, as the reader passes them.
if matches!(tok, super::Tok::Comment(_)) {
return false;
}
let name = self.name.take();
let string_key = std::mem::take(&mut self.string_key);
match tok {
super::Tok::Name(n) => self.name = Some(n),
// A string keys an entry as a name does, but gives a dictionary no type.
super::Tok::Str(s) | super::Tok::Hex(s) => self.string_key = self.keys_data(s),
super::Tok::Word(b":") if self.types.len() < MAX_DEPTH => self.types.push(name.unwrap_or_default()),
super::Tok::Word(b":") => self.deeper = self.deeper.saturating_add(1),
super::Tok::Word(b";") if self.deeper > 0 => self.deeper -= 1,
super::Tok::Word(b";") => {
self.types.pop();
}
super::Tok::Word(b",") => return string_key || name.is_some_and(|key| self.keys_data(key)),
_ => {}
}
false
}
/// Whether ASCII85 data follows an entry keyed `key` of the innermost dictionary.
fn keys_data(&self, key: &[u8]) -> bool {
let Some(ty) = self.types.last().filter(|_| self.deeper == 0) else { return false };
let (Ok(ty), Ok(key)) = (std::str::from_utf8(ty), std::str::from_utf8(key)) else { return false };
ascii85_follows(ty, Some(key))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1147,6 +1278,57 @@ mod tests {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(fill, Paint::None, 0.0)))
}
/// An image draws no stroke, so an image with a brush in its art style keeps its drawn look.
#[test]
fn an_image_with_a_brush_keeps_its_drawn_look() {
use super::super::super::brush::{Tool, parse_tool_object};
let tool = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's name in the files
let text = format!(
"%AI9_BeginArtStyles\n/KnownStyle :\n(Inked) /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\
/StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
6 /Real (Diameter) ,\n2 /Real (DiameterVariation) ,\n2 /Int (DiameterDependsOn) ,\n\
40 /Real (Roundness) ,\n10 /Real (RoundnessVariation) ,\n5 /Int (RoundnessDependsOn) ,\n\
30 /Real (Angle) ,\n180 /Real (AngleVariation) ,\n6 /Int (AngleDependsOn) ,\n0 /Int (AngleRelativeTo) ,\n\
/GObjRef : (N) ({tool}) /PluginObject ; (Brush) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n"
);
let styles = crate::import::ai::style::art_styles(text.as_bytes());
let st = &styles["Inked"];
let brushes = [parse_tool_object(Tool::Calligraphic, "N", "1 4 8 30 50 -150 210 2 5 6 0").unwrap()];
let (gradients, patterns, names, v7_patterns, bending) =
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new());
let paints = StylePaints {
gradients: &gradients,
patterns: &patterns,
names: &names,
v7_patterns: &v7_patterns,
brushes: &brushes,
kept: &[true],
bending: &bending,
space: Affine::IDENTITY,
};
let look = Node::group(NodeId(1), vec![square(2, Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))]);
assert!(styled(&square(3, Paint::None), &look, st, &paints).is_some(), "a path takes the style");
let image =
ImageObject { key: "a".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
assert!(styled(&Node::new(NodeId(4), NodeKind::Image(Box::new(image))), &look, st, &paints).is_none());
}
#[test]
fn data_stops_at_its_end_or_a_marker_of_the_section() {
let section = Some(("BeginTextDocument", "EndTextDocument"));
assert_eq!(data_end(b"\n%_(a~>\n%AI11_EndTextDocument\n", section), Some(7));
assert_eq!(data_end(b"\n%_(a\n%AI11_EndTextDocument\n", section), Some(6));
assert_eq!(data_end(b"\n%_(a\n%AI11_BeginTextDocument\n", section), Some(6));
// Bytes that aren't UTF-8 after the marker, as the scan reads a comment.
assert_eq!(data_end(b"\n%_(a\n%AI11_EndTextDocument \xff\n", section), Some(6));
// Right after the key a `%` starts no marker, and an empty begin marker matches no comment.
assert_eq!(data_end(b"%AI11_EndTextDocument\n%_(a\n", section), None);
assert_eq!(data_end(b"\n%\n", Some(("", "EndPalette"))), None);
assert_eq!(data_end(b"\n%AI11_EndTextDocument\n", None), None);
}
#[test]
fn only_outlines_without_gradients_move_alike() {
// GUARDS: an image's pixels and a placed gradient stay behind when an effect moves the outline.
+1 -1
View File
@@ -316,7 +316,7 @@ pub(super) const IGNORED: &[&str] = &[
"Ar",
"Xs",
"Xt",
// A brushed path's width at each anchor (`(…) Xt n XD`), on its hidden source path.
// Five indexed curves of samples along a brushed path (`(…) Xt n XD`), on its hidden source path.
"XD",
"Xi",
"XI",
+143 -22
View File
@@ -32,12 +32,12 @@
//! gradient (`Bb` … `flag (name) x y angle length … Bg` … `BB`); `stroke fill Xd` says whether it
//! paints at all. A blend filter's `mode opacity isolate knockout shape Xy` is its transparency.
//!
//! A stroke with a Calligraphic brush is its own basic filter, `(Adobe Calligraphic Brush Tool)`, with
//! its stroke's style and the brush's settings for the object as entries (see
//! [`super::brush`]).
//! A stroke with a Calligraphic or Art brush is its own basic filter, `(Adobe Calligraphic Brush
//! Tool)` or `(Adobe ArtOnPath Brush Tool)`, with its stroke's style and the brush's settings for
//! the object as entries (see [`super::brush`]).
//!
//! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills
//! with a pattern or gradient too; strokes with a Calligraphic brush too), each with its own
//! with a pattern or gradient too; strokes with a Calligraphic or Art brush too), each with its own
//! transparency and effects, and the effects of
//! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and
//! effects (a path's stack of several fills and strokes when its drawn look places each of its
@@ -51,6 +51,7 @@ use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, WidthProfile};
use vectorcraft_effects::is_pathfinder;
use super::brush::PathBrush;
use super::lex::{Lexer, Tok};
use super::obj::V;
use super::paint;
@@ -79,6 +80,7 @@ const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's nam
const PATHFINDER: &str = "Adobe Pathfinder"; // brand-ok: the filter's name in the files
const CALLIGRAPHIC: &str = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's and plug-in's name in the files
const ART_BRUSH: &str = "Adobe ArtOnPath Brush Tool"; // brand-ok: the filter's and plug-in's name in the files
const CHAIN: &str = "Chain Style Filter";
const STACK: &str = "Stack Style Filter";
@@ -112,12 +114,32 @@ pub(super) fn warp_style(n: i64) -> Option<&'static str> {
id.strip_prefix("warp.")
}
/// The keys of an Effect › Pathfinder › Add filter's dictionary besides `DisplayString`, each with
/// the kind and the one value read: `Command` 0 is Add, and the rest are its Pathfinder Options,
/// Soft Mix and Trap settings
/// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137).
const PATHFINDER_ADD: [(&str, Kind, f64); 13] = [
("Command", Kind::Int, 0.0),
/// The Effect › Pathfinder effects by the `Command` number of their filter. `Command` 0 is Add
/// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137);
/// the numbers 1 to 11 are VectorCraft's own reader rules, learned from saved files. Trap (12) isn't
/// read.
const PATHFINDER_COMMANDS: [&str; 12] = [
"pathfinder.add",
"pathfinder.intersect",
"pathfinder.exclude",
"pathfinder.subtract",
"pathfinder.minusBack",
"pathfinder.divide",
"pathfinder.outline",
"pathfinder.trim",
"pathfinder.merge",
"pathfinder.crop",
"pathfinder.hardMix",
"pathfinder.softMix",
];
/// The keys of an Effect › Pathfinder filter's dictionary besides `Command` and `DisplayString`,
/// each with the kind and the one value read: its Pathfinder Options, Soft Mix's mix and the Trap
/// settings. Every operation needs each of them at this value, even where the operation doesn't use
/// it, except that Soft Mix reads a `Mix` from 0.01 to 1 as its Mixing Rate (VectorCraft's own
/// reader rule, learned from saved files: the rate is `Mix` times 100, within the effect's 1 to 100;
/// a saved `Mix` outside that range keeps the drawn look).
const PATHFINDER_OPTIONS: [(&str, Kind, f64); 12] = [
("ConvertCustom", Kind::Bool, 1.0),
("ExtractUnpainted", Kind::Bool, 1.0),
("Mix", Kind::Real, 0.5),
@@ -195,8 +217,8 @@ pub(super) enum StyleItem {
miter: f64,
dash: Option<Dash>,
profile: Option<WidthProfile>,
/// A Calligraphic brush: its name, and its settings for this object.
brush: Option<(String, vectorcraft_brush::Calligraphic)>,
/// A brush: its name, and its settings for this object.
brush: Option<(String, PathBrush)>,
},
}
@@ -1061,18 +1083,38 @@ fn effect(f: &Filter, under: Under) -> Option<Effect> {
Some(Effect { id: id.into(), params, visible: f.visible })
}
/// Effect › Pathfinder › Add, when every key of [`PATHFINDER_ADD`] is there with its kind and value
/// and the only other key is the `DisplayString` text, each written once. `None` for anything else,
/// which keeps the drawn look.
/// The Effect › Pathfinder effect of [`PATHFINDER_COMMANDS`] that an integer `Command` names, when
/// every key of [`PATHFINDER_OPTIONS`] is there with its kind and value (Soft Mix's `Mix` from 0.01
/// to 1, as its Mixing Rate in percent) and the only other key is the `DisplayString` text, each
/// written once. `None` for anything else, which keeps the drawn look.
fn pathfinder_effect(f: &Filter) -> Option<Effect> {
let known = f.kinds.iter().all(|(key, kind)| (key == "DisplayString" && *kind == Kind::Other) || PATHFINDER_ADD.iter().any(|(k, ..)| k == key));
let known = f
.kinds
.iter()
.all(|(key, kind)| (key == "DisplayString" && *kind == Kind::Other) || key == "Command" || PATHFINDER_OPTIONS.iter().any(|(k, ..)| k == key));
if !known || f.repeated || !f.arrays.is_empty() || f.fill.is_some() || f.stroke.is_some() || f.gradient || f.pattern.is_some() {
return None;
}
if !PATHFINDER_ADD.iter().all(|(key, kind, value)| f.kinds.get(*key) == Some(kind) && f.params.get(*key) == Some(value)) {
if f.kinds.get("Command") != Some(&Kind::Int) {
return None;
}
Some(Effect { id: "pathfinder.add".into(), params: json!({}), visible: f.visible })
let command = f.params.get("Command").copied().filter(|c| c.is_finite() && c.fract() == 0.0 && *c >= 0.0)?;
let id = *PATHFINDER_COMMANDS.get(usize::try_from(command as i64).ok()?)?;
let soft_mix = id == "pathfinder.softMix";
let options = PATHFINDER_OPTIONS
.iter()
.all(|(key, kind, value)| f.kinds.get(*key) == Some(kind) && ((soft_mix && *key == "Mix") || f.params.get(*key) == Some(value)));
if !options {
return None;
}
let params = if soft_mix {
let mix = f.params.get("Mix").copied().filter(|m| (0.01..=1.0).contains(m))?;
// Rounded to six decimals: a `Mix` of 0.3 is a rate of 30.
json!({"mixingRate": (mix * 100.0 * 1e6).round() / 1e6})
} else {
json!({})
};
Some(Effect { id: id.into(), params, visible: f.visible })
}
/// The fill or stroke basic filter `f` paints, `Ok(None)` when it paints nothing, `Err` when it
@@ -1105,6 +1147,7 @@ fn paint_item(f: &Filter) -> Result<Option<StyleItem>, ()> {
_ => Err(()),
},
CALLIGRAPHIC => calligraphic_stroke(f).map(Some).ok_or(()),
ART_BRUSH => art_stroke(f).map(Some).ok_or(()),
_ => Err(()),
}
}
@@ -1136,7 +1179,85 @@ fn calligraphic_stroke(f: &Filter) -> Option<StyleItem> {
miter: f.miter,
dash: None,
profile: None,
brush: Some((name.clone(), super::brush::calligraphic(values, variation, codes))),
brush: Some((name.clone(), PathBrush::Calligraphic(super::brush::calligraphic(values, variation, codes)))),
})
}
/// The entries an Art brush's filter may hold, with the settings [`art_stroke`] reads. Any other
/// entry keeps the drawn look, a variable width's among them.
const ART_ENTRIES: [&str; 18] = [
"StrokeStyle",
"DisplayString",
"Brush",
"Proportional",
"Segmented",
"UpsideDown",
"Counterclockwise",
"ScaleYDependsOn",
"Spacing",
"StartSegmentLength",
"EndSegmentLength",
"Adjustment",
"FoldAdjustment",
"Scaling",
"MinScaling",
"MaxScaling",
"Colorization",
"Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files
];
/// An Art brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a color, names
/// no Art brush, holds an entry that isn't read, or its settings aren't those of art that stretches
/// along the path, unflipped, at the brush's own width (`Scaling` 1), in Tints (`Colorization` 1),
/// with `FoldAdjustment` 1, which is [`vectorcraft_brush::ArtBrush`]'s `overlap: false`: its drawn
/// look stays.
fn art_stroke(f: &Filter) -> Option<StyleItem> {
if !f.paints.0
|| f.gradient
|| f.pattern.is_some()
|| f.repeated
|| !f.arrays.is_empty()
|| !f.kinds.keys().all(|k| ART_ENTRIES.contains(&k.as_str()))
{
return None;
}
let (name, plugin) = f.plugin_object.as_ref()?;
if plugin != ART_BRUSH {
return None;
}
let off = |key: &str, kind: Kind| matches!(entry(f, key, kind), Ok(None | Some(0.0)));
let plain = off("Proportional", Kind::Bool)
&& off("Segmented", Kind::Bool)
&& off("UpsideDown", Kind::Int)
&& off("Counterclockwise", Kind::Int)
&& off("ScaleYDependsOn", Kind::Int)
&& off("Spacing", Kind::Real)
&& off("StartSegmentLength", Kind::Real)
&& off("EndSegmentLength", Kind::Real);
if !plain || entry(f, "Adjustment", Kind::Int).ok()?.unwrap_or(1.0) != 1.0 || entry(f, "FoldAdjustment", Kind::Bool).ok()?.unwrap_or(1.0) != 1.0 {
return None;
}
let one = |key: &str| entry(f, key, Kind::Real).is_ok_and(|v| v.unwrap_or(1.0) == 1.0);
if entry(f, "Scaling", Kind::Real).ok()?? != 1.0 || !one("MinScaling") || !one("MaxScaling") {
return None;
}
if entry(f, "Colorization", Kind::Int).ok()?? != 1.0 {
return None;
}
let paint = f.stroke.clone()?;
// The tinted pieces are process colors, so a swatch's ink would be lost.
if matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
return None;
}
Some(StyleItem::Stroke {
paint,
width: f.width,
cap: f.cap,
join: f.join,
miter: f.miter,
dash: None,
profile: None,
brush: Some((name.clone(), PathBrush::Art)),
})
}
@@ -1180,7 +1301,7 @@ impl Builder<'_> {
let c = &filters[k];
match c.name.as_str() {
_ if c.compound => {}
FILL | STROKE | CALLIGRAPHIC => match paint_item(c) {
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) {
Ok(Some(StyleItem::Stroke { .. })) => under.strokes = true,
Ok(Some(StyleItem::Pattern { .. })) => under.patterns = true,
_ => {}
@@ -1194,7 +1315,7 @@ impl Builder<'_> {
let c = &filters[k];
match c.name.as_str() {
_ if c.compound => self.plain = false,
FILL | STROKE | CALLIGRAPHIC => {
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => {
paints += 1;
look.visible &= c.visible;
match paint_item(c) {
@@ -1300,7 +1421,7 @@ fn style(filters: &[Filter]) -> ArtStyle {
let f = &filters[id];
match f.name.as_str() {
STACK if f.compound => b.stack(id, &mut st),
FILL | STROKE | CALLIGRAPHIC | CHAIN => {
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH | CHAIN => {
if let Some((item, look)) = b.item(id) {
st.items.push(item);
st.looks.push(look);
+48 -2
View File
@@ -516,8 +516,46 @@ fn pathfinder_add_is_read_as_its_effect() {
}
#[test]
fn pathfinder_commands_other_than_add_keep_the_drawn_look() {
for command in 1..=12 {
fn each_pathfinder_command_is_read_as_its_effect() {
// GUARDS: only `Command` 0 (Add) was read; a group with any other operation came in as its drawn look.
let ids = [
(1, "pathfinder.intersect"),
(2, "pathfinder.exclude"),
(3, "pathfinder.subtract"),
(4, "pathfinder.minusBack"),
(5, "pathfinder.divide"),
(6, "pathfinder.outline"),
(7, "pathfinder.trim"),
(8, "pathfinder.merge"),
(9, "pathfinder.crop"),
(10, "pathfinder.hardMix"),
];
for (command, id) in ids {
let st = group_style(&[pathfinder(&pathfinder_keys(&command.to_string(), "0.5"))]);
assert!(st.plain && st.simple && st.pathfinder, "{command}: {st:?}");
let [e] = st.effects.as_slice() else { panic!("{command}: {st:?}") };
assert_eq!((e.id.as_str(), &e.params, e.visible), (id, &json!({}), true), "{command}");
}
}
#[test]
fn soft_mix_is_read_with_its_mixing_rate() {
for (mix, rate) in [("0.5", 50.0), ("0.3", 30.0), ("0.01", 1.0), ("1", 100.0)] {
let st = group_style(&[pathfinder(&pathfinder_keys("11", mix))]);
assert!(st.plain && st.simple && st.pathfinder, "{mix}: {st:?}");
let [e] = st.effects.as_slice() else { panic!("{mix}: {st:?}") };
assert_eq!((e.id.as_str(), &e.params), ("pathfinder.softMix", &json!({"mixingRate": rate})), "{mix}");
}
// A rate the effect doesn't take keeps the drawn look.
for mix in ["0", "0.005", "1.5", "-0.5", "1e999"] {
let st = group_style(&[pathfinder(&pathfinder_keys("11", mix))]);
assert!(!st.plain && !st.pathfinder && st.effects.is_empty(), "{mix}: {st:?}");
}
}
#[test]
fn trap_and_unknown_pathfinder_commands_keep_the_drawn_look() {
for command in [12, 13, 40] {
let st = group_style(&[pathfinder(&pathfinder_keys(&command.to_string(), "0.5"))]);
assert!(!st.plain && !st.pathfinder && st.effects.is_empty(), "{command}: {st:?}");
}
@@ -623,7 +661,15 @@ fn pathfinder_settings_this_does_not_read_keep_the_drawn_look() {
("unpainted art kept", add.replace("1 /Bool (ExtractUnpainted)", "0 /Bool (ExtractUnpainted)")),
("other trap tint", add.replace("0.4 /Real (TrapTint)", "0.25 /Real (TrapTint)")),
("Mix on Add", add.replace("0.5 /Real (Mix)", "0.3 /Real (Mix)")),
("Mix on Intersect", pathfinder_keys("1", "0.3")),
("Mix on Hard Mix", pathfinder_keys("10", "0.3")),
("Mix as a bool", add.replace("0.5 /Real (Mix)", "1 /Bool (Mix)")),
("Soft Mix with Mix as an int", pathfinder_keys("11", "0.5").replace("0.5 /Real (Mix)", "1 /Int (Mix)")),
("Soft Mix without Mix", pathfinder_keys("11", "0.5").replace("0.5 /Real (Mix) ,\n", "")),
("Soft Mix with other Precision", pathfinder_keys("11", "0.3").replace("10 /Real (Precision)", "5 /Real (Precision)")),
("Intersect with other Precision", pathfinder_keys("1", "0.5").replace("10 /Real (Precision)", "5 /Real (Precision)")),
("Divide keeping unpainted art", pathfinder_keys("5", "0.5").replace("1 /Bool (ExtractUnpainted)", "0 /Bool (ExtractUnpainted)")),
("Command twice, Intersect then Add", add.replace("0 /Int (Command)", "1 /Int (Command) ,\n0 /Int (Command)")),
];
for (case, keys) in cases {
let st = group_style(&[pathfinder(&keys)]);
File diff suppressed because it is too large Load Diff
+22 -10
View File
@@ -17,8 +17,8 @@
//! offset from it. A right-to-left paragraph (key `33` of its attributes) swaps the two: its
//! "left"-aligned lines end at the anchor and its "right"-aligned ones start there. The frame of the
//! story gives a matrix that carries that layout onto the canvas of the app, which is 16383 points a
//! side; the canvas's centre, 8191.5, is the centre of the file's `%AI3_TemplateBox`. See
//! [`Story::place`].
//! side; the canvas's centre, 8191.5, is the centre of the file's `%AI3_TemplateBox`. A story without
//! a layout has its point type's anchor at its frame's point. See [`Story::place`].
use std::collections::BTreeMap;
@@ -28,13 +28,13 @@ use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection,
use vectorcraft_geom::{Affine, PathData, Point, Rect};
/// Longest story read (bytes).
const MAX_STORY: usize = 1 << 20;
pub(crate) const MAX_STORY: usize = 1 << 20;
/// Biggest text document read (bytes, decoded).
const MAX_BYTES: usize = 64 << 20;
pub(crate) const MAX_BYTES: usize = 64 << 20;
/// Deepest nesting read.
const MAX_DEPTH: usize = 64;
/// Most values read.
const MAX_VALUES: usize = 4_000_000;
pub(crate) const MAX_VALUES: usize = 4_000_000;
/// The centre of the app's canvas, in the units of its text document.
pub(super) const CANVAS_CENTRE: f64 = 8191.5;
@@ -241,8 +241,8 @@ const MAX_SHEETS: usize = 4096;
const MAX_SHEETS_BASED_ON: usize = 16;
/// Most frames read of one story, and most path segments of a frame.
const MAX_FRAMES: usize = 4096;
const MAX_SEGMENTS: usize = 1 << 16;
pub(crate) const MAX_FRAMES: usize = 4096;
pub(crate) const MAX_SEGMENTS: usize = 1 << 16;
/// Where a frame puts its story's text.
#[derive(Debug, Clone, PartialEq)]
@@ -513,8 +513,17 @@ impl Texts {
if indices.iter().any(|n| self.frame_vertical(*n) != vertical) {
return None;
}
// The first frame's layout, when the file keeps one: where its lines start.
let (mut anchor, mut lines, mut path_spacing) = ((0.0, 0.0), vec![], 0.0);
// The first frame's layout, when the file keeps one: where its lines start. Without one, the
// anchor of point type is its frame's point (key 0, where the layout's anchor is too).
let point = indices.first().and_then(|n| self.frames.get(*n)).and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::nums);
let (mut anchor, mut lines, mut path_spacing) = (
match point.as_deref() {
Some([x, y]) if matches!(frames.first(), Some(FrameKind::Point(_))) => (*x, *y),
_ => (0.0, 0.0),
},
vec![],
0.0,
);
if let Some(f) = view.get("2").and_then(|l| find_node(l, "F")) {
// Vertical type on a path turns its glyphs across the path: their matrices don't give
// the spacing.
@@ -854,8 +863,11 @@ impl Story {
let text: String = if threaded {
String::new()
} else {
// The `\r` that ends the story's last paragraph; one before it ends a paragraph, and the
// empty paragraph after it is the story's last.
self.text
.trim_end_matches('\r')
.strip_suffix('\r')
.unwrap_or(&self.text)
.chars()
.map(|c| match c {
'\r' => '\n',

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