Merge main into fix/graph-series-paint-other-commands

This commit is contained in:
dotCooCoo
2026-11-13 11:18:16 -08:00
174 changed files with 5840 additions and 773 deletions
Generated
+1
View File
@@ -6666,6 +6666,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"siphasher",
"skrifa 0.47.0",
"smallvec",
"thiserror 2.0.21",
+1
View File
@@ -141,6 +141,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
- 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged.
- 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged.
- 2026-11-11: Window › Swatch Libraries › Gradients › Organic Fades: fifteen soft fades (ten linear, five radial) through three to five unevenly spaced stops with off-center midpoints, mixed with the Perceptual method, that thin out to transparent at the end of a linear one and at the edge of a radial one; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets.
- 2026-11-13: Gradient panel › Method: Hue Clockwise and Hue Counterclockwise mix a linear, radial, conic or diamond gradient's colors around the hue wheel in one direction, so red to blue passes through magenta one way and through yellow and green the other (`paint.editGradient` `method`: `hue-clockwise`, `hue-counterclockwise`); SVG and PDF exports write such a gradient as an image.
- 2026-11-11: Window › Swatch Libraries › Gradients › Holographic: seven foils (opaque linear gradients of six narrow pastel and pearl bands with off-center midpoints) and seven glows (radial gradients from a bright center out to a clear edge), mixed with the Perceptual method; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets.
- 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`).
- 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged.
+11 -3
View File
@@ -418,10 +418,18 @@ fn convert(args: &[String]) -> Result<(), String> {
// Save as Template writes every artboard, with the .ai options.
("file.saveAsTemplate", json!({"path": output, "format": "ait", "outlineText": outline_text, "acknowledgeLoss": replace_lossy}))
} else {
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
let params = json!({"path": output, "artboard": artboard, "range": range, "acknowledgeLoss": replace_lossy});
let mut params = with_pixel_scale(params, &output, scale, ppi);
// An option the format doesn't read comes back in the warnings, so each goes out only
// when it was asked for: `outlineText` with --outline-text, and `hiddenLayers` to the
// formats that have it (SVG, PSD), where a conversion keeps hidden layers and objects,
// written hidden.
if outline_text {
params["outlineText"] = json!(true);
}
if vectorcraft_engine::cmd::fileio::writable_format(None, Some(&output)).is_ok_and(|f| f.options.iter().any(|o| o.name == "hiddenLayers")) {
params["hiddenLayers"] = json!(true);
}
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
+21
View File
@@ -177,6 +177,27 @@ fn convert_reports_import_warnings() {
assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}");
}
/// A conversion sends each format only the options it reads, so its warnings name an unused
/// option only when the command line gave one: `--outline-text` for a format without it.
#[test]
fn convert_warns_about_an_unused_option_only_when_one_was_given() {
let svg = tmp("unused-option.svg");
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="20" height="10"/></svg>"#).unwrap();
let unused = |out: &Value| -> Vec<String> {
let w = out["warnings"].as_array().unwrap_or_else(|| panic!("{out}"));
w.iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect()
};
for ext in ["png", "jpg", "pdf", "ai", "eps", "psd", "dxf", "svg", "txt", "vectorcraft", "vctemplate"] {
let path = tmp(&format!("unused-option.{ext}"));
let out: Value = serde_json::from_str(ok(&["convert", &svg, &path]).trim()).unwrap();
assert!(unused(&out).is_empty(), "{ext}: {out}");
}
let out: Value = serde_json::from_str(ok(&["convert", &svg, &tmp("unused-option-outlined.svg"), "--outline-text"]).trim()).unwrap();
assert!(unused(&out).is_empty(), "{out}");
let out: Value = serde_json::from_str(ok(&["convert", &svg, &tmp("unused-option-outlined.png"), "--outline-text"]).trim()).unwrap();
assert_eq!(unused(&out), ["`outlineText` was not used: PNG files don't read it"], "{out}");
}
#[test]
fn convert_writes_legacy_ai_with_its_options() {
// Two artboards, a shown rectangle and a hidden one.
+4 -3
View File
@@ -107,15 +107,16 @@ wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
# through winit and rfd.
wayland-backend = { version = "0.3", features = ["client_system"] }
# Waits for the screen color sampler's portal answer and for the portal leaving the bus at the same
# time (src/linux_screen_color/request.rs). Apache-2.0 OR MIT, already in the build through zbus.
futures-lite = "2"
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
# exactly: review the diff before bumping.
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
zbus = "=5.19.0"
# Waits for a portal answer (the save dialog's and the screen color sampler's) and for the portal
# leaving the bus at the same time (src/portal_request.rs). Apache-2.0 OR MIT, already in the build
# through zbus.
futures-lite = "2"
# Tells whether arboard reads through the compositor's data-control protocol or through X11, the
# same check arboard makes (src/clipboard.rs). MIT OR Apache-2.0, already in the build through
# arboard.
@@ -4,7 +4,6 @@
//! parent is the window, named on that thread as for the portal's save dialog (`portal_save`).
mod portal;
mod request;
/// The portal's color sampler over the window `parent` holds; `ui` is woken when it answers.
pub fn sampler(ui: egui::Context, parent: super::host_services::Parent) -> vectorcraft_ui_egui::screen_color::StartSampler {
@@ -61,9 +61,9 @@ pub(super) fn connect(builder: zbus::Result<zbus::blocking::connection::Builder<
/// after it has no bound, and ends when the portal answers or leaves the bus.
pub(super) fn pick(conn: &Connection, parent: &str) -> Result<Option<[f32; 3]>, Failure> {
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
let token = super::request::token("vectorcraft_color").map_err(Failure::Failed)?;
let token = crate::portal_request::token("vectorcraft_color").map_err(Failure::Failed)?;
// Made before the call: a portal can send its answer before the call returns.
let replies = super::request::Responses::new(conn).map_err(Failure::Failed)?;
let replies = crate::portal_request::Responses::new(conn).map_err(Failure::Failed)?;
let proxy = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Screenshot")
.map_err(|e| Failure::Failed(e.to_string()))?;
let options = HashMap::from([("handle_token", Value::from(token.as_str()))]);
@@ -106,6 +106,7 @@ fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<[f3
#[cfg(test)]
mod tests {
use super::*;
use crate::portal_request::test_bus::private_bus;
fn color(rgb: (f64, f64, f64)) -> HashMap<String, OwnedValue> {
HashMap::from([("color".into(), OwnedValue::try_from(Value::from(rgb)).unwrap())])
@@ -186,35 +187,6 @@ mod tests {
}
}
/// A session bus of its own (`dbus-daemon`), `None` when there is none to run.
fn private_bus() -> Option<(Daemon, String)> {
use std::io::BufRead as _;
let spawning = crate::spawning();
let daemon = std::process::Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(std::process::Stdio::piped())
.spawn();
drop(spawning);
let Ok(daemon) = daemon else {
eprintln!("no dbus-daemon: skipped");
return None;
};
let mut daemon = Daemon(daemon);
let mut address = String::new();
std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap();
Some((daemon, address.trim().to_string()))
}
/// Stops the private bus when the test ends, unwinding included.
struct Daemon(std::process::Child);
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
/// The client's connection to the private bus at `address`, made as the sampler makes its own.
fn client(address: &str) -> Connection {
connect(zbus::blocking::connection::Builder::address(address)).unwrap()
@@ -1,127 +0,0 @@
//! The answer to the color sampler's XDG desktop portal request
//! (<https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html>).
//! The wait ends with the request's `Response`, or with an error when the portal leaves the bus
//! first.
use std::io::Read as _;
use futures_lite::{StreamExt as _, future};
use zbus::blocking::{Connection, MessageIterator};
use zbus::zvariant::OwnedObjectPath;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
/// A request token: `prefix`, then 128 bits from the operating system's random device, so the
/// token is unique and can't be guessed, as the Request documentation asks.
pub(super) fn token(prefix: &str) -> Result<String, String> {
let mut bytes = [0; 16];
std::fs::File::open("/dev/urandom")
.and_then(|mut file| file.read_exact(&mut bytes))
.map_err(|e| format!("could not create a portal request token: {e}"))?;
let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
Ok(format!("{prefix}_{random}"))
}
/// Subscriptions made before a portal method call, on every request path: a portal older than
/// version 0.9 ignores `handle_token` and answers on the request path it returns, and its answer
/// can come before the call's reply. Each stream keeps every signal from the time it is made.
pub(super) struct Responses {
replies: MessageIterator,
owners: MessageIterator,
}
impl Responses {
pub(super) fn new(conn: &Connection) -> Result<Self, String> {
let reply_rule = zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender(DESKTOP)
.and_then(|b| b.interface("org.freedesktop.portal.Request"))
.and_then(|b| b.member("Response"))
.map_err(|e| e.to_string())?
.build();
let owner_rule = zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender("org.freedesktop.DBus")
.and_then(|b| b.path("/org/freedesktop/DBus"))
.and_then(|b| b.interface("org.freedesktop.DBus"))
.and_then(|b| b.member("NameOwnerChanged"))
.and_then(|b| b.arg(0, DESKTOP))
.map_err(|e| e.to_string())?
.build();
Ok(Self {
replies: MessageIterator::for_match_rule(reply_rule, conn, None).map_err(|e| e.to_string())?,
owners: MessageIterator::for_match_rule(owner_rule, conn, None).map_err(|e| e.to_string())?,
})
}
/// The `Response` on the request path `call` returned, from the connection that returned it.
/// An error when that connection leaves the bus first, or the bus connection closes.
pub(super) fn wait(self, call: &zbus::Message) -> Result<zbus::Message, String> {
enum Event {
Reply(Option<zbus::Result<zbus::Message>>),
Owner(Option<zbus::Result<zbus::Message>>),
}
let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
let portal = call.header().sender().map(|s| s.as_str().to_string()).ok_or("the portal answered without a sender")?;
let mut replies = self.replies.into_inner();
let mut owners = self.owners.into_inner();
future::block_on(async {
loop {
let event = future::or(async { Event::Reply(replies.next().await) }, async { Event::Owner(owners.next().await) }).await;
match event {
Event::Reply(reply) => {
let reply = reply.ok_or("the portal closed without an answer")?.map_err(|e| e.to_string())?;
let header = reply.header();
if header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == Some(portal.as_str())
{
return Ok(reply);
}
}
Event::Owner(message) => {
let message = message.ok_or("the portal connection closed")?.map_err(|e| e.to_string())?;
if owner_disappeared(&message, &portal)? {
return Err("the desktop portal disappeared while waiting for its answer".into());
}
}
}
}
})
}
}
/// Whether `message` is the bus's `NameOwnerChanged` saying that the connection `portal` no longer
/// owns the portal's name. The bus itself must send it: another connection can't end the wait.
fn owner_disappeared(message: &zbus::Message, portal: &str) -> Result<bool, String> {
if message.header().sender().map(|s| s.as_str()) != Some("org.freedesktop.DBus") {
return Ok(false);
}
let (name, old, new): (String, String, String) = message.body().deserialize().map_err(|e| e.to_string())?;
Ok(name == DESKTOP && old == portal && new != portal)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_bus_can_report_portal_owner_loss() {
let changed = |sender, name, old, new| {
zbus::Message::signal("/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged")
.unwrap()
.sender(sender)
.unwrap()
.build(&(name, old, new))
.unwrap()
};
let forged = changed(":1.42", DESKTOP, ":1.7", "");
assert!(!owner_disappeared(&forged, ":1.7").unwrap());
let real = changed("org.freedesktop.DBus", DESKTOP, ":1.7", "");
assert!(owner_disappeared(&real, ":1.7").unwrap());
let other = changed("org.freedesktop.DBus", "org.example.Other", ":1.7", "");
assert!(!owner_disappeared(&other, ":1.7").unwrap());
let old = changed("org.freedesktop.DBus", DESKTOP, ":1.8", "");
assert!(!owner_disappeared(&old, ":1.7").unwrap());
let acquired = changed("org.freedesktop.DBus", DESKTOP, "", ":1.7");
assert!(!owner_disappeared(&acquired, ":1.7").unwrap());
}
}
+2
View File
@@ -53,6 +53,8 @@ mod mac_tablet;
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
mod portal_request;
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))]
mod portal_save;
mod prefs_dir;
+231
View File
@@ -0,0 +1,231 @@
//! Linux and the BSDs: the answer to an XDG desktop portal request
//! (<https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html>),
//! for the save dialog (`portal_save`) and, on Linux, the screen color sampler
//! (`linux_screen_color`). The wait ends with the request's `Response`, or with an error when the
//! portal leaves the bus first.
use futures_lite::{StreamExt as _, future};
use zbus::blocking::{Connection, MessageIterator};
use zbus::zvariant::OwnedObjectPath;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
/// A request token: `prefix`, then 128 bits from the operating system's random device, so the
/// token is unique and can't be guessed, as the Request documentation asks.
#[cfg(target_os = "linux")]
pub(crate) fn token(prefix: &str) -> Result<String, String> {
use std::io::Read as _;
let mut bytes = [0; 16];
std::fs::File::open("/dev/urandom")
.and_then(|mut file| file.read_exact(&mut bytes))
.map_err(|e| format!("could not create a portal request token: {e}"))?;
let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
Ok(format!("{prefix}_{random}"))
}
/// Subscriptions made before a portal method call, on every request path: a portal older than
/// version 0.9 ignores `handle_token` and answers on the request path it returns, and its answer
/// can come before the call's reply. Each stream keeps every signal from the time it is made.
pub(crate) struct Responses {
replies: MessageIterator,
owners: MessageIterator,
}
impl Responses {
pub(crate) fn new(conn: &Connection) -> Result<Self, String> {
let reply_rule = zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender(DESKTOP)
.and_then(|b| b.interface("org.freedesktop.portal.Request"))
.and_then(|b| b.member("Response"))
.map_err(|e| e.to_string())?
.build();
let owner_rule = zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender("org.freedesktop.DBus")
.and_then(|b| b.path("/org/freedesktop/DBus"))
.and_then(|b| b.interface("org.freedesktop.DBus"))
.and_then(|b| b.member("NameOwnerChanged"))
.and_then(|b| b.arg(0, DESKTOP))
.map_err(|e| e.to_string())?
.build();
Ok(Self {
replies: MessageIterator::for_match_rule(reply_rule, conn, None).map_err(|e| e.to_string())?,
owners: MessageIterator::for_match_rule(owner_rule, conn, None).map_err(|e| e.to_string())?,
})
}
/// The `Response` on the request path `call` returned, from the connection that returned it.
/// An error when that connection leaves the bus first, or the bus connection closes. A
/// `Response` the portal sent before it left is still the answer.
pub(crate) fn wait(self, call: &zbus::Message) -> Result<zbus::Message, String> {
enum Event {
Reply(Option<zbus::Result<zbus::Message>>),
Owner(Option<zbus::Result<zbus::Message>>),
}
let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
let portal = call.header().sender().map(|s| s.as_str().to_string()).ok_or("the portal answered without a sender")?;
let answers = |reply: &zbus::Message| {
let header = reply.header();
header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == Some(portal.as_str())
};
let mut replies = self.replies.into_inner();
let mut owners = self.owners.into_inner();
future::block_on(async {
loop {
let event = future::or(async { Event::Owner(owners.next().await) }, async { Event::Reply(replies.next().await) }).await;
match event {
Event::Reply(reply) => {
let reply = reply.ok_or("the portal closed without an answer")?.map_err(|e| e.to_string())?;
if answers(&reply) {
return Ok(reply);
}
}
Event::Owner(message) => {
let message = message.ok_or("the portal connection closed")?.map_err(|e| e.to_string())?;
if owner_disappeared(&message, &portal)? {
// zbus puts each message in every matching stream before it reads the
// next one, so a `Response` that came before the bus's report is
// already queued: it is still the answer.
while let Some(Some(reply)) = future::poll_once(replies.next()).await {
if let Ok(reply) = reply
&& answers(&reply)
{
return Ok(reply);
}
}
return Err("the desktop portal disappeared while waiting for its answer".into());
}
}
}
}
})
}
}
/// Whether `message` is the bus's `NameOwnerChanged` saying that the connection `portal` no longer
/// owns the portal's name. The bus itself must send it: another connection can't end the wait.
fn owner_disappeared(message: &zbus::Message, portal: &str) -> Result<bool, String> {
if message.header().sender().map(|s| s.as_str()) != Some("org.freedesktop.DBus") {
return Ok(false);
}
let (name, old, new): (String, String, String) = message.body().deserialize().map_err(|e| e.to_string())?;
Ok(name == DESKTOP && old == portal && new != portal)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_bus_can_report_portal_owner_loss() {
let changed = |sender, name, old, new| {
zbus::Message::signal("/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged")
.unwrap()
.sender(sender)
.unwrap()
.build(&(name, old, new))
.unwrap()
};
let forged = changed(":1.42", DESKTOP, ":1.7", "");
assert!(!owner_disappeared(&forged, ":1.7").unwrap());
let real = changed("org.freedesktop.DBus", DESKTOP, ":1.7", "");
assert!(owner_disappeared(&real, ":1.7").unwrap());
let other = changed("org.freedesktop.DBus", "org.example.Other", ":1.7", "");
assert!(!owner_disappeared(&other, ":1.7").unwrap());
let old = changed("org.freedesktop.DBus", DESKTOP, ":1.8", "");
assert!(!owner_disappeared(&old, ":1.7").unwrap());
let acquired = changed("org.freedesktop.DBus", DESKTOP, "", ":1.7");
assert!(!owner_disappeared(&acquired, ":1.7").unwrap());
}
/// Through a real D-Bus: when the bus has reported the portal gone, a `Response` the portal sent
/// before it left is still the answer; without one the wait ends with an error.
#[test]
fn a_queued_answer_wins_over_the_portal_leaving() {
use std::collections::HashMap;
use zbus::blocking::connection::Builder;
use zbus::zvariant::OwnedValue;
for answered in [true, false] {
let Some((_daemon, address)) = test_bus::private_bus() else { return };
let address = address.as_str();
let portal = Builder::address(address).unwrap().name(DESKTOP).unwrap().build().unwrap();
let unique = portal.unique_name().unwrap().to_string();
let client = Builder::address(address).unwrap().build().unwrap();
let responses = Responses::new(&client).unwrap();
// zbus puts each message in every matching stream before it reads the next one
// (`src/connection/socket_reader.rs`), so once this stream has the bus's report,
// `responses` holds the `Response` sent before it.
let gone_rule = zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender("org.freedesktop.DBus")
.unwrap()
.member("NameOwnerChanged")
.unwrap()
.arg(0, DESKTOP)
.unwrap()
.build();
let gone = MessageIterator::for_match_rule(gone_rule, &client, None).unwrap();
let path = "/org/freedesktop/portal/desktop/request/test/t1";
if answered {
let results = HashMap::<String, OwnedValue>::new();
portal.emit_signal(None::<&str>, path, "org.freedesktop.portal.Request", "Response", &(0_u32, results)).unwrap();
}
portal.close().unwrap();
// On a thread of its own, so a report that never comes fails the test instead of
// hanging it.
let (seen, report) = std::sync::mpsc::channel();
let left = unique.clone();
std::thread::spawn(move || {
let mut gone = gone;
let _ = seen.send(gone.any(|m| m.is_ok_and(|m| owner_disappeared(&m, &left).unwrap_or(false))));
});
assert_eq!(report.recv_timeout(std::time::Duration::from_secs(30)), Ok(true), "the bus did not report the portal gone");
// The call's reply as `wait` reads it: the request path, from the portal's connection.
let call = zbus::Message::signal("/", "org.example.Test", "Reply")
.unwrap()
.sender(unique.as_str())
.unwrap()
.build(&OwnedObjectPath::try_from(path).unwrap())
.unwrap();
let waited = responses.wait(&call);
assert_eq!(waited.is_ok(), answered, "{waited:?}");
}
}
}
/// The private session bus that the D-Bus tests of the portal requests run on.
#[cfg(test)]
pub(crate) mod test_bus {
/// A session bus of its own (`dbus-daemon`), `None` when there is none to run.
pub(crate) fn private_bus() -> Option<(Daemon, String)> {
use std::io::BufRead as _;
let spawning = crate::spawning();
let daemon = std::process::Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(std::process::Stdio::piped())
.spawn();
drop(spawning);
let Ok(daemon) = daemon else {
eprintln!("no dbus-daemon: skipped");
return None;
};
let mut daemon = Daemon(daemon);
let mut address = String::new();
std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap();
Some((daemon, address.trim().to_string()))
}
/// Stops the private bus when the test ends, unwinding included.
pub(crate) struct Daemon(std::process::Child);
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
}
+56 -45
View File
@@ -8,6 +8,7 @@
//! ([`vectorcraft_ui_egui::picks::PickAnswer`]); the app gives the name that type's extension.
//! rfd shows the dialog when the portal can't (no session bus, no file chooser), with zenity as its
//! own fallback. PhotoCraft's `portal_save.rs` (storytold/photocraft#6151) makes the same call.
//! rfd also shows the dialog when the portal leaves the bus before it answers.
//!
//! The dialog's parent is the window: `x11:<window id>` on X11, and on Wayland (Linux) a handle
//! from the compositor's xdg-foreign protocol, as rfd names it (its `window_identifier`).
@@ -183,37 +184,18 @@ pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option<V
fn save_file(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
use std::sync::atomic::{AtomicU32, Ordering};
use zbus::blocking::{MessageIterator, Proxy};
use zbus::zvariant::OwnedObjectPath;
use zbus::blocking::Proxy;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
const REQUEST: &str = "org.freedesktop.portal.Request";
static NEXT: AtomicU32 = AtomicU32::new(0);
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
// Listen before asking, on every request path: a portal older than version 0.9 ignores
// `handle_token` and answers on the request path it returns, and its answer can come before
// the call's reply. The stream keeps every `Response` from the time it is made.
let rule = zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender(DESKTOP)
.and_then(|b| b.interface(REQUEST))
.and_then(|b| b.member("Response"))
.map_err(|e| e.to_string())?
.build();
let responses = MessageIterator::for_match_rule(rule, conn, None).map_err(|e| e.to_string())?;
// Made before the call: a portal can send its answer before the call returns, and a portal
// that leaves the bus while its dialog is open ends the wait.
let responses = crate::portal_request::Responses::new(conn)?;
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
// The title is empty, as rfd sends it for a save.
let call = chooser.call_method("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
// The answer is the `Response` on the path returned, from the connection that returned it.
let portal = call.header().sender().map(|s| s.as_str().to_string());
let reply = responses
.filter_map(Result::ok)
.find(|m| {
let header = m.header();
header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref()
})
.ok_or("the save dialog closed without an answer")?;
let reply = responses.wait(&call)?;
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
answer(code, &results)
}
@@ -495,26 +477,22 @@ mod tests {
Old,
/// As `Old`, with the `Response` sent before the reply that names its path.
OldEarly,
/// As `Current`, but it leaves the bus 300 ms after its reply without a `Response`, as a
/// portal that exits while its dialog is open.
Exits,
/// Leaves the bus right after its reply, without a `Response`.
ExitsAtOnce,
/// Sends its `Response` right after its reply, then leaves the bus.
AnswersAndExits,
}
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
/// that answers `SaveFile` from `script` as `portal` says, as PhotoCraft's portal tests run it.
/// `None` when there is no `dbus-daemon` to run.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
use std::io::BufRead as _;
fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, crate::portal_request::test_bus::Daemon)> {
use zbus::zvariant::OwnedObjectPath;
let spawning = crate::spawning();
let daemon = std::process::Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(std::process::Stdio::piped())
.spawn();
drop(spawning);
let mut daemon = daemon.ok()?;
let mut address = String::new();
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
let address = address.trim().to_string();
let (daemon, address) = crate::portal_request::test_bus::private_bus()?;
let server =
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
let seen = Seen::default();
@@ -539,8 +517,8 @@ mod tests {
});
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
let token = match portal {
Portal::Current => text("handle_token").unwrap_or_default(),
Portal::Old | Portal::OldEarly => format!("old{}", calls.unwrap_or_default()),
Portal::Current | Portal::Exits | Portal::ExitsAtOnce | Portal::AnswersAndExits => text("handle_token").unwrap_or_default(),
};
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
@@ -558,14 +536,22 @@ mod tests {
if server.reply(&header, &handle).is_err() {
break;
}
if portal == Portal::Old {
if matches!(portal, Portal::Old | Portal::Exits) {
// As a person answers the dialog: after the caller has the path returned.
std::thread::sleep(std::time::Duration::from_millis(300));
}
if matches!(portal, Portal::Exits | Portal::ExitsAtOnce) {
break;
}
if portal != Portal::OldEarly && respond().is_err() {
break;
}
if portal == Portal::AnswersAndExits {
break;
}
}
// The portal leaves the bus: the bus sends `NameOwnerChanged` for its name.
let _ = server.close();
});
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
Some((client, seen, daemon))
@@ -589,7 +575,7 @@ mod tests {
#[test]
fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() {
let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)];
let Some((conn, seen, mut daemon)) = mock_portal(script, Portal::Current) else {
let Some((conn, seen, _daemon)) = mock_portal(script, Portal::Current) else {
eprintln!("no dbus-daemon: skipped");
return;
};
@@ -599,7 +585,6 @@ mod tests {
assert_eq!(save_within(&conn, &request), Ok(None));
let seen = seen.lock().map(|s| s.clone()).unwrap_or_default();
assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone()))));
let _ = daemon.kill();
}
/// A portal older than 0.9 answers on the request path it returns, and the answer is awaited
@@ -607,13 +592,12 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn the_answer_is_awaited_on_the_path_the_portal_returns() {
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else {
let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
let _ = daemon.kill();
}
/// A portal older than 0.9 can answer before its reply names the request path: the answer is
@@ -622,14 +606,41 @@ mod tests {
#[test]
fn an_answer_that_comes_before_the_path_is_kept() {
let script = vec![(0, Some(("file:///tmp/card", "SVG"))), (1, None)];
let Some((conn, _seen, mut daemon)) = mock_portal(script, Portal::OldEarly) else {
let Some((conn, _seen, _daemon)) = mock_portal(script, Portal::OldEarly) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
assert_eq!(save_within(&conn, &request), Ok(None));
let _ = daemon.kill();
}
/// A portal that leaves the bus after its reply without answering, while its dialog is open or
/// at once, ends the wait with an error, and rfd shows the dialog.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn a_portal_that_exits_during_the_dialog_ends_the_wait() {
for portal in [Portal::Exits, Portal::ExitsAtOnce] {
let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], portal) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_within(&conn, &request()), Err("the desktop portal disappeared while waiting for its answer".to_string()));
}
}
/// A portal that answers and then leaves the bus: the save reads the answer. Whether the answer
/// or the bus's report comes first depends on timing here; `portal_request`'s
/// `a_queued_answer_wins_over_the_portal_leaving` queues both before the wait.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn an_answer_sent_just_before_the_portal_exits_is_kept() {
let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::AnswersAndExits) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
}
/// The labels go to the dialog in the interface's language, which is how the answer is matched.
+1 -1
View File
@@ -531,7 +531,7 @@ impl Cms {
}
}
/// Gamut warning: can this colour be printed in the working CMYK space?
/// Gamut warning: can this color be printed in this context's CMYK space?
pub fn out_of_gamut(&self, c: &Color) -> bool {
self.gamut_error(c) > GAMUT_THRESHOLD
}
+135 -25
View File
@@ -65,24 +65,62 @@ pub enum GradientMethod {
/// Mixed in a perceptual colour space (OKLab), so the steps look even to the eye: blue to
/// yellow passes through a light blue-grey rather than a dull mid grey.
Perceptual,
/// Mixed around the hue wheel, clockwise as the color wheels draw it: the hue angle falls
/// from each stop to the next (red, magenta, blue, cyan, green, yellow, red), and saturation
/// and brightness change evenly ([`hue_mix`]).
HueClockwise,
/// Mixed around the hue wheel the other way: the hue angle rises from each stop to the next
/// (red, yellow, green, cyan, blue, magenta, red).
HueCounterclockwise,
}
impl GradientMethod {
/// Parse a method name (`classic`, `perceptual`; any case).
/// Every method, in the order the Gradient panel lists them.
pub const ALL: [GradientMethod; 4] = [Self::Classic, Self::Perceptual, Self::HueClockwise, Self::HueCounterclockwise];
/// Parse a method name (`classic`, `perceptual`, `hue-clockwise`, `hue-counterclockwise`;
/// any case, and the hyphen may be a space or an underscore, or left out). Any other text,
/// one with a separator elsewhere included, is `None`.
pub fn parse(s: &str) -> Option<Self> {
[Self::Classic, Self::Perceptual].into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
let name = s.to_ascii_lowercase();
Self::ALL.into_iter().find(|m| ["-", " ", "_", ""].iter().any(|sep| m.id().replace('-', sep) == name))
}
pub fn label(self) -> &'static str {
match self {
Self::Classic => "Classic",
Self::Perceptual => "Perceptual",
Self::HueClockwise => "Hue Clockwise",
Self::HueCounterclockwise => "Hue Counterclockwise",
}
}
/// The method's name in command params (`classic`, `perceptual`, `hue-clockwise`,
/// `hue-counterclockwise`).
pub fn id(self) -> &'static str {
match self {
Self::Classic => "classic",
Self::Perceptual => "perceptual",
Self::HueClockwise => "hue-clockwise",
Self::HueCounterclockwise => "hue-counterclockwise",
}
}
pub fn is_classic(&self) -> bool {
*self == Self::Classic
}
/// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), or in OKLab (Perceptual: an
/// RGB colour between the two).
/// Does it mix around the hue wheel (Hue Clockwise or Hue Counterclockwise)? The colors
/// between two stops then are not mixes of the stops' inks.
pub fn is_hue(&self) -> bool {
matches!(self, Self::HueClockwise | Self::HueCounterclockwise)
}
/// The method that mixes `b` to `a` through the colors this one mixes `a` to `b` through: a
/// hue method turned the other way, Classic and Perceptual as they are.
pub fn mirrored(self) -> Self {
match self {
Self::HueClockwise => Self::HueCounterclockwise,
Self::HueCounterclockwise => Self::HueClockwise,
m => m,
}
}
/// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), in OKLab (Perceptual: an
/// RGB colour between the two), or around the hue wheel ([`hue_mix`]: an RGB colour).
pub fn mix(self, a: &Color, b: &Color, t: f32) -> Color {
match self {
Self::Classic => Color::lerp(a, b, t),
@@ -93,10 +131,68 @@ impl GradientMethod {
let c = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 };
Color::rgb(c(r), c(g), c(bl))
}
Self::HueClockwise => hue_mix(a, b, t, false),
Self::HueCounterclockwise => hue_mix(a, b, t, true),
}
}
}
/// The brightness (0..1) at or below which a color is black for [`hue_mix`], and the spread
/// between its largest and smallest RGB numbers (saturation × brightness) at or below which it
/// has no hue of its own: under half a color level.
const NO_HUE: f32 = 0.4 / 255.0;
/// The hue difference (degrees) that moves an RGB number by half a color level in a color whose
/// largest and smallest RGB numbers are 1 apart; a color with a smaller spread takes
/// proportionally more degrees. [`hue_mix`] counts two hues closer than this as one hue.
const SAME_HUE: f32 = 60.0 * 0.5 / 255.0;
/// `a` and `b` mixed at `t` (0..1) around the hue wheel, as display RGB: hue (degrees),
/// saturation and brightness each change evenly from `a`'s to `b`'s. The hue angle only rises
/// (`rising`) or only falls, by the fixed-direction rules of
/// <https://www.w3.org/TR/css-color-4/#hue-interpolation>, and two equal hues stay where they are.
/// Two hues that differ by less than half a color level ([`SAME_HUE`]) count as equal and take the
/// short way, so a color and a tint or shade of it, whose computed hues differ by rounding, do
/// not turn the whole wheel. A color whose RGB numbers are less than half a color level apart
/// ([`NO_HUE`]) has no hue and takes the other color's; black has no saturation either and takes
/// the other color's too. Two colors without a hue mix as grays. A `t` that is not a number
/// gives `a`.
pub fn hue_mix(a: &Color, b: &Color, t: f32, rising: bool) -> Color {
let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
let ([mut h0, mut s0, v0], [mut h1, mut s1, v1]) = (a.to_hsb(), b.to_hsb());
let (black0, black1) = (v0 <= NO_HUE, v1 <= NO_HUE);
// The spread between each color's own largest and smallest RGB numbers.
let (spread0, spread1) = (s0 * v0, s1 * v1);
// Black takes the other color's saturation, so it darkens that color rather than graying it.
if black0 && !black1 {
s0 = s1;
} else if black1 && !black0 {
s1 = s0;
}
let (gray0, gray1) = (black0 || spread0 <= NO_HUE, black1 || spread1 <= NO_HUE);
match (gray0, gray1) {
(true, _) => h0 = h1,
(false, true) => h1 = h0,
(false, false) => {
// The short way from the first hue to the second, -180..180.
let short = (h1 - h0 + 180.0).rem_euclid(360.0) - 180.0;
h1 = if short.abs() * spread0.min(spread1) <= SAME_HUE {
h0 + short
} else if rising {
h0 + short.rem_euclid(360.0)
} else {
h0 + short.rem_euclid(360.0) - 360.0
};
}
}
let at = |p: f32, q: f32| p + (q - p) * t;
let (h, s, v) = (at(h0, h1), at(s0, s1), at(v0, v1));
if !(h.is_finite() && s.is_finite() && v.is_finite()) {
return Color::rgb(0.0, 0.0, 0.0);
}
Color::from_hsb(h.rem_euclid(360.0), s.clamp(0.0, 1.0), v.clamp(0.0, 1.0))
}
/// What a linear, radial, conic or diamond gradient paints past the ends of its stops (the
/// Gradient panel's Spread). With Repeat and Reflect, the stops run [`Gradient::repeats`] times
/// along the vector (around the circle, for a conic), and keep going past both of its ends.
@@ -192,10 +288,11 @@ const MAX_INK_TINTS: usize = 4096;
const INK_MIX_STEPS: u32 = 64;
/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly
/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved
/// where a straight line strays, at most [`PERCEPTUAL_DEPTH`] times, in order.
fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec<f32>) {
let at = |v: f32| GradientMethod::Perceptual.mix(a, b, v).to_rgb();
/// stays within [`PERCEPTUAL_TOLERANCE`] of `method`'s mix of `a` and `b` (Perceptual, or around
/// the hue wheel): the span halved where a straight line strays, at most [`PERCEPTUAL_DEPTH`]
/// times, in order.
fn perceptual_steps(method: GradientMethod, a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec<f32>) {
let at = |v: f32| method.mix(a, b, v).to_rgb();
let (c0, c1) = (at(v0), at(v1));
let stray = [0.25, 0.5, 0.75]
.into_iter()
@@ -206,19 +303,20 @@ fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out:
.fold(0.0, f32::max);
if depth < PERCEPTUAL_DEPTH && stray > PERCEPTUAL_TOLERANCE {
let m = (v0 + v1) / 2.0;
perceptual_steps(a, b, (v0, m), depth + 1, out);
perceptual_steps(method, a, b, (v0, m), depth + 1, out);
out.push(m);
perceptual_steps(a, b, (m, v1), depth + 1, out);
perceptual_steps(method, a, b, (m, v1), depth + 1, out);
}
}
/// The mix positions (`v`, 0..1) of the stops a Perceptual gradient's curve has between stops `a`
/// and `b`, in order: [`perceptual_steps`] on either side of the midpoint, and the midpoint (½).
fn perceptual_mixes(a: &GradientStop, b: &GradientStop) -> Vec<f32> {
/// The mix positions (`v`, 0..1) of the stops the curve of a gradient mixed by `method`
/// (Perceptual, or around the hue wheel) has between stops `a` and `b`, in order:
/// [`perceptual_steps`] on either side of the midpoint, and the midpoint (½).
fn perceptual_mixes(method: GradientMethod, a: &GradientStop, b: &GradientStop) -> Vec<f32> {
let mut vs = Vec::new();
perceptual_steps(&a.color, &b.color, (0.0, 0.5), 1, &mut vs);
perceptual_steps(method, &a.color, &b.color, (0.0, 0.5), 1, &mut vs);
vs.push(0.5);
perceptual_steps(&a.color, &b.color, (0.5, 1.0), 1, &mut vs);
perceptual_steps(method, &a.color, &b.color, (0.5, 1.0), 1, &mut vs);
vs
}
@@ -512,10 +610,11 @@ impl Gradient {
};
(t, c, o, Some(s.midpoint), None)
});
// Perceptual: stops along the mix (`v`) where a straight line would stray from it, the
// midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them.
// Perceptual and the hue methods: stops along the mix (`v`) where a straight line would
// stray from it, the midpoint (v = ½) among them, placed through the midpoint as
// [`Self::sample`] maps them.
let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| {
perceptual_mixes(s, n).into_iter().map(move |v| {
perceptual_mixes(self.method, s, n).into_iter().map(move |v| {
let mid = v == 0.5;
let t = curve_offset(s, n, v);
let o = s.opacity + (n.opacity - s.opacity) * v;
@@ -571,11 +670,12 @@ impl Gradient {
/// position itself when the two colors' lightness differs by less than
/// [`MIN_INK_LIGHTNESS_SPAN`], or when the display RGB mix's lightness turns back somewhere
/// on the way from one color's lightness to the other's (checked at [`INK_MIX_STEPS`] even
/// steps), as from red to blue. `None` for a Classic gradient, and when the curve has more
/// than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
/// steps), as from red to blue. `None` for a Classic gradient, for one mixed around the hue
/// wheel (its colors between two stops are not mixes of the stops' inks), and when the curve
/// has more than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
pub fn ink_mixes(&self) -> Option<Vec<(f32, usize, f32, f32)>> {
use crate::libraries::srgb_to_oklab;
if self.method.is_classic() || !self.fits_ink_tints() {
if self.method != GradientMethod::Perceptual || !self.fits_ink_tints() {
return None;
}
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
@@ -592,7 +692,7 @@ impl Gradient {
.collect::<Vec<_>>()
.windows(2)
.all(|w| (w[1] - w[0]) * (l1 - l0) >= 0.0);
for v in perceptual_mixes(s, n) {
for v in perceptual_mixes(self.method, s, n) {
let weight = if by_position {
v
} else {
@@ -650,8 +750,10 @@ impl Gradient {
Some(Ramp { stops })
}
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
/// segment's midpoint mirrored with it.
/// segment's midpoint mirrored with it, and a hue method turned the other way (red to blue
/// through magenta, reversed, is blue to red through magenta).
pub fn reverse(&mut self) {
self.method = self.method.mirrored();
self.stops.reverse();
for i in 0..self.stops.len() {
// A midpoint belongs to the segment after its stop: reversed, stop i's segment is the
@@ -666,6 +768,13 @@ impl Gradient {
pub fn sort(&mut self) {
self.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
}
/// The gradient's colors as a Classic gradient: its curve's stops ([`Self::expanded_stops`]) as
/// stops of their own, mixed in display RGB, within [`PERCEPTUAL_TOLERANCE`] of it. The stops
/// keep no swatch links, tints or process plates.
pub fn as_classic_curve(&self) -> Gradient {
let stops = self.expanded().map(|(t, color, opacity, _)| GradientStop { opacity, ..GradientStop::new(t, color) }).collect();
Gradient { kind: self.kind, stops, dither: self.dither, method: GradientMethod::Classic, spread: self.spread, repeats: self.repeats }
}
/// The part of the gradient from `from` to `to` (0..1, either way round) as a linear gradient
/// of its own running 0 → 1: the same colours (midpoints become stops, as in
/// [`Self::expanded_stops`]). One colour throughout when `from == to`.
@@ -1261,8 +1370,9 @@ impl GradientPaint {
}
/// Is this gradient drawn as pixels rather than as a vector gradient when it paints a fill
/// (`fill`) or a stroke (SVG and PDF draw such gradients as images): a freeform gradient, a
/// dithered fill (dither applies to fills only), or a Perceptual one (its colours don't mix
/// linearly between the stops, as SVG's and PDF's gradients do)?
/// dithered fill (dither applies to fills only), or a Perceptual one or one mixed around the
/// hue wheel (its colours don't mix linearly between the stops, as SVG's and PDF's gradients
/// do)?
pub fn draws_as_pixels(&self, fill: bool) -> bool {
self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic()
}
+16
View File
@@ -59,6 +59,9 @@ impl Gradient {
if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() {
return None;
}
if self.mirrors_a_hue_turn() {
return self.as_classic_curve().padded_within(geom, area, max_stops);
}
let period = geom.period(kind, self.repeat_count());
let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)];
let ts = corners.map(|p| period.param_at(kind, p));
@@ -128,6 +131,9 @@ impl Gradient {
if self.stops.is_empty() {
return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() });
}
if self.mirrors_a_hue_turn() {
return Cow::Owned(self.as_classic_curve().unrolled_within(max_stops).into_owned());
}
let n = self.repeat_count() as usize;
let budget = max_stops.min(MAX_SPREAD_STOPS);
let kept = n.min(budget / 2).max(1);
@@ -159,12 +165,22 @@ impl Gradient {
if !self.is_spread() || self.stops.is_empty() {
return None;
}
if self.mirrors_a_hue_turn() {
return self.as_classic_curve().cycle();
}
let one = whole_run(&self.stops);
let runs = if self.spread == GradientSpread::Reflect { 2 } else { 1 };
let stops = self.runs(&one, false, 0.0, runs, runs as f64);
Some((Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }, runs as u32))
}
/// Does the gradient reflect while mixing around the hue wheel? A mirrored run of its stops
/// would turn the wheel the other way, and one gradient has one method, so its runs are
/// written from its curve's stops ([`Self::as_classic_curve`]), which mirror as they are.
fn mirrors_a_hue_turn(&self) -> bool {
self.method.is_hue() && self.spread == GradientSpread::Reflect
}
/// `n` runs of `one` (one run of the stops, [`whole_run`]) on a line of `of` runs from 0 to 1,
/// the first of them `start` runs along it and an odd-numbered run when `first_odd` (with
/// Reflect, the odd-numbered runs are mirrored).
+40
View File
@@ -118,6 +118,46 @@ fn reflect_mirrors_every_other_run_and_its_midpoints() {
assert!((p.gradient.stops[2].midpoint - 0.7).abs() < 1e-6, "{:?}", p.gradient.stops);
}
/// A gradient mixed around the hue wheel that reflects: a mirrored run turns the wheel back the
/// way it came, so every point keeps its color when the runs are written out as a gradient that
/// pads, unrolled along the vector, or as one cycle, with a repeating one's too.
#[test]
fn reflected_and_repeated_hue_turns_keep_every_color() {
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None };
let stops = || {
let mut red = GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0));
red.midpoint = 0.3;
vec![red, GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]
};
let near = |a: Color, b: Color, what: &str| {
assert!(a.to_rgb().iter().zip(b.to_rgb()).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{what}: {a:?} vs {b:?}");
};
for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
for spread in [GradientSpread::Reflect, GradientSpread::Repeat] {
let g = Gradient { method, spread, repeats: 2, ..Gradient::new(GradientKind::Linear, stops()) };
let p = g.padded_over(&geom, Rect::new(-10.0, 0.0, 20.0, 10.0)).unwrap();
assert_eq!((p.gradient.spread, p.gradient.repeats), (GradientSpread::Pad, 1));
for k in 0..300 {
let q = Point::new(-10.0 + f64::from(k) * 0.1 + 0.013, 5.0);
let want = g.sample(g.spread_param(geom.param_at(GradientKind::Linear, q)) as f32).0;
let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0;
near(want, got, &format!("{method:?} {spread:?} padded at {}", q.x));
}
let unrolled = g.unrolled();
let (cycle, runs) = g.cycle().unwrap();
for k in 0..=200 {
let t = (k as f32 + 0.37) / 201.0;
let want = g.sample(g.spread_param(f64::from(t)) as f32).0;
near(want, unrolled.sample(t).0, &format!("{method:?} {spread:?} unrolled at {t}"));
// One cycle covers `runs` of the two runs between 0 and 1.
let along = t * runs as f32 / 2.0;
let want = g.sample(g.spread_param(f64::from(along)) as f32).0;
near(want, cycle.sample(t).0, &format!("{method:?} {spread:?} cycle at {t}"));
}
}
}
}
#[test]
fn stops_inside_a_run_hold_their_colors_to_its_ends() {
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None };
+277
View File
@@ -514,6 +514,283 @@ fn perceptual_gradients_draw_as_pixels() {
}
}
fn two_stops(a: Color, b: Color, method: GradientMethod) -> Gradient {
let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]);
g.method = method;
g
}
/// The hue methods mix around the hue wheel in one direction: Hue Counterclockwise raises the
/// hue angle from each stop to the next and Hue Clockwise lowers it, by the fixed-direction rules
/// of <https://www.w3.org/TR/css-color-4/#hue-interpolation> (the angles below are that
/// section's own examples), with saturation and brightness changing evenly.
#[test]
fn hue_methods_turn_one_way_around_the_wheel() {
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let rising = two_stops(red, blue, GradientMethod::HueCounterclockwise);
assert_eq!(rising.sample(0.0).0.to_hex(), "#ff0000");
assert_eq!(rising.sample(0.25).0.to_hex(), "#ffff00", "60°");
assert_eq!(rising.sample(0.5).0.to_hex(), "#00ff00", "120°");
assert_eq!(rising.sample(1.0).0.to_hex(), "#0000ff");
let falling = two_stops(red, blue, GradientMethod::HueClockwise);
assert_eq!(falling.sample(0.0).0.to_hex(), "#ff0000");
assert_eq!(falling.sample(0.5).0.to_hex(), "#ff00ff", "300°");
assert_eq!(falling.sample(1.0).0.to_hex(), "#0000ff");
assert_eq!(two_stops(red, blue, GradientMethod::Classic).sample(0.5).0.to_rgb(), [0.5, 0.0, 0.5], "classic: the RGB mix");
// 30° to 190° and to 230°: halfway is 110° and 130° rising, 290° and 310° falling.
for (to, up, down) in [(190.0, 110.0, 290.0), (230.0, 130.0, 310.0)] {
let (a, b) = (Color::from_hsb(30.0, 0.5, 0.8), Color::from_hsb(to, 0.9, 0.4));
for (method, want) in [(GradientMethod::HueCounterclockwise, up), (GradientMethod::HueClockwise, down)] {
let [h, s, v] = method.mix(&a, &b, 0.5).to_hsb();
assert!((h - want).abs() < 0.05, "{method:?} to {to}: hue {h}, not {want}");
assert!((s - 0.7).abs() < 1e-3 && (v - 0.6).abs() < 1e-3, "{method:?}: saturation {s}, brightness {v}");
}
}
// The other way round, each method takes the arc the other took.
let back = two_stops(blue, red, GradientMethod::HueClockwise);
assert_eq!(back.sample(0.5).0.to_hex(), "#00ff00");
// A position that is not a number gives the first color.
assert_eq!(hue_mix(&red, &blue, f32::NAN, true).to_hex(), "#ff0000");
}
/// A color without a hue does not turn the wheel: a gray takes the hue of the stop it mixes
/// with, black its saturation too, two grays mix as grays, and two stops of one hue keep that
/// hue, whichever way the method turns.
#[test]
fn hue_methods_keep_the_hue_where_a_stop_has_none() {
let red = Color::rgb(1.0, 0.0, 0.0);
let close = |c: Color, want: [f32; 3], what: &str| {
let got = c.to_rgb();
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{what}: {got:?}, not {want:?}");
};
let green = Color::rgb(0.0, 1.0, 0.0);
for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
close(method.mix(&Color::WHITE, &green, 0.5), [0.5, 1.0, 0.5], "white to green: a light green");
close(method.mix(&green, &Color::WHITE, 0.5), [0.5, 1.0, 0.5], "green to white: a light green");
close(method.mix(&Color::rgb(0.5, 0.5, 0.5), &green, 0.5), [0.375, 0.75, 0.375], "gray to green");
close(method.mix(&Color::BLACK, &green, 0.5), [0.0, 0.5, 0.0], "black to green: a dark green, not a gray green");
close(method.mix(&green, &Color::BLACK, 0.25), [0.0, 0.75, 0.0], "green to black");
close(method.mix(&Color::BLACK, &Color::WHITE, 0.5), [0.5, 0.5, 0.5], "black to white: gray");
close(method.mix(&Color::BLACK, &Color::BLACK, 0.5), [0.0, 0.0, 0.0], "black to black");
let dark = Color::rgb(0.5, 0.0, 0.0);
close(method.mix(&red, &dark, 0.5), [0.75, 0.0, 0.0], "one hue: a red between the two");
close(method.mix(&red, &red, 0.5), [1.0, 0.0, 0.0], "one color stays");
// One hue with saturation and brightness both changing: the hue stays, and the color is
// the even mix of saturation and brightness, which is not Classic's RGB mix.
let pink = Color::rgb(1.0, 0.8, 0.8);
close(method.mix(&dark, &pink, 0.5), [0.75, 0.3, 0.3], "dark red to pink");
assert!((Color::lerp(&dark, &pink, 0.5).to_rgb()[1] - 0.4).abs() < 1e-3, "classic differs");
// A color whose RGB numbers are under half a color level apart has no hue of its own: a
// near gray leaning blue takes green's hue rather than turning from blue.
let near_gray = Color::rgb(0.5, 0.5, 0.5 + 0.3 / 255.0);
let [h, _, _] = method.mix(&near_gray, &green, 0.5).to_hsb();
assert!((h - 120.0).abs() < 0.5, "{method:?}: near gray to green passes hue {h}");
// One a whole color level apart has its hue (240°) and turns from it.
let bluish = Color::rgb(128.0 / 255.0, 128.0 / 255.0, 129.0 / 255.0);
let [h, _, _] = method.mix(&bluish, &green, 0.5).to_hsb();
let want = if method == GradientMethod::HueClockwise { 180.0 } else { 0.0 };
assert!(((h - want + 180.0).rem_euclid(360.0) - 180.0).abs() < 0.5, "{method:?}: bluish gray to green passes hue {h}, not {want}");
}
}
/// Hues that differ only by rounding are one hue: a color and a tint or shade of it, whose
/// computed hue angles differ in their last digits, mix without turning the wheel, with either
/// method, and a stop added to a ramp leaves the ramp's colors as they were. Hues a whole color
/// level apart still turn.
#[test]
fn hue_methods_take_hues_within_half_a_color_level_as_one() {
let level = |n: u8| f32::from(n) / 255.0;
let rgb8 = |r: u8, g: u8, b: u8| Color::rgb(level(r), level(g), level(b));
let methods = [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise];
// Colors against their 50% tint toward white and a shade at 40% brightness. At least one
// pair's computed hues differ, or the test checks nothing.
let colors = [rgb8(60, 253, 230), rgb8(241, 194, 107), rgb8(68, 32, 130), rgb8(13, 200, 77), rgb8(250, 3, 201), rgb8(90, 91, 200)];
let mut uneven = 0;
for c in colors {
let [h, s, v] = c.to_hsb();
let tint = Color::lerp(&c, &Color::WHITE, 0.5);
let shade = Color::from_hsb(h, s, v * 0.4);
for other in [tint, shade] {
let [h1, _, _] = other.to_hsb();
assert!((h1 - h).abs() < 0.01, "{c:?} and {other:?} are one hue: {h} and {h1}");
uneven += usize::from(h1 != h);
for method in methods {
for (a, b) in [(c, other), (other, c)] {
for k in 0..=20 {
let [hm, _, _] = method.mix(&a, &b, k as f32 / 20.0).to_hsb();
let off = (hm - h + 180.0).rem_euclid(360.0) - 180.0;
assert!(off.abs() < 0.5, "{method:?} {a:?} → {b:?} at {k}/20: hue {hm}, not {h}");
}
}
}
}
}
assert!(uneven > 0, "no pair's hues differ by rounding");
// Across 0°: 359.99° and 0.01° are one hue too.
for method in methods {
let [h, _, _] = method.mix(&Color::from_hsb(359.99, 1.0, 1.0), &Color::from_hsb(0.01, 1.0, 1.0), 0.5).to_hsb();
assert!(!(1.0..=359.0).contains(&h), "{method:?} across 0°: {h}");
}
// A stop added to a ramp samples the ramp's color there, and the ramp keeps its colors:
// white to red, a color to its tint, and a turn from red to blue.
let ramps = [
(Color::WHITE, rgb8(255, 0, 0)),
(rgb8(60, 253, 230), Color::lerp(&rgb8(60, 253, 230), &Color::WHITE, 0.5)),
(rgb8(255, 0, 0), rgb8(0, 0, 255)),
];
for method in methods {
for (a, b) in ramps {
let g = two_stops(a, b, method);
for at in [0.21, 0.5, 0.83] {
let (stops, i) = insert_stop(&g, at);
assert_eq!((stops.len(), i), (3, 1));
let with = Gradient { stops, ..g.clone() };
for k in 0..=200 {
let t = k as f32 / 200.0;
let (was, now) = (g.sample(t).0.to_rgb(), with.sample(t).0.to_rgb());
let off = was.iter().zip(now).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
assert!(off < 1.0 / 255.0, "{method:?} {a:?} → {b:?}, a stop at {at}: {was:?} became {now:?} at {t}");
}
}
}
}
// One color level of hue is a hue of its own: red to a red one level toward yellow turns
// nearly the whole wheel one way and stays put the other.
let (red, next) = (rgb8(255, 0, 0), rgb8(255, 1, 0));
let [h, _, _] = GradientMethod::HueClockwise.mix(&red, &next, 0.5).to_hsb();
assert!((h - 180.0).abs() < 1.0, "the long way: {h}");
let [h, _, _] = GradientMethod::HueCounterclockwise.mix(&red, &next, 0.5).to_hsb();
assert!(h < 1.0, "the short way: {h}");
}
/// Renderers mix linearly between stops: a hue segment becomes stops on its way around the
/// wheel, close enough that the linear ramp between them stays within half a color level of it,
/// through the midpoint and with the opacity changing evenly.
#[test]
fn hue_segments_expand_into_stops_on_the_curve() {
let pairs = [
(Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)),
(Color::rgb(1.0, 0.0, 0.0), Color::rgb(1.0, 0.02, 0.0)),
(Color::from_hsb(30.0, 0.5, 0.8), Color::from_hsb(190.0, 0.9, 0.4)),
(Color::cmyk(0.0, 0.9, 0.9, 0.0), Color::WHITE),
(Color::BLACK, Color::rgb(0.0, 1.0, 0.0)),
];
for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
for (a, b) in pairs {
let mut g = two_stops(a, b, method);
g.stops[0].midpoint = 0.3;
g.stops[1].opacity = 0.5;
let ex: Vec<_> = g.expanded().collect();
assert!((3..=600).contains(&ex.len()), "{method:?} {a:?} → {b:?}: {} stops", ex.len());
assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order");
assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6), "a stop stands for the midpoint");
let ramp = Ramp::new(&g, &Color::to_rgb);
for k in 0..=4000 {
let t = k as f32 / 4000.0;
let (c, o) = g.sample(t);
let [r, gr, bl] = c.to_rgb();
let off = [r, gr, bl, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
assert!(off < 0.5 / 255.0, "{method:?} {a:?} → {b:?} at {t}: {off}");
}
assert_eq!(g.sample_keep_model(0.4), g.sample(0.4), "one mix");
}
}
}
/// Reversing a gradient gives the same ramp the other way round: a hue method turns the other
/// way with the stops, so every color keeps its place mirrored, and reversing twice gives the
/// gradient back. Classic and Perceptual keep their method. A backward span and the curve as a
/// Classic gradient hold the same colors.
#[test]
fn reversing_a_hue_gradient_keeps_its_colors_mirrored() {
let near = |a: Color, b: Color, what: &str| {
assert!(a.to_rgb().iter().zip(b.to_rgb()).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{what}: {a:?} vs {b:?}");
};
for (method, other) in
[(GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise), (GradientMethod::HueCounterclockwise, GradientMethod::HueClockwise)]
{
let mut g = Gradient::new(
GradientKind::Linear,
vec![
GradientStop { midpoint: 0.3, ..GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)) },
GradientStop { opacity: 0.5, ..GradientStop::new(0.6, Color::rgb(0.0, 0.0, 1.0)) },
GradientStop::new(1.0, Color::rgb(0.9, 0.8, 0.1)),
],
);
g.method = method;
let mut back = g.clone();
back.reverse();
assert_eq!(back.method, other);
let (span, curve) = (g.span(1.0, 0.0), g.as_classic_curve());
assert!(span.method.is_classic() && curve.method.is_classic());
assert!(curve.stops.len() > g.stops.len() && curve.stops.iter().all(|s| s.swatch.is_none() && s.midpoint == 0.5));
for k in 0..=400 {
let t = k as f32 / 400.0;
let (want, opacity) = g.sample(1.0 - t);
near(want, back.sample(t).0, &format!("{method:?} reversed at {t}"));
assert!((opacity - back.sample(t).1).abs() < 1e-4, "{method:?} opacity at {t}");
near(want, span.sample(t).0, &format!("{method:?} backward span at {t}"));
near(g.sample(t).0, curve.sample(t).0, &format!("{method:?} curve at {t}"));
}
back.reverse();
assert_eq!(back.method, method);
assert_eq!(back.stops.len(), g.stops.len());
}
for method in [GradientMethod::Classic, GradientMethod::Perceptual] {
assert_eq!(method.mirrored(), method);
let mut g = blue_yellow(method);
g.reverse();
assert_eq!(g.method, method);
}
}
/// The hue methods are written by name, read back, and parsed from their param names; their
/// gradients draw as pixels in SVG and PDF, and are not mixes of the stops' inks.
#[test]
fn hue_methods_save_parse_and_draw_as_pixels() {
for (method, name, id) in [
(GradientMethod::HueClockwise, "HueClockwise", "hue-clockwise"),
(GradientMethod::HueCounterclockwise, "HueCounterclockwise", "hue-counterclockwise"),
] {
let g = two_stops(Color::cmyk(0.0, 1.0, 1.0, 0.0), Color::cmyk(1.0, 1.0, 0.0, 0.0), method);
let v = serde_json::to_value(&g).unwrap();
assert_eq!(v["method"], name);
assert_eq!(serde_json::from_value::<Gradient>(v).unwrap(), g);
assert_eq!(method.id(), id);
for written in [id.to_string(), id.to_uppercase(), id.replace('-', " "), id.replace('-', "_"), name.to_string(), method.label().to_string()] {
assert_eq!(GradientMethod::parse(&written), Some(method), "{written}");
}
assert!(method.is_hue() && !method.is_classic());
let paint = GradientPaint::new(g.clone());
assert!(paint.draws_as_pixels(true) && paint.draws_as_pixels(false));
assert!(g.ink_mixes().is_none(), "not mixes of the stops' inks");
assert!(
paint.ink_pixels(Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(0.0, 0.0, 10.0, 10.0), 4, 4, true).is_none(),
"an RGB image, not an ink image"
);
}
assert!(!GradientMethod::Perceptual.is_hue() && !GradientMethod::Classic.is_hue());
for (id, method) in [("classic", GradientMethod::Classic), ("perceptual", GradientMethod::Perceptual)] {
assert_eq!(method.id(), id);
assert_eq!(GradientMethod::parse(id), Some(method));
}
for bad in [
"hue",
"clockwise",
"hue-",
"",
"hue clockwise wise",
"c-l-a-s-s-i-c",
"per ceptual",
"hue--clockwise",
"hue-clock_wise",
" classic",
"hue- clockwise",
] {
assert_eq!(GradientMethod::parse(bad), None, "{bad}");
}
}
/// A Perceptual gradient of an ink's tints as tints: its stops keep their tints, and each tint on
/// the curve has the lightness of the color mixed there, so the tints between the stops are not
/// a straight line.
+4 -3
View File
@@ -102,12 +102,13 @@ impl Color {
pub fn to_lab(&self) -> cms::Lab {
cms::active().lab(self)
}
/// Colour-managed CMYK in the working CMYK space with `intent` (see [`Color::to_cmyk`] for the
/// profile-free formula).
/// Color-managed CMYK in the active CMYK space (the front document's assigned CMYK profile, else
/// the working CMYK space) with `intent` (see [`Color::to_cmyk`] for the profile-free formula).
pub fn to_cmyk_managed(&self, intent: cms::Intent) -> [f32; 4] {
cms::active().to_cmyk(self, intent)
}
/// Gamut warning: true when this colour can't be reproduced in the working CMYK space.
/// Gamut warning: true when this color can't be reproduced in the active CMYK space (the front
/// document's assigned CMYK profile, else the working CMYK space).
pub fn out_of_gamut(&self) -> bool {
cms::active().out_of_gamut(self)
}
+34 -7
View File
@@ -18,7 +18,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Nod
use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath};
use vectorcraft_pathops::BoolOp;
use super::{CommandSpec, Result, Session, always, bad, cmd};
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd};
/// The arrowheads file's name, in the folder of the preferences.
pub const FILE_NAME: &str = "Arrowheads.vectorcraft";
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Arrowheads",
[],
None,
"{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read, or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus",
"{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web, or the file is outside the automation read roots), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read (naming the file by its file name outside the read roots), or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus",
always,
list
),
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Arrowheads",
[],
None,
"{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads → {path, created: whether the file was made now}",
"{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads. Under automation read roots, a file outside them is an error, and nothing is made or opened → {path, created: whether the file was made now}",
has_file,
edit
),
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reload Arrowheads",
[],
None,
"{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}",
"{} read the arrowheads file again (a save of it from VectorCraft does this already; under automation read roots, a file outside them is an error and is not read) → {count, skipped: [{name, reason}]}",
has_file,
reload
),
@@ -409,13 +409,36 @@ fn skipped(s: &Session) -> Vec<Value> {
s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect()
}
/// The arrowheads file at `path` checked for reading against the automation roots in force: an agent
/// confined to read roots reaches the file only inside them. The error names the file by its file
/// name outside them.
fn may_reach(path: &str) -> Result<()> {
crate::file_access::check_read(path).map_err(|e| super::fileio::naming_in_error(EngineError::Other(e), path))
}
/// `msg`, an error about the arrowheads file at `path`, as the request being served may see it: the
/// file named whole inside the read roots, else by its file name.
fn shown_error(msg: &str, path: Option<&str>) -> String {
let Some(path) = path else { return msg.to_string() };
match super::fileio::naming_in_error(EngineError::Other(msg.to_string()), path) {
EngineError::Other(m) => m,
e => e.to_string(),
}
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let heads: Vec<Value> = s.arrowheads.heads.iter().map(|h| json!({"name": h.name, "reach": h.reach(), "halfWidth": h.half_width()})).collect();
Ok(json!({"arrowheads": heads, "file": s.arrowheads.path, "skipped": skipped(s), "error": s.arrowheads.error}))
let path = s.arrowheads.path.as_deref();
let file = path.filter(|p| crate::file_access::check_read(p).is_ok());
let error = s.arrowheads.error.as_deref().map(|e| shown_error(e, path));
Ok(json!({"arrowheads": heads, "file": file, "skipped": skipped(s), "error": error}))
}
fn reload(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "stroke.arrowheads.reload";
if let Some(path) = &s.arrowheads.path {
may_reach(path)?;
}
s.reload_arrowheads();
if let Some(e) = &s.arrowheads.error {
return Err(bad(C, e.clone()));
@@ -426,8 +449,11 @@ fn reload(s: &mut Session, _: &Value) -> Result<Value> {
fn edit(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "stroke.arrowheads.edit";
let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?;
// Checked before anything is made, brought to the front or opened.
may_reach(&path)?;
if let Some(dir) = std::path::Path::new(&path).parent() {
std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
let dir = dir.to_string_lossy();
std::fs::create_dir_all(&*dir).map_err(|e| super::fileio::naming_in_error(bad(C, format!("{dir}: {e}")), &dir))?;
}
// Made only when no file is there, also when another run makes one at the same moment.
let bytes = vectorcraft_format::save_file(&starter());
@@ -446,7 +472,8 @@ fn edit(s: &mut Session, _: &Value) -> Result<Value> {
s.set_active(i);
}
None => {
crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?;
// Opened in the request's own scope, so the document keeps the roots it was opened under.
s.execute("document.open", &json!({ "path": path }))?;
}
}
Ok(json!({"path": path, "created": created}))
+1 -1
View File
@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gamut Warning",
[],
None,
"{ids?|id?, colors?: [color] (#rrggbbaa and #rgba are refused)} colours that can't be printed in the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}",
"{ids?|id?, colors?: [color] (#rrggbbaa and #rgba are refused)} colors that can't be printed in the document's assigned CMYK profile, else the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}",
has_doc,
gamut_check
),
+3 -2
View File
@@ -1388,8 +1388,9 @@ fn band_edges(lo: f64, hi: f64, n: usize, runs: Option<(f64, usize)>) -> Vec<f64
fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32))> {
let at = |t: f64| (t, g.sample_keep_model(t as f32));
let mut v = vec![at(t0)];
// A Perceptual gradient curves between its stops: the mesh takes a column at each of the stops
// its expansion adds, so the linear mix between columns follows that curve.
// A Perceptual gradient, or one mixed around the hue wheel, curves between its stops: the mesh
// takes a column at each of the stops its expansion adds, so the linear mix between columns
// follows that curve.
if !g.method.is_classic() {
v.extend(g.expanded().map(|(o, ..)| f64::from(o)).filter(|o| *o > t0 && *o < t1).map(at));
}
+2 -2
View File
@@ -69,8 +69,8 @@ pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts};
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub(crate) use export::with_artboard_fills;
pub(crate) use export::{export_crop, isolated};
pub use export::{export_doc, export_source};
pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated};
pub use export::{export_doc, export_source, unused_option_notes};
use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::is_placeholder;
File diff suppressed because one or more lines are too long
+49 -3
View File
@@ -15,8 +15,45 @@ use super::{
ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return,
};
/// The keys the export commands take for every format, beside the path, the format and the
/// options `document.formats` lists for each: `richBlack` is Appearance of Black's setting for a
/// raster export, and `params` an exporter plug-in's own.
pub(crate) const EXPORT_PARAMS: [&str; 4] = ["includeArtboardFill", "selectedOnly", "richBlack", "params"];
/// A note for each option in `p` that some format takes but the file an export of `cmd` writes in
/// `f` doesn't read (`{format: pdf, quality: 80}`), in the wording of a save's note. An exporter
/// plug-in's format gets none. The notes cover the formats' options only: a key of the command's
/// own ([`EXPORT_PARAMS`]) and an artboard choice that a selection export leaves out get none.
pub fn unused_option_notes(cmd: &str, f: &Format, p: &Value) -> Vec<String> {
let Some(given) = p.as_object() else { return Vec::new() };
if vectorcraft_plugins::export::by_format(f.id).is_some() {
return Vec::new();
}
let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
let written = super::save::written_as(cmd, f, p);
let legacy = f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false);
let reads = |k: &str| match (f.id, k) {
// A legacy `.ai` file is not a PDF: it reads its own options only.
("ai", _) if legacy => super::legacy_ai::reads(k),
// The other `.ai` files don't read the legacy file's options, and `preserveEditing` is
// on for them whatever is given.
("ai", "preserveEditing") => false,
("ai", _) if super::legacy_ai::LEGACY_ONLY.contains(&k) => false,
// An export writes one file: only a save writes each artboard to a file of its own, and
// a native file's `range` names those files.
("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
_ => super::save::reads_option(f, k),
};
given
.iter()
.filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !reads(k))
.map(|(k, _)| super::save::unused_option_note(k, &written))
.collect()
}
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(text_param(p, "format", "document.serialize")?.unwrap_or("vectorcraft")), None)?;
let unused = unused_option_notes("document.serialize", f, p);
let expanded = expand_presets(s, "document.serialize", p)?;
let p = with_black_output(s, &expanded);
let (doc, display_doc_title) = source(s, f, &p, "document.serialize")?;
@@ -27,7 +64,8 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
{
o.insert("_displayDocTitle".into(), json!(display_doc_title));
}
let enc = encode_all(doc, f.id, &q)?;
let mut enc = encode_all(doc, f.id, &q)?;
enc.add_setting_notes(unused);
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
let (main, linked) = files.split_at(enc.files.len());
// Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes.
@@ -58,6 +96,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
super::check_not_lossy_overwrite(s.doc()?, path, p, "document.export")?;
}
let f = writable("document.export", text_param(p, "format", "document.export")?, path)?;
let unused = unused_option_notes("document.export", f, p);
let expanded = expand_presets(s, "document.export", p)?;
let p = with_black_output(s, &expanded);
let (doc, display_doc_title) = source(s, f, &p, "document.export")?;
@@ -68,7 +107,8 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
{
o.insert("_displayDocTitle".into(), json!(display_doc_title));
}
let enc = encode_all(doc, f.id, &q)?;
let mut enc = encode_all(doc, f.id, &q)?;
enc.add_setting_notes(unused);
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.notes() }))
}
@@ -315,12 +355,18 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
}
let f = writable(C, text_param(p, "format", C)?, path)?;
let unused = unused_option_notes(C, f, p);
let expanded = expand_presets(s, C, p)?;
let p = with_black_output(s, &expanded);
let (d, bounds) = selection(s, C, f.id == "txt")?;
// The encoder's warnings (options accepted but not applied, features approximated or left
// out), as document.export reports them.
let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
let (bytes, mut warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
for note in unused {
if !warnings.contains(&note) {
warnings.push(note);
}
}
write_or_return(path, &bytes, json!({ "format": f.id, "warnings": warnings, "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
}
+12
View File
@@ -65,6 +65,18 @@ pub(super) fn named(f: &Format) -> Format {
Format { label: "legacy .ai (version 7)", ..*f }
}
/// The `.ai` options that only a legacy file reads: a PDF-compatible `.ai` file and a `.ai` file
/// without PDF content don't.
pub(super) const LEGACY_ONLY: [&str; 3] = ["transparentImages", "hiddenObjects", "outlineText"];
/// Does an export of a legacy file read option `key`? It reads the version, the file's own
/// options ([`LEGACY_ONLY`]), the artboards to write, and `useArtboards`, which the export reads
/// before [`encode`] runs. It reads none of the other `.ai` options and none of the PDF options
/// that a PDF-compatible `.ai` file takes.
pub(super) fn reads(key: &str) -> bool {
matches!(key, "aiVersion" | "useArtboards") || LEGACY_ONLY.contains(&key) || super::ARTBOARD_PARAMS.contains(&key)
}
/// What a legacy `.ai` does with the images it can't hold (`transparentImages`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SeeThrough {
+8 -2
View File
@@ -317,7 +317,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
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))
.map(|k| unused_option_note(&k, &written))
.collect();
let lossy_source = super::lossy_source(st, p);
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source })
@@ -467,6 +467,12 @@ pub fn reads_option(f: &Format, key: &str) -> bool {
f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key)))
}
/// The note a save or an export gives for option `key`, as the request names it, which the file it
/// writes (`written`, see [`written_as`]) doesn't read.
pub(super) fn unused_option_note(key: &str, written: &Format) -> String {
format!("`{key}` was not used: {} files don't read it", written.label)
}
/// Formats that carry the whole native document: they lose nothing, and record links relative to
/// where they are written. A `.ai` or `.ait` file is a PDF carrying it; EPS carries it in comments.
fn is_native(f: &Format) -> bool {
@@ -636,7 +642,7 @@ fn without_pdf(f: &Format) -> Format {
/// `f` named, in its errors and notes, as the file a save with options `p` writes: a version 7
/// `.ai` as the legacy `.ai`, and a `.ai` or `.ait` with `pdfCompatible: false` as one saved
/// without PDF content.
fn written_as(cmd: &str, f: &Format, p: &Value) -> Format {
pub(super) fn written_as(cmd: &str, f: &Format, p: &Value) -> Format {
if f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false) {
super::legacy_ai::named(f)
} else if matches!(f.id, "ai" | "ait") && !bool_or(p, "pdfCompatible", true) {
@@ -141,6 +141,137 @@ fn what_the_file_loses_comes_before_a_note_on_the_options() {
assert_eq!(r["warnings"], json!(w));
}
/// A session with one artboard and a selected rectangle on it.
fn with_art() -> Session {
let mut s = session(60.0, 40.0, 1);
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
s
}
/// The warnings of reply `r` that name an option the file doesn't read.
fn unused(r: &Value) -> Vec<String> {
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect()
}
/// An option that only another format reads is accepted, and the reply's warnings name it in the
/// words of a save's note; `document.export`, `document.serialize` and `document.exportSelection`
/// give the same note.
#[test]
fn an_option_only_another_format_reads_gets_a_note() {
let (mut s, notes) = (with_art(), unused);
for cmd in ["document.export", "document.serialize", "document.exportSelection"] {
let r = s.execute(cmd, &json!({"format": "pdf", "quality": 80, "outlineText": true})).unwrap();
assert_eq!(
notes(&r),
["`outlineText` was not used: PDF files don't read it", "`quality` was not used: PDF files don't read it"],
"{cmd}: {r}"
);
}
// The format's own options, the keys every export takes and null values get none.
for p in [
json!({"format": "jpg", "quality": 80, "scale": 2}),
json!({"format": "ai", "compress": true, "compatibility": "1.7"}),
json!({"format": "pdf", "quality": null, "selectedOnly": false, "includeArtboardFill": false, "useArtboards": true}),
json!({"format": "svg", "outlineText": true, "svg": {"decimals": 2}}),
] {
let r = s.execute("document.export", &p).unwrap();
assert!(notes(&r).is_empty(), "{p}: {r}");
}
let r = s.execute("document.serialize", &json!({"format": "png", "compress": true, "marks": {"trim": true}, "svg": {"minify": true}})).unwrap();
assert_eq!(
notes(&r),
[
"`compress` was not used: PNG files don't read it",
"`marks` was not used: PNG files don't read it",
"`svg` was not used: PNG files don't read it"
],
"{r}"
);
// The note names the kind of file written, as a save's does.
let r = s.execute("document.export", &json!({"format": "ai", "pdfCompatible": false, "quality": 80})).unwrap();
assert_eq!(notes(&r), ["`quality` was not used: non-PDF-compatible .ai files don't read it"], "{r}");
let r = s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false, "range": "1"})).unwrap();
assert!(notes(&r).is_empty(), "{r}");
let r = s.execute("document.export", &json!({"format": "vectorcraft", "artboard": 0})).unwrap();
assert_eq!(notes(&r), ["`artboard` was not used: VectorCraft files don't read it"], "{r}");
}
/// A legacy `.ai` file reads its own options and the artboards to write, not the other `.ai`
/// options or the PDF options.
#[test]
fn a_legacy_ai_export_notes_the_options_of_the_other_ai_files() {
let (mut s, notes) = (with_art(), unused);
let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": "7", "compress": true, "marks": {"trim": true}})).unwrap();
assert_eq!(
notes(&r),
["`compress` was not used: legacy .ai (version 7) files don't read it", "`marks` was not used: legacy .ai (version 7) files don't read it"],
"{r}"
);
let r =
s.execute("document.export", &json!({"format": "ai", "aiVersion": "7", "outlineText": true, "hiddenObjects": "omit", "range": "1"})).unwrap();
assert!(notes(&r).is_empty(), "{r}");
let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": "7", "useArtboards": false})).unwrap();
assert!(notes(&r).is_empty(), "{r}");
}
/// The `.ai` files other than the legacy one don't read the legacy file's own options.
#[test]
fn a_pdf_compatible_ai_export_notes_the_legacy_options() {
let (mut s, notes) = (with_art(), unused);
let unread = json!({"format": "ai", "aiVersion": "pdf", "outlineText": true, "hiddenObjects": "omit", "transparentImages": "omit"});
let r = s.execute("document.export", &unread).unwrap();
assert_eq!(
notes(&r),
[
"`hiddenObjects` was not used: PDF-compatible .ai files don't read it",
"`outlineText` was not used: PDF-compatible .ai files don't read it",
"`transparentImages` was not used: PDF-compatible .ai files don't read it"
],
"{r}"
);
}
/// `preserveEditing` is always on for a `.ai` file, so an export that gives it gets a note.
#[test]
fn an_ai_export_notes_preserve_editing() {
let (mut s, notes) = (with_art(), unused);
let r = s.execute("document.export", &json!({"format": "ai", "preserveEditing": false, "range": "1"})).unwrap();
assert_eq!(notes(&r), ["`preserveEditing` was not used: PDF-compatible .ai files don't read it"], "{r}");
}
/// An export writes one file: only a save writes each artboard to a file of its own, so an export
/// notes `separateArtboards`, and the `range` that names those files for a native file.
#[test]
fn an_export_notes_the_options_that_only_a_save_reads() {
let (mut s, notes) = (with_art(), unused);
let r = s.execute("document.export", &json!({"format": "ai", "separateArtboards": true, "range": "1"})).unwrap();
assert_eq!(notes(&r), ["`separateArtboards` was not used: PDF-compatible .ai files don't read it"], "{r}");
for (format, label) in [("vectorcraft", "VectorCraft"), ("template", "VectorCraft Template")] {
let r = s.execute("document.export", &json!({"format": format, "separateArtboards": true, "range": "1", "compress": true})).unwrap();
assert_eq!(
notes(&r),
[format!("`range` was not used: {label} files don't read it"), format!("`separateArtboards` was not used: {label} files don't read it")],
"{r}"
);
}
}
/// The legacy `.ai` options that the notes go by are the ones the `.ai` option table describes as
/// the legacy file's ("with aiVersion 7"), so an option added to the table is not noted as unread
/// on a legacy export by mistake.
#[test]
fn the_legacy_ai_options_are_the_ones_the_option_table_names() {
let described: Vec<&str> = native::AI_OPTIONS.iter().filter(|o| o.description.starts_with("with aiVersion 7")).map(|o| o.name).collect();
assert_eq!(described, legacy_ai::LEGACY_ONLY);
for key in described.iter().chain(&ARTBOARD_PARAMS).chain(&["aiVersion", "useArtboards"]) {
assert!(legacy_ai::reads(key), "{key}");
assert!(native::AI_OPTIONS.iter().any(|o| o.name == *key), "{key} is not a .ai option");
}
for key in ["compress", "pdfCompatible", "separateArtboards", "editingData", "marks"] {
assert!(!legacy_ai::reads(key), "{key}");
}
}
#[test]
fn note_texts_give_the_losses_first_each_kind_in_order() {
use super::encode::{Note, note_texts};
+28 -18
View File
@@ -15,9 +15,8 @@ fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Value {
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap()
}
fn tmp(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("vc-pdfedit-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
/// The path of `name` in the test's folder `dir`.
fn tmp(dir: &std::path::Path, name: &str) -> String {
dir.join(name).to_string_lossy().to_string()
}
@@ -114,8 +113,9 @@ fn the_legacy_payload_name_is_accepted() {
#[test]
fn ai_files_are_pdfs_that_save_and_reopen_editable() {
let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = rich();
let path = tmp("art.ai");
let path = tmp(&scratch, "art.ai");
let r = s.execute("document.save", &json!({"path": path, "preserveEditing": false, "range": "1"})).unwrap();
assert_eq!(r["path"], path.as_str());
// As saved (Save stamps the File Info dates).
@@ -134,11 +134,11 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() {
let again = vectorcraft_pdf::editing(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(vectorcraft_format::load(&again.data).unwrap().layers, s.doc().unwrap().doc.layers);
// Save As also writes a plain PDF (the document then saves back as PDF); PNG stays an export.
assert_eq!(s.execute("document.save", &json!({"path": tmp("x.pdf")})).unwrap()["format"], "pdf");
assert!(s.execute("document.save", &json!({"path": tmp("x.png")})).is_err(), "PNG is written with document.export");
assert_eq!(s.execute("document.save", &json!({"path": tmp(&scratch, "x.pdf")})).unwrap()["format"], "pdf");
assert!(s.execute("document.save", &json!({"path": tmp(&scratch, "x.png")})).is_err(), "PNG is written with document.export");
// A plain PDF named .ai opens as artwork, without a path to save over.
let plain = b64(&s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap());
let plain_path = tmp("plain.ai");
let plain_path = tmp(&scratch, "plain.ai");
std::fs::write(&plain_path, plain).unwrap();
let r = s.execute("document.open", &json!({"path": plain_path})).unwrap();
assert_eq!(r["restored"], false);
@@ -149,9 +149,10 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() {
/// As does, leaving the document's own path alone.
#[test]
fn exports_write_ai_as_save_as_does() {
let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = rich();
let before = comparable(&s.doc().unwrap().doc);
let path = tmp("exported.ai");
let path = tmp(&scratch, "exported.ai");
let r = s.execute("document.export", &json!({"path": path})).unwrap();
assert_eq!(r["format"], "ai", "{r}");
let bytes = std::fs::read(&path).unwrap();
@@ -225,12 +226,13 @@ fn ait_templates_open_their_document_untitled() {
/// from Template opens it as an untitled copy of the document, layers and art included.
#[test]
fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() {
let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = rich();
s.execute("layer.new", &json!({"name": "Notes"})).unwrap();
s.execute("shape.ellipse", &json!({"x": 20, "y": 120, "width": 40, "height": 30})).unwrap();
let before = comparable(&s.doc().unwrap().doc);
assert_eq!(before.layers.len(), 2);
let path = tmp("brochure template.ait");
let path = tmp(&scratch, "brochure template.ait");
let r = s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
assert_eq!(r["format"], "ait", "{r}");
let bytes = std::fs::read(&path).unwrap();
@@ -253,6 +255,7 @@ fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() {
/// writes a `.ait`.
#[test]
fn save_as_template_names_its_file_after_the_template_format() {
let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = rich();
for (name, format, saved) in [
("card.png", None, "card.vctemplate"),
@@ -261,7 +264,7 @@ fn save_as_template_names_its_file_after_the_template_format() {
("card.pdf", Some("ait"), "card.ait"),
("card v1.2", None, "card v1.2.vctemplate"),
] {
let (typed, want) = (tmp(&format!("named/{name}")), tmp(&format!("named/{saved}")));
let (typed, want) = (tmp(&scratch, &format!("named/{name}")), tmp(&scratch, &format!("named/{saved}")));
let _ = std::fs::create_dir_all(std::path::Path::new(&typed).parent().unwrap());
let _ = std::fs::remove_file(&typed);
let _ = std::fs::remove_file(&want);
@@ -273,7 +276,7 @@ fn save_as_template_names_its_file_after_the_template_format() {
assert!(ait || vectorcraft_format::load(&bytes).is_ok_and(|d| d.template), "{name}: a native template");
assert!(!std::path::Path::new(&typed).exists(), "{name}: nothing written under the typed name");
}
for (cmd, p) in [("file.saveAs", json!({"format": "ait"})), ("document.save", json!({"path": tmp("card.ait")}))] {
for (cmd, p) in [("file.saveAs", json!({"format": "ait"})), ("document.save", json!({"path": tmp(&scratch, "card.ait")}))] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("file.saveAsTemplate") && !e.contains("document.export"), "{cmd}: {e}");
}
@@ -284,8 +287,9 @@ fn save_as_template_names_its_file_after_the_template_format() {
/// nothing writes keeps its own error.
#[test]
fn exports_of_an_ait_name_save_as_template() {
let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = rich();
let path = tmp("exported.ait");
let path = tmp(&scratch, "exported.ait");
let _ = std::fs::remove_file(&path);
for (cmd, p) in [
("document.export", json!({"path": path})),
@@ -305,8 +309,9 @@ fn exports_of_an_ait_name_save_as_template() {
/// file; `aiVersion` "pdf" writes the PDF-compatible template.
#[test]
fn save_as_template_refuses_a_legacy_ait() {
let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = rich();
let path = tmp("legacy-ait/card.ait");
let path = tmp(&scratch, "legacy-ait/card.ait");
let dir = std::path::Path::new(&path).parent().unwrap().to_path_buf();
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
@@ -424,7 +429,8 @@ fn an_ai_of_the_arts_bounds_carries_no_editing_data() {
let mut s = art_outside_the_artboards();
let before = comparable(&s.doc().unwrap().doc);
let left_out = |v: &Value| v["warnings"].as_array().unwrap().iter().any(|w| w == super::save::AI_EDITING_NEEDS_THE_ARTBOARDS);
let path = tmp("art-bounds.ai");
let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let path = tmp(&scratch, "art-bounds.ai");
for extra in [json!({}), json!({"range": "2"}), json!({"editingData": false}), json!({"path": path})] {
let mut params = json!({"format": "ai", "useArtboards": false});
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
@@ -670,6 +676,7 @@ fn editing_copy(bytes: &[u8]) -> vectorcraft_doc::Document {
/// 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 scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = see_through_session();
let before = (*s.doc().unwrap().doc).clone();
// The mask's art is one path, and it reads back as that path.
@@ -682,7 +689,7 @@ fn transparency_clipping_and_opacity_masks_come_back_from_the_editing_copy() {
assert_eq!(see_through_outline(&editing_copy(&bytes)), want, "{opts}");
}
// Each artboard in a file of its own: the first holds all the art.
let path = tmp("see-through.ai");
let path = tmp(&scratch, "see-through.ai");
let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true})).unwrap();
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
assert_eq!(files.len(), 3, "{r}");
@@ -812,7 +819,8 @@ fn image_outline(doc: &vectorcraft_doc::Document) -> Vec<String> {
/// the linked image keeps its path and its path relative to the document's folder.
#[test]
fn images_come_back_from_the_editing_copy() {
let dir = tmp("ai-images");
let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let dir = tmp(&scratch, "ai-images");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(format!("{dir}/art")).unwrap();
let png = |w: u32, h: u32, f: &dyn Fn(u32, u32) -> [u8; 4]| {
@@ -882,6 +890,7 @@ fn type_outline(doc: &vectorcraft_doc::Document) -> Vec<String> {
/// for other apps alone as type, with every save option that writes the copy.
#[test]
fn type_comes_back_from_the_editing_copy() {
let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
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();
@@ -889,7 +898,7 @@ fn type_comes_back_from_the_editing_copy() {
// 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");
let path = tmp(&scratch, "type.ai");
s.execute("document.save", &json!({"path": path})).unwrap();
let before = type_outline(&s.doc().unwrap().doc);
assert_eq!(before.len(), 3, "{before:#?}");
@@ -971,6 +980,7 @@ fn gradient_session() -> Session {
/// gradient.
#[test]
fn gradients_and_patterns_come_back_from_the_editing_copy() {
let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = gradient_session();
let before = (*s.doc().unwrap().doc).clone();
let want = paint_outline(&before);
@@ -990,7 +1000,7 @@ fn gradients_and_patterns_come_back_from_the_editing_copy() {
let copy = editing_copy(&b64(&r));
assert_eq!(paint_outline(&copy), want, "{opts}");
}
let path = tmp("gradients.ai");
let path = tmp(&scratch, "gradients.ai");
let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true})).unwrap();
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
assert_eq!(files.len(), 3, "{r}");
@@ -59,7 +59,7 @@ fn live_texts(s: &Session) -> Vec<String> {
#[test]
fn pdf_place_keeps_live_text_from_bytes_and_unlinked_paths() {
let bytes = live_text_pdf();
let dir = vectorcraft_testkit::temp_dir("pdf-place-live-text");
let dir = vectorcraft_testkit::TempDir::new("pdf-place-live-text");
let path = dir.join("text.pdf");
std::fs::write(&path, &bytes).unwrap();
for source in [json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)}), json!({"path": path, "link": false})] {
@@ -71,7 +71,6 @@ fn pdf_place_keeps_live_text_from_bytes_and_unlinked_paths() {
assert_eq!(live_texts(&s), ["Second page"], "{r}");
assert!(!r["warnings"].to_string().contains("outlines"), "{r}");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
@@ -9,7 +9,7 @@ use crate::{EngineError, READ_ONLY_LOCATION, Session};
#[test]
fn readonly_error_is_typed_and_other_writers_keep_their_error() {
let dir = vectorcraft_testkit::temp_dir("save-refusal");
let dir = vectorcraft_testkit::TempDir::new("save-refusal");
let path = dir.join("Poster.vectorcraft").to_string_lossy().into_owned();
for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem, ErrorKind::StorageFull, ErrorKind::NotFound, ErrorKind::Other] {
let typed = write_save_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err();
@@ -26,7 +26,6 @@ fn readonly_error_is_typed_and_other_writers_keep_their_error() {
}
assert!(!std::path::Path::new(&path).exists());
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
+6 -2
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md)",
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md)",
has_doc,
edit_gradient
),
@@ -126,7 +126,11 @@ fn parse_kind(k: &str) -> Parsed<GradientKind> {
fn method_param(p: &Value) -> Parsed<Option<GradientMethod>> {
match p.get("method") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(GradientMethod::parse).map(Some).ok_or_else(|| "`method` must be classic or perceptual".into()),
Some(v) => v
.as_str()
.and_then(GradientMethod::parse)
.map(Some)
.ok_or_else(|| "`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise".into()),
}
}
+1 -1
View File
@@ -215,7 +215,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
o.insert("stroke".into(), json!(stroke));
o.insert("kind".into(), json!(g.kind.label().to_lowercase()));
o.insert("stops".into(), stops_json(&g.stops));
o.insert("method".into(), json!(g.method.label().to_lowercase()));
o.insert("method".into(), json!(g.method.id()));
// A freeform preset takes no spread (paint.editGradient refuses one).
if g.kind != vectorcraft_color::GradientKind::Freeform {
o.insert("spread".into(), json!(g.spread.label().to_lowercase()));
+23 -12
View File
@@ -2478,7 +2478,7 @@ fn series_colours_survive_native_save_open_and_export() {
let second = group(s.doc().unwrap().doc.node(id).unwrap(), "2025").id;
s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#f00"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [second.0], "color": "#008080"})).unwrap();
let dir = vectorcraft_testkit::temp_dir("graph-palette");
let dir = vectorcraft_testkit::TempDir::new("graph-palette");
let path = dir.join("palette.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
@@ -2892,7 +2892,8 @@ fn series_types_survive_save_and_open_and_older_files_have_none() {
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-kinds").join("kinds.vectorcraft");
let dir = vectorcraft_testkit::TempDir::new("graph-kinds");
let path = dir.join("kinds.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).series_kinds, [None, Some(GraphKind::Line)]);
@@ -3020,7 +3021,8 @@ fn value_axis_options_survive_save_and_open_and_are_left_out_when_unset() {
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-axes").join("axes.vectorcraft");
let dir = vectorcraft_testkit::TempDir::new("graph-axes");
let path = dir.join("axes.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
@@ -3222,7 +3224,8 @@ fn tick_and_label_options_survive_save_and_open_and_are_left_out_when_unset() {
&json!({"tickLength": "full", "tickMarks": 3, "categoryTickLength": "short", "ticksBetweenLabels": true, "suffix": "%"}),
)
.unwrap();
let path = vectorcraft_testkit::temp_dir("graph-ticks").join("ticks.vectorcraft");
let dir = vectorcraft_testkit::TempDir::new("graph-ticks");
let path = dir.join("ticks.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
@@ -3461,7 +3464,8 @@ fn designs_and_markers_survive_save_and_open() {
save_design(&mut s, "Pill");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": "Pill"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-designs").join("designs.vectorcraft");
let dir = vectorcraft_testkit::TempDir::new("graph-designs");
let path = dir.join("designs.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"]));
@@ -3859,7 +3863,8 @@ fn column_designs_survive_save_and_open_and_follow_their_design() {
assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series");
let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap();
assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"}));
let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft");
let dir = vectorcraft_testkit::TempDir::new("graph-column-designs");
let path = dir.join("columns.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale };
@@ -4381,7 +4386,8 @@ fn legends_in_wedges_put_the_series_labels_inside_the_pie() {
s.execute("graph.setType", &json!({"legend": true})).unwrap();
assert!(!spec(&s, id).pie_legend_in_wedges);
assert!(s.execute("graph.setType", &json!({"pieLegend": "inside"})).is_err());
let path = vectorcraft_testkit::temp_dir("graph-pie").join("pie.vectorcraft");
let dir = vectorcraft_testkit::TempDir::new("graph-pie");
let path = dir.join("pie.vectorcraft");
s.execute("graph.setType", &json!({"pieLegend": "wedges", "piePosition": "stacked", "pieSort": "all"})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
@@ -4522,7 +4528,8 @@ fn the_legend_across_top_runs_in_rows_above_the_plot() {
assert!(ys.iter().all(|y| *y + 8.0 <= 100.0 - 8.0 + 1e-6), "{ys:?}");
let xs: Vec<f64> = (0..6).map(|i| swatch(&s, i).x1).collect();
assert!(xs.iter().all(|x| *x <= 400.0), "{xs:?}");
let path = vectorcraft_testkit::temp_dir("graph-top").join("top.vectorcraft");
let dir = vectorcraft_testkit::TempDir::new("graph-top");
let path = dir.join("top.vectorcraft");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
@@ -4641,7 +4648,8 @@ fn filled_lines_are_bands_in_the_series_colour() {
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#ff0000"})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [2]]})).unwrap();
assert_eq!(series_parts(&s, id, 0)[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
let path = vectorcraft_testkit::temp_dir("graph-filled").join("filled.vectorcraft");
let dir = vectorcraft_testkit::TempDir::new("graph-filled");
let path = dir.join("filled.vectorcraft");
s.execute("graph.setType", &json!({"firstRowInFront": true, "firstColumnInFront": true})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
@@ -4768,7 +4776,8 @@ fn a_graph_labels_anti_aliasing_method_survives_it_being_drawn_again() {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories_anti_alias, None);
s.execute("edit.redo", &json!({})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-label-anti-alias").join("labels.vectorcraft");
let dir = vectorcraft_testkit::TempDir::new("graph-label-anti-alias");
let path = dir.join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories_anti_alias, Some(TypeAntiAlias::Strong));
@@ -4811,7 +4820,8 @@ fn formatting_one_label_keeps_that_kind_of_label() {
assert_eq!(look("0").2, "#0000ff", "every value axis number");
assert_eq!(look("0").1, 9.0);
// Kept through save and open; nothing written when unset.
let path = vectorcraft_testkit::temp_dir("graph-labels").join("labels.vectorcraft");
let dir = vectorcraft_testkit::TempDir::new("graph-labels");
let path = dir.join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 14.0);
@@ -4980,7 +4990,8 @@ fn paragraph_formats_are_kept_when_the_graph_is_drawn_again() {
let v = text_id(&s, col, "Axes", "4");
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
s.execute("text.setFormat", &json!({"composer": "singleLine"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-label-paras").join("labels.vectorcraft");
let dir = vectorcraft_testkit::TempDir::new("graph-label-paras");
let path = dir.join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
s.execute("select.set", &json!({"ids": [col.0]})).unwrap();
+13 -6
View File
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Profile",
[],
None,
"{name: a category of file.newPresets} open the category's New Document profile as a document to edit (File › Save keeps the changes for the next file.new); a category without a profile file gets the shipped profile written first → document.open's result",
"{name: a category of file.newPresets} open the category's New Document profile as a document to edit (File › Save keeps the changes for the next file.new); a category without a profile file gets the shipped profile written first; under automation roots it needs write and read access to the file, and an error names the file by its file name outside the read roots → document.open's result",
always,
edit_profile
),
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset Profile",
[],
None,
"{name: a category of file.newPresets} delete the category's profile file, so the shipped profile (today's defaults) applies again; an open copy of it stays open → {name, reset: whether a file was deleted}",
"{name: a category of file.newPresets} delete the category's profile file, so the shipped profile (today's defaults) applies again; an open copy of it stays open; under automation roots it needs write access to the file, and an error names the file by its file name outside the read roots → {name, reset: whether a file was deleted}",
always,
reset_profile
),
@@ -999,10 +999,17 @@ fn list_profiles(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "profiles": profiles, "folder": s.new_doc_profile_dir().and_then(shown) }))
}
/// `msg`, an error about the profile file at `path`, as the request being served may see it: the
/// file named whole inside the read roots, else by its file name.
fn profile_error(msg: String, path: &str) -> super::EngineError {
super::fileio::naming_in_error(super::EngineError::Other(msg), path)
}
/// The file of profile `name` for `cmd` to change, checked against the request's write access.
fn writable_profile(s: &Session, name: &str, cmd: &str) -> Result<std::path::PathBuf> {
let path = s.profile_path(name).ok_or_else(|| bad(cmd, "New Document profiles need the preferences folder, which this run doesn't have"))?;
crate::file_access::check_write(&path.to_string_lossy()).map_err(super::EngineError::Other)?;
let file = path.to_string_lossy();
crate::file_access::check_write(&file).map_err(|e| profile_error(e, &file))?;
Ok(path)
}
@@ -1012,13 +1019,13 @@ fn edit_profile(s: &mut Session, p: &Value) -> Result<Value> {
let file = writable_profile(s, name, C)?;
let path = file.to_string_lossy().to_string();
// document.open reads it next: a profile it can't open is not written either.
crate::file_access::check_read(&path).map_err(super::EngineError::Other)?;
crate::file_access::check_read(&path).map_err(|e| profile_error(e, &path))?;
if !file.exists() {
let bytes = vectorcraft_format::save(&shipped_profile(s, name), false);
file.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.and_then(|()| std::fs::write(&file, bytes))
.map_err(|e| super::EngineError::Other(format!("{path}: {e}")))?;
.map_err(|e| profile_error(format!("{path}: {e}"), &path))?;
}
s.execute("document.open", &json!({ "path": path }))
}
@@ -1031,7 +1038,7 @@ fn reset_profile(s: &mut Session, p: &Value) -> Result<Value> {
let reset = match std::fs::remove_file(&file) {
Ok(()) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => return Err(super::EngineError::Other(format!("{path}: {e}"))),
Err(e) => return Err(profile_error(format!("{path}: {e}"), &path)),
};
Ok(json!({ "name": name, "reset": reset }))
}
+1 -1
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
has_doc,
|s, p| set_paint(s, p, true)
),
+1 -3
View File
@@ -773,9 +773,7 @@ fn check_export_options(id: &str, params: &Value) -> Result<()> {
NO_SAVE_FORMAT
}
"document.export" | "document.exportSelection" | "document.serialize" => {
// `richBlack` is Appearance of Black's setting for a raster export, and `params` an
// exporter plug-in's own.
keys.extend(["includeArtboardFill", "selectedOnly", "richBlack", "params"]);
keys.extend(super::fileio::EXPORT_PARAMS);
keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name));
NO_FORMAT
}
+2
View File
@@ -410,6 +410,8 @@ fn gradient_key(g: &Gradient) -> Option<GradientKey<'_>> {
let method = match g.method {
GradientMethod::Classic => 0,
GradientMethod::Perceptual => 1,
GradientMethod::HueClockwise => 2,
GradientMethod::HueCounterclockwise => 3,
};
Some((kind, method, g.dither, g.painted_spread(), g.stops.iter().map(stop_key).collect::<Option<_>>()?))
}
+12 -1
View File
@@ -404,11 +404,18 @@ impl Session {
true
}
/// Close the document in tab `index` (`file.close`). Closing a tab other than the current one
/// keeps the current document current, and its tool keeps its state: no document is switched
/// to. Closing the current document makes the tab now at `index` current, or the last tab when
/// it was the last one. False, changing nothing, when `index` is past the last document.
pub fn close_document(&mut self, index: usize) -> bool {
if index >= self.docs.len() {
return false;
}
self.reset_tool_for_doc_switch();
let front = self.active().map(|d| d.uid).filter(|_| self.active != Some(index));
if front.is_none() {
self.reset_tool_for_doc_switch();
}
// Closed (saved or discarded): nothing left to recover.
let uid = self.docs[index].uid;
cmd::recovery::forget(self, uid);
@@ -416,6 +423,10 @@ impl Session {
if let Some(stash) = &mut self.batch_stash {
stash.push(old);
}
if let Some(uid) = front {
self.active = self.docs.iter().position(|d| d.uid == uid);
return true;
}
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
self.sync_front_composite_fonts();
self.sync_front_profiles();
+1
View File
@@ -47,6 +47,7 @@ mod dashalign;
mod dcs;
mod distort;
mod docsetup;
mod document_close;
mod document_move;
mod draw2;
mod dynamic_symbols;
+81
View File
@@ -284,6 +284,87 @@ fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() {
assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null);
}
/// Automation roots that grant reading `read` alone.
fn read_roots(read: &[&std::path::Path]) -> Arc<crate::file_access::AutomationRoots> {
crate::file_access::AutomationRoots::new(read, &[]).unwrap().unwrap()
}
#[test]
fn an_agent_outside_the_arrowheads_files_folder_sees_its_file_name_alone_and_cannot_reload_it() {
let dir = folder("roots-list");
let (prefs, other) = (dir.join("prefs"), dir.join("other"));
std::fs::create_dir_all(&prefs).unwrap();
std::fs::create_dir_all(&other).unwrap();
let file = prefs.join(arrowheads::FILE_NAME);
std::fs::write(&file, b"not a drawing").unwrap();
let (file, prefs_text) = (file.to_string_lossy().to_string(), prefs.to_string_lossy().to_string());
let mut s = Session::new();
s.attach_arrowheads_file(file.clone());
let (outside, inside) = (read_roots(&[&other]), read_roots(&[&prefs]));
let list = crate::file_access::confine(Some(&outside), || s.execute("stroke.arrowheads.list", &json!({}))).unwrap();
assert_eq!(list["file"], serde_json::Value::Null, "{list}");
let error = list["error"].as_str().unwrap();
assert!(error.starts_with(arrowheads::FILE_NAME) && !error.contains(&prefs_text), "{error}");
let list = crate::file_access::confine(Some(&inside), || s.execute("stroke.arrowheads.list", &json!({}))).unwrap();
assert_eq!(list["file"].as_str(), Some(file.as_str()));
assert!(list["error"].as_str().unwrap().starts_with(&file), "{list}");
// A good file now: an agent outside the folder cannot have it read.
std::fs::write(&file, vectorcraft_format::save_file(&arrowheads::starter())).unwrap();
let e = crate::file_access::confine(Some(&outside), || s.execute("stroke.arrowheads.reload", &json!({}))).unwrap_err().to_string();
assert!(e.contains("outside the read root") && e.ends_with(arrowheads::FILE_NAME) && !e.contains(&prefs_text), "{e}");
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
assert!(list["arrowheads"].as_array().unwrap().is_empty() && list["error"].is_string(), "not read again: {list}");
let r = crate::file_access::confine(Some(&inside), || s.execute("stroke.arrowheads.reload", &json!({}))).unwrap();
assert_eq!(r["count"], 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_agent_edits_the_arrowheads_file_only_inside_its_read_roots() {
let dir = folder("roots-edit");
let (prefs, other) = (dir.join("prefs"), dir.join("other"));
std::fs::create_dir_all(&other).unwrap();
let file = prefs.join(arrowheads::FILE_NAME).to_string_lossy().to_string();
let prefs_text = prefs.to_string_lossy().to_string();
let (mut s, _) = session_with_line();
s.attach_arrowheads_file(file.clone());
let line_doc = s.doc().unwrap().uid;
let outside = read_roots(&[&other]);
let edit = |s: &mut Session, roots: &Arc<crate::file_access::AutomationRoots>| {
crate::file_access::confine(Some(roots), || s.execute("stroke.arrowheads.edit", &json!({})))
};
// Refused before the folder or the file is made, or anything is opened.
let e = edit(&mut s, &outside).unwrap_err().to_string();
assert!(e.ends_with(arrowheads::FILE_NAME) && !e.contains(&prefs_text), "{e}");
assert!(!prefs.exists(), "no folder made");
assert_eq!((s.documents().len(), s.doc().unwrap().uid), (1, line_doc));
// From the Stroke panel menu (no roots), the file is made and opened.
assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], true);
assert_eq!(s.documents().len(), 2);
// Open already: still not brought to the front for an agent outside the folder.
s.execute("document.activate", &json!({"index": 0})).unwrap();
assert!(edit(&mut s, &outside).is_err());
assert_eq!((s.documents().len(), s.doc().unwrap().uid), (2, line_doc));
// Inside the read roots: opened under them, with its path.
let inside = read_roots(&[&prefs]);
let mut agent = Session::new();
agent.attach_arrowheads_file(file.clone());
let r = edit(&mut agent, &inside).unwrap();
assert_eq!((r["path"].as_str(), &r["created"]), (Some(file.as_str()), &json!(false)));
let st = agent.doc().unwrap();
assert_eq!(st.path.as_deref(), Some(file.as_str()));
assert!(st.opened_by.as_ref().is_some_and(|r| Arc::ptr_eq(r, &inside)), "opened under the agent's roots");
// A missing file inside the roots is still made first.
let fresh = dir.join("fresh").join(arrowheads::FILE_NAME).to_string_lossy().to_string();
let mut agent = Session::new();
agent.attach_arrowheads_file(fresh.clone());
let r = edit(&mut agent, &read_roots(&[&dir])).unwrap();
assert_eq!(r["created"], true);
assert!(std::path::Path::new(&fresh).is_file());
assert_eq!(agent.doc().unwrap().path.as_deref(), Some(fresh.as_str()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_heads_listed_after_a_save_are_read_from_the_file_and_editing_keeps_it() {
let dir = folder("saved");
+1 -1
View File
@@ -840,7 +840,7 @@ fn shape_builder_applies_a_merge_before_what_comes_next() {
}),
];
let _proof = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = vectorcraft_testkit::temp_dir("shape-builder-merge-first");
let dir = vectorcraft_testkit::TempDir::new("shape-builder-merge-first");
for (what, then) in next {
let mut s = session();
if what == "Select All Instances" {
+3 -2
View File
@@ -22,7 +22,8 @@ fn revision(s: &Session) -> u64 {
#[test]
fn a_pdf_export_cancelled_between_pages_stops_writes_nothing_and_changes_nothing() {
let path = vectorcraft_testkit::temp_dir("cancel-pdf").join("out.pdf");
let dir = vectorcraft_testkit::TempDir::new("cancel-pdf");
let path = dir.join("out.pdf");
std::fs::remove_file(&path).ok();
let mut s = session(5);
let before = (revision(&s), s.doc().unwrap().history.undo.len());
@@ -66,7 +67,7 @@ fn a_cancelled_flattening_export_stops_before_the_pages() {
#[test]
fn raster_exports_and_svg_stop_when_cancelled_too() {
let dir = vectorcraft_testkit::temp_dir("cancel-formats");
let dir = vectorcraft_testkit::TempDir::new("cancel-formats");
let mut s = session(3);
let token = CancelToken::new();
token.cancel();
+9 -18
View File
@@ -36,7 +36,7 @@ fn error(s: &mut Session, p: Value) -> String {
#[test]
fn an_export_compares_equal_and_a_moved_square_counts_the_pixels_it_changed() {
let dir = vectorcraft_testkit::temp_dir("compare-moved");
let dir = vectorcraft_testkit::TempDir::new("compare-moved");
let png = path_in(&dir, "art.png");
let (mut s, id) = session();
export(&mut s, &png);
@@ -54,12 +54,11 @@ fn an_export_compares_equal_and_a_moved_square_counts_the_pixels_it_changed() {
assert_eq!(r["rmse"].as_f64(), Some(rmse), "{r}");
// Above every difference, nothing is over.
assert_eq!(compare(&mut s, json!({"path": png, "threshold": 1}))["over"], json!(0));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_image_and_the_render_are_flattened_on_the_background() {
let dir = vectorcraft_testkit::temp_dir("compare-background");
let dir = vectorcraft_testkit::TempDir::new("compare-background");
let png = path_in(&dir, "clear.png");
// Transparent everywhere, with red in the color channels that the alpha hides.
image::RgbaImage::from_pixel(80, 40, image::Rgba([255, 0, 0, 0])).save(&png).unwrap();
@@ -81,12 +80,11 @@ fn the_image_and_the_render_are_flattened_on_the_background() {
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(255.0)), Some(1600)), "{r}");
// A threshold just above 128/255 leaves the gray differences out.
assert_eq!(compare(&mut s, json!({"path": png, "background": "#808080", "threshold": 0.51}))["over"], json!(0));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_image_of_other_proportions_is_refused_and_one_pixel_is_allowed() {
let dir = vectorcraft_testkit::temp_dir("compare-size");
let dir = vectorcraft_testkit::TempDir::new("compare-size");
let (mut s, _) = session();
let square = path_in(&dir, "square.png");
image::RgbaImage::from_pixel(30, 30, image::Rgba([255; 4])).save(&square).unwrap();
@@ -96,12 +94,11 @@ fn an_image_of_other_proportions_is_refused_and_one_pixel_is_allowed() {
image::RgbaImage::from_pixel(80, 41, image::Rgba([255; 4])).save(&taller).unwrap();
let r = compare(&mut s, json!({"path": taller}));
assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["total"].as_u64()), (Some(80), Some(41), Some(3280)), "{r}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_export_whose_height_was_rounded_compares_equal() {
let dir = vectorcraft_testkit::temp_dir("compare-rounded");
let dir = vectorcraft_testkit::TempDir::new("compare-rounded");
let png = path_in(&dir, "art.png");
// 20.25 pt at 2 pixels a point is 40.5 rows, which the export rounds to 41.
let mut s = Session::new();
@@ -112,7 +109,6 @@ fn an_export_whose_height_was_rounded_compares_equal() {
export(&mut s, &png);
let r = compare(&mut s, json!({"path": png}));
assert_eq!(r, json!({"width": 80, "height": 41, "rmse": 0.0, "over": 0, "total": 3280, "threshold": 0.08}));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
@@ -151,7 +147,7 @@ fn a_linked_image_is_drawn_from_its_file_as_a_png_export_draws_it() {
#[test]
fn an_image_that_cannot_be_decoded_and_a_missing_font_warn_as_a_png_export_does() {
let dir = vectorcraft_testkit::temp_dir("compare-warnings");
let dir = vectorcraft_testkit::TempDir::new("compare-warnings");
let png = path_in(&dir, "art.png");
let (mut s, _) = session();
export(&mut s, &png);
@@ -167,12 +163,11 @@ fn an_image_that_cannot_be_decoded_and_a_missing_font_warn_as_a_png_export_does(
assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{r}");
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("Nonexistent Sans Probe")), "{r}");
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("doesn't fit its frame")), "{r}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn diff_path_writes_each_pixels_differences() {
let dir = vectorcraft_testkit::temp_dir("compare-diff");
let dir = vectorcraft_testkit::TempDir::new("compare-diff");
let png = path_in(&dir, "art.png");
let diff = path_in(&dir, "diff.png");
let (mut s, id) = session();
@@ -188,12 +183,11 @@ fn diff_path_writes_each_pixels_differences() {
}
let changed = img.pixels().filter(|p| p.0[0] != 0).count();
assert_eq!(changed as u64, r["over"].as_u64().unwrap());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn missing_unreadable_and_malformed_inputs_are_errors() {
let dir = vectorcraft_testkit::temp_dir("compare-errors");
let dir = vectorcraft_testkit::TempDir::new("compare-errors");
let (mut s, _) = session();
let png = path_in(&dir, "art.png");
export(&mut s, &png);
@@ -216,14 +210,13 @@ fn missing_unreadable_and_malformed_inputs_are_errors() {
let e = error(&mut s, p.clone());
assert!(e.contains(want), "{p}: {e}");
}
let _ = std::fs::remove_dir_all(dir);
}
/// A planar TIFF whose header claims more strips than the decoder can count (2^31 + 1 rows a strip
/// each, times 5 planes) is refused as too large to draw, without a panic.
#[test]
fn a_tiff_with_more_strips_than_can_be_counted_is_too_large() {
let dir = vectorcraft_testkit::temp_dir("compare-strips");
let dir = vectorcraft_testkit::TempDir::new("compare-strips");
let (mut s, _) = session();
let plane = |v: u16| vec![v; 6];
let channels = [plane(200), plane(40), plane(10), plane(0), plane(90)];
@@ -247,12 +240,11 @@ fn a_tiff_with_more_strips_than_can_be_counted_is_too_large() {
std::fs::write(&tif, &file).unwrap();
let e = error(&mut s, json!({"path": tif}));
assert_eq!(e, "invalid parameters for `document.compare`: an image of 3 × 2147483649 pixels is too large to draw");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_image_and_the_difference_image_stay_inside_the_automation_roots() {
let dir = vectorcraft_testkit::temp_dir("compare-roots");
let dir = vectorcraft_testkit::TempDir::new("compare-roots");
for d in ["in", "out", "elsewhere"] {
std::fs::create_dir_all(dir.join(d)).unwrap();
}
@@ -272,5 +264,4 @@ fn the_image_and_the_difference_image_stay_inside_the_automation_roots() {
assert!(!std::path::Path::new(&stray).exists());
let kept = path_in(&dir.join("out"), "diff.png");
assert_eq!(run(json!({"path": inside, "diffPath": kept})).unwrap()["diffPath"], json!(kept));
let _ = std::fs::remove_dir_all(dir);
}
+1 -1
View File
@@ -312,7 +312,7 @@ fn an_images_stroke_with_outline_object_follows_its_opaque_area() {
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 3}, "item": 0}));
assert_eq!([shade(&s, 61, 100), shade(&s, 64, 100)], ["white", "black"]);
// Relinked to an opaque image, the stroke follows the frame 3 pt out: it shows from 43 to 50.
let dir = vectorcraft_testkit::temp_dir("outline-object-relink");
let dir = vectorcraft_testkit::TempDir::new("outline-object-relink");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("opaque.png");
let mut png = vec![];
+22
View File
@@ -57,6 +57,28 @@ fn type_gives_font_properties_and_gradients_css_gradients() {
assert!(text.contains("background-image: linear-gradient(90deg, "), "{text}");
}
/// CSS gradients mix linearly: a gradient mixed around the hue wheel is written through the stops
/// on its way around the wheel, so red to blue lists green one way and magenta the other.
#[test]
fn gradients_mixed_around_the_hue_wheel_give_css_the_stops_on_their_way() {
let mut s = session();
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 100, "width": 100, "height": 50}));
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
let gradient = |s: &mut Session, method: &str| {
run(s, "paint.setFill", json!({"gradient": {"kind": "linear", "method": method, "stops": stops}}));
let text = css(&run(s, "css.selection", json!({}))).to_string();
text.lines().find(|l| l.contains("linear-gradient(")).unwrap_or_else(|| panic!("a gradient in {text}")).to_string()
};
let classic = gradient(&mut s, "classic");
assert_eq!(classic.matches('%').count(), 2, "{classic}");
for (method, middle, other) in [("hue-counterclockwise", "#00ff00 50%", "#ff00ff"), ("hue-clockwise", "#ff00ff 50%", "#00ff00")] {
let line = gradient(&mut s, method);
assert!(line.matches('%').count() > 2, "{method}: {line}");
assert!(line.contains("#ff0000 0%") && line.contains("#0000ff 100%"), "{method}: {line}");
assert!(line.contains(middle) && !line.contains(other), "{method}: {line}");
}
}
#[test]
fn generate_covers_the_document_and_an_empty_selection_has_none() {
let mut s = session();
+3 -8
View File
@@ -3,8 +3,6 @@
//! the Links panel's Show DCS Transparency Interactions (`links.list` with `dcsTransparency`).
//! The files are made from scratch as http://www.telegraphics.net/doc/DCS.txt describes them.
use std::path::PathBuf;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Node, NodeKind};
@@ -17,12 +15,9 @@ fn session() -> Session {
s
}
/// A fresh scratch folder for one test.
fn temp_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vc-dcs-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
/// A fresh scratch folder for one test, removed when dropped.
fn temp_dir(name: &str) -> vectorcraft_testkit::TempDir {
vectorcraft_testkit::TempDir::new(&format!("dcs-{name}"))
}
/// A plate program: one 2 × 2 grayscale image (`gray`, row by row) on a 20 × 10 pt page.
+91
View File
@@ -0,0 +1,91 @@
//! `file.close {index}`: closing a document tab other than the current one keeps the current
//! document current, and closing the current one makes its neighbor current.
use serde_json::json;
use crate::*;
/// A session with documents A, B, C and D, in that order, D current.
fn four() -> Session {
let mut s = Session::new();
for name in ["A", "B", "C", "D"] {
s.execute("file.new", &json!({"name": name, "created": null})).unwrap();
}
s
}
fn order(s: &Session) -> String {
s.documents().iter().map(DocState::title).collect::<Vec<_>>().join("")
}
fn current(s: &Session) -> String {
s.active().map(DocState::title).unwrap_or_default()
}
fn close(s: &mut Session, index: usize) {
s.execute("file.close", &json!({"index": index})).unwrap_or_else(|e| panic!("file.close {index}: {e}"));
}
#[test]
fn closing_a_tab_before_the_current_one_keeps_the_current_document() {
let mut s = four();
close(&mut s, 0);
assert_eq!((order(&s), current(&s)), ("BCD".into(), "D".into()));
assert_eq!(s.active_index(), Some(2));
assert!(s.set_active(1));
close(&mut s, 0);
assert_eq!((order(&s), current(&s)), ("CD".into(), "C".into()));
assert_eq!(s.active_index(), Some(0));
}
#[test]
fn closing_a_tab_after_the_current_one_keeps_the_current_document() {
let mut s = four();
assert!(s.set_active(1));
close(&mut s, 3);
assert_eq!((order(&s), current(&s)), ("ABC".into(), "B".into()));
close(&mut s, 2);
assert_eq!((order(&s), current(&s)), ("AB".into(), "B".into()));
assert_eq!(s.active_index(), Some(1));
}
#[test]
fn closing_the_current_tab_makes_its_neighbor_current() {
let mut s = four();
assert!(s.set_active(1));
close(&mut s, 1);
assert_eq!((order(&s), current(&s)), ("ACD".into(), "C".into()), "the tab that took its place");
assert!(s.set_active(2));
close(&mut s, 2);
assert_eq!((order(&s), current(&s)), ("AC".into(), "C".into()), "the last tab: the one before it");
s.execute("file.close", &json!({})).unwrap();
assert_eq!((order(&s), current(&s)), ("A".into(), "A".into()), "without an index, the current one closes");
close(&mut s, 0);
assert!(s.active().is_none());
assert!(s.execute("file.close", &json!({"index": 0})).is_err(), "no document left");
}
#[test]
fn closing_another_tab_keeps_the_tool_and_the_documents_edits() {
let mut s = four();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let resets = s.tool_resets;
let nodes = s.doc().unwrap().doc.node_count();
let history = super::history(&s);
close(&mut s, 1);
assert_eq!(s.tool_resets, resets, "no document was switched to, so the tool is not reset");
assert_eq!(super::history(&s), history);
assert_eq!(s.doc().unwrap().doc.node_count(), nodes);
assert!(s.doc().unwrap().is_dirty(), "the document keeps its unsaved edit");
close(&mut s, 2);
assert_ne!(s.tool_resets, resets, "closing the current document resets the tool");
}
#[test]
fn a_bad_index_closes_nothing() {
let mut s = four();
assert!(s.set_active(2));
assert!(s.execute("file.close", &json!({"index": 4})).is_err());
assert!(!s.close_document(4));
assert_eq!((order(&s), current(&s)), ("ABCD".into(), "C".into()));
}
+17 -22
View File
@@ -14,12 +14,12 @@ use crate::cmd::findfiles::{self, Limits, Rules, Visitor};
use crate::cmd::fontfiles::{self, FontSearch, MAX_FONT_FILE, Sought, copy_into};
use crate::{EngineError, Session};
/// A fresh temporary folder (canonical, as a search reports it).
fn folder(tag: &str) -> PathBuf {
let dir = vectorcraft_testkit::temp_dir(&format!("fontfiles-{tag}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
findfiles::plain(std::fs::canonicalize(&dir).unwrap())
/// A fresh temporary folder, removed when the first part is dropped, and its path (canonical, as a
/// search reports it).
fn folder(tag: &str) -> (vectorcraft_testkit::TempDir, PathBuf) {
let dir = vectorcraft_testkit::TempDir::new(&format!("fontfiles-{tag}"));
let path = findfiles::plain(std::fs::canonicalize(&*dir).unwrap());
(dir, path)
}
/// A session whose folder searches may go anywhere in `root`, on two threads.
@@ -103,7 +103,7 @@ fn missing_fonts_are_listed_symbols_included() {
/// listed finds the other fonts' files.
#[test]
fn an_overlong_font_name_leaves_the_other_fonts_searchable() {
let root = folder("long-name");
let (_dir, root) = folder("long-name");
std::fs::write(root.join("Findme.otf"), vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap();
let mut s = session_for(&root);
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
@@ -120,7 +120,7 @@ fn an_overlong_font_name_leaves_the_other_fonts_searchable() {
/// apps' folders, such as the attachments a mail app keeps in the home folder's Library.
#[test]
fn the_fonts_folder_next_to_a_document_in_another_apps_folder_isnt_offered() {
let root = folder("next-to");
let (_dir, root) = folder("next-to");
let home = root.join("Users/me");
let mut s = Session::new();
s.search_rules = Some(Rules { home: Some(home.clone()), ..Rules::default() });
@@ -140,7 +140,7 @@ fn the_fonts_folder_next_to_a_document_in_another_apps_folder_isnt_offered() {
#[cfg(unix)]
#[test]
fn the_fonts_folder_next_to_a_document_is_an_absolute_path() {
let root = folder("relative");
let (_dir, root) = folder("relative");
std::fs::create_dir_all(root.join("pkg/Fonts")).unwrap();
let cwd = std::env::current_dir().unwrap();
let up = "../".repeat(cwd.components().count().saturating_sub(1));
@@ -160,7 +160,7 @@ fn the_fonts_folder_next_to_a_document_is_an_absolute_path() {
#[test]
fn a_search_finds_a_missing_fonts_file_by_its_names() {
let root = folder("find");
let (_dir, root) = folder("find");
std::fs::create_dir_all(root.join("x/y")).unwrap();
std::fs::write(root.join("x/y/Findme.otf"), vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap();
std::fs::write(root.join("other.ttf"), bundled("SourceSerif4-Regular.ttf")).unwrap();
@@ -199,7 +199,7 @@ fn a_search_finds_a_missing_fonts_file_by_its_names() {
/// when they are inside them.
#[test]
fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
let root = folder("read-roots");
let (_dir, root) = folder("read-roots");
let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3");
let (home, wide) = (root.join("home"), root.join("lrf-wide"));
for dir in [&home, &wide] {
@@ -228,12 +228,11 @@ fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
assert!(finished(&mut s)["error"].as_str().is_some_and(|e| e.contains("lrf-wide")));
let r = ask(&mut s, &narrow, json!({}));
assert_eq!((r["state"].as_str(), r["error"].as_str(), r.get("folder")), (Some("failed"), Some("the folder can't be read"), None), "{r}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn junk_search_params_never_fail_internally() {
let root = folder("junk");
let (_dir, root) = folder("junk");
let mut s = session_for(&root);
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
text(&mut s, 20.0, "No Such Font Family", "Regular");
@@ -297,7 +296,7 @@ impl Visitor for Blocked {
#[test]
fn stop_ends_only_the_search_it_names() {
let root = folder("stop");
let (_dir, root) = folder("stop");
std::fs::write(root.join("a.ttf"), b"font").unwrap();
let mut s = session_for(&root);
let (tx, rx) = mpsc::channel();
@@ -330,7 +329,7 @@ fn adding_fonts_needs_vectorcraft_s_fonts_folder() {
#[test]
fn copying_never_replaces_a_file() {
let root = folder("copy");
let (_dir, root) = folder("copy");
let (src, dest) = (root.join("src"), root.join("dest"));
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&dest).unwrap();
@@ -376,12 +375,11 @@ fn copying_never_replaces_a_file() {
assert_eq!(reasons[..2], ["larger than 256 MB", "not a file"]);
assert!(r.copied.is_empty() && reasons.len() == 3, "{r:?}");
assert!(!dest.join("huge.otf").exists() && !dest.join("folder.otf").exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn only_font_files_are_copied() {
let root = folder("allowed");
let (_dir, root) = folder("allowed");
let (src, dest) = (root.join("src"), root.join("dest"));
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&dest).unwrap();
@@ -395,13 +393,12 @@ fn only_font_files_are_copied() {
let not_a_font = |n: &str| (src.join(n), "not a font file".to_string());
assert_eq!(r.skipped, [not_a_font("notes.txt"), not_a_font("Script")]);
assert!(!dest.join("notes.txt").exists() && !dest.join("Script").exists());
let _ = std::fs::remove_dir_all(&root);
}
/// A search reads no font file larger than Add Fonts copies, and finds a smaller copy of the font.
#[test]
fn a_search_skips_font_files_too_large_to_add() {
let root = folder("too-large");
let (_dir, root) = folder("too-large");
let (big, small) = (root.join("big").join("Sizeme.otf"), root.join("small").join("Sizeme.otf"));
let font = vectorcraft_testkit::fonts::renamed("Sizeme Sans 3");
for path in [&big, &small] {
@@ -415,7 +412,6 @@ fn a_search_skips_font_files_too_large_to_add() {
s.execute("text.findFontFiles", &json!({"folder": root, "fonts": [{"family": "Sizeme Sans 3"}, {"family": "No Such Sans"}]})).unwrap();
let r = finished(&mut s);
assert_eq!(r["fonts"][0]["files"], json!([small]), "{r}");
let _ = std::fs::remove_dir_all(&root);
}
/// A suitcase font: a file without an extension or with `.suit`, empty but for the fonts in its
@@ -424,7 +420,7 @@ fn a_search_skips_font_files_too_large_to_add() {
#[test]
fn suitcase_fonts_are_found_and_copied_with_their_fork() {
use vectorcraft_testkit::fonts::{renamed, suitcase_fork};
let root = folder("suitcase");
let (_dir, root) = folder("suitcase");
let (src, dest) = (root.join("src"), root.join("dest"));
std::fs::create_dir_all(src.join("sub")).unwrap();
std::fs::create_dir_all(&dest).unwrap();
@@ -467,5 +463,4 @@ fn suitcase_fonts_are_found_and_copied_with_their_fork() {
// The font scan reads the copies.
let db = vectorcraft_text::FontDb::with_font_dirs(vec![dest.clone()]);
assert_eq!((db.styles("Suitme Sans 3"), db.styles("Other Sans 33")), (vec!["Regular".to_string()], vec!["Regular".to_string()]));
let _ = std::fs::remove_dir_all(&root);
}
+72 -2
View File
@@ -511,7 +511,7 @@ fn an_outlined_stroke_leaves_dither_behind() {
/// The Gradient panel's Method (Illustrator 30): `method` set and cleared through the gradient
/// commands, on fills and strokes, kept by the params, swatches and a native save and load;
/// anything but classic or perceptual is refused without editing.
/// anything but the four method names is refused without editing.
#[test]
fn method_is_a_gradient_option_that_saves_and_loads() {
use vectorcraft_color::GradientMethod;
@@ -548,7 +548,7 @@ fn method_is_a_gradient_option_that_saves_and_loads() {
let before = node(&s, id).appearance.clone();
for bad in [json!("smooth"), json!(1), json!(true)] {
let e = s.execute("paint.editGradient", &json!({"method": bad})).unwrap_err().to_string();
assert!(e.contains("`method` must be classic or perceptual"), "{e}");
assert!(e.contains("`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise"), "{e}");
assert!(s.execute("paint.setFill", &json!({"gradient": {"method": bad}})).is_err());
}
assert_eq!(node(&s, id).appearance, before);
@@ -566,6 +566,76 @@ fn method_is_a_gradient_option_that_saves_and_loads() {
assert!(back.gradient.dither && back.gradient.method == GradientMethod::Perceptual);
}
/// The hue methods: `method` hue-clockwise and hue-counterclockwise set through the gradient
/// commands on fills and strokes, as one undo step, kept by the params, a swatch, a saved preset
/// and a native save and load, and written with or without the hyphen.
#[test]
fn the_hue_methods_are_set_saved_and_loaded() {
use vectorcraft_color::GradientMethod;
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
s.execute("paint.setFill", &json!({"gradient": {"stops": stops}})).unwrap();
for (name, method, middle) in
[("hue-clockwise", GradientMethod::HueClockwise, "#ff00ff"), ("hue-counterclockwise", GradientMethod::HueCounterclockwise, "#00ff00")]
{
let undo = s.doc().unwrap().history.undo.len();
s.execute("paint.editGradient", &json!({"method": name})).unwrap();
let g = fill_gradient(&s, id);
assert_eq!(g.gradient.method, method);
assert_eq!(g.gradient.stops.len(), 2, "{name}: the stops stay as they were");
assert_eq!(g.gradient.sample(0.5).0.to_hex(), middle, "{name}: halfway around the wheel");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{name}: one undo step");
// The params carry it, so a swatch, a preset and a copied paint keep it.
let p = vectorcraft_tools::params::gradient_params(&g);
assert_eq!(p["method"], json!(name));
let mut q = gradient_param(&g);
q["name"] = json!(format!("Wheel {name}"));
s.execute("swatch.new", &q).unwrap();
let Some(Paint::Gradient(sw)) = s.doc().unwrap().doc.swatch(&format!("Wheel {name}")).map(|w| w.paint.clone()) else {
panic!("a gradient swatch")
};
assert_eq!(sw.gradient.method, method, "{name}: the swatch");
s.execute("gradient.presets.save", &json!({"name": format!("Preset {name}")})).unwrap();
s.execute("paint.editGradient", &json!({"method": "classic"})).unwrap();
s.execute("gradient.presets.apply", &json!({"name": format!("Preset {name}")})).unwrap();
assert_eq!(fill_gradient(&s, id).gradient.method, method, "{name}: the preset");
// Saved and opened again.
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("a gradient fill") };
assert_eq!(saved.gradient.method, method, "{name}: the native file keeps the method");
}
// Strokes take it too, and the name reads without the hyphen and in any case.
for (name, method) in [("hue-clockwise", GradientMethod::HueClockwise), ("hue-counterclockwise", GradientMethod::HueCounterclockwise)] {
for written in [name.replace('-', " "), name.replace('-', "").to_uppercase()] {
s.execute("paint.setStroke", &json!({"gradient": {"method": "classic"}})).unwrap();
s.execute("paint.editGradient", &json!({"method": written, "stroke": true})).unwrap();
let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") };
assert_eq!(st.gradient.method, method, "{written}");
}
}
// Reverse gives the same ramp the other way round: the hue method turns with the stops, so
// the middle keeps its color, unless the edit names a method too.
s.execute("paint.editGradient", &json!({"method": "hue-clockwise", "stroke": false})).unwrap();
let before = fill_gradient(&s, id).gradient;
s.execute("paint.editGradient", &json!({"reverse": true, "stroke": false})).unwrap();
let after = fill_gradient(&s, id).gradient;
assert_eq!(after.method, GradientMethod::HueCounterclockwise);
assert_eq!((after.stops[0].color.to_hex(), after.stops[1].color.to_hex()), ("#0000ff".to_string(), "#ff0000".to_string()));
for t in [0.25, 0.5, 0.75] {
assert_eq!(after.sample(t).0.to_hex(), before.sample(1.0 - t).0.to_hex(), "reversed at {t}");
}
s.execute("paint.editGradient", &json!({"reverse": true, "method": "hue-counterclockwise", "stroke": false})).unwrap();
assert_eq!(fill_gradient(&s, id).gradient.method, GradientMethod::HueCounterclockwise, "a method in the edit wins");
// Half a name is refused, nothing changed.
let before = node(&s, id).appearance.clone();
for bad in ["hue", "clockwise", "hue-clock"] {
let e = s.execute("paint.editGradient", &json!({"method": bad})).unwrap_err().to_string();
assert!(e.contains("`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise"), "{bad}: {e}");
}
assert_eq!(node(&s, id).appearance, before);
}
/// A conic gradient through `paint.setFill`, the Gradient panel's edits, transforms, the params
/// agents and swatches read, and the native format.
#[test]
+6 -4
View File
@@ -275,7 +275,8 @@ fn a_batch_fit_selection_replays_but_an_action_uses_the_current_selection() {
#[test]
fn a_save_records_its_date_and_replays_it() {
let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned();
let dir = vectorcraft_testkit::TempDir::new("journal-save");
let path = dir.join("a.vectorcraft").to_string_lossy().into_owned();
let mut s = Session::new();
s.execute("file.new", &json!({"created": null})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
@@ -290,7 +291,6 @@ fn a_save_records_its_date_and_replays_it() {
assert_eq!(s.doc().unwrap().doc.metadata.modified, Some(2_000_000_000));
assert!(s.execute("document.save", &json!({"path": path, "modified": "today"})).is_err());
// Save As stamps the same way; Save a Copy leaves the document's dates (and records none).
let dir = vectorcraft_testkit::temp_dir("journal-save");
s.execute("file.saveAs", &json!({"path": dir.join("b.vectorcraft").to_string_lossy(), "modified": 2_000_000_100})).unwrap();
assert_eq!(s.journal.last().unwrap().1["modified"], json!(2_000_000_100));
s.execute("file.saveCopy", &json!({"path": dir.join("c.vectorcraft").to_string_lossy()})).unwrap();
@@ -316,7 +316,8 @@ fn replayed(s: &Session) -> Session {
#[test]
fn a_save_in_a_batch_records_its_date_and_replays_it() {
let path = vectorcraft_testkit::temp_dir("journal-batch").join("a.vectorcraft").to_string_lossy().into_owned();
let dir = vectorcraft_testkit::TempDir::new("journal-batch");
let path = dir.join("a.vectorcraft").to_string_lossy().into_owned();
let mut s = Session::new();
s.execute("file.new", &json!({"created": 1_000_000_000})).unwrap();
let rect = json!({"x": 10, "y": 10, "width": 50, "height": 40});
@@ -698,7 +699,8 @@ fn a_failed_batch_closes_the_documents_it_opened_and_restores_the_ones_it_closed
#[test]
fn a_failed_batch_brings_back_a_document_it_reverted() {
let path = vectorcraft_testkit::temp_dir("journal-batch").join("revert.vectorcraft").to_string_lossy().into_owned();
let dir = vectorcraft_testkit::TempDir::new("journal-batch");
let path = dir.join("revert.vectorcraft").to_string_lossy().into_owned();
let mut s = Session::new();
s.execute("file.new", &json!({"created": null})).unwrap();
s.execute("document.save", &json!({"path": path, "modified": null})).unwrap();
+4 -4
View File
@@ -816,7 +816,7 @@ fn style_order_of(s: &mut Session) -> Vec<String> {
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_library_item_rename_is_saved_or_taken_back() {
let dir = vectorcraft_testkit::temp_dir("library-rename-item");
let dir = vectorcraft_testkit::TempDir::new("library-rename-item");
let folder = dir.to_string_lossy().to_string();
let mut s = session();
s.libraries.set_dir(Some(folder.clone()));
@@ -836,7 +836,6 @@ fn a_library_item_rename_is_saved_or_taken_back() {
run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "Text", "name": " Text "}));
assert!(s.execute("library.renameItem", &json!({"kind": "charStyle", "item": "Text", "name": "Copy"})).is_err());
assert_eq!(style_order_of(&mut s), ["Text"], "taken back");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(target_arch = "wasm32"))]
@@ -1056,7 +1055,8 @@ fn a_new_item_is_added_where_it_is_dropped() {
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn an_add_that_cannot_be_placed_makes_no_library() {
let folder = vectorcraft_testkit::temp_dir("library-add-unplaced").to_string_lossy().to_string();
let dir = vectorcraft_testkit::TempDir::new("library-add-unplaced");
let folder = dir.to_string_lossy().to_string();
let mut s = session();
s.libraries.set_dir(Some(folder.clone()));
for bad in [json!({"kind": "fillColor", "color": "#808080", "group": "Nope"}), json!({"kind": "fillColor", "color": "#808080", "after": "Nope"})]
@@ -1163,7 +1163,7 @@ fn a_library_of_one_repeated_name_imports_numbered() {
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_library_is_exported_to_a_file_and_imported_from_it() {
let dir = vectorcraft_testkit::temp_dir("library-export");
let dir = vectorcraft_testkit::TempDir::new("library-export");
let path = dir.join("Brand.vclibrary").to_string_lossy().to_string();
let (mut s, g, ..) = brand();
run(&mut s, "library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}]}));
+28
View File
@@ -416,6 +416,34 @@ fn spot_gradients_drawn_as_images_warn() {
assert!(!spot_warning(&warnings), "{warnings:?}");
}
/// A spot gradient mixed around the hue wheel is an image in process colors with the warning,
/// where the same stops mixed by Perceptual are a Separation or DeviceN shading: the colors
/// between its stops are not tints or mixes of the stops' inks.
#[test]
fn hue_wheel_spot_gradients_export_as_images_and_warn() {
let (mut s, _) = setup();
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": {"c": 1, "m": 0, "y": 1, "k": 0}, "spot": true}));
let spot_warning = |w: &[String]| w.iter().any(|w| w.contains("spot colors in gradients exported as images"));
let ink_to_leaf = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "swatch": "Leaf"}]});
for method in ["hue-clockwise", "hue-counterclockwise"] {
for (what, stops) in [("Ink → paper", ink_to_white(80.0)), ("Ink → Leaf", ink_to_leaf.clone())] {
for kind in ["linear", "radial", "conic", "diamond"] {
run(&mut s, "paint.editGradient", with(stops.clone(), json!({"method": method, "kind": kind})));
let (pdf, warnings) = pdf_of(&s);
assert!(pdf.contains("/Subtype/Image"), "{method} {kind} {what}: an image");
assert!(!pdf.contains("/Separation") && !pdf.contains("/DeviceN"), "{method} {kind} {what}: no spot ink shading");
assert!(spot_warning(&warnings), "{method} {kind} {what}: {warnings:?}");
}
}
// Without spot inks: an image, without the warning.
let red_blue = json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "kind": "linear"});
run(&mut s, "paint.editGradient", with(red_blue, json!({"method": method})));
let (pdf, warnings) = pdf_of(&s);
assert!(pdf.contains("/Subtype/Image"), "{method}: a process gradient is an image");
assert!(!spot_warning(&warnings), "{method}: {warnings:?}");
}
}
#[test]
fn freeform_gradients_without_points_warn_about_spot_stops() {
let (mut s, id) = setup();
+20 -26
View File
@@ -14,12 +14,12 @@ use crate::cmd::linkfiles::{self, LinkSearch, Sought};
use crate::tests_links::{BLUE, RED, place, png};
use crate::{EngineError, Session};
/// A fresh temporary folder (canonical, as a search reports it).
fn folder(tag: &str) -> PathBuf {
let dir = vectorcraft_testkit::temp_dir(&format!("linkfiles-{tag}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
findfiles::plain(std::fs::canonicalize(&dir).unwrap())
/// A fresh temporary folder, removed when the first part is dropped, and its path (canonical, as a
/// search reports it).
fn folder(tag: &str) -> (vectorcraft_testkit::TempDir, PathBuf) {
let dir = vectorcraft_testkit::TempDir::new(&format!("linkfiles-{tag}"));
let path = findfiles::plain(std::fs::canonicalize(&*dir).unwrap());
(dir, path)
}
/// Write `bytes` to the file at `path`, making its folders.
@@ -80,7 +80,7 @@ fn found(r: &Value, name: &str, root: &Path) -> Vec<(String, String, bool)> {
/// innermost out up to the first that differs.
#[test]
fn files_found_are_ranked_by_what_the_link_saved() {
let root = folder("rank");
let (_dir, root) = folder("rank");
let mut s = session_for(&root);
let (logo, photo) = (png(60, 40, RED), png(50, 30, BLUE));
write(&root.join("orig/logo.png"), &logo);
@@ -127,7 +127,7 @@ fn files_found_are_ranked_by_what_the_link_saved() {
/// the files found are ranked by the folder names they share with that path.
#[test]
fn a_classic_mac_path_is_found_by_its_name_and_folders() {
let root = folder("classic");
let (_dir, root) = folder("classic");
let mut s = session_for(&root);
let photo = png(50, 30, BLUE);
write(&root.join("orig/photo.png"), &photo);
@@ -158,7 +158,7 @@ fn a_classic_mac_path_is_found_by_its_name_and_folders() {
#[test]
fn parent_components_in_a_saved_path_are_resolved_before_ranking() {
for (tag, saved) in [("classic-up", "Old Disk:Projects:Art::photo.png"), ("slash-up", "/old/Projects/Art/../photo.png")] {
let root = folder(tag);
let (_dir, root) = folder(tag);
let mut s = session_for(&root);
let photo = png(50, 30, BLUE);
write(&root.join("orig/photo.png"), &photo);
@@ -188,7 +188,7 @@ fn parent_components_in_a_saved_path_are_resolved_before_ranking() {
/// files, whatever their status.
#[test]
fn a_search_looks_for_the_missing_files_or_the_objects_given() {
let root = folder("which");
let (_dir, root) = folder("which");
let mut s = session_for(&root);
write(&root.join("orig/gone.png"), &png(40, 30, RED));
write(&root.join("orig/here.png"), &png(40, 30, BLUE));
@@ -225,7 +225,7 @@ fn a_search_looks_for_the_missing_files_or_the_objects_given() {
/// other contents leaves it to search every folder.
#[test]
fn a_search_ends_once_every_linked_file_has_its_same_file() {
let root = folder("early");
let (_dir, root) = folder("early");
let mut s = session_for(&root);
s.search_threads = Some(1);
let bytes = png(40, 30, RED);
@@ -265,7 +265,7 @@ impl Visitor for Blocked {
#[test]
fn stop_ends_only_the_search_it_names() {
let root = folder("stop");
let (_dir, root) = folder("stop");
std::fs::write(root.join("a.png"), b"png").unwrap();
let mut s = session_for(&root);
let (tx, rx) = mpsc::channel();
@@ -290,7 +290,7 @@ fn stop_ends_only_the_search_it_names() {
/// roots, the replies are as before.
#[test]
fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
let root = folder("read-roots");
let (_dir, root) = folder("read-roots");
let mut s = session_for(&root);
let photo = png(40, 30, RED);
write(&root.join("lrf-away/photo.png"), &photo);
@@ -343,14 +343,13 @@ fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
let r = ask(&mut s, &narrow, json!({}));
assert_eq!((r["state"].as_str(), r["error"].as_str()), (Some("failed"), Some("the folder can't be read")), "{r}");
hidden(&r);
let _ = std::fs::remove_dir_all(&root);
}
/// A link's saved path relative to the document is shown to a confined caller when it leads to a
/// file inside its read roots, and left out when `..` leads out of them.
#[test]
fn a_relative_link_path_is_judged_from_the_documents_folder() {
let root = folder("read-roots-relative");
let (_dir, root) = folder("read-roots-relative");
let mut s = session_for(&root);
let home = root.join("home");
write(&root.join("lrf-away/photo.png"), &png(40, 30, RED));
@@ -370,14 +369,13 @@ fn a_relative_link_path_is_judged_from_the_documents_folder() {
assert_eq!(r["links"][0].get("path"), shown.then(|| json!(saved)).as_ref(), "{saved}: {r}");
finished(&mut s);
}
let _ = std::fs::remove_dir_all(&root);
}
/// `image.info` leaves a linked file outside the confined caller's read roots unnamed (`link`
/// null), as `links.info` leaves it out, and keeps the other fields.
#[test]
fn image_info_leaves_out_a_link_outside_the_read_roots() {
let root = folder("read-roots-info");
let (_dir, root) = folder("read-roots-info");
let mut s = session_for(&root);
let pic = root.join("lrf-away/photo.png");
write(&pic, &png(40, 30, RED));
@@ -391,12 +389,11 @@ fn image_info_leaves_out_a_link_outside_the_read_roots() {
let info = ask(&mut s, "links.info");
assert!(info.get("link").is_none() && !info.to_string().contains("lrf-away"), "{info}");
assert_eq!(s.execute("image.info", &json!({"id": id})).unwrap()["link"], json!(pic));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn relinking_with_copy_puts_the_file_in_the_documents_folder() {
let root = folder("copy");
let (_dir, root) = folder("copy");
let mut s = session_for(&root);
write(&root.join("orig/photo.png"), &png(40, 30, RED));
let id = placed(&mut s, &root.join("orig/photo.png"));
@@ -443,14 +440,13 @@ fn relinking_with_copy_puts_the_file_in_the_documents_folder() {
let image = placed(&mut unsaved, &found);
let r = unsaved.execute("links.relink", &json!({"ids": [image], "path": found, "copy": true}));
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg.contains("save the document first")), "{r:?}");
let _ = std::fs::remove_dir_all(&root);
}
/// Copy takes images only: with copy, a call that includes a placed document fails and copies
/// nothing; without it, the document is relinked where its file is.
#[test]
fn copy_refuses_placed_documents() {
let root = folder("copy-placed");
let (_dir, root) = folder("copy-placed");
let mut s = session_for(&root);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
let badge = root.join("badge.vectorcraft");
@@ -471,7 +467,7 @@ fn copy_refuses_placed_documents() {
#[cfg(unix)]
#[test]
fn a_copy_stays_inside_the_write_root() {
let root = folder("roots");
let (_dir, root) = folder("roots");
let mut s = session_for(&root);
write(&root.join("orig/photo.png"), &png(40, 30, RED));
let id = placed(&mut s, &root.join("orig/photo.png"));
@@ -487,14 +483,13 @@ fn a_copy_stays_inside_the_write_root() {
assert!(matches!(&r, Err(EngineError::Other(m)) if m.contains("outside the write root")), "{r:?}");
assert!(!root.join("doc/photo 2.png").exists());
assert_eq!(std::fs::read(allowed.join("photo.png")).unwrap(), b"kept as it is");
let _ = std::fs::remove_dir_all(&root);
}
/// An SVG's linked files are read on the importer's own thread (it needs a deep stack): the roots
/// in force on the thread that opens the SVG hold there too.
#[test]
fn an_svg_reads_its_links_within_the_read_root() {
let root = folder("svg-roots");
let (_dir, root) = folder("svg-roots");
for dir in ["in", "out"] {
write(&root.join(dir).join("dot.png"), &png(4, 4, RED));
}
@@ -513,12 +508,11 @@ fn an_svg_reads_its_links_within_the_read_root() {
assert_eq!(import("in"), inside, "inside the read root: read as before");
assert_ne!(import("out"), outside, "outside the read root: not read");
});
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn junk_search_params_never_fail_internally() {
let root = folder("junk");
let (_dir, root) = folder("junk");
let mut s = session_for(&root);
write(&root.join("orig/a.png"), &png(4, 4, RED));
let id = placed(&mut s, &root.join("orig/a.png"));
+33
View File
@@ -631,6 +631,39 @@ fn the_profile_follows_the_preset_access_and_reset() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn profile_errors_name_the_file_by_its_name_outside_the_read_roots() {
let (mut s, dir) = with_profiles("errors");
let elsewhere = dir.with_extension("elsewhere");
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
let whole = dir.join("Print.vectorcraft").to_string_lossy().to_string();
let run = |s: &mut Session, roots: &std::sync::Arc<crate::file_access::AutomationRoots>, cmd: &str| {
crate::file_access::confine(Some(roots), || s.execute(cmd, &json!({"name": "Print"}))).unwrap_err().to_string()
};
// No write access: edit and reset are refused before anything is written or deleted.
let read_only = crate::file_access::AutomationRoots::new(&[elsewhere.as_path()], &[]).unwrap().unwrap();
for cmd in ["file.newProfiles.edit", "file.newProfiles.reset"] {
let e = run(&mut s, &read_only, cmd);
assert!(e.ends_with(": Print.vectorcraft") && !e.contains(&whole), "{cmd}: {e}");
}
// Write access without read access: edit is refused, and nothing is written.
let write_only = crate::file_access::AutomationRoots::new(&[elsewhere.as_path()], &[dir.as_path()]).unwrap().unwrap();
let e = run(&mut s, &write_only, "file.newProfiles.edit");
assert!(e.contains("outside the read root") && e.ends_with(": Print.vectorcraft") && !e.contains(&whole), "{e}");
assert!(!dir.join("Print.vectorcraft").exists());
// A file reset cannot delete (a folder in its place) is named by its file name too.
std::fs::create_dir_all(dir.join("Print.vectorcraft")).unwrap();
let e = run(&mut s, &write_only, "file.newProfiles.reset");
assert!(e.starts_with("Print.vectorcraft: ") && !e.contains(&whole), "{e}");
// Inside the read roots the error names the whole path.
let inside = crate::file_access::AutomationRoots::new(&[dir.as_path()], &[dir.as_path()]).unwrap().unwrap();
let e = run(&mut s, &inside, "file.newProfiles.reset");
assert!(e.starts_with(&format!("{whole}: ")), "{e}");
let _ = std::fs::remove_dir_all(&elsewhere);
let _ = std::fs::remove_dir_all(&dir);
}
/// The new document's links find their files from the profile's file, as a document opened from
/// that file does; a document without a profile has no such file.
#[test]
+3
View File
@@ -756,6 +756,9 @@ fn precise_cursors_replace_every_pen_state() {
Cursor::LivePaintBucket,
Cursor::LivePaintBucketFace,
Cursor::LivePaintBucketEdge,
Cursor::LivePaintSelectFace,
Cursor::LivePaintSelectEdge,
Cursor::LivePaintSelectOutside,
] {
assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}");
}
+2 -4
View File
@@ -51,7 +51,7 @@ fn entries_keep_their_sections_and_the_command_counts_codes() {
assert_eq!(r["codes"]["modifiers"], json!([{"value": 64, "count": 2}, {"value": 80, "count": 1}]));
assert_eq!((r["unread"].as_array().unwrap().len(), r["truncated"].as_bool()), (0, Some(false)), "{r}");
// The same from a file, and from its bytes.
let dir = vectorcraft_testkit::temp_dir("shortcutset-read");
let dir = vectorcraft_testkit::TempDir::new("shortcutset-read");
let path = dir.join("Studio Keys.kys");
std::fs::write(&path, text).unwrap();
let mut s = Session::new();
@@ -59,7 +59,6 @@ fn entries_keep_their_sections_and_the_command_counts_codes() {
assert_eq!((r["set"].as_str(), r["entries"].as_array().unwrap().len()), (Some("Studio Keys"), 3));
let b64 = vectorcraft_format::base64_encode(text.as_bytes());
assert_eq!(s.execute("shortcutSet.read", &json!({ "dataBase64": b64 })).unwrap()["entries"].as_array().unwrap().len(), 3);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
@@ -124,7 +123,7 @@ fn caps_nesting_entries_and_names() {
#[test]
fn big_files_are_read_up_to_the_cap() {
let text = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(5 << 20));
let dir = vectorcraft_testkit::temp_dir("shortcutset-big");
let dir = vectorcraft_testkit::TempDir::new("shortcutset-big");
let path = dir.join("Big.kys");
std::fs::write(&path, &text).unwrap();
let b64 = vectorcraft_format::base64_encode(text.as_bytes());
@@ -145,7 +144,6 @@ fn big_files_are_read_up_to_the_cap() {
assert_eq!(fits.len(), (4 << 20) + 3);
std::fs::write(&path, &fits).unwrap();
assert_eq!(s.execute("shortcutSet.read", &json!({ "path": path.to_string_lossy() })).unwrap()["truncated"], false);
let _ = std::fs::remove_dir_all(dir);
}
fn matched(s: &mut Session, p: Value) -> Vec<String> {
+5 -5
View File
@@ -161,7 +161,7 @@ fn a_changed_word_replaces_the_old_one() {
#[test]
fn the_custom_dictionary_file_is_read_and_kept_up_to_date() {
let _one_at_a_time = dictionary_lock();
let dir = vectorcraft_testkit::temp_dir("spelling-dictionary");
let dir = vectorcraft_testkit::TempDir::new("spelling-dictionary");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "kqzvwx\nnot one word\n\n").unwrap();
let mut s = session();
@@ -224,7 +224,7 @@ fn in_dictionary(s: &mut Session, word: &str) -> bool {
#[test]
fn a_dictionary_file_that_cant_be_read_whole_is_never_written_over() {
let _one_at_a_time = dictionary_lock();
let dir = vectorcraft_testkit::temp_dir("spelling-unreadable");
let dir = vectorcraft_testkit::TempDir::new("spelling-unreadable");
// A folder where the file should be can't be read as one; a file past what craft-proof reads
// would lose its other words if written back.
let folder = dir.join("Custom Dictionary.txt");
@@ -248,7 +248,7 @@ fn a_change_that_cant_be_saved_is_taken_back() {
let _one_at_a_time = dictionary_lock();
let mut s = session();
run(&mut s, "text.spelling.addWord", json!({"word": "wkqzvx"}));
let dir = vectorcraft_testkit::temp_dir("spelling-rollback");
let dir = vectorcraft_testkit::TempDir::new("spelling-rollback");
let folder = dir.join("Custom Dictionary.txt");
std::fs::create_dir_all(&folder).unwrap();
let mut failing = session();
@@ -266,7 +266,7 @@ fn a_change_that_cant_be_saved_is_taken_back() {
fn a_dictionary_edit_is_saved_whole_or_not_at_all() {
let _one_at_a_time = dictionary_lock();
const EDIT: &str = "text.spelling.editDictionary";
let dir = vectorcraft_testkit::temp_dir("spelling-edit");
let dir = vectorcraft_testkit::TempDir::new("spelling-edit");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "pqzvka\npqzvkb\n").unwrap();
let mut s = session();
@@ -302,7 +302,7 @@ fn a_dictionary_edit_is_saved_whole_or_not_at_all() {
fn a_word_another_window_saved_stays_when_this_save_fails() {
let _one_at_a_time = dictionary_lock();
use std::os::unix::fs::PermissionsExt;
let dir = vectorcraft_testkit::temp_dir("spelling-readonly");
let dir = vectorcraft_testkit::TempDir::new("spelling-readonly");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "").unwrap();
let mut s = session();
+22 -14
View File
@@ -7,10 +7,8 @@ use vectorcraft_color::cms;
use crate::Session;
use crate::store::{MAX_PRESET_FILE, file_stem, holds};
fn fresh_dir(tag: &str) -> std::path::PathBuf {
let dir = vectorcraft_testkit::temp_dir(&format!("store-{tag}"));
let _ = std::fs::remove_dir_all(&dir);
dir
fn fresh_dir(tag: &str) -> vectorcraft_testkit::TempDir {
vectorcraft_testkit::TempDir::new(&format!("store-{tag}"))
}
fn saved_names(s: &mut Session) -> Vec<String> {
@@ -20,7 +18,8 @@ fn saved_names(s: &mut Session) -> Vec<String> {
#[test]
fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow() {
let dir = fresh_dir("presets").join("PDF Presets");
let root = fresh_dir("presets");
let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Press", "description": "for the printer", "preset": "Press Quality"})).unwrap();
@@ -42,7 +41,8 @@ fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow()
#[test]
fn a_rename_that_cannot_be_written_keeps_the_preset_under_its_old_name() {
let dir = fresh_dir("rename-fails").join("PDF Presets");
let root = fresh_dir("rename-fails");
let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap();
@@ -59,7 +59,8 @@ fn a_rename_that_cannot_be_written_keeps_the_preset_under_its_old_name() {
#[test]
fn a_rename_that_only_changes_case_keeps_one_file() {
let dir = fresh_dir("rename-case").join("PDF Presets");
let root = fresh_dir("rename-case");
let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap();
@@ -80,7 +81,8 @@ fn file_names(dir: &std::path::Path) -> Vec<String> {
#[cfg(windows)]
#[test]
fn a_rename_that_only_changes_case_and_cannot_be_written_keeps_the_old_file() {
let dir = fresh_dir("rename-case-fails").join("PDF Presets");
let root = fresh_dir("rename-case-fails");
let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap();
@@ -103,7 +105,8 @@ fn a_rename_that_only_changes_case_and_cannot_be_written_keeps_the_old_file() {
#[cfg(not(any(windows, target_vendor = "apple")))]
#[test]
fn a_rename_that_only_changes_case_and_cannot_be_moved_keeps_the_old_preset() {
let dir = fresh_dir("rename-case-blocked").join("PDF Presets");
let root = fresh_dir("rename-case-blocked");
let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap();
@@ -118,7 +121,8 @@ fn a_rename_that_only_changes_case_and_cannot_be_moved_keeps_the_old_preset() {
#[test]
fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() {
let dir = fresh_dir("seed").join("PDF Presets");
let root = fresh_dir("seed");
let dir = root.join("PDF Presets");
let mut a = Session::new();
a.execute("pdf.preset.save", &json!({"name": "From Preferences"})).unwrap();
a.attach_pdf_preset_store(dir.clone());
@@ -155,7 +159,8 @@ fn a_write_reads_the_file_it_replaces_only_one_byte_past_the_new_length() {
#[test]
fn a_saved_preset_replaces_an_oversized_file_in_its_place() {
let dir = fresh_dir("oversized-put").join("PDF Presets");
let root = fresh_dir("oversized-put");
let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap();
@@ -171,7 +176,8 @@ fn a_saved_preset_replaces_an_oversized_file_in_its_place() {
#[test]
fn damaged_unsafe_and_oversized_preset_files_are_skipped() {
let dir = fresh_dir("damaged").join("PDF Presets");
let root = fresh_dir("damaged");
let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Good"})).unwrap();
@@ -214,7 +220,8 @@ fn icc_named(name: &str) -> Vec<u8> {
#[test]
fn stored_profiles_register_again_and_removing_one_removes_its_copy() {
let dir = fresh_dir("profiles").join("Color Profiles");
let root = fresh_dir("profiles");
let dir = root.join("Color Profiles");
std::fs::create_dir_all(&dir).unwrap();
let name = "StoreRGB-1";
let file = format!("{}.icc", file_stem(name));
@@ -229,7 +236,8 @@ fn stored_profiles_register_again_and_removing_one_removes_its_copy() {
// A profile loaded from elsewhere is copied in, whatever the file is called.
let other = "StoreRGB-2";
let loose = vectorcraft_testkit::temp_dir("store-loose").join("whatever.icm");
let loose_dir = vectorcraft_testkit::TempDir::new("store-loose");
let loose = loose_dir.join("whatever.icm");
std::fs::write(&loose, icc_named(other)).unwrap();
let r = s.execute("color.loadProfile", &json!({"path": loose.to_str().unwrap()})).unwrap();
assert_eq!((r["name"].as_str(), r["remembered"].as_bool()), (Some(other), Some(true)));
+3 -5
View File
@@ -130,11 +130,9 @@ fn reset_defaults_restores_missing_swatches_or_replaces_them() {
assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#abcdef").unwrap()), "the default fill is unlinked too");
}
/// A fresh scratch folder for one test.
fn temp_dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("vc-swatchlib-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
/// A fresh scratch folder for one test, removed when the returned value is dropped.
fn temp_dir(name: &str) -> vectorcraft_testkit::TempDir {
vectorcraft_testkit::TempDir::new(&format!("swatchlib-{name}"))
}
#[test]
+2 -2
View File
@@ -294,7 +294,7 @@ fn files_named_after_the_title_follow_windows_naming_rules() {
#[test]
fn files_are_written_beside_the_path() {
let dir = vectorcraft_testkit::temp_dir("webexport");
let dir = vectorcraft_testkit::TempDir::new("webexport");
let mut s = two_colours();
let path = dir.join("art.gif").to_string_lossy().to_string();
let r = run(&mut s, "document.exportForWeb", json!({"path": path}));
@@ -324,7 +324,7 @@ fn an_export_says_when_an_image_could_not_be_decoded() {
let r = run(&mut s, "document.exportForWeb.preview", json!({"format": format}));
assert!(undecoded(&r), "{format} preview: {}", r["warnings"]);
}
let dir = vectorcraft_testkit::temp_dir("webexport-undecoded");
let dir = vectorcraft_testkit::TempDir::new("webexport-undecoded");
let page = dir.join("page.html").to_string_lossy().to_string();
let r = run(&mut s, "document.exportForWeb", json!({"path": page, "output": "html"}));
assert!(undecoded(&r), "{r}");
+29
View File
@@ -600,6 +600,35 @@ fn free_transform_distort_mode_via_pointer() {
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort"));
}
/// With Constrain on, a Free Distort corner keeps to the diagonal through the opposite corner, in
/// one undo step that undoes and redoes whole.
#[test]
fn free_transform_constrained_distort_keeps_the_corner_on_its_diagonal() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
let v = ViewInfo::default();
s.select_tool("freeTransform", v).unwrap();
s.set_tool_option("mode", &json!("distort"));
s.set_tool_option("constrain", &json!(true));
let before = undo_len(&s);
s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 320.0, 230.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 340.0, 260.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 260.0), v).unwrap();
let pts = |s: &Session| -> Vec<Point> { s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect() };
let has_all = |pts: &[Point], want: [(f64, f64); 4]| want.iter().all(|&(x, y)| pts.iter().any(|q| q.distance(Point::new(x, y)) < 1e-6));
// (40, 60) along the diagonal (200, 100) is 0.28 of it, (56, 28).
let distorted = [(100.0, 100.0), (300.0, 100.0), (356.0, 228.0), (100.0, 200.0)];
let original = [(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)];
assert!(has_all(&pts(&s), distorted), "{:?}", pts(&s));
assert_eq!(undo_len(&s), before + 1);
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort"));
s.execute("edit.undo", &json!({})).unwrap();
assert!(has_all(&pts(&s), original), "{:?}", pts(&s));
s.execute("edit.redo", &json!({})).unwrap();
assert!(has_all(&pts(&s), distorted), "{:?}", pts(&s));
}
#[test]
fn free_transform_cmd_held_once_a_corner_drag_started_distorts_in_one_step() {
let mut s = session();
+6 -7
View File
@@ -653,7 +653,7 @@ fn loading_the_working_rgb_profile_again_takes_effect_at_once() {
bytes
};
let name = "Reload RGB";
let dir = vectorcraft_testkit::temp_dir("reload-working-rgb");
let dir = vectorcraft_testkit::TempDir::new("reload-working-rgb");
let load = |s: &mut Session, builtin: &str| {
let path = dir.join(format!("{}.icc", builtin.len()));
std::fs::write(&path, renamed(builtin, name)).unwrap();
@@ -734,11 +734,9 @@ impl Drop for ProofReset {
}
}
/// A folder of its own for the test `tag`, empty.
fn fresh_dir(tag: &str) -> std::path::PathBuf {
let dir = vectorcraft_testkit::temp_dir(&format!("color-settings-{tag}"));
let _ = std::fs::remove_dir_all(&dir);
dir
/// A folder of its own for the test `tag`, empty, removed when dropped.
fn fresh_dir(tag: &str) -> vectorcraft_testkit::TempDir {
vectorcraft_testkit::TempDir::new(&format!("color-settings-{tag}"))
}
/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a
@@ -785,7 +783,8 @@ fn color_settings_come_back_in_the_next_session() {
let root = fresh_dir("color-settings");
let (profiles, file) = (root.join("Color Profiles"), root.join("Color Settings.json"));
let loaded = "StoreRGB-6";
let icc = vectorcraft_testkit::temp_dir("store-color-settings").join("loaded.icc");
let icc_dir = vectorcraft_testkit::TempDir::new("store-color-settings");
let icc = icc_dir.join("loaded.icc");
let _reset = ColorSettingsReset { profile: Some(loaded), paths: vec![root.clone(), icc.clone()] };
std::fs::write(&icc, icc_named(loaded)).unwrap();
+20 -14
View File
@@ -12,10 +12,10 @@ use vectorcraft_testkit::invariants::{check_all, check_session, doc_json};
use vectorcraft_testkit::strategies::{command_param_keys, junk_params_for_command, junk_values, param_keys, safe_value_for_command};
/// Commands that replace/close the active document or touch the filesystem are fine to call, but
/// their file and folder params are redirected into a temp dir (`safe_value_for_command`, which
/// `junk_params_for_command` applies, by the keys the command catalog names as files and folders).
fn safe_path() -> String {
vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string()
/// their file and folder params are redirected into the test's temp dir `dir` (`safe_value_for_command`,
/// which `junk_params_for_command` applies, by the keys the command catalog names as files and folders).
fn safe_path(dir: &std::path::Path) -> String {
dir.join("out.bin").to_string_lossy().to_string()
}
/// `r`, after failing the case if the command panicked. The engine's guard reports a panic as
@@ -219,7 +219,8 @@ fn junk_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec<Value> {
#[test]
fn every_command_with_junk_params() {
let path = safe_path();
let dir = vectorcraft_testkit::TempDir::new("sweep");
let path = safe_path(&dir);
let mut failures = vec![];
let mut calls = 0usize;
for fx in Fixture::ALL {
@@ -311,8 +312,8 @@ fn names_location(spec: Option<&vectorcraft_engine::CommandSpec>, key: &str) ->
/// command alone in at least one case ([`junk_cases`]).
#[test]
fn sweep_cases_name_files_only_inside_the_temp_folder() {
let path = safe_path();
let dir = vectorcraft_testkit::temp_dir("sweep");
let dir = vectorcraft_testkit::TempDir::new("sweep");
let path = safe_path(&dir);
let mut unsent = vec![];
let mut outside = std::collections::BTreeSet::new();
for c in command_specs() {
@@ -344,8 +345,8 @@ fn sweep_cases_name_files_only_inside_the_temp_folder() {
/// `paths: [{ids, path}]`, each with a string in the temp folder, and a folder key a folder there.
#[test]
fn sweep_cases_send_the_file_keys_of_the_command_catalog() {
let path = safe_path();
let dir = vectorcraft_testkit::temp_dir("sweep");
let dir = vectorcraft_testkit::TempDir::new("sweep");
let path = safe_path(&dir);
let spec = |id: &str| command_specs().iter().find(|c| c.id == id).unwrap();
let inside = |v: &Value| v.as_str().is_some_and(|s| std::path::Path::new(s).starts_with(&dir));
for (id, key) in [
@@ -381,7 +382,8 @@ fn sweep_cases_send_the_file_keys_of_the_command_catalog() {
/// document as it was, still applies after it.
#[test]
fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() {
let path = safe_path();
let dir = vectorcraft_testkit::TempDir::new("sweep");
let path = safe_path(&dir);
let mut failures = vec![];
for fx in [Fixture::Single, Fixture::Multi] {
for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) {
@@ -1127,7 +1129,8 @@ fn serializable_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec<Va
/// After a successful fuzzed call, the document still round-trips and exports.
#[test]
fn fuzzed_calls_keep_documents_serializable() {
let path = safe_path();
let dir = vectorcraft_testkit::TempDir::new("sweep");
let path = safe_path(&dir);
let mut failures = vec![];
for c in command_specs() {
for p in serializable_cases(c, &path) {
@@ -1196,7 +1199,8 @@ fn recovery_commands_with_a_damaged_store_and_junk_params() {
] {
store.write(name, bytes).unwrap();
}
let path = safe_path();
let dir = vectorcraft_testkit::TempDir::new("sweep");
let path = safe_path(&dir);
for fx in Fixture::ALL {
for id in ["file.recovery.save", "file.recovery.list", "file.recovery.restore", "file.recovery.discard"] {
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
@@ -1216,7 +1220,8 @@ fn recovery_commands_with_a_damaged_store_and_junk_params() {
/// interaction or a broken tree.
#[test]
fn envelope_commands_with_junk_params_on_envelopes() {
let path = safe_path();
let dir = vectorcraft_testkit::TempDir::new("sweep");
let path = safe_path(&dir);
let makes: [(&str, Value); 3] = [
("object.envelope.makeWithWarp", json!({"style": "twist", "bend": 80})),
("object.envelope.makeWithMesh", json!({"rows": 2, "cols": 3})),
@@ -1262,7 +1267,8 @@ fn envelope_commands_with_junk_params_on_envelopes() {
#[test]
fn text_commands_with_junk_params_on_inline_graphics() {
use vectorcraft_testkit::fixtures::{ellipse, exec, id_of, select};
let path = safe_path();
let dir = vectorcraft_testkit::TempDir::new("sweep");
let path = safe_path(&dir);
let setup = || {
let mut s = Fixture::Multi.session();
let e = ellipse(&mut s, 400.0, 300.0, 20.0, 20.0);
+3 -5
View File
@@ -29,10 +29,9 @@ fn finished(s: &mut Session) -> Value {
#[test]
fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() {
let root = vectorcraft_testkit::temp_dir("font-folder");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Downloads/Deep")).unwrap();
let root = plain(std::fs::canonicalize(&root).unwrap());
let dir = vectorcraft_testkit::TempDir::new("font-folder");
std::fs::create_dir_all(dir.join("Downloads/Deep")).unwrap();
let root = plain(std::fs::canonicalize(&*dir).unwrap());
let fonts = root.join("App Fonts");
vectorcraft_text::set_app_font_dir(fonts.clone());
assert!(vectorcraft_text::system_font_dirs().contains(&fonts));
@@ -85,5 +84,4 @@ fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() {
// Only files the last search found are copied.
let r = s.execute("text.addFontFiles", &json!({"files": [root.join("other.ttf")]}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("the last text.findFontFiles search")), "{r:?}");
let _ = std::fs::remove_dir_all(&root);
}
+2 -1
View File
@@ -6142,7 +6142,8 @@ fn findme_wanted() -> vectorcraft_text::WantedFonts {
/// Whether reading `bytes` as a font file a search reads panics.
fn search_reads(what: &str, bytes: &[u8]) -> Result<Vec<usize>, TestCaseError> {
let path = vectorcraft_testkit::temp_dir("font-search-fuzz").join(format!("{:?}.ttc", std::thread::current().id()));
let dir = vectorcraft_testkit::TempDir::new("font-search-fuzz");
let path = dir.join("font.ttc");
std::fs::write(&path, bytes).unwrap();
catch_quiet(|| findme_wanted().provided_by(&path)).map_err(|e| TestCaseError::fail(format!("{what}: a search reading the file panicked: {e}")))
}
+3 -2
View File
@@ -134,7 +134,8 @@ fn a_failed_batch_restores_the_file_the_history_and_a_drag_in_progress() {
(doc_json(&st.doc), st.history.undo.len(), st.history.redo.len())
};
// Save As, then a failing step: the document keeps no file and stays modified.
let path = vectorcraft_testkit::temp_dir("batch-rollback").join("named.vectorcraft").to_string_lossy().to_string();
let dir = vectorcraft_testkit::TempDir::new("batch-rollback");
let path = dir.join("named.vectorcraft").to_string_lossy().to_string();
let save = json!({"command": "file.saveAs", "params": {"path": path}});
assert!(s.execute("command.batch", &json!({"commands": [rect(0), save, fail]})).is_err());
assert_eq!(state(&s), before);
@@ -170,7 +171,7 @@ fn empty_batch_is_harmless() {
#[test]
fn save_open_via_commands_roundtrips() {
let mut s = fixtures::rich_session();
let dir = vectorcraft_testkit::temp_dir("engine-save");
let dir = vectorcraft_testkit::TempDir::new("engine-save");
let path = dir.join("rich.vectorcraft");
exec(&mut s, "document.save", json!({"path": path.to_str().unwrap()}));
let want = doc_json(&s.doc().unwrap().doc);
+1 -4
View File
@@ -37,9 +37,7 @@ fn laid_out_in(s: &Session, id: u64) -> Vec<String> {
#[test]
fn type_in_a_removed_font_falls_back_after_a_rescan() {
let root = vectorcraft_testkit::temp_dir("removed-font");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let root = vectorcraft_testkit::TempDir::new("removed-font");
vectorcraft_text::set_user_font_dirs(vec![root.clone()]);
let file = root.join("Gonefont.otf");
std::fs::write(&file, vectorcraft_testkit::fonts::renamed(FAMILY)).unwrap();
@@ -72,5 +70,4 @@ fn type_in_a_removed_font_falls_back_after_a_rescan() {
assert_eq!(status(&mut s), ("exact".into(), FAMILY.into()));
assert!(listed(&mut s));
assert_eq!(laid_out_in(&s, id), [FAMILY]);
let _ = std::fs::remove_dir_all(&root);
}
+28
View File
@@ -2634,6 +2634,34 @@ fn gradient_options_and_links_the_data_cant_hold_are_reported() {
let back = read_back(&doc);
let Paint::Gradient(g) = back.layers[0].children().unwrap()[0].appearance.items[0].paint() else { panic!("a gradient") };
assert_eq!((color(&g.gradient.stops[0].color), g.gradient.stops[0].swatch.as_deref()), (color(&BRAND), None));
// A gradient mixed around the hue wheel, either way, is written as a classic one of the stops
// on its curve, no more than the reader reads, and opens with the colors it had.
for method in [vectorcraft_color::gradient::GradientMethod::HueClockwise, vectorcraft_color::gradient::GradientMethod::HueCounterclockwise] {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut g = sunset();
g.method = method;
let want = g.clone();
let n = painted(
&mut doc,
shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)),
vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(g, None, None)))],
);
doc.insert(Some(layer), usize::MAX, n).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert_eq!(out.warnings, [writer::GRADIENT_HUE], "{method:?}");
let back = read_back(&doc);
let Paint::Gradient(g) = back.layers[0].children().unwrap()[0].appearance.items[0].paint() else { panic!("a gradient") };
assert!(g.gradient.method.is_classic(), "{method:?}");
let written = g.gradient.stops.len();
assert!((want.stops.len() + 1..=crate::import::MAX_GRADIENT_STOPS).contains(&written), "{method:?}: {written} stops");
for k in 0..=100 {
let t = k as f32 / 100.0;
let ((a, ao), (b, bo)) = (want.sample(t), g.gradient.sample(t));
let (a, b) = (a.to_rgb(), b.to_rgb());
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.03) && (ao - bo).abs() < 0.03, "{method:?} at {t}: {a:?} {ao} vs {b:?} {bo}");
}
}
}
/// A pattern whose art reaches past its tile is written as its tiles cut to the shape: a definition
+1 -1
View File
@@ -36,7 +36,7 @@ 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};
pub(super) use paints::{GRADIENT_HUE, GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name};
#[cfg(test)]
pub(super) use symbols::{MISSING_SYMBOL, SYMBOL_ART, SYMBOL_COUNT, SYMBOL_OPTIONS};
#[cfg(test)]
+29 -5
View File
@@ -21,6 +21,7 @@ pub(in crate::ai_editing) const SWATCH_PLACEMENT: &str =
"the placement of pattern swatches that move, turn or scale their pattern is left out of the editing data for other apps";
pub(in crate::ai_editing) const GRADIENT_MIX: &str =
"gradients that mix their colors perceptually or dither are written as classic gradients in the editing data for other apps";
pub(in crate::ai_editing) const GRADIENT_HUE: &str = "gradients mixed around the hue wheel are written in process colors, with the colors between their stops as stops of their own (at most 256), in the editing data for other apps";
pub(in crate::ai_editing) const CONIC: &str =
"conic gradients are written as linear gradients along their vector, with the same stops, in the editing data for other apps";
pub(in crate::ai_editing) const STOP_LINKS: &str =
@@ -56,6 +57,19 @@ fn writable_gradient(g: &Gradient) -> bool {
g.kind != GradientKind::Freeform && !g.stops.is_empty() && g.stops.len() <= crate::import::MAX_GRADIENT_STOPS
}
/// `g` as the data holds its colors: one mixed around the hue wheel (the data's gradients mix
/// their stops' colors directly) as a classic gradient of the stops on its curve
/// ([`Gradient::as_classic_curve`]), no more than the `.ai` reader reads when it pads
/// ([`GRADIENT_HUE`]); others as they are.
fn hue_as_stops(g: &Gradient) -> std::borrow::Cow<'_, Gradient> {
use std::borrow::Cow;
if !g.method.is_hue() {
return Cow::Borrowed(g);
}
let curve = g.as_classic_curve();
Cow::Owned(if curve.is_spread() { curve } else { curve.fitted(crate::import::MAX_GRADIENT_STOPS).into_owned() })
}
/// Is a gradient of `kind` written as a radial one: a radial gradient, or a diamond (the ellipse
/// through its square's corners, [`DIAMOND`])?
fn written_radial(kind: GradientKind) -> bool {
@@ -86,9 +100,11 @@ impl Writer<'_> {
pub(in crate::ai_editing) fn reserve_swatch_gradients(&mut self) {
let doc = self.doc;
for sw in doc.swatches_iter() {
// As `swatch_paint` writes it: the stops of one that repeats or reflects repeated.
// As `swatch_paint` writes it: one mixed around the hue wheel as the stops on its curve,
// and the stops of one that repeats or reflects repeated.
if let Paint::Gradient(g) = &sw.paint
&& let gradient = g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS)
&& let stops = hue_as_stops(&g.gradient)
&& let gradient = stops.unrolled_within(crate::import::MAX_GRADIENT_STOPS)
&& writable_gradient(&gradient)
&& !self.gradients.iter().any(|d| d.name == sw.name)
{
@@ -149,6 +165,10 @@ impl Writer<'_> {
// The data has no spread: a gradient that repeats or reflects is written with its stops
// repeated over `area` (a conic one, written linear, around its turn).
let mut g: GradientPaint = (**g).clone();
if g.gradient.method.is_hue() {
self.warn(GRADIENT_HUE);
g.gradient = hue_as_stops(&g.gradient).into_owned();
}
if g.gradient.is_spread() {
self.warn(SPREAD);
match g.gradient.padded_within(&geom, area, crate::import::MAX_GRADIENT_STOPS) {
@@ -557,13 +577,17 @@ impl Writer<'_> {
/// be written.
pub(super) fn swatch_paint(&mut self, s: &mut String, sw: &vectorcraft_color::swatch::Swatch) -> bool {
// A swatch has no placement: one that repeats or reflects holds its stops repeated along
// its vector.
let unrolled = match &sw.paint {
Paint::Gradient(g) => Some(g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS)),
// its vector, and one mixed around the hue wheel the stops on its curve.
let stops = match &sw.paint {
Paint::Gradient(g) => Some(hue_as_stops(&g.gradient)),
_ => None,
};
let unrolled = stops.as_deref().map(|g| g.unrolled_within(crate::import::MAX_GRADIENT_STOPS));
match &sw.paint {
Paint::Gradient(g) if unrolled.as_deref().is_some_and(writable_gradient) => {
if g.gradient.method.is_hue() {
self.warn(GRADIENT_HUE);
}
if g.gradient.is_spread() {
self.warn(SPREAD);
}
+1
View File
@@ -39,6 +39,7 @@ pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN;
pub(crate) use envelope::MAX_CHECKED;
pub(crate) use envelope::TYPE_DRAWN as ENVELOPE_TYPE_DRAWN;
pub(crate) use envelope::{GridPoint as EnvelopeGridPoint, PLUGIN as DEFORM_PLUGIN, outline as envelope_outline, upright as envelope_upright};
pub(crate) use envelope::{MAX_ANY as ENVELOPE_MAX_ANY, MAX_SOLID as ENVELOPE_MAX_SOLID};
pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType};
pub(crate) use frames::text_frames;
pub use obj::{NameEncoding, Names};
+48 -10
View File
@@ -73,9 +73,9 @@ const MOVED_SLACK: f64 = 0.5;
const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17;
/// More of the inked pixels than this differing, each with all its neighbours, and the envelope
/// doesn't come out as drawn.
const MAX_SOLID: f64 = 0.03;
pub(crate) const MAX_SOLID: f64 = 0.03;
/// More of the inked pixels than this differing at all, and the envelope doesn't come out as drawn.
const MAX_ANY: f64 = 0.1;
pub(crate) const MAX_ANY: f64 = 0.1;
/// The whole number of entry `key`: `None` when it is missing, `Some(None)` when it isn't one.
fn whole(o: &Obj, key: &str) -> Option<Option<i64>> {
@@ -648,6 +648,9 @@ pub(crate) enum TypeKept {
Budget,
/// Its live envelope doesn't draw as its drawn art.
Differs,
/// A page that the layers, which don't draw as it, were compared with holds it against them
/// ([`WarpedType::open_alone`]).
Page,
}
/// What [`WarpedType::settle_envelopes`] did.
@@ -657,9 +660,10 @@ pub(crate) struct TypedSettled {
pub(crate) live: usize,
/// Why each of the others kept its drawn art.
pub(crate) kept: Vec<TypeKept>,
/// Each live envelope's id, with what takes its place again when the layers with it don't draw
/// as the page: its hidden type, then its drawn art.
pub(crate) put_back: Vec<(NodeId, Vec<Node>)>,
/// Each live envelope's place among the envelopes of type (in the order they were read) and
/// id, with what takes its place again when the layers with it don't draw as the page: its
/// hidden type, then its drawn art.
pub(crate) put_back: Vec<(usize, NodeId, Vec<Node>)>,
}
/// The text objects read with a Warp and the envelopes of type, and what the import has left of the
@@ -672,6 +676,9 @@ pub struct WarpedType {
pub(super) checks: usize,
pub(super) spent: Spent,
pub(super) page: Option<Rect>,
/// The envelopes of type that may open live in layers built without a page, by their place
/// among them ([`Self::open_alone`]): none when `None`.
pub(super) alone: Option<BTreeSet<usize>>,
}
impl WarpedType {
@@ -682,14 +689,33 @@ impl WarpedType {
/// These, for layers `compared` with a page. Layers that aren't leave the envelopes of type
/// their drawn art and their hidden type, with the note ([`TYPE_DRAWN`]) added to `notes`: a
/// live envelope opens only where the page chooses it.
/// live envelope opens only where the page chooses it. Unless [`Self::open_alone`] names some:
/// then they all wait, as with a page, and only those named may stay live.
pub(crate) fn compared(mut self, compared: bool, notes: &mut Vec<String>) -> Self {
if !compared && !std::mem::take(&mut self.envelopes).is_empty() && !notes.iter().any(|n| n == TYPE_DRAWN) {
if !compared && self.alone.is_none() && !std::mem::take(&mut self.envelopes).is_empty() && !notes.iter().any(|n| n == TYPE_DRAWN) {
notes.push(TYPE_DRAWN.into());
}
self
}
/// Let the envelopes of type at `places` (among them, in the order they were read) open live in
/// layers built without a page, where they draw as their drawn art: a page these same layers,
/// read the same way, were compared with weighed them and didn't hold them against the layers'
/// difference from it. All the envelopes of type are settled as they were then, in the same
/// order and with the same checks, so each that opens live draws as it was weighed; then the
/// others get their drawn art back ([`Self::settle_envelopes`]). `count` is how many envelopes
/// of type that reading had: a reading with another number of them names none.
pub(crate) fn open_alone(&mut self, places: &[usize], count: usize) {
if self.envelopes.len() == count {
self.alone = Some(places.iter().copied().filter(|i| *i < count).collect());
}
}
/// How many envelopes of type wait for their type.
pub(crate) fn envelope_count(&self) -> usize {
self.envelopes.len()
}
/// Make each envelope of type live where it draws as its drawn art, its contents' text slots
/// holding their type: `type_of` gives, for a slot's id, the hidden text object it became in
/// `doc` and the type the contents take in its place (the slot's visibility and lock are the
@@ -713,16 +739,28 @@ impl WarpedType {
doc.layers.iter().for_each(|l| collect_shown(l, &mut shown));
let envelopes = std::mem::take(&mut self.envelopes);
let mut waiting = envelopes.len();
for envelope in envelopes {
for (place, envelope) in envelopes.into_iter().enumerate() {
waiting -= 1;
match self.typed_live(doc, envelope, &type_of, &shown, waiting) {
Ok(put_back) => {
Ok((id, put_back)) => {
settled.live += 1;
settled.put_back.push(put_back);
settled.put_back.push((place, id, put_back));
}
Err(why) => settled.kept.push(why),
}
}
// Built without a page, the envelopes [`Self::open_alone`] doesn't name get their drawn art
// and hidden type back. One inside another that got its own back went with that art.
if let Some(free) = &self.alone {
let (stay, back): (Vec<_>, Vec<_>) = std::mem::take(&mut settled.put_back).into_iter().partition(|(place, ..)| free.contains(place));
for (_, id, nodes) in back {
if doc.insert_beside(vec![(id, nodes)], true).is_ok() || doc.node(id).is_none() {
settled.live = settled.live.saturating_sub(1);
settled.kept.push(TypeKept::Page);
}
}
settled.put_back = stay;
}
settled
}
@@ -1367,6 +1367,7 @@ impl<'a> Reader<'a> {
checks: self.envelope_checks,
spent: self.envelope_spent,
page,
alone: None,
},
styles_read,
text_patterns,
+1 -1
View File
@@ -8280,7 +8280,7 @@ fn warped_type_on_the_page_spends_what_is_left_of_the_budget() {
let copy = |w: &super::envelope::WarpedLook| super::envelope::WarpedLook { slot: w.slot, look: w.look.clone(), left_out: None };
let page = Some(Rect::new(0.0, 60.0, 60.0, 100.0));
let settle = |w: &super::envelope::WarpedLook, spent| {
let warped = super::envelope::WarpedType { looks: vec![copy(w)], envelopes: vec![], checks: 1, spent, page };
let warped = super::envelope::WarpedType { looks: vec![copy(w)], envelopes: vec![], checks: 1, spent, page, alone: None };
warped.settle(&mut doc.clone(), Some, &Default::default(), &mut vec![])
};
let spent = || super::envelope::Spent { any: MAX_DIFFERENCE / 2.0, solid: MAX_SOLID_DIFFERENCE / 2.0 };
+597 -35
View File
@@ -1,5 +1,6 @@
use super::compare::{
MAX_DIFFERENCE, MAX_PAGES_COMPARED, MAX_PIXELS_COMPARED, MAX_SOLID_DIFFERENCE, PageView, compare, comparison_samples, difference, pixels_compared,
MAX_DIFFERENCE, MAX_PAGES_COMPARED, MAX_PIXELS_COMPARED, MAX_SOLID_DIFFERENCE, PAST_THE_PAGE, PageView, compare, comparison_samples, difference,
pixels_compared,
};
use super::plan::{made_only, make_slots, plan, readable_all};
use super::slots::{
@@ -230,6 +231,24 @@ pub(super) fn build_from(
outlined: bool,
trust: bool,
unit: f64,
) -> Result<(Document, Vec<String>), String> {
build_weighing(structure, data, visible, page, outlined, trust, unit, &mut None)
}
/// [`build_from`]. When the layers with their live envelopes don't draw as the page, and don't with
/// the drawn art of every live envelope put back either, `free` is set to the envelopes of type
/// (by their place among them, as read) that opened live and that the page doesn't hold against
/// them ([`held_back`]), whether or not the layers are then used, and however.
#[allow(clippy::too_many_arguments)]
pub(super) fn build_weighing(
structure: Structure,
data: &[u8],
visible: Option<&Document>,
page: Page,
outlined: bool,
trust: bool,
unit: f64,
free: &mut Option<Vec<usize>>,
) -> Result<(Document, Vec<String>), String> {
let Structure {
mut doc,
@@ -468,12 +487,14 @@ pub(super) fn build_from(
let warped_type = warped_type.compared(frame.is_some(), &mut notes);
let bound = [&bound_text, &bound_visible];
let typed = settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, bound, &mut restyled, &mut notes)?;
// Each live envelope of type's place among the envelopes of type, by its id.
let places: Vec<(NodeId, usize)> = typed.iter().map(|(place, id, _)| (*id, *place)).collect();
// The live envelopes and what takes the place of each again, when the layers with them don't
// draw as the page, with the note that says so.
let envelopes_drawn: Vec<(NodeId, Vec<Node>, &str)> = envelopes_drawn
.into_iter()
.map(|(id, art)| (id, vec![art], ai::ENVELOPE_DRAWN))
.chain(typed.into_iter().map(|(id, nodes)| (id, nodes, ai::ENVELOPE_TYPE_DRAWN)))
.chain(typed.into_iter().map(|(_, id, nodes)| (id, nodes, ai::ENVELOPE_TYPE_DRAWN)))
.collect();
if empty > 0 {
notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT }));
@@ -525,20 +546,21 @@ pub(super) fn build_from(
notes.extend(unseen_note(unseen).into_iter().chain(note));
}
other => match drawn_or_trusted(&frame, &mut view, &mut doc, &envelopes_drawn, &notes, unseen, trust, why, reflow) {
Ok(more) => notes.extend(more),
Ok((more, held)) => {
*free = held.map(|held| free_places(&places, &held));
notes.extend(more);
}
// The objects that draw differently from the page come in as its art, from
// the placement that draws closer to it. The layers don't draw as the page:
// envelopes of type keep their drawn art and their type, hidden.
// the envelopes of type it holds against them keep their drawn art and their
// type, hidden. Where no placement's differing objects can then come in as the
// page's art (an object that holds type isn't replaced), every envelope of type
// keeps its drawn art, and that is tried again.
Err(why) => {
let typed = type_drawn(&mut doc, &envelopes_drawn);
let other = other.map(|(_, mut other, unseen)| {
type_drawn(&mut other, &envelopes_drawn);
(other, unseen)
});
let placements = closer_first(&frame, &mut view, (doc, unseen), other, reflow);
let Some((kept, unseen, kept_notes)) = placements.into_iter().find_map(|(base, unseen)| {
keep_differing(&base, &frame, &mut view, &threaded_frames, &shown, reflow).map(|(kept, n)| (kept, unseen, n))
}) else {
let other = other.map(|(_, other, unseen)| (other, unseen));
let layers = Placements { doc: &doc, unseen, other: other.as_ref(), drawn: &envelopes_drawn, places: &places };
let found = keep_differing_typed(&frame, &mut view, &layers, &threaded_frames, &shown, reflow, free);
let Some((kept, unseen, kept_notes, typed)) = found else {
return Err(why);
};
doc = kept;
@@ -693,8 +715,9 @@ fn bound_slots(
/// anchors. Before that, the envelopes of type open live where they draw as their drawn art, with
/// the type of their text slots (made type, one text object for the slot, not threaded, bound to
/// no text variable and no visibility variable, `bound`'s second set, and no wrap object); the
/// others keep their drawn art, with a note. Returns each live envelope of type's id with what
/// takes its place again (its hidden type, then its drawn art).
/// others keep their drawn art, with a note. Returns each live envelope of type's place among the
/// envelopes of type (in the order they were read) and id, with what takes its place again (its
/// hidden type, then its drawn art).
fn settle_warped_type(
mut warped: ai::WarpedType,
doc: &mut Document,
@@ -703,7 +726,7 @@ fn settle_warped_type(
bound: [&BTreeSet<NodeId>; 2],
restyled: &mut Vec<Rect>,
notes: &mut Vec<String>,
) -> Result<Vec<(NodeId, Vec<Node>)>, String> {
) -> Result<Vec<(usize, NodeId, Vec<Node>)>, String> {
let [bound_text, bound_visible] = bound;
let made = |slot| match filled.get(&slot).map(|f| f.text.as_slice()).filter(|_| !bound_text.contains(&slot)) {
Some(&[one]) => Some(one),
@@ -1152,15 +1175,15 @@ const MAX_DIFFERING: usize = 64;
const MAX_PATCH_PAIRS: usize = 4_000_000;
/// The layers as read and at the placed files' other placement (each with its count of text
/// objects the page doesn't draw), the one that draws closer to the page first. `reflow` as in
/// [`PageView::difference`].
fn closer_first(
/// objects the page doesn't draw, and what goes with it), the one that draws closer to the page
/// first. `reflow` as in [`PageView::difference`].
fn closer_first<T>(
frame: &Frame<'_>,
view: &mut PageView,
doc: (Document, usize),
other: Option<(Document, usize)>,
doc: (Document, usize, T),
other: Option<(Document, usize, T)>,
reflow: bool,
) -> Vec<(Document, usize)> {
) -> Vec<(Document, usize, T)> {
match other {
Some(other)
if view.difference_on_every_page(&other.0, frame.rect, reflow).any < view.difference_on_every_page(&doc.0, frame.rect, reflow).any =>
@@ -1278,6 +1301,73 @@ fn keep_differing(
Some((kept, note.into_iter().chain([format!("{DREW_DIFFERENTLY}: {}", layers.join(", "))]).collect()))
}
/// The places (among the envelopes of type, as read) of the live envelopes of type in `places`
/// (each with its id) that `held` doesn't list.
fn free_places(places: &[(NodeId, usize)], held: &BTreeSet<NodeId>) -> Vec<usize> {
places.iter().filter(|(id, _)| !held.contains(id)).map(|(_, place)| *place).collect()
}
/// The layers of [`keep_differing_typed`]: `doc`, `unseen` text objects of it taken out; the placed
/// files' other placement of it, when it has one, with how many of its text objects were; its live
/// envelopes, as [`drawn_or_trusted`] takes them; and the place of each live envelope of type
/// among the envelopes of type, by its id.
struct Placements<'a> {
doc: &'a Document,
unseen: usize,
other: Option<&'a (Document, usize)>,
drawn: &'a [(NodeId, Vec<Node>, &'a str)],
places: &'a [(NodeId, usize)],
}
/// [`keep_differing`] of the placement of `layers` that draws closer to the page, the envelopes of
/// type the page holds against that placement ([`held_back`], each placement weighed on its own: the
/// other one draws placed files elsewhere, under or over the envelopes) having their drawn art and
/// hidden type back; and where none of the placements' differing objects can then come in as the
/// page's art, every envelope of type: the document, how many of its text objects were taken out,
/// its notes, and whether an envelope of type got its drawn art back. A placement that draws as the
/// page with only the envelopes the page holds put back is used as it is, as one with all the live
/// envelopes' drawn art put back would have been ([`drawn_or_trusted`]): nothing in it differs to
/// come in as the page's art. `free` is set to the places of those the page doesn't hold against
/// the layers as read.
#[allow(clippy::too_many_arguments)]
fn keep_differing_typed(
frame: &Frame<'_>,
view: &mut PageView,
layers: &Placements<'_>,
threaded_frames: &BTreeSet<NodeId>,
shown: &[Vec<Arc<Node>>],
reflow: bool,
free: &mut Option<Vec<usize>>,
) -> Option<(Document, usize, Vec<String>, bool)> {
let held = held_back(frame, view, layers.doc, layers.drawn, reflow);
*free = Some(free_places(layers.places, &held));
let held_other = layers.other.map(|(other, _)| held_back(frame, view, other, layers.drawn, reflow));
let all: BTreeSet<NodeId> = layers.places.iter().map(|(id, _)| *id).collect();
let fewer = held.len() < all.len() || held_other.as_ref().is_some_and(|h| h.len() < all.len());
let mut tries = vec![];
if fewer {
tries.push((held, held_other));
}
tries.push((all.clone(), Some(all.clone())));
tries.into_iter().find_map(|(held, held_other)| {
// Each placement with what it put back: whether an envelope of type, and whether only some.
let mut base = layers.doc.clone();
let typed = (type_drawn(&mut base, layers.drawn, &held), held.len() < all.len());
let other = layers.other.map(|(other, unseen)| {
let (mut other, held) = (other.clone(), held_other.as_ref().unwrap_or(&held));
let typed = (type_drawn(&mut other, layers.drawn, held), held.len() < all.len());
(other, *unseen, typed)
});
let placements = closer_first(frame, view, (base, layers.unseen, typed), other, reflow);
placements.into_iter().find_map(|(base, unseen, (typed, some))| {
if some && let Ok(notes) = compare(frame, view, &base, reflow) {
return Some((base, unseen, notes, typed));
}
keep_differing(&base, frame, view, threaded_frames, shown, reflow).map(|(kept, notes)| (kept, unseen, notes, typed))
})
})
}
/// Copy page drawing IDs, including mask art outside the ordinary child tree.
pub(super) fn reid_page_art(doc: &mut Document, source: &Node) -> Node {
let mut copy = doc.reid(source);
@@ -1338,9 +1428,9 @@ fn slot_content(
/// The layers `doc` don't draw as the page (`why`), `unseen` text objects of them taken out: the
/// notes to add when `doc` with each live envelope `drawn` lists replaced by the drawn art it took
/// the place of (see [`ai::Structure::envelopes_drawn`]) draws as the page, `doc` then being that;
/// else, with `trust`, the notes for `doc` as it is but for its envelopes of type, which keep their
/// drawn art ([`type_drawn`]). An error, `why`, otherwise. The envelope note is added unless
/// `notes` has it.
/// else, with `trust`, the notes for `doc` as it is but for the envelopes of type the page holds
/// against it ([`held_back`]), which keep their drawn art ([`type_drawn`]), with those envelopes.
/// An error, `why`, otherwise. The envelope note is added unless `notes` has it.
#[allow(clippy::too_many_arguments)]
fn drawn_or_trusted(
frame: &Frame<'_>,
@@ -1352,7 +1442,7 @@ fn drawn_or_trusted(
trust: bool,
why: String,
reflow: bool,
) -> Result<Vec<String>, String> {
) -> Result<(Vec<String>, Option<BTreeSet<NodeId>>), String> {
let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}"));
let mut put_back = doc.clone();
// Each envelope still there, in place of the art it took the place of.
@@ -1379,26 +1469,143 @@ fn drawn_or_trusted(
envelope.push(say.to_string());
}
}
return Ok(envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect());
return Ok((envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect(), None));
}
}
if trust {
let typed = type_drawn(doc, drawn).then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n));
let held = held_back(frame, view, doc, drawn, reflow);
let typed = type_drawn(doc, drawn, &held).then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n));
let trusted = format!("{why}: they come from its editing data all the same");
return Ok(typed.into_iter().chain(unseen_note(unseen)).chain([trusted]).collect());
return Ok((typed.into_iter().chain(unseen_note(unseen)).chain([trusted]).collect(), Some(held)));
}
Err(why)
}
/// The live envelopes of type in `drawn` (as [`drawn_or_trusted`] takes them) that the page holds
/// against the layers `doc`, which don't draw as it even with every live envelope's drawn art put
/// back: by their ids, those where the layers don't draw as the page. The envelope and its drawn art
/// are drawn together on a layer of their own, without the rest of the layers, on the page check's
/// drawing of each page they lie on: in the pixels they ink there, the layers with the envelope may
/// differ from the page in no more than the envelope's own check allows against its drawn art (at
/// all, and with all their neighbors, away from type: [`compare`]); and in the order they were
/// read, the envelopes that stay live may differ there in no more of each page together than half
/// of what the page check allows (a budget of the same size as the one their own checks share, kept
/// apart from it). Art that differs elsewhere on the page doesn't hold them. One the layers don't
/// draw where it is and as itself ([`drawn_in_place`]) is held, wherever it lies: drawn on its own,
/// it would be weighed elsewhere than it draws, or on pixels a clip or mask hides, and what holds it
/// can draw it onto a page from off it. Another envelope on no page stays live, as the page check
/// doesn't see it, unless the page holds all the art that prints ([`Frame::whole`]), where art past
/// it is compared too: there one reaching past the page is held. Past [`MAX_WEIGHED`] of them on
/// the pages, or where the page check can't weigh one, they are all held.
fn held_back(frame: &Frame<'_>, view: &mut PageView, doc: &Document, drawn: &[(NodeId, Vec<Node>, &str)], reflow: bool) -> BTreeSet<NodeId> {
let typed: Vec<(NodeId, &Node, &Node)> = drawn
.iter()
.filter(|(_, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN)
.filter_map(|(id, art, _)| Some((*id, doc.node(*id)?, art.last()?)))
.collect();
let all: BTreeSet<NodeId> = typed.iter().map(|(id, ..)| *id).collect();
if all.is_empty() {
return all;
}
let pages = view.regions(frame.rect);
let past = |b: Rect| frame.whole && !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(b);
let mut held = BTreeSet::new();
let mut in_place = std::collections::HashMap::new();
drawn_in_place(&doc.layers, &all, true, &mut in_place);
// Those the page check sees, by the boxes of the envelope and its drawn art.
let mut seen = vec![];
for (id, live, art) in typed {
if !in_place.get(&id).copied().unwrap_or(false) {
held.insert(id);
continue;
}
match vectorcraft_geom::union_opt(live.visual_bounds(), art.visual_bounds()) {
Some(b) if past(b) => {
held.insert(id);
}
Some(b) if pages_touched(b, &pages, 1.0).is_empty() => {}
_ => seen.push((id, live, art)),
}
}
if seen.len() > MAX_WEIGHED {
return all;
}
// The pixels of each page where the envelopes that stay live differ from it: at all, and with
// all their neighbors.
let mut spent = vec![(0usize, 0usize); pages.len()];
for (id, live, art) in seen {
// The envelope and its drawn art, shown, on a layer of their own in `doc`.
let mut nodes: Vec<Arc<Node>> = [live, art].into_iter().cloned().map(Arc::new).collect();
for n in &mut nodes {
let n = Arc::make_mut(n);
(n.visible, n.opacity) = (true, 1.0);
}
let mut one = doc.clone();
let mut layer = Node::layer(NodeId(0), "", vectorcraft_doc::LayerColor::Preset(0));
if let Some(children) = layer.children_mut() {
*children = nodes;
}
one.layers = vec![Arc::new(layer)];
let bounds = vectorcraft_render::encode::art_bounds(&one);
if bounds.is_some_and(past) {
held.insert(id);
continue;
}
let on = bounds.map_or(vec![], |b| pages_touched(b, &pages, 1.0));
let mut added = spent.clone();
let mut fits = true;
for (i, ink) in view.inked(&one, &on) {
let Some((any, solid)) = view.differing_in(doc, i, &ink, reflow) else { return all };
let inked = ink.iter().filter(|p| **p).count() as f64;
let pixels = ink.len().max(1) as f64;
let Some((a, s)) = added.get_mut(i) else { return all };
*a += any;
*s += solid;
fits &= any as f64 <= inked * ai::ENVELOPE_MAX_ANY
&& solid as f64 <= inked * ai::ENVELOPE_MAX_SOLID
&& *a as f64 <= pixels * MAX_DIFFERENCE / 2.0
&& *s as f64 <= pixels * MAX_SOLID_DIFFERENCE / 2.0;
}
if fits {
spent = added;
} else {
held.insert(id);
}
}
held
}
/// Which of the objects `ids` among `nodes` (or what they hold) are drawn where they are and as
/// themselves (`plain`: so far, above `nodes`), into `out` by id: under layers and groups that
/// neither clip them, mask them nor have an appearance of their own (whose effects can move or copy
/// them), and in no envelope, repeat, blend or other object that draws what it holds elsewhere or
/// more than once.
fn drawn_in_place(nodes: &[Arc<Node>], ids: &BTreeSet<NodeId>, plain: bool, out: &mut std::collections::HashMap<NodeId, bool>) {
for n in nodes {
if ids.contains(&n.id) {
out.insert(n.id, plain);
}
let Some(children) = n.children() else { continue };
let keeps = match n.kind {
NodeKind::Layer { clip, .. } | NodeKind::Group { clip, .. } => {
!clip && n.appearance == vectorcraft_doc::Appearance::default() && n.extras().mask.is_none()
}
_ => false,
};
drawn_in_place(children, ids, plain && keeps, out);
}
}
/// Put each live envelope of type in `drawn` (as [`drawn_or_trusted`] takes them) that `doc` still
/// has back as its hidden type and its drawn art: whether one was. An envelope of type opens live
/// only where the layers with it draw as the page, so where they don't, though they are used all
/// the same (the editing data trusted, or the objects that differ come in as the page's art), it
/// keeps its drawn art.
fn type_drawn(doc: &mut Document, drawn: &[(NodeId, Vec<Node>, &str)]) -> bool {
/// has and `held` lists back as its hidden type and its drawn art: whether one was. An envelope of
/// type opens live only where the page doesn't hold it against the layers ([`held_back`]), so where
/// they don't draw as the page though they are used all the same (the editing data trusted, or the
/// objects that differ come in as the page's art), one the page holds against them keeps its drawn
/// art.
fn type_drawn(doc: &mut Document, drawn: &[(NodeId, Vec<Node>, &str)], held: &BTreeSet<NodeId>) -> bool {
let typed: Vec<(NodeId, Vec<Node>)> = drawn
.iter()
.filter(|(id, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN && doc.node(*id).is_some())
.filter(|(id, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN && held.contains(id) && doc.node(*id).is_some())
.map(|(id, art, _)| (*id, art.clone()))
.collect();
if typed.is_empty() || doc.insert_beside(typed, true).is_err() {
@@ -2562,4 +2769,359 @@ mod candidate_tests {
replace_unread(&mut doc, &frame, &unread, &[], &BTreeSet::new(), &mut copied).unwrap();
assert!(copied.contains(&nodes[0].id) && !copied.contains(&nodes[1].id));
}
/// A rectangle on `rect` filled with `color`, with an id of `doc`'s.
fn filled_rect(doc: &mut Document, rect: Rect, color: vectorcraft_color::Color) -> Node {
let paint = vectorcraft_color::Paint::solid(color);
Node::path(
doc.alloc_id(),
vectorcraft_geom::shapes::rectangle(rect),
vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0),
)
}
/// A live envelope as [`drawn_or_trusted`] takes it: its id, what takes its place again, its note.
type Envelope = (NodeId, Vec<Node>, &'static str);
/// The layers `doc` with a black rectangle on each of `lives` standing in for a live envelope of
/// type, its drawn art a black rectangle on the matching one of `arts`: the layers, and the
/// envelopes as [`drawn_or_trusted`] takes them.
fn stand_ins(lives: &[Rect], arts: &[Rect]) -> (Document, Vec<Envelope>) {
let mut doc = Document::new(100.0, 100.0);
let layer = doc.layers[0].id;
let mut drawn = vec![];
for (live, art) in lives.iter().zip(arts) {
let n = filled_rect(&mut doc, *live, vectorcraft_color::Color::BLACK);
let id = n.id;
doc.insert(Some(layer), 0, n).unwrap();
let art = filled_rect(&mut doc, *art, vectorcraft_color::Color::BLACK);
drawn.push((id, vec![art], ai::ENVELOPE_TYPE_DRAWN));
}
(doc, drawn)
}
/// The page holds an envelope of type against layers that don't draw as it only where the
/// layers with the envelope differ from the page where it or its drawn art inks: not where the
/// page draws other art that the layers don't have.
#[test]
fn the_page_holds_back_only_envelopes_of_type_where_it_differs_from_them() {
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let mut page = Document::new(100.0, 100.0);
let layer = page.layers[0].id;
// The first two envelopes' drawn art, and a square the layers don't have.
for r in [Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(60.0, 10.0, 80.0, 30.0), Rect::new(10.0, 60.0, 30.0, 80.0)] {
let n = filled_rect(&mut page, r, vectorcraft_color::Color::BLACK);
page.insert(Some(layer), 0, n).unwrap();
}
// Live where its drawn art is; moved off its drawn art; and where the page draws nothing.
let (doc, drawn) = stand_ins(
&[Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(66.0, 10.0, 86.0, 30.0), Rect::new(60.0, 60.0, 80.0, 80.0)],
&[Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(60.0, 10.0, 80.0, 30.0), Rect::new(60.0, 60.0, 80.0, 80.0)],
);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] };
let mut view = PageView::new(&frame);
assert!(compare(&frame, &mut view, &doc, false).is_err());
assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), BTreeSet::from([drawn[1].0, drawn[2].0]));
// Only envelopes of type are weighed.
let other: Vec<_> = drawn.iter().map(|(id, art, _)| (*id, art.clone(), ai::ENVELOPE_DRAWN)).collect();
assert!(held_back(&frame, &mut view, &doc, &other, false).is_empty());
}
/// The envelopes of type that stay live may together differ from the page where they ink in no
/// more than half of what the page check allows: the next one past that is held back, though
/// on its own it would stay.
#[test]
fn envelopes_of_type_stay_live_within_half_of_what_the_page_check_allows() {
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let halves = [Rect::new(0.0, 0.0, 100.0, 25.0), Rect::new(0.0, 25.0, 100.0, 50.0)];
let mut page = Document::new(100.0, 100.0);
let layer = page.layers[0].id;
for r in halves {
let n = filled_rect(&mut page, r, vectorcraft_color::Color::BLACK);
page.insert(Some(layer), 0, n).unwrap();
}
// White lines two pixels high over each, 6 points apart: 8% of what each inks differs, 2% of
// the page, and none of it with all its neighbors.
for (i, y) in [3.0, 9.0, 15.0, 21.0, 28.0, 34.0, 40.0, 46.0].into_iter().enumerate() {
let n = filled_rect(&mut page, Rect::new(0.0, y, 100.0, y + 0.5), vectorcraft_color::Color::WHITE);
page.insert(Some(layer), halves.len() + i, n).unwrap();
}
let (doc, drawn) = stand_ins(&halves, &halves);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] };
let mut view = PageView::new(&frame);
assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), BTreeSet::from([drawn[1].0]));
assert!(held_back(&frame, &mut view, &doc, &drawn[1..], false).is_empty());
}
/// A page of 100 points with a black rectangle on each of `rects`, and its frame (`whole`: the
/// page holds all the art that prints).
fn black_page(rects: &[Rect]) -> Document {
let mut page = Document::new(100.0, 100.0);
let layer = page.layers[0].id;
for r in rects {
let n = filled_rect(&mut page, *r, vectorcraft_color::Color::BLACK);
page.insert(Some(layer), 0, n).unwrap();
}
page
}
/// The frame of `page`, its 100-point square (`whole`: the page holds all the art that prints).
fn frame_of(page: &Document, whole: bool) -> Frame<'_> {
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
Frame { page, page_rect: rect, rect, whole, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }
}
/// An envelope of type is held back where the layers with it differ from the page in more of
/// the pixels it and its drawn art ink than its own check allows against its drawn art, though
/// that is far less of the page than the page check allows.
#[test]
fn an_envelope_of_type_differing_in_more_of_its_pixels_than_its_check_allows_is_held_back() {
// Half a point off its drawn art, which the page draws: 22% of the pixels they ink differ,
// a tenth of a percent of the page, and none with all their neighbors.
let page = black_page(&[Rect::new(30.5, 10.0, 34.5, 20.0)]);
let (doc, drawn) = stand_ins(&[Rect::new(30.0, 10.0, 34.0, 20.0)], &[Rect::new(30.5, 10.0, 34.5, 20.0)]);
let frame = frame_of(&page, false);
let mut view = PageView::new(&frame);
assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), BTreeSet::from([drawn[0].0]));
}
/// An envelope of type that the layers don't draw where it is and as itself is held back though
/// it draws as the page, wherever it lies: one that a clip group or a clipping sublayer clips, an
/// opacity mask masks, or that a group with an appearance of its own, another envelope, a repeat
/// or a blend holds. Drawn on its own, it would be weighed on pixels a clip hides, or elsewhere
/// than it draws, and what holds it can draw it onto the page from off it. One in a plain group
/// is weighed, and one off the page in a plain group stays live.
#[test]
fn envelopes_of_type_the_layers_dont_draw_in_place_are_held_back() {
let at = |i: usize| {
let (x, y) = if i < 4 { (5.0 + 20.0 * i as f64, 5.0) } else { (5.0 + 20.0 * (i - 4) as f64, 30.0) };
Rect::new(x, y, x + 15.0, y + 15.0)
};
let off = [Rect::new(120.0, 5.0, 135.0, 20.0), Rect::new(140.0, 5.0, 155.0, 20.0)];
let page = black_page(&(0..8).map(at).collect::<Vec<_>>());
let mut doc = Document::new(100.0, 100.0);
let layer = doc.layers[0].id;
let mut drawn = vec![];
let mut stand_in = |doc: &mut Document, r: Rect| {
let n = filled_rect(doc, r, vectorcraft_color::Color::BLACK);
drawn.push((n.id, vec![filled_rect(doc, r, vectorcraft_color::Color::BLACK)], ai::ENVELOPE_TYPE_DRAWN));
Arc::new(n)
};
let group = |doc: &mut Document, children: Vec<Arc<Node>>, clip: bool| Node::new(doc.alloc_id(), NodeKind::Group { children, clip });
let fill = vectorcraft_doc::Appearance {
items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(
vectorcraft_color::Color::BLACK,
)))],
..Default::default()
};
let mut objects = vec![];
// 0: in a plain group.
let n = stand_in(&mut doc, at(0));
objects.push(group(&mut doc, vec![n], false));
// 1: in a clip group.
let n = stand_in(&mut doc, at(1));
let mut clip = filled_rect(&mut doc, at(1).inflate(2.0, 2.0), vectorcraft_color::Color::BLACK);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
objects.push(group(&mut doc, vec![Arc::new(clip), n], true));
// 2: in another envelope.
let n = stand_in(&mut doc, at(2));
let r = at(2);
let points = vec![kurbo::Point::new(r.x0, r.y0), kurbo::Point::new(r.x1, r.y0), kurbo::Point::new(r.x0, r.y1), kurbo::Point::new(r.x1, r.y1)];
let kind = Box::new(vectorcraft_doc::live::EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] });
let options = vectorcraft_doc::live::EnvelopeOptions::NEW;
objects.push(Node::new(
doc.alloc_id(),
NodeKind::Envelope { content: vec![n], kind, fidelity: 50.0, editing: false, options, frame: Box::default() },
));
// 3: in a group with an appearance of its own.
let n = stand_in(&mut doc, at(3));
let mut styled = group(&mut doc, vec![n], false);
styled.appearance = fill.clone().into();
objects.push(styled);
// 4: in a group with an opacity mask.
let n = stand_in(&mut doc, at(4));
let mut masked = group(&mut doc, vec![n], false);
let mask = filled_rect(&mut doc, at(4).inflate(2.0, 2.0), vectorcraft_color::Color::WHITE);
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask, true)));
objects.push(masked);
// 5: in a clipping sublayer.
let n = stand_in(&mut doc, at(5));
let mut clip = filled_rect(&mut doc, at(5).inflate(2.0, 2.0), vectorcraft_color::Color::BLACK);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let mut sublayer = Node::layer(doc.alloc_id(), "Clipped", vectorcraft_doc::LayerColor::Preset(1));
if let NodeKind::Layer { clip: c, children, .. } = &mut sublayer.kind {
(*c, *children) = (true, vec![Arc::new(clip), n]);
}
objects.push(sublayer);
// 6: in a repeat; 7: in a blend.
let n = stand_in(&mut doc, at(6));
objects.push(Node::new(doc.alloc_id(), NodeKind::Repeat(vectorcraft_doc::RepeatSpec::grid(vec![n], 0.0, 0.0))));
let n = stand_in(&mut doc, at(7));
objects.push(Node::new(doc.alloc_id(), NodeKind::Blend { children: vec![n], spec: Box::default() }));
// 8: off the page in a group with an appearance of its own (an effect could draw it onto the
// page); 9: off the page in a plain group.
let n = stand_in(&mut doc, off[0]);
let mut styled = group(&mut doc, vec![n], false);
styled.appearance = fill.into();
objects.push(styled);
let n = stand_in(&mut doc, off[1]);
objects.push(group(&mut doc, vec![n], false));
for n in objects {
doc.insert(Some(layer), 0, n).unwrap();
}
let frame = frame_of(&page, false);
let mut view = PageView::new(&frame);
let held = held_back(&frame, &mut view, &doc, &drawn, false);
assert_eq!(held, drawn[1..9].iter().map(|(id, ..)| *id).collect::<BTreeSet<_>>());
}
/// On a page that holds all the art that prints, which the page check also compares past the
/// page, an envelope of type reaching past the page, or lying off it, is held back; on another
/// page, the part on the page is weighed, and one off it stays live.
#[test]
fn on_a_page_that_holds_all_the_art_an_envelope_of_type_past_it_is_held_back() {
let on = Rect::new(10.0, 10.0, 30.0, 30.0);
let across = Rect::new(90.0, 40.0, 110.0, 60.0);
let off = Rect::new(120.0, 10.0, 140.0, 30.0);
let page = black_page(&[on, Rect::new(90.0, 40.0, 100.0, 60.0)]);
let (doc, drawn) = stand_ins(&[on, across, off], &[on, across, off]);
for (whole, held) in [(true, BTreeSet::from([drawn[1].0, drawn[2].0])), (false, BTreeSet::new())] {
let frame = frame_of(&page, whole);
let mut view = PageView::new(&frame);
assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), held, "whole: {whole}");
}
// On the page by its box, past it by what it draws: a Transform effect of its own draws a
// copy of it 100 points to the right, off the page.
let page = black_page(&[on]);
let (mut doc, drawn) = stand_ins(&[on], &[on]);
let effect =
vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 100, "copies": 1}), visible: true };
doc.node_mut(drawn[0].0).unwrap().appearance.effects.push(effect);
for (whole, held) in [(true, BTreeSet::from([drawn[0].0])), (false, BTreeSet::new())] {
let frame = frame_of(&page, whole);
let mut view = PageView::new(&frame);
assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), held, "drawn past the page, whole: {whole}");
}
}
/// Where the objects that draw differently from the page are to come in as its art and the
/// envelopes of type the page doesn't hold stay live, but one of those holds the difference in
/// its box (an object that holds type isn't replaced), every envelope of type gets its drawn art
/// back and that is tried again: the layers are still used.
#[test]
fn objects_come_in_as_the_page_art_with_every_envelope_of_type_put_back_where_a_live_one_holds_a_difference() {
let (a, b, dot, other) = (
Rect::new(10.0, 10.0, 20.0, 20.0),
Rect::new(40.0, 10.0, 50.0, 20.0),
Rect::new(27.0, 12.0, 33.0, 18.0),
Rect::new(70.0, 70.0, 90.0, 90.0),
);
// The page draws a dot in the gap between the two rectangles of the envelope's stand-in.
let page = black_page(&[a, b, dot, other]);
let mut doc = Document::new(100.0, 100.0);
let layer = doc.layers[0].id;
let square = filled_rect(&mut doc, other, vectorcraft_color::Color::BLACK);
doc.insert(Some(layer), 0, square).unwrap();
// The stand-in holds type, hidden; its drawn art is the two rectangles.
let style = vectorcraft_doc::CharStyle::default();
let mut text =
Node::new(doc.alloc_id(), NodeKind::Text(Box::new(vectorcraft_doc::TextObject::point(kurbo::Point::new(12.0, 18.0), "A", style))));
text.visible = false;
let parts = vec![
Arc::new(filled_rect(&mut doc, a, vectorcraft_color::Color::BLACK)),
Arc::new(filled_rect(&mut doc, b, vectorcraft_color::Color::BLACK)),
];
let mut children = parts.clone();
children.push(Arc::new(text));
let live = Node::new(doc.alloc_id(), NodeKind::Group { children, clip: false });
let id = live.id;
doc.insert(Some(layer), 0, live).unwrap();
let art = Node::new(doc.alloc_id(), NodeKind::Group { children: parts, clip: false });
let drawn: Vec<Envelope> = vec![(id, vec![art], ai::ENVELOPE_TYPE_DRAWN)];
let frame = frame_of(&page, false);
let mut view = PageView::new(&frame);
assert!(compare(&frame, &mut view, &doc, false).is_err());
let layers = Placements { doc: &doc, unseen: 0, other: None, drawn: &drawn, places: &[(id, 0)] };
let mut free = None;
let found = keep_differing_typed(&frame, &mut view, &layers, &BTreeSet::new(), &[], false, &mut free);
assert_eq!(free, Some(vec![0]), "the page doesn't hold the envelope");
let Some((kept, _, notes, typed)) = found else { panic!("the layers aren't used") };
assert!(typed, "the envelope got its drawn art back");
assert!(kept.node(id).is_none());
assert!(notes.iter().any(|n| n.starts_with(DREW_DIFFERENTLY)), "{notes:?}");
}
/// The placed files' other placement is weighed on its own: an envelope of type that the layers
/// as read draw as the page, but that a placed file moved over it at the other placement makes
/// differ, gets its drawn art back there, where that placement's differing objects then come in
/// as the page's art.
#[test]
fn the_other_placement_of_placed_files_is_weighed_on_its_own() {
let black = vectorcraft_color::Color::BLACK;
let gray = vectorcraft_color::Color::rgb(0.5, 0.5, 0.5);
let (envelope, fillers) = (Rect::new(10.0, 10.0, 30.0, 30.0), [Rect::new(40.0, 80.0, 50.0, 90.0), Rect::new(70.0, 80.0, 80.0, 90.0)]);
let placed = Rect::new(60.0, 60.0, 76.0, 76.0);
// The page draws the envelope's drawn art in two halves.
let page = black_page(&[Rect::new(10.0, 10.0, 20.0, 30.0), Rect::new(20.0, 10.0, 30.0, 30.0), placed, fillers[0], fillers[1]]);
let (mut doc, drawn) = stand_ins(&[envelope], &[envelope]);
let layer = doc.layers[0].id;
for r in fillers {
let n = filled_rect(&mut doc, r, black);
doc.insert(Some(layer), 0, n).unwrap();
}
// Two placed files, standing in as rectangles: one 20 points above where the page has it, one
// a gray rectangle off the art. At their other placement the first is where the page has it
// and the gray one lies over half of the envelope.
let image = filled_rect(&mut doc, Rect::new(60.0, 40.0, 76.0, 56.0), black);
let strip = filled_rect(&mut doc, Rect::new(85.0, 40.0, 95.0, 60.0), gray);
// On top of the layer: the strip covers the envelope where it lies over it.
let top = doc.layers[0].children().map_or(0, Vec::len);
let (image, strip) = (doc.insert(Some(layer), top, image).unwrap(), doc.insert(Some(layer), top + 1, strip).unwrap());
let mut other = doc.clone();
other.node_mut(image).unwrap().transform(Affine::translate(Vec2::new(0.0, 20.0)), PAGE_MOVE);
other.node_mut(strip).unwrap().transform(Affine::translate(Vec2::new(-65.0, -30.0)), PAGE_MOVE);
let frame = frame_of(&page, false);
let mut view = PageView::new(&frame);
assert!(held_back(&frame, &mut view, &doc, &drawn, false).is_empty());
assert_eq!(held_back(&frame, &mut view, &other, &drawn, false), BTreeSet::from([drawn[0].0]));
let other = (other, 0);
let layers = Placements { doc: &doc, unseen: 0, other: Some(&other), drawn: &drawn, places: &[(drawn[0].0, 0)] };
let mut free = None;
let found = keep_differing_typed(&frame, &mut view, &layers, &BTreeSet::new(), &[], false, &mut free);
assert_eq!(free, Some(vec![0]), "the layers as read don't hold the envelope");
let Some((kept, _, notes, typed)) = found else { panic!("the layers aren't used") };
assert!(notes.iter().any(|n| n.starts_with(DREW_DIFFERENTLY)), "{notes:?}");
assert!(typed && kept.node(drawn[0].0).is_none(), "the envelope keeps its drawn art at the other placement");
}
/// Where putting back only the envelopes of type the page holds makes the layers draw as the page,
/// though putting back every live envelope's drawn art doesn't (an envelope of type the page
/// doesn't hold has stale drawn art), the layers are used as they then are: nothing differs to
/// come in as the page's art.
#[test]
fn layers_that_draw_as_the_page_with_the_held_envelopes_of_type_put_back_are_used_so() {
let (held, kept_live) = (Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(60.0, 10.0, 80.0, 30.0));
let page = black_page(&[held, kept_live]);
// The first is drawn 6 points off its drawn art, which the page draws; the second draws as the
// page, its drawn art 6 points off.
let (doc, drawn) = stand_ins(&[Rect::new(16.0, 10.0, 36.0, 30.0), kept_live], &[held, Rect::new(66.0, 10.0, 86.0, 30.0)]);
let frame = frame_of(&page, false);
let mut view = PageView::new(&frame);
assert!(compare(&frame, &mut view, &doc, false).is_err());
let mut all = doc.clone();
all.insert_beside(drawn.iter().map(|(id, art, _)| (*id, art.clone())).collect(), true).unwrap();
assert!(compare(&frame, &mut view, &all, false).is_err(), "with every drawn art put back they don't draw as the page");
let places = [(drawn[0].0, 0), (drawn[1].0, 1)];
let layers = Placements { doc: &doc, unseen: 0, other: None, drawn: &drawn, places: &places };
let mut free = None;
let found = keep_differing_typed(&frame, &mut view, &layers, &BTreeSet::new(), &[], false, &mut free);
assert_eq!(free, Some(vec![1]));
let Some((kept, _, notes, typed)) = found else { panic!("the layers aren't used") };
assert!(typed && kept.node(drawn[0].0).is_none() && kept.node(drawn[1].0).is_some());
assert!(!notes.iter().any(|n| n.starts_with(DREW_DIFFERENTLY)), "{notes:?}");
}
}
+52 -10
View File
@@ -570,6 +570,34 @@ impl PageView {
}
}
}
/// Which pixels of each of the pages `on` `art` inks (draws over with an alpha above 8 of 255),
/// drawn as the layers are drawn on them, row by row.
pub(super) fn inked(&mut self, art: &Document, on: &[usize]) -> Vec<(usize, Vec<bool>)> {
self.draw(art, on)
.into_iter()
.map(|(i, drawn)| (i, drawn.pixels.as_chunks::<4>().0.iter().map(|p| u32::from(p[3]) > WHITE).collect()))
.collect()
}
/// Of the pixels of page `i` that `marks` marks (row by row), how many `b` draws differently from
/// the page, as [`compared`] counts them: those that differ (with `reflow`, those away from type),
/// and those that differ with all their neighbors, away from type. `None` when `marks` isn't the
/// page's size.
pub(super) fn differing_in(&mut self, b: &Document, i: usize, marks: &[bool], reflow: bool) -> Option<(usize, usize)> {
self.layered(b, &[i]);
let printed = as_printed(b);
let b = printed.as_ref().unwrap_or(b);
let page = self.pages.get(i)?;
let layered = self.drawn.last()?.pages.get(i)?.as_ref()?;
let (differs, typed, solid) = differing_pixels(&page.drawn, &layered.pixels, &page.counted_type(b, page.region, reflow), &page.skipped())?;
if marks.len() != differs.len() {
return None;
}
let any = marks.iter().zip(&differs).zip(&typed).filter(|((m, d), t)| **m && **d && !(reflow && **t)).count();
let solid = marks.iter().zip(&solid).filter(|(m, s)| **m && **s).count();
Some((any, solid))
}
}
/// The `w` × `h` pixels of `from` from pixel (`x`, `y`): `None` unless they lie in it, from a pixel.
@@ -682,8 +710,27 @@ pub(super) fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render:
/// [`compared`], and which of the pixels (row by row) are counted in its `solid` share: none when
/// the drawings can't be compared.
fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> (Difference, Vec<bool>) {
if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height {
let Some((differs, typed, solid)) = differing_pixels(x, y, areas, skip) else {
return (Difference { any: 1.0, art: 1.0, solid: 1.0, typed: 0.0 }, vec![]);
};
let area = (f64::from(x.width) * f64::from(x.height)).max(1.0);
let any = differs.iter().filter(|d| **d).count() as f64 / area;
let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count() as f64 / area;
let shares = Difference { any, art, solid: solid.iter().filter(|s| **s).count() as f64 / area, typed: any - art };
(shares, solid)
}
/// Which pixels (row by row) of two drawings of the same size differ ([`differ`], those in `skip`
/// counting as alike), which of those lie in the type's `areas`, and which differ with all their
/// neighbors away from type: [`compared`]'s pixels. `None` when the drawings can't be compared.
fn differing_pixels(
x: &vectorcraft_render::Rendered,
y: &vectorcraft_render::Rendered,
areas: &[Rect],
skip: &[Rect],
) -> Option<(Vec<bool>, Vec<bool>, Vec<bool>)> {
if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height {
return None;
}
let (w, h) = (x.width as usize, x.height as usize);
// Pixels in `skip` count as alike.
@@ -691,10 +738,9 @@ fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render:
let differs: Vec<bool> =
x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).zip(&skipped).map(|((p, q), s)| !*s && differ(p, q)).collect();
if !differs.contains(&true) {
return (Difference { any: 0.0, art: 0.0, solid: 0.0, typed: 0.0 }, vec![false; w * h]);
return Some((differs, vec![false; w * h], vec![false; w * h]));
}
let typed = in_areas(areas, w, h);
let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count();
let typed: Vec<bool> = in_areas(areas, w, h).into_iter().zip(&differs).map(|(t, d)| t && *d).collect();
// Which pixels differ with their left and right neighbors.
let mut across = vec![false; w * h];
for (k, a) in across.iter_mut().enumerate() {
@@ -706,11 +752,7 @@ fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render:
for (k, s) in solid.iter_mut().enumerate().skip(w).take(w * h.saturating_sub(2)) {
*s = !typed.get(k).copied().unwrap_or(true) && [k - w, k, k + w].iter().all(|n| across.get(*n).copied().unwrap_or(false));
}
let area = (w as f64 * h as f64).max(1.0);
let any = differs.iter().filter(|d| **d).count() as f64 / area;
let art = art as f64 / area;
let shares = Difference { any, art, solid: solid.iter().filter(|s| **s).count() as f64 / area, typed: any - art };
(shares, solid)
Some((differs, typed, solid))
}
/// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin
@@ -879,7 +921,7 @@ const NOTABLE_DIFFERENCE: f64 = 0.002;
pub(in crate::import) const MAX_SOLID_DIFFERENCE: f64 = 0.001;
/// How far (points) the art the layers print may reach past a page that holds it all.
const PAST_THE_PAGE: f64 = 1.0;
pub(super) const PAST_THE_PAGE: f64 = 1.0;
/// Do the layers' art and the page's own look alike (where the page holds all the art that prints,
/// also round it), on every page the frame has ([`Frame::more`])? An error says they don't; `Ok` has
+19 -7
View File
@@ -1,4 +1,4 @@
use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_without_page, reid_page_art};
use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art};
use super::slots::LAYERS_UNREAD;
use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai};
@@ -177,15 +177,27 @@ pub fn layered_ai_from(
add_notes(&mut all, read.notes());
(visible, all, false)
};
let built = structure
.unwrap_or_else(|| ai::read_over_page(&data, false, names).map(Box::new))
.and_then(|structure| build_from(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit));
// The envelopes of type the page doesn't hold against layers that don't draw as it, and how many
// envelopes of type the layers had.
let (mut free, mut count) = (None, 0);
let built = structure.unwrap_or_else(|| ai::read_over_page(&data, false, names).map(Box::new)).and_then(|structure| {
count = structure.warped_type.envelope_count();
build_weighing(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit, &mut free)
});
// Layers that waited on the page only for their envelopes of type open alone, as they would
// have without them, each envelope keeping its drawn art, unless one opened live with the page.
let live = matches!(&built, Ok((done, _)) if holds_type_envelope(&done.layers));
// have without them, unless one opened live with the layers drawing as the page. Alone, each
// envelope keeps its drawn art but those the page didn't hold against the layers, which open
// live as they were weighed. They were weighed on the layers as compared with the page, before
// any object of them came in as the page's art: those layers aren't used here, whatever came of
// them.
let live = free.is_none() && matches!(&built, Ok((done, _)) if holds_type_envelope(&done.layers));
if !live
&& let Some((mut done, notes)) = alone.and_then(|(pages, unit)| {
let structure = ai::read_over_page(&data, false, names).ok()?;
let mut structure = ai::read_over_page(&data, false, names).ok()?;
// With none free, nothing needs settling again: every envelope keeps its drawn art.
if let Some(free) = free.as_deref().filter(|free| !free.is_empty()) {
structure.warped_type.open_alone(free, count);
}
build_without_page(structure, &data, &pages, unit).ok()?.ok()
})
{
+202 -21
View File
@@ -5528,6 +5528,11 @@ fn type_envelope(keys: &str, drawn: &str) -> String {
/// [`type_envelope`], with `after` (on plain lines, written on `%_` ones) after the text object in
/// its contents, such as the text object's XML id.
fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String {
type_envelope_of(0, keys, drawn, after)
}
/// [`type_envelope_with`] around the type of story `story`.
fn type_envelope_of(story: u32, keys: &str, drawn: &str, after: &str) -> String {
let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n");
let mesh = format!(
"/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n",
@@ -5536,18 +5541,23 @@ fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String {
corner(80.0, 70.0, 80.0, 60.0, 70.0, 70.0),
corner(80.0, 40.0, 70.0, 40.0, 80.0, 50.0),
);
type_envelope_on(keys, &mesh, drawn, after)
type_envelope_on_story(story, keys, &mesh, drawn, after)
}
/// [`type_envelope_with`], with `shape` (on plain lines, written on `%_` ones) in the group before
/// its dictionary, where the editing data saves the envelope's mesh.
fn type_envelope_on(keys: &str, shape: &str, drawn: &str, after: &str) -> String {
type_envelope_on_story(0, keys, shape, drawn, after)
}
/// [`type_envelope_on`] around the type of story `story`.
fn type_envelope_on_story(story: u32, keys: &str, shape: &str, drawn: &str, after: &str) -> String {
let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() };
format!(
"{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n",
hidden(&format!("u\n{shape}U\n")),
hidden(&format!("/ArtDictionary :\n{keys}")),
hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(0))),
hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(story))),
)
}
@@ -5594,7 +5604,12 @@ fn arc_envelope(text: &Node, bend: f64) -> Node {
/// The type of the file with an envelope of type whose drawn art is `drawn`, read with its text
/// document `doc`: the hidden text object it keeps, as the reader makes it.
fn made_envelope_type(doc: &str) -> Node {
let art = native_with_text(&layer("Art", true, &type_envelope(&arc_keys(0.3), &format!("{}\n", square(10, 10)))), doc);
made_envelope_type_of(doc, 0)
}
/// [`made_envelope_type`] of story `story`.
fn made_envelope_type_of(doc: &str, story: u32) -> Node {
let art = native_with_text(&layer("Art", true, &type_envelope_of(story, &arc_keys(0.3), &format!("{}\n", square(10, 10)), "")), doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let mut text = None;
r.document.walk(|n| {
@@ -5685,9 +5700,9 @@ fn an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it() {
/// A `.ai` whose layers would open alone, without its PDF part, waits on the PDF part when an
/// envelope of type is in them, as for the other live envelopes: one opens live only where the
/// layers with it draw as the page. Where they don't, they open alone as before, the envelope
/// keeping its drawn art and its type, hidden, with the note, even though it would draw as that
/// drawn art; where they do, it opens live (#3179).
/// layers with it draw as the page. Where they don't, the page drawing nothing where the envelope
/// is, they open alone as before, the envelope keeping its drawn art and its type, hidden, with the
/// note, even though it would draw as that drawn art; where they do, it opens live (#3179).
#[test]
fn an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone() {
let Some(family) = installed_family() else { return };
@@ -5751,9 +5766,10 @@ fn an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live() {
}
/// Layers used though they draw differently from the page (the editing data trusted) leave an
/// envelope of type its drawn art and its type, hidden, with the note, even where it draws as that
/// drawn art: one opens live only where the layers with it draw as the page. A `.ai` whose layers
/// would open alone then opens alone, as it would without the envelope (#3179).
/// envelope of type its drawn art and its type, hidden, with the note, where the page draws
/// nothing where the envelope is, even where it draws as that drawn art: one opens live only where
/// the layers with it draw as the page there. A `.ai` whose layers would open alone then opens
/// alone, as it would without the envelope (#3179).
#[test]
fn trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art() {
let Some(family) = installed_family() else { return };
@@ -5789,19 +5805,27 @@ fn trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_dr
assert!(!notes.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{notes:?}");
}
/// Layers whose objects that draw differently from the page come in as its art leave an envelope
/// of type its drawn art and its type, hidden, with the note, even where the envelope draws as the
/// page: the layers with it don't (#3179).
#[test]
fn layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art() {
let Some(family) = installed_family() else { return };
let doc = type_envelope_text(&family, None);
let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0));
// A square black in the layers and cyan on the page, away from the envelope.
/// The layers and the page of a file with an envelope of type that draws as its drawn art, and a
/// square black in the layers and cyan on the page, away from the envelope; the page draws the
/// envelope bent by `page_bend` (percent).
fn envelope_beside_a_differing_square(family: &str, page_bend: f64) -> (String, String) {
let doc = type_envelope_text(family, None);
let text = made_envelope_type(&doc);
let (drawn, _) = drawn_envelope(&arc_envelope(&text, 30.0));
let (_, page) = drawn_envelope(&arc_envelope(&text, page_bend));
let body = format!("{}\n{}\n{}", square(10, 10), square_16(76, BLACK), type_envelope(&arc_keys(0.3), &drawn));
let art = native_with_text(&layer("Art", true, &body), &doc);
let cyan = "1 0 0 0 setcmykcolor 76 10 moveto 92 10 lineto 92 26 lineto 76 26 lineto closepath fill";
let r = import(&eps(&format!("{}\n{cyan}\n{page}", page_square(10, 10)), &art)).unwrap();
(native_with_text(&layer("Art", true, &body), &doc), format!("{}\n{cyan}\n{page}", page_square(10, 10)))
}
/// Layers whose objects that draw differently from the page come in as its art keep an envelope of
/// type live where the envelope draws as the page: the art that differs isn't the envelope's, and
/// the layers with it then draw as the page (#3179).
#[test]
fn layers_with_objects_that_come_in_as_the_page_art_keep_an_envelope_of_type_that_draws_as_the_page_live() {
let Some(family) = installed_family() else { return };
let (art, page) = envelope_beside_a_differing_square(&family, 30.0);
let r = import(&eps(&page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert!(
r.warnings.iter().any(|n| n.starts_with("objects that drew differently from the file's own page came in as its art")),
@@ -5809,12 +5833,169 @@ fn layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_it
r.warnings
);
let (envelopes, loose) = envelopes_and_type(&r.document);
assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings);
assert!(loose.is_empty(), "{loose:?}");
assert!(!r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings);
}
/// Layers used though they draw differently from the page (the editing data trusted) leave an
/// envelope of type its drawn art and its type, hidden, with the note, where the page draws it
/// otherwise than they do (bent the other way), though other art differs too: the page holds the
/// envelope against them where it is (#3179).
#[test]
fn trusted_layers_leave_an_envelope_of_type_the_page_draws_otherwise_its_drawn_art() {
let Some(family) = installed_family() else { return };
let (art, page) = envelope_beside_a_differing_square(&family, -30.0);
let r = crate::import_trusting(&eps(&page, &art), true, true).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{:?}", r.warnings);
let (envelopes, loose) = envelopes_and_type(&r.document);
assert!(envelopes.is_empty(), "{envelopes:?}");
assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings);
assert_eq!(loose_type_names(&r.document), [None]);
assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings);
}
/// Layers used though they draw differently from the page (the editing data trusted) keep an
/// envelope of type live where the page draws it as they do and the art that differs is elsewhere
/// (#3179).
#[test]
fn trusted_layers_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page() {
let Some(family) = installed_family() else { return };
let (art, page) = envelope_beside_a_differing_square(&family, 30.0);
let r = crate::import_trusting(&eps(&page, &art), true, true).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{:?}", r.warnings);
let (envelopes, loose) = envelopes_and_type(&r.document);
assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings);
assert!(loose.is_empty(), "{loose:?}");
assert!(!r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings);
}
/// A `.ai` whose layers would open alone, and that waits on its PDF part for an envelope of type,
/// opens alone with the envelope live where the page draws it as the layers do, though the page
/// also draws art the layers don't have: the envelope's drawn art put back wouldn't make the layers
/// draw as the page (#3179).
#[test]
fn layers_that_would_open_alone_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page() {
let Some(family) = installed_family() else { return };
let stories = [("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0)];
let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})"));
// [`native_with_text`] writes 60 characters a line: the end of the data, `~>`, isn't split.
while ascii85(doc.as_bytes()).len() % 60 == 1 {
doc.push('\n');
}
let (drawn, _) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0));
let art = format!("u\n{}U\n{}", text_object(1), type_envelope(&arc_keys(0.3), &drawn));
let file = private(&native_with_text(&layer("Art", true, &art), &doc));
let names = crate::NameEncoding::default();
let open = |page: Document| {
let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") };
crate::layered_ai_from(layers, page, vec![], false, false, 1.0, names)
};
// The layers alone, with the envelope's drawn art: what the page draws.
let (mut page, notes, _) = open(pdf_part(80.0, 80.0));
assert!(envelopes_and_type(&page).0.is_empty(), "{notes:?}");
// And a black square the layers don't have, away from the type.
add_black_square(&mut page, 80.0, 4.0);
let (d, notes, read) = open(page);
assert!(read, "{notes:?}");
let (envelopes, loose) = envelopes_and_type(&d);
assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{notes:?}");
assert_eq!(loose, [("Note".to_string(), true)], "{notes:?}");
assert!(!notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}");
}
/// A black square of 16 points at (`x`, `y`) put on top of `page`'s first layer.
fn add_black_square(page: &mut Document, x: f64, y: f64) {
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint));
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, y, x + 16.0, y + 16.0));
let square = Node::path(page.alloc_id(), path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() });
let layer = page.layers[0].id;
page.insert(Some(layer), 0, square).unwrap();
}
/// A `.ai` whose layers would open alone, with the editing data trusted, opens alone with an
/// envelope of type live where the page draws it as the layers do, though the page also draws art
/// they don't have: not from the layers compared with the page, which would say they come from the
/// editing data all the same (#3179).
#[test]
fn trusted_layers_that_would_open_alone_open_alone_with_an_envelope_of_type_the_page_doesnt_hold_live() {
let Some(family) = installed_family() else { return };
let stories = [("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0)];
let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})"));
while ascii85(doc.as_bytes()).len() % 60 == 1 {
doc.push('\n');
}
let (drawn, _) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0));
let art = format!("u\n{}U\n{}", text_object(1), type_envelope(&arc_keys(0.3), &drawn));
let file = private(&native_with_text(&layer("Art", true, &art), &doc));
let names = crate::NameEncoding::default();
let open = |page: Document, trust: bool| {
let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") };
crate::layered_ai_from(layers, page, vec![], false, trust, 1.0, names)
};
let (mut page, _, _) = open(pdf_part(80.0, 80.0), false);
add_black_square(&mut page, 80.0, 4.0);
let (d, notes, read) = open(page, true);
assert!(read, "{notes:?}");
let (envelopes, loose) = envelopes_and_type(&d);
assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{notes:?}");
assert_eq!(loose, [("Note".to_string(), true)], "{notes:?}");
assert!(notes.is_empty(), "{notes:?}");
}
/// Of two envelopes of type in a `.ai` whose layers would open alone, the one the page holds
/// against the layers (it draws nothing where the envelope is) keeps its drawn art and its type,
/// hidden, with the note, and the other opens live: each by its place among the envelopes of type
/// as the layers are read, both times they are (#3179).
#[test]
fn layers_that_would_open_alone_keep_live_only_the_envelopes_of_type_the_page_doesnt_hold() {
let Some(family) = installed_family() else { return };
let stories = [
("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0),
("HILL\r", 0, (8170.0, 8200.0), (0.0, 0.0), 0.0),
("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0),
];
let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})"));
while ascii85(doc.as_bytes()).len() % 60 == 1 {
doc.push('\n');
}
let drawn = |story| drawn_envelope(&arc_envelope(&made_envelope_type_of(&doc, story), 30.0)).0;
let art = format!(
"u\n{}U\n{}{}",
text_object(2),
type_envelope_of(0, &arc_keys(0.3), &drawn(0), ""),
type_envelope_of(1, &arc_keys(0.3), &drawn(1), "")
);
let file = private(&native_with_text(&layer("Art", true, &art), &doc));
let names = crate::NameEncoding::default();
let open = |page: Document| {
let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") };
crate::layered_ai_from(layers, page, vec![], false, false, 1.0, names)
};
// The layers alone, with the drawn art of both envelopes, but the first's: the page draws
// nothing where the first is.
let (mut page, notes, _) = open(pdf_part(80.0, 80.0));
let top = page.layers[0].clone();
let first = top
.children()
.into_iter()
.flatten()
.filter(|n| matches!(n.kind, NodeKind::Group { .. }))
.min_by(|a, b| a.visual_bounds().map_or(f64::MAX, |r| r.y0).total_cmp(&b.visual_bounds().map_or(f64::MAX, |r| r.y0)))
.map(|n| n.id)
.unwrap_or_else(|| panic!("no drawn art: {notes:?}"));
page.remove(first).unwrap();
let (d, notes, read) = open(page);
assert!(read, "{notes:?}");
let (envelopes, mut loose) = envelopes_and_type(&d);
assert_eq!(envelopes, [vec!["HILL".to_string()]], "{notes:?}");
loose.sort();
assert_eq!(loose, [("FLUX".to_string(), false), ("Note".to_string(), true)], "{notes:?}");
assert!(notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}");
}
/// An envelope of type whose type a visibility variable binds keeps its drawn art, as one whose type
/// a text variable binds does: the binding stays the hidden type's, which the live envelope would
/// take the place of (#3179).
+1
View File
@@ -115,6 +115,7 @@ const CONIC: &str = "conic gradients are written as solid wedges clipped to the
const DIAMOND: &str = "diamond gradients are written as four linear gradients clipped to the shape (legacy .ai files have no diamond gradient)";
const SPREAD: &str = "gradients that repeat or reflect are written with their stops repeated across the shape (legacy .ai gradients only pad)";
const SPREAD_CAPPED: &str = "gradients that repeat or reflect are written with at most 256 stops; they pad past those";
const HUE_STOPS: &str = "gradients mixed around the hue wheel are written in process colors, with the colors between their stops as stops of their own (at most 256; legacy .ai gradients mix their stops' colors directly)";
/// Wedges a conic gradient is written as: one a degree.
const CONIC_WEDGES: usize = 360;
const STROKES: &str =
+40
View File
@@ -198,6 +198,46 @@ fn many_repeats_are_written_within_the_stops_the_reader_reads() {
assert_eq!(stops, Some(written), "every stop written is read");
}
/// The format's gradients mix their stops' colors directly: a gradient mixed around the hue wheel
/// is written with the colors on its way around the wheel as stops, no more than the reader
/// reads, with a warning, and opens with the colors it had.
#[test]
fn hue_wheel_gradients_are_written_with_the_stops_on_their_curve() {
use vectorcraft_color::GradientMethod;
for (method, middle) in [(GradientMethod::HueCounterclockwise, [0.0, 1.0, 0.0]), (GradientMethod::HueClockwise, [1.0, 0.0, 1.0])] {
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let g = GradientPaint::new(Gradient { method, ..Gradient::new(GradientKind::Linear, stops) });
let want = g.gradient.clone();
let mut doc = Document::new(200.0, 100.0);
let n = shape(&mut doc, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Paint::Gradient(Box::new(g)), Paint::None);
let l = doc.layers[0].id;
doc.insert(Some(l), 0, n).unwrap();
let out = write(&doc);
assert!(out.warnings.iter().any(|w| w == HUE_STOPS), "{method:?}: {:?}", out.warnings);
let text = String::from_utf8_lossy(&out.bytes).into_owned();
let def = text.split("%AI5_BeginGradient: (Gradient)\n").nth(1).and_then(|d| d.split("%AI5_EndGradient").next()).unwrap();
let written = def.matches(" %_Bs\n").count();
// The two ends and each sixth of the wheel between them, where the way turns in RGB.
assert!((3..=crate::import::MAX_GRADIENT_STOPS).contains(&written), "{method:?}: {written} stops");
let read = crate::import(&out.bytes).unwrap().document;
let mut back = None;
read.walk(|n| {
if let (None, Paint::Gradient(g)) = (&back, n.appearance.fill_paint()) {
back = Some(g.gradient.clone());
}
});
let back = back.unwrap();
assert!(back.method.is_classic(), "{method:?}");
let mid = back.sample(0.5).0.to_rgb();
assert!(mid.iter().zip(middle).all(|(a, b)| (a - b).abs() < 0.03), "{method:?}: the middle is {mid:?}");
for k in 0..=100 {
let t = k as f32 / 100.0;
let (a, b) = (want.sample(t).0.to_rgb(), back.sample(t).0.to_rgb());
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.03), "{method:?} at {t}: {a:?} vs {b:?}");
}
}
}
/// A radial gradient repeated out from a center far from the shape is written with its ramp points
/// (percent) to five decimals, so its runs keep their places hundreds of runs out.
#[test]
+11 -1
View File
@@ -9,7 +9,7 @@ use super::{
ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
};
use super::{CONIC, CONIC_WEDGES, DIAMOND, SPREAD, SPREAD_CAPPED};
use super::{CONIC, CONIC_WEDGES, DIAMOND, HUE_STOPS, SPREAD, SPREAD_CAPPED};
pub(super) struct Writer<'a> {
doc: &'a Document,
@@ -693,6 +693,16 @@ impl<'a> Writer<'a> {
/// (its distance a fraction of the radius, at most 1, §3.5.5; `0 0 0 0 Bh` at the center,
/// §3.5.6), `1 (name) x y angle length 1 0 0 1 0 0 Bg`.
fn gradient(&mut self, g: &GradientPaint, bounds: Rect) -> String {
// A gradient mixed around the hue wheel (the format's gradients mix their stops' colors
// directly): the stops on its curve, no more than the reader reads, in a definition of
// its own.
if g.gradient.method.is_hue() {
self.warn(HUE_STOPS);
let curve = g.gradient.as_classic_curve();
let gradient = if curve.is_spread() { curve } else { curve.fitted(crate::import::MAX_GRADIENT_STOPS).into_owned() };
let p = GradientPaint { gradient, swatch: None, ..g.clone() };
return self.gradient(&p, bounds);
}
// A gradient that repeats or reflects (the format's gradients pad past their ends): its
// stops repeated as many times as `bounds` needs, in a definition of its own, no more
// stops than the reader reads.
+12 -3
View File
@@ -42,6 +42,7 @@ const SPOT_GRADIENTS_L2: &str =
"gradients mixing a spot color with other inks are written in process colors at PostScript Level 2 (Level 3 keeps the spot inks)";
const SPOT_GRADIENTS_SHARED: &str = "gradients whose spot inks can't share a DeviceN color space are written without the spot inks";
const SPOT_GRADIENTS_PERCEPTUAL: &str = "Perceptual gradients of spot colors are written in process colors";
const SPOT_GRADIENTS_HUE: &str = "gradients of spot colors mixed around the hue wheel are written in process colors";
const CONIC_WEDGES: &str = "conic gradients are written as solid wedges (EPS has no conic shading)";
const DIAMOND_QUADRANTS: &str = "diamond gradients are written as four linear gradients (EPS has no diamond shading)";
const SPREAD_CAPPED: &str =
@@ -913,8 +914,8 @@ impl<'a> Scene<'a> {
/// them. Paper white adds no ink, a gray stop is black ink, and a spot stop's process plates
/// print with its ink. An off-center midpoint is a stop
/// with the inks halfway between its two stops. `None` for a gradient without a spot ink, and,
/// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method;
/// it is then written in process colors.
/// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method
/// or around the hue wheel; it is then written in process colors.
fn ink_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<(GradientSpace, RawStops)> {
let doc = self.doc;
let spot_of = |s: &vectorcraft_color::GradientStop| -> Option<String> {
@@ -936,7 +937,7 @@ impl<'a> Scene<'a> {
return None;
}
if !g.method.is_classic() {
self.warn(SPOT_GRADIENTS_PERCEPTUAL);
self.warn(if g.method.is_hue() { SPOT_GRADIENTS_HUE } else { SPOT_GRADIENTS_PERCEPTUAL });
return None;
}
if names.len() > MAX_DEVICE_N {
@@ -1093,6 +1094,14 @@ impl<'a> Scene<'a> {
/// both levels; a diamond: four linear gradients, [`Self::diamond_quadrants`]).
fn gradient(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) {
let geom = g.resolve(bounds);
// One mixed around the hue wheel that reflects is repeated from its curve's stops, which
// keep no swatch links: the warning [`Self::ink_stops`] gives the others.
if g.gradient.method.is_hue() && g.gradient.is_spread() && g.gradient.spread == vectorcraft_color::GradientSpread::Reflect {
let doc = self.doc;
if g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) {
self.warn(SPOT_GRADIENTS_HUE);
}
}
// A linear, radial or diamond gradient that repeats or reflects: PostScript's shadings
// pad past their ends, so its stops are repeated as many times as the area needs.
if let Some(padded) = g.gradient.padded_over(&geom, area) {
+47
View File
@@ -673,6 +673,53 @@ fn perceptual_gradients_of_spot_inks_are_written_in_process_colors() {
assert!(text(&export(&d, &opts(&d), None, None).unwrap()).contains("/ColorSpace VCdn0"));
}
/// The colors between two stops mixed around the hue wheel are not mixes of the stops' inks: a
/// gradient of spot inks mixed that way is written in process colors, with its own warning, as
/// the same gradient without spot inks is.
#[test]
fn hue_wheel_gradients_of_spot_inks_are_written_in_process_colors() {
for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
let stops = vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), spot_stop(1.0, "Leaf Ink", LEAF, 1.0)];
let mut hue = Gradient::new(GradientKind::Linear, stops.clone());
hue.method = method;
// Reflecting, it is written from the stops on its curve, and warns all the same.
let reflected = Gradient { spread: vectorcraft_color::GradientSpread::Reflect, repeats: 2, ..hue.clone() };
let r = spot_gradient_doc_of(reflected);
let reflected_out = export(&r, &opts(&r), None, None).unwrap();
let reflected_page = checked_page(&text(&reflected_out));
assert!(!reflected_page.contains("VCdn") && !reflected_page.contains("VCsp"), "{method:?} reflected: {reflected_page}");
assert!(
reflected_out.warnings.iter().any(|w| w.contains("mixed around the hue wheel are written in process colors")),
"{method:?} reflected: {:?}",
reflected_out.warnings
);
let d = spot_gradient_doc_of(hue);
let out = export(&d, &opts(&d), None, None).unwrap();
let page = checked_page(&text(&out));
assert!(!page.contains("VCdn") && !page.contains("VCsp"), "{method:?}: {page}");
assert!(page.contains("/ColorSpace /DeviceCMYK"), "{method:?}: {page}");
assert!(
out.warnings.iter().any(|w| w.contains("gradients of spot colors mixed around the hue wheel are written in process colors")),
"{method:?}: {:?}",
out.warnings
);
assert!(!out.warnings.iter().any(|w| w.contains("Perceptual")), "{method:?}: {:?}", out.warnings);
let process = |c: Color| GradientStop::new(0.0, c);
let mut plain = Gradient::new(GradientKind::Linear, vec![process(GOLD), GradientStop { offset: 1.0, ..process(LEAF) }]);
plain.method = method;
let p = spot_gradient_doc_of(plain);
let plain_out = export(&p, &opts(&p), None, None).unwrap();
assert!(!plain_out.warnings.iter().any(|w| w.contains("hue wheel")), "{method:?}: {:?}", plain_out.warnings);
let plain_page = checked_page(&text(&plain_out));
let shading = |s: &str| -> Option<String> {
let i = s.find("/ShadingType")?;
Some(s[i..i + s[i..].find("shfill")?].to_string())
};
assert!(shading(&page).is_some_and(|s| s.contains("/Bounds")), "{method:?}: {page}");
assert_eq!(shading(&page), shading(&plain_page), "{method:?}");
}
}
#[test]
fn rgb_is_written_as_cmyk_when_asked_and_in_cmyk_documents() {
let d = doc_with(|d| vec![red_rect(d)]);
+13 -1
View File
@@ -40,9 +40,21 @@ fn an_option_no_format_takes_is_an_error() {
assert!(e.contains(named), "{method}: {e}");
}
}
// One object can still carry several formats' options.
// One object can still carry several formats' options; the warnings name those the file
// doesn't read.
let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": {"quality": 80, "styling": "style"}}));
assert!(!r.is_error, "{r:?}");
let warnings = text(&r)["warnings"].clone();
for key in ["quality", "styling"] {
assert!(warnings.as_array().unwrap().contains(&json!(format!("`{key}` was not used: PDF files don't read it"))), "{warnings}");
}
// `app.export` and `file.export` give the same warning.
let params = json!({"format": "pdf", "quality": 80});
let note = json!("`quality` was not used: PDF files don't read it");
let r = h.call("app.export", params.clone()).unwrap();
assert!(r["warnings"].as_array().unwrap().contains(&note), "app.export: {r}");
let r = h.call("engine.execute", json!({"command": "file.export", "params": params})).unwrap();
assert!(r["warnings"].as_array().unwrap().contains(&note), "file.export: {r}");
}
/// `app.save`, `file.save` and `file.exportForScreens` refuse a key no format takes before they
+8 -2
View File
@@ -1477,13 +1477,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.action.record",
"Begin Recording",
"",
"{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}",
"{set?: index, action?: index, after?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. With action, record into the action at action in set instead: ui.action.stop puts the recorded steps after step after of that action, else after its last step, and the steps after them keep whether they are excluded. The panel's Begin Recording button passes the selected action and its selected step. action without set, after without action, an index past the last set, action or step, an explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}, or with action {set, action, name}",
),
(
"ui.action.stop",
"Stop Playing/Recording",
"",
"{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}",
"{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it or the action ui.action.record named, at the place it named, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}",
),
(
"ui.action.insertStop",
@@ -1521,6 +1521,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{set: index, action: index, step: index} delete one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step). The steps after it keep whether they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error and changes nothing. Undo does not bring the step back. The panel's Delete Selection runs this for the selected step → {set, action, step}",
),
(
"ui.action.moveStep",
"Move Action Step",
"",
"{set: index, action: index, step: index, toSet?: index, toAction?: index, before: index} move one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step) into the action at toAction in toSet (default: the same set and action), before the step at index before there, or after its last step when before is its number of steps; before counts the steps as they are before the move. The step keeps its params and whether it is excluded, and the other steps keep theirs. A selected step stays selected, and a moved selected step makes its new action the selected one and its set the current one. A Record Stop dialog open on a step follows the step, and the steps that the recording in progress adds to either action stay after the steps that were before them. A step put back just before or just after itself does not move. A missing or invalid index, or a move that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. Undo does not move the step back. Dragging a step row in the panel runs this → {set, action, step, moved}",
),
(
"ui.batchDialog",
"Batch…",
+1
View File
@@ -30,6 +30,7 @@ craft-pdf = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
smallvec = "1"
siphasher = "1"
weezl = "0.2"
serde = { workspace = true }
# The XMP packet that File Info is read from (file_info.rs); the version usvg already uses.
+7 -5
View File
@@ -463,7 +463,9 @@ impl Exporter<'_> {
/// than an image?
pub(super) fn perceptual_spot(&self, g: &vectorcraft_color::GradientPaint, fill: bool) -> bool {
let gr = &g.gradient;
!gr.method.is_classic()
// Not one mixed around the hue wheel: its colors between two stops are not tints or
// mixes of the stops' inks, so it stays an image.
gr.method == vectorcraft_color::GradientMethod::Perceptual
&& gr.kind != GradientKind::Freeform
&& !(fill && gr.dither)
&& (self.one_spot_ink(gr).is_some() || self.device_n_inks_only(gr))
@@ -476,7 +478,7 @@ impl Exporter<'_> {
/// other gradients, and for an ink too light for lightness to tell its tints apart, which
/// [`Self::spot_stops`] then writes with the stops' own tints.
fn perceptual_spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
if g.method.is_classic() {
if g.method != vectorcraft_color::GradientMethod::Perceptual {
return None;
}
let ink = self.one_spot_ink(g)?;
@@ -494,7 +496,7 @@ impl Exporter<'_> {
let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points();
if from_stops && self.has_spot_stop(&g.gradient) {
self.warn(
"spot colors in gradients exported as images (freeform or dithered gradients, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors",
"spot colors in gradients exported as images (freeform or dithered gradients, ones mixed around the hue wheel, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors",
);
}
}
@@ -623,9 +625,9 @@ impl Exporter<'_> {
let field = freeform.then(|| g.freeform_on(b).model_field(spread_scale(b)));
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
if g.gradient.kind == GradientKind::Conic {
self.warn("conic gradients drawn as pixels (dithered fills, and Perceptual ones other than spot ink shadings), in a PDF/A file or with more than 97 stops are exported as images");
self.warn("conic gradients drawn as pixels (dithered fills, ones mixed around the hue wheel, and Perceptual ones other than spot ink shadings), in a PDF/A file or with more than 97 stops are exported as images");
} else if g.gradient.kind == GradientKind::Diamond {
self.warn("diamond gradients drawn as pixels (dithered fills, and Perceptual ones other than spot ink shadings), with translucent stops or without an extent are exported as images");
self.warn("diamond gradients drawn as pixels (dithered fills, ones mixed around the hue wheel, and Perceptual ones other than spot ink shadings), with translucent stops or without an extent are exported as images");
} else if !freeform {
self.warn("linear and radial gradients drawn as pixels are exported as images");
}
+31
View File
@@ -104,4 +104,35 @@ impl Builder<'_> {
}
true
}
/// A Type 3 glyph under an active ActualText span.
pub(super) fn actual_type3_glyph(&mut self, key: u128, at: Placement, look: Look, opacity: f32, advance: f64) -> bool {
self.flush_glyphs();
self.flush_type3();
if let (Some(stroke), Some(line)) = (&look.stroke, &mut self.text)
&& look.fill.is_none()
&& line.stroke_actual(key, at, stroke.clone(), look.overprint_stroke)
{
return true;
}
let pending = self.actual.pending.take();
let content = match pending.as_deref() {
Some(text) if !text.is_empty() => TextContent::Actual(text),
_ => TextContent::Pen,
};
let place = |line: &mut TextLine| line.push_content(&look, at, opacity, advance, content);
if !self.text.as_mut().is_some_and(place) {
self.flush_text();
let mut line = TextLine::new(at, opacity);
if !place(&mut line) {
self.actual.pending = pending;
return false;
}
self.text = Some(line);
}
if let Some(line) = &mut self.text {
line.has_type3 = true;
}
true
}
}
+178 -7
View File
@@ -12,6 +12,18 @@ use super::{
Upright, Walked, clipped, contains, fold_stop_opacity, function_paint, gradient_shape, is_rectangle, luminance, mask_spec, shading_gradient,
};
fn is_colour_face(face: &vectorcraft_text::FontFace) -> bool {
use skrifa::Tag;
use skrifa::raw::TableProvider;
let Ok(font) = skrifa::FontRef::new(face.file_data()) else {
return false;
};
font.colr().is_ok()
|| font.table_data(Tag::new(b"SVG ")).is_some()
|| font.table_data(Tag::new(b"CBDT")).is_some()
|| font.table_data(Tag::new(b"sbix")).is_some()
}
impl<'p> Builder<'p> {
pub(super) fn new(next: u64, colors: Colors<'p>, text_as: TextAs, route: bool) -> Self {
Self {
@@ -22,6 +34,7 @@ impl<'p> Builder<'p> {
blend: BlendMode::Normal,
page: Rect::ZERO,
glyphs: None,
type3_run: None,
text: None,
text_glyphs: vec![],
text_as,
@@ -102,6 +115,7 @@ impl<'p> Builder<'p> {
self.root_groups.clear();
self.blend = BlendMode::Normal;
self.glyphs = None;
self.type3_run = None;
self.text = None;
self.text_glyphs.clear();
self.mask = None;
@@ -221,13 +235,48 @@ impl<'p> Builder<'p> {
}
}
/// Finish the open glyph runs and the art drawn through the current soft mask.
/// Finish the open glyph runs, type 3 runs and the art drawn through the current soft mask.
pub(super) fn flush(&mut self) {
self.flush_glyphs();
self.flush_type3();
self.flush_text();
self.flush_masked();
}
/// Finish the open Type 3 font text run as a named group `<g>`.
pub(super) fn flush_type3(&mut self) {
if let Some(run) = self.type3_run.take()
&& !run.nodes.is_empty()
{
let Some(left) = self.objects_left.checked_sub(1) else {
self.warn(super::entry::TOO_MANY_OBJECTS);
return;
};
self.objects_left = left;
let mut g = Node::group(self.id(), run.nodes);
g.name = Some("<g>".to_string());
self.emit(Arc::new(g), run.group);
}
}
/// Add nodes from an interpreted Type 3 glyph to the current Type 3 run.
pub(super) fn push_type3_nodes(&mut self, nodes: Vec<Arc<Node>>) {
if nodes.is_empty() {
return;
}
let group = self.current_group();
if let Some(run) = &mut self.type3_run {
if run.group != group {
self.flush_type3();
self.type3_run = Some(super::state::Type3Run { nodes, group });
} else {
run.nodes.extend(nodes);
}
} else {
self.type3_run = Some(super::state::Type3Run { nodes, group });
}
}
/// The art drawn through the graphics state's soft mask so far, as one masked object.
fn flush_masked(&mut self) {
if self.masked.is_empty() {
@@ -391,7 +440,7 @@ impl<'p> Builder<'p> {
let Some(mut line) = self.text.take() else { return false };
let held = std::mem::take(&mut line.held);
// Type the file names itself (ActualText) is in reading order as it stands.
let checked = !glyphs.is_empty() && line.mapped();
let checked = !glyphs.is_empty() && line.mapped() && !line.has_type3;
let mut outlined = false;
if let Some((t, opacity, facts)) = line.finish() {
if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) {
@@ -509,23 +558,26 @@ impl<'p> Builder<'p> {
/// Read a soft mask, pattern cell or type 3 glyph (`read`) on its own: its art, in the
/// coordinates `page` has. `None` when nested too deeply.
fn nested(&mut self, page: Rect, read: impl FnOnce(&mut Self)) -> Option<Vec<Arc<Node>>> {
pub(super) fn nested(&mut self, page: Rect, blend: BlendMode, read: impl FnOnce(&mut Self)) -> Option<Vec<Arc<Node>>> {
if self.nested >= MAX_NESTED {
return None;
}
let outer_type3 = self.type3_run.take();
self.flush();
let saved = Saved {
stack: std::mem::replace(&mut self.stack, vec![Frame::new(FrameKind::Root)]),
root_groups: std::mem::take(&mut self.root_groups),
blend: std::mem::replace(&mut self.blend, BlendMode::Normal),
blend: std::mem::replace(&mut self.blend, blend),
mask: self.mask.take(),
page: std::mem::replace(&mut self.page, page),
type3_run: outer_type3,
};
self.nested += 1;
read(self);
let art = self.close_frames();
self.nested -= 1;
(self.stack, self.root_groups, self.blend, self.mask, self.page) = (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page);
(self.stack, self.root_groups, self.blend, self.mask, self.page, self.type3_run) =
(saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page, saved.type3_run);
Some(art)
}
@@ -537,7 +589,7 @@ impl<'p> Builder<'p> {
}
let page = self.page;
let saved = std::mem::replace(&mut self.white_images, m.mask_type() == MaskType::Alpha);
let art = self.nested(page, |b| m.interpret(b));
let art = self.nested(page, BlendMode::Normal, |b| m.interpret(b));
self.white_images = saved;
let spec = match art {
Some(art) => {
@@ -601,7 +653,7 @@ impl<'p> Builder<'p> {
// A swatch is shared with uses outside an alpha mask, and pattern paints keep their
// colours there: its images are read as they are.
let white = std::mem::take(&mut self.white_images);
let art = self.nested(Rect::ZERO, |b| {
let art = self.nested(Rect::ZERO, BlendMode::Normal, |b| {
// A cell whose content can't be decoded is empty.
let _ = t.interpret(b, Affine::IDENTITY, stroke);
});
@@ -799,6 +851,125 @@ impl<'p> Builder<'p> {
self.text_glyphs.push(GlyphRun { path, paint, opacity, stroke, scale, overprint });
}
fn type3_font_name(&mut self, key: u128) -> FontInfo {
if let Some(n) = self.font_names.get(&key) {
return n.clone();
}
let name = self.fonts.get(&key).map(|f| f.name.clone());
let ps = name.as_deref().map(|n| match n.split_once('+') {
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest.to_string(),
_ => n.to_string(),
});
let installed = ps.as_deref().and_then(|ps| vectorcraft_text::FontDb::global().find_postscript(ps));
let n = match (installed, name) {
(Some(face), _) => FontInfo { family: face.family.clone(), style: face.style.clone(), face: Some(face) },
(None, Some(name)) => {
let families = self.families.get_or_insert_with(Families::available);
let (family, style, found) = families.resolve(&name, None, false);
if !found && !family.is_empty() && !self.missing_fonts.contains(&family) {
self.missing_fonts.push(family.clone());
}
let face = vectorcraft_text::FontDb::global().face(&family, &style);
FontInfo { family, style, face }
}
(None, None) => {
let d = vectorcraft_doc::CharStyle::default();
FontInfo { family: d.font_family, style: d.font_style, face: None }
}
};
self.font_names.insert(key, n.clone());
n
}
/// Type 3 glyph as live type: added to the line being gathered if it has a ToUnicode mapping.
pub(super) fn type_type3_glyph(
&mut self,
t3: &craft_pdf::interpret::font::Type3Glyph<'_>,
m: Affine,
scale: f64,
paint: Option<&craft_pdf::interpret::Paint<'_>>,
stroke: Option<&StrokeProps>,
overprint: bool,
) -> bool {
if self.text_as != TextAs::Text || self.nested > 0 {
return false;
}
let t3_key = craft_pdf::interpret::CacheKey::cache_key(t3);
let Some(&(font_key, has_to_unicode)) = self.scan.type3_cache_map.get(&t3_key) else {
return false;
};
let (is_type3, to_unicode, is_colour) = match self.fonts.get(&font_key) {
Some(f) => (f.is_type3, has_to_unicode || f.to_unicode, f.is_colour),
None => (true, has_to_unicode, false),
};
let authored = self.actual.active();
if !is_type3 || (!to_unicode && !authored) || is_colour {
return false;
}
let unicode = t3.as_unicode().filter(|u| !super::cid::replacement(u));
if !authored && unicode.is_none() {
return false;
}
let text: String = match unicode {
Some(BfString::Char(c)) => c.to_string(),
Some(BfString::String(s)) => s,
None => String::new(),
};
let Some(at) = Placement::of(m) else { return false };
if !authored && (text.is_empty() || text.chars().any(|c| c.is_control())) {
return false;
}
let width = t3.advance_width().filter(|w| w.is_finite());
let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
let info = self.type3_font_name(font_key);
if info.face.as_ref().is_some_and(|f| is_colour_face(f)) {
return false;
}
let (paint, opacity) = match paint {
Some(p) => {
let (paint, opacity) = self.paint(p, stroke.is_some(), self.walked(true));
(Some(paint), opacity)
}
None => (None, 1.0),
};
let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale)));
let look = Look {
font: font_key,
family: info.family,
style: info.style,
version: None,
size: at.size,
h_scale: at.h_scale,
overprint_fill: stroke.is_none() && overprint,
overprint_stroke: stroke.is_some() && overprint,
fill: if stroke.is_none() { paint } else { None },
stroke,
};
if authored {
return self.actual_type3_glyph(font_key, at, look, opacity, advance);
}
let text = visual_glyph_text(text);
let place = |line: &mut TextLine| line.push(&look, at, opacity, advance, &text);
self.flush_glyphs();
self.flush_type3();
if let (Some(s), Some(line)) = (&look.stroke, &mut self.text)
&& look.fill.is_none()
&& line.stroke_last(font_key, at, s.clone(), overprint)
{
return true;
}
if !self.text.as_mut().is_some_and(place) {
self.flush_text();
let mut line = TextLine::new(at, opacity);
place(&mut line);
self.text = Some(line);
}
if let Some(line) = &mut self.text {
line.has_type3 = true;
}
true
}
/// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps
/// its outline (no Unicode text, mirrored, not type mode, or in a span whose ActualText is
/// empty). No `paint`: invisible text,
+17 -5
View File
@@ -33,6 +33,7 @@ impl<'a> Device<'a> for Builder<'_> {
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &craft_pdf::interpret::Paint<'a>, draw_mode: &PathDrawMode) {
self.flush_glyphs();
self.flush_type3();
let walked = self.walked(false);
if self.hold_glyph_stroke(path, transform, paint, draw_mode, walked) {
return;
@@ -174,7 +175,7 @@ impl<'a> Device<'a> for Builder<'_> {
GlyphDrawMode::Invisible => {
let typed = match glyph {
Glyph::Outline(o) => self.type_glyph(o, transform * glyph_transform, mean_scale(transform), None, None, false),
Glyph::Type3(_) => false,
Glyph::Type3(t3) => self.type_type3_glyph(t3, transform * glyph_transform, mean_scale(transform), None, None, false),
};
if !typed {
self.warn(INVISIBLE_TEXT_NOTE);
@@ -186,6 +187,7 @@ impl<'a> Device<'a> for Builder<'_> {
};
match glyph {
Glyph::Outline(o) => {
self.flush_type3();
let scale = mean_scale(transform);
let overprint = self.glyph_overprint(stroke.is_some());
if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) {
@@ -216,16 +218,26 @@ impl<'a> Device<'a> for Builder<'_> {
if self.nested >= MAX_NESTED {
return;
}
self.flush();
self.nested += 1;
t3.interpret(self, transform, glyph_transform, paint);
self.nested -= 1;
let scale = mean_scale(transform);
let overprint = self.glyph_overprint(stroke.is_some());
if self.type_type3_glyph(t3, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) {
return;
}
self.flush_glyphs();
self.flush_text();
let art = self.nested(self.page, self.blend, |b| {
t3.interpret(b, transform, glyph_transform, paint);
});
if let Some(nodes) = art {
self.push_type3_nodes(nodes);
}
}
}
}
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
self.flush_glyphs();
self.flush_type3();
self.flush_text();
match image {
Image::Raster(r) => {
+9
View File
@@ -62,6 +62,13 @@ pub(super) struct GlyphRun {
/// swatch made for it.
pub(super) type PatternReadings = (u32, Vec<(Vec<Paint>, String)>);
/// A run of Type 3 glyph graphics preserved inside a named group.
#[derive(Clone, Debug)]
pub(super) struct Type3Run {
pub(super) nodes: Vec<Arc<Node>>,
pub(super) group: Option<usize>,
}
/// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back.
pub(super) struct Saved {
pub(super) stack: Vec<Frame>,
@@ -69,6 +76,7 @@ pub(super) struct Saved {
pub(super) blend: BlendMode,
pub(super) mask: Option<Arc<MaskSpec>>,
pub(super) page: Rect,
pub(super) type3_run: Option<Type3Run>,
}
pub(super) struct Builder<'p> {
@@ -80,6 +88,7 @@ pub(super) struct Builder<'p> {
pub(super) blend: BlendMode,
pub(super) page: Rect,
pub(super) glyphs: Option<GlyphRun>,
pub(super) type3_run: Option<Type3Run>,
pub(super) text: Option<TextLine>,
/// The glyphs of the open line of type (`text`) that show, each as the outline it would be
/// kept as: what the line becomes when its type wouldn't draw as the file does
+77 -2
View File
@@ -13,6 +13,8 @@
use std::collections::{HashMap, HashSet};
use siphasher::sip128::{Hasher128, SipHasher13};
use crate::import_color::items;
use craft_pdf::interpret::CacheKey;
use craft_pdf::syntax::Pdf;
@@ -250,6 +252,13 @@ fn pdf_doc_char(c: u8) -> char {
mapped.copied().unwrap_or(char::from(c))
}
/// Calculate a 128-bit siphash of a value, matching hayro-interpret's `hash128`.
pub(crate) fn hash128<T: std::hash::Hash + ?Sized>(value: &T) -> u128 {
let mut state = SipHasher13::new();
value.hash(&mut state);
state.finish128().as_u128()
}
/// One page as the interpreter walks it.
#[derive(Default)]
pub(crate) struct Scan {
@@ -282,6 +291,8 @@ pub(crate) struct Scan {
/// fills and of its strokes (indices into [`Self::inks`]), where all of its text shown in an
/// ink is shown in the same one.
text_inks: Changes<[Option<u32>; 2]>,
/// Type 3 glyph cache key -> (font cache key, whether font has /ToUnicode).
pub type3_cache_map: HashMap<u128, (u128, bool)>,
}
impl Scan {
@@ -642,9 +653,58 @@ impl Walker<'_, '_> {
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
&& let Some(name) = font_name(&f)
{
self.fonts.entry(f.cache_key()).or_insert_with(|| {
let is_type3 = f.get::<Name<'_>>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3");
let to_unicode = f.get::<Object<'_>>(b"ToUnicode").is_some();
let f_key = f.cache_key();
const MAX_TYPE3_CACHE_MAP: usize = 1 << 20;
if is_type3 && self.out.type3_cache_map.len().saturating_add(257) <= MAX_TYPE3_CACHE_MAP {
for gid in 0..=256u32 {
let hash = hash128(&(f_key, skrifa::GlyphId::new(gid)));
self.out.type3_cache_map.insert(hash, (f_key, to_unicode));
}
}
let is_colour = if is_type3 {
let mut colour = false;
let mut seen_streams = HashSet::new();
let mut decoded_bytes = 0usize;
const MAX_CHARPROC_BYTES: usize = 16 << 20;
if let Some(char_procs) = f.get::<Dict<'_>>(b"CharProcs") {
for key in char_procs.keys() {
if let Some(stream) = char_procs.get::<Stream<'_>>(key.as_ref()) {
if !seen_streams.insert(stream.cache_key()) {
continue;
}
if decoded_bytes >= MAX_CHARPROC_BYTES {
colour = true;
break;
}
if let Ok(data) = stream.decoded_within(MAX_CHARPROC_BYTES.saturating_sub(decoded_bytes)) {
decoded_bytes = decoded_bytes.saturating_add(data.len());
let mut iter = TypedIter::new(data.as_ref());
while let Some(op) = iter.next() {
match op {
TypedInstruction::ColorGlyph(_) => {
colour = true;
break;
}
TypedInstruction::ShapeGlyph(_) => break,
_ => {}
}
}
if colour {
break;
}
}
}
}
}
colour
} else {
false
};
self.fonts.entry(f_key).or_insert_with(|| {
let embedded = embeds_glyphs(&f);
FontNote { name, embedded, vertical: vertical_metrics(&f, embedded) }
FontNote { name, embedded, vertical: vertical_metrics(&f, embedded), is_type3, to_unicode, is_colour }
});
}
}
@@ -879,6 +939,12 @@ pub(crate) struct FontNote {
pub embedded: bool,
/// A composite font written vertically: its descendant's metrics.
pub vertical: Option<Vertical>,
/// Whether this is a Type 3 font.
pub is_type3: bool,
/// Whether this font specifies a /ToUnicode map.
pub to_unicode: bool,
/// Whether this Type 3 font contains coloured glyph descriptions (d0/ColorGlyph).
pub is_colour: bool,
}
/// The most CIDs a vertical font's widths are read for (the most a CID-keyed font has).
@@ -1020,6 +1086,15 @@ fn cid_ranges(a: &Array<'_>, per: usize, mut each: impl FnMut(u32, &[f64])) {
/// descendant CIDFont's `BaseFont` is taken, the font program's own name (§9.7.4.1); without one,
/// the `BaseFont` without the CMap's name on its end.
fn font_name(f: &Dict<'_>) -> Option<String> {
if f.get::<Name<'_>>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3") {
if let Some(base) = f.get::<Name<'_>>(b"BaseFont") {
return Some(String::from_utf8_lossy(base.as_ref()).into_owned());
}
if let Some(name) = f.get::<Name<'_>>(b"Name") {
return Some(String::from_utf8_lossy(name.as_ref()).into_owned());
}
return Some("Type3".to_string());
}
let base = String::from_utf8_lossy(f.get::<Name<'_>>(b"BaseFont")?.as_ref()).into_owned();
if f.get::<Name<'_>>(b"Subtype").is_none_or(|s| s.as_ref() != b"Type0") {
return Some(base);
+3
View File
@@ -218,6 +218,8 @@ pub(crate) struct TextLine {
/// The strokes of its glyphs' outlines drawn between them, one per glyph with ink so far
/// (written right after the line).
pub held: Vec<Node>,
/// Whether this line holds Type 3 font glyphs.
pub has_type3: bool,
}
/// Where the file put a glyph: the glyph's index in the line, and its advance in the file's font
@@ -266,6 +268,7 @@ impl TextLine {
drawn_spaces: vec![],
drawn_space: None,
held: vec![],
has_type3: false,
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
//! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes
//! as their brush art), opacity and blend modes (transparency groups),
//! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and
//! freeform and Perceptual gradients and dithered gradient fills as images at the raster
//! freeform and Perceptual gradients, gradients mixed around the hue wheel and dithered gradient fills as images at the raster
//! effects resolution (clipped to what they paint), embedded images (resampled and
//! compressed as the Compression settings say) and text as outlined glyph paths, or as real
//! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and
+20
View File
@@ -687,6 +687,26 @@ fn dithered_and_perceptual_gradients_of_cmyk_stops_are_written_as_cmyk_images()
assert_ne!(w[0].space, "DeviceCMYK", "converted to RGB");
}
/// The colors between two stops mixed around the hue wheel are not mixes of the stops' inks: a
/// gradient of CMYK stops mixed that way is an RGB image, in a linear, radial, conic or diamond
/// gradient.
#[test]
fn hue_wheel_gradients_of_cmyk_stops_are_written_as_rgb_images() {
for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
for mode in [ColorMode::Cmyk, ColorMode::Rgb] {
for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] {
let stops = vec![GradientStop::new(0.0, Color::cmyk(0.0, 1.0, 1.0, 0.0)), GradientStop::new(1.0, Color::cmyk(1.0, 1.0, 0.0, 0.0))];
let d = gradient_doc(mode, Gradient { method, ..Gradient::new(kind, stops) });
let out = export_with_report(&d, &opts(json!({}))).unwrap();
let w = written(&out.bytes);
assert_eq!(w.len(), 1, "{method:?} {mode:?} {kind:?}: one opaque image: {w:?}");
assert_eq!(w[0].space, "DeviceRGB", "{method:?} {mode:?} {kind:?}");
assert!(out.warnings.iter().any(|m| m.contains("exported as images")), "{method:?} {mode:?} {kind:?}: {:?}", out.warnings);
}
}
}
}
/// A stop in another model, or linked to a spot swatch, keeps the RGB image.
#[test]
fn gradients_with_a_stop_that_is_not_process_cmyk_stay_rgb_images() {
+88 -2
View File
@@ -112,6 +112,25 @@ fn type3(first: usize, upm: f64) -> Vec<String> {
vec![font, a, b]
}
/// A Type 3 font with a ToUnicode CMap.
fn type3_with_to_unicode(first: usize, upm: f64) -> Vec<String> {
let s = 1.0 / upm;
let (h, w) = (upm * 0.5, upm);
let a = stream("", &format!("{h} 0 0 0 {h} {upm} d1 0 0 {} {} re f", h * 0.8, upm * 0.7));
let b = stream("", &format!("{w} 0 0 0 {w} {upm} d1 0 0 {} {} re f", w * 0.9, upm * 0.7));
let to_u = stream(
"",
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /T3-UCS def /CMapType 2 def 1 begincodespacerange <00> <FF> endcodespacerange 2 beginbfchar\n<61> <0061>\n<62> <0062>\nendbfchar endcmap CMapName currentdict /CMap defineresource pop end end",
);
let font = format!(
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 {w} {w}] /FontMatrix [{s} 0 0 {s} 0 0] /CharProcs << /a {} 0 R /b {} 0 R >> /Encoding << /Type /Encoding /Differences [97 /a /b] >> /FirstChar 97 /LastChar 98 /Widths [{h} {w}] /ToUnicode {} 0 R /Resources << >> >>",
first + 1,
first + 2,
first + 3
);
vec![font, a, b, to_u]
}
/// A 300 × 200 pt page drawing `content`, plus `entries` in the page dictionary, with fonts:
/// - `/F1`: Source Sans 3, embedded, a simple TrueType font;
/// - `/F4`: Source Sans 3 as a Type 0 font, Identity-H (two-byte glyph ids, see [`gids`]);
@@ -146,9 +165,13 @@ fn sized_page(w: f64, h: f64, content: &str, entries: &str) -> Vec<u8> {
let t3 = n + objs.len();
objs.extend(type3(t3, 1000.0));
objs.extend(type3(t3 + 3, 100.0));
let t3u = t3 + 6;
objs.extend(type3_with_to_unicode(t3u, 1000.0));
let t30u = t3u + 4;
objs.extend(type3_with_to_unicode(t30u, 100.0));
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
let resources = format!(
"/Font << /F1 {} 0 R /F4 {} 0 R /F5 {} 0 R /F6 {} 0 R /T3 {t3} 0 R /T30 {} 0 R /HV << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>",
"/Font << /F1 {} 0 R /F4 {} 0 R /F5 {} 0 R /F6 {} 0 R /T3 {t3} 0 R /T30 {} 0 R /T3U {t3u} 0 R /T30U {t30u} 0 R /HV << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>",
n + 2,
n + 5,
n + 6,
@@ -416,7 +439,16 @@ fn vertical_glyphs_narrower_than_an_em_stay_on_their_column() {
fn type3_glyphs_follow_the_font_matrix() {
for font in ["T3", "T30"] {
let d = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Text);
assert!(texts(&d).is_empty(), "Type 3 text keeps its outlines");
assert!(texts(&d).is_empty(), "Type 3 text without ToUnicode keeps its outlines");
let mut groups = 0;
d.walk(|n| {
if let NodeKind::Group { .. } = &n.kind
&& n.name.as_deref() == Some("<g>")
{
groups += 1;
}
});
assert_eq!(groups, 1, "{font}: Type 3 text run must be preserved inside named group <g>");
let mut boxes = contours(&d);
boxes.sort_by(|a, b| a.x0.total_cmp(&b.x0));
// `a` at x 20 (0.4 em = 8 pt wide), `b` after its half-em advance; 0.7 em = 14 pt tall,
@@ -429,6 +461,60 @@ fn type3_glyphs_follow_the_font_matrix() {
}
}
/// Type 3 fonts with ToUnicode import as live text in Text mode, and as named groups <g> in Paths mode.
/// Tests both 1000 UPM (/T3U) and 100 UPM (/T30U) to verify FontMatrix scaling.
#[test]
fn type3_font_with_to_unicode_imports_as_live_text() {
for font in ["T3U", "T30U"] {
let d_text = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Text);
let t = texts(&d_text);
assert_eq!(t.len(), 1, "{font}: Type 3 with ToUnicode should import as live text");
assert_eq!(t[0].plain_text(), "ab");
let run = &t[0].runs[0];
assert!((run.style.size - 20.0).abs() < 1e-3, "{font}: font size should be 20 pt, got {}", run.style.size);
let d_paths = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Outlines);
assert!(texts(&d_paths).is_empty(), "In Outlines mode, Type 3 text should be outlines");
let mut groups = 0;
d_paths.walk(|n| {
if let NodeKind::Group { .. } = &n.kind
&& n.name.as_deref() == Some("<g>")
{
groups += 1;
}
});
assert_eq!(groups, 1, "In Paths mode, Type 3 text should be grouped in <g>");
}
}
/// Invisible Type 3 text (mode 3) with ToUnicode imports as searchable text.
#[test]
fn type3_invisible_with_to_unicode_imports_as_searchable_text() {
let d = open_as(&page("BT /T3U 20 Tf 3 Tr 20 50 Td (ab) Tj ET", ""), TextAs::Text);
let t = texts(&d);
assert_eq!(t.len(), 1, "Invisible Type 3 text with ToUnicode imports as searchable text");
assert_eq!(t[0].plain_text(), "ab");
for r in &t[0].runs {
assert!(r.style.fill.is_none(), "Invisible text has no fill: {:?}", r.style.fill);
assert!(!r.style.has_stroke(), "Invisible text has no stroke");
}
}
/// Type 3 text with ActualText replacement honors authored text or suppression.
#[test]
fn type3_with_actual_text() {
// Authored replacement "XYZ" over Type 3 glyphs (ab)
let d = open_as(&page("/Span << /ActualText (XYZ) >> BDC BT /T3U 20 Tf 20 50 Td (ab) Tj ET EMC", ""), TextAs::Text);
let t = texts(&d);
assert_eq!(t.len(), 1, "Type 3 with ActualText should import as live text");
assert_eq!(t[0].plain_text(), "XYZ");
// Empty ActualText suppresses mapped text
let d_empty = open_as(&page("/Span << /ActualText () >> BDC BT /T3U 20 Tf 20 50 Td (ab) Tj ET EMC", ""), TextAs::Text);
let t_empty = texts(&d_empty);
assert!(t_empty.is_empty(), "Empty ActualText should suppress text");
}
/// A font that isn't embedded or installed is set in a stand-in: the line still starts where
/// the file starts it, on its baseline, and is as long as the file's.
#[test]
+2 -2
View File
@@ -529,9 +529,9 @@ impl Writer<'_> {
self.warn(if freeform {
"freeform gradients are written as images clipped to their shapes"
} else if conic {
"conic gradients with translucent stops, dither or the Perceptual method are written as images clipped to their shapes"
"conic gradients with translucent stops, dither or a method other than Classic are written as images clipped to their shapes"
} else if g.gradient.kind == GradientKind::Diamond {
"diamond gradients with translucent stops, dither, the Perceptual method or no extent are written as images clipped to their shapes"
"diamond gradients with translucent stops, dither, a method other than Classic or no extent are written as images clipped to their shapes"
} else {
"linear and radial gradients drawn as pixels are written as images clipped to their shapes"
});
+4
View File
@@ -47,6 +47,10 @@
//! rasterised in SVG
//! (<https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html>).
//! On characters they are written as linear gradients through stops on the perceptual curve.
//! * **Gradients mixed around the hue wheel** (Hue Clockwise and Hue Counterclockwise,
//! VectorCraft's own methods) fill or stroke objects as an `<image>` of the gradient too, at the
//! document's raster effects resolution. On characters they are written as gradients through
//! stops on their way around the wheel.
//! * **Symbol instances** the shared `<symbol>` would paint differently from the canvas (stained,
//! scaled with strokes, turned or scaled with effects, brushes or live objects, or holding
//! pattern paints or unlinked masks, which stay on the page) are written as their own art.
+33
View File
@@ -214,6 +214,39 @@ fn perceptual_gradients_are_images_and_classic_ones_stay_vector() {
assert!(!svg.contains("<image ") && svg.matches("<linearGradient").count() == 2, "{svg}");
}
/// SVG gradients mix linearly: a fill and a stroke mixed around the hue wheel are written as
/// images of the gradient, as the canvas mixes them. Red to blue passes through green one way
/// around the wheel and through magenta the other way.
#[test]
fn hue_wheel_gradients_are_images_of_the_turn_around_the_wheel() {
use vectorcraft_color::GradientMethod;
let shape = shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0));
for (method, middle) in [(GradientMethod::HueCounterclockwise, [0, 255, 0]), (GradientMethod::HueClockwise, [255, 0, 255])] {
let stops = vec![
vectorcraft_color::GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)),
vectorcraft_color::GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0)),
];
let mut g = Gradient::new(GradientKind::Linear, stops);
g.method = method;
let p = Paint::Gradient(Box::new(GradientPaint::new(g)));
let mut d = doc_with(Node::path(NodeId(0), shape.clone(), Appearance::basic(p.clone(), p, 4.0)));
d.raster_effects_ppi = 144.0;
let o = export_full(&d, &ExportOptions::default(), None);
let svg = &o.svg;
assert_eq!(svg.matches("<image ").count(), 2, "{method:?}: the fill and the stroke: {svg}");
assert!(!svg.contains("<linearGradient"), "{method:?}: {svg}");
assert!(o.warnings.iter().any(|w| w.contains("drawn as pixels are written as images")), "{method:?}: {:?}", o.warnings);
let png = png_of(svg);
assert_eq!(png.dimensions(), (320, 240));
let [r, gr, b, a] = png.get_pixel(160, 120).0.map(i32::from);
assert_eq!(a, 255);
let off = [r, gr, b].iter().zip(middle).map(|(x, y)| (x - y).abs()).max().unwrap_or(0);
assert!(off <= 4, "{method:?}: the middle is {:?}, not {middle:?}", [r, gr, b]);
let back = import(svg).unwrap();
assert_similar(&render_artboard(&d), &render_artboard(&back), 12.0, 0.01);
}
}
/// A dithered Perceptual gradient: its fill is an image of the perceptual mix with dither's noise
/// (within a colour level or so of the mix, and not the undithered pixels), its stroke an image of
/// the perceptual mix without noise (dither applies to fills only).
+71
View File
@@ -85,6 +85,41 @@ pub fn temp_dir(tag: &str) -> std::path::PathBuf {
dir
}
/// A test's own temporary directory, removed with everything in it when dropped, also when the
/// test panics. [`temp_dir`] makes it, with the same checks, under a tag no other `TempDir` in this
/// process uses, so tests running alongside never share one. A removal that fails (a file still
/// held open) is ignored; the next process with the same id empties the directory before use.
pub struct TempDir(std::path::PathBuf);
impl TempDir {
/// A new empty directory `vectorcraft-testkit-<tag>-<n>-<process id>` in the system temporary
/// folder, where `n` counts the `TempDir`s made in this process.
pub fn new(tag: &str) -> Self {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Self(temp_dir(&format!("{tag}-{n}")))
}
}
impl std::ops::Deref for TempDir {
type Target = std::path::PathBuf;
fn deref(&self) -> &std::path::PathBuf {
&self.0
}
}
impl AsRef<std::path::Path> for TempDir {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// How many shapes an SVG draws: its `<path>` elements and the basic shapes SVG export writes
/// simple shapes as (`<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polyline>`, `<polygon>`).
pub fn svg_shapes(svg: &str) -> usize {
@@ -134,4 +169,40 @@ mod tests {
assert!(super::temp_dir(tag).join("mine.txt").is_file(), "a later call keeps this process's files");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_test_dir_is_removed_with_its_contents_when_dropped() {
let dir = super::TempDir::new("dropped");
let path = dir.to_path_buf();
std::fs::create_dir_all(path.join("nested")).unwrap();
std::fs::write(path.join("nested").join("a.txt"), b"a").unwrap();
drop(dir);
assert!(!path.exists(), "{} is left", path.display());
}
#[test]
fn a_test_dir_is_removed_when_its_test_panics() {
let mut path = None;
let r = super::catch_quiet::<()>(|| {
let dir = super::TempDir::new("panicked");
std::fs::write(dir.join("a.txt"), b"a").unwrap();
path = Some(dir.to_path_buf());
panic!("the test fails");
});
assert_eq!(r.err().as_deref(), Some("the test fails"));
let path = path.unwrap();
assert!(!path.exists(), "{} is left", path.display());
}
#[test]
fn test_dirs_with_one_tag_are_separate_and_private() {
let (a, b) = (super::TempDir::new("shared-tag"), super::TempDir::new("shared-tag"));
assert_ne!(*a, *b);
std::fs::write(a.join("a.txt"), b"a").unwrap();
assert_eq!(std::fs::read_dir(&*b).unwrap().count(), 0, "one test's files are not in another's directory");
#[cfg(unix)]
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&*a).unwrap().permissions()) & 0o777, 0o700);
drop(a);
assert!(b.is_dir(), "dropping one leaves the other");
}
}
+27 -5
View File
@@ -480,6 +480,8 @@ pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0);
/// Select Strokes off, Alt-click selects the face under the pointer as a click does and a marquee
/// takes no edges; with Select Fills off, a click selects the edge nearest the pointer as Alt-click
/// does and a marquee takes no faces; with both off, a click or a marquee selects no face or edge.
/// The pointer shows what a click at that point selects ([`LivePaintSelectionTool::pick_at`]): a
/// face, an edge, or nothing. From the press to the release it keeps the look it had at the press.
pub struct LivePaintSelectionTool {
pub(super) cache: RegionCache,
pub(super) hover: LpHover,
@@ -502,6 +504,8 @@ struct LpDrag {
shift: bool,
/// The face or edge under the press, which a click selects (or with Shift toggles).
hit: Option<NodeId>,
/// The cursor at the press, which the drag keeps.
cursor: Cursor,
}
impl LpDrag {
@@ -538,6 +542,22 @@ impl LivePaintSelectionTool {
(false, false) => None,
}
}
/// The face or edge a click at `p` selects, Alt held or not, with its cursor: the visible part of
/// the kind [`LivePaintSelectionTool::picks_edge`] asks for, of a Live Paint group the tool takes
/// ([`lp_part_at`], [`lp_selectable`]), and [`Cursor::LivePaintSelectFace`] or
/// [`Cursor::LivePaintSelectEdge`]; None and [`Cursor::LivePaintSelectOutside`] where a click
/// selects no face or edge.
fn pick_at(&self, cx: &ToolContext, p: Point, alt: bool) -> (Option<NodeId>, Cursor) {
let Some(edge) = self.picks_edge(alt) else { return (None, Cursor::LivePaintSelectOutside) };
let hit = lp_part_at(cx.doc, p, cx.tol(4.0), edge, &|id| lp_selectable(cx, id)).and_then(|(_, part)| part).map(|n| n.id);
let cursor = match (hit, edge) {
(None, _) => Cursor::LivePaintSelectOutside,
(Some(_), true) => Cursor::LivePaintSelectEdge,
(Some(_), false) => Cursor::LivePaintSelectFace,
};
(hit, cursor)
}
}
/// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and
@@ -592,9 +612,8 @@ impl Tool for LivePaintSelectionTool {
vec![]
}
PointerKind::Down => {
let part = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e, &|id| lp_selectable(cx, id)));
let hit = part.and_then(|(_, part)| part).map(|n| n.id);
self.drag = Some(LpDrag { start: p, cur: p, shift: ev.mods.shift, hit });
let (hit, cursor) = self.pick_at(cx, p, ev.mods.alt);
self.drag = Some(LpDrag { start: p, cur: p, shift: ev.mods.shift, hit, cursor });
vec![]
}
PointerKind::Drag => {
@@ -652,8 +671,11 @@ impl Tool for LivePaintSelectionTool {
lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, self.width as f32)
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Arrow
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
match &self.drag {
Some(d) => d.cursor,
None => self.pick_at(cx, p, m.alt).1,
}
}
fn options(&self) -> Value {

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