mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 23:24:12 -04:00
Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae82ed6500 | ||
|
|
40dd19d17a | ||
|
|
8148d024ea | ||
|
|
68c9d440ab | ||
|
|
bde7140396 | ||
|
|
cdb0bbe315 | ||
|
|
32e22891d6 | ||
|
|
69a90f6033 | ||
|
|
14de645441 | ||
|
|
b9c3dd4519 | ||
|
|
b9d370aeff | ||
|
|
b9b681c748 | ||
|
|
78330cfa2f | ||
|
|
187a75ca3f | ||
|
|
13df37f9d1 | ||
|
|
8f71c94473 | ||
|
|
8511d0321b | ||
|
|
1e87e85ab7 | ||
|
|
c5d2297ef1 | ||
|
|
bf785840e5 | ||
|
|
3fc78187ec | ||
|
|
5a3e833ec8 | ||
|
|
5bdd9d539f | ||
|
|
a285efda46 | ||
|
|
d5e44b321e | ||
|
|
6902e1ab3f | ||
|
|
f0bb2760c9 | ||
|
|
a71f8eb56a | ||
|
|
a8e45f0e2d | ||
|
|
51f8582c25 | ||
|
|
9122ed0a7e | ||
|
|
6920adf092 | ||
|
|
1eb9bea82a | ||
|
|
d55b4f77cf | ||
|
|
8b8c164e3f | ||
|
|
4ed644d98d | ||
|
|
26644850fc | ||
|
|
8735dbc6fd | ||
|
|
c266083acd | ||
|
|
a3dc3ca68e | ||
|
|
d82eafce7a | ||
|
|
f3ff11ff45 | ||
|
|
cb33850e9c | ||
|
|
a1372d498a | ||
|
|
b9cc13beff | ||
|
|
b5cadb9412 | ||
|
|
981f28469e | ||
|
|
0072594442 | ||
|
|
d27a356007 | ||
|
|
02a0ad2541 | ||
|
|
71eae2559d | ||
|
|
c61fd7085f | ||
|
|
e9c6c53b2a | ||
|
|
8aef393aab | ||
|
|
c94f93ddc0 | ||
|
|
8b8607db8c | ||
|
|
c600673648 | ||
|
|
63fc56f327 | ||
|
|
da90552ee5 | ||
|
|
d60f3b12ff | ||
|
|
92fa9cbab2 | ||
|
|
8d185babb4 | ||
|
|
0e3ce7744b | ||
|
|
d5a3422dcd | ||
|
|
1d19d84493 | ||
|
|
1686e6748e | ||
|
|
d67290253d | ||
|
|
4064ef8656 | ||
|
|
ffd343f058 | ||
|
|
938fb5bce6 | ||
|
|
f5241fabd0 | ||
|
|
b81d8ccf6c | ||
|
|
ce45d2a79c | ||
|
|
2469a77ad4 | ||
|
|
fd28438c57 | ||
|
|
27b5eb7100 | ||
|
|
38314db4ba | ||
|
|
1aaa81f56b | ||
|
|
d5561d6e02 | ||
|
|
0746519517 | ||
|
|
8fead43ed2 | ||
|
|
4dcd0b6caa | ||
|
|
285a3da81a | ||
|
|
4f30e52368 | ||
|
|
d3751cc07b | ||
|
|
7cd6b5d983 | ||
|
|
8875b3fbca | ||
|
|
2108c3cc99 | ||
|
|
848e7a296a | ||
|
|
101fd55fa0 | ||
|
|
6939a481a4 | ||
|
|
b28e8490c2 | ||
|
|
a6012d86e8 | ||
|
|
27230233cc |
No files matched your search
+2
-1
@@ -9,7 +9,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (about 75 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -134,6 +134,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-17 | About 75 community PRs landed in three batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). |
|
||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
|
||||
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
|
||||
|
||||
@@ -159,6 +159,15 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// A headless session that remembers what the user keeps between runs: the PDF presets saved and
|
||||
/// the ICC profiles loaded (#2168, #2174), in the folders next to the preferences that the desktop
|
||||
/// app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there.
|
||||
fn with_stores(mut h: Headless) -> Headless {
|
||||
prefs_dir::attach_profile_store(&mut h.session);
|
||||
prefs_dir::attach_pdf_preset_store(&mut h.session);
|
||||
h
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut connect: Option<String> = None;
|
||||
let mut headless = false;
|
||||
@@ -198,14 +207,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
|
||||
} else if headless {
|
||||
Box::new(Headless::with_document())
|
||||
Box::new(with_stores(Headless::with_document()))
|
||||
} else if let Some(addr) = connect {
|
||||
// Explicit address: fail loudly if the app isn't there.
|
||||
Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?)
|
||||
} else {
|
||||
match Remote::connect(DEFAULT_ADDR) {
|
||||
Ok(r) => Box::new(r),
|
||||
Err(_) => Box::new(Headless::with_document()),
|
||||
Err(_) => Box::new(with_stores(Headless::with_document())),
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
|
||||
@@ -213,8 +222,8 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
|
||||
vectorcraft_mcp::logging::install();
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
// The server reads and writes from threads of its own, so it takes the handles, not locks held here.
|
||||
Server::new(backend).serve(std::io::BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn commands() -> Result<(), String> {
|
||||
@@ -327,7 +336,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut h = Headless::new();
|
||||
let mut h = with_stores(Headless::new());
|
||||
let mut out = std::io::stdout().lock();
|
||||
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
|
||||
// steps (an export…) still run, unprinted.
|
||||
|
||||
@@ -367,3 +367,97 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
|
||||
/// A fresh settings folder for one test (`VECTORCRAFT_CONFIG_DIR`), so no run reads or writes the
|
||||
/// developer's real profile.
|
||||
fn config_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = tmp(&format!("config-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`;
|
||||
/// the result of each step.
|
||||
fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec<Value> {
|
||||
let mut cmd = Command::new(BIN);
|
||||
cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS");
|
||||
for (id, params) in steps {
|
||||
cmd.args(["--cmd", id, "--params", ¶ms.to_string()]);
|
||||
}
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()["result"].clone()).collect()
|
||||
}
|
||||
|
||||
/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a
|
||||
/// name of the same length).
|
||||
fn icc_file_named(path: &std::path::Path, name: &str) {
|
||||
let builtin = "Display P3";
|
||||
assert_eq!(builtin.len(), name.len());
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
let mut bytes = vectorcraft_color::cms::icc_bytes(builtin).unwrap().to_vec();
|
||||
let (from, to) = (utf16(builtin), utf16(name));
|
||||
let at = bytes.windows(from.len()).position(|w| w == from).expect("the description in the profile");
|
||||
bytes[at..at + to.len()].copy_from_slice(&to);
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn profile_names(settings: &Value) -> Vec<&str> {
|
||||
settings["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect()
|
||||
}
|
||||
|
||||
/// #2168, #2174: the PDF presets saved and the ICC profiles loaded in one headless run are there in
|
||||
/// the next run, and gone again once deleted.
|
||||
#[test]
|
||||
fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() {
|
||||
let config = config_dir("restart");
|
||||
let icc = tmp("studio-p3.icc");
|
||||
icc_file_named(&icc, "StudioRGB3");
|
||||
let r = run_in(
|
||||
&config,
|
||||
&[
|
||||
("pdf.preset.save", json!({"name": "Press", "description": "for the printer"})),
|
||||
("color.loadProfile", json!({"path": icc.to_str().unwrap()})),
|
||||
],
|
||||
);
|
||||
assert_eq!(r[1]["name"], "StudioRGB3");
|
||||
|
||||
let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]);
|
||||
let presets: Vec<&str> =
|
||||
r[0]["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(presets, ["Press"]);
|
||||
assert_eq!(r[0]["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Press").unwrap()["description"], "for the printer");
|
||||
assert!(profile_names(&r[1]).contains(&"StudioRGB3"), "{:?}", profile_names(&r[1]));
|
||||
// The profile works as a working space, and the file it came from can go.
|
||||
std::fs::remove_file(&icc).unwrap();
|
||||
let steps = [
|
||||
("edit.colorSettings", json!({"rgb": "StudioRGB3"})),
|
||||
("pdf.preset.delete", json!({"name": "Press"})),
|
||||
("edit.colorSettings", json!({"rgb": vectorcraft_color::cms::SRGB})),
|
||||
("color.removeProfile", json!({"name": "StudioRGB3"})),
|
||||
];
|
||||
let r = run_in(&config, &steps);
|
||||
assert_eq!(r[0]["rgb"], "StudioRGB3");
|
||||
assert_eq!(r[3]["removed"], "StudioRGB3");
|
||||
|
||||
let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]);
|
||||
assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true));
|
||||
assert!(!profile_names(&r[1]).contains(&"StudioRGB3"));
|
||||
}
|
||||
|
||||
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`) keep what they save for the run only, and
|
||||
/// leave nothing in the settings folder.
|
||||
#[test]
|
||||
fn runs_without_preferences_remember_nothing() {
|
||||
let config = config_dir("noprefs");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#])
|
||||
.env("VECTORCRAFT_CONFIG_DIR", &config)
|
||||
.env("VECTORCRAFT_NO_PREFS", "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(!config.exists(), "nothing written");
|
||||
let r = run_in(&config, &[("pdf.preset.list", json!({}))]);
|
||||
assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true));
|
||||
}
|
||||
@@ -854,8 +854,14 @@ fn main() -> std::process::ExitCode {
|
||||
"VectorCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
let mut session = Session::new();
|
||||
// The profiles loaded before come back before the preferences name any of them.
|
||||
prefs_dir::attach_profile_store(&mut session);
|
||||
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
load_prefs(&mut app, saved);
|
||||
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
|
||||
// which takes over from the copy the preferences file used to hold.
|
||||
prefs_dir::attach_pdf_preset_store(&mut app.session);
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
if let Some(w) = cc.winit_window() {
|
||||
app.ui.window = Some(window::restore(w, saved_window));
|
||||
|
||||
@@ -24,6 +24,31 @@ pub fn install_fonts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder `name` next to the settings, for what the user keeps: none in a run without
|
||||
/// preferences (`VECTORCRAFT_NO_PREFS`), which keeps nothing.
|
||||
fn store_dir(name: &str) -> Option<PathBuf> {
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(prefs_path_for("VectorCraft", "vectorcraft")?.parent()?.join(name))
|
||||
}
|
||||
|
||||
/// Register the ICC profiles `color.loadProfile` kept in the `Color Profiles` folder (#2174), and
|
||||
/// keep the ones loaded from now on there. Before anything names a profile.
|
||||
pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) {
|
||||
if let Some(dir) = store_dir("Color Profiles") {
|
||||
session.attach_profile_store(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from
|
||||
/// now on there. After the preferences were restored: the folder is the one place the presets live.
|
||||
pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) {
|
||||
if let Some(dir) = store_dir("PDF Presets") {
|
||||
session.attach_pdf_preset_store(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -120,3 +120,6 @@ sync_github_name = false
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.theromis]
|
||||
sync_github_name = true
|
||||
@@ -257,9 +257,25 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// A number that changes whenever [`register_icc`] registers a profile: conversions through a
|
||||
/// profile looked up by name, such as those of a [`ProofTarget::Cmyk`] proof, give the same colors
|
||||
/// as long as the number stays the same.
|
||||
/// Forget a profile [`register_icc`] registered (by its name); `false` when there is none of that
|
||||
/// name (the built-in profiles can't be removed). Settings that name it no longer resolve, so a
|
||||
/// caller checks [`active_settings`] first.
|
||||
pub fn unregister_icc(name: &str) -> bool {
|
||||
let mut u = USER.write().unwrap_or_else(|e| e.into_inner());
|
||||
let before = u.len();
|
||||
u.retain(|x| x.name != name);
|
||||
let removed = u.len() != before;
|
||||
drop(u);
|
||||
if removed {
|
||||
clear_proof_cache();
|
||||
PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// A number that changes whenever [`register_icc`] registers a profile or [`unregister_icc`]
|
||||
/// removes one: conversions through a profile looked up by name, such as those of a
|
||||
/// [`ProofTarget::Cmyk`] proof, give the same colors as long as the number stays the same.
|
||||
pub fn profiles_generation() -> u64 {
|
||||
PROFILES_GENERATION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Cooperative cancellation and progress for long-running work (exports, flattening, baking).
|
||||
//!
|
||||
//! A host that wants to be able to stop a call (the MCP server, on `notifications/cancelled`)
|
||||
//! runs it inside [`scope`] with a [`CancelToken`]. Code deep below, which has no handle on the
|
||||
//! host, calls [`check`] (or [`is_cancelled`] where it can't return an error) at points where
|
||||
//! stopping is safe, and returns [`Cancelled`] up through the same `Result`s it already has. The
|
||||
//! token is tied to the thread that entered the scope, so concurrent sessions on other threads
|
||||
//! (and tests running in parallel) never see each other's cancellation, and code outside any
|
||||
//! scope is never cancelled.
|
||||
//!
|
||||
//! Work that stops early leaves nothing behind: callers drop whatever was half built and report
|
||||
//! the error, so a cancelled command changes nothing.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// A flag one thread raises and another polls. Cloning shares the flag.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CancelToken(Arc<AtomicBool>);
|
||||
|
||||
impl CancelToken {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Ask for the work holding this token to stop.
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// The error of work that stopped because it was cancelled.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Cancelled;
|
||||
|
||||
impl std::fmt::Display for Cancelled {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Cancelled {}
|
||||
|
||||
/// Receives progress: `(done, total, what is being done)`. `total` is 0 when it isn't known.
|
||||
pub type ProgressSink = Rc<dyn Fn(u64, u64, &str)>;
|
||||
|
||||
struct Scope {
|
||||
token: CancelToken,
|
||||
sink: Option<ProgressSink>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CURRENT: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Puts the previous scope back, also when the work inside unwinds.
|
||||
struct Restore(Option<Scope>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.0.take();
|
||||
// `try_with`: during thread teardown the slot may be gone; nothing to restore then.
|
||||
let _ = CURRENT.try_with(|c| *c.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with `token` (and an optional progress `sink`) in effect on this thread.
|
||||
pub fn scope<T>(token: &CancelToken, sink: Option<ProgressSink>, f: impl FnOnce() -> T) -> T {
|
||||
let previous = CURRENT.with(|c| c.borrow_mut().replace(Scope { token: token.clone(), sink }));
|
||||
let _restore = Restore(previous);
|
||||
f()
|
||||
}
|
||||
|
||||
/// Has the work running on this thread been cancelled? Always false outside a [`scope`].
|
||||
pub fn is_cancelled() -> bool {
|
||||
CURRENT.with(|c| c.borrow().as_ref().is_some_and(|s| s.token.is_cancelled()))
|
||||
}
|
||||
|
||||
/// Is this thread running inside a [`scope`], so that a cancel can reach it?
|
||||
pub fn is_cancellable() -> bool {
|
||||
CURRENT.with(|c| c.borrow().is_some())
|
||||
}
|
||||
|
||||
/// `Err(Cancelled)` when the work running on this thread has been cancelled.
|
||||
pub fn check() -> Result<(), Cancelled> {
|
||||
if is_cancelled() { Err(Cancelled) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`]
|
||||
/// that has a sink.
|
||||
pub fn progress(done: u64, total: u64, what: &str) {
|
||||
// The sink runs outside the borrow, so it may itself call back into this module.
|
||||
let sink = CURRENT.with(|c| c.borrow().as_ref().and_then(|s| s.sink.clone()));
|
||||
if let Some(sink) = sink {
|
||||
sink(done, total, what);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_work_inside_the_scope_is_cancelled() {
|
||||
let t = CancelToken::new();
|
||||
assert_eq!(check(), Ok(()));
|
||||
scope(&t, None, || {
|
||||
assert_eq!(check(), Ok(()));
|
||||
t.cancel();
|
||||
assert_eq!(check(), Err(Cancelled));
|
||||
// A nested scope with its own token shadows, then restores.
|
||||
scope(&CancelToken::new(), None, || assert_eq!(check(), Ok(())));
|
||||
assert!(is_cancelled());
|
||||
});
|
||||
assert!(!is_cancelled(), "the scope ended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_reaches_the_sink_and_the_scope_survives_a_panic() {
|
||||
let seen = Rc::new(RefCell::new(vec![]));
|
||||
let sink: ProgressSink = {
|
||||
let seen = seen.clone();
|
||||
Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string())))
|
||||
};
|
||||
let t = CancelToken::new();
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
scope(&t, Some(sink), || {
|
||||
progress(1, 3, "page");
|
||||
panic!("boom");
|
||||
})
|
||||
}));
|
||||
assert!(r.is_err());
|
||||
progress(2, 3, "after");
|
||||
assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]);
|
||||
}
|
||||
}
|
||||
+203
-34
@@ -2,10 +2,12 @@
|
||||
//!
|
||||
//! Two encodings hold them: a four-component JPEG, whose samples are stored inverted with Adobe's
|
||||
//! marker (the convention JPEG readers and PDF writers take a CMYK JPEG to follow), and a CMYK
|
||||
//! TIFF (8 or 16 bits, chunky, ink amounts as they are). [`ImageBlob::cmyk_tiff`] writes the
|
||||
//! latter, Deflate-compressed. Every other image decodes to RGB(A) only.
|
||||
//! TIFF (8 or 16 bits, ink amounts as they are, each pixel's inks together or one plane per ink).
|
||||
//! [`ImageBlob::cmyk_tiff`] writes the latter, inks together, Deflate-compressed. Every other image
|
||||
//! decodes to RGB(A) only.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use zune_jpeg::zune_core::bytestream::ZCursor;
|
||||
use zune_jpeg::zune_core::colorspace::ColorSpace;
|
||||
@@ -54,25 +56,136 @@ fn is_tiff(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*")
|
||||
}
|
||||
|
||||
/// The colour type of TIFF `bytes` and its decoder.
|
||||
/// The width and height of the first image of TIFF `bytes` when it has more strips or tiles than
|
||||
/// the `tiff` decoder can count: strips per plane times planes past `u32::MAX`, or tiles per plane
|
||||
/// times planes past `usize::MAX`. A file that stores one plane per channel (PlanarConfiguration 2)
|
||||
/// has a plane for each channel; other files have one. The decoder multiplies these counts without
|
||||
/// a check, which panics where overflow checks are on. Such an image, when it is at least a pixel
|
||||
/// wide, claims at least 2^32 samples.
|
||||
/// Where a tag repeats, the value giving the most strips or tiles counts. `None` for other images
|
||||
/// and for bytes that aren't a TIFF.
|
||||
pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let le = bytes.starts_with(b"II*\0");
|
||||
if !le && !bytes.starts_with(b"MM\0*") {
|
||||
return None;
|
||||
}
|
||||
let u16_at = |at: usize| -> Option<u16> {
|
||||
let b: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
|
||||
};
|
||||
let u32_at = |at: usize| -> Option<u32> {
|
||||
let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
|
||||
};
|
||||
let ifd = usize::try_from(u32_at(4)?).ok()?;
|
||||
// (tag, the entry's single BYTE, SHORT, LONG or IFD value, which sits in the entry, or its
|
||||
// single LONG8 or IFD8 value that fits in 32 bits, which sits where the entry points)
|
||||
let entry = |i: usize| -> Option<(u16, Option<u32>)> {
|
||||
let e = ifd.checked_add(i.checked_mul(12)?.checked_add(2)?)?;
|
||||
let (ty, count, at) = (u16_at(e.checked_add(2)?)?, u32_at(e.checked_add(4)?)?, e.checked_add(8)?);
|
||||
let value = match (ty, count) {
|
||||
(1, 1) => bytes.get(at).map(|&b| u32::from(b)),
|
||||
(3, 1) => u16_at(at).map(u32::from),
|
||||
(4 | 13, 1) => u32_at(at),
|
||||
(16 | 18, 1) => {
|
||||
let at = usize::try_from(u32_at(at)?).ok()?;
|
||||
let (lo, hi) = (u32_at(at)?, u32_at(at.checked_add(4)?)?);
|
||||
let wide = if le { u64::from(hi) << 32 | u64::from(lo) } else { u64::from(lo) << 32 | u64::from(hi) };
|
||||
u32::try_from(wide).ok()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Some((u16_at(e)?, value))
|
||||
};
|
||||
let (mut width, mut height, mut planes, mut planar) = (0u32, 0u32, 1u32, false);
|
||||
let (mut rows, mut tile_w, mut tile_h) = (None::<u32>, None::<u32>, None::<u32>);
|
||||
let least = |was: Option<u32>, v: u32| Some(was.map_or(v, |w| w.min(v)));
|
||||
for i in 0..usize::from(u16_at(ifd)?) {
|
||||
let Some((tag, value)) = entry(i) else { break };
|
||||
let Some(v) = value else { continue };
|
||||
match tag {
|
||||
256 => width = width.max(v),
|
||||
257 => height = height.max(v),
|
||||
277 => planes = planes.max(v),
|
||||
278 => rows = least(rows, v),
|
||||
284 => planar |= v == 2,
|
||||
322 => tile_w = least(tile_w, v),
|
||||
323 => tile_h = least(tile_h, v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let planes = if planar { u128::from(planes) } else { 1 };
|
||||
let tiles = match (tile_w, tile_h) {
|
||||
(Some(tw @ 1..), Some(th @ 1..)) => u128::from(width.div_ceil(tw)) * u128::from(height.div_ceil(th)) * planes > usize::MAX as u128,
|
||||
_ => false,
|
||||
};
|
||||
let rows = rows.unwrap_or(height);
|
||||
let strips = rows > 0 && u128::from(height.saturating_sub(1) / rows + 1) * planes > u128::from(u32::MAX);
|
||||
(tiles || strips).then_some((width, height))
|
||||
}
|
||||
|
||||
/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too.
|
||||
fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
|
||||
if !is_tiff(bytes) {
|
||||
if !is_tiff(bytes) || oversized_tiff(bytes).is_some() {
|
||||
return None;
|
||||
}
|
||||
let mut d = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?;
|
||||
Some((d.colortype().ok()?, d))
|
||||
}
|
||||
|
||||
/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)?
|
||||
fn planar(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>) -> bool {
|
||||
d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1)
|
||||
}
|
||||
|
||||
/// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color
|
||||
/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file
|
||||
/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to
|
||||
/// that many samples, which includes images whose planes together need more than the decoder's
|
||||
/// limit (256 MB of samples).
|
||||
fn read_samples(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>, n: usize, per: usize) -> Option<Vec<u8>> {
|
||||
let mut buf = tiff::decoder::DecodingResult::U8(Vec::new());
|
||||
let layout = d.read_image_to_buffer(&mut buf).ok()?;
|
||||
let data = match buf {
|
||||
tiff::decoder::DecodingResult::U8(v) => v,
|
||||
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
if Some(data.len()) != n.checked_mul(per) {
|
||||
return None;
|
||||
}
|
||||
if layout.planes <= 1 {
|
||||
return Some(data);
|
||||
}
|
||||
if layout.planes != per {
|
||||
return None;
|
||||
}
|
||||
let planes: Vec<&[u8]> = data.chunks_exact(n).collect();
|
||||
Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect())
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// A CMYK TIFF of ink amounts `inks` (Deflate-compressed); `None` when it can't be written.
|
||||
pub fn cmyk_tiff(inks: &Inks) -> Option<Self> {
|
||||
use tiff::encoder::{Compression, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
|
||||
Self::cmyk_tiff_at(inks, None)
|
||||
}
|
||||
|
||||
/// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per
|
||||
/// inch) when it is given and both are finite and above 0.
|
||||
pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option<Self> {
|
||||
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
|
||||
use tiff::tags::{ResolutionUnit, Tag};
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
TiffEncoder::new(&mut out)
|
||||
.ok()?
|
||||
.with_compression(Compression::Deflate(DeflateLevel::Balanced))
|
||||
.write_image::<CMYK8>(inks.width, inks.height, &inks.data)
|
||||
.ok()?;
|
||||
let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced));
|
||||
let mut img = enc.new_image::<CMYK8>(inks.width, inks.height).ok()?;
|
||||
if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) {
|
||||
// Ten-thousandths of a pixel per inch.
|
||||
let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 };
|
||||
img.encoder().write_tag(Tag::XResolution, r(x)).ok()?;
|
||||
img.encoder().write_tag(Tag::YResolution, r(y)).ok()?;
|
||||
img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?;
|
||||
}
|
||||
img.write_data(&inks.data).ok()?;
|
||||
Some(Self::new("image/tiff", out.into_inner()))
|
||||
}
|
||||
|
||||
@@ -119,34 +232,42 @@ fn jpeg_inks(bytes: &[u8]) -> Option<Inks> {
|
||||
Inks::new(w, h, data)
|
||||
}
|
||||
|
||||
/// The ink amounts of a chunky CMYK TIFF (16-bit samples to 8 bits).
|
||||
/// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane
|
||||
/// per ink.
|
||||
fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
|
||||
let (kind, mut d) = tiff(bytes)?;
|
||||
let (w, h) = d.dimensions().ok()?;
|
||||
// Planar samples (one plane per ink) would read as interleaved.
|
||||
let planar = d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
|
||||
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) || planar || pixels(w, h).is_none() {
|
||||
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) {
|
||||
return None;
|
||||
}
|
||||
let data = match d.read_image().ok()? {
|
||||
tiff::decoder::DecodingResult::U8(v) => v,
|
||||
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
Inks::new(w, h, data)
|
||||
let n = usize::try_from(pixels(w, h)?).ok()?;
|
||||
Inks::new(w, h, read_samples(&mut d, n, 4)?)
|
||||
}
|
||||
|
||||
/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per
|
||||
/// ink, which the image decoder doesn't read, their ink amounts written again by
|
||||
/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK
|
||||
/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read.
|
||||
pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option<Vec<u8>> {
|
||||
let inks = tiff_inks(bytes)?;
|
||||
let (_, mut d) = tiff(bytes)?;
|
||||
if !planar(&mut d) {
|
||||
return Some(bytes.to_vec());
|
||||
}
|
||||
ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes))
|
||||
}
|
||||
|
||||
/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at
|
||||
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
|
||||
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
|
||||
/// and grayscale with extra samples, of which the first is the alpha when the file says so (other
|
||||
/// channels are left out). Associated (premultiplied) alpha is divided back out. `None` for other
|
||||
/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`].
|
||||
/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be
|
||||
/// stored together or one plane per channel. `None` for other images and ones larger than
|
||||
/// [`MAX_CMYK_PIXELS`].
|
||||
pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
|
||||
use tiff::tags::Tag;
|
||||
let (kind, mut d) = tiff(bytes)?;
|
||||
let (w, h) = d.dimensions().ok()?;
|
||||
let planar = d.find_tag_unsigned::<u16>(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
|
||||
// 0: white is zero, 1: black is zero (the two grayscales).
|
||||
let photometric = d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten();
|
||||
// ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha.
|
||||
@@ -159,18 +280,8 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if planar {
|
||||
return None;
|
||||
}
|
||||
let n = usize::try_from(pixels(w, h)?).ok()?;
|
||||
let data: Vec<u8> = match d.read_image().ok()? {
|
||||
tiff::decoder::DecodingResult::U8(v) => v,
|
||||
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
if Some(data.len()) != n.checked_mul(samples) {
|
||||
return None;
|
||||
}
|
||||
let data = read_samples(&mut d, n, samples)?;
|
||||
let mut out = Vec::with_capacity(n * 4);
|
||||
for p in data.chunks_exact(samples) {
|
||||
let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX };
|
||||
@@ -246,6 +357,64 @@ mod tests {
|
||||
assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque");
|
||||
}
|
||||
|
||||
/// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel,
|
||||
/// times 2 planes.
|
||||
#[test]
|
||||
fn tiffs_with_more_tiles_than_can_be_counted_are_oversized() {
|
||||
let header = |planar: u32| {
|
||||
let mut f = b"II*\0\x08\0\0\0".to_vec();
|
||||
let entries = [(256u16, u32::MAX), (257, u32::MAX), (258, 8), (262, 1), (277, 2), (284, planar), (322, 1), (323, 1), (324, 0), (325, 0)];
|
||||
f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
|
||||
for (tag, v) in entries {
|
||||
f.extend([tag.to_le_bytes(), 4u16.to_le_bytes()].concat());
|
||||
f.extend([1u32.to_le_bytes(), v.to_le_bytes()].concat());
|
||||
}
|
||||
f.extend([0; 4]);
|
||||
f
|
||||
};
|
||||
assert_eq!(oversized_tiff(&header(2)), Some((u32::MAX, u32::MAX)));
|
||||
assert_eq!(oversized_tiff(&header(1)), None, "one plane: (2^32 − 1)² tiles can be counted in 64 bits");
|
||||
assert!(tiff(&header(2)).is_none(), "not decoded");
|
||||
}
|
||||
|
||||
/// A header claiming more strips than can be counted: 2^31 + 1 rows, a strip each, times 2
|
||||
/// planes. The decoder counts strips when the file has strip offsets, tile sizes or not, and
|
||||
/// reads a LONG8 value from where the entry points.
|
||||
#[test]
|
||||
fn tiffs_with_more_strips_than_can_be_counted_are_oversized() {
|
||||
// Each entry holds one value of type 3 (SHORT), 4 (LONG) or 16 (LONG8, after the directory).
|
||||
let header = |entries: &[(u16, u16, u32)]| {
|
||||
let after = 8 + 2 + 12 * entries.len() + 4;
|
||||
let mut f = b"II*\0\x08\0\0\0".to_vec();
|
||||
f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
|
||||
let mut wide = vec![];
|
||||
for &(tag, ty, v) in entries {
|
||||
f.extend([tag.to_le_bytes(), ty.to_le_bytes()].concat());
|
||||
f.extend(1u32.to_le_bytes());
|
||||
if ty == 16 {
|
||||
f.extend(u32::try_from(after + wide.len()).unwrap().to_le_bytes());
|
||||
wide.extend(u64::from(v).to_le_bytes());
|
||||
} else {
|
||||
f.extend(v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
f.extend([0; 4]);
|
||||
f.extend(wide);
|
||||
f
|
||||
};
|
||||
let tall = 1u32 << 31 | 1;
|
||||
let strips = [(256, 4, 1), (257, 4, tall), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)];
|
||||
assert_eq!(oversized_tiff(&header(&strips)), Some((1, tall)));
|
||||
let tile_sizes = [&strips[..], &[(322, 4, u32::MAX), (323, 4, u32::MAX)]].concat();
|
||||
assert_eq!(oversized_tiff(&header(&tile_sizes)), Some((1, tall)), "tile sizes without tile offsets");
|
||||
let mut long8 = strips;
|
||||
long8[1] = (257, 16, tall);
|
||||
assert_eq!(oversized_tiff(&header(&long8)), Some((1, tall)), "ImageLength as a LONG8");
|
||||
for h in [header(&tile_sizes), header(&long8)] {
|
||||
assert!(tiff(&h).is_none(), "not decoded");
|
||||
}
|
||||
}
|
||||
|
||||
fn inks() -> Inks {
|
||||
Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use vectorcraft_geom::corners::{Corner, cut_corners, path_corners};
|
||||
use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners};
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect};
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct LiveCorners<'a> {
|
||||
/// The corners that can be cut, in anchor order ([`path_corners`] of `base`).
|
||||
pub corners: Vec<Corner>,
|
||||
radii: Vec<f64>,
|
||||
/// The radius each of `corners` is drawn with ([`fitted_radii`]).
|
||||
drawn: Vec<f64>,
|
||||
kinds: Vec<CornerKind>,
|
||||
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
|
||||
sources: Vec<Vec<usize>>,
|
||||
@@ -54,7 +56,9 @@ impl<'a> LiveCorners<'a> {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Some(Self { corners: path_corners(&base), base, xf, radii, kinds, sources })
|
||||
let corners = path_corners(&base);
|
||||
let drawn = fitted_radii(&corners, &radii);
|
||||
Some(Self { corners, base, xf, radii, drawn, kinds, sources })
|
||||
}
|
||||
|
||||
/// The Live Corners of a path object.
|
||||
@@ -73,6 +77,11 @@ impl<'a> LiveCorners<'a> {
|
||||
self.radii.get(index).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// The radius corner `index` is drawn with, in document units: its own, no larger than its
|
||||
/// neighbours leave room for (0 for an anchor that is no corner).
|
||||
pub fn drawn_radius(&self, index: usize) -> f64 {
|
||||
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.drawn.get(i)).copied().unwrap_or(0.0)
|
||||
}
|
||||
/// The kind corner `index` is cut with.
|
||||
pub fn kind(&self, index: usize) -> CornerKind {
|
||||
self.kinds.get(index).copied().unwrap_or_default()
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod appearance;
|
||||
pub mod assets;
|
||||
pub mod blend;
|
||||
pub mod cancel;
|
||||
pub mod clipnest;
|
||||
pub mod cmyk;
|
||||
pub mod corners;
|
||||
|
||||
@@ -11,15 +11,20 @@ use crate::ImageBlob;
|
||||
/// address and aborts when an allocation fails, so it keeps the decoder's own 512 MB.
|
||||
pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded or would take
|
||||
/// more than `max` bytes as RGBA. The size is read from the header first: the decoder's own limit
|
||||
/// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717).
|
||||
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded, would take
|
||||
/// more than `max` bytes as RGBA, or need more than `max` bytes to decode. The size is read from
|
||||
/// the header first: the decoder's limit is on the file's samples, and a gray image is a quarter
|
||||
/// of what it becomes here (#1717).
|
||||
fn rgba_within(bytes: &[u8], max: u64) -> Option<image::RgbaImage> {
|
||||
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||
if u64::from(w) * u64::from(h) > max / 4 {
|
||||
return None;
|
||||
}
|
||||
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(max);
|
||||
reader.limits(limits);
|
||||
Some(reader.decode().ok()?.into_rgba8())
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
@@ -131,6 +136,35 @@ mod tests {
|
||||
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
|
||||
}
|
||||
|
||||
/// A GIF whose `w` × `h` screen holds one opaque red pixel at the top left. The file holds no
|
||||
/// other pixels, and the decoder makes them transparent.
|
||||
fn gif_with_one_red_pixel(w: u16, h: u16) -> Vec<u8> {
|
||||
let mut gif = b"GIF89a".to_vec();
|
||||
gif.extend_from_slice(&w.to_le_bytes());
|
||||
gif.extend_from_slice(&h.to_le_bytes());
|
||||
// Flags for a global color table of two colors, the background and aspect bytes, then the
|
||||
// table: red and black.
|
||||
gif.extend_from_slice(&[0x80, 0, 0, 255, 0, 0, 0, 0, 0]);
|
||||
// A 1 × 1 image at (0, 0), its LZW data (clear, color 0, end), and the end of the file.
|
||||
gif.extend_from_slice(&[0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 0x01, 0, 0x3B]);
|
||||
gif
|
||||
}
|
||||
|
||||
/// The pixels are decoded within the allowance, not within the decoder's own 512 MB: an RGBA
|
||||
/// image of 11,586 × 11,586 pixels (536,941,584 bytes) is recolored and sampled.
|
||||
#[test]
|
||||
fn pixels_past_the_decoders_own_limit_are_decoded_within_the_allowance() {
|
||||
let (w, h) = (11_586, 11_586);
|
||||
let rgba = u64::from(w) * u64::from(h) * 4;
|
||||
assert!(rgba > 512 << 20);
|
||||
let gif = gif_with_one_red_pixel(w, h);
|
||||
assert!(rgba_within(&gif, rgba - 1).is_none(), "past the allowance");
|
||||
let b = ImageBlob::new("image/gif", gif);
|
||||
assert!(b.map_rgb(|[r, g, bl]| [bl, g, r]).is_some(), "red becomes blue");
|
||||
assert_eq!(b.sample(0.5, 0.5, 1), Some([255, 0, 0, 255]));
|
||||
assert_eq!(b.sample(f64::from(w) - 0.5, f64::from(h) - 0.5, 1), None, "transparent");
|
||||
}
|
||||
|
||||
/// #1717: the RGBA size is checked from the header, before anything is decoded: a grey image
|
||||
/// the decoder's own limit lets through is four times that as RGBA.
|
||||
#[test]
|
||||
|
||||
@@ -89,7 +89,8 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind {
|
||||
|
||||
/// `n` with its geometry effects evaluated (`None` = unchanged).
|
||||
fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
if !needs_bake(n) {
|
||||
// A cancelled export leaves the rest of the document as it is: the caller drops the result.
|
||||
if !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
|
||||
@@ -120,6 +121,13 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let m = reshape(n, symbol.as_deref())?;
|
||||
return Some(bake_node(d, &m).unwrap_or(m));
|
||||
}
|
||||
// A path whose Transform effect copies it with a gradient: each copy an object of its own, so
|
||||
// its gradient fits it rather than spanning every copy.
|
||||
if crate::has_gradient_copies(n)
|
||||
&& let Some(m) = reshape(n, None)
|
||||
{
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that
|
||||
// Transform effects only move stays type). The new pieces (results, outlines, copies, the
|
||||
// fills' and strokes' art) get ids of their own.
|
||||
@@ -572,6 +580,15 @@ mod tests {
|
||||
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
/// Type tests need glyph outlines, which only the optional craft-fonts build input provides.
|
||||
fn skip_without_craft_fonts() -> bool {
|
||||
let skip = vectorcraft_text::CRAFT_FONTS.is_empty();
|
||||
if skip {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
}
|
||||
skip
|
||||
}
|
||||
|
||||
fn fx(id: &str, params: serde_json::Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
}
|
||||
@@ -647,6 +664,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
for (params, origins, square) in [
|
||||
(json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)),
|
||||
(json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)),
|
||||
@@ -678,6 +698,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
|
||||
let mut hidden = type_node(55);
|
||||
hidden.visible = false;
|
||||
@@ -689,6 +712,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_outlined_next_to_type_that_stays_type_keeps_its_look() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
|
||||
let mut shadowed = type_node(55);
|
||||
shadowed.opacity = 0.5;
|
||||
@@ -711,6 +737,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
|
||||
// The clipping path is a group with a fill of its own, holding type with an effect of its own.
|
||||
let mut moved = type_node(60);
|
||||
@@ -729,6 +758,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_with_its_own_transform_effect_copies_stays_type() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut n = type_node(50);
|
||||
n.opacity = 0.5;
|
||||
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))];
|
||||
@@ -775,6 +807,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_whose_copies_paint_over_each_other_is_still_outlined() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
|
||||
assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2);
|
||||
// A 20 pt stroke on the characters reaches the copy below.
|
||||
@@ -790,6 +825,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
|
||||
let mut translucent = type_node(50);
|
||||
translucent.opacity = 0.5;
|
||||
@@ -862,6 +900,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_whose_copies_hold_too_much_is_still_outlined() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let spaced = |spaces: usize| {
|
||||
let mut n = type_node(50);
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
@@ -899,6 +940,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]);
|
||||
inner.id = NodeId(53);
|
||||
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
|
||||
@@ -932,4 +976,36 @@ mod tests {
|
||||
// An effect that also scales.
|
||||
assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none());
|
||||
}
|
||||
/// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies.
|
||||
fn gradient_copies(id: u64) -> Node {
|
||||
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0));
|
||||
n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2})));
|
||||
n
|
||||
}
|
||||
|
||||
/// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right.
|
||||
fn gradient_pieces(n: &Node) -> Vec<f64> {
|
||||
let mut out = vec![];
|
||||
let mut walk = vec![n];
|
||||
while let Some(n) = walk.pop() {
|
||||
match n.children() {
|
||||
Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)),
|
||||
_ => {
|
||||
assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}");
|
||||
out.extend(n.geometric_bounds().map(|b| b.width()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() {
|
||||
let d = bake_document(&doc_with(gradient_copies(50))).unwrap();
|
||||
// Three squares of their own, not one path spanning all three.
|
||||
assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -267,8 +267,20 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
|
||||
|
||||
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// A Zig Zag ridge: where it sits, the path's unit direction there, and the length along the
|
||||
/// path to the ridges before and after it (zero at an open path's ends).
|
||||
struct Ridge {
|
||||
p: Point,
|
||||
dir: Vec2,
|
||||
gap_in: f64,
|
||||
gap_out: f64,
|
||||
}
|
||||
|
||||
/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately
|
||||
/// displaced along the normal.
|
||||
/// displaced along the normal. Corner points join them with straight lines. Smooth points make a
|
||||
/// wave: every peak and valley is a smooth point whose handles run along the path, a third of the
|
||||
/// way to the next ridge, so each span between them follows a half cosine (an S through the path)
|
||||
/// whatever the size of the ridges (#2343).
|
||||
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
|
||||
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
|
||||
@@ -277,29 +289,33 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let mut subs = vec![];
|
||||
for sp in &path.subpaths {
|
||||
let segs = sp.segment_count();
|
||||
let mut pts = vec![];
|
||||
let gap = |i: usize| sp.segment(i).arclen(ZIGZAG_ARCLEN_ACCURACY) / (ridges + 1) as f64;
|
||||
let mut pts: Vec<Ridge> = vec![];
|
||||
let mut k = 0usize;
|
||||
for i in 0..segs {
|
||||
let c = sp.segment(i);
|
||||
// Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up
|
||||
// near the anchors, and so does any uneven curve.
|
||||
let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY);
|
||||
let step = len / (ridges + 1) as f64;
|
||||
for j in 0..=ridges {
|
||||
let s = j as f64 / (ridges + 1) as f64;
|
||||
let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s };
|
||||
let mut gap_in = step;
|
||||
let nrm = if j == 0 {
|
||||
// Average the normals of the segments meeting at the anchor.
|
||||
let prev = if i > 0 {
|
||||
Some(sp.segment(i - 1))
|
||||
Some(i - 1)
|
||||
} else if sp.closed {
|
||||
Some(sp.segment(segs - 1))
|
||||
Some(segs - 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
gap_in = prev.map_or(0.0, gap);
|
||||
let n1 = normal_at(&c, 0.0);
|
||||
match prev {
|
||||
Some(pc) => {
|
||||
let s = n1 + normal_at(&pc, 1.0);
|
||||
Some(pi) => {
|
||||
let s = n1 + normal_at(&sp.segment(pi), 1.0);
|
||||
if s.hypot() > 1e-9 { s / s.hypot() } else { n1 }
|
||||
}
|
||||
None => n1,
|
||||
@@ -308,7 +324,7 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
normal_at(&c, t)
|
||||
};
|
||||
let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
|
||||
pts.push(c.eval(t) + nrm * (amp * sign));
|
||||
pts.push(Ridge { p: c.eval(t) + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in, gap_out: step });
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
@@ -316,12 +332,18 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
if segs > 0 {
|
||||
let c = sp.segment(segs - 1);
|
||||
let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
|
||||
pts.push(c.p3 + normal_at(&c, 1.0) * (amp * sign));
|
||||
let nrm = normal_at(&c, 1.0);
|
||||
pts.push(Ridge { p: c.p3 + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in: gap(segs - 1), gap_out: 0.0 });
|
||||
} else {
|
||||
pts.extend(sp.anchors.iter().map(|a| a.p));
|
||||
pts.extend(sp.anchors.iter().map(|a| Ridge { p: a.p, dir: Vec2::ZERO, gap_in: 0.0, gap_out: 0.0 }));
|
||||
}
|
||||
}
|
||||
subs.push(if smooth { catmull_rom(&pts, sp.closed, 1.0) } else { SubPath::polyline(&pts, sp.closed) });
|
||||
subs.push(if smooth {
|
||||
let anchors = pts.iter().map(|r| Anchor::with_handles(r.p, r.p - r.dir * (r.gap_in / 3.0), r.p + r.dir * (r.gap_out / 3.0))).collect();
|
||||
SubPath::new(anchors, sp.closed)
|
||||
} else {
|
||||
SubPath::polyline(&pts.iter().map(|r| r.p).collect::<Vec<_>>(), sp.closed)
|
||||
});
|
||||
}
|
||||
PathData::new(subs)
|
||||
}
|
||||
@@ -293,6 +293,15 @@ mod tests {
|
||||
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
/// Type tests need glyph outlines, which only the optional craft-fonts build input provides.
|
||||
fn skip_without_craft_fonts() -> bool {
|
||||
let skip = vectorcraft_text::CRAFT_FONTS.is_empty();
|
||||
if skip {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
}
|
||||
skip
|
||||
}
|
||||
|
||||
fn sq(id: u64, x: f64, color: Color) -> Arc<Node> {
|
||||
Arc::new(Node::path(
|
||||
NodeId(id),
|
||||
@@ -350,6 +359,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_changes_the_result_of_every_pathfinder_effect() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let glyphs = crate::outline_text(&type_h()).unwrap();
|
||||
let b = glyphs.geometric_bounds().unwrap();
|
||||
assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}");
|
||||
@@ -377,6 +389,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut t = type_h();
|
||||
(t.opacity, t.blend) = (0.5, BlendMode::Multiply);
|
||||
let look = |p: &Arc<Node>| (p.appearance.fill_paint(), p.opacity, p.blend);
|
||||
@@ -418,6 +433,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
use vectorcraft_color::BlendMode;
|
||||
let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply));
|
||||
assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}");
|
||||
@@ -447,6 +465,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
@@ -470,4 +491,32 @@ mod tests {
|
||||
assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}");
|
||||
assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||
let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))];
|
||||
for (k, paint) in paints.into_iter().enumerate() {
|
||||
let square =
|
||||
Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)));
|
||||
let mut g = Node::group(NodeId(10), vec![square]);
|
||||
let params = serde_json::json!({"moveH": 30, "copies": 2});
|
||||
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own");
|
||||
assert_eq!(copies.len(), 3);
|
||||
for (i, c) in copies.iter().enumerate() {
|
||||
// Each copy's own box: an unplaced gradient fits it, a placed one moved with it.
|
||||
assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6);
|
||||
match c.appearance.fill_paint() {
|
||||
Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"),
|
||||
Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"),
|
||||
p => panic!("a gradient: {p:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
|
||||
@@ -32,6 +32,17 @@ pub fn needs_outline(n: &Node) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Is `n` a path or compound path with a visible Transform effect that makes copies and a gradient
|
||||
/// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy
|
||||
/// paints its gradient by itself ([`copy_with_gradients`]), as a group's copies do.
|
||||
pub fn has_gradient_copies(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
|
||||
&& (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient())
|
||||
&& geometry_effects(n)
|
||||
.iter()
|
||||
.any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0)
|
||||
}
|
||||
|
||||
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
|
||||
/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke
|
||||
/// paints ([`outline_text`], which leaves inline graphics out).
|
||||
@@ -127,6 +138,16 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
a.transform(*xf, false);
|
||||
Node::group(n.id, vec![Arc::new(a)])
|
||||
}
|
||||
// The object as the one piece the copies of its Transform effect are made from.
|
||||
_ if has_gradient_copies(n) => {
|
||||
let mut one = n.clone();
|
||||
one.opacity = 1.0;
|
||||
one.blend = Default::default();
|
||||
one.isolate = false;
|
||||
one.extras_mut().mask = None;
|
||||
one.appearance.effects.clear();
|
||||
Node::group(n.id, vec![Arc::new(one)])
|
||||
}
|
||||
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
|
||||
_ => return None,
|
||||
};
|
||||
@@ -171,16 +192,21 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
|
||||
/// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a
|
||||
/// symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n) {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) {
|
||||
return None;
|
||||
}
|
||||
as_art(n, symbol_art)
|
||||
}
|
||||
|
||||
/// `n` (see [`needs_outline`]) as plain art for Expand Appearance: [`reshape`]d when it has
|
||||
/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand
|
||||
/// Appearance: [`reshape`]d when it has
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None`
|
||||
/// when it has neither.
|
||||
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
// A path whose Transform effect copies it with a gradient: each copy an object of its own.
|
||||
if has_gradient_copies(n) {
|
||||
return as_art(n, symbol_art);
|
||||
}
|
||||
let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty();
|
||||
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
|
||||
return None;
|
||||
@@ -268,13 +294,21 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
for e in fx {
|
||||
let Some(bounds) = art.geometric_bounds() else { break };
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
let transform = (e.id == "distort.transform").then(|| crate::distort::transform_map(bounds, ¶ms, seed));
|
||||
for_each_leaf(art, &mut |leaf| {
|
||||
let Some((path, rule)) = leaf_geometry(leaf) else { return };
|
||||
// A gradient paints each copy by itself, not the one piece the copies make together.
|
||||
if let Some((m, copies)) = transform
|
||||
&& copies > 0
|
||||
&& (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient())
|
||||
{
|
||||
copy_with_gradients(leaf, &path, m, copies);
|
||||
return;
|
||||
}
|
||||
let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
if e.id == "distort.transform" {
|
||||
let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed);
|
||||
if let Some((m, copies)) = transform {
|
||||
transform_placed(art, m, copies);
|
||||
}
|
||||
}
|
||||
@@ -283,6 +317,34 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
|
||||
/// more, each mapped once more by `m` than the last, as a Transform effect copies an object. Each
|
||||
/// copy is an object of its own, so its gradients fit it (a placed gradient moves with it) rather
|
||||
/// than spanning every copy. The copies stop where [`crate::distort::transform_within`]'s would.
|
||||
fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize) {
|
||||
let each = path.anchor_count();
|
||||
let mut anchors = each;
|
||||
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
|
||||
let mut xf = Affine::IDENTITY;
|
||||
for _ in 0..copies {
|
||||
anchors = anchors.saturating_add(each);
|
||||
if anchors > crate::distort::MAX_COPY_ANCHORS {
|
||||
break;
|
||||
}
|
||||
xf *= m;
|
||||
let mut copy = leaf.clone();
|
||||
copy.transform(xf, vectorcraft_doc::Scaling::default());
|
||||
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
|
||||
break;
|
||||
}
|
||||
placements.push(Arc::new(copy));
|
||||
}
|
||||
// The copies become nodes of their own under a group with the object's id and name.
|
||||
let mut g = Node::group(leaf.id, placements);
|
||||
g.name = leaf.name.clone();
|
||||
*leaf = g;
|
||||
}
|
||||
|
||||
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
|
||||
/// order. A group inside one of them is part of it.
|
||||
fn type_outline_bounds(n: &Node, out: &mut Vec<Option<Rect>>) {
|
||||
|
||||
@@ -510,6 +510,31 @@ fn zigzag_spaces_ridges_evenly_along_a_straight_line() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2343: Smooth points make a continuous wave, not a zigzag with rounded tips. Every peak and
|
||||
/// valley is a smooth point whose handles run parallel to the line, and the curve between them
|
||||
/// follows a cosine, even where the ridges are much taller than they are apart.
|
||||
#[test]
|
||||
fn zigzag_smooth_is_a_wave() {
|
||||
use vectorcraft_geom::kurbo::ParamCurve;
|
||||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false));
|
||||
for size in [3.0, 30.0] {
|
||||
let out = run("distort.zigZag", json!({"ridges": 9, "points": "smooth", "size": size}), &line);
|
||||
let sp = &out.subpaths[0];
|
||||
assert_eq!(sp.anchors.len(), 11);
|
||||
for a in &sp.anchors {
|
||||
assert!(close(a.h_in.y, a.p.y, 1e-9) && close(a.h_out.y, a.p.y, 1e-9), "size {size}: handles not level at {a:?}");
|
||||
}
|
||||
for i in 0..sp.segment_count() {
|
||||
let c = sp.segment(i);
|
||||
for k in 0..=20 {
|
||||
let q = c.eval(k as f64 / 20.0);
|
||||
let want = -size * (std::f64::consts::PI * q.x / 10.0).cos();
|
||||
assert!((q.y - want).abs() < 0.03 * size, "size {size}: {q:?} off the wave ({want})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in
|
||||
/// turn all the way round.
|
||||
#[test]
|
||||
|
||||
@@ -80,22 +80,22 @@ pub struct Clipboard {
|
||||
pub para_styles: Vec<TextStyleDef>,
|
||||
/// Brushes the objects' strokes use.
|
||||
pub brushes: Vec<Brush>,
|
||||
/// The artboard the objects were copied from (none when they lie on no artboard): Paste on All
|
||||
/// Artboards keeps their offset to it.
|
||||
/// The artboard the objects were copied from (none when they lie on no artboard; the first of
|
||||
/// [`Self::artboards`] when there are any): Paste on All Artboards keeps their offset to it.
|
||||
pub source_artboard: Option<Rect>,
|
||||
/// Per object: the name of the layer it was copied from (Paste Remembers Layers).
|
||||
pub source_layers: Vec<Option<String>>,
|
||||
/// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its
|
||||
/// own resources as they are now.
|
||||
pub source_doc: Option<u64>,
|
||||
/// An artboard copied with the Artboard tool (`artboard.copy`); `nodes` are then its art. Pasting
|
||||
/// adds a copy of it with the art.
|
||||
pub artboard: Option<vectorcraft_doc::Artboard>,
|
||||
/// The artboards copied with the Artboard tool (`artboard.copy`); `nodes` are then their art.
|
||||
/// Pasting adds a copy of each with the art.
|
||||
pub artboards: Vec<vectorcraft_doc::Artboard>,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty() && self.artboard.is_none()
|
||||
self.nodes.is_empty() && self.artboards.is_empty()
|
||||
}
|
||||
|
||||
/// Objects `roots` of `st`'s document with their resources, source artboard and layers.
|
||||
|
||||
@@ -39,10 +39,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load Color Profile…",
|
||||
[],
|
||||
None,
|
||||
"{path: .icc/.icm file} register an ICC profile until the app quits or the `run` ends (native only; it isn't remembered, so load it again in each run that names it) → {name, kind}",
|
||||
"{path: .icc/.icm file of at most 32 MB} register an ICC profile (native only). The desktop app and vectorcraft-cli keep a copy of the file in the `Color Profiles` folder next to the preferences and register it again at every start (not with VECTORCRAFT_NO_PREFS, or in a confined `mcp --automation-*-root` server: there it lasts until the process ends) → {name, kind, remembered: whether a copy was kept}",
|
||||
always,
|
||||
load_profile
|
||||
),
|
||||
cmd!(
|
||||
"color.removeProfile",
|
||||
"Remove Color Profile…",
|
||||
[],
|
||||
None,
|
||||
"{name} forget a profile color.loadProfile loaded, and the copy kept of it (built-in profiles and the working spaces stay) → {removed: name}",
|
||||
always,
|
||||
remove_profile
|
||||
),
|
||||
cmd!(
|
||||
"edit.assignProfile",
|
||||
"Assign Profile…",
|
||||
@@ -178,12 +187,35 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_profile(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "color.loadProfile";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)?;
|
||||
let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?;
|
||||
Ok(json!({"name": info.name, "kind": info.kind}))
|
||||
let bytes = crate::store::read_capped(std::path::Path::new(path), crate::store::MAX_PROFILE_FILE).map_err(|e| bad(C, format!("{path}: {e}")))?;
|
||||
let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?;
|
||||
// The bytes are kept, not the path: the file may move or go away.
|
||||
let remembered = s.store_profile(&info.name, &bytes);
|
||||
Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered}))
|
||||
}
|
||||
|
||||
/// Forget a profile `color.loadProfile` loaded, and the copy that is kept of it.
|
||||
fn remove_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "color.removeProfile";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let info = cms::profiles().into_iter().find(|i| i.name == name).ok_or_else(|| bad(C, format!("no profile named `{name}`")))?;
|
||||
if info.builtin {
|
||||
return Err(bad(C, format!("`{name}` is a built-in profile and stays")));
|
||||
}
|
||||
let working = cms::active_settings();
|
||||
if working.rgb == name || working.cmyk == name {
|
||||
return Err(bad(C, format!("`{name}` is a working space: choose another with edit.colorSettings first")));
|
||||
}
|
||||
if !cms::unregister_icc(&info.name) {
|
||||
return Err(bad(C, format!("no profile named `{name}`")));
|
||||
}
|
||||
s.unstore_profile(&info.name);
|
||||
touch_all(s);
|
||||
Ok(json!({"removed": info.name}))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -512,6 +512,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?;
|
||||
let id = made.get("id").and_then(Value::as_u64).map(NodeId);
|
||||
let made = super::typecmd::with_bounds(s, id, made);
|
||||
Ok(super::typecmd::with_overflow(s, id.as_slice(), made))
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
|
||||
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste",
|
||||
["Edit"],
|
||||
Some("Cmd+V"),
|
||||
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). An artboard copied with artboard.copy comes with its art, right of the last artboard (the other paste commands but Paste on All Artboards: where it was), its art back in the layers it came from → {ids, artboard: the new artboard's index or null, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Offset)
|
||||
),
|
||||
@@ -172,6 +172,26 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if summary { Ok(inspect::node_summary_opts(n, opts, &doc.symbol_boxes())) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
|
||||
}
|
||||
|
||||
/// Most node ids `document.inspect {ids}` takes.
|
||||
const MAX_INSPECT_IDS: usize = 10_000;
|
||||
|
||||
fn doc_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.inspect";
|
||||
let opts = slice_opts(p, C)?;
|
||||
let ids = match p.get("ids") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) if a.len() > MAX_INSPECT_IDS => return Err(bad(C, format!("`ids` takes at most {MAX_INSPECT_IDS} ids"))),
|
||||
Some(_) => Some(checked_ids_param(s, p, "ids", C)?),
|
||||
};
|
||||
let kind = match p.get("kind") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(k)) => Some(k.clone()),
|
||||
Some(_) => return Err(bad(C, "`kind` must be a string")),
|
||||
};
|
||||
let scope = inspect::Scope { ids, kind, limit: opt_u64(p, C, "limit")?, offset: opt_u64(p, C, "offset")?.unwrap_or(0) };
|
||||
Ok(inspect::document_scoped(s, opts, &scope))
|
||||
}
|
||||
|
||||
/// `depth` / `childLimit` for a summary read: absent (or null) means no limit.
|
||||
fn slice_opts(p: &Value, cmd: &str) -> Result<inspect::SummaryOpts> {
|
||||
Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")?, compound_member: false })
|
||||
@@ -495,16 +515,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
let st = s.doc()?;
|
||||
let same_doc = clip.source_doc == Some(st.uid);
|
||||
let parent = st.target_parent()?;
|
||||
// A copied artboard comes back with its art: right of the last artboard, or where it was
|
||||
// (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art.
|
||||
let board = clip
|
||||
.artboard
|
||||
.as_ref()
|
||||
// Copied artboards come back with their art: together right of the last artboard, as they lay,
|
||||
// or where they were (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the
|
||||
// art.
|
||||
let boards = clip
|
||||
.artboards
|
||||
.iter()
|
||||
.map(|a| a.rect)
|
||||
.reduce(|a, b| a.union(b))
|
||||
.filter(|_| mode != PasteMode::AllArtboards)
|
||||
.map(|a| (a, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, a.rect) } else { Vec2::ZERO }));
|
||||
.map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO }));
|
||||
// Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art
|
||||
// always goes back into its layers.
|
||||
let remember = (st.doc.paste_remembers_layers || board.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
let remember = (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
// Front/back: relative to the selection (top-most / bottom-most selected object); with
|
||||
// nothing selected, the top / bottom of the current layer.
|
||||
let anchor = match mode {
|
||||
@@ -515,7 +538,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let placements: Vec<Affine> = match (mode, board) {
|
||||
let placements: Vec<Affine> = match (mode, boards) {
|
||||
(_, Some((_, dv))) => vec![Affine::translate(dv)],
|
||||
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
|
||||
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
||||
@@ -528,9 +551,13 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
// In place, in front, in back: onto the active artboard when the UI names it.
|
||||
_ => vec![onto_artboard(&st.doc, p, clip)],
|
||||
};
|
||||
let (ids, imported, artboard) = s.edit(mode.label(), |d, sel| {
|
||||
let (ids, imported, artboards) = s.edit(mode.label(), |d, sel| {
|
||||
let imported = clip.import_into(d, choices, same_doc);
|
||||
let artboard = board.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv));
|
||||
let artboards: Vec<usize> = boards
|
||||
.into_iter()
|
||||
.flat_map(|(all, dv)| all.iter().map(move |a| (a, dv)))
|
||||
.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv))
|
||||
.collect();
|
||||
let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new();
|
||||
// Objects pasted into each parent so far (keeps their order in front and back pastes).
|
||||
let mut placed: BTreeMap<Option<NodeId>, usize> = BTreeMap::new();
|
||||
@@ -565,15 +592,16 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
// Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are
|
||||
// right away (opening the file later would, but paste must not depend on a reopen).
|
||||
super::typecmd::refresh_bounds_of(d, &new_ids);
|
||||
Ok((new_ids, imported, artboard))
|
||||
Ok((new_ids, imported, artboards))
|
||||
})?;
|
||||
// The Artboard tool takes the pasted artboard.
|
||||
if let Some(i) = artboard.filter(|_| s.tool_id() == "artboard") {
|
||||
s.set_tool_option("active", &json!(i));
|
||||
// The Artboard tool selects the pasted artboards, the first one active.
|
||||
if !artboards.is_empty() && s.tool_id() == "artboard" {
|
||||
s.set_tool_option("selected", &json!(artboards));
|
||||
}
|
||||
Ok(json!({
|
||||
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"artboard": artboard,
|
||||
"artboard": artboards.first(),
|
||||
"artboards": artboards,
|
||||
"added": imported.added,
|
||||
"merged": imported.merged,
|
||||
"renamed": imported.renamed,
|
||||
|
||||
@@ -138,6 +138,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !params.is_null() && !params.is_object() {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
}
|
||||
super::params::check_effect_color(C, id, ¶ms)?;
|
||||
// Crop Marks take their style from the preferences when applied.
|
||||
if id == effects::CROP_MARKS && params.get("style").is_none() {
|
||||
params["style"] = json!(s.crop_mark_style().id());
|
||||
@@ -313,7 +314,11 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.transpose()?;
|
||||
let visible = p.get("visible").and_then(Value::as_bool);
|
||||
let item = item_target(s, p, C)?;
|
||||
if set_params_effects(s, p).iter().any(|e| e == GLASS) {
|
||||
let edited = set_params_effects(s, p);
|
||||
for effect in &edited {
|
||||
super::params::check_effect_color(C, effect, ¶ms)?;
|
||||
}
|
||||
if edited.iter().any(|e| e == GLASS) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
for id in appearance_targets(s, p)? {
|
||||
@@ -504,10 +509,11 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
|
||||
expand_pieces(d, &mut m, false, &mut stroke_art);
|
||||
m
|
||||
})
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) {
|
||||
effects::expand_leaf(d, &v, &mut stroke_art)
|
||||
} else {
|
||||
// Type, images, symbol instances, live objects: through their outlines.
|
||||
// Type, images, symbol instances, live objects (and paths whose Transform effect copies
|
||||
// them with a gradient): through their outlines.
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
@@ -995,4 +1001,27 @@ mod tests {
|
||||
assert_eq!(ch.len(), 1);
|
||||
assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() {
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
let (mut s, id) = session_with_rect();
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
s.edit("gradient", |d, _| {
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.set_fill(ramp);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
let mut boxes = vec![];
|
||||
node(&s, id).walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) {
|
||||
boxes.push(n.geometric_bounds().unwrap().width());
|
||||
}
|
||||
});
|
||||
assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient");
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,9 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run:
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
||||
use super::distinct_names;
|
||||
use super::screens::file_part;
|
||||
|
||||
let st = s.doc()?;
|
||||
let names: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
||||
if names.is_empty() {
|
||||
@@ -136,12 +139,16 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets")));
|
||||
}
|
||||
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
|
||||
// Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder
|
||||
// destination uses the same name). `file_part` writes `/`, `\` and `:` in the data set's name as
|
||||
// `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
|
||||
let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name))));
|
||||
let steps = filter_steps(run.steps, true, false);
|
||||
let run = Run { steps: &steps, ..*run };
|
||||
let mut processed = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for name in &names {
|
||||
match process_one_dataset(s, &run, name, &doc_stem) {
|
||||
for (name, stem) in names.iter().zip(&stems) {
|
||||
match process_one_dataset(s, &run, name, stem) {
|
||||
Ok(row) => processed.push(row),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
@@ -184,14 +191,12 @@ fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext:
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, doc_stem: &str) -> Result<Value> {
|
||||
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, stem: &str) -> Result<Value> {
|
||||
s.execute("dataset.select", &json!({"name": name}))?;
|
||||
// File + Data Set Name naming when overriding Save/Export (helpx).
|
||||
let stem = format!("{doc_stem}_{name}");
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, "vectorcraft");
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, "vectorcraft");
|
||||
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
|
||||
// Data sets share one open document: write each row out but leave it open for the next.
|
||||
finish_destination(s, run, &stem, "vectorcraft", "", false)?;
|
||||
finish_destination(s, run, stem, "vectorcraft", "", false)?;
|
||||
Ok(json!({"dataset": name, "result": batch}))
|
||||
}
|
||||
|
||||
@@ -434,6 +439,102 @@ mod tests {
|
||||
assert_eq!(written.len(), 2, "{written:?}");
|
||||
}
|
||||
|
||||
/// The names of the files in `dir`, sorted.
|
||||
fn file_names(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut names: Vec<String> =
|
||||
std::fs::read_dir(dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// A new document with an empty data set for each of `names`.
|
||||
fn with_data_sets(names: &[&str]) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
for name in names {
|
||||
s.execute("dataset.new", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_keeps_data_set_files_inside_the_destination_folder() {
|
||||
let root = tmp("set-names-root");
|
||||
let dest = root.join("out");
|
||||
// The folder that `a/../../x` would pass through on its way out of `out`.
|
||||
std::fs::create_dir_all(dest.join("Untitled_a")).unwrap();
|
||||
let mut s = with_data_sets(&["a/../../x", "../y"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_..-y.vectorcraft", "Untitled_a", "Untitled_a-..-..-x.vectorcraft"], "{r}");
|
||||
assert_eq!(file_names(&root), ["out"], "nothing is written outside the destination folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_saves_data_sets_named_with_separators() {
|
||||
let dest = tmp("set-names-sep");
|
||||
let exports = tmp("set-names-sep-export");
|
||||
let mut s = with_data_sets(&["a/b", "c\\d", "e:f"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_a-b.vectorcraft", "Untitled_c-d.vectorcraft", "Untitled_e-f.vectorcraft"], "{r}");
|
||||
// Override Export points each data set's export at the destination folder under the same name.
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destinationFolder": exports.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "document.export", "params": {"format": "svg"}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_gives_each_data_set_a_file_of_its_own() {
|
||||
let dest = tmp("set-names-same");
|
||||
let mut s = with_data_sets(&["a:b", "a-b", "A-B"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_A-B-3.vectorcraft", "Untitled_a-b-2.vectorcraft", "Untitled_a-b.vectorcraft"], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_refuses_nesting_and_empty_commands() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -413,6 +413,8 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let mut enc = encode_files(doc, f, p)?;
|
||||
// Baking, flattening and drawing stop early when cancelled and leave a truncated result.
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
// An image the renderer couldn't decode draws nothing: say so rather than write a picture
|
||||
// that silently lacks it.
|
||||
if f.raster && vectorcraft_render::decode_failures() > failed {
|
||||
@@ -502,7 +504,10 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
};
|
||||
let mut enc = Encoded::default();
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
for b in chosen {
|
||||
let total = chosen.len() as u64;
|
||||
for (done, b) in chosen.into_iter().enumerate() {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
vectorcraft_doc::cancel::progress(done as u64, total, &format!("Rendering artboard {} of {total}", done + 1));
|
||||
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
|
||||
check_format_size(f, region.width() * scale, region.height() * scale)?;
|
||||
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
|
||||
|
||||
@@ -447,18 +447,28 @@ pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC;
|
||||
/// decode, and for bytes that aren't an image.
|
||||
pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
use image::ImageDecoder as _;
|
||||
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
|
||||
return Some(format!("an image of {w} × {h} pixels is too large to draw"));
|
||||
}
|
||||
let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
|
||||
let (w, h) = d.dimensions();
|
||||
(d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
|
||||
}
|
||||
|
||||
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
|
||||
/// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop
|
||||
/// documents, whose merged image is read ([`psdread`]). Grayscale TIFFs with an alpha channel or
|
||||
/// other extra channels, which the decoder can't read either, become RGBA too.
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]), except that ones storing one plane per
|
||||
/// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]);
|
||||
/// CMYK TIFFs with an alpha channel, which the decoder can't read, become RGBA in the active color
|
||||
/// settings' CMYK, as do CMYK Photoshop documents, whose merged image is read ([`psdread`]).
|
||||
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
|
||||
/// either, become RGBA too, whether they store their channels together or one plane per channel.
|
||||
/// A TIFF with more strips or tiles than the decoder can count
|
||||
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw.
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let ppi = super::ppi::resolution(bytes);
|
||||
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
|
||||
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
if psdread::is_psd(bytes) {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
|
||||
@@ -469,10 +479,14 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
reader.limits(limits);
|
||||
let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?;
|
||||
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
|
||||
let cmyk = || ImageBlob::new(f.mime, bytes.to_vec()).cmyk().is_some();
|
||||
if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) {
|
||||
let kept = match f.id {
|
||||
"png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()),
|
||||
"tiff" => vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, ppi),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kept) = kept {
|
||||
let (width, height) = reader.into_dimensions().map_err(err)?;
|
||||
return stored(bytes.to_vec(), f.mime, (width, height), ppi);
|
||||
return stored(kept, f.mime, (width, height), ppi);
|
||||
}
|
||||
let extra_samples = || {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
|
||||
@@ -186,7 +186,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Batch…",
|
||||
[],
|
||||
None,
|
||||
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for Override Save/Export). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
|
||||
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\` and `:` in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
|
||||
always,
|
||||
action_batch::action_batch
|
||||
),
|
||||
@@ -861,6 +861,8 @@ pub(crate) fn read_file_head(path: &str, max: usize) -> Result<Vec<u8>> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
// Work that stops early for a cancel can leave a truncated result: none of it is written.
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
may_write(path)?;
|
||||
// Never a half-written file: see [`write_atomic`].
|
||||
write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
@@ -869,6 +871,7 @@ pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
/// [`write_file`] with what `fill` writes into the file (a save streamed into it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
may_write(path)?;
|
||||
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
@@ -1073,14 +1076,18 @@ pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) ->
|
||||
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
|
||||
/// (compared without case).
|
||||
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
distinct_names(names.into_iter().map(|(name, fallback)| {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() { fallback } else { base }
|
||||
}))
|
||||
}
|
||||
|
||||
/// `names` with `-2`, `-3`… after a name already used (compared without case).
|
||||
fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
names
|
||||
.into_iter()
|
||||
.map(|(name, fallback)| {
|
||||
let mut base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() {
|
||||
base = fallback;
|
||||
}
|
||||
.map(|base| {
|
||||
let mut name = base.clone();
|
||||
for i in 2.. {
|
||||
if taken.insert(name.to_lowercase()) {
|
||||
|
||||
@@ -169,6 +169,7 @@ pub(crate) fn merge(base: &mut Value, over: &Value) {
|
||||
pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
|
||||
match e {
|
||||
PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()),
|
||||
PdfError::Cancelled => EngineError::Cancelled,
|
||||
e => EngineError::Other(e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -273,7 +274,11 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
|
||||
}
|
||||
}
|
||||
let set = &opts.settings;
|
||||
if set.pdf13() {
|
||||
vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency");
|
||||
}
|
||||
let flat = flatten_for(cmd, doc, set, p)?;
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
if flat.is_some() {
|
||||
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
|
||||
warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"));
|
||||
|
||||
@@ -185,6 +185,9 @@ fn parent_folder(path: &str) -> Option<String> {
|
||||
fn save_format_of(f: &str) -> std::result::Result<&'static Format, String> {
|
||||
match format(f) {
|
||||
Some(f) if SAVE_FORMATS.contains(&f.id) => Ok(f),
|
||||
Some(f) if TEMPLATE_FORMATS.contains(&f.id) => {
|
||||
Err(format!("Save writes {}, not {}: use file.saveAsTemplate to write it", SAVE_FORMATS.join(", "), f.label))
|
||||
}
|
||||
Some(f) => Err(format!("Save writes {}, not {}: use document.export for other formats", SAVE_FORMATS.join(", "), f.label)),
|
||||
None => Err(format!("unknown format `{f}` (Save writes {})", SAVE_FORMATS.join(", "))),
|
||||
}
|
||||
@@ -196,6 +199,18 @@ pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Res
|
||||
save_format_of(format.or_else(|| path.and_then(format_for_name).map(|f| f.id)).unwrap_or("vectorcraft"))
|
||||
}
|
||||
|
||||
/// `path` for a template in format `f`: kept when its extension names `f`, with the extension
|
||||
/// replaced when it names another format (`card.png` → `card.vctemplate`), else with `f`'s
|
||||
/// extension added (`card`, `card v1.2`).
|
||||
fn template_path(path: &str, f: &Format) -> String {
|
||||
let ext = f.extensions[0];
|
||||
match format_for_name(path) {
|
||||
Some(g) if g.id == f.id => path.to_string(),
|
||||
Some(_) => std::path::Path::new(path).with_extension(ext).to_string_lossy().into_owned(),
|
||||
None => format!("{path}.{ext}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`save_format_of`] for command `cmd`.
|
||||
fn checked(cmd: &str, f: &str) -> Result<&'static Format> {
|
||||
save_format_of(f).map_err(|e| bad(cmd, e))
|
||||
@@ -217,6 +232,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
(_, None, Some(f)) => checked(cmd, f.id)?,
|
||||
(_, None, None) => checked(cmd, st.format)?,
|
||||
};
|
||||
let path = if mode == SaveMode::Template { path.map(|p| template_path(&p, format)) } else { path };
|
||||
let mut given: Map<String, Value> = match p.get("options") {
|
||||
Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(),
|
||||
None | Some(Value::Null) => Map::new(),
|
||||
@@ -755,7 +771,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save As…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+S"),
|
||||
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
|
||||
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::SaveAs, p)
|
||||
),
|
||||
@@ -773,7 +789,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{…as document.save} {path?, format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
"{…as document.save} {path? (saved with the format's extension: one naming another format is replaced, and one is added to a name without it), format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Template, p)
|
||||
),
|
||||
|
||||
@@ -148,9 +148,9 @@ fn safe_name(s: &str) -> Option<String> {
|
||||
(!n.trim_matches('.').is_empty()).then_some(n)
|
||||
}
|
||||
|
||||
/// Text that goes into a file name (prefix, suffix): path separators and `:` become `-`, so it can't
|
||||
/// start a folder or climb out of the export folder.
|
||||
fn file_part(s: &str) -> String {
|
||||
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch): path separators and
|
||||
/// `:` become `-`, so the name cannot make a subfolder or point outside the folder the file is written to.
|
||||
pub(super) fn file_part(s: &str) -> String {
|
||||
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,100 @@ fn cmyk_tiffs_keep_their_inks_and_other_tiffs_become_png() {
|
||||
assert_eq!(rgb.blob.mime, "image/png");
|
||||
}
|
||||
|
||||
/// A 3 × 2 TIFF at 150 ppi of `channels` (`extra`: their ExtraSamples values), 8 or 16 `bits` a
|
||||
/// sample, chunky or `planar`.
|
||||
fn hand_tiff(photometric: u16, bits: u16, extra: &[u16], planar: bool, channels: &[Vec<u16>]) -> Vec<u8> {
|
||||
let t = vectorcraft_testkit::tiff::Tiff { width: 3, height: 2, photometric, bits, extra, planar, ppi: Some(150), channels };
|
||||
vectorcraft_testkit::tiff::tiff(&t)
|
||||
}
|
||||
|
||||
/// What Place keeps of TIFF `bytes`: the ink amounts, when it keeps them, and the pixels any image
|
||||
/// reader decodes from the stored image, as RGBA.
|
||||
fn kept(what: &str, bytes: &[u8]) -> (Option<Vec<u8>>, Vec<u8>) {
|
||||
let r = raster_image(bytes).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!((r.width, r.height, r.ppi), (3, 2, Some((150.0, 150.0))), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&r.blob.bytes), Some((150.0, 150.0)), "{what}: the stored image keeps the resolution");
|
||||
let rgba = image::load_from_memory(&r.blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_rgba8().into_raw();
|
||||
(r.blob.cmyk().map(|i| i.data), rgba)
|
||||
}
|
||||
|
||||
/// TIFFs that store one plane per channel (PlanarConfiguration 2) read as their chunky versions do:
|
||||
/// grayscale and RGB with extra channels, and CMYK with and without them (#1466).
|
||||
#[test]
|
||||
fn planar_tiffs_read_as_their_chunky_versions() {
|
||||
let ch = |v: [u16; 6]| v.to_vec();
|
||||
let (gray, alpha, spot) = (ch([0, 51, 102, 153, 204, 255]), ch([255, 128, 0, 255, 64, 255]), ch([9, 8, 7, 6, 5, 4]));
|
||||
let (r, g, b) = (ch([255, 0, 0, 10, 20, 30]), ch([0, 255, 0, 40, 50, 60]), ch([0, 0, 255, 70, 80, 90]));
|
||||
let (c, m, y, k) = (ch([255, 0, 0, 0, 12, 200]), ch([0, 255, 0, 0, 34, 100]), ch([0, 0, 255, 0, 56, 50]), ch([0, 0, 0, 255, 78, 0]));
|
||||
// Pixel by pixel, the samples of `px`.
|
||||
let interleave = |px: &[&Vec<u16>]| -> Vec<u8> { (0..6).flat_map(|i| px.iter().map(move |v| v[i] as u8)).collect() };
|
||||
let opaque = ch([255; 6]);
|
||||
let inks = interleave(&[&c, &m, &y, &k]);
|
||||
// (photometric, ExtraSamples, channels, the RGBA pixels when the color is gray or RGB, the inks when it is CMYK)
|
||||
type Case = (u16, Vec<u16>, Vec<Vec<u16>>, Option<Vec<u8>>, Option<Vec<u8>>);
|
||||
let cases: Vec<Case> = vec![
|
||||
(1, vec![2], vec![gray.clone(), alpha.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None),
|
||||
(1, vec![2, 0], vec![gray.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None),
|
||||
(2, vec![0], vec![r.clone(), g.clone(), b.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &opaque])), None),
|
||||
(2, vec![2, 0], vec![r.clone(), g.clone(), b.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &alpha])), None),
|
||||
(5, vec![], vec![c.clone(), m.clone(), y.clone(), k.clone()], None, Some(inks.clone())),
|
||||
(5, vec![0], vec![c.clone(), m.clone(), y.clone(), k.clone(), spot.clone()], None, Some(inks.clone())),
|
||||
(5, vec![2], vec![c.clone(), m.clone(), y.clone(), k.clone(), alpha.clone()], None, None),
|
||||
];
|
||||
for (photometric, extra, channels, want_rgba, want_inks) in cases {
|
||||
for bits in [8, 16] {
|
||||
let wide: Vec<Vec<u16>> = channels.iter().map(|ch| ch.iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect();
|
||||
let what = format!("photometric {photometric}, extra samples {extra:?}, {bits} bits");
|
||||
let chunky = kept(&format!("{what}, chunky"), &hand_tiff(photometric, bits, &extra, false, &wide));
|
||||
let planar = kept(&format!("{what}, planar"), &hand_tiff(photometric, bits, &extra, true, &wide));
|
||||
assert_eq!(planar.0, chunky.0, "{what}: planar keeps the inks chunky keeps");
|
||||
assert_eq!(planar.0, want_inks, "{what}: the inks");
|
||||
// A 16-bit chunky CMYK file is stored as it is, and a planar one is written again with
|
||||
// its 8-bit inks: their RGB pixels may be a level apart.
|
||||
let close = planar.1.len() == chunky.1.len() && planar.1.iter().zip(&chunky.1).all(|(a, b)| a.abs_diff(*b) <= 1);
|
||||
assert!(close, "{what}: planar reads as chunky: {:?} against {:?}", planar.1, chunky.1);
|
||||
if let Some(want) = &want_rgba {
|
||||
assert_eq!(&planar.1, want, "{what}: the pixels");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows
|
||||
/// a strip each, times 5 planes) is refused as too large to draw, without a panic.
|
||||
#[test]
|
||||
fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
|
||||
let plane = |v: u16| vec![v; 6];
|
||||
let mut file = hand_tiff(5, 8, &[0], true, &[plane(200), plane(40), plane(10), plane(0), plane(90)]);
|
||||
// ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the
|
||||
// eighth, at offset 102).
|
||||
file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes());
|
||||
file[102..106].copy_from_slice(&1u32.to_le_bytes());
|
||||
let e = raster_image(&file).err().expect("refused");
|
||||
assert!(e.to_string().contains("an image of 3 × 2147483649 pixels is too large to draw"), "{e}");
|
||||
assert!(too_large_note(&file).is_some());
|
||||
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
|
||||
}
|
||||
|
||||
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
|
||||
/// reads (#1466).
|
||||
#[test]
|
||||
fn planar_cmyk_tiffs_open_and_place_with_their_inks() {
|
||||
let inks = [vec![255u16, 0, 12, 0, 40, 9], vec![0, 255, 34, 0, 50, 8], vec![0, 0, 56, 0, 60, 7], vec![0, 0, 78, 255, 70, 6]];
|
||||
let file = hand_tiff(5, 8, &[], true, &inks);
|
||||
let want: Vec<u8> = (0..6).flat_map(|i| inks.iter().map(move |v| v[i] as u8)).collect();
|
||||
assert!(image::load_from_memory(&file).is_err(), "the image decoder reads planar CMYK now");
|
||||
let mut s = Session::new();
|
||||
open(&mut s, "planar.tif", &file);
|
||||
let blob = &s.doc().unwrap().doc.images[&image_of(&s).key];
|
||||
assert_eq!(blob.cmyk().map(|i| i.data), Some(want.clone()), "Open keeps the inks");
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
s.execute("file.place", &json!({"name": "planar.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
|
||||
let blob = &s.doc().unwrap().doc.images[&image_of(&s).key];
|
||||
assert_eq!(blob.cmyk().map(|i| i.data), Some(want), "Place keeps the inks");
|
||||
assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_beats_a_wrong_extension() {
|
||||
let png = image_bytes(2, 2, image::ImageFormat::Png);
|
||||
@@ -129,7 +223,8 @@ fn templates_open_untitled() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let dir = tmp_dir("tpl");
|
||||
let path = dir.join("t.vectorcraft").to_string_lossy().to_string();
|
||||
s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
|
||||
// A template under a native file name still opens untitled.
|
||||
std::fs::write(&path, b64(&s.execute("file.saveAsTemplate", &json!({})).unwrap())).unwrap();
|
||||
let r = s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
|
||||
@@ -201,6 +201,37 @@ fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() {
|
||||
assert!(vectorcraft_pdf::editing(&b64(&r)).is_some_and(|e| e.intact));
|
||||
}
|
||||
|
||||
/// Save as Template writes a file named with its template format's extension: another format's
|
||||
/// extension is replaced and a name without one gets it; Save and Save As name the command that
|
||||
/// writes a `.ait`.
|
||||
#[test]
|
||||
fn save_as_template_names_its_file_after_the_template_format() {
|
||||
let mut s = rich();
|
||||
for (name, format, saved) in [
|
||||
("card.png", None, "card.vctemplate"),
|
||||
("card.vectorcraft", None, "card.vctemplate"),
|
||||
("card.ait", Some("template"), "card.vctemplate"),
|
||||
("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 _ = std::fs::create_dir_all(std::path::Path::new(&typed).parent().unwrap());
|
||||
let _ = std::fs::remove_file(&typed);
|
||||
let _ = std::fs::remove_file(&want);
|
||||
let r = s.execute("file.saveAsTemplate", &json!({"path": typed, "format": format})).unwrap();
|
||||
assert_eq!(r["path"], want.as_str(), "{name}: {r}");
|
||||
let bytes = std::fs::read(&want).unwrap_or_else(|e| panic!("{name}: {saved} not written: {e}"));
|
||||
let ait = saved.ends_with(".ait");
|
||||
assert_eq!(bytes.starts_with(b"%PDF"), ait, "{name}");
|
||||
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")}))] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("file.saveAsTemplate") && !e.contains("document.export"), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standards_and_screens_leave_the_editing_data_out() {
|
||||
let mut s = rich();
|
||||
|
||||
@@ -818,7 +818,10 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R
|
||||
let see_through: Vec<bool> = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect();
|
||||
let cmyk = src.color_mode == ColorMode::Cmyk;
|
||||
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
|
||||
// Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing).
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
for g in overlapping(&reaches) {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
|
||||
let group = g.iter().any(|&i| see_through[i]).then(|| {
|
||||
let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect();
|
||||
@@ -950,6 +953,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
|
||||
);
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
|
||||
// The drawing stops early when cancelled: this image is not whole.
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
|
||||
// Without transparency the clip alone shapes the image: it is opaque, over white (its
|
||||
// edge pixels too).
|
||||
|
||||
@@ -459,7 +459,7 @@ pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: boo
|
||||
if let Some(k) = &it.copies
|
||||
&& Arc::ptr_eq(&k.from, &it.doc)
|
||||
&& k.roots == roots
|
||||
&& (!k.selection.guides.is_empty() == has_guide_copies)
|
||||
&& (k.selection.guides.is_empty() != has_guide_copies)
|
||||
{
|
||||
return Ok(Some(k.clone()));
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index?: n (default: the Artboard tool's artboard), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} copy placed right of the last artboard, with copies of the art fully inside it when `art` (locked and hidden art only with prefs moveLockedWithArtboard) → {index, ids: the art's copies}",
|
||||
"{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}",
|
||||
has_doc,
|
||||
artboard_duplicate
|
||||
),
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index?, art?} (defaults as artboard.duplicate) put the artboard, and the art fully inside it when `art`, on the clipboard: edit.paste adds a copy right of the last artboard, edit.pasteInPlace/InFront/InBack where it was, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
"{index? | indices?, art?} (defaults as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
has_doc,
|
||||
|s, p| artboard_copy(s, p, false)
|
||||
),
|
||||
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cut Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index?, art?} artboard.copy, then delete the artboard and that art in one undo step (not the only artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
"{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
has_doc,
|
||||
|s, p| artboard_copy(s, p, true)
|
||||
),
|
||||
@@ -68,16 +68,28 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The artboard (`index`) and whether its art comes along (`art`) for the artboard copy
|
||||
/// commands: by default the Artboard tool's artboard and its Move/Copy Artwork with Artboard option.
|
||||
/// An `index` that names no artboard is an error.
|
||||
fn board_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(usize, bool)> {
|
||||
/// The artboards (`index` or `indices`) and whether their art comes along (`art`) for the artboard
|
||||
/// copy commands: by default the artboards the Artboard tool has selected (its active one when none
|
||||
/// of them is in the document) and its Move/Copy Artwork with Artboard option.
|
||||
fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<usize>, bool)> {
|
||||
let t = s.tool_options_of("artboard");
|
||||
let i = match p.get("index") {
|
||||
Some(_) => artboard_index(&s.doc()?.doc, p, cmd)?,
|
||||
None => t["active"].as_u64().unwrap_or(0) as usize,
|
||||
let d = &s.doc()?.doc;
|
||||
let indices = if p.get("index").is_some() || p.get("indices").is_some() {
|
||||
artboard_indices(d, p, cmd)?
|
||||
} else {
|
||||
let mut picked: Vec<u64> = t["selected"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_u64)
|
||||
.filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len()))
|
||||
.collect();
|
||||
if picked.is_empty() {
|
||||
picked.push(t["active"].as_u64().unwrap_or(0));
|
||||
}
|
||||
artboard_indices(d, &json!({ "indices": picked }), cmd)?
|
||||
};
|
||||
Ok((i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true))))
|
||||
Ok((indices, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true))))
|
||||
}
|
||||
|
||||
/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of
|
||||
@@ -87,8 +99,6 @@ pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str
|
||||
artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into()))
|
||||
}
|
||||
|
||||
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
|
||||
/// ones only with prefs moveLockedWithArtboard), in paint order.
|
||||
/// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`,
|
||||
/// in order and once each.
|
||||
pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
@@ -104,55 +114,87 @@ pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &s
|
||||
Ok(indices)
|
||||
}
|
||||
|
||||
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
|
||||
/// ones only with prefs moveLockedWithArtboard), in paint order.
|
||||
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
Ok(if art { d.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] })
|
||||
}
|
||||
|
||||
/// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object
|
||||
/// counted once, with the first of them it is on (artboards can overlap).
|
||||
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut arts = vec![];
|
||||
for &i in indices {
|
||||
let art = artboard_art(s, i, art)?.into_iter().filter(|id| seen.insert(*id)).collect();
|
||||
arts.push((i, art));
|
||||
}
|
||||
Ok(arts)
|
||||
}
|
||||
|
||||
/// How far a copy of `rect` moves to sit right of the last artboard (Duplicate Artboards, Paste).
|
||||
pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec2 {
|
||||
let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max);
|
||||
Vec2::new(right + 20.0 - rect.x0, 0.0)
|
||||
}
|
||||
|
||||
/// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep
|
||||
/// their places relative to each other, together right of the last artboard.
|
||||
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (i, art) = board_and_art(s, p, "artboard.duplicate")?;
|
||||
let art = artboard_art(s, i, art)?;
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &art)?, ..Scaling::default() };
|
||||
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
|
||||
let arts = artboards_art(s, &indices, art)?;
|
||||
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() };
|
||||
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
|
||||
let dv = d.artboards.get(i).map(|a| beside_artboards(d, a.rect)).unwrap_or_default();
|
||||
copy_artboard(d, i, dv, &art, sc)
|
||||
let dv = arts
|
||||
.iter()
|
||||
.filter_map(|(i, _)| d.artboards.get(*i))
|
||||
.map(|a| a.rect)
|
||||
.reduce(|a, b| a.union(b))
|
||||
.map(|r| beside_artboards(d, r))
|
||||
.unwrap_or_default();
|
||||
let (mut index, mut ids) = (vec![], vec![]);
|
||||
for (i, art) in &arts {
|
||||
let (at, copies) = copy_artboard(d, *i, dv, art, sc)?;
|
||||
index.push(at);
|
||||
ids.extend(copies);
|
||||
}
|
||||
Ok((index, ids))
|
||||
})?;
|
||||
Ok(json!({"index": index, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
Ok(json!({"index": index.first(), "indices": index, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Artboard Copy / Cut: the artboard and its art onto the clipboard (pasting brings both); a cut
|
||||
/// then deletes them.
|
||||
/// Artboard Copy / Cut: the artboards and their art onto the clipboard (pasting brings them all);
|
||||
/// a cut then deletes them.
|
||||
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
|
||||
let (i, art) = board_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
|
||||
let art = artboard_art(s, i, art)?;
|
||||
let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
|
||||
let arts = artboards_art(s, &indices, art)?;
|
||||
let st = s.doc()?;
|
||||
if cut && st.doc.artboards.len() <= 1 {
|
||||
if cut && st.doc.artboards.len() <= indices.len() {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
}
|
||||
let board = st.doc.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let clip = Clipboard { source_artboard: Some(board.rect), artboard: Some(board), ..Clipboard::copy(st, &art) };
|
||||
let art = st.doc.paint_order(arts.into_iter().flat_map(|(_, a)| a));
|
||||
let boards: Vec<_> = indices.iter().filter_map(|i| st.doc.artboards.get(*i).cloned()).collect();
|
||||
let clip = Clipboard { source_artboard: boards.first().map(|a| a.rect), artboards: boards, ..Clipboard::copy(st, &art) };
|
||||
let copied = clip.nodes.len();
|
||||
if cut {
|
||||
s.edit("Cut Artboard", |d, sel| {
|
||||
if i >= d.artboards.len() {
|
||||
if indices.iter().any(|i| *i >= d.artboards.len()) {
|
||||
return Err(EngineError::Other("no such artboard".into()));
|
||||
}
|
||||
// Its guides go with it.
|
||||
let gone = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
|
||||
// The last first, so the others keep their places; their guides go with them.
|
||||
for &i in indices.iter().rev() {
|
||||
let gone = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
|
||||
}
|
||||
d.remove_many(&art)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
if s.tool_id() == "artboard" {
|
||||
s.set_tool_option("active", &json!(i.saturating_sub(1)));
|
||||
s.set_tool_option("active", &json!(indices.first().map_or(0, |i| i.saturating_sub(1))));
|
||||
}
|
||||
}
|
||||
s.clipboard = clip;
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
//! command with its default. So are the `params` of an effect (#1592): `effect.apply` and
|
||||
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
|
||||
//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes
|
||||
//! what it is given, so the journal of an older version still replays.
|
||||
//! what it is given, so the journal of an older version still replays, except for an effect's
|
||||
//! `color` that the effect can't read, which `effect.apply` and `effect.setParams` refuse
|
||||
//! themselves ([`check_effect_color`]).
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -212,8 +214,9 @@ fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
|
||||
|
||||
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
|
||||
/// effect takes (those of its dialog defaults and of its params description), each of its
|
||||
/// default's type. Numbers out of range pass: the effect clamps them when it draws. An effect
|
||||
/// plug-in resolves its own against its manifest, and an unknown effect is the command's to report.
|
||||
/// default's type, and a `color` the effect can draw ([`check_effect_color`]). Numbers out of
|
||||
/// range pass: the effect clamps them when it draws. An effect plug-in resolves its own against
|
||||
/// its manifest, and an unknown effect is the command's to report.
|
||||
pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> {
|
||||
let Value::Object(given) = params else { return Ok(()) };
|
||||
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
|
||||
@@ -240,6 +243,40 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
check_effect_color(id, effect, params)
|
||||
}
|
||||
|
||||
/// Can an effect read `v` as its `color`: a string [`Color::from_hex`] reads (`#rrggbb`, `#rgb`,
|
||||
/// `#rrggbbaa`, black, white, red, green, blue), `[r, g, b]` whose first three entries are numbers
|
||||
/// (read on a 0..1 scale) or a color object (`{"model": "rgb", "r": …}`)? The effect draws its
|
||||
/// default color for any other value, except that an array of three or more entries draws an
|
||||
/// entry that isn't a number as 0.
|
||||
///
|
||||
/// [`Color::from_hex`]: vectorcraft_color::Color::from_hex
|
||||
fn is_effect_color(v: &Value) -> bool {
|
||||
match v {
|
||||
Value::String(s) => vectorcraft_color::Color::from_hex(s).is_some(),
|
||||
Value::Array(a) => a.len() >= 3 && a.iter().take(3).all(Value::is_number),
|
||||
Value::Object(_) => serde_json::from_value::<vectorcraft_color::Color>(v.clone()).is_ok(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `color` in `params` of built-in effect `effect`, when the effect has a `color` parameter,
|
||||
/// as command `id` was given it: a parameter error naming it unless the effect can read it
|
||||
/// ([`is_effect_color`]). `effect.apply` and `effect.setParams` run this themselves before they
|
||||
/// edit, so the app's own edits, the steps of a `command.batch` and a replayed journal are held to
|
||||
/// it too.
|
||||
pub(crate) fn check_effect_color(id: &str, effect: &str, params: &Value) -> Result<()> {
|
||||
let key = "color";
|
||||
let Some(v) = params.get(key) else { return Ok(()) };
|
||||
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
|
||||
return Ok(());
|
||||
}
|
||||
let takes_color = vectorcraft_render::effects::effect_info(effect).is_some_and(|info| info.defaults.get(key).is_some_and(Value::is_string));
|
||||
if takes_color && !is_effect_color(v) {
|
||||
return Err(bad(id, format!("`{key}` must be a #rrggbb color")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,16 @@ pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT];
|
||||
|
||||
/// What `pdf.preset.export` writes and `pdf.preset.import` reads.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct PresetFile {
|
||||
format: String,
|
||||
pub(crate) struct PresetFile {
|
||||
pub(crate) format: String,
|
||||
#[serde(default)]
|
||||
presets: Vec<PdfPreset>,
|
||||
pub(crate) presets: Vec<PdfPreset>,
|
||||
}
|
||||
|
||||
impl PresetFile {
|
||||
pub(crate) fn new(presets: Vec<PdfPreset>) -> Self {
|
||||
Self { format: PRESET_FORMAT.into(), presets }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -112,6 +118,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let at = s.pdf_saved_index(&name);
|
||||
let saved = at.and_then(|i| s.prefs.pdf_presets.get(i)).cloned();
|
||||
let old_name = saved.as_ref().map(|old| old.name.clone());
|
||||
// A saved preset changes from its own settings unless `preset` says where to start.
|
||||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let (Some(old), None) = (&saved, str_param(p, "preset")) {
|
||||
@@ -127,6 +134,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let description = str_param(p, "description").map(str::to_string).or_else(|| saved.map(|old| old.description)).unwrap_or_default();
|
||||
let preset = PdfPreset { name: name.clone(), description, settings: settings.clone() };
|
||||
s.store_pdf_preset(old_name.as_deref(), &preset);
|
||||
match at.and_then(|i| s.prefs.pdf_presets.get_mut(i)) {
|
||||
Some(slot) => *slot = preset,
|
||||
None => s.prefs.pdf_presets.push(preset),
|
||||
@@ -141,7 +149,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset and stays")));
|
||||
}
|
||||
let i = s.pdf_saved_index(name).ok_or_else(|| bad(C, format!("no saved PDF preset named `{name}`")))?;
|
||||
Ok(json!({ "deleted": s.prefs.pdf_presets.remove(i).name }))
|
||||
let deleted = s.prefs.pdf_presets.remove(i).name;
|
||||
s.unstore_pdf_preset(&deleted);
|
||||
Ok(json!({ "deleted": deleted }))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -196,12 +206,14 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(slot) = s.prefs.pdf_presets.get_mut(i) {
|
||||
preset.name = slot.name.clone();
|
||||
imported.push(preset.name.clone());
|
||||
*slot = preset;
|
||||
*slot = preset.clone();
|
||||
s.store_pdf_preset(Some(&preset.name), &preset);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
preset.name = unique_name(&base, |n| s.pdf_preset_taken(n, None));
|
||||
imported.push(preset.name.clone());
|
||||
s.store_pdf_preset(None, &preset);
|
||||
s.prefs.pdf_presets.push(preset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Setup",
|
||||
[],
|
||||
None,
|
||||
"{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)",
|
||||
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
|
||||
has_doc,
|
||||
setup
|
||||
),
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Preview",
|
||||
[],
|
||||
None,
|
||||
"{settings?: {…settings of print.setup} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing",
|
||||
"{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing",
|
||||
has_doc,
|
||||
preview
|
||||
),
|
||||
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print",
|
||||
[],
|
||||
Some("Cmd+P"),
|
||||
"{settings?: {…settings of print.setup} (over the document's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device",
|
||||
"{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device",
|
||||
has_doc,
|
||||
print
|
||||
),
|
||||
@@ -57,6 +57,23 @@ pub(crate) fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSett
|
||||
settings_over(cmd, saved(doc), p)
|
||||
}
|
||||
|
||||
/// The settings `p` asks for: those of the print preset `p.preset` names ([Default] or a saved
|
||||
/// one, any case), else those saved with `doc`, with `p.settings` over them, checked.
|
||||
fn preset_or_saved(cmd: &str, s: &Session, doc: &Document, p: &Value) -> Result<PrintSettings> {
|
||||
let base = match p.get("preset") {
|
||||
None | Some(Value::Null) => saved(doc),
|
||||
Some(Value::String(name)) => {
|
||||
let set = s.print_preset_settings(name).ok_or_else(|| {
|
||||
let names: Vec<String> = s.print_presets().into_iter().map(|q| q.name).collect();
|
||||
bad(cmd, format!("no print preset named `{name}` (presets: {})", names.join(", ")))
|
||||
})?;
|
||||
to_json(&set)?
|
||||
}
|
||||
Some(_) => return Err(bad(cmd, "preset must be the name of a print preset")),
|
||||
};
|
||||
settings_over(cmd, base, p)
|
||||
}
|
||||
|
||||
/// The settings `v` (JSON) with `p.settings` over them, checked.
|
||||
pub(crate) fn settings_over(cmd: &str, mut v: Value, p: &Value) -> Result<PrintSettings> {
|
||||
match p.get("settings") {
|
||||
@@ -75,8 +92,9 @@ pub(crate) fn to_json(set: &PrintSettings) -> Result<Value> {
|
||||
|
||||
fn setup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = to_json(&settings(SETUP, doc, p)?)?;
|
||||
if p.get("settings").is_some_and(|v| !v.is_null()) && doc.print_setup.as_ref() != Some(&set) {
|
||||
let set = to_json(&preset_or_saved(SETUP, s, doc, p)?)?;
|
||||
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
|
||||
if (given("settings") || given("preset")) && doc.print_setup.as_ref() != Some(&set) {
|
||||
let stored = set.clone();
|
||||
s.edit("Print Setup", |d, _| {
|
||||
d.print_setup = Some(stored);
|
||||
@@ -89,7 +107,7 @@ fn setup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "print.preview";
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = settings(C, doc, p)?;
|
||||
let set = preset_or_saved(C, s, doc, p)?;
|
||||
super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?;
|
||||
let mut pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
|
||||
pv.warnings.extend(type_warnings(doc, set.print_layers, &areas(&pv)));
|
||||
@@ -148,7 +166,7 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let postscript = to_postscript(p)?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut set = settings(PRINT, doc, p)?;
|
||||
let mut set = preset_or_saved(PRINT, s, doc, p)?;
|
||||
if postscript {
|
||||
// PostScript has no transparency: `flattenerPreset`, else the Advanced preset, else medium.
|
||||
let given = str_param(p, "flattenerPreset").filter(|n| !n.trim().is_empty());
|
||||
|
||||
@@ -440,6 +440,10 @@ fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) {
|
||||
/// has none. An object whose effects all paint below it keeps its vector art over an image of
|
||||
/// them; one whose effects change it (blur, feather, inner glow) becomes the image.
|
||||
fn walk(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option<Node> {
|
||||
// A cancelled export leaves the rest of the document as it is: the caller drops the result.
|
||||
if vectorcraft_doc::cancel::is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
let checkpoint = report.checkpoint();
|
||||
let result = walk_inner(out, n, report);
|
||||
report.attribute_since(checkpoint, n);
|
||||
@@ -558,6 +562,8 @@ pub fn export_pdf_with_report(
|
||||
) -> std::result::Result<vectorcraft_pdf::ExportReport, vectorcraft_pdf::PdfError> {
|
||||
let mut report = FlattenReport::new(Which::All, doc);
|
||||
let flat = flatten(doc, &mut report);
|
||||
// Flattening stops early when cancelled, leaving a half-flattened copy that must not be written.
|
||||
vectorcraft_doc::cancel::check().map_err(|_| vectorcraft_pdf::PdfError::Cancelled)?;
|
||||
let mut exported = vectorcraft_pdf::export_with_report(flat.as_ref().unwrap_or(doc), opts)?;
|
||||
exported.warnings.extend(report.warnings());
|
||||
Ok(exported)
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area / Path Type",
|
||||
[],
|
||||
None,
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, overflow?: [id] (the text doesn't fit; left out when it fits)}",
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}",
|
||||
has_doc,
|
||||
create_in_path
|
||||
),
|
||||
@@ -609,7 +609,8 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(super::typecmd::with_overflow(s, &[id], json!({"id": id.0})))
|
||||
let made = super::typecmd::with_bounds(s, Some(id), json!({"id": id.0}));
|
||||
Ok(super::typecmd::with_overflow(s, &[id], made))
|
||||
}
|
||||
|
||||
/// What `type.step` changes.
|
||||
|
||||
@@ -133,11 +133,52 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
|
||||
m
|
||||
}
|
||||
|
||||
/// Captured character styles set Auto leading explicitly. A missing style attribute instead
|
||||
/// leaves the text's existing leading alone, unlike a run's omitted Auto value.
|
||||
fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
/// Every character attribute of `v` as a JSON map, without the style name: those the run's JSON
|
||||
/// leaves out at their defaults (Auto leading and kerning, no OpenType features, Roman baseline
|
||||
/// alignment…) are written out. A style captured from text sets them all; an attribute missing
|
||||
/// from a style instead leaves the text's own value alone.
|
||||
pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
let mut m = to_attrs(v);
|
||||
m.insert("leading".into(), json!(v.leading));
|
||||
for (k, x) in [
|
||||
("font_version", json!(v.font_version)),
|
||||
("leading", json!(v.leading)),
|
||||
("kerning", json!(v.kerning)),
|
||||
("features", json!(v.features)),
|
||||
("overprint_fill", json!(v.overprint_fill)),
|
||||
("overprint_stroke", json!(v.overprint_stroke)),
|
||||
("stroke_cap", json!(v.stroke_cap)),
|
||||
("stroke_join", json!(v.stroke_join)),
|
||||
("stroke_miter_limit", json!(v.stroke_miter_limit)),
|
||||
("stroke_dash", json!(v.stroke_dash)),
|
||||
("stroke_align", json!(v.stroke_align)),
|
||||
("position", json!(v.position)),
|
||||
("small_caps", json!(v.small_caps)),
|
||||
("charAlign", json!(v.char_align)),
|
||||
("proportionalMetrics", json!(v.proportional_metrics)),
|
||||
("digits", json!(v.digits)),
|
||||
] {
|
||||
m.insert(k.into(), x);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Every paragraph attribute of `v` as a JSON map, without the style name: those the paragraph's
|
||||
/// JSON leaves out at their defaults (the Hyphenation options, the Every-line Composer, no tab
|
||||
/// stops, Auto direction…) are written out, as [`char_attrs`] does for characters.
|
||||
pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
|
||||
let mut m = to_attrs(v);
|
||||
for (k, x) in [
|
||||
("hyphenation", json!(v.hyphenation)),
|
||||
("composer", json!(v.composer)),
|
||||
("tabs", json!(v.tabs)),
|
||||
("mojikumi", json!(v.mojikumi)),
|
||||
("direction", json!(v.direction)),
|
||||
("leading_model", json!(v.leading_model)),
|
||||
("burasagari", json!(v.burasagari)),
|
||||
("kinsoku", json!(v.kinsoku)),
|
||||
] {
|
||||
m.insert(k.into(), x);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
@@ -187,13 +228,19 @@ fn with_attrs<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Val
|
||||
}
|
||||
|
||||
/// `cur` after its style changed from `old` to `new`: attributes still equal to the old style's
|
||||
/// value (or not set by it) take the new value; overrides stay. Unchanged if the style doesn't
|
||||
/// round-trip through JSON.
|
||||
fn restyle<T: Serialize + DeserializeOwned + Clone>(cur: &T, old: &Map<String, Value>, new: &Map<String, Value>) -> T {
|
||||
let Some(mut m) = to_map(cur) else { return cur.clone() };
|
||||
// Runs omit Auto leading from JSON; it still matches a style's explicit null.
|
||||
if old.get("leading") == Some(&Value::Null) {
|
||||
m.entry("leading").or_insert(Value::Null);
|
||||
/// value (or not set by it) take the new value; overrides stay. `attrs` lists every attribute of
|
||||
/// `cur` ([`char_attrs`], [`para_attrs`]), so one at its default matches a style that sets the
|
||||
/// default. Unchanged if the style doesn't round-trip through JSON.
|
||||
fn restyle<T: Serialize + DeserializeOwned + Clone>(
|
||||
cur: &T,
|
||||
attrs: fn(&T) -> Map<String, Value>,
|
||||
old: &Map<String, Value>,
|
||||
new: &Map<String, Value>,
|
||||
) -> T {
|
||||
let Some(full) = to_map(cur) else { return cur.clone() };
|
||||
let mut m = attrs(cur);
|
||||
if let Some(name) = full.get("style_name") {
|
||||
m.insert("style_name".into(), name.clone());
|
||||
}
|
||||
for (k, v) in new {
|
||||
if old.get(k).is_none_or(|o| m.get(k) == Some(o)) {
|
||||
@@ -246,7 +293,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
|
||||
Kind::Char => {
|
||||
for r in &mut t.runs {
|
||||
if r.style.style_name == target {
|
||||
r.style = restyle(&r.style, old, new);
|
||||
r.style = restyle(&r.style, char_attrs, old, new);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -254,7 +301,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
|
||||
Kind::Para => {
|
||||
for pa in t.para_styles_mut() {
|
||||
if pa.style_name == target {
|
||||
*pa = restyle(pa, old, new);
|
||||
*pa = restyle(pa, para_attrs, old, new);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -286,7 +333,7 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
|
||||
char_attrs(&style)
|
||||
}
|
||||
// The paragraph at `start`.
|
||||
Kind::Para => to_attrs(t.para_at(t.paragraphs_in(at, at).start)),
|
||||
Kind::Para => para_attrs(t.para_at(t.paragraphs_in(at, at).start)),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -314,7 +361,7 @@ fn attrs_param(p: &Value, kind: Kind, verb: &str) -> Result<Option<Map<String, V
|
||||
let c = kind.cmd(verb);
|
||||
let full = match kind {
|
||||
Kind::Char => char_attrs(&with_attrs(&CharStyle::default(), m, &c)?),
|
||||
Kind::Para => to_attrs(&with_attrs(&ParaStyle::default(), m, &c)?),
|
||||
Kind::Para => para_attrs(&with_attrs(&ParaStyle::default(), m, &c)?),
|
||||
};
|
||||
Ok(Some(m.keys().filter_map(|k| full.get(k).map(|v| (k.clone(), v.clone()))).collect()))
|
||||
}
|
||||
@@ -708,6 +755,78 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The attribute names a style of type `T` can set, without the style name.
|
||||
fn settable<T: DeserializeOwned>() -> std::collections::BTreeSet<String> {
|
||||
attr_names::<T>().into_iter().filter(|&n| n != "style_name").map(String::from).collect()
|
||||
}
|
||||
|
||||
/// New and Redefine capture every paragraph attribute. A paragraph style redefined from a
|
||||
/// paragraph at the defaults gives its paragraphs the defaults back where they had the old
|
||||
/// definition's values, and `setAttrs` with a default value does too; local overrides, and
|
||||
/// attributes `setAttrs` leaves out, stay.
|
||||
#[test]
|
||||
fn para_style_redefined_to_the_defaults_updates_its_paragraphs() {
|
||||
use vectorcraft_doc::{Composer, HyphenationOptions};
|
||||
let (mut s, source) = session_with_text("Source");
|
||||
let [linked, local] =
|
||||
["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap());
|
||||
let ids = [source, linked, local];
|
||||
let para = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.para_at(0).clone(),
|
||||
_ => panic!("not text"),
|
||||
};
|
||||
let limits = |s: &Session| ids.map(|id| para(s, id).hyphenation.hyphen_limit);
|
||||
let composers = |s: &Session| ids.map(|id| para(s, id).composer);
|
||||
let defined = |s: &Session, i: usize| s.doc().unwrap().doc.para_styles[i].attrs.keys().cloned().collect::<std::collections::BTreeSet<_>>();
|
||||
s.execute("text.setFormat", &json!({"ids": ids, "hyphenLimit": 2, "composer": "singleLine"})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Body", "id": source})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [local], "hyphenLimit": 4})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [source], "hyphenLimit": 0, "composer": "everyLine"})).unwrap();
|
||||
s.execute("paraStyle.redefine", &json!({"name": "Body", "id": source})).unwrap();
|
||||
assert_eq!((limits(&s), composers(&s)), ([0, 0, 4], [Composer::EveryLine; 3]));
|
||||
assert_eq!(para(&s, linked).hyphenation, HyphenationOptions::default());
|
||||
assert!(ids.iter().all(|&id| para(&s, id).style_name.as_deref() == Some("Body")));
|
||||
assert_eq!(defined(&s, 0), settable::<ParaStyle>(), "Redefine captures every attribute");
|
||||
s.execute("paraStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
|
||||
assert_eq!(defined(&s, 1), settable::<ParaStyle>(), "New captures every attribute");
|
||||
// Style options: a default value reaches the users too; an attribute left out stays as it is.
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"hyphenation": {"hyphen_limit": 3}, "composer": "singleLine"}})).unwrap();
|
||||
assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::SingleLine; 3]));
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"composer": "everyLine"}})).unwrap();
|
||||
assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::EveryLine; 3]));
|
||||
assert_eq!(Value::Object(s.doc().unwrap().doc.para_styles[0].attrs.clone()), json!({"composer": "everyLine"}));
|
||||
}
|
||||
|
||||
/// The same for character styles: Auto kerning and the Roman baseline alignment, which the
|
||||
/// run's JSON leaves out, are captured and reach the style's users.
|
||||
#[test]
|
||||
fn char_style_redefined_to_the_defaults_updates_its_text() {
|
||||
use vectorcraft_doc::CharAlign;
|
||||
let (mut s, source) = session_with_text("Source");
|
||||
let [linked, local] =
|
||||
["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap());
|
||||
let ids = [source, linked, local];
|
||||
let kerning = |s: &Session| ids.map(|id| runs(s, id)[0].style.kerning);
|
||||
let aligns = |s: &Session| ids.map(|id| runs(s, id)[0].style.char_align);
|
||||
let defined = |s: &Session, i: usize| s.doc().unwrap().doc.char_styles[i].attrs.keys().cloned().collect::<std::collections::BTreeSet<_>>();
|
||||
s.execute("text.setFormat", &json!({"ids": ids, "kerning": 40, "charAlign": "emBoxCenter"})).unwrap();
|
||||
s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [local], "kerning": 60})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [source], "kerning": "auto", "charAlign": "romanBaseline"})).unwrap();
|
||||
s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap();
|
||||
assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3]));
|
||||
assert_eq!(defined(&s, 0), settable::<CharStyle>(), "Redefine captures every attribute");
|
||||
s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
|
||||
assert_eq!(defined(&s, 1), settable::<CharStyle>(), "New captures every attribute");
|
||||
s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"kerning": 10, "charAlign": "emBoxTop"}})).unwrap();
|
||||
assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::EmBoxTop; 3]));
|
||||
s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"charAlign": "romanBaseline"}})).unwrap();
|
||||
assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::RomanBaseline; 3]));
|
||||
assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_leading_new_distinguishes_unset_attributes() {
|
||||
let (mut s, id) = session_with_text("Title");
|
||||
|
||||
@@ -269,6 +269,17 @@ pub(crate) fn with_overflow(s: &Session, ids: &[NodeId], mut result: Value) -> V
|
||||
result
|
||||
}
|
||||
|
||||
/// Adds `bounds` to the result of a command that made the type object `id`: the same
|
||||
/// `{x, y, width, height}` box `document.inspect` reports for it (after `justify`, so a script can
|
||||
/// place the new type without a helper object); `null` when it has none.
|
||||
pub(crate) fn with_bounds(s: &Session, id: Option<NodeId>, mut result: Value) -> Value {
|
||||
let Ok(st) = s.doc() else { return result };
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
let b = id.and_then(|i| st.doc.node(i)).and_then(|n| n.geometric_bounds_with(&symbols));
|
||||
result["bounds"] = crate::inspect::rect_json(b);
|
||||
result
|
||||
}
|
||||
|
||||
/// The warning that Print gives for `ids`, area type and type on a path whose text doesn't fit
|
||||
/// (as [`with_overflow`] finds it): the text past the end of their frames doesn't print. It names
|
||||
/// the first 20 ids and counts the rest; `None` when `ids` is empty.
|
||||
@@ -783,6 +794,30 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod area_tests {
|
||||
use super::*;
|
||||
|
||||
/// #2413: `text.create` and `text.createInPath` return the new type's `bounds`, the box
|
||||
/// `document.inspect` reports for it, so a script can place it without a helper object.
|
||||
#[test]
|
||||
fn text_create_returns_the_inspect_bounds() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
|
||||
let inspected = |s: &mut Session, id: &Value| {
|
||||
let doc = s.execute("document.inspect", &json!({})).unwrap();
|
||||
fn find(v: &Value, id: &Value) -> Option<Value> {
|
||||
if &v["id"] == id {
|
||||
return Some(v["bounds"].clone());
|
||||
}
|
||||
v.get("children").or_else(|| v.get("layers"))?.as_array()?.iter().find_map(|c| find(c, id))
|
||||
}
|
||||
find(&doc, id).unwrap()
|
||||
};
|
||||
let made = s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Hello", "size": 24, "justify": "center"})).unwrap();
|
||||
assert!(made["bounds"]["width"].as_f64().unwrap() > 10.0, "{made}");
|
||||
assert_eq!(made["bounds"], inspected(&mut s, &made["id"]));
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 200, "width": 100, "height": 50})).unwrap();
|
||||
let area = s.execute("text.createInPath", &json!({"path": rect["id"], "mode": "area", "text": "Boxed"})).unwrap();
|
||||
assert_eq!(area["bounds"], inspected(&mut s, &area["id"]));
|
||||
}
|
||||
|
||||
/// #1748: `text.create` takes `justify`; point type then lines up on its point (centred on
|
||||
/// it, or ending there), and `text.setStyle` moves it the same way afterwards.
|
||||
#[test]
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
//! variable, or a graph root to a `graph` variable (CSV data), put a row of values in
|
||||
//! each dataset, and `dataset.select` swaps the whole document to that row in one undo
|
||||
//! step. `dataset.import` / `dataset.export` move those rows as CSV (header: `name`
|
||||
//! then variable names). Agents reach every command through `run_command`, like all
|
||||
//! engine commands.
|
||||
//! then variable names); import also reads tab-separated files and files without the
|
||||
//! `name` column. Agents reach every command through `run_command`, like all engine
|
||||
//! commands.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -535,16 +536,61 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Parse a datasets CSV into rows of cells (no header interpretation yet): commas between cells,
|
||||
/// `"` quotes a cell (doubled inside), which may then hold commas and line breaks, as
|
||||
/// [`csv_escape`] writes them. An unquoted cell is trimmed. A quoted cell keeps what its quotes
|
||||
/// hold as written, spaces included, and drops white space after the closing quote at the end
|
||||
/// of the cell. Blank lines are skipped.
|
||||
fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
|
||||
/// A row of a datasets CSV: the file line it starts on, and its cells.
|
||||
type CsvRow = (usize, Vec<String>);
|
||||
|
||||
/// Whether a datasets CSV separates its cells with tabs: its header row (the first line that
|
||||
/// isn't blank), read with tabs between cells, has a tab outside quotes and no comma outside
|
||||
/// quotes.
|
||||
fn tab_separated(text: &str) -> bool {
|
||||
// `start`: nothing but `\r` read yet in this cell. `blank`: no cell on this line holds
|
||||
// anything but white space yet.
|
||||
let (mut in_quotes, mut start, mut blank, mut tab) = (false, true, true, false);
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' if in_quotes => {
|
||||
if chars.peek() == Some(&'"') {
|
||||
chars.next();
|
||||
blank = false;
|
||||
} else {
|
||||
in_quotes = false;
|
||||
}
|
||||
}
|
||||
'"' if start => in_quotes = true,
|
||||
c if in_quotes => blank &= c.is_whitespace(),
|
||||
',' => return false,
|
||||
'\t' => {
|
||||
tab = true;
|
||||
start = true;
|
||||
continue;
|
||||
}
|
||||
'\r' => continue,
|
||||
'\n' if blank => {
|
||||
tab = false;
|
||||
start = true;
|
||||
continue;
|
||||
}
|
||||
'\n' => return tab,
|
||||
c => blank &= c.is_whitespace(),
|
||||
}
|
||||
start = false;
|
||||
}
|
||||
tab
|
||||
}
|
||||
|
||||
/// Parse a datasets CSV into rows of cells (no header interpretation yet). Cells are separated
|
||||
/// by commas, or by tabs when [`tab_separated`] says so. `"` quotes a cell (doubled inside),
|
||||
/// which may then hold separators and line breaks, as [`csv_escape`] writes them. An unquoted
|
||||
/// cell is trimmed. A quoted cell keeps what its quotes hold as written, spaces included, and
|
||||
/// drops white space after the closing quote at the end of the cell. Blank lines are skipped.
|
||||
fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<CsvRow>> {
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let sep = if tab_separated(text) { '\t' } else { ',' };
|
||||
let (mut rows, mut row, mut cur) = (Vec::new(), Vec::<String>::new(), String::new());
|
||||
// `closed`: where the closing quote of a quoted cell falls in `cur`.
|
||||
let (mut in_quotes, mut closed, mut line) = (false, None, 1usize);
|
||||
// `closed`: where the closing quote of a quoted cell falls in `cur`. `start`: the line the
|
||||
// current row starts on.
|
||||
let (mut in_quotes, mut closed, mut line, mut start) = (false, None, 1usize, 1usize);
|
||||
let mut chars = text.chars().peekable();
|
||||
loop {
|
||||
let c = chars.next();
|
||||
@@ -559,12 +605,12 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
|
||||
}
|
||||
}
|
||||
Some('"') if cur.is_empty() => in_quotes = true,
|
||||
Some(',') if !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)),
|
||||
Some(ch) if ch == sep && !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)),
|
||||
Some('\r') if !in_quotes => {}
|
||||
Some('\n') | None if !in_quotes => {
|
||||
row.push(end_csv_cell(&mut cur, &mut closed));
|
||||
if row.iter().any(|c| !c.trim().is_empty()) {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
rows.push((start, std::mem::take(&mut row)));
|
||||
// +1 for the header.
|
||||
if rows.len() > MAX_CSV_ROWS + 1 {
|
||||
return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows")));
|
||||
@@ -575,6 +621,7 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
|
||||
return Ok(rows);
|
||||
}
|
||||
line += 1;
|
||||
start = line;
|
||||
}
|
||||
None => return Err(bad(cmd, format!("line {line}: unclosed quote"))),
|
||||
Some(c) => {
|
||||
@@ -659,27 +706,27 @@ fn datasets_to_csv(vars: &Variables) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Import datasets from CSV: first column `name`, remaining headers are variable names.
|
||||
/// Existing rows of the same name are replaced; new names are appended. One undo step.
|
||||
/// Import datasets from CSV. A first header cell `name` makes the first column the dataset
|
||||
/// names; the other headers are variable names. Without it, every header is a variable name
|
||||
/// and the rows take the first free names "Data Set 1", "Data Set 2" and so on. Existing rows
|
||||
/// of the same name are replaced; new names are appended. One undo step.
|
||||
fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.import";
|
||||
let bytes = datasets_csv_bytes(p, C)?;
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| bad(C, "CSV is not UTF-8"))?;
|
||||
let rows = parse_datasets_csv(text, C)?;
|
||||
let Some(header) = rows.first() else {
|
||||
let Some((_, header)) = rows.first() else {
|
||||
return Err(bad(C, "CSV is empty"));
|
||||
};
|
||||
if header.first().map(|h| h.trim().eq_ignore_ascii_case("name")) != Some(true) {
|
||||
return Err(bad(C, "first header cell must be `name`"));
|
||||
}
|
||||
let name_column = header.first().is_some_and(|h| h.trim().eq_ignore_ascii_case("name"));
|
||||
let (kinds, existing_names) = {
|
||||
let st = s.doc()?;
|
||||
let kinds: BTreeMap<String, VariableKind> = st.doc.variables.variables.iter().map(|v| (v.name.clone(), v.kind)).collect();
|
||||
let existing: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
||||
let existing: BTreeSet<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
||||
(kinds, existing)
|
||||
};
|
||||
let mut columns: Vec<(usize, String, VariableKind)> = Vec::new();
|
||||
for (i, h) in header.iter().enumerate().skip(1) {
|
||||
for (i, h) in header.iter().enumerate().skip(usize::from(name_column)) {
|
||||
let name = h.trim();
|
||||
if name.is_empty() {
|
||||
return Err(bad(C, format!("empty header in column {}", i + 1)));
|
||||
@@ -691,19 +738,25 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "CSV has a header but no data rows"));
|
||||
}
|
||||
let mut parsed: Vec<DataSet> = Vec::new();
|
||||
for (r_i, row) in rows.iter().enumerate().skip(1) {
|
||||
let line = r_i + 1;
|
||||
let raw_name = row.first().map(|c| c.trim()).unwrap_or("");
|
||||
// The names of the rows read so far, and the last number given to a row without a name.
|
||||
let (mut seen, mut number) = (BTreeSet::new(), 0usize);
|
||||
for &(line, ref row) in rows.iter().skip(1) {
|
||||
let raw_name = if name_column { row.first().map_or("", |c| c.trim()) } else { "" };
|
||||
let name = if raw_name.is_empty() {
|
||||
// Same numbering as Capture Data Set when a row leaves the name blank.
|
||||
(1..)
|
||||
.map(|n| format!("Data Set {n}"))
|
||||
.find(|n| parsed.iter().all(|d| d.name != *n) && !existing_names.iter().any(|e| e == n))
|
||||
.unwrap_or_else(|| format!("Data Set {line}"))
|
||||
// Same numbering as Capture Data Set when a row leaves the name blank or the file has
|
||||
// no name column. The search starts after the last number given.
|
||||
let (n, name) = (number + 1..)
|
||||
.map(|n| (n, format!("Data Set {n}")))
|
||||
.find(|(_, n)| !seen.contains(n) && !existing_names.contains(n))
|
||||
.unwrap_or_else(|| (line, format!("Data Set {line}")));
|
||||
number = n;
|
||||
name
|
||||
} else if raw_name.chars().count() > MAX_NAME {
|
||||
return Err(bad(C, format!("line {line}: `name` is longer than {MAX_NAME} characters")));
|
||||
} else {
|
||||
named(Some(raw_name), "name", C)?
|
||||
raw_name.to_string()
|
||||
};
|
||||
if parsed.iter().any(|d| d.name == name) {
|
||||
if !seen.insert(name.clone()) {
|
||||
return Err(bad(C, format!("line {line}: dataset `{name}` appears twice")));
|
||||
}
|
||||
let mut values = BTreeMap::new();
|
||||
@@ -937,7 +990,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Data Sets…",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{path? | data? | dataBase64? | csv?} load datasets from CSV (header: name, then variable names; one row per dataset). Rows whose name already exists are replaced → {imported, updated, names}",
|
||||
"{path? | data? | dataBase64? | csv?} load datasets from CSV, comma- or tab-separated (header: name, then variable names; one row per dataset; without the name column every header is a variable and the rows take the first free names Data Set 1, 2…). Rows whose name already exists are replaced → {imported, updated, names}",
|
||||
has_doc,
|
||||
dataset_import
|
||||
),
|
||||
|
||||
@@ -6,7 +6,7 @@ use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, S
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn rect_json(r: Option<vectorcraft_geom::Rect>) -> Value {
|
||||
pub(crate) fn rect_json(r: Option<vectorcraft_geom::Rect>) -> Value {
|
||||
match r {
|
||||
Some(r) => json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }),
|
||||
None => Value::Null,
|
||||
@@ -131,9 +131,113 @@ pub fn document(s: &Session) -> Value {
|
||||
/// The same summary with [`SummaryOpts`] applied to the layer tree (artboards and
|
||||
/// the rest always come whole): `depth: 0` is the skeleton — top layers with counts.
|
||||
pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value {
|
||||
document_scoped(s, opts, &Scope::default())
|
||||
}
|
||||
|
||||
/// Which nodes `document.inspect` lists in place of the whole layer tree.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Scope {
|
||||
/// Only these nodes (any level, already checked to exist), in the order given.
|
||||
pub ids: Option<Vec<vectorcraft_doc::NodeId>>,
|
||||
/// Only nodes of this panel label (case-insensitive), searched in the subtrees of `ids`
|
||||
/// or else of every layer.
|
||||
pub kind: Option<String>,
|
||||
/// At most this many entries after `offset` (`None`: all).
|
||||
pub limit: Option<u64>,
|
||||
/// Entries skipped first.
|
||||
pub offset: u64,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
fn scoped(&self) -> bool {
|
||||
self.ids.is_some() || self.kind.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// A node with the ids of its ancestors (layer first).
|
||||
type Hit<'a> = (&'a Node, Vec<u64>);
|
||||
|
||||
fn ancestors(d: &Document, id: vectorcraft_doc::NodeId) -> Vec<u64> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = d.parent_of(id);
|
||||
// The bound keeps a malformed tree from looping.
|
||||
while let Some(p) = cur.filter(|_| out.len() < 10_000) {
|
||||
out.push(p.0);
|
||||
cur = d.parent_of(p);
|
||||
}
|
||||
out.reverse();
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_kind<'a>(n: &'a Node, kind: &str, path: &mut Vec<u64>, out: &mut Vec<Hit<'a>>) {
|
||||
if n.kind_label().eq_ignore_ascii_case(kind) {
|
||||
out.push((n, path.clone()));
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
path.push(n.id.0);
|
||||
for c in ch.iter().rev() {
|
||||
collect_kind(c, kind, path, out);
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// [`document_opts`] with the layer tree replaced by the nodes `scope` picks. Without `ids`
|
||||
/// or `kind` they are the top layers (`layers`, paged by `limit`/`offset`); with either they
|
||||
/// are `nodes` (each with its `path` of ancestor ids when picked by `kind`). A paged or scoped
|
||||
/// reply also reports `total`, the entries before paging.
|
||||
pub fn document_scoped(s: &Session, opts: SummaryOpts, scope: &Scope) -> Value {
|
||||
let Some(st) = s.active() else { return Value::Null };
|
||||
let d = &st.doc;
|
||||
let symbols = d.symbol_boxes();
|
||||
let mut hits: Vec<Hit> = Vec::new();
|
||||
match (&scope.ids, scope.kind.as_deref().filter(|k| !k.is_empty())) {
|
||||
(None, None) => hits.extend(d.layers.iter().rev().map(|l| (&**l, Vec::new()))),
|
||||
(Some(ids), None) => hits.extend(ids.iter().filter_map(|&i| d.node(i)).map(|n| (n, Vec::new()))),
|
||||
(ids, Some(kind)) => {
|
||||
let mut path = Vec::new();
|
||||
match ids {
|
||||
Some(ids) => {
|
||||
for n in ids.iter().filter_map(|&i| d.node(i)) {
|
||||
path = ancestors(d, n.id);
|
||||
collect_kind(n, kind, &mut path, &mut hits);
|
||||
}
|
||||
}
|
||||
None => d.layers.iter().rev().for_each(|l| collect_kind(l, kind, &mut path, &mut hits)),
|
||||
}
|
||||
}
|
||||
}
|
||||
let total = hits.len();
|
||||
let skip = usize::try_from(scope.offset).unwrap_or(usize::MAX);
|
||||
let take = scope.limit.map_or(usize::MAX, |l| usize::try_from(l).unwrap_or(usize::MAX));
|
||||
let by_kind = scope.kind.as_deref().is_some_and(|k| !k.is_empty());
|
||||
let nodes: Vec<Value> = hits
|
||||
.into_iter()
|
||||
.skip(skip)
|
||||
.take(take)
|
||||
.map(|(n, path)| {
|
||||
let member = d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. }));
|
||||
let mut v = node_summary_opts(n, SummaryOpts { compound_member: member, ..opts }, &symbols);
|
||||
if by_kind {
|
||||
v["path"] = json!(path);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let mut out = document_base(s);
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert(if scope.scoped() { "nodes" } else { "layers" }.into(), Value::Array(nodes));
|
||||
if scope.scoped() || scope.limit.is_some() || scope.offset > 0 {
|
||||
o.insert("total".into(), json!(total));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Everything `document.inspect` reports except the layer tree.
|
||||
fn document_base(s: &Session) -> Value {
|
||||
let Some(st) = s.active() else { return Value::Null };
|
||||
let d = &st.doc;
|
||||
json!({
|
||||
"title": st.title(),
|
||||
"path": st.path,
|
||||
@@ -142,8 +246,6 @@ pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value {
|
||||
"units": d.units.label(),
|
||||
"colorMode": format!("{:?}", d.color_mode),
|
||||
"artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::<Vec<_>>(),
|
||||
// Top of the stack first, like the Layers panel.
|
||||
"layers": d.layers.iter().rev().map(|l| node_summary_opts(l, opts, &symbols)).collect::<Vec<_>>(),
|
||||
"currentLayer": st.active_layer.map(|l| l.0),
|
||||
// The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`).
|
||||
"layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod journal;
|
||||
pub mod link_watch;
|
||||
pub mod namematch;
|
||||
pub mod steps;
|
||||
pub mod store;
|
||||
mod tooling;
|
||||
pub mod units;
|
||||
|
||||
@@ -34,6 +35,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use journal::Journal;
|
||||
pub use tooling::{UiRequest, ViewInfo};
|
||||
pub use vectorcraft_doc as doc;
|
||||
pub use vectorcraft_doc::cancel;
|
||||
pub use vectorcraft_tools as tools;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -50,11 +52,20 @@ pub enum EngineError {
|
||||
NoNode(NodeId),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
/// The caller asked for the command to stop ([`cancel`]). It changed nothing.
|
||||
#[error("cancelled; nothing was changed")]
|
||||
Cancelled,
|
||||
/// A bug: the command panicked. The document was rolled back to its state before the command.
|
||||
#[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")]
|
||||
Internal { cmd: String, msg: String },
|
||||
}
|
||||
|
||||
impl From<cancel::Cancelled> for EngineError {
|
||||
fn from(_: cancel::Cancelled) -> Self {
|
||||
EngineError::Cancelled
|
||||
}
|
||||
}
|
||||
|
||||
impl From<vectorcraft_doc::DocError> for EngineError {
|
||||
fn from(e: vectorcraft_doc::DocError) -> Self {
|
||||
EngineError::Other(e.to_string())
|
||||
@@ -1166,6 +1177,12 @@ pub struct Session {
|
||||
pub style_libraries: cmd::stylelib::Libraries,
|
||||
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
|
||||
pub libraries: cmd::library::Libraries,
|
||||
/// Where the saved PDF presets are kept, one file each; `None`: only for the session
|
||||
/// ([`Session::attach_pdf_preset_store`]).
|
||||
pdf_preset_dir: Option<std::path::PathBuf>,
|
||||
/// Where the ICC profiles `color.loadProfile` loads are kept; `None`: only for the process
|
||||
/// ([`Session::attach_profile_store`]).
|
||||
profile_dir: Option<std::path::PathBuf>,
|
||||
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
|
||||
pub recent_urls: Vec<String>,
|
||||
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
|
||||
@@ -1258,6 +1275,8 @@ impl Session {
|
||||
freeform_point: None,
|
||||
style_libraries: Default::default(),
|
||||
libraries: Default::default(),
|
||||
pdf_preset_dir: None,
|
||||
profile_dir: None,
|
||||
recent_urls: vec![],
|
||||
ui_language: None,
|
||||
journal_note: Default::default(),
|
||||
@@ -1892,6 +1911,8 @@ mod tests_brushsym;
|
||||
#[cfg(test)]
|
||||
mod tests_build;
|
||||
#[cfg(test)]
|
||||
mod tests_cancel;
|
||||
#[cfg(test)]
|
||||
mod tests_charstroke;
|
||||
#[cfg(test)]
|
||||
mod tests_clip;
|
||||
@@ -2118,6 +2139,8 @@ mod tests_slices;
|
||||
#[cfg(test)]
|
||||
mod tests_smartguides;
|
||||
#[cfg(test)]
|
||||
mod tests_store;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegradient;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! The user's own PDF presets and ICC profiles, kept in folders next to the preferences so that
|
||||
//! they outlive the process (#2168, #2174). The apps decide where ([`Session::attach_pdf_preset_store`],
|
||||
//! [`Session::attach_profile_store`]); a session nobody attached a folder to keeps nothing, which
|
||||
//! is how the engine's own tests and embedders run.
|
||||
//!
|
||||
//! * **PDF presets**: one `.vcpdfpresets` file per preset (the format `pdf.preset.export` writes,
|
||||
//! so a file can also be imported by hand), named after the preset. A session that attaches the
|
||||
//! folder replaces its saved presets with the ones in it; a folder that doesn't exist yet takes
|
||||
//! the presets the session has (the ones an older version kept in the preferences file).
|
||||
//! * **ICC profiles**: the bytes of each profile `color.loadProfile` registered, as `.icc` files
|
||||
//! named after the profile, never the path they came from (a profile in a system folder may
|
||||
//! move or go away). Attaching the folder registers them again.
|
||||
//!
|
||||
//! The files are untrusted input like any other: sizes and counts are capped, a file that does not
|
||||
//! parse is skipped with a log record, and every write is atomic.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use vectorcraft_pdf::{PdfPreset, builtin_preset};
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile};
|
||||
|
||||
/// Files read from one store folder, at most (the rest are ignored).
|
||||
pub const MAX_STORE_FILES: usize = 1000;
|
||||
/// The largest PDF presets file read (a preset is a few kilobytes).
|
||||
pub const MAX_PRESET_FILE: u64 = 1 << 20;
|
||||
/// The largest ICC profile file read or loaded (CMYK profiles with large tables are a few MB).
|
||||
pub const MAX_PROFILE_FILE: u64 = 32 << 20;
|
||||
/// The most stored ICC profile bytes read at startup, all files together (the rest are skipped).
|
||||
pub const MAX_PROFILE_STORE: u64 = 256 << 20;
|
||||
/// The extension of a stored ICC profile.
|
||||
const PROFILE_EXT: &str = "icc";
|
||||
|
||||
/// The file name (without extension) a stored item called `name` gets: its name made safe for any
|
||||
/// file system, then a hash of the name, so that names that differ only in characters a file name
|
||||
/// can't hold, or in case, can't share a file. Names equal in any case are one item.
|
||||
pub(crate) fn file_stem(name: &str) -> String {
|
||||
let safe: String = name.trim().chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' }).take(48).collect();
|
||||
// FNV-1a over the lower-cased name: stable across versions and platforms, unlike `DefaultHasher`.
|
||||
let hash = name.trim().to_lowercase().bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3));
|
||||
format!("{}-{hash:016x}", safe.trim())
|
||||
}
|
||||
|
||||
/// The regular files of `dir` with the extension `ext` (any case), by file name, at most
|
||||
/// [`MAX_STORE_FILES`]; none when the folder can't be read.
|
||||
fn list_files(dir: &Path, ext: &str) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return vec![] };
|
||||
let mut files: Vec<PathBuf> = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext)) && p.is_file())
|
||||
.collect();
|
||||
files.sort_by_key(|p| p.file_name().map(|n| n.to_string_lossy().to_lowercase()));
|
||||
files.truncate(MAX_STORE_FILES);
|
||||
files
|
||||
}
|
||||
|
||||
/// The content of `path` when it is at most `max` bytes; the file is never read past `max`.
|
||||
pub(crate) fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let mut bytes = vec![];
|
||||
file.take(max + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(too_large(max));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// What a file over its cap is told (a detail after the file's name, in the log or an error).
|
||||
fn too_large(max: u64) -> String {
|
||||
format!("larger than {max} bytes")
|
||||
}
|
||||
|
||||
/// Write `bytes` to `dir/file`, making the folder; nothing is written when the file has them
|
||||
/// already. A failure is logged: remembering is best effort and never fails the command.
|
||||
fn put(dir: &Path, file: &str, bytes: &[u8]) -> bool {
|
||||
let path = dir.join(file);
|
||||
if std::fs::read(&path).is_ok_and(|old| old == bytes) {
|
||||
return true;
|
||||
}
|
||||
let written = std::fs::create_dir_all(dir).and_then(|()| write_atomic(&path, bytes));
|
||||
if let Err(e) = &written {
|
||||
log::warn!("can't save {}: {e}", path.display());
|
||||
}
|
||||
written.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
vectorcraft_format::write_atomic(path, bytes)
|
||||
}
|
||||
|
||||
/// The browser has no folders to keep anything in (the apps there never attach a store).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system"))
|
||||
}
|
||||
|
||||
/// Remove `dir/file`; a file that isn't there is what was wanted.
|
||||
fn take(dir: &Path, file: &str) {
|
||||
let path = dir.join(file);
|
||||
if let Err(e) = std::fs::remove_file(&path)
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
log::warn!("can't remove {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Keep the PDF presets this session saves in `dir`, and take the ones already there as the
|
||||
/// saved presets (see the module docs). Call it once, after the preferences were restored.
|
||||
pub fn attach_pdf_preset_store(&mut self, dir: PathBuf) {
|
||||
if dir.is_dir() {
|
||||
self.prefs.pdf_presets = load_presets(&dir);
|
||||
self.pdf_preset_dir = Some(dir);
|
||||
} else {
|
||||
self.pdf_preset_dir = Some(dir);
|
||||
for preset in self.prefs.pdf_presets.clone() {
|
||||
self.store_pdf_preset(None, &preset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the saved preset `preset` to the store, which held it as `old_name` when that is set
|
||||
/// (a rename). Without a store, nothing happens.
|
||||
pub(crate) fn store_pdf_preset(&self, old_name: Option<&str>, preset: &PdfPreset) {
|
||||
let Some(dir) = &self.pdf_preset_dir else { return };
|
||||
let mut clean = preset.clone();
|
||||
clean.settings.clear_passwords();
|
||||
let file = format!("{}.{PRESET_FORMAT}", file_stem(&clean.name));
|
||||
if let Some(old) = old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o)))
|
||||
&& old != file
|
||||
{
|
||||
take(dir, &old);
|
||||
}
|
||||
match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) {
|
||||
Ok(bytes) => {
|
||||
put(dir, &file, &bytes);
|
||||
}
|
||||
Err(e) => log::warn!("can't save the PDF preset `{}`: {e}", preset.name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the preset `name` from the store.
|
||||
pub(crate) fn unstore_pdf_preset(&self, name: &str) {
|
||||
if let Some(dir) = &self.pdf_preset_dir {
|
||||
take(dir, &format!("{}.{PRESET_FORMAT}", file_stem(name)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the ICC profiles this session loads in `dir`, and register the ones already there.
|
||||
/// Call it before anything that names a profile (restoring the preferences, the first command).
|
||||
pub fn attach_profile_store(&mut self, dir: PathBuf) {
|
||||
let mut left = MAX_PROFILE_STORE;
|
||||
for path in list_files(&dir, PROFILE_EXT) {
|
||||
if left == 0 {
|
||||
log::warn!("skipping the other stored colour profiles: they hold more than {MAX_PROFILE_STORE} bytes together");
|
||||
break;
|
||||
}
|
||||
let loaded = read_capped(&path, MAX_PROFILE_FILE.min(left)).and_then(|bytes| {
|
||||
left -= bytes.len() as u64;
|
||||
vectorcraft_color::cms::register_icc(&bytes, None).map_err(|e| e.to_string())
|
||||
});
|
||||
if let Err(e) = loaded {
|
||||
log::warn!("skipping the stored colour profile {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
self.profile_dir = Some(dir);
|
||||
}
|
||||
|
||||
/// Write the profile `name` (its file's `bytes`) to the store; `false` when it didn't go there.
|
||||
pub(crate) fn store_profile(&self, name: &str, bytes: &[u8]) -> bool {
|
||||
self.profile_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PROFILE_EXT}", file_stem(name)), bytes))
|
||||
}
|
||||
|
||||
/// Remove the profile `name` from the store.
|
||||
pub(crate) fn unstore_profile(&self, name: &str) {
|
||||
if let Some(dir) = &self.profile_dir {
|
||||
take(dir, &format!("{}.{PROFILE_EXT}", file_stem(name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The presets of the files in `dir`: valid ones only, no name twice (any case), none called like a
|
||||
/// built-in preset, at most [`MAX_STORE_FILES`].
|
||||
fn load_presets(dir: &Path) -> Vec<PdfPreset> {
|
||||
let mut presets: Vec<PdfPreset> = vec![];
|
||||
for path in list_files(dir, PRESET_FORMAT) {
|
||||
let file = read_capped(&path, MAX_PRESET_FILE).and_then(|bytes| serde_json::from_slice::<PresetFile>(&bytes).map_err(|e| e.to_string()));
|
||||
let file = match file {
|
||||
Ok(f) if f.format == PRESET_FORMAT => f,
|
||||
Ok(f) => {
|
||||
log::warn!("skipping {}: not a PDF presets file (format `{}`)", path.display(), f.format);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("skipping the stored PDF preset {}: {e}", path.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for mut preset in file.presets {
|
||||
let name = preset.name.trim().to_string();
|
||||
let usable = !name.is_empty()
|
||||
&& builtin_preset(&name).is_none()
|
||||
&& !presets.iter().any(|q| q.name.eq_ignore_ascii_case(&name))
|
||||
&& preset.settings.check_values().is_ok()
|
||||
&& presets.len() < MAX_STORE_FILES;
|
||||
if usable {
|
||||
preset.name = name;
|
||||
preset.settings.clear_passwords();
|
||||
presets.push(preset);
|
||||
} else {
|
||||
log::warn!("skipping the PDF preset `{name}` of {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
presets
|
||||
}
|
||||
@@ -2042,6 +2042,65 @@ fn document_inspect_slices_the_layer_tree() {
|
||||
assert!(s.execute("document.inspect", &json!({"depth": "deep"})).is_err());
|
||||
}
|
||||
|
||||
/// `document.inspect` scoped by `ids`, `kind`, `limit` and `offset`: each entry is the node as
|
||||
/// the full tree lists it, bad input is an error, and the unscoped reply keeps its shape.
|
||||
#[test]
|
||||
fn document_inspect_scopes_by_ids_kind_and_page() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0;
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0).0;
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0).0;
|
||||
let mut ins = |p: Value| s.execute("document.inspect", &p).unwrap();
|
||||
|
||||
let full = ins(json!({}));
|
||||
assert!(full.get("nodes").is_none() && full.get("total").is_none(), "{full}");
|
||||
let layer = full["layers"][0].clone();
|
||||
let layer_id = layer["id"].as_u64().unwrap();
|
||||
let find = |tree: &Value, id: u64| -> Value {
|
||||
fn walk(n: &Value, id: u64) -> Option<Value> {
|
||||
if n["id"] == id {
|
||||
return Some(n.clone());
|
||||
}
|
||||
n["children"].as_array()?.iter().find_map(|c| walk(c, id))
|
||||
}
|
||||
walk(tree, id).unwrap()
|
||||
};
|
||||
|
||||
// ids: just those nodes, in the order given, equal to their part of the full tree.
|
||||
let some = ins(json!({"ids": [c, group]}));
|
||||
assert!(some.get("layers").is_none());
|
||||
assert_eq!(some["nodes"], json!([find(&layer, c), find(&layer, group)]));
|
||||
assert_eq!(some["total"], 2);
|
||||
assert_eq!(some["artboards"], full["artboards"]);
|
||||
assert_eq!(ins(json!({"ids": [group], "depth": 0}))["nodes"][0].get("children"), None);
|
||||
|
||||
// kind: every match with its ancestor path, top of the stack first; paged.
|
||||
let paths = ins(json!({"kind": "rectangle"}));
|
||||
let ids: Vec<u64> = paths["nodes"].as_array().unwrap().iter().map(|n| n["id"].as_u64().unwrap()).collect();
|
||||
assert_eq!(ids, [c, b, a]);
|
||||
assert_eq!(paths["nodes"][1]["path"], json!([layer_id, group]));
|
||||
assert_eq!(paths["total"], 3);
|
||||
let page = ins(json!({"kind": "Rectangle", "offset": 1, "limit": 1}));
|
||||
assert_eq!(page["nodes"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(page["nodes"][0]["id"], b);
|
||||
assert_eq!(page["total"], 3);
|
||||
assert_eq!(ins(json!({"kind": "Rectangle", "offset": 99}))["nodes"], json!([]));
|
||||
assert_eq!(ins(json!({"kind": "Rectangle", "ids": [group]}))["total"], 2);
|
||||
|
||||
// limit/offset alone page the top layers.
|
||||
let paged = ins(json!({"limit": 0}));
|
||||
assert_eq!(paged["layers"], json!([]));
|
||||
assert_eq!(paged["total"], 1);
|
||||
|
||||
for bad in
|
||||
[json!({"ids": [999_999]}), json!({"ids": "all"}), json!({"ids": [-1]}), json!({"kind": 3}), json!({"limit": -1}), json!({"offset": 1.5})]
|
||||
{
|
||||
assert!(s.execute("document.inspect", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `document.find` searches names, kinds and type content across the whole tree and
|
||||
/// answers ids with ancestor paths plus the total, so capped replies stay explicit.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Cancelling a long export (#2165): it stops at the next checkpoint with `EngineError::Cancelled`,
|
||||
//! writes nothing, leaves the document as it was, and the session goes on working.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cancel::{self, CancelToken, ProgressSink};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn session(artboards: usize) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": artboards})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn revision(s: &Session) -> u64 {
|
||||
s.doc().unwrap().revision
|
||||
}
|
||||
|
||||
#[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");
|
||||
std::fs::remove_file(&path).ok();
|
||||
let mut s = session(5);
|
||||
let before = (revision(&s), s.doc().unwrap().history.undo.len());
|
||||
let token = CancelToken::new();
|
||||
let pages = Rc::new(Cell::new(0));
|
||||
// The sink runs inside the export, as the page loop reports each page: cancel on the third.
|
||||
let sink: ProgressSink = {
|
||||
let (token, pages) = (token.clone(), pages.clone());
|
||||
Rc::new(move |done, total, _| {
|
||||
// Stages before the pages report without a total.
|
||||
if total != 5 {
|
||||
return;
|
||||
}
|
||||
pages.set(done);
|
||||
if done == 3 {
|
||||
token.cancel();
|
||||
}
|
||||
})
|
||||
};
|
||||
let params = json!({"path": path.to_str().unwrap()});
|
||||
let r = cancel::scope(&token, Some(sink), || s.execute("document.exportPdf", ¶ms));
|
||||
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
|
||||
assert_eq!(pages.get(), 3, "the export stopped at the checkpoint after the cancel, not at the end");
|
||||
assert!(!path.exists(), "a cancelled export writes no file");
|
||||
assert_eq!((revision(&s), s.doc().unwrap().history.undo.len()), before, "the document is as it was");
|
||||
// The session is fine: the same export, not cancelled, completes.
|
||||
s.execute("document.exportPdf", ¶ms).unwrap();
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_flattening_export_stops_before_the_pages() {
|
||||
let mut s = session(2);
|
||||
let token = CancelToken::new();
|
||||
token.cancel();
|
||||
// PDF/X-1a flattens transparency first; a token raised beforehand stops at the first check.
|
||||
let r = cancel::scope(&token, None, || s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})));
|
||||
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
|
||||
assert!(s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_exports_and_svg_stop_when_cancelled_too() {
|
||||
let dir = vectorcraft_testkit::temp_dir("cancel-formats");
|
||||
let mut s = session(3);
|
||||
let token = CancelToken::new();
|
||||
token.cancel();
|
||||
for ext in ["png", "svg", "pdf"] {
|
||||
let path = dir.join(format!("out.{ext}"));
|
||||
std::fs::remove_file(&path).ok();
|
||||
let params = json!({"path": path.to_str().unwrap()});
|
||||
let r = cancel::scope(&token, None, || s.execute("document.export", ¶ms));
|
||||
assert!(matches!(r, Err(EngineError::Cancelled)), "{ext}: {r:?}");
|
||||
assert!(!path.exists(), "{ext}: nothing is written");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_cancelled_outside_a_scope() {
|
||||
let token = CancelToken::new();
|
||||
token.cancel();
|
||||
// The token only counts for work run under it, on its thread.
|
||||
let mut s = session(1);
|
||||
assert!(s.execute("document.exportPdf", &json!({})).is_ok());
|
||||
}
|
||||
@@ -338,8 +338,9 @@ fn template_opens_as_untitled() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-tpl-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("letterhead.vectorcraft");
|
||||
s.execute("file.saveAsTemplate", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
let r = s.execute("file.saveAsTemplate", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(r["path"], dir.join("letterhead.vctemplate").to_str().unwrap(), "saved with the template's extension");
|
||||
s.execute("document.open", &json!({"path": r["path"]})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.path.is_none() && !st.doc.template);
|
||||
assert!(st.doc.title.starts_with("Untitled-"), "{}", st.doc.title);
|
||||
|
||||
@@ -148,7 +148,93 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() {
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n));
|
||||
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboard.is_none(), "nothing copied");
|
||||
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
|
||||
}
|
||||
|
||||
/// Artboards 1 and 2, each with a square on it, and an artboard 3 below artboard 1 with a square on
|
||||
/// it, with the Artboard tool selecting artboards 1 and 2 → the squares' ids, artboard by artboard.
|
||||
fn two_selected_artboards(s: &mut Session) -> [u64; 3] {
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 0, "y": 700, "width": 100, "height": 100, "name": "Small"})).unwrap();
|
||||
let sq = |s: &mut Session, x: u64, y: u64| {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let ids = [sq(s, 10, 10), sq(s, 1010, 10), sq(s, 10, 710)];
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("artboard", ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("selected", &json!([0, 1]));
|
||||
ids
|
||||
}
|
||||
|
||||
/// The objects on the document's layers, in paint order.
|
||||
fn objects(s: &Session) -> Vec<u64> {
|
||||
s.doc().unwrap().doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().map(|n| n.id.0)).collect()
|
||||
}
|
||||
|
||||
/// Duplicate Artboards copies every selected artboard with its art, in one undo step (the public
|
||||
/// Use the artboard context menu page). The copies keep their places relative to each other, right
|
||||
/// of the last artboard.
|
||||
#[test]
|
||||
fn duplicate_artboards_copies_every_selected_artboard_in_one_step() {
|
||||
let mut s = session();
|
||||
let [a, b, _] = two_selected_artboards(&mut s);
|
||||
let before = (boards(&s), objects(&s));
|
||||
for p in [json!({"indices": [0, 1]}), json!({})] {
|
||||
let out = s.execute("artboard.duplicate", &p).unwrap();
|
||||
assert_eq!((out["index"].clone(), out["indices"].clone()), (json!(3), json!([3, 4])), "{p}");
|
||||
let copies = ids(&out["ids"]);
|
||||
assert_eq!(copies.len(), 2, "{p}");
|
||||
assert_eq!(boards(&s)[3..], [(1120.0, "Artboard 1 copy".to_string()), (2120.0, "Artboard 2 copy".to_string())], "{p}");
|
||||
assert_eq!((left(&s, copies[0]), left(&s, copies[1])), (1130.0, 2130.0), "{p}: each square on its artboard's copy");
|
||||
assert!(left(&s, a) == 10.0 && left(&s, b) == 1010.0, "{p}: the originals stay");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((boards(&s), objects(&s)), before, "{p}: one undo step");
|
||||
}
|
||||
}
|
||||
|
||||
/// With the Artboard tool, Copy takes every selected artboard and the art on each (the public Cut,
|
||||
/// copy, and paste artboards page). Paste adds a copy of each with its art in one undo step,
|
||||
/// together right of the last artboard; Paste in Place puts them where they were. The tool then
|
||||
/// selects the pasted artboards.
|
||||
#[test]
|
||||
fn artboard_tool_copy_and_paste_bring_every_selected_artboard() {
|
||||
let mut s = session();
|
||||
two_selected_artboards(&mut s);
|
||||
let before = (boards(&s), objects(&s));
|
||||
assert_eq!(s.execute("edit.copy", &json!({})).unwrap()["copied"], 2, "the squares on artboards 1 and 2");
|
||||
let out = s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!((out["artboard"].clone(), out["artboards"].clone()), (json!(3), json!([3, 4])));
|
||||
assert_eq!(boards(&s)[3..], [(1120.0, "Artboard 1 copy".to_string()), (2120.0, "Artboard 2 copy".to_string())]);
|
||||
let pasted = ids(&out["ids"]);
|
||||
assert_eq!((left(&s, pasted[0]), left(&s, pasted[1])), (1130.0, 2130.0));
|
||||
assert_eq!(s.tool_options()["selected"], json!([3, 4]), "the tool selects the pasted artboards");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((boards(&s), objects(&s)), before, "one undo step");
|
||||
let out = s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!((d.artboards[3].rect, d.artboards[4].rect), (d.artboards[0].rect, d.artboards[1].rect));
|
||||
let pasted = ids(&out["ids"]);
|
||||
assert_eq!((left(&s, pasted[0]), left(&s, pasted[1])), (10.0, 1010.0));
|
||||
}
|
||||
|
||||
/// With the Artboard tool, Cut takes every selected artboard and the art on each away in one undo
|
||||
/// step, and leaves the other artboards and their art; it fails when every artboard is selected.
|
||||
#[test]
|
||||
fn artboard_tool_cut_removes_every_selected_artboard() {
|
||||
let mut s = session();
|
||||
let [a, b, c] = two_selected_artboards(&mut s);
|
||||
let before = (boards(&s), objects(&s));
|
||||
assert_eq!(s.execute("edit.cut", &json!({})).unwrap()["copied"], 2);
|
||||
assert_eq!((boards(&s), objects(&s)), (vec![(0.0, "Small".into())], vec![c]));
|
||||
s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
||||
assert_eq!(boards(&s), vec![(0.0, "Small".into()), (0.0, "Artboard 1".into()), (1000.0, "Artboard 2".into())]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((boards(&s), objects(&s)), before, "the cut is one undo step");
|
||||
assert!(objects(&s).contains(&a) && objects(&s).contains(&b));
|
||||
s.set_tool_option("selected", &json!([0, 1, 2]));
|
||||
assert!(s.execute("edit.cut", &json!({})).is_err(), "every artboard");
|
||||
assert_eq!(boards(&s), before.0);
|
||||
}
|
||||
|
||||
/// With the Artboard tool, Copy and Paste copy its artboard with the art on it (#446): Paste places
|
||||
|
||||
@@ -336,6 +336,35 @@ fn pdf_export_expands_pattern_fills() {
|
||||
assert!(rep.bytes.len() > 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_raster_export_says_when_a_pattern_holds_an_image_that_could_not_be_decoded() {
|
||||
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short, made
|
||||
// into a pattern and then cleared from the page: only the pattern's tile draws it.
|
||||
let mut s = session();
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(20, 20, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
png.truncate(idat + 8);
|
||||
let placed = s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
let placed = NodeId(placed["ids"][0].as_u64().unwrap());
|
||||
sel(&mut s, &[placed]);
|
||||
s.execute("object.pattern.make", &json!({"name": "Cut", "width": 20, "height": 20})).unwrap();
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
sel(&mut s, &[placed]);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert!(doc(&s).node(placed).is_none());
|
||||
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Cut"})).unwrap();
|
||||
// The first export draws the pattern's tile, and the later ones draw it from the tile kept.
|
||||
for n in 0..3 {
|
||||
let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap();
|
||||
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "export {n}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- repeat ----------
|
||||
|
||||
#[test]
|
||||
@@ -587,3 +616,25 @@ fn picking_another_pattern_swatch_keeps_the_pattern_transform() {
|
||||
assert_eq!(pattern, "Dots");
|
||||
assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 15.0, 10.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_glow_on_a_pattern_fill_stays_inside_the_object_on_the_threaded_renderer() {
|
||||
// #2426: the glow's pixmap was drawn with the pattern's paint transform, so it landed
|
||||
// scaled at the top left instead of along the object's edges.
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap();
|
||||
let big = rect(&mut s, 100.0, 100.0, 150.0, 150.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
|
||||
sel(&mut s, &[big]);
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.innerGlow", "params": {"blur": 10}})).unwrap();
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
r.threads = 2;
|
||||
let view = Affine::scale(1.5) * Affine::translate((-20.0, -30.0));
|
||||
let out = r.render(&s.doc().unwrap().doc, 400, 400, view, &Default::default());
|
||||
let [red, ..] = out.pixel(110, 100);
|
||||
assert!(red < 0x30, "glow outside the object: red {red}");
|
||||
let [red, ..] = out.pixel(123, 200);
|
||||
assert!(red > 0x40, "no glow along the edge: red {red}");
|
||||
}
|
||||
@@ -66,6 +66,66 @@ fn print_writes_the_saved_setup_with_overrides() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A document with four 300 × 200 artboards, set up to print two copies, and a saved print preset
|
||||
/// "Sheet" that prints artboard 4 only.
|
||||
fn four_artboards_and_a_preset() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 4})).unwrap();
|
||||
s.execute("print.setup", &json!({"settings": {"copies": 2}})).unwrap();
|
||||
s.execute("print.presets.save", &json!({"name": "Sheet", "settings": {"artboards": "range", "range": "4"}})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_takes_a_print_preset_by_name() {
|
||||
let mut s = four_artboards_and_a_preset();
|
||||
// The preset's settings stand in for the document's: one copy of artboard 4.
|
||||
let (bytes, v) = print(&mut s, json!({"preset": "sheet"}));
|
||||
assert_eq!((v["pages"].clone(), vectorcraft_pdf::info(&bytes, None).unwrap().pages.len()), (json!(1), 1));
|
||||
let (_, v) = print(&mut s, json!({"preset": "Sheet", "settings": {"copies": 3}}));
|
||||
assert_eq!(v["pages"], 3, "settings apply over the preset's");
|
||||
let (_, v) = print(&mut s, json!({"preset": "[Default]"}));
|
||||
assert_eq!(v["pages"], 4, "[Default]: every artboard, one copy");
|
||||
assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["copies"], 2, "printing doesn't change the saved setup");
|
||||
// A PostScript file too.
|
||||
let v = s.execute("file.print", &json!({"preset": "Sheet", "format": "postscript"})).unwrap();
|
||||
assert_eq!(v["pages"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_print_preset_is_refused_with_the_presets() {
|
||||
let mut s = four_artboards_and_a_preset();
|
||||
for c in ["file.print", "print.preview", "print.setup"] {
|
||||
// Agents (MCP, the control channel) may pass `preset`.
|
||||
s.check_params(c, &json!({"preset": "Sheet", "settings": {}})).unwrap();
|
||||
let e = s.execute(c, &json!({"preset": "Nope"})).unwrap_err();
|
||||
assert!(matches!(e, EngineError::BadParams { .. }), "{c}: {e}");
|
||||
assert!(e.to_string().contains("no print preset named `Nope` (presets: [Default], Sheet)"), "{c}: {e}");
|
||||
assert!(matches!(s.execute(c, &json!({"preset": 4})), Err(EngineError::BadParams { .. })), "{c}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["copies"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_with_a_preset_stores_its_settings() {
|
||||
let mut s = four_artboards_and_a_preset();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let v = s.execute("print.setup", &json!({"preset": "Sheet"})).unwrap();
|
||||
let got = (v["settings"]["copies"].clone(), v["settings"]["artboards"].clone(), v["settings"]["range"].clone());
|
||||
assert_eq!(got, (json!(1), json!("range"), json!("4")));
|
||||
let stored = s.doc().unwrap().doc.print_setup.clone().unwrap();
|
||||
assert_eq!(stored, v["settings"]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
// The same preset again is no new step; settings apply over it.
|
||||
s.execute("print.setup", &json!({"preset": "Sheet"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let v = s.execute("print.setup", &json!({"preset": "Sheet", "settings": {"collate": false}})).unwrap();
|
||||
assert_eq!((v["settings"]["range"].clone(), v["settings"]["collate"].clone()), (json!("4"), json!(false)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["copies"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_lists_pages_tiles_inks_and_warnings() {
|
||||
let (mut s, _) = session();
|
||||
|
||||
@@ -52,6 +52,20 @@ fn pages_place_the_artboard_on_the_trim_box() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_print_preset_lays_out_its_settings_with_settings_over_them() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 4})).unwrap();
|
||||
s.execute("print.setup", &json!({"settings": {"media": "a4"}})).unwrap();
|
||||
s.execute("print.presets.save", &json!({"name": "Sheet", "settings": {"artboards": "range", "range": "4"}})).unwrap();
|
||||
let v = s.execute("print.preview", &json!({"preset": "Sheet", "settings": {"artboards": "range", "range": "3"}})).unwrap();
|
||||
assert_eq!((v["pages"].clone(), v["sheets"][0]["artboard"].clone()), (json!(1), json!(2)), "{v}");
|
||||
assert_eq!(v["settings"]["media"], "letter", "the preset's paper, not the document's");
|
||||
let v = s.execute("print.preview", &json!({"preset": "Sheet"})).unwrap();
|
||||
assert_eq!((v["pages"].clone(), v["sheets"][0]["artboard"].clone()), (json!(1), json!(3)));
|
||||
assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["media"], "a4", "the preview keeps nothing");
|
||||
}
|
||||
|
||||
/// Point type at (`x`, `y`) in `family` → its id.
|
||||
fn type_in(s: &mut Session, x: f64, y: f64, family: &str) -> u64 {
|
||||
s.execute("text.create", &json!({"x": x, "y": y, "text": "Ab", "font": family, "style": "Regular"})).unwrap()["id"].as_u64().unwrap()
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! The stores of PDF presets and ICC profiles (#2168, #2174): what one session saves, the next one
|
||||
//! finds; damaged or oversized files are skipped, never fatal.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::cms;
|
||||
|
||||
use crate::Session;
|
||||
use crate::store::{MAX_PRESET_FILE, file_stem};
|
||||
|
||||
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 saved_names(s: &mut Session) -> Vec<String> {
|
||||
let v = s.execute("pdf.preset.list", &json!({})).unwrap();
|
||||
v["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow() {
|
||||
let dir = fresh_dir("presets").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();
|
||||
a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap();
|
||||
a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "Screen"})).unwrap();
|
||||
a.execute("pdf.preset.save", &json!({"name": "Old"})).unwrap();
|
||||
a.execute("pdf.preset.delete", &json!({"name": "Old"})).unwrap();
|
||||
|
||||
let mut b = Session::new();
|
||||
b.attach_pdf_preset_store(dir.clone());
|
||||
assert_eq!(saved_names(&mut b), ["Press", "Screen"]);
|
||||
let press = b.prefs.pdf_presets.iter().find(|p| p.name == "Press").unwrap();
|
||||
assert_eq!(press.description, "for the printer");
|
||||
assert_eq!(press.settings, b.pdf_presets().into_iter().find(|p| p.name == "Press Quality").unwrap().settings);
|
||||
// The file of a name that is gone is gone.
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
|
||||
assert!(!dir.join(format!("{}.vcpdfpresets", file_stem("Old"))).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() {
|
||||
let dir = fresh_dir("seed").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());
|
||||
assert!(dir.is_dir(), "written at once");
|
||||
let mut b = Session::new();
|
||||
b.attach_pdf_preset_store(dir);
|
||||
assert_eq!(saved_names(&mut b), ["From Preferences"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_unsafe_and_oversized_preset_files_are_skipped() {
|
||||
let dir = fresh_dir("damaged").join("PDF Presets");
|
||||
let mut a = Session::new();
|
||||
a.attach_pdf_preset_store(dir.clone());
|
||||
a.execute("pdf.preset.save", &json!({"name": "Good"})).unwrap();
|
||||
let write = |name: &str, bytes: &[u8]| std::fs::write(dir.join(name), bytes).unwrap();
|
||||
write("a-not-json.vcpdfpresets", b"{ not json");
|
||||
write("b-other-format.vcpdfpresets", br#"{"format": "something else", "presets": []}"#);
|
||||
let huge = vec![b' '; MAX_PRESET_FILE as usize + 1];
|
||||
write("c-huge.vcpdfpresets", &huge);
|
||||
write("d-readme.txt", b"ignored");
|
||||
let good = std::fs::read_to_string(dir.join(format!("{}.vcpdfpresets", file_stem("Good")))).unwrap();
|
||||
// A built-in name, an empty name, a second "good" in another case, and values no export accepts.
|
||||
let mut file: Value = serde_json::from_str(&good).unwrap();
|
||||
let mut preset = file["presets"][0].clone();
|
||||
for name in ["High Quality Print", " ", "GOOD"] {
|
||||
preset["name"] = json!(name);
|
||||
file["presets"].as_array_mut().unwrap().push(preset.clone());
|
||||
}
|
||||
preset["name"] = json!("Bad Values");
|
||||
preset["settings"]["advanced"]["fontSubsetPercent"] = json!(500);
|
||||
file["presets"].as_array_mut().unwrap().push(preset);
|
||||
write("e-mixed.vcpdfpresets", file.to_string().as_bytes());
|
||||
|
||||
let mut b = Session::new();
|
||||
b.attach_pdf_preset_store(dir);
|
||||
assert_eq!(saved_names(&mut b), ["Good"]);
|
||||
}
|
||||
|
||||
/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a
|
||||
/// name of the same length).
|
||||
fn icc_named(name: &str) -> Vec<u8> {
|
||||
let builtin = cms::DISPLAY_P3;
|
||||
assert_eq!(builtin.len(), name.len());
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec();
|
||||
let (from, to) = (utf16(builtin), utf16(name));
|
||||
let at = bytes.windows(from.len()).position(|w| w == from).unwrap();
|
||||
bytes[at..at + to.len()].copy_from_slice(&to);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_profiles_register_again_and_removing_one_removes_its_copy() {
|
||||
let dir = fresh_dir("profiles").join("Color Profiles");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let name = "StoreRGB-1";
|
||||
let file = format!("{}.icc", file_stem(name));
|
||||
std::fs::write(dir.join("junk.icc"), b"not a profile").unwrap();
|
||||
std::fs::write(dir.join("empty.icc"), b"").unwrap();
|
||||
std::fs::write(dir.join(&file), icc_named(name)).unwrap();
|
||||
assert!(cms::profile(name).is_none());
|
||||
|
||||
let mut s = Session::new();
|
||||
s.attach_profile_store(dir.clone());
|
||||
assert!(cms::profile(name).is_some_and(|p| !p.builtin), "registered from the store");
|
||||
|
||||
// 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");
|
||||
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)));
|
||||
std::fs::remove_file(&loose).unwrap();
|
||||
assert_eq!(std::fs::read(dir.join(format!("{}.icc", file_stem(other)))).unwrap(), icc_named(other));
|
||||
|
||||
let r = s.execute("color.removeProfile", &json!({"name": name})).unwrap();
|
||||
assert_eq!(r["removed"], name);
|
||||
assert!(cms::profile(name).is_none() && !dir.join(&file).exists());
|
||||
assert!(s.execute("color.removeProfile", &json!({"name": name})).is_err(), "already gone");
|
||||
assert!(s.execute("color.removeProfile", &json!({"name": cms::SRGB})).is_err(), "built-in profiles stay");
|
||||
s.execute("color.removeProfile", &json!({"name": other})).unwrap();
|
||||
}
|
||||
@@ -948,6 +948,70 @@ fn datasets_csv_keeps_spaces_in_quoted_cells() {
|
||||
assert_eq!(value("C"), text_value(" quoted "));
|
||||
}
|
||||
|
||||
/// A header row with a tab and no comma outside quotes makes tabs the separator; a quoted cell
|
||||
/// may then hold tabs and commas. A header row with a comma outside quotes keeps commas, even
|
||||
/// when a cell holds a tab. A tab-separated header with an unclosed quote fails with the reason.
|
||||
#[test]
|
||||
fn datasets_csv_reads_tab_separated_files() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Last, First", "kind": "text"})).unwrap();
|
||||
let tsv = "name\tTitle\tShow\r\nA\tTwo words\ttrue\r\nB\t\"a, b\tc\"\tfalse\r\n";
|
||||
let imp = s.execute("dataset.import", &json!({"csv": tsv})).unwrap();
|
||||
assert_eq!(imp["names"], json!(["A", "B"]));
|
||||
s.execute("dataset.import", &json!({"csv": "name,Title\nC,one\ttwo"})).unwrap();
|
||||
s.execute("dataset.import", &json!({"csv": "name\t\"Last, First\"\nD\tSmith, Jo"})).unwrap();
|
||||
let value = |name: &str, var: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get(var)).cloned();
|
||||
let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into()));
|
||||
assert_eq!(value("A", "Title"), text_value("Two words"));
|
||||
assert_eq!(value("A", "Show"), Some(vectorcraft_doc::DataValue::Visible(true)));
|
||||
assert_eq!(value("B", "Title"), text_value("a, b\tc"));
|
||||
assert_eq!(value("B", "Show"), Some(vectorcraft_doc::DataValue::Visible(false)));
|
||||
assert_eq!(value("C", "Title"), text_value("one\ttwo"));
|
||||
assert_eq!(value("D", "Last, First"), text_value("Smith, Jo"));
|
||||
let unclosed = s.execute("dataset.import", &json!({"csv": "name\tTitle\t\"Show\nA\tx\ttrue"})).unwrap_err().to_string();
|
||||
assert!(unclosed.contains("unclosed quote"), "{unclosed}");
|
||||
}
|
||||
|
||||
/// Without a `name` column every header cell is a variable, and the rows take the first free
|
||||
/// names Data Set 1, 2 and so on. A first header cell `name`, in any case, still holds the names.
|
||||
#[test]
|
||||
fn datasets_csv_without_a_name_column_numbers_the_rows() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Data Set 2", "values": {"Title": "kept"}})).unwrap();
|
||||
let imp = s.execute("dataset.import", &json!({"csv": "Title\nx\ny\n"})).unwrap();
|
||||
assert_eq!((imp["imported"].as_u64(), imp["updated"].as_u64()), (Some(2), Some(0)));
|
||||
assert_eq!(imp["names"], json!(["Data Set 1", "Data Set 3"]));
|
||||
let value = |s: &Session, name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("Title")).cloned();
|
||||
let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into()));
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("x"));
|
||||
assert_eq!(value(&s, "Data Set 2"), text_value("kept"));
|
||||
assert_eq!(value(&s, "Data Set 3"), text_value("y"));
|
||||
let named = s.execute("dataset.import", &json!({"csv": "NAME,Title\nData Set 1,z"})).unwrap();
|
||||
assert_eq!((named["imported"].as_u64(), named["updated"].as_u64()), (Some(0), Some(1)));
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("z"));
|
||||
}
|
||||
|
||||
/// An error about a row's values or name gives the file line the row starts on, counting blank
|
||||
/// lines and line breaks inside quoted cells.
|
||||
#[test]
|
||||
fn datasets_csv_row_errors_name_the_file_line() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
let error = |s: &mut Session, csv: &str| s.execute("dataset.import", &json!({"csv": csv})).unwrap_err().to_string();
|
||||
let blank_line = error(&mut s, "name,Show\n\nX,maybe");
|
||||
assert!(blank_line.contains("line 3:"), "{blank_line}");
|
||||
let line_break = error(&mut s, "name,Title,Show\nA,\"one\ntwo\",true\nB,x,maybe");
|
||||
assert!(line_break.contains("line 4:"), "{line_break}");
|
||||
let twice = error(&mut s, "name,Title\nA,x\n\nA,y");
|
||||
assert!(twice.contains("line 4:"), "{twice}");
|
||||
let long = error(&mut s, &format!("name,Title\n\n{},x", "n".repeat(256)));
|
||||
assert!(long.contains("line 3:") && long.contains("longer than 255"), "{long}");
|
||||
}
|
||||
|
||||
/// Import / export datasets as CSV: header `name` then variables; round-trip keeps values.
|
||||
#[test]
|
||||
fn datasets_csv_round_trips_and_rejects_bad_input() {
|
||||
@@ -989,7 +1053,7 @@ fn datasets_csv_round_trips_and_rejects_bad_input() {
|
||||
assert_eq!((again["imported"].as_u64(), again["updated"].as_u64()), (Some(0), Some(2)));
|
||||
assert!(s.execute("dataset.import", &json!({"csv": "name,Ghost\nX,y"})).is_err());
|
||||
assert!(s.execute("dataset.import", &json!({"csv": "name,Show\nX,maybe"})).is_err());
|
||||
assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "first column must be name");
|
||||
assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "no variable named Title");
|
||||
|
||||
let out = dir.file("sets.csv");
|
||||
s.execute("dataset.export", &json!({"path": out})).unwrap();
|
||||
|
||||
@@ -275,6 +275,8 @@ fn structured_junk() {
|
||||
("stroke.set", json!({"weight": -5, "dash": [0, 0, 0], "miterLimit": -1})),
|
||||
("stroke.set", json!({"weight": 1e308, "dash": [-1, -2], "dashOffset": 1e308})),
|
||||
("stroke.set", json!({"dash": [1e-12]})),
|
||||
("document.inspect", json!({"ids": [u64::MAX, 0, 1e308], "kind": "", "limit": u64::MAX, "offset": u64::MAX})),
|
||||
("document.inspect", json!({"ids": null, "kind": {"a": 1}, "limit": -1, "offset": "x", "depth": u64::MAX})),
|
||||
("transparency.set", json!({"opacity": -50})),
|
||||
("transparency.set", json!({"opacity": 1e308, "blend": "NoSuchBlend"})),
|
||||
("perspective.move", json!({"from": [0, 0], "to": [1e308, -1e308], "perpendicular": true, "copy": true, "plane": "ground"})),
|
||||
|
||||
@@ -389,3 +389,66 @@ fn unknown_dimension_options_are_refused_by_commands_but_a_newer_file_still_open
|
||||
let doc = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
assert!(doc.node(NodeId(id)).unwrap().extras().dimension.is_some());
|
||||
}
|
||||
|
||||
/// With Shift down, the dashed line from the first picked point follows the 45-degree step the
|
||||
/// second click will pick (#2432), not the raw pointer.
|
||||
#[test]
|
||||
fn shift_shows_the_constrained_line_while_picking_the_second_point() {
|
||||
use vectorcraft_engine::tools::Overlay;
|
||||
let mut s = session();
|
||||
s.select_tool("dimension", ViewInfo::default()).unwrap();
|
||||
ev(&mut s, PointerKind::Down, 300.0, 300.0);
|
||||
ev(&mut s, PointerKind::Up, 300.0, 300.0);
|
||||
let end = |s: &mut Session, shift: bool| {
|
||||
let e = PointerEvent::new(PointerKind::Move, 372.0, 310.0).with_mods(Mods { shift, ..Mods::default() });
|
||||
s.pointer(&e, ViewInfo::default()).unwrap();
|
||||
s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Line { b, .. } = o { Some(b) } else { None }).unwrap()
|
||||
};
|
||||
assert_eq!(end(&mut s, false).y, 310.0);
|
||||
assert!((end(&mut s, true).y - 300.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// A click near a line, away from its anchors, snaps to the closest point of the line (#2432).
|
||||
#[test]
|
||||
fn clicking_near_a_line_snaps_the_measured_point_onto_it() {
|
||||
let mut s = session();
|
||||
edge(&mut s);
|
||||
s.select_tool("dimension", ViewInfo::default()).unwrap();
|
||||
for (x, y) in [(130.0, 103.0), (150.0, 97.0), (140.0, 130.0)] {
|
||||
ev(&mut s, PointerKind::Down, x, y);
|
||||
ev(&mut s, PointerKind::Up, x, y);
|
||||
}
|
||||
let id = s.doc().unwrap().selection.objects[0].0;
|
||||
assert_eq!(text(&s, id), "20.00 pt");
|
||||
}
|
||||
|
||||
/// Both picked points snap onto anchors a few pixels away, the rubber band and a ring show the
|
||||
/// snap before the click, and the dimension stays linked to the anchors (#2516).
|
||||
#[test]
|
||||
fn both_points_snap_onto_anchors_with_a_visible_indicator() {
|
||||
use vectorcraft_engine::tools::Overlay;
|
||||
let mut s = session();
|
||||
let line = edge(&mut s);
|
||||
s.select_tool("dimension", ViewInfo::default()).unwrap();
|
||||
ev(&mut s, PointerKind::Move, 103.0, 102.0);
|
||||
let ring = |s: &mut Session| {
|
||||
s.overlays(ViewInfo::default())
|
||||
.into_iter()
|
||||
.any(|o| matches!(o, Overlay::Anchor { p, size, .. } if size > 8.0 && p.x == 100.0 && p.y == 100.0))
|
||||
};
|
||||
assert!(ring(&mut s), "ring on the anchor before the first click");
|
||||
ev(&mut s, PointerKind::Down, 103.0, 102.0);
|
||||
ev(&mut s, PointerKind::Up, 103.0, 102.0);
|
||||
ev(&mut s, PointerKind::Move, 169.0, 98.0);
|
||||
let end = s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Line { b, .. } = o { Some(b) } else { None }).unwrap();
|
||||
assert_eq!((end.x, end.y), (172.0, 100.0), "rubber band ends on the anchor");
|
||||
ev(&mut s, PointerKind::Down, 169.0, 98.0);
|
||||
ev(&mut s, PointerKind::Up, 169.0, 98.0);
|
||||
ev(&mut s, PointerKind::Down, 136.0, 130.0);
|
||||
ev(&mut s, PointerKind::Up, 136.0, 130.0);
|
||||
let id = s.doc().unwrap().selection.objects[0].0;
|
||||
assert_eq!(text(&s, id), "72.00 pt");
|
||||
run(&mut s, "select.set", json!({"ids":[line]}));
|
||||
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
|
||||
assert_eq!(text(&s, id), "144.00 pt", "linked to both anchors");
|
||||
}
|
||||
@@ -1157,6 +1157,69 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- data set CSV files ----------
|
||||
|
||||
/// A document with a text, a visibility and a graph variable, each bound to an object, and two
|
||||
/// data sets.
|
||||
fn merge_session() -> vectorcraft_engine::Session {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = |r: Value| r["id"].as_u64().unwrap();
|
||||
let t = id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One"})).unwrap());
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 10, "y": 40, "width": 50, "height": 40})).unwrap());
|
||||
let graph = json!({"type": "column", "x": 80, "y": 10, "width": 100, "height": 80, "csv": ",Sales\nA,1\nB,2"});
|
||||
let g = id(s.execute("graph.create", &graph).unwrap());
|
||||
for (name, kind, object) in [("Title", "text", t), ("Show", "visibility", r), ("Chart", "graph", g)] {
|
||||
s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": name, "ids": [object]})).unwrap();
|
||||
}
|
||||
s.execute("dataset.new", &json!({"name": "A", "values": {"Title": "Two, \"quoted\"\nlines", "Show": true, "Chart": ",Q\nX,3"}})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "B", "values": {"Title": " padded ", "Show": false}})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// [`merge_session`]'s data sets exported as CSV, made once.
|
||||
fn saved_datasets() -> String {
|
||||
static TEXT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
TEXT.get_or_init(|| merge_session().execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string()).clone()
|
||||
}
|
||||
|
||||
/// `dataset.import` with `params` on [`merge_session`]'s document, the first three data sets it
|
||||
/// read selected in turn and every data set exported again; the document then renders and exports.
|
||||
fn datasets(what: &str, params: Value) -> Result<(), TestCaseError> {
|
||||
survive(what, || {
|
||||
let mut s = merge_session();
|
||||
if let Ok(r) = no_panic(s.execute("dataset.import", ¶ms)) {
|
||||
for name in r["names"].as_array().into_iter().flatten().take(3) {
|
||||
let _ = no_panic(s.execute("dataset.select", &json!({"name": name})));
|
||||
}
|
||||
}
|
||||
let _ = no_panic(s.execute("dataset.export", &json!({})));
|
||||
Some((*s.doc().ok()?.doc).clone())
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn dataset_csv_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "name,Title,Show,Chart\n", "Title\tShow\tChart\n", "name\tTitle\n", "Show,Chart\n"])) {
|
||||
datasets("data set CSV garbage", json!({"csv": format!("{head}{s}")}))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dataset_csv_bytes_never_panic(bytes in prop::collection::vec(any::<u8>(), 0..300)) {
|
||||
datasets("data set CSV bytes", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)}))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_dataset_csvs_never_panic(cut in 0usize..200, edits in prop::collection::vec(arb_edit(), 0..10), tabs in any::<bool>()) {
|
||||
let text = saved_datasets();
|
||||
let text = if tabs { text.replace(',', "\t") } else { text };
|
||||
datasets("mutated data set CSV", json!({"csv": mutate_text(&text, cut, &edits)}))?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- shortcut sets ----------
|
||||
|
||||
/// Shortcut set `bytes` read with `shortcutSet.read`, with import matches saved to annotate it: an
|
||||
@@ -1240,6 +1303,17 @@ fn raster_samples() -> Vec<(&'static str, Vec<u8>)> {
|
||||
out.push((name, b));
|
||||
}
|
||||
out[0].1 = vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out[0].1, (300.0, 150.0));
|
||||
// TIFFs that store one plane per channel: CMYK with a channel after the inks, and gray with
|
||||
// alpha, 16 bits a sample.
|
||||
let plane = |v: u16| vec![v; 15];
|
||||
let cmyk = [plane(200), plane(40), plane(10), plane(0), plane(90)];
|
||||
let cmyk =
|
||||
vectorcraft_testkit::tiff::Tiff { width: 5, height: 3, photometric: 5, bits: 8, extra: &[0], planar: true, ppi: Some(300), channels: &cmyk };
|
||||
out.push(("planar-cmyk.tif", vectorcraft_testkit::tiff::tiff(&cmyk)));
|
||||
let gray = [plane(30_000), plane(65_535)];
|
||||
let gray =
|
||||
vectorcraft_testkit::tiff::Tiff { width: 5, height: 3, photometric: 1, bits: 16, extra: &[2], planar: true, ppi: None, channels: &gray };
|
||||
out.push(("planar-gray.tif", vectorcraft_testkit::tiff::tiff(&gray)));
|
||||
// A layered PSD with transparency (as VectorCraft exports it), and a PackBits PSB by hand.
|
||||
let mut s = rich_session();
|
||||
let r = s.execute("document.export", &json!({"format": "psd", "ppi": 2})).unwrap();
|
||||
@@ -1262,7 +1336,7 @@ proptest! {
|
||||
/// Mutated image headers (resolution metadata, chunk and segment lengths) never crash reading
|
||||
/// their resolution or placing them.
|
||||
#[test]
|
||||
fn mutated_images_place_without_panics(which in 0usize..6, cut in 0usize..4000, edits in prop::collection::vec((0usize..120, any::<u8>()), 0..12)) {
|
||||
fn mutated_images_place_without_panics(which in 0usize..8, cut in 0usize..4000, edits in prop::collection::vec((0usize..120, any::<u8>()), 0..12)) {
|
||||
let (name, mut bytes) = raster_samples().swap_remove(which);
|
||||
for (at, b) in edits {
|
||||
if let Some(x) = bytes.get_mut(at) {
|
||||
@@ -1932,12 +2006,13 @@ proptest! {
|
||||
// ---------- print presets files ----------
|
||||
|
||||
/// Import print presets from `data`, then lay out (and print, when short) the rich document with
|
||||
/// each one imported.
|
||||
/// each one imported, by its settings and by its name.
|
||||
fn print_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
|
||||
survive_library(what, "print.presets.import", data, |s, r| {
|
||||
let list = no_panic(s.execute("print.presets.list", &json!({}))).unwrap_or_default();
|
||||
for name in r["imported"].as_array().into_iter().flatten() {
|
||||
let Some(p) = list["presets"].as_array().into_iter().flatten().find(|p| &p["name"] == name) else { continue };
|
||||
let _ = no_panic(s.execute("print.preview", &json!({ "preset": name })));
|
||||
let settings = json!({ "settings": p["settings"] });
|
||||
let pages = no_panic(s.execute("print.preview", &settings)).ok().and_then(|v| v["pages"].as_u64());
|
||||
let _ = no_panic(s.execute("print.setup", &settings));
|
||||
@@ -2468,6 +2543,8 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"1 /Bool (BSDashAdj) ,",
|
||||
"0.75 /Real (opac) ,",
|
||||
"1e308 /Real (blur) ,",
|
||||
"1 /Int (csrc) ,",
|
||||
"-1e308 /Real (dark) ,",
|
||||
"0 /Real ,",
|
||||
"; (BSVariableWidthTValuesArray) ,",
|
||||
"; (BSCenteredWidthsArray) ,",
|
||||
|
||||
@@ -44,6 +44,58 @@ fn effect_params_no_longer_taken_still_replay_and_open() {
|
||||
assert_eq!(stored(&back)["blurr"], json!(3));
|
||||
}
|
||||
|
||||
/// An effect's `color` that the effect can't read (a string `Color::from_hex` doesn't read, an
|
||||
/// `[r, g, b]` without three numbers, an object that isn't a color, `null`) is refused by
|
||||
/// `effect.apply`, `appearance.addEffect` and `effect.setParams` themselves, not only by the check,
|
||||
/// and the document and its undo history stay as they were. The forms the effect reads pass. A
|
||||
/// document saved with an unreadable color still opens with it.
|
||||
#[test]
|
||||
fn an_effect_color_it_cannot_draw_is_refused() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {"color": "#ff0000"}})).unwrap();
|
||||
let state = |s: &Session| {
|
||||
let st = s.active().unwrap();
|
||||
(st.doc.clone(), st.history.undo.len())
|
||||
};
|
||||
let before = state(&s);
|
||||
let why = "`color` must be a #rrggbb color";
|
||||
for bad in [json!("not-a-color"), json!("#ff00"), json!([1, 0]), json!(["#ff0000", 0, 0]), json!({"gray": 50}), json!(null)] {
|
||||
for (cmd, params) in [
|
||||
("effect.setParams", json!({"index": 0, "params": {"color": bad}})),
|
||||
("effect.apply", json!({"effect": "stylize.outerGlow", "params": {"color": bad}})),
|
||||
("appearance.addEffect", json!({"effect": "adjust.shiftToColor", "params": {"color": bad}})),
|
||||
] {
|
||||
let e = s.execute(cmd, ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{cmd} {params}: {e}");
|
||||
let checked = s.check_params(cmd, ¶ms).unwrap_err().to_string();
|
||||
assert!(checked.contains(why), "{cmd} {params}: {checked}");
|
||||
assert!(state(&s) == before, "{cmd} {params} changed the document or its undo history");
|
||||
}
|
||||
}
|
||||
let stored = |s: &Session| s.active().unwrap().doc.layers[0].children().unwrap()[0].appearance.effects[0].params["color"].clone();
|
||||
for good in [
|
||||
json!("#00ff00"),
|
||||
json!("#0f0"),
|
||||
json!("00ff00"),
|
||||
json!("#00ff00ff"),
|
||||
json!("green"),
|
||||
json!([0, 1, 0]),
|
||||
json!({"model": "rgb", "r": 0.0, "g": 1.0, "b": 0.0}),
|
||||
json!({"model": "cmyk", "c": 1.0, "m": 0.0, "y": 1.0, "k": 0.0}),
|
||||
] {
|
||||
let params = json!({"index": 0, "params": {"color": good}});
|
||||
s.check_params("effect.setParams", ¶ms).unwrap_or_else(|e| panic!("{good}: {e}"));
|
||||
s.execute("effect.setParams", ¶ms).unwrap_or_else(|e| panic!("{good}: {e}"));
|
||||
assert_eq!(stored(&s), good);
|
||||
}
|
||||
let mut doc = (*s.active().unwrap().doc).clone();
|
||||
doc.node_mut(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["color"] = json!("not-a-color");
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
assert_eq!(back.layers[0].children().unwrap()[0].appearance.effects[0].params["color"], json!("not-a-color"));
|
||||
}
|
||||
|
||||
/// What a command adds to its own journal entry passes the check, so a recorded journal can be
|
||||
/// sent back through the control channel or MCP.
|
||||
#[test]
|
||||
|
||||
@@ -96,8 +96,24 @@ impl MeshReader {
|
||||
let [n0, n1, n2, n3] = corners else { return None };
|
||||
// (anchor, towards the next corner, towards the previous one, colour, opacity)
|
||||
let read = |n: &Vec<f64>| -> Option<(Point, Vec2, Vec2, Color, f32)> {
|
||||
let comps = n.len().checked_sub(9)?;
|
||||
let &[opacity, x, y, nx, ny, _, px, py, _] = n.get(comps..)? else { return None };
|
||||
// Older editing streams omit opacity; the colour space determines the number
|
||||
// of colour components, so a coordinate must never be mistaken for opacity.
|
||||
let comps = match self.space.as_str() {
|
||||
"DeviceRGB" => 3,
|
||||
"DeviceCMYK" => 4,
|
||||
"DeviceGray" => 1,
|
||||
_ => return None,
|
||||
};
|
||||
let (opacity, geometry) = match n.get(comps..)? {
|
||||
geometry if geometry.len() == 8 => (1.0, geometry),
|
||||
[opacity, geometry @ ..] if geometry.len() == 8 => (*opacity, geometry),
|
||||
_ => return None,
|
||||
};
|
||||
let &[x, y, nx, ny, nw, px, py, pw] = geometry else { return None };
|
||||
// Non-unit rational weights need a different surface representation.
|
||||
if nw != 1.0 || pw != 1.0 {
|
||||
return None;
|
||||
}
|
||||
if ![opacity, x, y, nx, ny, px, py].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ pub(super) struct Obj {
|
||||
pub entries: Vec<(Option<String>, Vec<V>)>,
|
||||
/// Values closed without a key.
|
||||
pub content: Vec<V>,
|
||||
/// Vector art enclosed by `X=` … `X+` in an opacity-mask dictionary.
|
||||
pub art: Option<std::sync::Arc<vectorcraft_doc::Node>>,
|
||||
}
|
||||
|
||||
impl Obj {
|
||||
|
||||
@@ -272,6 +272,7 @@ enum Kind {
|
||||
Group,
|
||||
Compound,
|
||||
Clip,
|
||||
Mask,
|
||||
Obj(Box<Obj>),
|
||||
}
|
||||
|
||||
@@ -290,6 +291,8 @@ struct Frame {
|
||||
base: usize,
|
||||
/// The clipping paths' indexes among the children.
|
||||
clips: Vec<usize>,
|
||||
/// A compound member group explicitly declares its subpaths' winding with `D`.
|
||||
compound_members: bool,
|
||||
}
|
||||
|
||||
/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands
|
||||
@@ -383,6 +386,8 @@ struct Reader<'a> {
|
||||
clip_fills: HashSet<NodeId>,
|
||||
/// Groups closed as an object's drawn look (`9 () XW`).
|
||||
looks: HashSet<NodeId>,
|
||||
/// Legacy compound member groups, even when followed by the generic `9 () XW` marker.
|
||||
compound_groups: HashSet<NodeId>,
|
||||
/// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line.
|
||||
mesh: Option<(super::mesh::MeshReader, bool)>,
|
||||
/// The art styles (`%AI9_BeginArtStyles`), by name.
|
||||
@@ -425,9 +430,6 @@ const IGNORED: &[&str] = &[
|
||||
"Xh",
|
||||
"XH",
|
||||
"XF",
|
||||
"D",
|
||||
"X=",
|
||||
"X+",
|
||||
"Bc",
|
||||
"XP",
|
||||
"Np",
|
||||
@@ -529,6 +531,7 @@ impl<'a> Reader<'a> {
|
||||
hidden_ids: HashSet::new(),
|
||||
clip_fills: HashSet::new(),
|
||||
looks: HashSet::new(),
|
||||
compound_groups: HashSet::new(),
|
||||
group_ended: false,
|
||||
layer_state: None,
|
||||
styles: HashMap::new(),
|
||||
@@ -785,7 +788,16 @@ impl<'a> Reader<'a> {
|
||||
self.ensure_space();
|
||||
}
|
||||
let base = if matches!(kind, Kind::Obj(_)) { self.stack.len() } else { self.frames.last().map_or(0, |f| f.base) };
|
||||
self.frames.push(Frame { kind, children: Vec::new(), hidden, after: self.after_object, gs: self.gs.clone(), base, clips: Vec::new() });
|
||||
self.frames.push(Frame {
|
||||
kind,
|
||||
children: Vec::new(),
|
||||
hidden,
|
||||
after: self.after_object,
|
||||
gs: self.gs.clone(),
|
||||
base,
|
||||
clips: Vec::new(),
|
||||
compound_members: false,
|
||||
});
|
||||
self.after_object = false;
|
||||
Ok(())
|
||||
}
|
||||
@@ -836,7 +848,7 @@ impl<'a> Reader<'a> {
|
||||
"*U" => self.close(what, |k| matches!(k, Kind::Compound))?,
|
||||
_ => self.close(what, |k| matches!(k, Kind::Clip))?,
|
||||
};
|
||||
let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, .. } = f;
|
||||
let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, compound_members, .. } = f;
|
||||
// A group holding only clipping paths (`q … u <path> W n U … Q`) doesn't clip on its own: `u`/`U` don't save
|
||||
// the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it.
|
||||
if matches!(kind, Kind::Group)
|
||||
@@ -879,6 +891,9 @@ impl<'a> Reader<'a> {
|
||||
if clipping && let Some(f) = self.frames.last_mut() {
|
||||
f.clips.push(f.children.len());
|
||||
}
|
||||
if compound_members {
|
||||
self.compound_groups.insert(node.id);
|
||||
}
|
||||
self.add(node, opened_hidden || hidden)?;
|
||||
if let Some(p) = painted {
|
||||
self.clip_fills.insert(p.id);
|
||||
@@ -994,6 +1009,16 @@ impl<'a> Reader<'a> {
|
||||
return Ok(());
|
||||
}
|
||||
";" => return self.end_obj(),
|
||||
"X=" => {
|
||||
if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") {
|
||||
// `X=` also introduces legacy text frame art, read by `text_frames`.
|
||||
return Ok(());
|
||||
}
|
||||
self.end_path_quietly();
|
||||
return self.open(Kind::Mask, hidden);
|
||||
}
|
||||
"X+" if self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) => return self.end_mask(),
|
||||
"X+" => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
// What follows an object (its name, transparency and style) ends with anything else. The
|
||||
@@ -1105,6 +1130,19 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
"d" => self.dash(&vals),
|
||||
"D" => {
|
||||
// The published Adobe Illustrator File Format Specification (version 3.0), §6.8,
|
||||
// permits `u` groups of compound members.
|
||||
// Their explicit winding declarations describe subpaths, not separate paints;
|
||||
// modern streams can also end these groups with `9 () XW`.
|
||||
let n = self.frames.len();
|
||||
if n >= 2
|
||||
&& self.frames.get(n - 2).is_some_and(|f| matches!(f.kind, Kind::Compound))
|
||||
&& let Some(f) = self.frames.last_mut().filter(|f| matches!(f.kind, Kind::Group))
|
||||
{
|
||||
f.compound_members = true;
|
||||
}
|
||||
}
|
||||
// Transparency and state.
|
||||
"Xy" => self.transparency(&vals),
|
||||
// A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as
|
||||
@@ -1224,6 +1262,23 @@ impl<'a> Reader<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Capture mask art in document coordinates, without adding it to the owner's siblings.
|
||||
fn end_mask(&mut self) -> Result<(), String> {
|
||||
self.end_path_quietly();
|
||||
let f = self.close("X+", |k| matches!(k, Kind::Mask))?;
|
||||
let (children, clip) = self.clip_first(f.children, &f.clips);
|
||||
let art = self.new_node(NodeKind::Group { children, clip }, &f.gs);
|
||||
self.gs = f.gs;
|
||||
self.after_object = f.after;
|
||||
let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else {
|
||||
return Err("mask art ends outside a mask dictionary".into());
|
||||
};
|
||||
if o.art.replace(Arc::new(art)).is_some() {
|
||||
return Err("a mask has more than one art block".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `;`: the innermost dictionary closes.
|
||||
fn end_obj(&mut self) -> Result<(), String> {
|
||||
if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) {
|
||||
@@ -1379,13 +1434,19 @@ impl<'a> Reader<'a> {
|
||||
/// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the
|
||||
/// opacity mask it may carry.
|
||||
fn art_dictionary(&mut self, o: &Obj) {
|
||||
// A mask (`/Mask`, keyed `AI9_Mask`): its art is on `X=` … `X+` lines, and drawing the
|
||||
// object without it shows what the mask hides, so it must be named (#2004). A mask that
|
||||
// is disabled changes nothing.
|
||||
if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask"))
|
||||
&& mask.nums("Disabled").first().copied() != Some(1.0)
|
||||
{
|
||||
self.unreadable("opacity masks");
|
||||
if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) {
|
||||
if let Some(art) = &mask.art {
|
||||
let on = |key: &str| mask.nums(key).first().copied() == Some(1.0);
|
||||
let value =
|
||||
OpacityMask { art: art.clone(), clip: on("Clipping"), invert: on("Inverted"), disabled: on("Disabled"), linked: on("Linked") };
|
||||
if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) {
|
||||
n.extras_mut().mask = Some(Box::new(value));
|
||||
} else if !on("Disabled") {
|
||||
self.unreadable("opacity masks without an owning object");
|
||||
}
|
||||
} else if mask.nums("Disabled").first().copied() != Some(1.0) {
|
||||
self.unreadable("opacity masks without readable art");
|
||||
}
|
||||
}
|
||||
let name = match o.text("AIArtName") {
|
||||
Some(name) => name,
|
||||
@@ -1542,7 +1603,11 @@ impl<'a> Reader<'a> {
|
||||
let style = vals.iter().rev().find_map(V::text).unwrap_or_default();
|
||||
let after = std::mem::take(&mut self.after_object);
|
||||
if code == 9.0
|
||||
&& let Some(look) = self.frames.last().and_then(|f| f.children.last()).filter(|n| matches!(n.kind, NodeKind::Group { .. }))
|
||||
&& let Some(look) = self
|
||||
.frames
|
||||
.last()
|
||||
.and_then(|f| f.children.last())
|
||||
.filter(|n| matches!(n.kind, NodeKind::Group { .. }) && !self.compound_groups.contains(&n.id))
|
||||
{
|
||||
self.looks.insert(look.id);
|
||||
}
|
||||
|
||||
@@ -330,9 +330,8 @@ fn style(filters: &[Filter]) -> ArtStyle {
|
||||
params: json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}),
|
||||
visible: true,
|
||||
}),
|
||||
SHADOW if param("csrc").unwrap_or(0.0) == 0.0 => effects.push(Effect {
|
||||
id: "stylize.dropShadow".into(),
|
||||
params: json!({
|
||||
SHADOW => {
|
||||
let mut params = json!({
|
||||
"mode": mode(1),
|
||||
"opacity": param("opac").unwrap_or(0.75) * 100.0,
|
||||
"blur": 2.0 * param("blur").unwrap_or(5.0),
|
||||
@@ -341,11 +340,15 @@ fn style(filters: &[Filter]) -> ArtStyle {
|
||||
"x": param("horz").unwrap_or(7.0),
|
||||
"y": param("vert").unwrap_or(7.0),
|
||||
"color": color,
|
||||
}),
|
||||
visible: true,
|
||||
}),
|
||||
// A shadow darkening the object's own colors is not read as Drop Shadow's Darkness.
|
||||
SHADOW => plain = false,
|
||||
});
|
||||
// A `csrc` of 1 is Darkness, the object's own colors with black added; any value other than 0 is read as
|
||||
// Darkness. `dark` is the percent of black (0 to 100, where `opac` is a fraction), and 100 when it is missing or not finite.
|
||||
if param("csrc").unwrap_or(0.0) != 0.0 {
|
||||
params["shade"] = json!("darkness");
|
||||
params["darkness"] = json!(param("dark").filter(|v| v.is_finite()).map_or(100.0, |v| v.clamp(0.0, 100.0)));
|
||||
}
|
||||
effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -393,6 +396,50 @@ mod tests {
|
||||
assert_eq!((shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), (Some(7.0), Some(7.0)));
|
||||
}
|
||||
|
||||
/// The style `Shade`: a Drop Shadow whose dictionary holds `keys`.
|
||||
fn shadow_style(keys: &str) -> ArtStyle {
|
||||
let data = format!(
|
||||
"/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n;\n"
|
||||
);
|
||||
art_styles(data.as_bytes()).remove("Shade").unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drop_shadow_with_darkness_reads_its_darkness() {
|
||||
// GUARDS: a `csrc` other than 0 made the style not plain, and the object came in as its drawn look.
|
||||
let style = shadow_style("1 /Int (blnd) ,\n0.75 /Real (opac) ,\n5 /Real (blur) ,\n1 /Int (csrc) ,\n50 /Real (dark) ,\n");
|
||||
assert!(style.plain, "{style:?}");
|
||||
let [shadow] = style.effects.as_slice() else { panic!("{style:?}") };
|
||||
let p = &shadow.params;
|
||||
assert_eq!(
|
||||
(shadow.id.as_str(), &p["shade"], &p["darkness"], &p["opacity"], &p["blur"]),
|
||||
("stylize.dropShadow", &json!("darkness"), &json!(50.0), &json!(75.0), &json!(10.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drop_shadow_darkness_is_100_percent_when_missing_and_kept_within_0_to_100() {
|
||||
for (dark, want) in [("", 100.0), ("(fifty) /Real (dark) ,\n", 100.0), ("250 /Real (dark) ,\n", 100.0), ("-5 /Real (dark) ,\n", 0.0)] {
|
||||
let style = shadow_style(&format!("1 /Int (csrc) ,\n{dark}"));
|
||||
assert!(style.plain, "{dark:?}: {style:?}");
|
||||
let [shadow] = style.effects.as_slice() else { panic!("{dark:?}: {style:?}") };
|
||||
assert_eq!((&shadow.params["shade"], &shadow.params["darkness"]), (&json!("darkness"), &json!(want)), "{dark:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drop_shadow_with_color_keeps_its_params() {
|
||||
// GUARDS: a `csrc` of 0, or none, is Color; its shadow has neither `shade` nor `darkness`.
|
||||
for csrc in ["", "0 /Int (csrc) ,\n50 /Real (dark) ,\n"] {
|
||||
let style = shadow_style(&format!("2 /Int (blnd) ,\n0.5 /Real (opac) ,\n{csrc}"));
|
||||
assert!(style.plain, "{csrc:?}: {style:?}");
|
||||
let [shadow] = style.effects.as_slice() else { panic!("{csrc:?}: {style:?}") };
|
||||
let want = json!({"mode": "Screen", "opacity": 50.0, "blur": 10.0, "x": 7.0, "y": 7.0, "color": vectorcraft_color::Color::BLACK});
|
||||
assert_eq!(shadow.params, want, "{csrc:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_dash_offsets_and_width_profiles_are_tamed() {
|
||||
let mut f = Filter::default();
|
||||
|
||||
@@ -809,8 +809,7 @@ fn what_it_doesnt_read_is_an_error_where_it_shows() {
|
||||
assert!(e.contains("pattern fills"), "{e}");
|
||||
let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err();
|
||||
assert!(e.contains("symbols"), "{e}");
|
||||
// An opacity mask (#2004): drawing the layer's art whole would show what the mask hides, so
|
||||
// the mask is named and the file is read from its PDF part instead.
|
||||
// A mask with no owner must still be reported (#2004).
|
||||
let mask = "%_/ArtDictionary :\n%_/XMLUID : (M) ; (AI10_ArtUID) ,\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_0 /Bool (Disabled) ,\n%_X=\n\
|
||||
%_0 0 0 1 k\n%_0 0 m\n%_5 0 L\n%_5 5 L\n%_0 5 L\n%_0 0 L\n%_f\n%_X+\n%_ (Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_; (AI9_Mask) ,\n%_;\n";
|
||||
let e = read(stream(&layer(mask)).as_bytes()).unwrap_err();
|
||||
@@ -1177,6 +1176,26 @@ fn a_styled_object_inside_a_drawn_look_survives_it() {
|
||||
assert_eq!((shadow.id.as_str(), shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), ("stylize.dropShadow", Some(3.0), Some(4.0)));
|
||||
}
|
||||
|
||||
/// An object whose style's Drop Shadow has Darkness (`csrc` 1, `dark` 50) comes in as itself with
|
||||
/// that shadow, not as its drawn look.
|
||||
#[test]
|
||||
fn an_object_with_a_darkness_shadow_comes_in_as_itself() {
|
||||
let styles = ART_STYLES.replace("3 /Real (horz) ,\n", "1 /Int (csrc) ,\n50 /Real (dark) ,\n3 /Real (horz) ,\n");
|
||||
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
|
||||
let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 (Blue) XW\n", rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
let r = read_ok(&format!("{styles}{}", layer(&art_)));
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}");
|
||||
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
let [shadow] = a[0].appearance.effects.as_slice() else { panic!("{:?}", a[0].appearance.effects) };
|
||||
assert_eq!(
|
||||
(shadow.id.as_str(), shadow.params["shade"].as_str(), shadow.params["darkness"].as_f64(), shadow.params["x"].as_f64()),
|
||||
("stylize.dropShadow", Some("darkness"), Some(50.0), Some(3.0))
|
||||
);
|
||||
}
|
||||
|
||||
/// A styled object read in place of its drawn look keeps the transparency written on the look (the
|
||||
/// object's own, after the look's group ends): here 60 % in Multiply.
|
||||
#[test]
|
||||
@@ -1219,3 +1238,75 @@ fn a_gradient_mesh_reads_as_a_patch() {
|
||||
let e = read(stream(&layer(&mesh("Separation"))).as_bytes()).unwrap_err();
|
||||
assert!(e.contains("gradient meshes"), "{e}");
|
||||
}
|
||||
|
||||
/// Both mesh-corner layouts must keep colour, position and handles, rather than guessing
|
||||
/// opacity from a coordinate. Unsupported weights and truncated corners remain errors.
|
||||
#[test]
|
||||
fn mesh_corners_with_and_without_opacity() {
|
||||
let geometry = ["10 20 10 25 1 15 20 1", "10 50 15 50 1 10 45 1", "40 50 40 45 1 35 50 1", "40 20 35 20 1 40 25 1"];
|
||||
for (space, colour) in [("DeviceRGB", "0.2 0.4 0.6"), ("DeviceCMYK", "0.1 0.2 0.3 0.4"), ("DeviceGray", "0.5")] {
|
||||
let mesh = |opacity: &str| {
|
||||
let corners: String = geometry.iter().map(|g| format!("%_[{colour} {opacity}{g}] /N X#\n")).collect();
|
||||
format!("/Mesh X!\n%_/{space} /CS X#\n{corners}%_/E X#\n/End X!\n")
|
||||
};
|
||||
let old = art(&read_ok(&layer(&mesh(""))).doc);
|
||||
let new = art(&read_ok(&layer(&mesh("1 "))).doc);
|
||||
assert_eq!(old[0].kind, new[0].kind);
|
||||
let translucent = art(&read_ok(&layer(&mesh("0.25 "))).doc);
|
||||
let NodeKind::Mesh(m) = &translucent[0].kind else { panic!("mesh") };
|
||||
assert!(m.points.iter().all(|p| p.opacity == 0.25));
|
||||
assert_eq!(m.points[0].p, Point::new(10.0, 80.0));
|
||||
for broken in [mesh("").replace("10 25 1", "10 25 0.5"), mesh("").replace("15 20 1]", "15 20]")] {
|
||||
assert!(read(stream(&layer(&broken)).as_bytes()).unwrap_err().contains("gradient meshes"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_opacity_mask_keeps_its_art_and_does_not_paint_as_a_sibling() {
|
||||
let owner = format!("{RED}{}f\n", rect_path(10.0, 20.0, 50.0, 60.0));
|
||||
let mask = "%_/ArtDictionary :\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_X=\n%_u\n%_1 g\n%_10 20 m\n%_10 60 L\n%_30 60 L\n%_30 20 L\n%_f\n%_U\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_0 /Bool (Disabled) ,\n%_; (AI9_Mask) ,\n%_;\n";
|
||||
let following = format!("{}f\n", rect_path(70.0, 20.0, 80.0, 30.0));
|
||||
for (invert, disabled, linked) in [(false, false, true), (true, false, false), (false, true, true)] {
|
||||
let mask = mask
|
||||
.replace("0 /Bool (Inverted)", &format!("{} /Bool (Inverted)", u8::from(invert)))
|
||||
.replace("0 /Bool (Disabled)", &format!("{} /Bool (Disabled)", u8::from(disabled)))
|
||||
.replace("1 /Bool (Linked)", &format!("{} /Bool (Linked)", u8::from(linked)));
|
||||
let r = read_ok(&layer(&format!("{owner}{mask}{following}")));
|
||||
assert!(r.warnings.is_empty());
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 2);
|
||||
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "mask colour must not leak");
|
||||
let m = a[0].extras().mask.as_ref().unwrap();
|
||||
assert_eq!((m.clip, m.invert, m.disabled, m.linked), (true, invert, disabled, linked));
|
||||
let mask_path = &m.art.children().unwrap()[0].children().unwrap()[0];
|
||||
assert!(matches!(mask_path.kind, NodeKind::Path { .. }));
|
||||
assert_eq!(mask_path.geometric_bounds(), Some(Rect::new(10.0, 40.0, 30.0, 80.0)));
|
||||
assert_ne!(mask_path.id, a[0].id);
|
||||
}
|
||||
for broken in [mask.replace("%_X+\n", ""), mask.replace("%_U\n", ""), mask.replace("%_f", "%_unknownMaskOperator")] {
|
||||
assert!(read(stream(&layer(&format!("{owner}{broken}"))).as_bytes()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// `9 () XW` also follows legacy compound member groups. The final gradient paints
|
||||
/// every member; the initial black paint is a placeholder, not a separate black object.
|
||||
#[test]
|
||||
fn winding_declared_compound_members_keep_the_final_gradient() {
|
||||
let defs = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n0 0 0 0 1 0 0 2 50 0 Bs\n0 0 0 0 0 0 1 2 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let body = format!(
|
||||
"*u\nu\n1 D\n0 g\n{}f\n0 D\n{}Bb\n1 (G) 0 0 0 100 1 0 0 1 0 0 Bg\nf\n0 BB\nU\n9 () XW\n*U\n",
|
||||
rect_path(10.0, 10.0, 80.0, 80.0),
|
||||
rect_path(30.0, 30.0, 50.0, 50.0)
|
||||
);
|
||||
let r = read_ok(&format!("{defs}{}", layer(&body)));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1);
|
||||
let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("compound, not separately painted look") };
|
||||
assert_eq!(children.len(), 2);
|
||||
assert!(matches!(fill(&a[0]), Paint::Gradient(g) if g.gradient.stops.len() == 2));
|
||||
assert!(children.iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
||||
// Without winding declarations this is still an independently painted appearance group.
|
||||
let r = read_ok(&format!("{defs}{}", layer(&body.replace("1 D\n", "").replace("0 D\n", ""))));
|
||||
assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }));
|
||||
}
|
||||
@@ -703,6 +703,44 @@ fn compare_scale(r: Rect) -> f64 {
|
||||
(400.0 / r.width().max(r.height()).max(1.0)).min(4.0)
|
||||
}
|
||||
|
||||
/// A flattened print page made only of images. Sampling it directly at 400 pixels aliases
|
||||
/// its fine edges while vector art receives coverage antialiasing. Compare both at a higher
|
||||
/// resolution and apply the same reconstruction filter; keep the existing difference limits.
|
||||
fn comparison_samples(doc: &Document) -> u32 {
|
||||
let (mut images, mut vector) = (false, false);
|
||||
doc.walk(|n| match n.kind {
|
||||
NodeKind::Image(_) => images = true,
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {}
|
||||
_ => vector = true,
|
||||
});
|
||||
if images && !vector { 4 } else { 1 }
|
||||
}
|
||||
|
||||
/// Render for the page check. The comparison scale bounds the long side to 400 px, so the
|
||||
/// extra samples stay bounded to 1600 px. Filter premultiplied channels, including alpha.
|
||||
fn comparison_render(
|
||||
renderer: &mut vectorcraft_render::Renderer,
|
||||
doc: &Document,
|
||||
rect: Rect,
|
||||
scale: f64,
|
||||
samples: u32,
|
||||
) -> vectorcraft_render::Rendered {
|
||||
let rendered = renderer.render_region(doc, rect, scale * f64::from(samples), false);
|
||||
if samples == 1 {
|
||||
return rendered;
|
||||
}
|
||||
let (width, height) = match vectorcraft_render::raster_size(rect, scale) {
|
||||
Ok(size) => size,
|
||||
// A malformed region cannot provide a comparable raster.
|
||||
Err(_) => return rendered,
|
||||
};
|
||||
let Some(src) = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.pixels) else {
|
||||
return vectorcraft_render::Rendered { width: 0, height: 0, pixels: Vec::new() };
|
||||
};
|
||||
let pixels = image::imageops::resize(&src, width, height, image::imageops::FilterType::Triangle).into_raw();
|
||||
vectorcraft_render::Rendered { width, height, pixels }
|
||||
}
|
||||
|
||||
/// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers
|
||||
/// with a renderer of their own, which keeps their decoded images between draws (the page's images
|
||||
/// can share their keys and not their pixels, so the two don't share one).
|
||||
@@ -712,6 +750,7 @@ struct PageView {
|
||||
page_type: Vec<Rect>,
|
||||
layers: vectorcraft_render::Renderer,
|
||||
scale: f64,
|
||||
samples: u32,
|
||||
}
|
||||
|
||||
impl PageView {
|
||||
@@ -720,10 +759,11 @@ impl PageView {
|
||||
let scale = compare_scale(ra);
|
||||
// Drawn on nothing and then seen on white: blending modes have no page to blend with, as in
|
||||
// the app that wrote the file.
|
||||
let page = vectorcraft_render::Renderer::new().render_region(frame.page, ra, scale, false);
|
||||
let samples = comparison_samples(frame.page);
|
||||
let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples);
|
||||
let mut page_type = vec![];
|
||||
type_areas(&frame.page.layers, ra, scale, &mut page_type);
|
||||
Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale }
|
||||
Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale, samples }
|
||||
}
|
||||
|
||||
/// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]).
|
||||
@@ -731,7 +771,7 @@ impl PageView {
|
||||
// The page doesn't print what isn't printed.
|
||||
let printed = as_printed(b);
|
||||
let b = printed.as_ref().unwrap_or(b);
|
||||
let y = self.layers.render_region(b, rb, self.scale, false);
|
||||
let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples);
|
||||
let mut areas = self.page_type.clone();
|
||||
type_areas(&b.layers, rb, self.scale, &mut areas);
|
||||
compared(&self.page, &y, &areas)
|
||||
@@ -743,7 +783,7 @@ impl PageView {
|
||||
fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 {
|
||||
let printed = as_printed(b);
|
||||
let b = printed.as_ref().unwrap_or(b);
|
||||
let y = self.layers.render_region(b, rb, self.scale, false);
|
||||
let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples);
|
||||
if (y.width, y.height) != (self.page.width, self.page.height) {
|
||||
return 1.0;
|
||||
}
|
||||
@@ -759,8 +799,9 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference {
|
||||
let b = printed.as_ref().unwrap_or(b);
|
||||
let scale = compare_scale(ra);
|
||||
// A renderer each: the two documents' images can share their keys and not their pixels.
|
||||
let x = vectorcraft_render::Renderer::new().render_region(a, ra, scale, false);
|
||||
let y = vectorcraft_render::Renderer::new().render_region(b, rb, scale, false);
|
||||
let samples = comparison_samples(a);
|
||||
let x = comparison_render(&mut vectorcraft_render::Renderer::new(), a, ra, scale, samples);
|
||||
let y = comparison_render(&mut vectorcraft_render::Renderer::new(), b, rb, scale, samples);
|
||||
let mut areas = vec![];
|
||||
type_areas(&a.layers, ra, scale, &mut areas);
|
||||
type_areas(&b.layers, rb, scale, &mut areas);
|
||||
@@ -772,7 +813,7 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference {
|
||||
/// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents,
|
||||
/// and what the comparison looks for is art that is missing or out of place.
|
||||
fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect]) -> Difference {
|
||||
if x.width != y.width || x.height != y.height {
|
||||
if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height {
|
||||
return Difference { any: 1.0, solid: 1.0 };
|
||||
}
|
||||
let differs: Vec<bool> = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect();
|
||||
@@ -1358,4 +1399,58 @@ mod tests {
|
||||
let texts: Vec<String> = parts.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect();
|
||||
assert_eq!(texts, ["Hello", "abcd"]);
|
||||
}
|
||||
/// A flattened page samples a fine stripe pattern once per comparison pixel; that
|
||||
/// aliases to white although vector coverage is gray. Shared minification avoids the
|
||||
/// false fallback, while genuinely missing art must still be rejected.
|
||||
#[test]
|
||||
fn raster_page_comparison_filters_minification_without_accepting_missing_art() {
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, ImageBlob, ImageObject};
|
||||
let mut vector = Document::new(1000.0, 500.0);
|
||||
let layer = vector.layers[0].id;
|
||||
for i in 0..400 {
|
||||
let x = f64::from(i) * 2.5 + 0.5;
|
||||
let n = Node::path(
|
||||
vector.alloc_id(),
|
||||
vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 1.0, 500.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
vector.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let rect = vector.artboards[0].rect;
|
||||
let source = vectorcraft_render::Renderer::new().render_region(&vector, rect, 1.6, true);
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_raw(source.width, source.height, source.to_straight())
|
||||
.unwrap()
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let blob = ImageBlob::new("image/png", png);
|
||||
let key = blob.content_key();
|
||||
let mut page = Document::new(1000.0, 500.0);
|
||||
page.images.insert(key.clone(), blob);
|
||||
let image = Node::new(
|
||||
page.alloc_id(),
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key,
|
||||
width: source.width,
|
||||
height: source.height,
|
||||
xf: Affine::scale(1.0 / 1.6),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
);
|
||||
page.insert(Some(page.layers[0].id), usize::MAX, image).unwrap();
|
||||
assert_eq!(comparison_samples(&page), 4);
|
||||
assert_eq!(comparison_samples(&vector), 1, "ordinary vector pages retain their comparison");
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let a = comparison_render(&mut renderer, &page, rect, 0.4, 1);
|
||||
let b = comparison_render(&mut vectorcraft_render::Renderer::new(), &vector, rect, 0.4, 1);
|
||||
assert!(compared(&a, &b, &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page");
|
||||
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
|
||||
let mut view = PageView::new(&frame);
|
||||
assert!(compare(&frame, &mut view, &vector).is_ok());
|
||||
assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0)).is_err(), "missing artwork remains an error");
|
||||
let shifted = Rect::new(0.0, 0.0, 500.0, 500.0);
|
||||
assert!(view.difference(&vector, shifted).any > MAX_DIFFERENCE, "wrong framing remains an error");
|
||||
}
|
||||
}
|
||||
@@ -994,3 +994,51 @@ fn a_copied_area_type_object_is_framed_where_its_own_art_path_is() {
|
||||
let b = frame.bounds().unwrap();
|
||||
assert!((b.x0 - 30.0).abs() < 1e-6 && (b.y0 - 30.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
/// A raster print page can carry a fully vector editing copy. The legacy mesh has no
|
||||
/// opacity operand and its vector mask hides half of it: ignoring either loses this import.
|
||||
#[test]
|
||||
fn raster_print_page_recovers_legacy_mesh_vector_mask_and_editable_curve() {
|
||||
let mesh = "/Mesh X!\n%_/DeviceRGB /CS X#\n\
|
||||
%_[0 0 1 20 20 20 30 1 30 20 1] /N X#\n\
|
||||
%_[0 0 1 20 60 30 60 1 20 50 1] /N X#\n\
|
||||
%_[0 0 1 60 60 60 50 1 50 60 1] /N X#\n\
|
||||
%_[0 0 1 60 20 50 20 1 60 30 1] /N X#\n%_/E X#\n/End X!\n";
|
||||
let mask = "%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_1 g\n%_20 20 m\n%_20 60 L\n%_40 60 L\n%_40 20 L\n%_f\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n";
|
||||
let curve = "0 1 1 0 1 0 0 Xa\n70 20 m\n70 50 90 50 90 20 c\nf\n";
|
||||
let data = native(&layer("Editable", true, &format!("u\n{mesh}{mask}{curve}U\n")));
|
||||
let samples = ("0000ff".repeat(20) + &"ffffff".repeat(20)).repeat(40);
|
||||
let page = format!(
|
||||
"gsave 20 20 translate 40 40 scale /DeviceRGB setcolorspace \
|
||||
<< /ImageType 1 /Width 40 /Height 40 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] \
|
||||
/ImageMatrix [40 0 0 -40 0 40] /DataSource <{samples}> >> image grestore \
|
||||
1 0 0 setrgbcolor 70 20 moveto 70 50 90 50 90 20 curveto closepath fill"
|
||||
);
|
||||
// Use AI9's zlib container, too, rather than only the newer Zstandard test helper.
|
||||
use std::io::Write as _;
|
||||
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(data.as_bytes()).unwrap();
|
||||
let packed = ascii85(&encoder.finish().unwrap());
|
||||
let file = format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI9_DataStream\n%{packed}\n%AI9_PrivateDataEnd\n"
|
||||
);
|
||||
let r = import(file.as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert_eq!(names(&r.document), ["Editable"]);
|
||||
let group = &r.document.layers[0].children().unwrap()[0];
|
||||
let a = group.children().unwrap();
|
||||
assert_eq!(a.len(), 2);
|
||||
assert!(matches!(a[0].kind, NodeKind::Mesh(_)));
|
||||
assert!(a[0].extras().mask.is_some());
|
||||
let NodeKind::Path { path, .. } = &a[1].kind else { panic!("editable path") };
|
||||
assert_eq!(path.anchor_count(), 2);
|
||||
assert_ne!(path.subpaths[0].anchors[0].h_out, path.subpaths[0].anchors[0].p);
|
||||
assert_ne!(a[0].id, a[1].id);
|
||||
// The normal EPS path remains available explicitly.
|
||||
let page = crate::import_with(file.as_bytes(), false).unwrap();
|
||||
assert!(matches!(page.document.layers[0].children().unwrap()[0].kind, NodeKind::Image(_)));
|
||||
// A wrong editing copy must still fail the page comparison.
|
||||
let wrong = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 120 100");
|
||||
let r = import(wrong.as_bytes()).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("different page")), "{:?}", r.warnings);
|
||||
}
|
||||
+99
-19
@@ -76,6 +76,10 @@ pub struct Corner {
|
||||
/// The sides themselves, leaving the corner, in the same order.
|
||||
pub side_u: Side,
|
||||
pub side_v: Side,
|
||||
/// The anchor indices (in the path) of the anchors at the other ends of the sides, in the
|
||||
/// same order.
|
||||
pub before: usize,
|
||||
pub after: usize,
|
||||
}
|
||||
|
||||
impl Corner {
|
||||
@@ -84,8 +88,10 @@ impl Corner {
|
||||
/// sides leave it straight on.
|
||||
fn of(sp: &SubPath, i: usize, index: usize) -> Option<Self> {
|
||||
let n = sp.anchors.len();
|
||||
let (prev, next) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) };
|
||||
let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev)?, sp.anchors.get(next)?);
|
||||
let (prev_i, next_i) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) };
|
||||
let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev_i)?, sp.anchors.get(next_i)?);
|
||||
let first = index.checked_sub(i)?;
|
||||
let (before, after) = (first + prev_i, first + next_i);
|
||||
if a.kind == AnchorKind::Smooth {
|
||||
return None;
|
||||
}
|
||||
@@ -96,7 +102,7 @@ impl Corner {
|
||||
if !(lu > EPS && lv > EPS && lu.is_finite() && lv.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let c = Self { index, at: a.p, u, v, lu, lv, side_u, side_v };
|
||||
let c = Self { index, at: a.p, u, v, lu, lv, side_u, side_v, before, after };
|
||||
(c.sin() > MIN_SINE).then_some(c)
|
||||
}
|
||||
|
||||
@@ -120,17 +126,43 @@ impl Corner {
|
||||
r * (1.0 + self.cos()) / self.sin()
|
||||
}
|
||||
|
||||
/// The largest radius the corner draws: its cut then reaches halfway along the shorter side,
|
||||
/// so neighbouring corners at their largest meet without overlapping (half the shorter side
|
||||
/// at a rectangle's corners).
|
||||
pub fn max_radius(&self) -> f64 {
|
||||
self.lu.min(self.lv) / 2.0 * self.sin() / (1.0 + self.cos())
|
||||
/// The radius of a round cut that meets each side `s` from the corner (the inverse of
|
||||
/// [`Corner::setback`]).
|
||||
pub fn radius_at_setback(&self, s: f64) -> f64 {
|
||||
s * self.sin() / (1.0 + self.cos())
|
||||
}
|
||||
|
||||
/// The radius the corner is drawn with: `radius`, no larger than [`Corner::max_radius`], 0 when
|
||||
/// negative or not a number.
|
||||
pub fn fitted(&self, radius: f64) -> f64 {
|
||||
radius.max(0.0).min(self.max_radius())
|
||||
/// The largest radius the corner draws whatever its neighbours do: its cut then reaches
|
||||
/// halfway along the shorter side, so neighbouring corners at their largest meet without
|
||||
/// overlapping (half the shorter side at a rectangle's corners).
|
||||
pub fn max_radius(&self) -> f64 {
|
||||
self.radius_at_setback(self.lu.min(self.lv) / 2.0)
|
||||
}
|
||||
|
||||
/// The largest radius the corner draws when the cuts of the corners at the other ends of its
|
||||
/// sides reach `before` along side u and `after` along side v: its cut reaches halfway along
|
||||
/// a side, or further, up to where the other end's cut begins, so all of a side whose other
|
||||
/// end isn't cut (#2332).
|
||||
pub fn max_radius_beside(&self, before: f64, after: f64) -> f64 {
|
||||
let room = |len: f64, other: f64| (len - other.max(0.0)).max(len / 2.0);
|
||||
self.radius_at_setback(room(self.lu, before).min(room(self.lv, after)))
|
||||
}
|
||||
|
||||
/// The largest radius the corner draws when it and the corners for which `edited` holds are
|
||||
/// set to one radius together, the other corners of `corners` keeping their `radii` (by
|
||||
/// anchor index). A side shared with an edited corner takes both cuts as far as they fit
|
||||
/// side by side, or halfway each; on a side to any other corner the cut reaches up to where
|
||||
/// that corner's cut begins ([`Corner::max_radius_beside`]).
|
||||
pub fn max_shared_radius(&self, corners: &[Corner], radii: &[f64], edited: impl Fn(usize) -> bool) -> f64 {
|
||||
let side = |len: f64, other: usize| match find(corners, other) {
|
||||
Some(c) if other == self.index || edited(other) => {
|
||||
let both = self.setback(1.0) + c.setback(1.0);
|
||||
let apart = if both > EPS { len / both } else { f64::INFINITY };
|
||||
apart.max(self.radius_at_setback(len / 2.0))
|
||||
}
|
||||
_ => self.radius_at_setback((len - requested_setback(corners, radii, other)).max(len / 2.0)),
|
||||
};
|
||||
side(self.lu, self.before).min(side(self.lv, self.after))
|
||||
}
|
||||
|
||||
/// The point `k` along the bisector, measured in (u + v): the centre of a round cut of radius
|
||||
@@ -237,15 +269,43 @@ pub fn path_corners(path: &PathData) -> Vec<Corner> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The corner at anchor `index` among `corners` (in anchor order).
|
||||
fn find(corners: &[Corner], index: usize) -> Option<&Corner> {
|
||||
corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| corners.get(i))
|
||||
}
|
||||
|
||||
/// How far along each side the cut of the corner at anchor `index` asks to reach, with the radius
|
||||
/// in `radii` there (0 for an anchor that is no corner, or a radius that is negative or not a
|
||||
/// number).
|
||||
fn requested_setback(corners: &[Corner], radii: &[f64], index: usize) -> f64 {
|
||||
find(corners, index).map_or(0.0, |c| c.setback(radii.get(index).copied().unwrap_or(0.0).max(0.0)))
|
||||
}
|
||||
|
||||
/// The radius each of `corners` (in anchor order) is drawn with, given the radii by anchor index:
|
||||
/// its own, no larger than leaves room for the cuts of its neighbours ([`Corner::max_radius_beside`]
|
||||
/// with the setbacks their radii ask for), so a corner whose neighbours are square rounds until
|
||||
/// its cut reaches them (#2332) and two neighbours never cut past each other.
|
||||
pub fn fitted_radii(corners: &[Corner], radii: &[f64]) -> Vec<f64> {
|
||||
corners
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let own = radii.get(c.index).copied().unwrap_or(0.0).max(0.0);
|
||||
let max = c.max_radius_beside(requested_setback(corners, radii, c.before), requested_setback(corners, radii, c.after));
|
||||
own.min(max)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `path` with each of its corners ([`path_corners`]) cut by the radius in `radii` and the kind in
|
||||
/// `kinds` at its anchor index (missing: 0 and round), each radius no larger than fits
|
||||
/// ([`Corner::fitted`]). A cut corner becomes two anchors, and a curved side between corners is
|
||||
/// ([`fitted_radii`]). A cut corner becomes two anchors, and a curved side between corners is
|
||||
/// trimmed to what is left of it; a closed subpath whose first corner is cut starts where that
|
||||
/// cut ends, so the cut closes it. Also returns, per subpath, the anchor index in `path` each
|
||||
/// anchor comes from.
|
||||
pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (PathData, Vec<Vec<usize>>) {
|
||||
let corners = path_corners(path);
|
||||
let mut corners = corners.iter().peekable();
|
||||
let fitted = fitted_radii(&corners, radii);
|
||||
let mut corners = corners.iter().zip(fitted).peekable();
|
||||
let (mut subpaths, mut sources) = (Vec::with_capacity(path.subpaths.len()), Vec::with_capacity(path.subpaths.len()));
|
||||
let mut first = 0;
|
||||
for sp in &path.subpaths {
|
||||
@@ -254,8 +314,7 @@ pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (Pat
|
||||
let cuts: Vec<Option<Cut>> = (0..n)
|
||||
.map(|i| {
|
||||
let index = first + i;
|
||||
let c = corners.next_if(|c| c.index == index)?;
|
||||
let radius = c.fitted(radii.get(index).copied().unwrap_or(0.0));
|
||||
let (c, radius) = corners.next_if(|(c, _)| c.index == index)?;
|
||||
(radius > EPS).then(|| c.cut(radius, kinds.get(index).copied().unwrap_or_default()))
|
||||
})
|
||||
.collect();
|
||||
@@ -357,9 +416,6 @@ mod tests {
|
||||
for c in &corners {
|
||||
let max = c.max_radius();
|
||||
assert!((c.setback(max) - c.lu.min(c.lv) / 2.0).abs() < 1e-9);
|
||||
assert_eq!(c.fitted(1e6), max);
|
||||
assert_eq!(c.fitted(-3.0), 0.0);
|
||||
assert_eq!(c.fitted(f64::NAN), 0.0);
|
||||
}
|
||||
// The right angle at (0, 0) takes up to 20, half its shorter side.
|
||||
assert!((corners[0].max_radius() - 20.0).abs() < 1e-9);
|
||||
@@ -470,4 +526,28 @@ mod tests {
|
||||
assert!((bez.eval(t).distance(c.at) - r).abs() < r * 1e-3);
|
||||
}
|
||||
}
|
||||
|
||||
/// #2332: a corner rounds until its cut reaches its neighbours' (all of a side whose other
|
||||
/// end is square); neighbours that both ask for more than half a side meet halfway.
|
||||
#[test]
|
||||
fn a_corner_rounds_up_to_its_neighbours_cuts() {
|
||||
let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let corners = path_corners(&square);
|
||||
let drawn = |radii: [f64; 4]| fitted_radii(&corners, &radii);
|
||||
assert_eq!(drawn([0.0, 500.0, 0.0, 0.0]), [0.0, 100.0, 0.0, 0.0]);
|
||||
assert_eq!(drawn([30.0, 500.0, 0.0, 0.0]), [30.0, 70.0, 0.0, 0.0]);
|
||||
assert_eq!(drawn([80.0, 500.0, 0.0, 0.0]), [50.0, 50.0, 0.0, 0.0]);
|
||||
assert_eq!(drawn([500.0; 4]), [50.0; 4]);
|
||||
assert_eq!(drawn([f64::NAN, 120.0, -1.0, 0.0]), [0.0, 100.0, 0.0, 0.0]);
|
||||
// The lone cut is a quarter circle from one neighbour to the other.
|
||||
let (cut, _) = cut_corners(&square, &[0.0, 500.0, 0.0, 0.0], &[]);
|
||||
let ends: Vec<Point> = cut.anchors().map(|(_, _, a)| a.p).collect();
|
||||
assert_eq!(ends, [Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(100.0, 100.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)]);
|
||||
// Dragged together with its neighbour, a corner meets it halfway; beside a square one,
|
||||
// it goes all the way.
|
||||
let tr = corners[1];
|
||||
assert!((tr.max_shared_radius(&corners, &[0.0; 4], |k| k == 0) - 50.0).abs() < 1e-9);
|
||||
assert!((tr.max_shared_radius(&corners, &[0.0; 4], |_| false) - 100.0).abs() < 1e-9);
|
||||
assert!((tr.max_shared_radius(&corners, &[0.0, 0.0, 40.0, 0.0], |_| false) - 60.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -52,15 +52,23 @@ pub fn max_corner_radius(w: f64, h: f64) -> f64 {
|
||||
w.abs().min(h.abs()) / 2.0
|
||||
}
|
||||
|
||||
/// The radius a corner of a `w` × `h` rectangle is drawn with: `radius`, no larger than
|
||||
/// [`max_corner_radius`] (the same limit for every corner, so opposite corners match), 0 when
|
||||
/// negative or not a number.
|
||||
pub fn fitted_corner_radius(w: f64, h: f64, radius: f64) -> f64 {
|
||||
radius.max(0.0).min(max_corner_radius(w, h))
|
||||
/// The radii the corners of a `w` × `h` rectangle (top-left, top-right, bottom-right,
|
||||
/// bottom-left) are drawn with: each its own, no larger than leaves room for its neighbours'
|
||||
/// cuts ([`crate::corners::fitted_radii`]): half the shorter side when they are all as large, up
|
||||
/// to a whole side when its neighbours are square (#2332); 0 when negative or not a number.
|
||||
pub fn fitted_corner_radii(w: f64, h: f64, radii: [f64; 4]) -> [f64; 4] {
|
||||
let corners = crate::corners::path_corners(&rectangle(Rect::new(0.0, 0.0, w, h)));
|
||||
let mut out = [0.0; 4];
|
||||
for (c, r) in corners.iter().zip(crate::corners::fitted_radii(&corners, &radii)) {
|
||||
if let Some(o) = out.get_mut(c.index) {
|
||||
*o = r;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rectangle whose corners (top-left, top-right, bottom-right, bottom-left, y down) each have
|
||||
/// their own radius and kind, every radius clamped to half the shorter side. A corner without a
|
||||
/// their own radius and kind, every radius no larger than [`fitted_corner_radii`] leaves. A corner without a
|
||||
/// radius is one anchor, the others two (where the cut meets each side). Clockwise from the
|
||||
/// top-left corner, its anchor on the top side first. Cut as any path's corners are
|
||||
/// ([`crate::corners::cut_corners`]).
|
||||
@@ -379,9 +387,11 @@ mod tests {
|
||||
let chamfer = rectangle_with_corners(r, radii, [CornerKind::Chamfer; 4]);
|
||||
assert!((chamfer.subpaths[0].area() - (6000.0 - 50.0)).abs() < 1e-9);
|
||||
assert!(chamfer.subpaths[0].anchors.iter().all(|a| !a.has_in() && !a.has_out()), "straight");
|
||||
// Radii clamp to half the shorter side; negative and NaN radii are none.
|
||||
// Radii clamp to what their neighbours leave, the whole shorter side when they are
|
||||
// square (#2332): the cut reaches the bottom-left corner. Negative and NaN radii are none.
|
||||
let p = rectangle_with_corners(r, [500.0, -5.0, f64::NAN, 0.0], [CornerKind::Round; 4]);
|
||||
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(30.0, 0.0));
|
||||
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(60.0, 0.0));
|
||||
assert_eq!(p.subpaths[0].anchors.last().map(|a| a.p), Some(Point::new(0.0, 60.0)));
|
||||
assert_eq!(p.anchor_count(), 5);
|
||||
assert_eq!(CornerKind::ALL.map(CornerKind::next), [CornerKind::InvertedRound, CornerKind::Chamfer, CornerKind::Round]);
|
||||
}
|
||||
@@ -392,7 +402,6 @@ mod tests {
|
||||
fn corners_of_a_non_square_rectangle_are_alike() {
|
||||
let r = Rect::new(0.0, 0.0, 200.0, 80.0);
|
||||
assert_eq!(max_corner_radius(-200.0, 80.0), 40.0);
|
||||
assert_eq!([30.0, 60.0, -1.0, f64::NAN].map(|x| fitted_corner_radius(200.0, 80.0, x)), [30.0, 40.0, 0.0, 0.0]);
|
||||
for (radius, drawn) in [(30.0, 30.0), (60.0, 40.0)] {
|
||||
for kinds in [[CornerKind::Round; 4], [CornerKind::Chamfer, CornerKind::InvertedRound, CornerKind::Round, CornerKind::Chamfer]] {
|
||||
let a: Vec<Point> = rectangle_with_corners(r, [radius; 4], kinds).subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
|
||||
@@ -85,6 +85,42 @@ impl Remote {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait until the app starts to answer, giving up when the call is cancelled
|
||||
/// ([`vectorcraft_engine::cancel`]): the app has no way to stop what it runs, but this server
|
||||
/// stops waiting for it (the connection is dropped, so a late answer can't be mistaken for
|
||||
/// the next call's). Without a cancellable call this is just the read the caller does next.
|
||||
fn wait_for_reply(stream: &TcpStream) -> std::io::Result<()> {
|
||||
const POLL: Duration = Duration::from_millis(100);
|
||||
const LIMIT: Duration = Duration::from_secs(90);
|
||||
if !vectorcraft_engine::cancel::is_cancellable() {
|
||||
return Ok(());
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
let kept = stream.read_timeout()?;
|
||||
stream.set_read_timeout(Some(POLL))?;
|
||||
let outcome = loop {
|
||||
match stream.peek(&mut [0]) {
|
||||
// Bytes (or the end of the stream) to read now.
|
||||
Ok(_) => break Ok(()),
|
||||
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
|
||||
if vectorcraft_engine::cancel::is_cancelled() {
|
||||
break Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Interrupted,
|
||||
"cancelled; the app may still finish what it was asked, which can't be stopped from here",
|
||||
));
|
||||
}
|
||||
if start.elapsed() >= LIMIT {
|
||||
break Err(e);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
};
|
||||
stream.set_read_timeout(kept)?;
|
||||
outcome
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
let Some((reader, writer)) = self.conn.as_mut() else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected to the app"));
|
||||
@@ -92,6 +128,7 @@ impl Remote {
|
||||
writer.write_all(line.as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
Self::wait_for_reply(writer)?;
|
||||
let mut buf = Vec::new();
|
||||
if !crate::server::read_line_capped(reader, &mut buf, MAX_REPLY_BYTES)? {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
|
||||
|
||||
@@ -253,6 +253,8 @@ impl Headless {
|
||||
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0);
|
||||
vectorcraft_render::raster_size(r, scale)?;
|
||||
let img = self.renderer.render_region(&doc, r, scale, true);
|
||||
// A cancelled render stops drawing: the picture is not whole and is not written.
|
||||
vectorcraft_engine::cancel::check().map_err(|e| e.to_string())?;
|
||||
let png = img.to_png()?;
|
||||
let mut out = json!({"width": img.width, "height": img.height});
|
||||
match s(p, "path") {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//! What lets the server hear a client while a call runs: a reader thread that takes lines off the
|
||||
//! transport, a queue the engine thread works through, and a writer thread that owns the output.
|
||||
//!
|
||||
//! The engine runs one command at a time, on the thread that called [`crate::Server::serve`]. The
|
||||
//! reader never touches the engine. While a call runs it can still
|
||||
//!
|
||||
//! - act on [`notifications/cancelled`](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation):
|
||||
//! a queued request is dropped, a running one has its [`CancelToken`] raised, and neither gets
|
||||
//! a reply;
|
||||
//! - answer the requests that need no engine (`ping`, the `…/list` methods, `prompts/get`), and
|
||||
//! - refuse, with a "busy" error that names the running call, a request that would have to wait
|
||||
//! behind a call that has already run for [`Server::with_busy_after`](crate::Server::with_busy_after).
|
||||
//!
|
||||
//! Requests that arrive sooner than that queue up and run in order, so a client that pipelines a
|
||||
//! few quick calls sees them answered in order, as before. The queue is bounded: past
|
||||
//! [`MAX_QUEUED`] lines the reader waits for the engine rather than buffering without bound.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::BufRead;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cancel::CancelToken;
|
||||
|
||||
use crate::server::{INVALID_REQUEST, MAX_LINE_BYTES, PARSE_ERROR, SERVER_BUSY, error, error_with, read_line_capped};
|
||||
|
||||
/// Most lines waiting for the engine. Past this the reader waits (it still reads nothing else).
|
||||
pub(crate) const MAX_QUEUED: usize = 64;
|
||||
|
||||
/// A line the engine thread has still to run.
|
||||
pub(crate) enum Job {
|
||||
/// A reply that is already known (a parse error, an overlong line), kept in order with the rest.
|
||||
Canned(String),
|
||||
/// A parsed message: a request, a notification or a batch.
|
||||
Message(Message),
|
||||
}
|
||||
|
||||
pub(crate) struct Message {
|
||||
pub(crate) value: Value,
|
||||
/// The request id as JSON text, for a request; what `notifications/cancelled` names.
|
||||
pub(crate) key: Option<String>,
|
||||
pub(crate) method: String,
|
||||
/// Raised by `notifications/cancelled`, or when the transport closes under the call.
|
||||
pub(crate) token: CancelToken,
|
||||
/// What the call is, for the "busy" answer: `tools/call command_run document.export`.
|
||||
pub(crate) label: String,
|
||||
}
|
||||
|
||||
/// The call the engine thread is running.
|
||||
struct Running {
|
||||
key: Option<String>,
|
||||
method: String,
|
||||
token: CancelToken,
|
||||
label: String,
|
||||
since: Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
queue: VecDeque<Job>,
|
||||
running: Option<Running>,
|
||||
/// The transport has no more lines.
|
||||
eof: bool,
|
||||
/// The output failed: nobody reads replies any more.
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
/// What the reader and the engine thread share.
|
||||
pub(crate) struct Shared {
|
||||
state: Mutex<State>,
|
||||
wake: Condvar,
|
||||
/// Whether the client speaks the `2026-07-28` revision, for results answered by the reader.
|
||||
pub(crate) modern: Arc<AtomicBool>,
|
||||
busy_after: Duration,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
// A thread that panicked while holding the lock left a queue that is still well formed.
|
||||
m.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
pub(crate) fn new(modern: Arc<AtomicBool>, busy_after: Duration) -> Self {
|
||||
Self { state: Mutex::new(State::default()), wake: Condvar::new(), modern, busy_after }
|
||||
}
|
||||
|
||||
/// The next job, or `None` once the transport is done and the queue empty (or the output
|
||||
/// failed). The job counts as running until [`Shared::finished`].
|
||||
pub(crate) fn next(&self) -> Option<Job> {
|
||||
let mut st = lock(&self.state);
|
||||
loop {
|
||||
if st.closed {
|
||||
return None;
|
||||
}
|
||||
if let Some(job) = st.queue.pop_front() {
|
||||
if let Job::Message(m) = &job {
|
||||
st.running = Some(Running {
|
||||
key: m.key.clone(),
|
||||
method: m.method.clone(),
|
||||
token: m.token.clone(),
|
||||
label: m.label.clone(),
|
||||
since: Instant::now(),
|
||||
});
|
||||
}
|
||||
// The reader may be waiting for room.
|
||||
self.wake.notify_all();
|
||||
return Some(job);
|
||||
}
|
||||
if st.eof {
|
||||
return None;
|
||||
}
|
||||
st = self.wake.wait(st).unwrap_or_else(PoisonError::into_inner);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn finished(&self) {
|
||||
lock(&self.state).running = None;
|
||||
self.wake.notify_all();
|
||||
}
|
||||
|
||||
/// The output failed: stop the running call and everything queued behind it.
|
||||
pub(crate) fn close(&self) {
|
||||
let mut st = lock(&self.state);
|
||||
st.closed = true;
|
||||
if let Some(r) = &st.running {
|
||||
r.token.cancel();
|
||||
}
|
||||
st.queue.clear();
|
||||
self.wake.notify_all();
|
||||
}
|
||||
|
||||
fn end_of_input(&self) {
|
||||
lock(&self.state).eof = true;
|
||||
self.wake.notify_all();
|
||||
}
|
||||
|
||||
fn is_closed(&self) -> bool {
|
||||
lock(&self.state).closed
|
||||
}
|
||||
|
||||
/// The running call, once it has run for `busy_after` (so a quick call never makes a
|
||||
/// pipelined neighbour fail).
|
||||
fn long_running(&self, st: &State) -> Option<Value> {
|
||||
let r = st.running.as_ref()?;
|
||||
let secs = r.since.elapsed();
|
||||
(secs >= self.busy_after).then(|| json!({"running": r.label, "requestId": r.key, "seconds": secs.as_secs()}))
|
||||
}
|
||||
}
|
||||
|
||||
/// The id of a request message: `Some` when `msg` is a JSON-RPC 2.0 request with a usable id.
|
||||
fn request_id(msg: &Value) -> Option<&Value> {
|
||||
let o = msg.as_object()?;
|
||||
if o.get("jsonrpc").and_then(Value::as_str) != Some("2.0") || !o.get("method")?.is_string() {
|
||||
return None;
|
||||
}
|
||||
o.get("id").filter(|id| id.is_string() || id.is_number())
|
||||
}
|
||||
|
||||
/// A short name for the call, as the "busy" answer shows it.
|
||||
fn label_of(method: &str, params: &Value) -> String {
|
||||
if method != "tools/call" {
|
||||
return method.to_string();
|
||||
}
|
||||
let name = params.get("name").and_then(Value::as_str).unwrap_or("?");
|
||||
let args = params.get("arguments");
|
||||
let which = ["id", "command", "path"].iter().find_map(|k| args?.get(k)?.as_str());
|
||||
match which {
|
||||
Some(w) => format!("{method} {name} {w}"),
|
||||
None => format!("{method} {name}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read lines from `reader` until the end, acting on each as described in the module docs.
|
||||
pub(crate) fn read_loop(shared: &Shared, out: &SyncSender<String>, mut reader: impl BufRead) -> std::io::Result<()> {
|
||||
let mut buf = Vec::new();
|
||||
let result = loop {
|
||||
match read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => break Ok(()),
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
if shared.is_closed() {
|
||||
break Ok(());
|
||||
}
|
||||
if buf.len() > MAX_LINE_BYTES {
|
||||
// Longer than anything a client can mean: answer the framing violation, skip the
|
||||
// rest of the line in bounded chunks, and keep serving what follows.
|
||||
let reply = error(Value::Null, INVALID_REQUEST, format!("request line longer than {MAX_LINE_BYTES} bytes")).to_string();
|
||||
submit_canned(shared, out, reply);
|
||||
let ended = loop {
|
||||
match read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES) {
|
||||
Ok(true) if buf.len() > MAX_LINE_BYTES => {}
|
||||
Ok(true) => break Ok(false),
|
||||
Ok(false) => break Ok(true),
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
};
|
||||
match ended {
|
||||
Ok(false) => continue,
|
||||
Ok(true) => break Ok(()),
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
}
|
||||
let line = match std::str::from_utf8(&buf) {
|
||||
Ok(l) => l,
|
||||
Err(e) => break Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)),
|
||||
};
|
||||
intake(shared, out, line);
|
||||
};
|
||||
shared.end_of_input();
|
||||
result
|
||||
}
|
||||
|
||||
/// A reply that is already known: in order behind queued work, or at once when the engine is
|
||||
/// tied up for long (then nobody can expect order).
|
||||
fn submit_canned(shared: &Shared, out: &SyncSender<String>, reply: String) {
|
||||
let mut st = lock(&shared.state);
|
||||
if shared.long_running(&st).is_some() {
|
||||
drop(st);
|
||||
let _ = out.send(reply);
|
||||
return;
|
||||
}
|
||||
st.queue.push_back(Job::Canned(reply));
|
||||
shared.wake.notify_all();
|
||||
}
|
||||
|
||||
fn intake(shared: &Shared, out: &SyncSender<String>, line: &str) {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return;
|
||||
}
|
||||
let value = match serde_json::from_str::<Value>(line) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return submit_canned(shared, out, error(Value::Null, PARSE_ERROR, format!("parse error: {e}")).to_string()),
|
||||
};
|
||||
let method = value.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let enveloped = value.get("jsonrpc").and_then(Value::as_str) == Some("2.0");
|
||||
if enveloped && method == "notifications/cancelled" && value.get("id").is_none() {
|
||||
return cancel(shared, value.get("params").unwrap_or(&Value::Null));
|
||||
}
|
||||
let params = value.get("params").cloned().unwrap_or(Value::Null);
|
||||
let id = request_id(&value).cloned();
|
||||
let is_batch = value.is_array();
|
||||
let token = CancelToken::new();
|
||||
let msg = Message { key: id.as_ref().map(Value::to_string), label: label_of(&method, ¶ms), method, token, value };
|
||||
let mut st = lock(&shared.state);
|
||||
loop {
|
||||
if st.closed {
|
||||
return;
|
||||
}
|
||||
let busy = shared.long_running(&st);
|
||||
if let Some(busy) = busy {
|
||||
if let Some(id) = &id {
|
||||
// Engine-free requests are answered here; others are told to retry.
|
||||
let reply = match crate::server::stateless_reply(&msg.value, shared.modern.load(Ordering::Relaxed)) {
|
||||
Some(r) => r,
|
||||
None => error_with(id.clone(), SERVER_BUSY, busy_message(&busy), json!({"busy": busy})),
|
||||
};
|
||||
drop(st);
|
||||
let _ = out.send(reply.to_string());
|
||||
return;
|
||||
}
|
||||
if is_batch {
|
||||
drop(st);
|
||||
let reply = error_with(Value::Null, SERVER_BUSY, busy_message(&busy), json!({"busy": busy}));
|
||||
let _ = out.send(reply.to_string());
|
||||
return;
|
||||
}
|
||||
}
|
||||
if st.queue.len() < MAX_QUEUED {
|
||||
break;
|
||||
}
|
||||
// Wake now and then: the call that fills the queue may be the one that turns it away.
|
||||
st = shared.wake.wait_timeout(st, Duration::from_millis(50)).unwrap_or_else(PoisonError::into_inner).0;
|
||||
}
|
||||
st.queue.push_back(Job::Message(msg));
|
||||
shared.wake.notify_all();
|
||||
}
|
||||
|
||||
fn busy_message(busy: &Value) -> String {
|
||||
format!(
|
||||
"the engine is busy: `{}` has been running for {} s and this request was not run. Retry when it has finished, or cancel it with notifications/cancelled (requestId {})",
|
||||
busy["running"].as_str().unwrap_or("a call"),
|
||||
busy["seconds"].as_u64().unwrap_or(0),
|
||||
busy["requestId"],
|
||||
)
|
||||
}
|
||||
|
||||
/// `notifications/cancelled {requestId}`: stop that request if it is still queued or running.
|
||||
/// An unknown or finished id is ignored, as is a request that can't be cancelled (`initialize`).
|
||||
fn cancel(shared: &Shared, params: &Value) {
|
||||
let Some(id) = params.get("requestId").filter(|id| id.is_string() || id.is_number()) else { return };
|
||||
let key = id.to_string();
|
||||
let mut st = lock(&shared.state);
|
||||
if let Some(r) = st.running.as_ref().filter(|r| r.key.as_deref() == Some(&key) && r.method != "initialize") {
|
||||
log::debug!("cancelling running request {key}: {}", r.label);
|
||||
r.token.cancel();
|
||||
return;
|
||||
}
|
||||
let at = st.queue.iter().position(|j| matches!(j, Job::Message(m) if m.key.as_deref() == Some(&key) && m.method != "initialize"));
|
||||
if let Some(at) = at {
|
||||
log::debug!("dropping queued request {key}");
|
||||
st.queue.remove(at);
|
||||
shared.wake.notify_all();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
mod backend;
|
||||
mod headless;
|
||||
mod intake;
|
||||
pub mod logging;
|
||||
mod prompts;
|
||||
mod resources;
|
||||
@@ -37,6 +38,8 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_batchdraw;
|
||||
#[cfg(test)]
|
||||
mod tests_concurrency;
|
||||
#[cfg(test)]
|
||||
mod tests_conventions;
|
||||
#[cfg(test)]
|
||||
mod tests_distortkeys;
|
||||
|
||||
+213
-68
@@ -1,10 +1,18 @@
|
||||
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::io::{BufRead, Write};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{SyncSender, sync_channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cancel;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::intake::{Job, Message, Shared, read_loop};
|
||||
use crate::prompts;
|
||||
use crate::resources;
|
||||
use crate::tools::{call_tool, tool_definitions};
|
||||
@@ -14,12 +22,18 @@ pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
const MODERN_VERSION: &str = "2026-07-28";
|
||||
const SUPPORTED_VERSIONS: &[&str] = &[MODERN_VERSION, PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
|
||||
|
||||
const PARSE_ERROR: i64 = -32700;
|
||||
const INVALID_REQUEST: i64 = -32600;
|
||||
pub(crate) const PARSE_ERROR: i64 = -32700;
|
||||
pub(crate) const INVALID_REQUEST: i64 = -32600;
|
||||
const METHOD_NOT_FOUND: i64 = -32601;
|
||||
const INVALID_PARAMS: i64 = -32602;
|
||||
const INTERNAL_ERROR: i64 = -32603;
|
||||
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
/// The engine is running a long call and this request was not run (implementation-defined range).
|
||||
pub(crate) const SERVER_BUSY: i64 = -32003;
|
||||
|
||||
/// How long a call runs before a request that would have to wait behind it is refused as busy
|
||||
/// instead ([`Server::with_busy_after`]).
|
||||
const DEFAULT_BUSY_AFTER: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is answered with an error and skipped, so one huge line can't make the server
|
||||
@@ -29,6 +43,40 @@ pub(crate) const MAX_LINE_BYTES: usize = 16 * 1024 * 1024;
|
||||
/// older clients can make the server parse and answer at once (#1727).
|
||||
pub(crate) const MAX_BATCH_MESSAGES: usize = 64;
|
||||
|
||||
/// Lines waiting to be written. A full queue holds up the thread that wants to write (the
|
||||
/// engine thread, the reader) rather than growing; progress is dropped instead (it is advisory).
|
||||
const OUTBOX: usize = 256;
|
||||
/// Fewest time between two `notifications/progress` of one request, the last one excepted.
|
||||
const PROGRESS_EVERY: Duration = Duration::from_millis(200);
|
||||
|
||||
/// The sink that turns the engine's progress into `notifications/progress` for the request
|
||||
/// `msg`, when the client asked for it with `_meta.progressToken`.
|
||||
/// <https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress>
|
||||
fn progress_sink(out: SyncSender<String>, msg: &Value) -> Option<cancel::ProgressSink> {
|
||||
let token = msg.get("params")?.get("_meta")?.get("progressToken").filter(|t| t.is_string() || t.is_number())?.clone();
|
||||
let last = Cell::new(None::<u64>);
|
||||
let at = Cell::new(None::<Instant>);
|
||||
Some(Rc::new(move |done, total, what| {
|
||||
// `progress` must increase with each notification.
|
||||
if last.get().is_some_and(|l| done <= l) {
|
||||
return;
|
||||
}
|
||||
let finished = total > 0 && done >= total;
|
||||
if !finished && at.get().is_some_and(|t| t.elapsed() < PROGRESS_EVERY) {
|
||||
return;
|
||||
}
|
||||
last.set(Some(done));
|
||||
at.set(Some(Instant::now()));
|
||||
let mut params = json!({"progressToken": token, "progress": done, "message": what});
|
||||
if total > 0 {
|
||||
params["total"] = json!(total);
|
||||
}
|
||||
let line = json!({"jsonrpc": "2.0", "method": "notifications/progress", "params": params}).to_string();
|
||||
// Advisory: a client that doesn't read fast enough misses an update, not the reply.
|
||||
let _ = out.try_send(line);
|
||||
}))
|
||||
}
|
||||
|
||||
const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are \
|
||||
points in document space (y down, origin at the first artboard's top-left; a new document is 612×792). \
|
||||
Draw with draw_shape / draw_path, change colours with set_paint, look with render_preview and doc_inspect. \
|
||||
@@ -42,20 +90,88 @@ vectorcraft://swatch/{name} read one thing at a time instead of the whole docume
|
||||
pub struct Server {
|
||||
backend: Box<dyn Backend>,
|
||||
initialized: bool,
|
||||
modern: bool,
|
||||
/// Whether the client speaks the `2026-07-28` revision. Shared with the reader thread, which
|
||||
/// answers some requests itself while the engine is busy.
|
||||
modern: Arc<AtomicBool>,
|
||||
/// Whether this server's client asked for log records (`logging/setLevel`). The queue is
|
||||
/// process-wide, so a server whose client didn't ask leaves it to the one that did.
|
||||
logging: bool,
|
||||
busy_after: Duration,
|
||||
}
|
||||
|
||||
fn response(id: Value, result: Value) -> Value {
|
||||
pub(crate) fn response(id: Value, result: Value) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "result": result})
|
||||
}
|
||||
|
||||
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
||||
pub(crate) fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
|
||||
}
|
||||
|
||||
pub(crate) fn error_with(id: Value, code: i64, message: impl Into<String>, data: Value) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into(), "data": data}})
|
||||
}
|
||||
|
||||
/// The requests that need neither the engine nor the server's state beyond the protocol revision,
|
||||
/// so the reader can answer them while the engine is busy.
|
||||
pub(crate) fn stateless_request(method: &str, params: &Value) -> Option<Result<Value, (i64, String)>> {
|
||||
Some(match method {
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(json!({"tools": tool_definitions()})),
|
||||
"resources/list" => Ok(resources::list()),
|
||||
"resources/templates/list" => Ok(resources::templates()),
|
||||
"prompts/list" => Ok(prompts::list()),
|
||||
"prompts/get" => {
|
||||
let name = params.get("name").and_then(Value::as_str);
|
||||
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
|
||||
match name {
|
||||
Some(name) => prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e)),
|
||||
None => Err((INVALID_PARAMS, "missing prompt `name`".to_string())),
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The reply to `msg` when it is a request [`stateless_request`] answers.
|
||||
pub(crate) fn stateless_reply(msg: &Value, modern: bool) -> Option<Value> {
|
||||
let o = msg.as_object()?;
|
||||
let method = o.get("method")?.as_str()?;
|
||||
let id = o.get("id").filter(|id| id.is_string() || id.is_number())?.clone();
|
||||
if o.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
|
||||
return None;
|
||||
}
|
||||
let params = o.get("params").cloned().unwrap_or(Value::Null);
|
||||
// A panic here is a bug: leave the request to the engine thread, which reports it.
|
||||
let r = vectorcraft_engine::guard::catch_panic(|| stateless_request(method, ¶ms)).ok()??;
|
||||
Some(match r {
|
||||
Ok(r) => response(id, complete_result(method, ¶ms, modern, r)),
|
||||
Err((code, m)) => error(id, code, m),
|
||||
})
|
||||
}
|
||||
|
||||
/// The cache hints the `2026-07-28` revision adds to list and read results. `modern` is whether the
|
||||
/// session negotiated that revision; a request can also name it in its `_meta`.
|
||||
pub(crate) fn complete_result(method: &str, params: &Value, modern: bool, mut r: Value) -> Value {
|
||||
let modern = params
|
||||
.get("_meta")
|
||||
.and_then(|m| m.get("io.modelcontextprotocol/protocolVersion"))
|
||||
.and_then(Value::as_str)
|
||||
.map_or(modern, |v| v == MODERN_VERSION);
|
||||
if modern && let Some(result) = r.as_object_mut() {
|
||||
let ttl = match method {
|
||||
"tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000),
|
||||
"resources/read" | "prompts/get" => Some(0),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ttl) = ttl {
|
||||
result.insert("resultType".into(), json!("complete"));
|
||||
result.insert("ttlMs".into(), json!(ttl));
|
||||
result.insert("cacheScope".into(), json!("private"));
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line into `buf` (newline stripped). `Ok(false)` at end of stream.
|
||||
/// `buf` holding more than `max` bytes means the line is overlong: no newline arrived within the
|
||||
/// bound, so the bytes so far are only its start. Only `max + 1` bytes are ever buffered for one
|
||||
@@ -101,7 +217,15 @@ fn write_reply(output: &mut impl Write, line: &str) -> std::io::Result<()> {
|
||||
|
||||
impl Server {
|
||||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||||
Self { backend, initialized: false, modern: false, logging: false }
|
||||
Self { backend, initialized: false, modern: Arc::new(AtomicBool::new(false)), logging: false, busy_after: DEFAULT_BUSY_AFTER }
|
||||
}
|
||||
|
||||
/// How long a call runs before [`Server::serve`] turns away a request that would have to wait
|
||||
/// behind it, with an error naming the call (default two seconds). Shorter waits queue, so a
|
||||
/// client pipelining quick calls is answered in order.
|
||||
pub fn with_busy_after(mut self, after: Duration) -> Self {
|
||||
self.busy_after = after;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn backend(&mut self) -> &mut dyn Backend {
|
||||
@@ -130,36 +254,76 @@ impl Server {
|
||||
|
||||
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
|
||||
/// the protocol stream).
|
||||
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
let mut reader = input;
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
if !read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES)? {
|
||||
return Ok(());
|
||||
}
|
||||
if buf.len() > MAX_LINE_BYTES {
|
||||
// Longer than anything a client can mean: answer the framing violation, skip the
|
||||
// rest of the line in bounded chunks, and keep serving what follows.
|
||||
write_reply(
|
||||
&mut output,
|
||||
&error(Value::Null, INVALID_REQUEST, format!("request line longer than {MAX_LINE_BYTES} bytes")).to_string(),
|
||||
)?;
|
||||
loop {
|
||||
if !read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES)? {
|
||||
return Ok(());
|
||||
}
|
||||
if buf.len() <= MAX_LINE_BYTES {
|
||||
break;
|
||||
///
|
||||
/// A reader thread takes lines off `input` while the calling thread runs them, so the server
|
||||
/// hears a client during a long call: `notifications/cancelled` stops a queued or running
|
||||
/// request (which then gets no reply), and `ping` and the other engine-free requests are
|
||||
/// answered at once. A request that would have to wait behind a call that has run for
|
||||
/// [`Server::with_busy_after`] is refused with a "busy" error. Calls still run one at a time,
|
||||
/// in arrival order, and replies of quick calls keep that order. See the module docs of
|
||||
/// `intake` and `docs/mcp.md`.
|
||||
///
|
||||
/// Returns when `input` ends and everything read has been answered, or when `output` fails
|
||||
/// (the running call is then cancelled and the queue dropped; the reader thread ends with its
|
||||
/// next line, which a client that closed our output closes as well).
|
||||
pub fn serve<R: BufRead + Send, W: Write + Send>(&mut self, input: R, mut output: W) -> std::io::Result<()> {
|
||||
let shared = Shared::new(self.modern.clone(), self.busy_after);
|
||||
let shared = &shared;
|
||||
let (tx, rx) = sync_channel::<String>(OUTBOX);
|
||||
let reader_tx = tx.clone();
|
||||
std::thread::scope(|scope| {
|
||||
// The only thread that writes: replies, progress and log records stay whole lines.
|
||||
let writer = scope.spawn(move || {
|
||||
let mut failed = None;
|
||||
for line in rx {
|
||||
if failed.is_none()
|
||||
&& let Err(e) = write_reply(&mut output, &line)
|
||||
{
|
||||
failed = Some(e);
|
||||
shared.close();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
failed
|
||||
});
|
||||
let reader = scope.spawn(move || read_loop(shared, &reader_tx, input));
|
||||
self.work(shared, &tx);
|
||||
drop(tx);
|
||||
let read = reader.join().unwrap_or_else(|_| Err(std::io::Error::other("the MCP reader thread panicked")));
|
||||
let written = writer.join().unwrap_or_else(|_| Some(std::io::Error::other("the MCP writer thread panicked")));
|
||||
match written {
|
||||
Some(e) => Err(e),
|
||||
None => read,
|
||||
}
|
||||
let line = std::str::from_utf8(&buf).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
let reply = self.handle_line(line);
|
||||
let notes = self.take_notifications();
|
||||
for line in reply.iter().chain(notes.iter()) {
|
||||
write_reply(&mut output, line)?;
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the queued jobs, one at a time, until the reader is done and the queue empty.
|
||||
fn work(&mut self, shared: &Shared, out: &SyncSender<String>) {
|
||||
while let Some(job) = shared.next() {
|
||||
match job {
|
||||
Job::Canned(line) => {
|
||||
let _ = out.send(line);
|
||||
}
|
||||
Job::Message(m) => self.run(m, out),
|
||||
}
|
||||
for note in self.take_notifications() {
|
||||
let _ = out.send(note);
|
||||
}
|
||||
shared.finished();
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self, m: Message, out: &SyncSender<String>) {
|
||||
let Message { value, key, token, .. } = m;
|
||||
let sink = key.is_some().then(|| progress_sink(out.clone(), &value)).flatten();
|
||||
let reply = cancel::scope(&token, sink, || self.handle_parsed(value));
|
||||
// A cancelled request gets no reply (MCP: the receiver should not answer it). A call that
|
||||
// finished before it noticed is dropped the same way: the client has stopped waiting.
|
||||
if key.is_some() && token.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
if let Some(reply) = reply {
|
||||
let _ = out.send(reply.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +334,16 @@ impl Server {
|
||||
return None;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(Value::Array(batch)) => {
|
||||
Ok(value) => self.handle_parsed(value),
|
||||
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
|
||||
};
|
||||
reply.map(|r| r.to_string())
|
||||
}
|
||||
|
||||
/// Handle one parsed line: a message, or a batch of them.
|
||||
fn handle_parsed(&mut self, value: Value) -> Option<Value> {
|
||||
match value {
|
||||
Value::Array(batch) => {
|
||||
// Batches were removed in 2025-06-18; still answer older clients sensibly.
|
||||
if batch.is_empty() {
|
||||
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
|
||||
@@ -181,10 +354,8 @@ impl Server {
|
||||
(!replies.is_empty()).then_some(Value::Array(replies))
|
||||
}
|
||||
}
|
||||
Ok(msg) => self.handle(msg),
|
||||
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
|
||||
};
|
||||
reply.map(|r| r.to_string())
|
||||
msg => self.handle(msg),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one JSON-RPC message; `None` for notifications and responses.
|
||||
@@ -216,26 +387,7 @@ impl Server {
|
||||
let r = vectorcraft_engine::guard::catch_panic(|| self.request(method, ¶ms))
|
||||
.unwrap_or_else(|msg| Err((INTERNAL_ERROR, format!("internal error in `{method}`: {msg} (please report this bug)"))));
|
||||
Some(match r {
|
||||
Ok(mut r) => {
|
||||
let modern = params
|
||||
.get("_meta")
|
||||
.and_then(|m| m.get("io.modelcontextprotocol/protocolVersion"))
|
||||
.and_then(Value::as_str)
|
||||
.map_or(self.modern, |v| v == MODERN_VERSION);
|
||||
if modern && let Some(result) = r.as_object_mut() {
|
||||
let ttl = match method {
|
||||
"tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000),
|
||||
"resources/read" | "prompts/get" => Some(0),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ttl) = ttl {
|
||||
result.insert("resultType".into(), json!("complete"));
|
||||
result.insert("ttlMs".into(), json!(ttl));
|
||||
result.insert("cacheScope".into(), json!("private"));
|
||||
}
|
||||
}
|
||||
response(id, r)
|
||||
}
|
||||
Ok(r) => response(id, complete_result(method, ¶ms, self.modern.load(Ordering::Relaxed), r)),
|
||||
Err((code, m)) => error(id, code, m),
|
||||
})
|
||||
}
|
||||
@@ -249,11 +401,14 @@ impl Server {
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
if let Some(r) = stateless_request(method, params) {
|
||||
return r;
|
||||
}
|
||||
match method {
|
||||
"initialize" => {
|
||||
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
|
||||
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
|
||||
self.modern = version == MODERN_VERSION;
|
||||
self.modern.store(version == MODERN_VERSION, Ordering::Relaxed);
|
||||
Ok(json!({
|
||||
"protocolVersion": version,
|
||||
"capabilities": {
|
||||
@@ -267,8 +422,6 @@ impl Server {
|
||||
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
|
||||
}))
|
||||
}
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(json!({"tools": tool_definitions()})),
|
||||
"tools/call" => {
|
||||
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
|
||||
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
|
||||
@@ -277,8 +430,6 @@ impl Server {
|
||||
}
|
||||
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
|
||||
}
|
||||
"resources/list" => Ok(resources::list()),
|
||||
"resources/templates/list" => Ok(resources::templates()),
|
||||
"resources/read" => {
|
||||
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
|
||||
let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e {
|
||||
@@ -288,12 +439,6 @@ impl Server {
|
||||
})?;
|
||||
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
|
||||
}
|
||||
"prompts/list" => Ok(prompts::list()),
|
||||
"prompts/get" => {
|
||||
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing prompt `name`".to_string()))?;
|
||||
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
|
||||
prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e))
|
||||
}
|
||||
"completion/complete" => Ok(prompts::complete(self.backend.as_mut(), params)),
|
||||
"logging/setLevel" => {
|
||||
// `level` is required. A null level is our own way of turning logging back off,
|
||||
|
||||
@@ -240,6 +240,13 @@ fn headless_end_to_end() {
|
||||
assert_eq!(skel["artboards"], doc["artboards"]);
|
||||
assert_eq!(call(&mut s, 42, "inspect_document", json!({"depth": -1}))["isError"], true);
|
||||
|
||||
// Scoping reaches the command: one node by id equals its part of the full tree;
|
||||
// an unknown id is a tool error.
|
||||
let r = call(&mut s, 43, "doc_inspect", json!({"ids": [id]}));
|
||||
let one: Value = serde_json::from_str(&text_of(&r)).unwrap();
|
||||
assert_eq!(one["nodes"], json!([rect]), "{one}");
|
||||
assert_eq!(call(&mut s, 44, "doc_inspect", json!({"ids": [999_999]}))["isError"], true);
|
||||
|
||||
// Screenshot returns image content (base64 PNG) and text.
|
||||
let shot_path = tmp("shot.png");
|
||||
let r = call(&mut s, 5, "screenshot", json!({"path": shot_path.to_str().unwrap()}));
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Reading while a call runs: `ping`, cancellation and busy answers reach the client while a long
|
||||
//! command holds the engine (#2165, #2171). Driven through [`Server::serve`] on a pipe, the way a
|
||||
//! client on stdio drives it, with a backend that works until it is told to stop.
|
||||
|
||||
use std::io::{BufReader, Write};
|
||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cancel;
|
||||
|
||||
use crate::{Backend, Server};
|
||||
|
||||
/// A backend whose `engine.execute` runs until cancelled (or for `limit`), like a long export.
|
||||
struct Slow {
|
||||
limit: Duration,
|
||||
/// Commands that ran to the end, and the ones that were cancelled.
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
/// Told when a command starts.
|
||||
started: Sender<String>,
|
||||
}
|
||||
|
||||
impl Backend for Slow {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
if method != "engine.execute" {
|
||||
return Ok(json!({"method": method}));
|
||||
}
|
||||
let id = params.get("command").and_then(Value::as_str).unwrap_or("?").to_string();
|
||||
self.started.send(id.clone()).ok();
|
||||
if id == "progress.demo" {
|
||||
for page in 1..=3 {
|
||||
// Enough apart to pass the sender's throttle.
|
||||
std::thread::sleep(Duration::from_millis(230));
|
||||
cancel::progress(page, 3, &format!("page {page}"));
|
||||
}
|
||||
return Ok(json!({"ran": id}));
|
||||
}
|
||||
let limit = if id.starts_with("quick") { Duration::ZERO } else { self.limit };
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < limit {
|
||||
if let Err(e) = cancel::check() {
|
||||
self.log.lock().unwrap().push(format!("cancelled {id}"));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
self.log.lock().unwrap().push(format!("done {id}"));
|
||||
Ok(json!({"ran": id}))
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
"slow".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the lines the server writes.
|
||||
struct Lines(Sender<String>, Vec<u8>);
|
||||
|
||||
impl Write for Lines {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
for &b in buf {
|
||||
if b == b'\n' {
|
||||
self.0.send(String::from_utf8_lossy(&std::mem::take(&mut self.1)).into_owned()).ok();
|
||||
} else {
|
||||
self.1.push(b);
|
||||
}
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A running server with a pipe in and a channel out.
|
||||
struct Rig {
|
||||
to_server: std::io::PipeWriter,
|
||||
replies: Receiver<String>,
|
||||
started: Receiver<String>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
fn new(limit: Duration, busy_after: Duration) -> Self {
|
||||
let (read, to_server) = std::io::pipe().unwrap();
|
||||
let (tx, replies) = mpsc::channel();
|
||||
let (started_tx, started) = mpsc::channel();
|
||||
let log = Arc::new(Mutex::new(vec![]));
|
||||
let backend = Slow { limit, log: log.clone(), started: started_tx };
|
||||
let thread = std::thread::spawn(move || {
|
||||
let mut server = Server::new(Box::new(backend)).with_busy_after(busy_after);
|
||||
server.serve(BufReader::new(read), Lines(tx, vec![])).unwrap();
|
||||
});
|
||||
Self { to_server, replies, started, log, thread: Some(thread) }
|
||||
}
|
||||
|
||||
fn send(&mut self, msg: Value) {
|
||||
writeln!(self.to_server, "{msg}").unwrap();
|
||||
}
|
||||
|
||||
fn request(&mut self, id: u64, method: &str, params: Value) {
|
||||
self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
|
||||
}
|
||||
|
||||
fn run(&mut self, id: u64, command: &str) {
|
||||
self.request(id, "tools/call", json!({"name": "command_run", "arguments": {"id": command}}));
|
||||
}
|
||||
|
||||
fn notify(&mut self, method: &str, params: Value) {
|
||||
self.send(json!({"jsonrpc": "2.0", "method": method, "params": params}));
|
||||
}
|
||||
|
||||
fn next(&self, within: Duration) -> Option<Value> {
|
||||
match self.replies.recv_timeout(within) {
|
||||
Ok(l) => serde_json::from_str(&l).ok(),
|
||||
Err(RecvTimeoutError::Timeout) => None,
|
||||
Err(RecvTimeoutError::Disconnected) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The next reply that answers request `id`, skipping notifications.
|
||||
fn reply(&self, id: u64, within: Duration) -> Option<Value> {
|
||||
let end = Instant::now() + within;
|
||||
while let Some(left) = end.checked_duration_since(Instant::now()) {
|
||||
let v = self.next(left)?;
|
||||
if v.get("id") == Some(&json!(id)) {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn finish(mut self) {
|
||||
let thread = self.thread.take().unwrap();
|
||||
drop(self.to_server);
|
||||
thread.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
const LONG: Duration = Duration::from_secs(20);
|
||||
const QUICK: Duration = Duration::from_secs(3);
|
||||
|
||||
#[test]
|
||||
fn ping_is_answered_while_a_command_runs() {
|
||||
let mut rig = Rig::new(LONG, Duration::from_millis(50));
|
||||
rig.run(1, "document.export");
|
||||
assert_eq!(rig.started.recv_timeout(QUICK).unwrap(), "document.export");
|
||||
// Past `busy_after` the call counts as long-running.
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
rig.request(2, "ping", json!({}));
|
||||
let pong = rig.reply(2, QUICK).expect("ping must not wait for the running command");
|
||||
assert_eq!(pong["result"], json!({}));
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 1}));
|
||||
rig.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_stops_the_running_command_and_sends_no_reply() {
|
||||
let mut rig = Rig::new(LONG, Duration::from_secs(60));
|
||||
rig.run(1, "document.export");
|
||||
rig.started.recv_timeout(QUICK).unwrap();
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 1, "reason": "user"}));
|
||||
// The engine is free again: the next command runs and answers.
|
||||
rig.run(2, "pdf.preset.list");
|
||||
let end = Instant::now() + QUICK;
|
||||
loop {
|
||||
if rig.log.lock().unwrap().iter().any(|l| l == "cancelled document.export") {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < end, "the export kept running: {:?}", rig.log.lock().unwrap());
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 2}));
|
||||
// Request 1 was cancelled: nothing answers it (MCP: the receiver should not reply).
|
||||
assert!(rig.reply(1, Duration::from_millis(300)).is_none());
|
||||
rig.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_would_wait_behind_a_long_call_is_refused_as_busy() {
|
||||
let mut rig = Rig::new(LONG, Duration::from_millis(50));
|
||||
rig.run(1, "document.export");
|
||||
rig.started.recv_timeout(QUICK).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
rig.run(2, "pdf.preset.list");
|
||||
let busy = rig.reply(2, QUICK).expect("a busy answer, not a wait");
|
||||
assert_eq!(busy["error"]["code"], -32003, "{busy}");
|
||||
assert!(busy["error"]["message"].as_str().unwrap().contains("document.export"), "{busy}");
|
||||
assert_eq!(busy["error"]["data"]["busy"]["requestId"], "1", "{busy}");
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 1}));
|
||||
// The refused request never ran, and the engine is free once the export stops.
|
||||
rig.run(3, "pdf.preset.list");
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 3}));
|
||||
rig.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_calls_pipelined_together_queue_and_answer_in_order() {
|
||||
let mut rig = Rig::new(Duration::from_millis(30), Duration::from_secs(60));
|
||||
for id in 1..=4 {
|
||||
rig.run(id, &format!("cmd{id}"));
|
||||
}
|
||||
for id in 1..=4 {
|
||||
let r = rig.reply(id, QUICK).unwrap_or_else(|| panic!("no reply to {id}"));
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
}
|
||||
assert_eq!(*rig.log.lock().unwrap(), ["done cmd1", "done cmd2", "done cmd3", "done cmd4"]);
|
||||
rig.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_queued_request_that_is_cancelled_never_runs() {
|
||||
let mut rig = Rig::new(LONG, Duration::from_secs(60));
|
||||
rig.run(1, "document.export");
|
||||
rig.started.recv_timeout(QUICK).unwrap();
|
||||
rig.run(2, "never.runs");
|
||||
rig.run(3, "quick.last");
|
||||
// Request 2 may not have been read yet when the cancel is: it is the order of lines that counts.
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 2}));
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 1}));
|
||||
let r = rig.reply(3, QUICK).expect("the engine goes on to the next request");
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 3}));
|
||||
let log = rig.log.lock().unwrap().clone();
|
||||
assert!(log.iter().all(|l| !l.contains("never.runs")), "{log:?}");
|
||||
assert!(log.iter().any(|l| l == "cancelled document.export"), "{log:?}");
|
||||
drop(log);
|
||||
rig.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_an_unknown_or_finished_request_changes_nothing() {
|
||||
let mut rig = Rig::new(Duration::from_millis(10), Duration::from_secs(60));
|
||||
rig.run(1, "quick");
|
||||
assert!(rig.reply(1, QUICK).unwrap().get("result").is_some());
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 1}));
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 99}));
|
||||
rig.notify("notifications/cancelled", json!({"requestId": [1], "reason": "junk"}));
|
||||
rig.notify("notifications/cancelled", json!("junk"));
|
||||
rig.run(2, "quick");
|
||||
assert!(rig.reply(2, QUICK).unwrap().get("result").is_some());
|
||||
rig.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_cannot_be_cancelled() {
|
||||
let mut rig = Rig::new(LONG, Duration::from_secs(60));
|
||||
rig.request(1, "initialize", json!({}));
|
||||
rig.notify("notifications/cancelled", json!({"requestId": 1}));
|
||||
assert!(rig.reply(1, QUICK).unwrap().get("result").is_some());
|
||||
rig.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_reaches_a_client_that_gave_a_token_before_the_reply() {
|
||||
let mut rig = Rig::new(LONG, Duration::from_secs(60));
|
||||
rig.request(1, "tools/call", json!({"name": "command_run", "arguments": {"id": "progress.demo"}, "_meta": {"progressToken": "tok"}}));
|
||||
let mut seen = vec![];
|
||||
let reply = loop {
|
||||
let v = rig.next(QUICK).expect("a reply");
|
||||
if v["method"] == "notifications/progress" {
|
||||
seen.push(v["params"].clone());
|
||||
} else {
|
||||
break v;
|
||||
}
|
||||
};
|
||||
assert!(reply.get("result").is_some(), "{reply}");
|
||||
assert_eq!(seen.len(), 3, "{seen:?}");
|
||||
let values: Vec<u64> = seen.iter().map(|p| p["progress"].as_u64().unwrap()).collect();
|
||||
assert_eq!(values, [1, 2, 3]);
|
||||
assert!(seen.iter().all(|p| p["progressToken"] == "tok" && p["total"] == 3), "{seen:?}");
|
||||
assert_eq!(seen[1]["message"], "page 2");
|
||||
// Without a token nothing is sent.
|
||||
rig.run(2, "progress.demo");
|
||||
let next = rig.next(QUICK).unwrap();
|
||||
assert_eq!(next["id"], 2, "{next}");
|
||||
rig.finish();
|
||||
}
|
||||
|
||||
/// The real engine behind the server: waits (like a slow render would) until the call is
|
||||
/// cancelled, then lets the export run into the cancelled token.
|
||||
struct Gated {
|
||||
inner: crate::Headless,
|
||||
started: Sender<()>,
|
||||
}
|
||||
|
||||
impl Backend for Gated {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
if method == "engine.execute" && params["command"] == "document.export" {
|
||||
self.started.send(()).ok();
|
||||
let end = Instant::now() + LONG;
|
||||
while !cancel::is_cancelled() && Instant::now() < end {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
self.inner.call(method, params)
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
"gated".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_export_through_the_real_engine_writes_nothing_and_leaves_the_session_usable() {
|
||||
let path = vectorcraft_testkit::temp_dir("mcp-cancel").join("out.pdf");
|
||||
std::fs::remove_file(&path).ok();
|
||||
let (read, mut to_server) = std::io::pipe().unwrap();
|
||||
let (tx, replies) = mpsc::channel();
|
||||
let (started_tx, started) = mpsc::channel();
|
||||
let thread = std::thread::spawn(move || {
|
||||
let backend = Gated { inner: crate::Headless::with_document(), started: started_tx };
|
||||
Server::new(Box::new(backend)).serve(BufReader::new(read), Lines(tx, vec![])).unwrap();
|
||||
});
|
||||
let send = |w: &mut std::io::PipeWriter, v: Value| writeln!(w, "{v}").unwrap();
|
||||
let params = json!({"name": "command_run", "arguments": {"id": "document.export", "params": {"path": path.to_str().unwrap()}}});
|
||||
send(&mut to_server, json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params}));
|
||||
started.recv_timeout(QUICK).unwrap();
|
||||
send(&mut to_server, json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}));
|
||||
// The next request is answered: the cancelled export neither blocks nor breaks the engine.
|
||||
send(&mut to_server, json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "doc_inspect", "arguments": {}}}));
|
||||
let reply: Value = serde_json::from_str(&replies.recv_timeout(QUICK).unwrap()).unwrap();
|
||||
assert_eq!(reply["id"], 2, "request 1 is not answered: {reply}");
|
||||
assert_eq!(reply["result"]["isError"], false, "{reply}");
|
||||
assert!(!path.exists(), "a cancelled export writes no file");
|
||||
drop(to_server);
|
||||
thread.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_call_stops_waiting_for_the_app_when_cancelled() {
|
||||
use std::io::BufRead;
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
// An app that takes the request and never answers.
|
||||
let app = std::thread::spawn(move || {
|
||||
let (conn, _) = listener.accept().unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
// Hold the connection open until the test is done with it.
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
drop(conn);
|
||||
});
|
||||
let mut remote = crate::Remote::connect(&addr).unwrap();
|
||||
let token = cancel::CancelToken::new();
|
||||
let raise = token.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
raise.cancel();
|
||||
});
|
||||
let start = Instant::now();
|
||||
let r = cancel::scope(&token, None, || remote.call("engine.execute", json!({"command": "document.export"})));
|
||||
let why = r.unwrap_err();
|
||||
assert!(why.contains("cancelled"), "{why}");
|
||||
assert!(start.elapsed() < Duration::from_secs(2), "returned when cancelled, not when the app answered");
|
||||
app.join().unwrap();
|
||||
}
|
||||
@@ -43,8 +43,9 @@ fn headless_opens_pdf_ai_and_templates() {
|
||||
std::fs::write(&ait, &pdf).unwrap();
|
||||
let r = text(&call_tool(&mut h, "open_file", &json!({"path": ait})));
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
let tpl = tmp("t.vectorcraft");
|
||||
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": tpl}})).unwrap();
|
||||
let saved = h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": tmp("t.vectorcraft")}})).unwrap();
|
||||
let tpl = tmp("t.vctemplate");
|
||||
assert_eq!(saved["path"], tpl.as_str(), "saved with the template's extension: {saved}");
|
||||
let r = text(&call_tool(&mut h, "open_file", &json!({"path": tpl})));
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(h.session.doc().unwrap().path, None, "Save asks for a new name");
|
||||
|
||||
+19
-11
@@ -36,6 +36,20 @@ fn num(desc: &str) -> Value {
|
||||
fn string(desc: &str) -> Value {
|
||||
json!({"type": "string", "description": desc})
|
||||
}
|
||||
/// The arguments `doc_inspect` / `inspect_document` pass on to `document.inspect`.
|
||||
const INSPECT_KEYS: [&str; 6] = ["depth", "childLimit", "ids", "kind", "limit", "offset"];
|
||||
|
||||
/// Their schema.
|
||||
fn inspect_props() -> Value {
|
||||
json!({
|
||||
"depth": {"type": "integer", "minimum": 0, "description": "Child levels of the layer tree to include (default all; 0 = top layers with counts)"},
|
||||
"childLimit": {"type": "integer", "minimum": 0, "description": "Children shown per node (default all; a level that shows fewer reports childCount)"},
|
||||
"ids": {"type": "array", "maxItems": 10000, "items": {"type": "integer", "minimum": 0}, "description": "List just these nodes (any level) as `nodes` instead of the whole layer tree; an unknown id is an error"},
|
||||
"kind": {"type": "string", "description": "List every node of this panel label (Layer, Group, Path, Type, Image, ...; ignores case) as `nodes`, each with its ancestor `path`; searches the subtrees of `ids` when given"},
|
||||
"limit": {"type": "integer", "minimum": 0, "description": "At most this many layers or nodes (the reply adds `total`)"},
|
||||
"offset": {"type": "integer", "minimum": 0, "description": "Layers or nodes skipped first (default 0)"}
|
||||
})
|
||||
}
|
||||
fn paint_schema(what: &str) -> Value {
|
||||
json!({
|
||||
"description": format!("{what}: \"#rrggbb\", \"none\", [r,g,b] (0..1), {{\"c\",\"m\",\"y\",\"k\"}}, {{\"gray\"}}, or a full paint.setFill params object ({{\"gradient\":…}} / {{\"swatch\":name}})"),
|
||||
@@ -129,8 +143,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"doc_inspect",
|
||||
"Inspect document",
|
||||
"Summary of the active document, including artboards, layer tree, selection and history.",
|
||||
obj(json!({"depth":{"type":"integer", "minimum":0},"childLimit":{"type":"integer", "minimum":0}}), &[]),
|
||||
"Summary of the active document, including artboards, layer tree, selection and history. On a large document scope it: depth 0 for the layer skeleton, ids for just those nodes, kind for every node of one kind, limit/offset to page.",
|
||||
obj(inspect_props(), &[]),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
@@ -175,13 +189,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"inspect_document",
|
||||
"Inspect document",
|
||||
"Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke), selection, undo history, current tool and default paint. On a large document pass depth: 0 for the layer skeleton (truncated levels report childCount), then locate objects with run_command document.find and read them with document.node.",
|
||||
obj(
|
||||
json!({
|
||||
"depth": {"type": "integer", "minimum": 0, "description": "Child levels of the layer tree to include (default all; 0 = top layers with counts)"},
|
||||
"childLimit": {"type": "integer", "minimum": 0, "description": "Children shown per node (default all; a level that shows fewer reports childCount)"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
obj(inspect_props(), &[]),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
@@ -919,9 +927,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
j(b.call(method, json!({"command": cmd, "params": params}))?)
|
||||
}
|
||||
"doc_inspect" | "inspect_document" => {
|
||||
// Only the slicing options reach the command, which validates them.
|
||||
// Only the slicing and scope options reach the command, which validates them.
|
||||
let slice: Map<String, Value> =
|
||||
a.iter().filter(|(k, _)| matches!(k.as_str(), "depth" | "childLimit")).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
a.iter().filter(|(k, _)| INSPECT_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
j(b.call("document.inspect", Value::Object(slice))?)
|
||||
}
|
||||
"ui_inspect" | "inspect_ui" => {
|
||||
|
||||
@@ -617,3 +617,64 @@ fn tools_never_leave_interactions_open() {
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })]
|
||||
|
||||
/// Requests mixed with `notifications/cancelled` (for ids that are queued, running, finished,
|
||||
/// unknown or junk) read from one stream: the server ends, every request nobody cancelled is
|
||||
/// answered exactly once, a cancelled one at most once, nothing is answered that wasn't asked,
|
||||
/// replies keep the order of the requests, and the document stays valid.
|
||||
#[test]
|
||||
fn cancellation_never_loses_or_duplicates_an_answer(
|
||||
steps in prop::collection::vec((0u8..6, 0u64..10), 1..40),
|
||||
) {
|
||||
let mut input = String::new();
|
||||
let mut asked: Vec<u64> = vec![];
|
||||
let mut cancelled: Vec<u64> = vec![];
|
||||
for (i, &(kind, n)) in steps.iter().enumerate() {
|
||||
let id = i as u64 + 1;
|
||||
let line = match kind {
|
||||
0 => json!({"jsonrpc": "2.0", "id": id, "method": "ping"}),
|
||||
1 => json!({"jsonrpc": "2.0", "id": id, "method": "tools/list"}),
|
||||
2 => json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": "draw_shape", "arguments": {"shape": "rectangle", "x": 1, "y": 2, "width": 30, "height": 20}}}),
|
||||
3 => json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": "command_run", "arguments": {"id": "select.all"}}, "_meta": {"progressToken": id}}),
|
||||
// A cancel for an id that may be queued, finished or never asked.
|
||||
4 => {
|
||||
let target = n.min(id.saturating_sub(1)).max(1);
|
||||
cancelled.push(target);
|
||||
input.push_str(&format!("{}\n", json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": target}})));
|
||||
continue;
|
||||
}
|
||||
// Junk cancels never take anything with them.
|
||||
_ => {
|
||||
let junk = [json!(null), json!([1]), json!({"a": 1}), json!("nope"), json!(1.5), json!(-1)];
|
||||
input.push_str(&format!("{}\n", json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": junk[n as usize % junk.len()]}})));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
asked.push(id);
|
||||
input.push_str(&format!("{line}\n"));
|
||||
}
|
||||
let mut s = server();
|
||||
let mut out = Vec::new();
|
||||
s.serve(input.as_bytes(), &mut out).unwrap();
|
||||
let ids: Vec<u64> = String::from_utf8(out)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str::<Value>(l).unwrap())
|
||||
.filter(|v| v.get("method").is_none())
|
||||
.map(|v| v["id"].as_u64().unwrap())
|
||||
.collect();
|
||||
prop_assert!(ids.windows(2).all(|w| w[0] < w[1]), "answered once each, in request order: {:?}", ids);
|
||||
for id in &asked {
|
||||
if !cancelled.contains(id) {
|
||||
prop_assert!(ids.contains(id), "request {} was never answered: {:?}", id, ids);
|
||||
}
|
||||
}
|
||||
for id in &ids {
|
||||
prop_assert!(asked.contains(id), "answer to a request nobody made: {}", id);
|
||||
}
|
||||
check_document(&document(&mut s)).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Document → PDF (krilla). Mirrors the tree walk of `vectorcraft-render`.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
@@ -44,8 +44,11 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
||||
fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> {
|
||||
let set = &opts.settings;
|
||||
set.check()?;
|
||||
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
|
||||
// Live geometry effects export as their result; raster effects are reported below.
|
||||
let baked = vectorcraft_effects::bake_document(doc);
|
||||
// Baking stops early when cancelled, leaving a half-baked copy that must not be written.
|
||||
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
let pages = pages(doc, opts)?;
|
||||
|
||||
@@ -77,7 +80,11 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result<ExportR
|
||||
// [`MAX_PAGE_SIDE`] is written in larger units; other files have every page in points.
|
||||
let scales = set.compatibility.has_user_unit() && !set.standard.is_pdfx();
|
||||
let mut units = Vec::with_capacity(pages.len());
|
||||
for (i, boxes) in pages {
|
||||
let total = pages.len() as u64;
|
||||
for (written, (i, boxes)) in pages.into_iter().enumerate() {
|
||||
// The page count and the page being written, for a client that asked for progress.
|
||||
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
|
||||
vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1));
|
||||
let (m, r) = (boxes.media, boxes.bleed);
|
||||
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, created_at) } else { String::new() };
|
||||
let art = crate::marks::art(doc, &marks, &boxes, bleed, &info);
|
||||
@@ -103,6 +110,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result<ExportR
|
||||
};
|
||||
w.page(&mut ex, &sheet).map_err(|_| PdfError::BadArtboard(i))?;
|
||||
}
|
||||
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
|
||||
let (layers, overprinted) = (ex.layers, ex.overprinted);
|
||||
w.absorb(ex);
|
||||
let (bytes, more) = w.finish()?;
|
||||
@@ -147,6 +155,42 @@ fn user_unit(w: f64, h: f64) -> f64 {
|
||||
if side.is_finite() && side > MAX_PAGE_SIDE { (side / MAX_PAGE_SIDE * 1000.0).ceil() / 1000.0 } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The RGB colours and non-CMYK images a CMYK document carries, which the export separates into
|
||||
/// CMYK. Their darkest values come out as rich blacks, so the export says how many there are.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RgbInCmyk {
|
||||
/// Distinct RGB colours, by the bits of their channels.
|
||||
colours: HashSet<[u32; 3]>,
|
||||
/// Keys of the raster images that aren't CMYK.
|
||||
images: HashSet<String>,
|
||||
}
|
||||
|
||||
impl RgbInCmyk {
|
||||
fn merge(&mut self, other: RgbInCmyk) {
|
||||
self.colours.extend(other.colours);
|
||||
self.images.extend(other.images);
|
||||
}
|
||||
|
||||
/// The warning for the export report; `None` when the document has nothing of the sort.
|
||||
fn warning(&self) -> Option<String> {
|
||||
let (c, i) = (self.colours.len(), self.images.len());
|
||||
if c == 0 && i == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut parts = vec![];
|
||||
if c > 0 {
|
||||
parts.push(format!("{c} RGB colour{}", if c == 1 { "" } else { "s" }));
|
||||
}
|
||||
if i > 0 {
|
||||
parts.push(format!("{i} RGB or grey image{}", if i == 1 { "" } else { "s" }));
|
||||
}
|
||||
Some(format!(
|
||||
"this CMYK document has {}: they are converted to CMYK through the colour settings, so dark ones can come out as rich blacks on several plates; paint them with CMYK colours (Edit › Edit Colors › Convert to CMYK) for pure black ink",
|
||||
parts.join(" and ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF being written, shared by export and print: the file's configuration and metadata, then
|
||||
/// its pages ([`Self::page`]), each drawn by an [`Exporter`].
|
||||
pub(crate) struct Writer {
|
||||
@@ -159,6 +203,8 @@ pub(crate) struct Writer {
|
||||
/// Spot colours written with a Lab alternate, gathered from the exporters ([`Self::absorb`]).
|
||||
lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>,
|
||||
warnings: Vec<String>,
|
||||
/// RGB content of a CMYK document, gathered from the exporters ([`Self::absorb`]).
|
||||
rgb_in_cmyk: RgbInCmyk,
|
||||
/// A PDF 1.3 file ([`crate::PdfSettings::pdf13`]): flat, without page groups.
|
||||
pdf13: bool,
|
||||
}
|
||||
@@ -234,7 +280,15 @@ impl Writer {
|
||||
created: created.map(date_time),
|
||||
};
|
||||
let pdf = write::Writer::new(settings, metadata);
|
||||
Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings, pdf13: set.pdf13() })
|
||||
Ok(Self {
|
||||
pdf,
|
||||
cmyk: out.blends_cmyk(cmyk),
|
||||
out: Arc::new(out),
|
||||
lab_spots: vec![],
|
||||
warnings,
|
||||
rgb_in_cmyk: RgbInCmyk::default(),
|
||||
pdf13: set.pdf13(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks.
|
||||
@@ -323,6 +377,7 @@ impl Writer {
|
||||
self.warnings.push(w);
|
||||
}
|
||||
}
|
||||
self.rgb_in_cmyk.merge(ex.rgb_in_cmyk);
|
||||
for spot in ex.lab_spots {
|
||||
if !self.lab_spots.iter().any(|(n, _)| *n == spot.0) {
|
||||
self.lab_spots.push(spot);
|
||||
@@ -342,6 +397,7 @@ impl Writer {
|
||||
let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes };
|
||||
let (bytes, more) = self.out.write_catalog(bytes)?;
|
||||
self.warnings.extend(more);
|
||||
self.warnings.extend(self.rgb_in_cmyk.warning());
|
||||
Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings))
|
||||
}
|
||||
}
|
||||
@@ -480,6 +536,8 @@ pub(crate) struct Exporter<'a> {
|
||||
pub out: Arc<ColorOut>,
|
||||
/// Colours are converted to the destination (printer's marks aren't).
|
||||
convert: bool,
|
||||
/// RGB colours and images of a CMYK document drawn so far.
|
||||
rgb_in_cmyk: RgbInCmyk,
|
||||
/// Text as glyph outlines; else the characters' fills are real text in embedded fonts.
|
||||
outline_text: bool,
|
||||
/// The fonts real text is written in, by face id, with their units per em; `None` for faces
|
||||
@@ -523,6 +581,7 @@ impl<'a> Exporter<'a> {
|
||||
transparent: None,
|
||||
out: Arc::default(),
|
||||
convert: true,
|
||||
rgb_in_cmyk: RgbInCmyk::default(),
|
||||
outline_text: set.advanced.outline_text,
|
||||
fonts: HashMap::new(),
|
||||
layers: false,
|
||||
@@ -691,6 +750,9 @@ impl Exporter<'_> {
|
||||
/// active colour settings, so the file carries press values.
|
||||
fn col(&mut self, c: &Color) -> krilla::color::Color {
|
||||
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
|
||||
if let (true, true, Color::Rgb { r, g, b }) = (cmyk_doc, self.convert, c) {
|
||||
self.rgb_in_cmyk.colours.insert([r.to_bits(), g.to_bits(), b.to_bits()]);
|
||||
}
|
||||
let c = self.out.color(c, self.intent, cmyk_doc, self.convert);
|
||||
if let (Color::Rgb { .. }, Some(note)) = (c, &self.out.srgb_note) {
|
||||
let note = note.clone();
|
||||
@@ -1641,7 +1703,11 @@ impl Exporter<'_> {
|
||||
let doc = self.doc;
|
||||
let out = self.out.clone();
|
||||
let img = doc.images.get(key).and_then(|blob| {
|
||||
let r = if blob.is_cmyk() {
|
||||
let cmyk_image = blob.is_cmyk();
|
||||
if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk {
|
||||
self.rgb_in_cmyk.images.insert(key.to_string());
|
||||
}
|
||||
let r = if cmyk_image {
|
||||
crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels())
|
||||
} else {
|
||||
crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels())
|
||||
|
||||
@@ -205,6 +205,9 @@ pub enum PdfError {
|
||||
Unsupported(String),
|
||||
#[error("PDF writer error: {0}")]
|
||||
Write(String),
|
||||
/// The caller asked for the export to stop ([`vectorcraft_doc::cancel`]).
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
#[error("cannot read PDF: {0}")]
|
||||
Parse(String),
|
||||
#[error("the PDF has no pages")]
|
||||
|
||||
@@ -103,3 +103,30 @@ fn a_cmyk_page_without_blending_through_is_an_isolated_group() {
|
||||
assert!(!t.contains("/Itrue") && t.contains("/S/Alpha"), "grouped {grouped}: {t}");
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_warning(d: &Document) -> Option<String> {
|
||||
export_with_report(d, &PdfOptions::uncompressed()).unwrap().warnings.into_iter().find(|w| w.contains("RGB"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_colours_in_a_cmyk_document_are_reported_and_separated() {
|
||||
let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk);
|
||||
add(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::rgb(0.11, 0.1, 0.1)));
|
||||
add(&mut d, rect(Rect::new(50.0, 0.0, 100.0, 50.0), Color::rgb(0.11, 0.1, 0.1)));
|
||||
add(&mut d, rect(Rect::new(0.0, 50.0, 50.0, 100.0), Color::rgb(0.9, 0.2, 0.2)));
|
||||
let w = rgb_warning(&d).expect("a warning");
|
||||
assert!(w.contains("2 RGB colours"), "{w}");
|
||||
// The colours are written as CMYK, not RGB.
|
||||
let t = text(&d);
|
||||
assert!(t.contains("k0") && !t.contains("rg\n"), "{t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_colours_and_rgb_documents_have_no_rgb_warning() {
|
||||
let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk);
|
||||
add(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)));
|
||||
assert_eq!(rgb_warning(&d), None);
|
||||
let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Rgb);
|
||||
add(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::rgb(0.1, 0.1, 0.1)));
|
||||
assert_eq!(rgb_warning(&d), None);
|
||||
}
|
||||
+39
-5
@@ -68,11 +68,13 @@ pub(crate) fn has_fx(n: &Node) -> bool {
|
||||
|
||||
/// Does `n` carry visible object-level effects that only apply through its art (type, images,
|
||||
/// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with
|
||||
/// a geometry effect on a fill or stroke of its own ([`effects::has_item_geometry`]), or is it a
|
||||
/// a geometry effect on a fill or stroke of its own ([`effects::has_item_geometry`]), is it a path
|
||||
/// whose Transform effect copies it with a gradient ([`effects::has_gradient_copies`]), or is it a
|
||||
/// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
|
||||
pub(crate) fn has_object_fx(n: &Node) -> bool {
|
||||
(effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some()))
|
||||
|| effects::has_item_geometry(n)
|
||||
|| effects::has_gradient_copies(n)
|
||||
|| effects::has_container_appearance(n)
|
||||
|| effects::has_crop_marks(n)
|
||||
|| effects::has_adjustment(n)
|
||||
@@ -382,6 +384,10 @@ pub(crate) struct PixelEntry {
|
||||
/// rasters made before their objects moved: only a frame rendered while interacting draws
|
||||
/// it.
|
||||
moved: bool,
|
||||
/// How many images in the content failed to decode while the raster was made
|
||||
/// ([`crate::paint::decode_failures`]): each frame that draws the raster counts them
|
||||
/// again ([`crate::paint::add_decode_failures`]).
|
||||
failed: u64,
|
||||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
@@ -877,7 +883,7 @@ impl Renderer {
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
if !self.shadows.get(&old).is_some_and(|e| silhouette(&pm, colored) == e.made.pixels) {
|
||||
if self.shadows.get(&old).is_none_or(|e| silhouette(&pm, colored) != e.made.pixels) {
|
||||
return;
|
||||
}
|
||||
if let Some(mut e) = self.shadows.remove(&old) {
|
||||
@@ -1241,6 +1247,7 @@ impl Renderer {
|
||||
self.stats.moved += usize::from(e.moved);
|
||||
let p = f.view * e.anchor;
|
||||
let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k);
|
||||
crate::paint::add_decode_failures(e.failed);
|
||||
draw_scaled(ctx, image, at, k);
|
||||
return;
|
||||
}
|
||||
@@ -1250,8 +1257,9 @@ impl Renderer {
|
||||
let px = f.px / k;
|
||||
let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f };
|
||||
let mut off = f.offscreen_context(wu, hu);
|
||||
let before = self.stats.moved;
|
||||
let (before, decoded) = (self.stats.moved, crate::paint::decode_failures());
|
||||
self.inside_layer(|r| paint(r, &mut off, &frame));
|
||||
let failed = crate::paint::decode_failures() - decoded;
|
||||
// Content drawn over moved rasters makes a moved raster.
|
||||
let moved = self.stats.moved > before;
|
||||
off.flush();
|
||||
@@ -1282,13 +1290,16 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
let image = std::sync::Arc::new(pm);
|
||||
if let Some(((key, made), inputs)) = key.zip(made).zip(inputs) {
|
||||
// A cancelled export drew part of the content: that raster isn't kept for later frames.
|
||||
if !vectorcraft_doc::cancel::is_cancelled()
|
||||
&& let Some(((key, made), inputs)) = key.zip(made).zip(inputs)
|
||||
{
|
||||
let anchor = content.reach.origin();
|
||||
let p = f.view * anchor;
|
||||
let rel = (x0 - p.x, y0 - p.y);
|
||||
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
|
||||
let node = Drawn::of(content.node, owner.as_ref());
|
||||
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, stamp });
|
||||
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, failed, stamp });
|
||||
self.trim_fx_rasters(FX_RASTER_BUDGET);
|
||||
}
|
||||
draw_scaled(ctx, image, (x0, y0), k);
|
||||
@@ -2005,6 +2016,29 @@ mod tests {
|
||||
assert_eq!(b.pixels, c.pixels);
|
||||
}
|
||||
|
||||
/// A blurred image that can't be decoded: every frame that draws it counts the failure
|
||||
/// ([`crate::paint::decode_failures`]), from a new raster or from the raster kept.
|
||||
#[test]
|
||||
fn cached_effect_rasters_count_the_images_they_could_not_decode() {
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
// A PNG signature with nothing after it.
|
||||
d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
|
||||
let im =
|
||||
ImageObject { key: "cut".into(), width: 40, height: 40, xf: Affine::translate((30.0, 30.0)), link: None, placement: Default::default() };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.appearance.effects = vec![Effect { id: "blur.gaussian".into(), params: json!({"radius": 4.0}), visible: true }];
|
||||
d.insert(Some(d.layers[0].id), 0, n).unwrap();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
for frame in 0..3 {
|
||||
let before = crate::paint::decode_failures();
|
||||
r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert!(crate::paint::decode_failures() > before, "frame {frame}");
|
||||
}
|
||||
assert_eq!(r.pixel_fx.len(), 1, "the later frames draw from the raster kept");
|
||||
}
|
||||
|
||||
/// While interacting, an object that only moved draws its drop shadow and filtered content
|
||||
/// from the rasters of an earlier frame, at the nearest whole pixel, without running the
|
||||
/// effects again (#1257). The frame after the interaction runs them again: it is what a fresh
|
||||
|
||||
@@ -274,6 +274,9 @@ pub(crate) fn draw_pixmap(ctx: &mut RenderContext, pm: Arc<Pixmap>, at: (f64, f6
|
||||
#[cfg(test)]
|
||||
PIXELS_DRAWN.with(|n| n.set(n.get() + u64::from(pm.width()) * u64::from(pm.height())));
|
||||
ctx.set_transform(Affine::translate(at));
|
||||
// The pixmap maps 1:1 onto the rect: a paint transform left by an earlier fill (a pattern,
|
||||
// radial or freeform gradient) would resample it (#2426).
|
||||
ctx.reset_paint_transform();
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
|
||||
@@ -1131,6 +1131,10 @@ impl Renderer {
|
||||
if !force && (!n.visible || f.opts.hidden.contains(&n.id)) {
|
||||
return;
|
||||
}
|
||||
// A cancelled export stops drawing; whoever asked for the picture drops it (see `cancel`).
|
||||
if vectorcraft_doc::cancel::is_cancelled() {
|
||||
return;
|
||||
}
|
||||
if force {
|
||||
// Bounds were already checked by draw_arc (or the caller wants it drawn regardless).
|
||||
} else if let Some(b) = fx::cull_bounds(f.symbols, n) {
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn decode_failures() -> u64 {
|
||||
fn decode(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||
let img = decode_within_allowance(bytes).ok();
|
||||
if img.is_none() {
|
||||
DECODE_FAILURES.with(|n| n.set(n.get() + 1));
|
||||
add_decode_failures(1);
|
||||
}
|
||||
img
|
||||
}
|
||||
@@ -125,6 +125,13 @@ pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option<vello_cpu::Pixm
|
||||
pixmap_of(img.to_rgba8())
|
||||
}
|
||||
|
||||
/// Count `n` images that couldn't be decoded on this thread ([`decode_failures`]). [`decode`]
|
||||
/// counts each failure, and a raster kept in a cache counts the failures it was drawn with again
|
||||
/// each time it is drawn.
|
||||
pub(crate) fn add_decode_failures(n: u64) {
|
||||
DECODE_FAILURES.with(|c| c.set(c.get() + n));
|
||||
}
|
||||
|
||||
/// An RGBA image as a premultiplied pixmap.
|
||||
fn pixmap_of(img: image::RgbaImage) -> Option<vello_cpu::Pixmap> {
|
||||
let (w, h) = img.dimensions();
|
||||
|
||||
@@ -89,6 +89,10 @@ struct TileEntry {
|
||||
sx: f64,
|
||||
sy: f64,
|
||||
pixmap: Arc<Pixmap>,
|
||||
/// How many images in the art failed to decode while the tile was drawn
|
||||
/// ([`crate::paint::decode_failures`]): each frame that draws from the tile counts them
|
||||
/// again ([`crate::paint::add_decode_failures`]).
|
||||
failed: u64,
|
||||
}
|
||||
|
||||
struct PatternCache {
|
||||
@@ -244,25 +248,35 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
&& std::ptr::eq(e.cms.as_ptr(), Arc::as_ptr(&cms))
|
||||
&& e.deps.as_ref().is_none_or(|d| d.current(doc))
|
||||
})
|
||||
.map(|e| (e.pixmap.clone(), e.sx, e.sy))
|
||||
.map(|e| (e.pixmap.clone(), e.sx, e.sy, e.failed))
|
||||
});
|
||||
let (pm, sx, sy) = match hit {
|
||||
Some(h) => h,
|
||||
Some((pm, sx, sy, failed)) => {
|
||||
crate::paint::add_decode_failures(failed);
|
||||
(pm, sx, sy)
|
||||
}
|
||||
None => {
|
||||
let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16;
|
||||
let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16;
|
||||
let before = crate::paint::decode_failures();
|
||||
let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else {
|
||||
ctx.set_paint(missing());
|
||||
return true;
|
||||
};
|
||||
let failed = crate::paint::decode_failures() - before;
|
||||
let pm = Arc::new(pm);
|
||||
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
|
||||
let deps = crate::fx::Reads::of_all(def.art.iter().map(|a| &**a)).any().then(|| Deps::of(doc));
|
||||
// A cancelled export drew part of the tile: it isn't kept for later frames.
|
||||
CACHE.with(|c| {
|
||||
if vectorcraft_doc::cancel::is_cancelled() {
|
||||
return;
|
||||
}
|
||||
let mut c = c.borrow_mut();
|
||||
let tiles = &mut c.tiles[ink as usize];
|
||||
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
|
||||
tiles.insert(key, TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone() }, bytes, t);
|
||||
let entry = TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone(), failed };
|
||||
tiles.insert(key, entry, bytes, t);
|
||||
});
|
||||
(pm, sx, sy)
|
||||
}
|
||||
@@ -573,6 +587,27 @@ mod tests {
|
||||
assert!(kept().1 <= budget, "{:?}", kept());
|
||||
}
|
||||
|
||||
/// A pattern whose art is an image that can't be decoded: every frame that draws the pattern
|
||||
/// counts the failure ([`crate::paint::decode_failures`]), from a new tile or from the
|
||||
/// tile kept.
|
||||
#[test]
|
||||
fn kept_tiles_count_the_images_they_could_not_decode() {
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject};
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
// A PNG signature with nothing after it.
|
||||
d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
|
||||
let im = ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||||
d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))];
|
||||
let mut r = Renderer::new();
|
||||
let start = tiles_rasterized();
|
||||
for n in 0..3 {
|
||||
let before = crate::paint::decode_failures();
|
||||
frame(&mut r, &d, 1.0);
|
||||
assert!(crate::paint::decode_failures() > before, "frame {n}");
|
||||
}
|
||||
assert_eq!(tiles_rasterized() - start, 1, "the later frames draw from the tile kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pattern_is_grey() {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
|
||||
@@ -697,3 +697,24 @@ fn a_strokes_own_glow_paints_around_that_stroke_only() {
|
||||
let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
|
||||
assert!(worst <= 4, "max channel difference {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_with_its_own_transform_paints_each_copy_with_its_own_gradient() {
|
||||
use vectorcraft_color::GradientGeom;
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(20.0, 50.0), aspect: 1.0, focal: None };
|
||||
// Fitted to the box on each render, and placed on the object.
|
||||
for paint in [ramp.clone(), GradientPaint { geom: Some(geom), ..ramp }] {
|
||||
let square = shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 40.0, 20.0, 60.0));
|
||||
let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(paint)), Paint::None, 0.0));
|
||||
n.appearance.effects = vec![fx("distort.transform", json!({"moveH": 40, "copies": 1}))];
|
||||
let img = render(&doc_with(Document::new(100.0, 100.0), n));
|
||||
let at = |x: usize| img.pixels.chunks(4).nth(50 * 100 + x).map(|p| (p[0], p[2])).unwrap_or_default();
|
||||
// The left edge of each copy is red, its right edge blue.
|
||||
for (left, right) in [(1, 18), (41, 58)] {
|
||||
assert!(at(left).0 > 200 && at(left).1 < 60, "{:?} at {left}", at(left));
|
||||
assert!(at(right).1 > 200 && at(right).0 < 60, "{:?} at {right}", at(right));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -622,9 +622,9 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option<Corners> {
|
||||
Some(LiveShape::Rectangle { w, h, radii, kinds, xf })
|
||||
if upright(xf) && radii.iter().zip(kinds).all(|(r, k)| *r <= 0.0 || *k == CornerKind::Round) =>
|
||||
{
|
||||
// The radii as drawn, none past half the shorter side (CSS draws one further when its
|
||||
// neighbours leave room).
|
||||
let radii = radii.map(|r| shapes::fitted_corner_radius(*w, *h, r));
|
||||
// The radii as drawn: no two neighbours' cuts add up to more than their side, so CSS
|
||||
// draws them as they are.
|
||||
let radii = shapes::fitted_corner_radii(*w, *h, *radii);
|
||||
if radii.iter().all(|r| *r <= 0.0) {
|
||||
Some(Corners::Square)
|
||||
} else if radii.iter().all(|r| (r - radii[0]).abs() < 1e-9) {
|
||||
|
||||
+105
-13
@@ -1941,7 +1941,7 @@ impl Writer<'_> {
|
||||
|
||||
/// One line of laid-out text starting at `start`. Default: a positioned `<tspan>` per segment
|
||||
/// (only the first of an anchored line). Fewer tspans: one positioned `<tspan>` per line with
|
||||
/// the style changes nested in it, positioned again only after tabs and justified word spaces.
|
||||
/// the style changes nested in it, positioned again only after tabs and inline graphics.
|
||||
/// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted.
|
||||
/// `positioned`: false when the caller already positioned the line (an outer `<tspan>`).
|
||||
fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) {
|
||||
@@ -1966,7 +1966,8 @@ impl Writer<'_> {
|
||||
attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift)));
|
||||
shift = st.baseline_shift;
|
||||
}
|
||||
// After a tab or a justified word space the next segment is placed.
|
||||
// After a tab, an inline graphic or a justified word space, and before a character that a
|
||||
// reader would draw elsewhere, the next segment is placed.
|
||||
place = seg.brk || (!fewer && !base.anchored && positioned);
|
||||
if (st.h_scale - st.v_scale).abs() > 1e-9 {
|
||||
attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance)));
|
||||
@@ -2164,26 +2165,49 @@ struct Line {
|
||||
struct Segment {
|
||||
/// Index into `TextObject::runs`.
|
||||
run: usize,
|
||||
/// Where the segment starts (text space; `y` is the baseline).
|
||||
/// Where a reader starts drawing the segment (text space; `y` is the baseline).
|
||||
x: f64,
|
||||
y: f64,
|
||||
text: String,
|
||||
/// The laid-out width (tracking and justification included).
|
||||
/// The width a reader gives the segment (tracking included). On a line that is justified or
|
||||
/// set with Mojikumi (unless `fewer`), that is the width without justification and Mojikumi
|
||||
/// ([`reader_advances`]), for horizontally scaled type also without its last character's
|
||||
/// kerning against the character after it ([`end_segment`]); elsewhere, the laid-out width.
|
||||
advance: f64,
|
||||
/// The next segment needs its own position (after a tab, or a word space of a justified line).
|
||||
/// The next segment needs its own position (after a tab, a word space of a justified line, or
|
||||
/// the next segment starts with a character the layout draws elsewhere than a reader would).
|
||||
brk: bool,
|
||||
}
|
||||
|
||||
/// How far a reader may draw a character from where the layout draws it before the character is
|
||||
/// given its own position (points).
|
||||
const PLACE_TOLERANCE: f64 = 1e-6;
|
||||
|
||||
/// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and,
|
||||
/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters
|
||||
/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all
|
||||
/// caps are upper case.
|
||||
/// caps are upper case. Unless `fewer`, a line that is justified or composed with Mojikumi also
|
||||
/// starts a new segment at each character the layout draws elsewhere than a reader continuing
|
||||
/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between
|
||||
/// the characters of a justified Japanese line, and the spacing Mojikumi changes.
|
||||
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Line> {
|
||||
let plain = t.plain_text();
|
||||
let mut lines = Vec::with_capacity(lay.lines.len());
|
||||
let (mut kerned, mut unkerned) = (None, None);
|
||||
for line in &lay.lines {
|
||||
let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count());
|
||||
let split_words = !fewer && !matches!(t.para_at(para).justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
|
||||
let style = t.para_at(para);
|
||||
let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
|
||||
// Each cluster's advance and drawing offset as a reader gives them, by its byte offset.
|
||||
let reader = if split_words || (!fewer && !style.mojikumi.is_none()) {
|
||||
Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Where a reader draws the next character of the last segment, and the byte offset and
|
||||
// reader's advance of the last segment's last cluster.
|
||||
let mut pen = 0.0;
|
||||
let mut tail = None;
|
||||
let mut segs: Vec<Segment> = Vec::new();
|
||||
let mut cluster = None;
|
||||
let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") };
|
||||
@@ -2206,26 +2230,51 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
|
||||
cluster = None;
|
||||
continue;
|
||||
}
|
||||
// The cluster as a reader sets it (a hyphen the layout adds is set as laid out).
|
||||
let set = reader.and_then(|r| r.get(&g.byte)).filter(|_| g.len > 0);
|
||||
if g.len > 0 && cluster == Some(g.byte) {
|
||||
// Another glyph of the same cluster.
|
||||
if let Some(last) = segs.last_mut() {
|
||||
// Another glyph of the same cluster (a reader's advance covers the whole cluster).
|
||||
if let Some(last) = segs.last_mut().filter(|_| set.is_none()) {
|
||||
last.advance += g.advance;
|
||||
pen += g.advance;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
cluster = Some(g.byte);
|
||||
let (advance, x) = match set {
|
||||
Some(&(advance, offset)) => {
|
||||
// A glyph the layout draws before its pen (Mojikumi) starts that much earlier.
|
||||
let x = g.xf.translation().x - offset;
|
||||
(advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x })
|
||||
}
|
||||
None => (g.advance, g.origin.x),
|
||||
};
|
||||
let src: String = source(g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect();
|
||||
let piece = if run.style.all_caps { src.to_uppercase() } else { src };
|
||||
let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace));
|
||||
let fresh = std::mem::take(&mut after_inline);
|
||||
// The layout draws the character elsewhere than a reader continuing the line would.
|
||||
let moved = reader.is_some()
|
||||
&& segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE);
|
||||
pen = x + advance;
|
||||
match segs.last_mut() {
|
||||
Some(last) if last.run == g.run && !last.brk && !fresh => {
|
||||
Some(last) if last.run == g.run && !last.brk && !fresh && !moved => {
|
||||
last.text.push_str(&piece);
|
||||
last.advance += g.advance;
|
||||
last.advance += advance;
|
||||
last.brk = brk;
|
||||
}
|
||||
_ => segs.push(Segment { run: g.run, x: g.origin.x, y: g.origin.y, text: piece, advance: g.advance, brk }),
|
||||
last => {
|
||||
if let Some(last) = last {
|
||||
last.brk |= moved;
|
||||
end_segment(last, tail, t, lay, &mut unkerned);
|
||||
}
|
||||
segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk });
|
||||
}
|
||||
}
|
||||
tail = set.map(|_| (g.byte, advance));
|
||||
}
|
||||
if let Some(last) = segs.last_mut() {
|
||||
end_segment(last, tail, t, lay, &mut unkerned);
|
||||
}
|
||||
segs.retain(|s| !s.text.is_empty());
|
||||
if !segs.is_empty() {
|
||||
@@ -2235,7 +2284,50 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
|
||||
lines
|
||||
}
|
||||
|
||||
/// Where a line of segments starts: its first segment's origin or, `anchor`ed (0.5: centre, 1:
|
||||
/// Ends segment `seg` of horizontally scaled type, which a reader stretches to its width
|
||||
/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance
|
||||
/// a reader gives it at the end of a `<tspan>`, without the font's kerning against the character
|
||||
/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them.
|
||||
fn end_segment(
|
||||
seg: &mut Segment,
|
||||
tail: Option<(usize, f64)>,
|
||||
t: &TextObject,
|
||||
lay: &vectorcraft_text::TextLayout,
|
||||
unkerned: &mut Option<HashMap<usize, (f64, f64)>>,
|
||||
) {
|
||||
let Some((byte, advance)) = tail else { return };
|
||||
if t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) {
|
||||
return;
|
||||
}
|
||||
if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) {
|
||||
seg.advance += alone - advance;
|
||||
}
|
||||
}
|
||||
|
||||
/// The advance a reader of live SVG text gives each character cluster of `t` and how far from
|
||||
/// its pen it draws the cluster's first glyph, keyed by the cluster's byte offset: `t` laid out
|
||||
/// as point type (which isn't justified) without Mojikumi, scaled as `t`'s layout `lay` is by
|
||||
/// Shrink Text to Fit. Without `kerning`, the font's pair kerning is left out (as manual
|
||||
/// kerning leaves it out).
|
||||
fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap<usize, (f64, f64)> {
|
||||
let mut set = TextObject { kind: TextKind::Point, ..t.clone() };
|
||||
for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) {
|
||||
p.mojikumi = vectorcraft_doc::Mojikumi::None;
|
||||
}
|
||||
if !kerning {
|
||||
for r in &mut set.runs {
|
||||
r.style.kerning.get_or_insert(0.0);
|
||||
}
|
||||
}
|
||||
let mut out: HashMap<usize, (f64, f64)> = HashMap::new();
|
||||
for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) {
|
||||
let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale));
|
||||
*advance += g.advance * lay.fit_scale;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a line of segments starts: where its first segment starts or, `anchor`ed (0.5: center, 1:
|
||||
/// right end), that point of its width. `None` for an empty line.
|
||||
fn line_start(line: &[Segment], anchor: Option<f64>) -> Option<(f64, f64)> {
|
||||
let (first, last) = (line.first()?, line.last()?);
|
||||
|
||||
@@ -84,13 +84,13 @@ fn live_corners_give_per_corner_radii_and_only_round_ones() {
|
||||
kinds[1] = CornerKind::InvertedRound;
|
||||
}
|
||||
assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in");
|
||||
// A radius past half the shorter side is drawn at half of it (#442), and exported so: CSS
|
||||
// would draw it larger, its neighbours leaving room.
|
||||
// A radius is drawn no larger than its neighbours leave room for, past half the shorter
|
||||
// side when they are small (#2332), and exported as drawn: 50 less the 4 below it.
|
||||
if let Some(LiveShape::Rectangle { radii, kinds, .. }) = d.node_mut(id).and_then(|n| n.live_shape_mut()) {
|
||||
*radii = [60.0, 8.0, 0.0, 4.0];
|
||||
*kinds = Default::default();
|
||||
}
|
||||
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("25px 8px 0 4px"));
|
||||
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("46px 8px 0 4px"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -158,6 +158,184 @@ fn justified_lines_place_each_word() {
|
||||
assert!(words >= 3, "{svg}");
|
||||
}
|
||||
|
||||
/// The only type in `d`.
|
||||
fn the_text(d: &Document) -> TextObject {
|
||||
d.layers[0].children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).unwrap()
|
||||
}
|
||||
|
||||
/// A drawn character: its text, where its first glyph is drawn, and the glyph's horizontal scale
|
||||
/// (font units to user space).
|
||||
type Drawn = (String, Point, f64);
|
||||
|
||||
/// Each character a usvg draws from `svg` (with the bundled font and the font files of `faces`)
|
||||
/// and how it draws the character's first glyph, in SVG user space and text order; spaces and
|
||||
/// tabs are left out.
|
||||
fn viewer_glyph_positions(svg: &str, faces: &[Arc<vectorcraft_text::FontFace>]) -> Vec<Drawn> {
|
||||
let mut db = usvg::fontdb::Database::new();
|
||||
db.load_font_data(std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap());
|
||||
for f in faces {
|
||||
db.load_font_data(f.file_data().to_vec());
|
||||
}
|
||||
let opt = usvg::Options { fontdb: Arc::new(db), ..Default::default() };
|
||||
let tree = usvg::Tree::from_str(svg, &opt).unwrap();
|
||||
fn walk(g: &usvg::Group, out: &mut Vec<Drawn>) {
|
||||
for n in g.children() {
|
||||
match n {
|
||||
usvg::Node::Group(g) => walk(g, out),
|
||||
usvg::Node::Text(t) => {
|
||||
for g in t.layouted().iter().flat_map(|span| &span.positioned_glyphs) {
|
||||
if g.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let m = t.abs_transform().pre_concat(g.transform());
|
||||
out.push((g.text.clone(), Point::new(m.tx as f64, m.ty as f64), m.sx as f64));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
walk(tree.root(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Each character the canvas draws of the only type in `d` and how it draws the character's
|
||||
/// first glyph, in document space and text order (spaces and tabs left out), with the faces
|
||||
/// it draws them in.
|
||||
fn canvas_glyph_positions(d: &Document) -> (Vec<Drawn>, Vec<Arc<vectorcraft_text::FontFace>>) {
|
||||
let t = the_text(d);
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let lay = vectorcraft_text::layout(db, &t);
|
||||
let plain = t.plain_text();
|
||||
let mut out: Vec<Drawn> = vec![];
|
||||
let mut faces: Vec<Arc<vectorcraft_text::FontFace>> = vec![];
|
||||
let mut last = None;
|
||||
for g in &lay.glyphs {
|
||||
let text = if g.len == 0 { "-" } else { &plain[g.byte..g.byte + g.len] };
|
||||
if text.trim().is_empty() || (g.len > 0 && last == Some(g.byte)) {
|
||||
continue;
|
||||
}
|
||||
last = Some(g.byte);
|
||||
if let Some(f) = db.face_by_id(g.font_id).filter(|f| !faces.iter().any(|o| o.id() == f.id())) {
|
||||
faces.push(f);
|
||||
}
|
||||
let m = t.xf * g.xf;
|
||||
out.push((text.to_string(), m * Point::ZERO, m.as_coeffs()[0]));
|
||||
}
|
||||
(out, faces)
|
||||
}
|
||||
|
||||
/// Asserts that a viewer draws every character of `d`'s live SVG text where the canvas draws it,
|
||||
/// as wide as the canvas draws it.
|
||||
fn assert_drawn_in_place(d: &Document) {
|
||||
let svg = export(d, &ExportOptions::default());
|
||||
let (canvas, faces) = canvas_glyph_positions(d);
|
||||
let viewer = viewer_glyph_positions(&svg, &faces);
|
||||
let chars = |v: &[Drawn]| v.iter().map(|(s, _, _)| s.as_str()).collect::<String>();
|
||||
assert_eq!(chars(&viewer), chars(&canvas), "{svg}");
|
||||
for ((s, v, vs), (_, c, cs)) in viewer.iter().zip(&canvas) {
|
||||
assert!((v.x - c.x).abs() < 0.05 && (v.y - c.y).abs() < 0.05, "{s}: the viewer draws it at {v:?}, the canvas at {c:?}: {svg}");
|
||||
assert!((vs / cs - 1.0).abs() < 0.002, "{s}: the viewer scales it by {vs}, the canvas by {cs}: {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A Justify All line without word spaces spreads its letters; live text keeps the gaps, with
|
||||
/// tracking and manual kerning too.
|
||||
#[test]
|
||||
fn justify_all_lines_keep_their_letter_gaps() {
|
||||
let tracked = CharStyle { tracking: 80.0, ..style(12.0) };
|
||||
let kerned = CharStyle { kerning: Some(-40.0), ..style(12.0) };
|
||||
for runs in [vec![run("abcdefgh", style(12.0))], vec![run("abc", style(12.0)), run("def", tracked), run("ghi", kerned)]] {
|
||||
let d = area_doc(runs, 200.0, Justify::JustifyAll);
|
||||
assert_drawn_in_place(&d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Justified and Mojikumi lines whose characters a reader draws where the layout does are written
|
||||
/// as before: a segment per style run, and per word on a justified line.
|
||||
#[test]
|
||||
fn lines_without_moved_characters_are_written_as_before() {
|
||||
let text = |d: &Document| {
|
||||
let svg = export(d, &ExportOptions::default());
|
||||
svg[svg.find("<text").unwrap()..svg.find("</text>").unwrap()].to_string()
|
||||
};
|
||||
let justified = area_doc(vec![run("aa bb cc dd ee ff", style(12.0))], 60.0, Justify::JustifyLeft);
|
||||
let mut composed = area_doc(vec![run("Latin words only", style(12.0))], 200.0, Justify::Center);
|
||||
let id = composed.layers[0].children().unwrap()[0].id;
|
||||
if let Some(NodeKind::Text(t)) = composed.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf;
|
||||
}
|
||||
let open = |x: &str, anchor: &str| {
|
||||
format!(
|
||||
r##"<text transform="matrix(1 0 0 1 20 20)" x="{x}" y="12" xml:space="preserve" font-family="'Source Sans 3'" font-size="12" fill="#000000"{anchor}>"##
|
||||
)
|
||||
};
|
||||
let words =
|
||||
[("aa ", "0", "12"), ("bb ", "15.636", "12"), ("cc ", "32.448", "12"), ("dd", "46.68", "12"), ("ee ", "0", "26.4"), ("ff", "14.304", "26.4")];
|
||||
let words: String = words.iter().map(|(w, x, y)| format!(r#"<tspan x="{x}" y="{y}">{w}</tspan>"#)).collect();
|
||||
assert_eq!(text(&justified), format!("{}{words}", open("0", "")));
|
||||
let centered = format!(r#"{}<tspan x="100" y="12">Latin words only</tspan>"#, open("100", r#" text-anchor="middle""#));
|
||||
assert_eq!(text(&composed), centered);
|
||||
}
|
||||
|
||||
/// Type scaled horizontally on a justified line is stretched to its own width, not to the
|
||||
/// justification space after it, nor to the font's kerning against the letter after it when
|
||||
/// the letters are spread (AV is a kerned pair).
|
||||
#[test]
|
||||
fn justified_lines_keep_scaled_type_at_its_width() {
|
||||
let wide = CharStyle { h_scale: 150.0, ..style(12.0) };
|
||||
for justify in [Justify::JustifyLeft, Justify::JustifyAll] {
|
||||
assert_drawn_in_place(&area_doc(vec![run("aa bb cc dd ee ff gg hh ii jj kk ll", wide.clone())], 120.0, justify));
|
||||
assert_drawn_in_place(&area_doc(vec![run("abcdefgh", wide.clone())], 200.0, justify));
|
||||
}
|
||||
assert_drawn_in_place(&area_doc(vec![run("AVAVAVAV", wide)], 200.0, Justify::JustifyAll));
|
||||
}
|
||||
|
||||
/// On lines that aren't spread, the spacing a reader applies itself (tracking, manual kerning,
|
||||
/// horizontal scale, baseline shift) puts each character where the canvas draws it.
|
||||
#[test]
|
||||
fn ragged_lines_keep_their_character_spacing() {
|
||||
let tracked = CharStyle { tracking: 80.0, ..style(12.0) };
|
||||
let kerned = CharStyle { kerning: Some(-40.0), ..style(12.0) };
|
||||
let wide = CharStyle { h_scale: 150.0, ..style(12.0) };
|
||||
let shifted = CharStyle { baseline_shift: 3.0, ..style(12.0) };
|
||||
for justify in [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLeft] {
|
||||
let mut runs =
|
||||
vec![run("plain ", style(12.0)), run("tracked ", tracked.clone()), run("kerned ", kerned.clone()), run("up ", shifted.clone())];
|
||||
// usvg anchors a centered or right-aligned line by its width before `textLength` stretches it.
|
||||
if matches!(justify, Justify::Left | Justify::JustifyLeft) {
|
||||
runs.push(run("wide ", wide.clone()));
|
||||
}
|
||||
runs.push(run("end", style(12.0)));
|
||||
assert_drawn_in_place(&area_doc(runs, 280.0, justify));
|
||||
}
|
||||
}
|
||||
|
||||
/// Justified Japanese lines spread between their characters (#344); live text keeps the gaps.
|
||||
#[test]
|
||||
fn justified_japanese_lines_keep_the_gaps_between_their_characters() {
|
||||
let text = "日本語の文章は文字と文字の間で両端を揃えます。この段落は三行になります。";
|
||||
for justify in [Justify::JustifyLeft, Justify::JustifyAll] {
|
||||
assert_drawn_in_place(&area_doc(vec![run(text, style(12.0))], 100.0, justify));
|
||||
}
|
||||
}
|
||||
|
||||
/// Japanese composition (Mojikumi) closes up consecutive punctuation and adds a quarter em between
|
||||
/// Japanese and Latin letters; live text keeps that spacing, on anchored lines too.
|
||||
#[test]
|
||||
fn japanese_composition_keeps_its_spacing() {
|
||||
let text = "「かっこ」、「かな」。漢字とLatinの間。";
|
||||
for justify in [Justify::Left, Justify::Center, Justify::Right] {
|
||||
let mut d = area_doc(vec![run(text, style(12.0))], 280.0, justify);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf;
|
||||
}
|
||||
assert_drawn_in_place(&d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Glyph outlines of every text a usvg (with the bundled font) lays out from `svg`, in SVG
|
||||
/// user space.
|
||||
fn viewer_outlines(svg: &str) -> Vec<BezPath> {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests.
|
||||
//! - [`ai`]: hand-written Illustrator editing data, and the EPS and `.ai` files that carry it.
|
||||
//! - [`ase`]: hand-written swatch exchange (`.ase`) files for swatch library tests.
|
||||
//! - [`tiff`]: hand-written TIFF files, chunky or planar, for image import tests.
|
||||
//! - [`fonts`]: font files made from the bundled fonts (a renamed family) for font tests.
|
||||
//!
|
||||
//! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`).
|
||||
@@ -30,6 +31,7 @@ pub mod invariants;
|
||||
pub mod pdf;
|
||||
pub mod raster;
|
||||
pub mod strategies;
|
||||
pub mod tiff;
|
||||
|
||||
pub use serde_json::{Value, json};
|
||||
pub use vectorcraft_engine::{Session, doc::NodeId};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Hand-written TIFF files for import tests: little-endian, uncompressed, 8 or 16 bits a sample,
|
||||
//! with each pixel's samples stored together (chunky) or one plane per sample (planar,
|
||||
//! PlanarConfiguration 2) (TIFF 6.0, sections 2 and 8).
|
||||
|
||||
/// One image: its channels in the order the file stores them, color first, extra samples after.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Tiff<'a> {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// PhotometricInterpretation: 0 white is zero, 1 black is zero, 2 RGB, 5 separated (CMYK).
|
||||
pub photometric: u16,
|
||||
/// Bits a sample: 8 or 16.
|
||||
pub bits: u16,
|
||||
/// The ExtraSamples values, one for each channel after the color (0 not alpha, 1 associated
|
||||
/// alpha, 2 unassociated alpha); none writes no ExtraSamples tag.
|
||||
pub extra: &'a [u16],
|
||||
/// One plane per channel (PlanarConfiguration 2) instead of each pixel's samples together.
|
||||
pub planar: bool,
|
||||
/// Pixels per inch, written as XResolution and YResolution with ResolutionUnit 2.
|
||||
pub ppi: Option<u32>,
|
||||
/// The channels: `width` × `height` samples each, row by row.
|
||||
pub channels: &'a [Vec<u16>],
|
||||
}
|
||||
|
||||
/// Pads `out` to an even length: TIFF offsets point at word boundaries.
|
||||
fn align(out: &mut Vec<u8>) {
|
||||
if out.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory entry: its tag, field type, count and value bytes.
|
||||
type Entry = (u16, u16, usize, Vec<u8>);
|
||||
|
||||
/// The TIFF file of `t`: the header, the directory at offset 8, the values longer than four
|
||||
/// bytes, then the image in one strip a plane.
|
||||
pub fn tiff(t: &Tiff) -> Vec<u8> {
|
||||
let pixels = usize::try_from(t.width * t.height).unwrap();
|
||||
assert!(t.channels.iter().all(|c| c.len() == pixels), "every channel has a sample a pixel");
|
||||
assert!(matches!(t.bits, 8 | 16), "8 or 16 bits a sample");
|
||||
let mut strips: Vec<Vec<u8>> = vec![];
|
||||
let samples_of = |i: usize| t.channels.iter().map(move |c| c[i]);
|
||||
let order: Vec<Vec<u16>> = if t.planar { t.channels.to_vec() } else { vec![(0..pixels).flat_map(samples_of).collect()] };
|
||||
for values in order {
|
||||
strips.push(match t.bits {
|
||||
8 => values.iter().map(|&v| u8::try_from(v).unwrap()).collect(),
|
||||
_ => values.iter().flat_map(|v| v.to_le_bytes()).collect(),
|
||||
});
|
||||
}
|
||||
let samples = u16::try_from(t.channels.len()).unwrap();
|
||||
let counts: Vec<u32> = strips.iter().map(|s| u32::try_from(s.len()).unwrap()).collect();
|
||||
let entries = |offsets: &[u32]| -> Vec<Entry> {
|
||||
let shorts = |tag, v: &[u16]| (tag, 3, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect());
|
||||
let longs = |tag, v: &[u32]| (tag, 4, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect());
|
||||
let rational = |tag, n: u32| (tag, 5, 1, [n.to_le_bytes(), 1u32.to_le_bytes()].concat());
|
||||
// In ascending tag order.
|
||||
let mut e = vec![
|
||||
longs(256, &[t.width]),
|
||||
longs(257, &[t.height]),
|
||||
shorts(258, &vec![t.bits; usize::from(samples)]),
|
||||
shorts(259, &[1]),
|
||||
shorts(262, &[t.photometric]),
|
||||
longs(273, offsets),
|
||||
shorts(277, &[samples]),
|
||||
longs(278, &[t.height]),
|
||||
longs(279, &counts),
|
||||
];
|
||||
if let Some(ppi) = t.ppi {
|
||||
e.extend([rational(282, ppi), rational(283, ppi)]);
|
||||
}
|
||||
e.push(shorts(284, &[if t.planar { 2 } else { 1 }]));
|
||||
if t.ppi.is_some() {
|
||||
e.push(shorts(296, &[2]));
|
||||
}
|
||||
if !t.extra.is_empty() {
|
||||
e.push(shorts(338, t.extra));
|
||||
}
|
||||
e
|
||||
};
|
||||
// The strips' offsets follow from the sizes of the rest, which don't depend on them.
|
||||
let padded = |n: usize| n + n % 2;
|
||||
let sized = entries(&vec![0; strips.len()]);
|
||||
let ifd_len = 2 + 12 * sized.len() + 4;
|
||||
let values_len: usize = sized.iter().filter(|e| e.3.len() > 4).map(|e| padded(e.3.len())).sum();
|
||||
let mut at = 8 + ifd_len + values_len;
|
||||
let mut offsets = vec![];
|
||||
for s in &strips {
|
||||
offsets.push(u32::try_from(at).unwrap());
|
||||
at += padded(s.len());
|
||||
}
|
||||
let mut out = b"II*\0\x08\0\0\0".to_vec();
|
||||
let entries = entries(&offsets);
|
||||
out.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
|
||||
let mut values = vec![];
|
||||
for (tag, ty, count, bytes) in entries {
|
||||
out.extend(tag.to_le_bytes());
|
||||
out.extend(ty.to_le_bytes());
|
||||
out.extend(u32::try_from(count).unwrap().to_le_bytes());
|
||||
if bytes.len() <= 4 {
|
||||
out.extend(&bytes);
|
||||
out.extend(vec![0; 4 - bytes.len()]);
|
||||
} else {
|
||||
out.extend(u32::try_from(8 + ifd_len + values.len()).unwrap().to_le_bytes());
|
||||
values.extend(bytes);
|
||||
align(&mut values);
|
||||
}
|
||||
}
|
||||
out.extend([0; 4]);
|
||||
out.extend(values);
|
||||
for s in strips {
|
||||
out.extend(s);
|
||||
align(&mut out);
|
||||
}
|
||||
assert_eq!(out.len(), at, "the strips sit where the directory says");
|
||||
out
|
||||
}
|
||||
@@ -521,11 +521,8 @@ fn pick_native_label(english: &str, pairs: &[(Option<String>, String)]) -> Optio
|
||||
|
||||
/// The family's native-language name from the font (typographic, else legacy), when it has one.
|
||||
fn native_family_name(font: &skrifa::FontRef<'_>, english: &str) -> Option<String> {
|
||||
let pairs = {
|
||||
let t = localized(font, StringId::TYPOGRAPHIC_FAMILY_NAME);
|
||||
if t.is_empty() { localized(font, StringId::FAMILY_NAME) } else { t }
|
||||
};
|
||||
pick_native_label(english, &pairs)
|
||||
pick_native_label(english, &localized(font, StringId::TYPOGRAPHIC_FAMILY_NAME))
|
||||
.or_else(|| pick_native_label(english, &localized(font, StringId::FAMILY_NAME)))
|
||||
}
|
||||
|
||||
/// Every localized string of `id`.
|
||||
|
||||
@@ -1162,6 +1162,16 @@ fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, re
|
||||
((at - rel).max(0.0), leader)
|
||||
}
|
||||
|
||||
/// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the
|
||||
/// tab origin: tabs take what [`tab_advance`] gives them, not the width of their glyph.
|
||||
fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 {
|
||||
let mut x = x_start;
|
||||
for (j, g) in sg.iter().enumerate().take(to).skip(from) {
|
||||
x += if g.ch == '\t' { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv };
|
||||
}
|
||||
x - x_start
|
||||
}
|
||||
|
||||
/// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make
|
||||
/// millions of outlines.
|
||||
const MAX_LEADER_COPIES: f64 = 4096.0;
|
||||
@@ -1223,12 +1233,16 @@ fn word_width(g: &[SGlyph], i: usize) -> f64 {
|
||||
/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it.
|
||||
/// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when
|
||||
/// the line may hyphenate a word; a line whose text before the overflowing word already ends
|
||||
/// within the zone breaks before that word.
|
||||
/// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop
|
||||
/// (`tabs`), with the line's content starting `x_start` from the tab origin.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn break_line(
|
||||
text: &str,
|
||||
g: &[SGlyph],
|
||||
i: usize,
|
||||
width: f64,
|
||||
tabs: &[vectorcraft_doc::TabStop],
|
||||
x_start: f64,
|
||||
hyphenate: Option<(&HyphenationOptions, f64)>,
|
||||
burasagari: Burasagari,
|
||||
kinsoku: Kinsoku,
|
||||
@@ -1245,7 +1259,11 @@ fn break_line(
|
||||
while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte) {
|
||||
cluster_end += 1;
|
||||
}
|
||||
let cluster_width: f64 = g[j..cluster_end].iter().map(|glyph| glyph.adv).sum();
|
||||
let cluster_width: f64 = if gl.ch == '\t' {
|
||||
tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0
|
||||
} else {
|
||||
g[j..cluster_end].iter().map(|glyph| glyph.adv).sum()
|
||||
};
|
||||
if j > i && !gl.is_space() && x + cluster_width > width + EPS {
|
||||
// It ends the line with the spaces after it.
|
||||
if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) {
|
||||
@@ -1390,7 +1408,7 @@ mod wrapping_tests {
|
||||
let text = "بَت";
|
||||
let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]);
|
||||
|
||||
let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, None, Burasagari::None, Kinsoku::Hard);
|
||||
let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard);
|
||||
|
||||
assert!(!hyphenated);
|
||||
assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together");
|
||||
@@ -1403,11 +1421,11 @@ mod wrapping_tests {
|
||||
let text = "بَتَث";
|
||||
let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]);
|
||||
|
||||
let (first_end, _) = break_line(text, &glyphs, 0, 3.0, None, Burasagari::None, Kinsoku::Hard);
|
||||
let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard);
|
||||
assert_eq!(first_end, 1);
|
||||
assert_eq!(source_range(&glyphs, 0, first_end), 0..4);
|
||||
|
||||
let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, None, Burasagari::None, Kinsoku::Hard);
|
||||
let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard);
|
||||
|
||||
assert!(!hyphenated);
|
||||
assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together");
|
||||
@@ -1567,7 +1585,10 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt
|
||||
// A paragraph whose commas or full stops may hang is composed line by line (as the Japanese
|
||||
// single-line composer does).
|
||||
let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs);
|
||||
if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang {
|
||||
// A tab's width depends on where it falls in its line, which the composer's fixed glyph widths
|
||||
// can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs).
|
||||
let has_tab = sg.iter().any(|g| g.ch == '\t');
|
||||
if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang || has_tab {
|
||||
return None;
|
||||
}
|
||||
let m = Metrics::of(&sg[0]);
|
||||
@@ -1695,7 +1716,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((¶.hyphenation, zone));
|
||||
let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) {
|
||||
Some(&(e, h)) if e > i => (e, h),
|
||||
_ if i < n => break_line(cx.text, &sg, i, width, hyphenate, para.burasagari, para.kinsoku),
|
||||
_ if i < n => break_line(cx.text, &sg, i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku),
|
||||
_ => (n, false),
|
||||
};
|
||||
let m = Metrics::max(&sg[i..end]).unwrap_or(pm);
|
||||
@@ -1739,7 +1760,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::<f64>() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim;
|
||||
let w: f64 = set_width(&sg, i, trimmed, ¶.tabs, ind_l) + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim;
|
||||
// Burasagari: a comma or full stop ending an area type line hangs outside it (Standard:
|
||||
// when it doesn't fit; Forced: always). The rest of the line is aligned and justified
|
||||
// without it, and it follows the line's last character.
|
||||
@@ -1816,6 +1837,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
let mut x_end = start_x;
|
||||
// Tab stops are measured from the frame's left edge (point type: the origin).
|
||||
let tab_origin = if regions.is_some() { x0 } else { 0.0 };
|
||||
// A line with tabs is measured as if it started at the left (see `set_width`), then
|
||||
// aligned as a whole: its tabs stop where they were measured, however it is aligned.
|
||||
let tab_shift = start_x - if regions.is_some() { ax0 } else { ind_l };
|
||||
let line_start = sg.get(i).map_or(pr.start, |g| g.byte);
|
||||
let line_end = sg.get(end).map_or(pr.end, |g| g.byte);
|
||||
let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]);
|
||||
@@ -1833,7 +1857,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
}
|
||||
let mut leader = "";
|
||||
if g.ch == '\t' {
|
||||
(adv, leader) = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]);
|
||||
(adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]);
|
||||
} else if j < body {
|
||||
if g.is_space() {
|
||||
adv += per_space;
|
||||
|
||||
+25
-12
@@ -304,16 +304,20 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) {
|
||||
/// Byte offset nearest to the text-space point `p` (for clicking/dragging with the Type tool).
|
||||
pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
|
||||
if layout.on_path {
|
||||
let best = layout.glyphs.iter().min_by(|a, b| {
|
||||
let best = layout.glyphs.iter().enumerate().min_by(|(_, a), (_, b)| {
|
||||
let ca = a.origin + dir(a.angle) * (a.advance * 0.5);
|
||||
let cb = b.origin + dir(b.angle) * (b.advance * 0.5);
|
||||
(p - ca).hypot2().total_cmp(&(p - cb).hypot2())
|
||||
});
|
||||
return match best {
|
||||
Some(g) if ((p - g.origin).dot(dir(g.angle)) < g.advance * 0.5) != g.rtl => g.byte,
|
||||
Some(g) => g.byte + g.len,
|
||||
None => layout.lines.first().map_or(0, |l| l.start),
|
||||
};
|
||||
let Some((i, g)) = best else { return layout.lines.first().map_or(0, |l| l.start) };
|
||||
// `first` and `last` are the first and last glyphs of the cluster. A click before the middle of
|
||||
// the cluster, measured along the first glyph's direction, goes to the cluster's start.
|
||||
let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len;
|
||||
let first = layout.glyphs[..=i].iter().rev().take_while(same).last().unwrap_or(g);
|
||||
let last = layout.glyphs[i..].iter().take_while(same).last().unwrap_or(g);
|
||||
let along = dir(first.angle);
|
||||
let span = (last.origin + dir(last.angle) * last.advance - first.origin).dot(along);
|
||||
return if ((p - first.origin).dot(along) < span * 0.5) != g.rtl { g.byte } else { g.byte + g.len };
|
||||
}
|
||||
let p = layout.logical_point(p);
|
||||
// Nearest line band (vertical distance first, then distance to the line's column).
|
||||
@@ -349,13 +353,20 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
|
||||
pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize {
|
||||
let Some(line) = layout.lines.get(li) else { return 0 };
|
||||
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
|
||||
let nearest = glyphs
|
||||
// A cluster drawn with several adjacent glyphs (a Devanagari pre-base matra and its consonant,
|
||||
// a consonant and its reph) has one caret stop at each end of its glyphs taken together.
|
||||
let mut clusters: Vec<(f64, f64, &PositionedGlyph)> = Vec::with_capacity(glyphs.len());
|
||||
for g in glyphs {
|
||||
let left = layout.logical_point(g.origin).x;
|
||||
let right = left + g.advance;
|
||||
match clusters.last_mut() {
|
||||
Some((l, r, c)) if c.byte == g.byte && c.len == g.len => (*l, *r) = (l.min(left), r.max(right)),
|
||||
_ => clusters.push((left, right, g)),
|
||||
}
|
||||
}
|
||||
let nearest = clusters
|
||||
.iter()
|
||||
.flat_map(|g| {
|
||||
let left = layout.logical_point(g.origin).x;
|
||||
let right = left + g.advance;
|
||||
if g.rtl { [(right, g.byte), (left, g.byte + g.len)] } else { [(left, g.byte), (right, g.byte + g.len)] }
|
||||
})
|
||||
.flat_map(|&(left, right, g)| if g.rtl { [(right, g.byte), (left, g.byte + g.len)] } else { [(left, g.byte), (right, g.byte + g.len)] })
|
||||
.min_by(|a, b| (a.0 - x).abs().total_cmp(&(b.0 - x).abs()));
|
||||
nearest.map_or(line.start, |(_, byte)| byte)
|
||||
}
|
||||
@@ -518,6 +529,8 @@ mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_combos;
|
||||
#[cfg(test)]
|
||||
mod tests_devanagari;
|
||||
#[cfg(test)]
|
||||
mod tests_embed;
|
||||
#[cfg(test)]
|
||||
mod tests_fit;
|
||||
|
||||
+241
-107
@@ -6,13 +6,13 @@ use std::sync::Arc;
|
||||
use unicode_bidi::Level;
|
||||
use unicode_script::{Script, UnicodeScript};
|
||||
|
||||
use harfrust::{Direction, Feature, ShapeOptions, UnicodeBuffer};
|
||||
use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
use vectorcraft_doc::text::INLINE_CHAR;
|
||||
use vectorcraft_doc::{CharStyle, InlineArt};
|
||||
|
||||
use crate::features::OtFeatures;
|
||||
use crate::features::{OtFeatures, ligatures_suppressed_by};
|
||||
use crate::fontdb::{FontDb, FontFace};
|
||||
|
||||
/// A shaped glyph with all character-style effects resolved, in points (y down).
|
||||
@@ -319,9 +319,11 @@ pub(crate) fn shape_range(
|
||||
let level_at = |i: usize| levels.get(i - range.start).copied().unwrap_or_else(Level::ltr);
|
||||
let output_start = out.len();
|
||||
|
||||
// Text runs also carry non-shaping attributes (fill, stroke, etc.). Coalesce adjacent equal
|
||||
// styles so an editor split does not become an accidental OpenType shaping boundary. Keep
|
||||
// original run spans: each emitted cluster is attributed by its source byte below.
|
||||
// Text runs also carry attributes the shaper does not read (fill, stroke, baseline shift and
|
||||
// others). Adjacent runs that shape alike ([`shapes_alike`]) are shaped together, so that a
|
||||
// change of those attributes is not a shaping boundary: kerning pairs, contextual forms and
|
||||
// joining go on across it. Each glyph takes those attributes from its own source run, found by
|
||||
// its source byte ([`shape_segment`]).
|
||||
/// A run of text shaped together: its byte range, style and source runs, or one inline graphic.
|
||||
struct ShapeSpan<'a> {
|
||||
bytes: Range<usize>,
|
||||
@@ -342,7 +344,7 @@ pub(crate) fn shape_range(
|
||||
&& let Some(previous) = spans.last_mut()
|
||||
&& previous.art.is_none()
|
||||
&& previous.bytes.end == a
|
||||
&& *previous.st == **st
|
||||
&& shapes_alike(previous.st, st)
|
||||
{
|
||||
previous.bytes.end = b;
|
||||
previous.source_runs.end = ri + 1;
|
||||
@@ -365,8 +367,7 @@ pub(crate) fn shape_range(
|
||||
// Synthesized Small Caps shape lowercase letters separately (as smaller capitals).
|
||||
let small_caps = st.small_caps.is_some() && !st.all_caps;
|
||||
// Split into segments by font coverage (and case, for Small Caps).
|
||||
let group_output_start = out.len();
|
||||
let mut seg = Segment { range: a..a, run: source_runs.start, st, face: primary.clone(), small: false, level: level_at(a) };
|
||||
let mut seg = Segment { range: a..a, runs, source_runs, st, face: primary.clone(), small: false, level: level_at(a) };
|
||||
let mut cache: Vec<(char, Arc<FontFace>)> = Vec::new();
|
||||
let mut script = Script::Common;
|
||||
for (i, c) in text[a..b].char_indices() {
|
||||
@@ -407,9 +408,6 @@ pub(crate) fn shape_range(
|
||||
seg.range.end = b;
|
||||
shape_segment(text, &range, &seg, feats, out);
|
||||
}
|
||||
for glyph in &mut out[group_output_start..] {
|
||||
glyph.run = source_run_at(runs, &source_runs, glyph.byte).unwrap_or(source_runs.start);
|
||||
}
|
||||
}
|
||||
// Right-to-left segments come out of the shaper in visual order: back to logical order for
|
||||
// line breaking (the lines are put in visual order once broken).
|
||||
@@ -429,6 +427,61 @@ fn source_run_at(runs: &[(Range<usize>, &CharStyle)], span: &Range<usize>, byte:
|
||||
source.get(i).filter(|(range, _)| range.contains(&byte)).map(|_| span.start + i)
|
||||
}
|
||||
|
||||
/// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text?
|
||||
/// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the
|
||||
/// character style's name, leading, baseline shift, rotation, Character Alignment, the kerning
|
||||
/// (each glyph takes its run's, see [`shape_segment`]) and the tracking, as long as the tracking
|
||||
/// keeps or drops standard ligatures alike ([`crate::features::ligatures_suppressed_by`]).
|
||||
fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
// Every field is named, so that a new attribute has to be sorted into one group or the other.
|
||||
let CharStyle {
|
||||
font_family,
|
||||
font_style,
|
||||
font_version,
|
||||
size,
|
||||
leading: _,
|
||||
tracking,
|
||||
kerning: _,
|
||||
baseline_shift: _,
|
||||
h_scale,
|
||||
v_scale,
|
||||
rotation: _,
|
||||
fill: _,
|
||||
stroke: _,
|
||||
stroke_width: _,
|
||||
underline: _,
|
||||
strikethrough: _,
|
||||
all_caps,
|
||||
features,
|
||||
style_name: _,
|
||||
overprint_fill: _,
|
||||
overprint_stroke: _,
|
||||
stroke_cap: _,
|
||||
stroke_join: _,
|
||||
stroke_miter_limit: _,
|
||||
stroke_dash: _,
|
||||
stroke_align: _,
|
||||
position,
|
||||
small_caps,
|
||||
char_align: _,
|
||||
proportional_metrics,
|
||||
digits,
|
||||
} = a;
|
||||
*font_family == b.font_family
|
||||
&& *font_style == b.font_style
|
||||
&& *font_version == b.font_version
|
||||
&& *size == b.size
|
||||
&& *h_scale == b.h_scale
|
||||
&& *v_scale == b.v_scale
|
||||
&& *all_caps == b.all_caps
|
||||
&& *features == b.features
|
||||
&& *position == b.position
|
||||
&& *small_caps == b.small_caps
|
||||
&& *proportional_metrics == b.proportional_metrics
|
||||
&& *digits == b.digits
|
||||
&& ligatures_suppressed_by(*tracking) == ligatures_suppressed_by(b.tracking)
|
||||
}
|
||||
|
||||
/// The script `c` is shaped in: Japanese and Chinese text mixes Han, Hiragana, Katakana and
|
||||
/// Bopomofo, shaped together (splitting them would cost a shaper call per change of script).
|
||||
fn shaping_script(c: char) -> Script {
|
||||
@@ -497,10 +550,15 @@ fn inline_glyph(face: &Arc<FontFace>, st: &CharStyle, art: &InlineArt, run: usiz
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece of one run shaped in one go: one face and, for Small Caps, one case.
|
||||
/// A piece of text shaped in one go: one face and, for Small Caps, one case, in runs that shape
|
||||
/// alike ([`shapes_alike`]).
|
||||
#[derive(Clone)]
|
||||
struct Segment<'a> {
|
||||
range: Range<usize>,
|
||||
run: usize,
|
||||
/// All the runs, and the indices of those the piece's shaping span takes in.
|
||||
runs: &'a [(Range<usize>, &'a CharStyle)],
|
||||
source_runs: Range<usize>,
|
||||
/// The style of the span's first run, which the others shape alike with.
|
||||
st: &'a CharStyle,
|
||||
face: Arc<FontFace>,
|
||||
/// Lowercase letters drawn as synthesized small capitals.
|
||||
@@ -508,15 +566,36 @@ struct Segment<'a> {
|
||||
level: Level,
|
||||
}
|
||||
|
||||
/// A segment shaped in one style: each glyph's id, cluster, x advance and x and y offset (font
|
||||
/// units), whether `palt` gave it another advance, and what `vpal` does to it.
|
||||
#[derive(Default)]
|
||||
struct Shaped {
|
||||
raw: Vec<(u32, u32, i32, i32, i32)>,
|
||||
proportional: Vec<bool>,
|
||||
vpal: Vec<Option<VAdjust>>,
|
||||
}
|
||||
|
||||
fn is_mark(c: char) -> bool {
|
||||
use unicode_general_category::{GeneralCategory, get_general_category};
|
||||
matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark)
|
||||
|| matches!(c, '\u{200D}' | '\u{FE00}'..='\u{FE0F}')
|
||||
}
|
||||
|
||||
/// Shape `seg` and append its glyphs to `out`. Each glyph takes the tracking, kerning, baseline
|
||||
/// shift, rotation and leading of its own source run. The font's kerning pairs apply after the
|
||||
/// characters with automatic kerning: the manual value replaces the font's pair after its
|
||||
/// character, and a character before it with automatic kerning keeps the font's pair with it
|
||||
/// (except before a character that All Caps or Small Caps sets as several letters, such as ß as
|
||||
/// SS). A cluster that would take in characters of runs with different styles (a ligature, a
|
||||
/// base with its marks) is not formed: the segment is shaped in pieces at those run boundaries.
|
||||
fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SGlyph>) {
|
||||
let Segment { range, run, st, face, small, level } = seg;
|
||||
let (range, run, small) = (range.clone(), *run, *small);
|
||||
let Segment { range, runs, source_runs, st, face, small, level } = seg;
|
||||
let (range, small) = (range.clone(), *small);
|
||||
// The source runs the segment's bytes lie in (a span can hold many segments).
|
||||
let span = runs.get(source_runs.clone()).unwrap_or_default();
|
||||
let first = span.partition_point(|(r, _)| r.end <= range.start);
|
||||
let last = span.partition_point(|(r, _)| r.start < range.end).max(first);
|
||||
let source_runs = &(source_runs.start + first..source_runs.start + last);
|
||||
let text_seg = &text[range.clone()];
|
||||
let full = st.size.max(0.0);
|
||||
// Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size.
|
||||
@@ -527,115 +606,168 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
let km = full / face.upem;
|
||||
let hs = st.h_scale / 100.0;
|
||||
let vs = st.v_scale / 100.0;
|
||||
let tracking = st.tracking / 1000.0 * size;
|
||||
let manual_kern = st.kerning.map(|v| v / 1000.0 * size).unwrap_or(0.0);
|
||||
let ascent = face.ascent * km * vs;
|
||||
let descent = face.descent * km * vs;
|
||||
let leading = st.effective_leading();
|
||||
let cap = face.cap_height * km * vs;
|
||||
let xh = face.x_height * km * vs;
|
||||
let upper = st.all_caps || small;
|
||||
let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' ');
|
||||
// The source run of the character at `byte`: its index and style.
|
||||
let run_at = |byte: usize| {
|
||||
let i = source_run_at(runs, source_runs, byte).unwrap_or(source_runs.start);
|
||||
(i, runs.get(i).map_or(*st, |(_, s)| *s))
|
||||
};
|
||||
// The starts of the source runs inside the segment.
|
||||
let boundaries = || runs.get(source_runs.clone()).unwrap_or_default().iter().map(|(r, _)| r.start).filter(|&b| b > range.start && b < range.end);
|
||||
// Shape the pieces of the segment between the bytes `at` (ascending, inside it) on their own.
|
||||
let shape_apart = |at: Vec<usize>, out: &mut Vec<SGlyph>| {
|
||||
let ends = at.iter().copied().chain([range.end]);
|
||||
for (start, end) in std::iter::once(range.start).chain(at.iter().copied()).zip(ends) {
|
||||
shape_segment(text, context, &Segment { range: start..end, ..seg.clone() }, feats, out);
|
||||
}
|
||||
};
|
||||
let manual = |byte: usize| run_at(byte).1.kerning.is_some();
|
||||
let mixed_kerning = boundaries().any(|b| manual(b) != manual(range.start));
|
||||
|
||||
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
|
||||
// Proportional Metrics: which glyphs `palt` gave another advance than their full width, and
|
||||
// what `vpal` does to each in vertical type.
|
||||
let mut proportional = vec![];
|
||||
let mut vpal = vec![];
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
// gid, cluster, x and y advance, x and y offset.
|
||||
let shape = |feats: &[Feature], direction: Direction| {
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
let c = st.digits.map(c);
|
||||
if upper {
|
||||
for u in c.to_uppercase() {
|
||||
buf.add(u, cl);
|
||||
// Shape the segment as if all of it were in `st`, with the features `more` on top.
|
||||
let shape_in = |st: &CharStyle, more: &[Feature]| {
|
||||
let mut shaped = Shaped::default();
|
||||
let Shaped { raw, proportional, vpal } = &mut shaped;
|
||||
let hb_shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
// gid, cluster, x and y advance, x and y offset.
|
||||
let shape = |feats: &[Feature], direction: Direction| {
|
||||
let feats: Vec<Feature> = feats.iter().chain(more).cloned().collect();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
let c = st.digits.map(c);
|
||||
if upper {
|
||||
for u in c.to_uppercase() {
|
||||
buf.add(u, cl);
|
||||
}
|
||||
} else {
|
||||
buf.add(c, cl);
|
||||
}
|
||||
} else {
|
||||
buf.add(c, cl);
|
||||
}
|
||||
}
|
||||
buf.set_direction(direction);
|
||||
buf.guess_segment_properties();
|
||||
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
|
||||
// glyphs for it. This retains context at real style/font segmentation boundaries without
|
||||
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
|
||||
if let Some(pre) = text.get(context.start..range.start) {
|
||||
buf.set_pre_context(pre);
|
||||
}
|
||||
if let Some(post) = text.get(range.end..context.end) {
|
||||
buf.set_post_context(post);
|
||||
}
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(feats));
|
||||
gb.glyph_infos()
|
||||
.iter()
|
||||
.zip(gb.glyph_positions())
|
||||
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.y_advance, pos.x_offset, pos.y_offset))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let along = if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight };
|
||||
raw = shape(&feats.resolve(st), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect();
|
||||
let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(&raw).all(|(a, b)| a.0 == b.0);
|
||||
if feats.proportional(st) {
|
||||
// `palt` is positioning only: the same glyphs set on their full widths tell which
|
||||
// advances it changed (kerning and the rest change both alike).
|
||||
let full = shape(&feats.resolve_fixed_width(st), along);
|
||||
if same_glyphs(&full) {
|
||||
proportional = full.iter().zip(&raw).map(|(a, b)| a.2 != b.2).collect();
|
||||
}
|
||||
}
|
||||
if feats.proportional_vertical(st) {
|
||||
// `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set
|
||||
// top to bottom with it (and the vertical kerning that goes with it, `vkrn` and `vapk`)
|
||||
// and without either tell how it moves each and changes its advance down the column
|
||||
// (y up, advances negative). Which advances `vpal` itself changed comes from setting
|
||||
// them with it alone.
|
||||
let ttb = |proportional, kerning| shape(&feats.resolve_vertical(st, proportional, kerning), Direction::TopToBottom);
|
||||
let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false));
|
||||
if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) {
|
||||
vpal = with
|
||||
buf.set_direction(direction);
|
||||
buf.guess_segment_properties();
|
||||
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
|
||||
// glyphs for it. This retains context at real style/font segmentation boundaries without
|
||||
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
|
||||
if let Some(pre) = text.get(context.start..range.start) {
|
||||
buf.set_pre_context(pre);
|
||||
}
|
||||
if let Some(post) = text.get(range.end..context.end) {
|
||||
buf.set_post_context(post);
|
||||
}
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
|
||||
gb.glyph_infos()
|
||||
.iter()
|
||||
.zip(&without)
|
||||
.zip(&alone)
|
||||
.map(|((a, b), c)| {
|
||||
let adj = VAdjust {
|
||||
advance: f64::from(b.3 - a.3) * k * vs,
|
||||
dx: f64::from(a.4 - b.4) * k * hs,
|
||||
dy: -f64::from(a.5 - b.5) * k * vs,
|
||||
proportional: c.3 != b.3,
|
||||
};
|
||||
(adj != VAdjust::default()).then_some(adj)
|
||||
})
|
||||
.collect();
|
||||
.zip(gb.glyph_positions())
|
||||
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.y_advance, pos.x_offset, pos.y_offset))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let along = if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight };
|
||||
*raw = shape(&feats.resolve(st), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect();
|
||||
let raw = &*raw;
|
||||
let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(raw).all(|(a, b)| a.0 == b.0);
|
||||
if feats.proportional(st) {
|
||||
// `palt` is positioning only: the same glyphs set on their full widths tell which
|
||||
// advances it changed (kerning and the rest change both alike).
|
||||
let full = shape(&feats.resolve_fixed_width(st), along);
|
||||
if same_glyphs(&full) {
|
||||
*proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if shaped.is_none() {
|
||||
// Fallback: nominal glyphs and hmtx advances, no shaping.
|
||||
if let Some(f) = face.skrifa() {
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
let c = st.digits.map(c);
|
||||
let chars: Vec<char> = if upper { c.to_uppercase().collect() } else { vec![c] };
|
||||
for u in chars {
|
||||
let g = cmap.map(u).unwrap_or_default();
|
||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||
raw.push((g.to_u32(), cl, adv.round() as i32, 0, 0));
|
||||
if feats.proportional_vertical(st) {
|
||||
// `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set
|
||||
// top to bottom with it (and the vertical kerning that goes with it, `vkrn` and `vapk`)
|
||||
// and without either tell how it moves each and changes its advance down the column
|
||||
// (y up, advances negative). Which advances `vpal` itself changed comes from setting
|
||||
// them with it alone.
|
||||
let ttb = |proportional, kerning| shape(&feats.resolve_vertical(st, proportional, kerning), Direction::TopToBottom);
|
||||
let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false));
|
||||
if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) {
|
||||
*vpal = with
|
||||
.iter()
|
||||
.zip(&without)
|
||||
.zip(&alone)
|
||||
.map(|((a, b), c)| {
|
||||
let adj = VAdjust {
|
||||
advance: f64::from(b.3 - a.3) * k * vs,
|
||||
dx: f64::from(a.4 - b.4) * k * hs,
|
||||
dy: -f64::from(a.5 - b.5) * k * vs,
|
||||
proportional: c.3 != b.3,
|
||||
};
|
||||
(adj != VAdjust::default()).then_some(adj)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
});
|
||||
if hb_shaped.is_none() {
|
||||
// Fallback: nominal glyphs and hmtx advances, no shaping.
|
||||
if let Some(f) = face.skrifa() {
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
let c = st.digits.map(c);
|
||||
let chars: Vec<char> = if upper { c.to_uppercase().collect() } else { vec![c] };
|
||||
for u in chars {
|
||||
let g = cmap.map(u).unwrap_or_default();
|
||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||
raw.push((g.to_u32(), cl, adv.round() as i32, 0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shaped
|
||||
};
|
||||
let Shaped { raw, proportional, vpal } = if mixed_kerning {
|
||||
// A font's kerning pair applies after a character with automatic kerning and not after one
|
||||
// with manual kerning. The segment is shaped apart where manual kerning ends, so each piece
|
||||
// is text with automatic kerning followed by text with manual kerning. A piece is shaped
|
||||
// with the font's kerning on, and off (`kern`, and the `apkn`, `vkrn` and `vapk` that
|
||||
// `OtFeatures` turns off with it) from the second character with manual kerning on: a
|
||||
// pair is applied only when both of its glyphs have the feature on.
|
||||
let ends: Vec<usize> = boundaries().filter(|&b| manual(b - 1) && !manual(b)).collect();
|
||||
if !ends.is_empty() {
|
||||
return shape_apart(ends, out);
|
||||
}
|
||||
let first = boundaries().find(|&b| manual(b)).unwrap_or(range.end);
|
||||
// A first character with manual kerning that All Caps or Small Caps sets as several letters
|
||||
// (ß as SS) gives them one cluster, which a feature range cannot split, so the font's
|
||||
// kerning between them would stay on: it is shaped apart from the text before it.
|
||||
if upper && first_char(first).to_uppercase().nth(1).is_some() {
|
||||
return shape_apart(vec![first], out);
|
||||
}
|
||||
let off = text.get(first..range.end).and_then(|s| s.chars().next()).map_or(range.end, |c| first + c.len_utf8());
|
||||
let more: Vec<Feature> = [b"kern", b"apkn", b"vkrn", b"vapk"]
|
||||
.into_iter()
|
||||
.filter(|_| off < range.end)
|
||||
.map(|t| Feature::new(Tag::new(t), 0, off..range.end))
|
||||
.collect();
|
||||
shape_in(&CharStyle { kerning: None, ..(*st).clone() }, &more)
|
||||
} else {
|
||||
// The span's first run may lie outside the segment (a span split by font, case, script
|
||||
// or direction): the kerning mode is that of the segment's own runs.
|
||||
shape_in(run_at(range.start).1, &[])
|
||||
};
|
||||
let mut cluster_starts: Vec<usize> = raw.iter().map(|r| r.1 as usize).collect();
|
||||
cluster_starts.sort_unstable();
|
||||
cluster_starts.dedup();
|
||||
// A glyph is drawn in one style: where a cluster would take in characters of runs in different
|
||||
// styles, the runs are shaped apart, as runs that do not shape alike are.
|
||||
let inside_clusters: Vec<usize> = boundaries().filter(|&b| cluster_starts.binary_search(&b).is_err() && run_at(b - 1).1 != run_at(b).1).collect();
|
||||
if !inside_clusters.is_empty() {
|
||||
return shape_apart(inside_clusters, out);
|
||||
}
|
||||
let n = raw.len();
|
||||
for (gi, &(gid, cl, xa, xo, yo)) in raw.iter().enumerate() {
|
||||
let cl = cl as usize;
|
||||
let (run, gst) = run_at(cl);
|
||||
// Cluster end: the next larger cluster value in the segment, else the segment end.
|
||||
let end = cluster_starts.get(cluster_starts.partition_point(|&c| c <= cl)).copied().unwrap_or(range.end);
|
||||
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
|
||||
@@ -644,6 +776,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
if ch == SOFT_HYPHEN {
|
||||
adv = 0.0;
|
||||
} else if last_in_cluster {
|
||||
let tracking = gst.tracking / 1000.0 * size;
|
||||
let manual_kern = gst.kerning.map(|v| v / 1000.0 * size).unwrap_or(0.0);
|
||||
adv += tracking + manual_kern;
|
||||
}
|
||||
out.push(SGlyph {
|
||||
@@ -657,11 +791,11 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
dy: -(yo as f64) * k * vs,
|
||||
sx: k * hs,
|
||||
sy: k * vs,
|
||||
bshift: st.baseline_shift + pos_shift,
|
||||
rotation: st.rotation,
|
||||
bshift: gst.baseline_shift + pos_shift,
|
||||
rotation: gst.rotation,
|
||||
ascent,
|
||||
descent,
|
||||
leading,
|
||||
leading: gst.effective_leading(),
|
||||
cap,
|
||||
xh,
|
||||
ch,
|
||||
|
||||
@@ -218,6 +218,44 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option<Vec<u8>> {
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The family of [`pair_kerning_font`].
|
||||
pub const PAIR_KERNING_FAMILY: &str = "Pairtest Sans";
|
||||
|
||||
/// The bundled Source Sans 3 Regular as family [`PAIR_KERNING_FAMILY`], its `GPOS` replaced by two
|
||||
/// `kern` pairs, A followed by V and S followed by S: each takes 60 units off the first glyph's
|
||||
/// advance (the pair's first value record) and moves the second 100 units left (its second).
|
||||
/// `None` when it can't be built.
|
||||
pub fn pair_kerning_font() -> Option<Vec<u8>> {
|
||||
use write_fonts::tables::gpos::{Gpos, PairPos, PairSet, PairValueRecord, PositionLookup, PositionLookupList, ValueRecord};
|
||||
use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord};
|
||||
use write_fonts::types::GlyphId16;
|
||||
|
||||
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
let font = skrifa::FontRef::new(BASE).ok()?;
|
||||
let charmap = font.charmap();
|
||||
let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?));
|
||||
let pair = |second| PairValueRecord::new(second, ValueRecord::new().with_x_advance(-60), ValueRecord::new().with_x_placement(-100));
|
||||
let mut pairs = [(gid('A')?, gid('V')?), (gid('S')?, gid('S')?)];
|
||||
pairs.sort_by_key(|p| p.0);
|
||||
let coverage = CoverageFormat1::new(pairs.iter().map(|p| p.0).collect());
|
||||
let pos = PairPos::format_1(coverage.into(), pairs.iter().map(|p| PairSet::new(vec![pair(p.1)])).collect());
|
||||
let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0])), vec![]))]);
|
||||
let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"kern"), Feature::new(None, vec![0]))]);
|
||||
let lookups = PositionLookupList::new(vec![PositionLookup::Pair(Lookup::new(Default::default(), vec![pos]))]);
|
||||
let family = PAIR_KERNING_FAMILY;
|
||||
let name = name_table(&[(1, family), (2, "Regular"), (4, "Pairtest Sans Regular"), (6, "PairtestSans-Regular")])?;
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_table(&Gpos::new(scripts, features, lookups)).ok()?;
|
||||
builder.add_raw(Tag::new(b"name"), name);
|
||||
for r in font.table_directory.table_records() {
|
||||
let tag = Tag::new(&r.tag().to_be_bytes());
|
||||
if tag != Tag::new(b"kern") && !builder.contains(tag) {
|
||||
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
|
||||
}
|
||||
}
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The family of [`twin_font`]: two versions of one family and style.
|
||||
pub const TWIN_FAMILY: &str = "Twintest Sans";
|
||||
|
||||
|
||||
@@ -486,6 +486,39 @@ fn tab_stops_position_text() {
|
||||
assert!((x_of(&p, 4) - 72.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() {
|
||||
// A tab at the start of area type is 36 pt wide: the line breaks and justifies around that,
|
||||
// so no line reaches past the frame's right edge (#2457).
|
||||
for justify in [Justify::Left, Justify::JustifyLeft] {
|
||||
let t =
|
||||
area("\tSample copy flows here while the layout takes shape. Swap these words", style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), justify);
|
||||
let lay = layout(db(), &t);
|
||||
let right = lay.glyphs.iter().filter(|g| !t.plain_text()[g.byte..].starts_with(' ')).map(|g| g.origin.x + g.advance).fold(0.0, f64::max);
|
||||
assert!(right <= 200.0 + 1e-6, "{justify:?}: the type reaches {right}");
|
||||
let first = lay.glyphs.iter().find(|g| g.byte == 1).map(|g| g.origin.x).unwrap();
|
||||
assert!((first - 36.0).abs() < 1e-6, "{justify:?}: the text after the tab starts at {first}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_centred_or_right_aligned_line_sets_its_tabs_as_if_it_started_at_the_left() {
|
||||
use vectorcraft_doc::{TabAlign, TabStop};
|
||||
// "A<tab>B" with a stop at 100 pt is 100 pt plus B wide whatever the alignment: the whole line
|
||||
// moves, the gap between A and B stays 100 pt, and a right aligned line ends on the edge (#2457).
|
||||
for justify in [Justify::Center, Justify::Right, Justify::JustifyCenter, Justify::JustifyRight] {
|
||||
let mut t = area("A\tB", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), justify);
|
||||
t.para.tabs = vec![TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
|
||||
let lay = layout(db(), &t);
|
||||
let at = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x).unwrap();
|
||||
assert!((at(2) - at(0) - 100.0).abs() < 1e-6, "{justify:?}: A at {}, B at {}", at(0), at(2));
|
||||
if matches!(justify, Justify::Right | Justify::JustifyRight) {
|
||||
let b = lay.glyphs.iter().find(|g| g.byte == 2).unwrap();
|
||||
assert!((b.origin.x + b.advance - 200.0).abs() < 1e-6, "{justify:?}: the line ends at {}", b.origin.x + b.advance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() {
|
||||
use vectorcraft_doc::{TabAlign, TabStop};
|
||||
|
||||
@@ -236,6 +236,35 @@ fn different_style_arabic_boundary_receives_joining_context() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_of_paint_in_a_mixed_direction_line_moves_no_glyph() {
|
||||
let db = FontDb::global();
|
||||
let text = "AVAV سلام AVAV";
|
||||
if db.face_covering('س').is_none_or(|face| !face.covers('ل')) {
|
||||
eprintln!("SKIPPED: a_change_of_paint_in_a_mixed_direction_line_moves_no_glyph; no font covers Arabic seen and lam");
|
||||
return;
|
||||
}
|
||||
let plain = layout(db, &TextObject::point(Point::ZERO, text, CharStyle::default()));
|
||||
assert!(plain.glyphs.iter().any(|g| g.rtl) && plain.glyphs.iter().any(|g| !g.rtl), "{plain:?}");
|
||||
let seen = text.find('س').unwrap_or_default();
|
||||
let mut restyled = TextObject::point(Point::ZERO, text, CharStyle::default());
|
||||
for byte in [1, seen] {
|
||||
let end = byte + text[byte..].chars().next().map_or(1, char::len_utf8);
|
||||
edit::style_range(&mut restyled.runs, byte, end, |s| s.fill = Default::default());
|
||||
}
|
||||
assert_eq!(restyled.runs.len(), 5);
|
||||
let restyled_layout = layout(db, &restyled);
|
||||
assert_same_shaped_glyphs(&plain, &restyled_layout);
|
||||
for glyph in &restyled_layout.glyphs {
|
||||
let run_ends = restyled.runs.iter().scan(0, |end, r| {
|
||||
*end += r.text.len();
|
||||
Some(*end)
|
||||
});
|
||||
let expected = run_ends.take_while(|end| *end <= glyph.byte).count();
|
||||
assert_eq!(glyph.run, expected, "glyph {glyph:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_range_handles_empty_runs_and_selected_utf8_subranges() {
|
||||
let db = FontDb::global();
|
||||
|
||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user