mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-21 06:34:18 -04:00
Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
278fba490a | ||
|
|
d807236bf2 | ||
|
|
9c1d1d56ed | ||
|
|
53bd154059 | ||
|
|
1f2e90a7b3 | ||
|
|
c7f748c75c | ||
|
|
4de769175e | ||
|
|
9a8e2ac6a0 | ||
|
|
419ed87cff | ||
|
|
eeb9513bbb | ||
|
|
c092ca1a48 | ||
|
|
ba2a11a807 | ||
|
|
f1757c7bda | ||
|
|
e616f1fe5c | ||
|
|
647d51eaf6 | ||
|
|
0d34a58b7b | ||
|
|
3a685eae61 | ||
|
|
e4d94109a2 | ||
|
|
4a90d13fc0 | ||
|
|
8a2d0db225 | ||
|
|
e358bbfdfb | ||
|
|
7d7853d7fd | ||
|
|
0a14001126 | ||
|
|
910e33b796 | ||
|
|
9b64fce9ec | ||
|
|
cd35c27a53 | ||
|
|
ab01a1a927 | ||
|
|
d054b5b4c7 | ||
|
|
ba32ef5b09 | ||
|
|
f0c2d8c890 | ||
|
|
e5c9054324 | ||
|
|
9fe0f03de2 | ||
|
|
429e147164 | ||
|
|
c0ec14b86d | ||
|
|
89a73abc38 | ||
|
|
cc8944ad2a | ||
|
|
05264b2801 | ||
|
|
372887fc6c | ||
|
|
841fc0cd51 | ||
|
|
d7e7066251 | ||
|
|
dc7216f0e4 | ||
|
|
36aecff81d | ||
|
|
9dbab52168 | ||
|
|
09a6fbe470 | ||
|
|
b30d80e056 | ||
|
|
1b8e3d9ae1 | ||
|
|
12d107cc56 | ||
|
|
eba9abc21e | ||
|
|
88494319da | ||
|
|
d25289a3be | ||
|
|
2025c357d9 | ||
|
|
16dcaa425b | ||
|
|
bf19ada5e1 | ||
|
|
c5798f446e | ||
|
|
4675a1633e | ||
|
|
d99b04e594 | ||
|
|
0f6154e7b9 | ||
|
|
fc594b51ee | ||
|
|
cdba0aac03 | ||
|
|
f44c3e9593 | ||
|
|
c965f49fda | ||
|
|
a10604bbc6 | ||
|
|
4f526b4e3f | ||
|
|
f2601137ab | ||
|
|
9938a31c9b | ||
|
|
5f37fccdce | ||
|
|
e8e88c14f2 | ||
|
|
2cdb0d2d53 | ||
|
|
fe16f852db | ||
|
|
43bb588e5e | ||
|
|
a57eaa0782 | ||
|
|
007df2ee24 | ||
|
|
0955e6845c | ||
|
|
4bafe834a6 | ||
|
|
87603e4c35 | ||
|
|
e60882a3a1 | ||
|
|
25a987a94f | ||
|
|
090659b78b |
No files matched your search
@@ -35,5 +35,5 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
run-package-check: true
|
||||
@@ -26,7 +26,8 @@ jobs:
|
||||
with:
|
||||
app-name: vectorcraft
|
||||
app-title: VectorCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
Generated
+1
@@ -5007,6 +5007,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-text",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ wat = "1.228"
|
||||
# Without `text` (glyph runs, through glifo and a second skrifa): the renderer draws the outlines
|
||||
# vectorcraft-text gives it. The other default features stay.
|
||||
vello_cpu = { version = "0.2", default-features = false, features = ["std", "png", "u8_pipeline", "f32_pipeline"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp", "tga"] }
|
||||
# Without `text` and the system fonts (fontdb, harfrust and skrifa, at other versions than ours):
|
||||
# SVG import reads `<text>` from the XML itself and never gives usvg a font. A test of the SVG
|
||||
# crate turns `text` on.
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<h1 align="center">VectorCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Vector illustration; an open-source, clean-room reimplementation of Adobe Illustrator, rebuilt in pure Rust.</b>
|
||||
<b>Vector illustration; an open-source, modern vector art application, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
|
||||
A fast, open-source vector illustration workflow. It runs natively on
|
||||
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
</p>
|
||||
|
||||
@@ -147,7 +147,7 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
|
||||
## Why VectorCraft
|
||||
|
||||
- **Familiar.** Illustrator's layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
|
||||
- **Familiar.** A familiar layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
|
||||
Pathfinder, Smart Guides, Appearance, Swatches, Layers and more. You already know how to use it.
|
||||
- **Fast.** Multithreaded SIMD rendering off the UI thread. 20,000 shapes render in about 27 ms at
|
||||
full retina resolution while the interface stays at 120 fps.
|
||||
@@ -224,9 +224,8 @@ VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the st
|
||||
and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and
|
||||
[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing.
|
||||
|
||||
**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against
|
||||
all of Illustrator): everyday
|
||||
**Where we are (2026-10-10): alpha.** About 88% of core menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday
|
||||
vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
@@ -240,20 +239,20 @@ and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-
|
||||
The interface
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's public documentation;
|
||||
The scores are self-assessed from our code against public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
|
||||
- about half of the raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- generative AI features.
|
||||
|
||||
**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability,
|
||||
alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in
|
||||
alongside the raster-effects package; then type, recent additions and 3D. The ranked list is in
|
||||
[`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
@@ -372,8 +371,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and VectorCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. VectorCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
@@ -79,14 +79,16 @@ USAGE:
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||
is a template, written as file.saveAsTemplate writes it). Prints one JSON result per step.
|
||||
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
|
||||
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it
|
||||
out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
|
||||
--artboard or --range).
|
||||
@@ -278,7 +280,8 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
} else {
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
let mut params = with_pixel_scale(params, &output, scale);
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
@@ -302,6 +305,16 @@ fn is_template(path: &str) -> bool {
|
||||
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
|
||||
}
|
||||
|
||||
/// The export `params` for `path`, with `--scale` (pixels per point) when its format is raster.
|
||||
/// DXF has a `scale` of its own (drawing units per unit), which would resize the drawing and leave
|
||||
/// its units unset.
|
||||
fn with_pixel_scale(mut params: Value, path: &str, scale: f64) -> Value {
|
||||
if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) {
|
||||
params["scale"] = json!(scale);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
let file = args.first().ok_or("info needs a FILE")?;
|
||||
let mut h = Headless::new();
|
||||
@@ -391,7 +404,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let r = if is_template(&path) {
|
||||
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
|
||||
} else {
|
||||
h.call("app.export", json!({"path": path, "scale": scale}))
|
||||
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
|
||||
};
|
||||
let r = r.map_err(|e| format!("export {path}: {e}"))?;
|
||||
emit(json!({"step": "export", "result": r}))?;
|
||||
|
||||
@@ -58,6 +58,28 @@ fn run_batch_exports() {
|
||||
assert_eq!(last["result"]["artboards"][0]["width"], 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_sizes_raster_exports_only() {
|
||||
// --scale is pixels per point. DXF has a `scale` of its own (drawing units per mm) that would
|
||||
// resize the drawing and leave its units unset.
|
||||
let svg = tmp("scale.svg");
|
||||
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="30" height="20"/></svg>"#)
|
||||
.unwrap();
|
||||
let svg = svg.to_str().unwrap();
|
||||
let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png"));
|
||||
let ok = |args: &[&str]| {
|
||||
let out = Command::new(BIN).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
ok(&["convert", svg, plain.to_str().unwrap()]);
|
||||
ok(&["convert", svg, scaled.to_str().unwrap(), "--scale", "2"]);
|
||||
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "convert --scale 2 changed the DXF");
|
||||
ok(&["run", "--in", svg, "--export", scaled.to_str().unwrap(), "--export", png.to_str().unwrap(), "--scale", "2"]);
|
||||
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "run --scale 2 changed the DXF");
|
||||
// 100 pt at scale 2 → 200 px (IHDR width).
|
||||
assert_eq!(std::fs::read(&png).unwrap()[16..20], 200u32.to_be_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_reports_errors() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
|
||||
|
||||
@@ -26,6 +26,7 @@ log = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
serde_json = { workspace = true }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
@@ -0,0 +1,184 @@
|
||||
//! `window.vectorcraft`: the control channel for the page, as promises.
|
||||
//!
|
||||
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
|
||||
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
|
||||
//! the CLI and the MCP server:
|
||||
//!
|
||||
//! ```js
|
||||
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
|
||||
//! await vectorcraft.request("ui.inspect") // control method → its result
|
||||
//! await vectorcraft.commands() // command list
|
||||
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
|
||||
//! vectorcraft.captureDownloads(true) // saves stay in the page…
|
||||
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
|
||||
//! ```
|
||||
//!
|
||||
//! Results resolve with the method's `result`; errors reject with an `Error`.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::saved_files::SavedFiles;
|
||||
|
||||
use crate::web::Inbox;
|
||||
|
||||
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
|
||||
|
||||
thread_local! {
|
||||
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
|
||||
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
|
||||
static CAPTURE: Cell<bool> = const { Cell::new(false) };
|
||||
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
|
||||
}
|
||||
|
||||
/// Wire the page's requests to the app: control requests go to `tx`, opened files to `inbox`.
|
||||
/// After a graphics restart the app keeps its channel: `tx` is `None` and only the context changes.
|
||||
pub fn connect(tx: Option<Sender<ControlRequest>>, inbox: Inbox, ctx: egui::Context) {
|
||||
if let Some(tx) = tx {
|
||||
TX.with(|t| *t.borrow_mut() = Some(tx));
|
||||
}
|
||||
INBOX.with(|i| *i.borrow_mut() = Some(inbox));
|
||||
CTX.with(|c| *c.borrow_mut() = Some(ctx));
|
||||
}
|
||||
|
||||
fn repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(ctx) = c.borrow().as_ref() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
|
||||
pub fn capture(path: &str, bytes: &[u8]) -> bool {
|
||||
if !CAPTURE.with(Cell::get) {
|
||||
return false;
|
||||
}
|
||||
SAVED.with(|s| s.borrow_mut().keep(path, bytes));
|
||||
true
|
||||
}
|
||||
|
||||
fn to_js(v: &Value) -> JsValue {
|
||||
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
fn from_js(v: &JsValue) -> Value {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return json!({});
|
||||
}
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Settle the promises of answered requests (called every frame).
|
||||
pub fn poll_replies() {
|
||||
WAITING.with(|w| {
|
||||
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
|
||||
Ok(v) => {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
|
||||
} else {
|
||||
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
|
||||
}
|
||||
false
|
||||
}
|
||||
Err(TryRecvError::Empty) => true,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
|
||||
false
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn request(method: &str, params: Value) -> js_sys::Promise {
|
||||
let method = method.to_string();
|
||||
let p = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
|
||||
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
|
||||
if sent {
|
||||
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
|
||||
} else {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
|
||||
}
|
||||
});
|
||||
repaint();
|
||||
p
|
||||
}
|
||||
|
||||
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
|
||||
}
|
||||
|
||||
/// Install `window.vectorcraft`.
|
||||
pub fn install() -> Result<(), JsValue> {
|
||||
let obj = js_sys::Object::new();
|
||||
set_fn(
|
||||
&obj,
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(¶ms)))
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
|
||||
request("engine.execute", json!({"command": command, "params": from_js(¶ms)}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
|
||||
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"screenshot",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(¶ms))).into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"openFile",
|
||||
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(|name: String, bytes: js_sys::Uint8Array| {
|
||||
let queued = INBOX.with(|i| {
|
||||
i.borrow().as_ref().is_some_and(|inbox| {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes.to_vec()));
|
||||
true
|
||||
})
|
||||
});
|
||||
repaint();
|
||||
JsValue::from_bool(queued)
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"files",
|
||||
Closure::<dyn Fn() -> JsValue>::new(|| {
|
||||
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"readFile",
|
||||
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
|
||||
SAVED.with(|s| {
|
||||
s.borrow()
|
||||
.get(&name)
|
||||
.map(|b| js_sys::Uint8Array::from(b).into())
|
||||
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,12 +16,16 @@
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
|
||||
//! language code) stands in for the browser's languages when the interface language is Automatic.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod fonts;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod pencil;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod saved_files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
|
||||
|
||||
/// Saved files kept (the oldest go first).
|
||||
pub const MAX_SAVED: usize = 16;
|
||||
|
||||
/// The last component of a `/` or `\` path.
|
||||
fn file_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Files by name, newest last: saving a name again replaces it.
|
||||
#[derive(Default)]
|
||||
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
|
||||
|
||||
impl SavedFiles {
|
||||
/// Keep `bytes` under the last component of `path`.
|
||||
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
|
||||
let name = file_name(path).to_string();
|
||||
self.0.retain(|(n, _)| *n != name);
|
||||
self.0.push((name, bytes.to_vec()));
|
||||
let extra = self.0.len().saturating_sub(MAX_SAVED);
|
||||
self.0.drain(..extra);
|
||||
}
|
||||
|
||||
/// A kept file by its name, or by a path ending in it.
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
let wanted = file_name(name);
|
||||
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
|
||||
}
|
||||
|
||||
/// `(name, size)` of each kept file, oldest first.
|
||||
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
|
||||
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_by_name_replaces_and_reads_back_by_path() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("exports/poster.png", b"one");
|
||||
s.keep(r"C:\out\poster.png", b"two");
|
||||
s.keep("notes.txt", b"three");
|
||||
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
|
||||
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("missing.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_the_oldest_past_the_cap() {
|
||||
let mut s = SavedFiles::default();
|
||||
for i in 0..MAX_SAVED + 3 {
|
||||
s.keep(&format!("f{i}.png"), &[0]);
|
||||
}
|
||||
assert_eq!(s.list().count(), MAX_SAVED);
|
||||
assert_eq!(s.get("f0.png"), None);
|
||||
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_separator_keeps_the_whole_name() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("odd/", b"x");
|
||||
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use wasm_bindgen::JsCast as _;
|
||||
use crate::locks::WebLocks;
|
||||
use crate::pencil::SharedPointers;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
|
||||
/// whether Shift was held: where dropped files land.
|
||||
@@ -122,9 +122,18 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
// The old services asked the old context for frames.
|
||||
app.services = services;
|
||||
app.status("The graphics were lost and have been restarted");
|
||||
crate::api::connect(None, host.inbox.clone(), ctx.clone());
|
||||
app
|
||||
}
|
||||
None => VectorcraftApp::new(Session::new(), services),
|
||||
None => {
|
||||
// `window.vectorcraft`: the page's control channel (crate::api).
|
||||
let (control_tx, control_rx) = std::sync::mpsc::channel();
|
||||
crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone());
|
||||
if let Err(e) = crate::api::install() {
|
||||
log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
|
||||
}
|
||||
VectorcraftApp::new(Session::new(), services).with_control(control_rx)
|
||||
}
|
||||
};
|
||||
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
|
||||
}),
|
||||
@@ -386,6 +395,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
app.logic(ctx);
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -399,6 +409,7 @@ impl eframe::App for WebShell {
|
||||
if let Some(app) = &mut self.app {
|
||||
app.ui(ui);
|
||||
}
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +555,9 @@ impl RecoveryStore for BrowserStore {
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if crate::api::capture(path, bytes) {
|
||||
return Ok(());
|
||||
}
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -320,9 +320,14 @@ fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile {
|
||||
/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table.
|
||||
const A2B_GRID: usize = 9;
|
||||
const B2A_GRID: usize = 17;
|
||||
/// Grid points per Lab channel of the gamut table.
|
||||
const GAMUT_GRID: usize = 9;
|
||||
|
||||
/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a
|
||||
/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding).
|
||||
/// It carries every tag ICC.1 requires of an output profile: the tables for all three intents
|
||||
/// and the gamut tag (Windows rejects a CMYK profile without them, so Photos couldn't show a CMYK
|
||||
/// TIFF that embedded one, #2707).
|
||||
pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile {
|
||||
let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32;
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
|
||||
@@ -337,6 +342,13 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID));
|
||||
b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q));
|
||||
}
|
||||
// Gamut: 0 where a colour separates and comes back within a ΔE of 3, else out of gamut.
|
||||
let mut gamut = Vec::with_capacity(GAMUT_GRID.pow(3));
|
||||
for i in 0..GAMUT_GRID.pow(3) {
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / GAMUT_GRID.pow(d) % GAMUT_GRID, GAMUT_GRID));
|
||||
let want = lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0);
|
||||
gamut.push(if lab::delta_e2000(want, to_lab(from_lab(want))) <= 3.0 { 0 } else { u16::MAX });
|
||||
}
|
||||
let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n];
|
||||
let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec<u16>| {
|
||||
let mut grid_points = [0u8; 16];
|
||||
@@ -346,8 +358,9 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
num_output_channels: outputs as u8,
|
||||
grid_points,
|
||||
clut: Some(LutStore::Store16(clut)),
|
||||
// A curves sit on the device side, B curves on the PCS side.
|
||||
a_curves: identity(4),
|
||||
// A curves sit on the device side (the gamut tag's is its one output), B curves on the
|
||||
// PCS side.
|
||||
a_curves: identity(inputs + outputs - 3),
|
||||
b_curves: identity(3),
|
||||
m_curves: vec![],
|
||||
matrix: Matrix3d::IDENTITY,
|
||||
@@ -356,6 +369,7 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
};
|
||||
let a2b = table(4, 3, A2B_GRID, a2b);
|
||||
let b2a = table(3, 4, B2A_GRID, b2a);
|
||||
let gamut = table(3, 1, GAMUT_GRID, gamut);
|
||||
let mut p = ColorProfile::default();
|
||||
p.profile_class = ProfileClass::OutputDevice;
|
||||
p.color_space = DataColorSpace::Cmyk;
|
||||
@@ -364,9 +378,12 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
p.white_point = moxcms::WHITE_POINT_D50.to_xyzd();
|
||||
p.media_white_point = Some(p.white_point);
|
||||
p.lut_a_to_b_perceptual = Some(a2b.clone());
|
||||
p.lut_a_to_b_saturation = Some(a2b.clone());
|
||||
p.lut_a_to_b_colorimetric = Some(a2b);
|
||||
p.lut_b_to_a_perceptual = Some(b2a.clone());
|
||||
p.lut_b_to_a_saturation = Some(b2a.clone());
|
||||
p.lut_b_to_a_colorimetric = Some(b2a);
|
||||
p.gamut = Some(gamut);
|
||||
labelled(p, name)
|
||||
}
|
||||
|
||||
|
||||
@@ -135,3 +135,20 @@ fn written_profiles_align_their_tags() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #2707: the CMYK profiles written here carry every tag ICC.1 requires of an output profile (the
|
||||
/// tables of all three intents and the gamut tag): Windows rejects one without them, and Photos
|
||||
/// then can't show a CMYK TIFF that embeds it.
|
||||
#[test]
|
||||
fn cmyk_profiles_have_the_tags_of_an_output_profile() {
|
||||
for name in BUILTIN_CMYK {
|
||||
let icc = icc_bytes(name).unwrap();
|
||||
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
|
||||
let tags: Vec<&[u8]> = (0..be(128)).map(|i| &icc[132 + 12 * i..136 + 12 * i]).collect();
|
||||
for want in [b"desc", b"cprt", b"wtpt", b"A2B0", b"A2B1", b"A2B2", b"B2A0", b"B2A1", b"B2A2", b"gamt"] {
|
||||
assert!(tags.contains(&&want[..]), "{name}: no {}", String::from_utf8_lossy(want));
|
||||
}
|
||||
let p = parse(&icc);
|
||||
assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}");
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -274,7 +274,12 @@ impl Unit {
|
||||
pub fn named(name: &str) -> Option<Unit> {
|
||||
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || u.suffix().chars().eq(given()))
|
||||
// Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches
|
||||
// is left to its label and key.
|
||||
Unit::ALL
|
||||
.into_iter()
|
||||
.find(|u| letters(u.label()).eq(given()))
|
||||
.or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given())))
|
||||
}
|
||||
/// A point value as the canvas measurement labels show it: two decimals and the suffix
|
||||
/// (`12.50 mm`).
|
||||
@@ -801,6 +806,12 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
|
||||
/// isn't a number).
|
||||
pub fn raster_ppi(&self) -> f64 {
|
||||
Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
|
||||
}
|
||||
|
||||
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
|
||||
/// Rulers, coordinate fields and guide snapping use the same supported range.
|
||||
pub fn ruler_origin_or_zero(&self) -> Point {
|
||||
|
||||
@@ -98,6 +98,13 @@ fn units_are_named_by_label_key_or_suffix() {
|
||||
assert_eq!(Unit::named(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_suffix_names_the_plain_unit() {
|
||||
assert_eq!(Unit::named(Unit::Feet.suffix()), Some(Unit::Feet));
|
||||
assert_eq!(Unit::named("FT"), Some(Unit::Feet));
|
||||
assert_eq!(Unit::named(Unit::FeetInches.label()), Some(Unit::FeetInches));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readouts_show_two_decimals() {
|
||||
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
|
||||
|
||||
@@ -594,6 +594,45 @@ pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow a `w` × `h` plane by `radius` pixels each way, the box of SVG's feMorphology: each value
|
||||
/// becomes the largest within the radius. A fractional radius blends the two whole radii around
|
||||
/// it, so a silhouette grows smoothly as the raster scales. Does nothing for a radius of zero or
|
||||
/// less, or when the sizes don't match.
|
||||
pub fn dilate_plane(a: &mut Vec<f32>, w: usize, h: usize, radius: f64) {
|
||||
if radius.is_nan() || radius <= 0.0 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
|
||||
return;
|
||||
}
|
||||
let radius = radius.min(MAX_BOX as f64);
|
||||
let whole = radius.floor() as usize;
|
||||
let frac = (radius - whole as f64) as f32;
|
||||
let mut tmp = vec![0.0; a.len()];
|
||||
for (len, other) in [(w, h), (h, w)] {
|
||||
max_plane(a, &mut tmp, len, whole, frac);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
transpose(a, &mut tmp, len, other);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest
|
||||
/// value within `whole`, blended by `frac` towards the largest within `whole + 1`.
|
||||
fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) {
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
let lo = x.saturating_sub(whole + 1);
|
||||
let hi = (x + whole + 1).min(w - 1);
|
||||
let (mut inner, mut ring) = (row[x], row[x]);
|
||||
for (j, v) in row[lo..=hi].iter().enumerate() {
|
||||
if (lo + j).abs_diff(x) <= whole {
|
||||
inner = inner.max(*v);
|
||||
}
|
||||
ring = ring.max(*v);
|
||||
}
|
||||
*o = inner + (ring - inner) * frac;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
|
||||
pub(super) type Px16 = [u16; 4];
|
||||
|
||||
|
||||
@@ -41,6 +41,26 @@ fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dilate_plane_grows_by_its_radius_and_blends_a_fraction() {
|
||||
// GUARDS: an Outer Glow grows its silhouette by a quarter raster pixel before the blur; a step in
|
||||
// that growth as the raster scales would show as the glow's width jumping with the zoom.
|
||||
let mut dot = vec![0.0f32; 7 * 7];
|
||||
dot[3 * 7 + 3] = 255.0;
|
||||
let mut grown = dot.clone();
|
||||
dilate_plane(&mut grown, 7, 7, 1.0);
|
||||
assert_eq!(grown[2 * 7 + 2], 255.0);
|
||||
assert_eq!(grown[7 + 3], 0.0);
|
||||
let mut half = dot.clone();
|
||||
dilate_plane(&mut half, 7, 7, 1.5);
|
||||
assert_eq!(half[3 * 7 + 4], 255.0);
|
||||
assert_eq!(half[3 * 7 + 5], 127.5);
|
||||
assert_eq!(half[3 * 7 + 6], 0.0);
|
||||
let mut same = dot.clone();
|
||||
dilate_plane(&mut same, 7, 7, 0.0);
|
||||
assert_eq!(same, dot);
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ pub enum RasterFx {
|
||||
/// instead of `color`; `None` when it is `color`.
|
||||
darkness: Option<f32>,
|
||||
},
|
||||
/// Blurs the art's solid silhouette grown by a quarter of a pixel of the document's raster grid,
|
||||
/// as the glows AI files export measure.
|
||||
OuterGlow {
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
@@ -93,6 +95,10 @@ impl RasterFx {
|
||||
pub fn spread(&self, bounds: Rect, line: f64) -> Rect {
|
||||
match self {
|
||||
RasterFx::Rasterize(r) => r.image(bounds, line),
|
||||
RasterFx::OuterGlow { .. } => {
|
||||
let o = self.outset(bounds) + 0.25 * line;
|
||||
bounds.inflate(o, o)
|
||||
}
|
||||
fx => {
|
||||
let o = fx.outset(bounds);
|
||||
bounds.inflate(o, o)
|
||||
|
||||
+124
-12
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pencil",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
|
||||
has_doc,
|
||||
freehand
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor} remove one anchor, re-fitting the curve",
|
||||
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
|
||||
has_doc,
|
||||
remove_anchor
|
||||
),
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
"{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
has_doc,
|
||||
convert_anchor
|
||||
),
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Segment",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
"{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
has_doc,
|
||||
reshape_segment
|
||||
),
|
||||
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
has_doc,
|
||||
split
|
||||
),
|
||||
@@ -215,6 +215,15 @@ fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
|
||||
/// value is [`usize_req`]'s error.
|
||||
pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => usize_req(p, key, cmd).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
match &mut n.kind {
|
||||
@@ -548,6 +557,87 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
|
||||
}
|
||||
|
||||
/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of
|
||||
/// `pd` to another, each within `within` of it, replaces the section between them (the shorter
|
||||
/// way round a closed subpath) with the stroke fitted at `tol`. The subpath's index and its new
|
||||
/// shape; None when the stroke doesn't start and end on the same subpath, or meets it once.
|
||||
fn redraw_section(pd: &PathData, pts: &[Point], tol: f64, within: f64) -> Option<(usize, SubPath)> {
|
||||
use kurbo::ParamCurveArclen;
|
||||
let (first, last) = (*pts.first()?, *pts.last()?);
|
||||
let (si, _, _, _, d_a) = pd.nearest(first)?;
|
||||
let (sj, _, _, _, d_b) = pd.nearest(last)?;
|
||||
if si != sj || d_a > within || d_b > within || pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut sp = pd.subpaths.get(si)?.clone();
|
||||
if sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each
|
||||
// point is looked up on the subpath as it is by then: splitting a straight segment makes two
|
||||
// new ones, which a `t` along the old one doesn't measure.
|
||||
let place = |sp: &mut SubPath, q: Point| {
|
||||
let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?;
|
||||
let n = sp.anchors.len();
|
||||
Some(if t <= 1e-6 {
|
||||
(seg % n, false)
|
||||
} else if t >= 1.0 - 1e-6 {
|
||||
((seg + 1) % n, false)
|
||||
} else {
|
||||
(sp.insert_anchor(seg, t), true)
|
||||
})
|
||||
};
|
||||
let (mut from, _) = place(&mut sp, first)?;
|
||||
let (to, added) = place(&mut sp, last)?;
|
||||
if added && to <= from {
|
||||
from += 1;
|
||||
}
|
||||
if from == to {
|
||||
return None;
|
||||
}
|
||||
let n = sp.anchors.len();
|
||||
// The length of the run of segments from anchor i forward to anchor j.
|
||||
let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::<f64>();
|
||||
// The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from
|
||||
// `end` round to `start`, then the stroke's inner anchors follow.
|
||||
let (start, end, reversed) = if sp.closed {
|
||||
if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) }
|
||||
} else if from < to {
|
||||
(from, to, false)
|
||||
} else {
|
||||
(to, from, true)
|
||||
};
|
||||
let mut stroke = pts.to_vec();
|
||||
if reversed {
|
||||
stroke.reverse();
|
||||
}
|
||||
let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None };
|
||||
*s0 = s1.p;
|
||||
let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None };
|
||||
*e0 = e1.p;
|
||||
let fit = fit_freehand(&stroke, tol, false).anchors;
|
||||
let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None };
|
||||
let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default();
|
||||
let (before, after): (Vec<Anchor>, Vec<Anchor>) = if sp.closed {
|
||||
((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![])
|
||||
} else {
|
||||
(sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec())
|
||||
};
|
||||
let mut anchors = before;
|
||||
if let Some(a) = anchors.last_mut() {
|
||||
*a = Anchor::with_handles(a.p, a.h_in, f0.h_out);
|
||||
}
|
||||
anchors.extend_from_slice(inner);
|
||||
let at_end = anchors.len();
|
||||
anchors.extend(after);
|
||||
// The anchor the stroke ends on: the first after it, or round to the first of a closed one.
|
||||
let end_at = if sp.closed { 0 } else { at_end };
|
||||
if let Some(a) = anchors.get_mut(end_at) {
|
||||
*a = Anchor::with_handles(a.p, f1.h_in, a.h_out);
|
||||
}
|
||||
Some((si, SubPath::new(anchors, sp.closed)))
|
||||
}
|
||||
|
||||
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.freehand";
|
||||
let samples = samples_param(p, "points", C)?;
|
||||
@@ -630,6 +720,28 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
|
||||
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
|
||||
let within = f64_or(rd, "within", 12.0);
|
||||
if !within.is_finite() || within < 0.0 {
|
||||
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
|
||||
}
|
||||
let (pd, _) = path_of(&s.doc()?.doc, id)?;
|
||||
// shortcut: a stroke pressure profile is left as it was, measured along the old path; fit
|
||||
// it to the new length if Calligraphic pressure strokes get redrawn.
|
||||
if let Some((si, sp)) = redraw_section(&pd, &pts, tol, within) {
|
||||
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
|
||||
*path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp;
|
||||
if keep_selected {
|
||||
sel.set([id]);
|
||||
} else {
|
||||
sel.remove(id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return Err(bad(C, "need at least two points"));
|
||||
}
|
||||
@@ -692,7 +804,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
|
||||
const C: &str = "path.curvatureEdit";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
|
||||
(None, None) => None,
|
||||
@@ -756,7 +868,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.removeAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let removed = s.edit("Delete Anchor Point", |d, sel| {
|
||||
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
@@ -863,7 +975,7 @@ fn remove_anchors_of(
|
||||
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.convertAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let smooth = str_param(p, "to") == Some("smooth");
|
||||
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
@@ -898,7 +1010,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.reshapeSegment";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let seg = usize_req(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
@@ -923,9 +1035,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.split";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let anchor = usize_opt(p, "anchor", C)?;
|
||||
let seg = usize_opt(p, "segment", C)?;
|
||||
if anchor.is_none() && seg.is_none() {
|
||||
return Err(bad(C, "need `anchor` or `segment` + `t`"));
|
||||
}
|
||||
|
||||
@@ -481,6 +481,10 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
/// too ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]).
|
||||
/// 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 Targa file, which starts with no signature, is recognized by its header
|
||||
/// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores
|
||||
/// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels
|
||||
/// as RGBA, need more than [`MAX_RASTER_ALLOC`] is refused as too large to draw.
|
||||
/// 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> {
|
||||
@@ -493,6 +497,9 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
|
||||
}
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
|
||||
if reader.format().is_none() && super::tgaread::is_targa(bytes) {
|
||||
reader.set_format(image::ImageFormat::Tga);
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
@@ -511,13 +518,25 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
|
||||
};
|
||||
let img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
let mut img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
Some(img) => img,
|
||||
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
|
||||
Some(read) => read.map_err(err)?,
|
||||
None => reader.decode().map_err(err)?.to_rgba8(),
|
||||
None => {
|
||||
use image::ImageDecoder as _;
|
||||
let decoder = reader.into_decoder().map_err(err)?;
|
||||
// The allowance counts the decoded samples and the RGBA pixels they become (#1717).
|
||||
let (w, h) = decoder.dimensions();
|
||||
if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC {
|
||||
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
image::DynamicImage::from_decoder(decoder).map_err(err)?.to_rgba8()
|
||||
}
|
||||
},
|
||||
};
|
||||
if f.id == "tga" {
|
||||
super::tgaread::fix_alpha(bytes, &mut img);
|
||||
}
|
||||
as_png(img, ppi)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ mod save;
|
||||
mod screens;
|
||||
mod svg;
|
||||
mod text;
|
||||
mod tgaread;
|
||||
pub mod zip;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -206,7 +207,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"PDF Info",
|
||||
[],
|
||||
None,
|
||||
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}",
|
||||
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}; a PostScript file (an EPS, a .ps file, a .ai in PostScript form) → {pages: 1, needsPassword: false, pageInfo: [{width, height (its bounding box, a US Letter page without one), rotation: 0, boxes: {}}]} (password and cropTo don't apply; thumbnail: 1 shows what the page prints)",
|
||||
always,
|
||||
pdfimport::pdf_info
|
||||
),
|
||||
@@ -646,7 +647,16 @@ pub const FORMATS: &[Format] = &[
|
||||
},
|
||||
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
|
||||
Format {
|
||||
id: "tga",
|
||||
label: "Targa",
|
||||
extensions: &["tga", "vda", "icb", "vst"],
|
||||
mime: "image/x-tga",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: TGA_OPTIONS,
|
||||
},
|
||||
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS },
|
||||
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
|
||||
// vector app for a raster editor's documents.
|
||||
@@ -674,6 +684,10 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"tga",
|
||||
"vda",
|
||||
"icb",
|
||||
"vst",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
@@ -690,9 +704,9 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
|
||||
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
|
||||
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
|
||||
/// them, and the packaging declares no `.epsf` or `.ps` type (File › Open and Place still read
|
||||
/// them all).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps"];
|
||||
/// them, and the packaging declares no `.epsf`, `.ps` or Targa type (File › Open and Place still
|
||||
/// read them all).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst"];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
|
||||
@@ -733,6 +747,10 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"tga",
|
||||
"vda",
|
||||
"icb",
|
||||
"vst",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
@@ -1085,14 +1103,74 @@ pub(crate) fn absolute_path(path: &str) -> String {
|
||||
/// stored in, when it has one: a relative path is taken from its folder. Only files on this
|
||||
/// computer, or on the network share the document is on, are followed (see `craft_docpath`); a
|
||||
/// path that is still relative (no document) is refused, as the working directory isn't the
|
||||
/// document's choice. Every stored path reaches the file system through this.
|
||||
/// document's choice. On Windows, a document on a mapped drive is on the share behind the drive
|
||||
/// ([`share_path`]): a path refused as on another computer is checked again against the
|
||||
/// document's path on that share. Every stored path reaches the file system through this.
|
||||
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
document_path_with(stored, document, share_path)
|
||||
}
|
||||
|
||||
/// [`document_path`], with `share` giving a document's path on the network share behind its drive.
|
||||
pub(crate) fn document_path_with(
|
||||
stored: &str,
|
||||
document: Option<&str>,
|
||||
share: impl FnOnce(&str) -> Option<String>,
|
||||
) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
use craft_docpath::Refused;
|
||||
let document = document.map(absolute_path);
|
||||
let path = craft_docpath::resolve(stored, document.as_deref().map(std::path::Path::new))?;
|
||||
let resolve = |document: Option<&str>| craft_docpath::resolve(stored, document.map(std::path::Path::new));
|
||||
let path = match resolve(document.as_deref()) {
|
||||
Err(Refused::Network) => {
|
||||
let share = document.as_deref().and_then(share).ok_or(Refused::Network)?;
|
||||
resolve(Some(share.as_str()))?
|
||||
}
|
||||
path => path?,
|
||||
};
|
||||
if !path.is_absolute() {
|
||||
return Err(craft_docpath::Refused::Invalid);
|
||||
return Err(Refused::Invalid);
|
||||
}
|
||||
path.into_os_string().into_string().map_err(|_| craft_docpath::Refused::Invalid)
|
||||
path.into_os_string().into_string().map_err(|_| Refused::Invalid)
|
||||
}
|
||||
|
||||
/// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is
|
||||
/// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may
|
||||
/// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as
|
||||
/// `\\?\UNC\server\share\…`.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn share_path(document: &str) -> Option<String> {
|
||||
share_of(document, |folder| {
|
||||
may_read(folder).ok()?;
|
||||
std::fs::canonicalize(folder).ok()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) fn share_path(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The path of `document` on the network share its drive is mapped to, given `final_path`, which
|
||||
/// returns the final path of a folder: `None` when `document` is not on a drive (`C:\` or `C:/`)
|
||||
/// or `final_path` gives no share (`\\?\UNC\server\share\…`), such as a folder on a local volume.
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn share_of(document: &str, final_path: impl FnOnce(&str) -> Option<std::path::PathBuf>) -> Option<String> {
|
||||
let mut chars = document.chars();
|
||||
if !matches!((chars.next(), chars.next(), chars.next()), (Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
// The drive's root folder keeps its separator (`Z:\`).
|
||||
let split = document.rfind(['\\', '/'])?;
|
||||
let (folder, name) = (document.get(..split.max(3))?, document.get(split + 1..)?);
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let target = final_path(folder)?;
|
||||
let target = target.to_str()?;
|
||||
let unc = target.get(..8).filter(|p| p.eq_ignore_ascii_case(r"\\?\UNC\")).and(target.get(8..))?.trim_end_matches('\\');
|
||||
if unc.split('\\').filter(|c| !c.is_empty()).count() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(format!(r"\\{unc}\{name}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Opening and placing PDF (and PDF-compatible `.ai`/`.ait`) files: the options `document.open`
|
||||
//! and `file.place` read ([`LoadOptions`]), the pages they import and `document.pdfInfo` (pages,
|
||||
//! boxes, a thumbnail) for the Import PDF dialog.
|
||||
//! boxes, a thumbnail; one page for a PostScript file) for the Import PDF dialog.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ColorMode, Document};
|
||||
@@ -162,6 +162,10 @@ const INFO: &str = "document.pdfInfo";
|
||||
pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = source(p, INFO)?.bytes;
|
||||
let o = LoadOptions::from_params(INFO, p)?;
|
||||
// Programs whose header is only `%!` are PostScript too, as `clipboard.importEps` reads them.
|
||||
if bytes.starts_with(b"%!") || vectorcraft_pdf::is_postscript(&bytes) {
|
||||
return postscript_info(&bytes, p);
|
||||
}
|
||||
let info = match vectorcraft_pdf::info(&bytes, o.password.as_deref()) {
|
||||
Ok(i) => i,
|
||||
Err(e @ (PdfError::NeedsPassword | PdfError::WrongPassword)) => {
|
||||
@@ -183,10 +187,34 @@ pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let page = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).filter(|i| *i < info.pages.len());
|
||||
let page = page.ok_or_else(|| bad(INFO, format!("thumbnail: no page {n} (the PDF has {})", info.pages.len())))?;
|
||||
let (doc, _) = page_document(&bytes, page, &o)?;
|
||||
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
|
||||
let png = super::encode(&doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
|
||||
out["thumbnail"] = json!(vectorcraft_format::base64_encode(&png));
|
||||
out["thumbnail"] = json!(thumbnail(&doc, p)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `document.pdfInfo` of a PostScript file (an EPS, a `.ps` file, a `.ai` in PostScript form): one
|
||||
/// page, the page [`vectorcraft_eps::page`] reads, with no PDF boxes. Its thumbnail is the file as
|
||||
/// the EPS reader reads it without any editing data it carries: what the page prints, or the
|
||||
/// file's preview when its PostScript can't be read.
|
||||
fn postscript_info(bytes: &[u8], p: &Value) -> Result<Value> {
|
||||
let page = vectorcraft_eps::page(bytes).map_err(err)?;
|
||||
let info = json!({"width": page.width(), "height": page.height(), "rotation": 0, "boxes": {}});
|
||||
let mut out = json!({"pages": 1, "needsPassword": false, "pageInfo": [info]});
|
||||
if let Some(n) = p.get("thumbnail").and_then(Value::as_u64) {
|
||||
if n != 1 {
|
||||
return Err(bad(INFO, format!("thumbnail: no page {n} (the file has 1)")));
|
||||
}
|
||||
let doc = vectorcraft_eps::import_with(bytes, false).map_err(err)?.document;
|
||||
out["thumbnail"] = json!(thumbnail(&doc, p)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A PNG of `doc`'s first artboard, `thumbnailSize` px (default 160, from 16 to 1024) on its longer
|
||||
/// side, base64.
|
||||
fn thumbnail(doc: &Document, p: &Value) -> Result<String> {
|
||||
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
|
||||
let png = super::encode(doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
|
||||
Ok(vectorcraft_format::base64_encode(&png))
|
||||
}
|
||||
@@ -189,6 +189,22 @@ fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
|
||||
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
|
||||
}
|
||||
|
||||
/// A 16-bit RGB TIFF whose samples need more than the allowance (6 bytes a pixel), though its RGBA
|
||||
/// pixels would fit, is refused as too large to draw before it is decoded.
|
||||
#[test]
|
||||
fn tiffs_whose_samples_pass_the_allowance_are_too_large() {
|
||||
let plane = |v: u16| vec![v; 6];
|
||||
let mut file = hand_tiff(2, 16, &[], false, &[plane(9000), plane(30_000), plane(60_000)]);
|
||||
// The loop sets ImageWidth, ImageLength and RowsPerStrip (the first, second and eighth entries) to `side`.
|
||||
let side = 20_000u32;
|
||||
assert!(u64::from(side * side) * 6 > MAX_RASTER_ALLOC && u64::from(side * side) * 4 <= MAX_RASTER_ALLOC);
|
||||
for at in [18, 30, 102] {
|
||||
file[at..at + 4].copy_from_slice(&side.to_le_bytes());
|
||||
}
|
||||
let e = raster_image(&file).err().expect("refused");
|
||||
assert_eq!(e.to_string(), "an image of 20000 × 20000 pixels is too large to draw");
|
||||
}
|
||||
|
||||
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
|
||||
/// reads (#1466).
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! EPS and PostScript files opened and placed: our own EPS files restore the document they carry,
|
||||
//! others are read by the PostScript interpreter or come in as their preview.
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::tests_svgedit::{comparable, rich};
|
||||
@@ -93,6 +95,33 @@ fn cmyk_images_keep_their_inks_when_an_eps_opens_or_is_placed() {
|
||||
assert_eq!(images(&s), [want.clone(), want]);
|
||||
}
|
||||
|
||||
/// A 4-bit gray image compressed with Flate and TIFF predictor 2 draws the samples it had before
|
||||
/// differencing, in an EPS and in a PostScript `.ai`.
|
||||
#[test]
|
||||
fn tiff_predicted_4_bit_images_open_from_eps_and_postscript_ai_files() {
|
||||
// Two rows of four samples, 0 5 15 3 and 9 9 2 0, differenced to 0 5 10 4 and 9 0 9 14.
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
z.write_all(&[0x05, 0xa4, 0x90, 0x9e]).unwrap();
|
||||
let hex: String = z.finish().unwrap().iter().map(|b| format!("{b:02x}")).collect();
|
||||
let ps = format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n%%EndComments\n40 20 scale 4 2 4 [4 0 0 -2 0 2] currentfile /ASCIIHexDecode filter \
|
||||
<< /Predictor 2 /Colors 1 /BitsPerComponent 4 /Columns 4 >> /FlateDecode filter image\n{hex}>\nshowpage\n%%EOF\n"
|
||||
);
|
||||
for name in ["predicted.eps", "predicted.ai"] {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, name, ps.as_bytes()).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut grays = vec![];
|
||||
d.walk(|n| {
|
||||
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||
grays = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8().into_raw();
|
||||
}
|
||||
});
|
||||
assert_eq!(grays, [0, 5, 15, 3, 9, 9, 2, 0].map(|v| v * 17), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_eps_file_comes_in_as_its_preview() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -69,6 +69,33 @@ fn pages_changed_elsewhere_open_as_artwork_with_a_warning() {
|
||||
assert_eq!(r["warnings"][0], load::EDITING_DAMAGED, "{r}");
|
||||
}
|
||||
|
||||
/// `pdf` with its editing file's description rewritten by another app in UTF-16BE with a byte
|
||||
/// order mark: "hash " and the hash it held, padded with spaces to its length.
|
||||
fn utf16_described(pdf: &[u8]) -> Vec<u8> {
|
||||
let from = b"(VectorCraft editing data; page hash ";
|
||||
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description"));
|
||||
let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17);
|
||||
let mut desc = b"(\xFE\xFF".to_vec();
|
||||
desc.extend(b"hash ".iter().chain(hash).flat_map(|&c| [0, c]));
|
||||
desc.push(b')');
|
||||
desc.resize(len, b' ');
|
||||
patched(pdf, &pdf[at..at + len], &desc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_changed_elsewhere_are_noticed_with_a_utf16_description() {
|
||||
let mut s = rich();
|
||||
let pdf = b64(&s.execute("document.exportPdf", &json!({"compression": {"compressText": false}})).unwrap());
|
||||
let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap());
|
||||
for (name, file) in [("edited.pdf", pdf), ("edited.ai", ai)] {
|
||||
let r = open(&mut s, name, &utf16_described(&file));
|
||||
assert_eq!((&r["warnings"], &r["restored"]), (&json!([]), &json!(true)), "{name}: the same pages: {r}");
|
||||
let edited = patched(&file, b"/MediaBox[0 0 300 200]", b"/MediaBox[0 0 300 201]");
|
||||
let r = open(&mut s, name, &utf16_described(&edited));
|
||||
assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_STALE), &json!(false)), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legacy_payload_name_is_accepted() {
|
||||
let mut s = rich();
|
||||
|
||||
@@ -157,27 +157,31 @@ fn a_postscript_ai_file_says_it_cannot_be_opened_yet() {
|
||||
let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains(name) && e.contains("PostScript") && e.contains("save it as PDF"), "{name}: {e}");
|
||||
}
|
||||
// An EPS with a binary header too, and the PDF info of either.
|
||||
// An EPS with a binary header too, and the PDF info of each. The plain program's PDF info gives
|
||||
// its page, and its thumbnail gives the reader's error; the binary file's header points at no
|
||||
// PostScript, so its PDF info is an error.
|
||||
let mut binary = vec![0xC5, 0xD0, 0xD3, 0xC6];
|
||||
binary.extend_from_slice(&[0; 28]);
|
||||
binary.extend_from_slice(ps);
|
||||
assert!(s.execute("document.open", &json!({"name": "x.eps", "dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
|
||||
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string().contains("PostScript"));
|
||||
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap();
|
||||
assert_eq!((v["pages"].as_u64(), v["pageInfo"][0]["width"].as_f64()), (Some(1), Some(100.0)), "{v}");
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps), "thumbnail": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("frobnicate"), "{e}");
|
||||
assert!(s.active().is_none());
|
||||
}
|
||||
|
||||
/// PostScript given where a PDF is read (`document.pdfInfo`, `clipboard.importPdf`) is refused
|
||||
/// with an error that names File › Open and File › Place, and both of those read the file.
|
||||
/// PostScript given where a PDF is read (`clipboard.importPdf`) is refused with an error that
|
||||
/// names File › Open and File › Place, and both of those read the file.
|
||||
#[test]
|
||||
fn postscript_read_as_a_pdf_names_open_and_place() {
|
||||
let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor 10 10 50 50 rectfill\nshowpage\n%%EOF\n";
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for cmd in ["document.pdfInfo", "clipboard.importPdf"] {
|
||||
let e = s.execute(cmd, &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{cmd}: {e}");
|
||||
assert!(!e.contains("can't be opened"), "{cmd}: {e}");
|
||||
}
|
||||
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{e}");
|
||||
assert!(!e.contains("can't be opened"), "{e}");
|
||||
for name in ["legacy.ai", "art.eps"] {
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
|
||||
@@ -186,6 +190,44 @@ fn postscript_read_as_a_pdf_names_open_and_place() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `document.pdfInfo` reads PostScript (an EPS, a `.ai` in PostScript form, a `.ps` file) as one
|
||||
/// page, its bounding box or a letter page without one, and its thumbnail shows that page. Each
|
||||
/// file here opens on that page with `editingData: false`.
|
||||
#[test]
|
||||
fn pdf_info_reads_postscript_as_one_page() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let data = |r: Value| vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let eps = data(s.execute("document.exportEps", &json!({"useArtboards": true})).unwrap());
|
||||
let ai = data(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap());
|
||||
assert!(eps.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) && ai.starts_with(b"%!PS"));
|
||||
let hires = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 101 41\n%%HiResBoundingBox: 0 0 100.5 40.25\n%%EndComments\n0 0 1 setrgbcolor 0 0 50 20 rectfill\n";
|
||||
let letter = b"%!PS\n1 0 0 setrgbcolor 72 72 144 72 rectfill\nshowpage\n";
|
||||
let bare = b"%!\n0 0 1 setrgbcolor 100 100 50 50 rectfill\nshowpage\n";
|
||||
for (name, bytes, size) in [
|
||||
("art.eps", &eps[..], (300.0, 200.0)),
|
||||
("legacy.ai", &ai, (101.0, 51.0)),
|
||||
("hires.eps", hires, (100.5, 40.25)),
|
||||
("page.ps", letter, (612.0, 792.0)),
|
||||
("bare.ps", bare, (612.0, 792.0)),
|
||||
] {
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": 1, "thumbnailSize": 100})).unwrap();
|
||||
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!(img.width().max(img.height()), 100, "{name}");
|
||||
let mut v = v;
|
||||
v["thumbnail"] = json!("png");
|
||||
let page = json!({"width": size.0, "height": size.1, "rotation": 0, "boxes": {}});
|
||||
assert_eq!(v, json!({"pages": 1, "needsPassword": false, "pageInfo": [page], "thumbnail": "png"}), "{name}");
|
||||
s.execute("document.open", &json!({"name": name, "dataBase64": b64(bytes), "editingData": false})).unwrap();
|
||||
let board = s.doc().unwrap().doc.artboards[0].rect;
|
||||
assert_eq!((board.width(), board.height()), size, "{name}: the page document.open reads");
|
||||
}
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&eps), "thumbnail": 2})).unwrap_err().to_string();
|
||||
assert!(e.contains("no page 2"), "{e}");
|
||||
}
|
||||
|
||||
/// A page showing only text, with an editor's private data when `private`.
|
||||
fn text_page(private: bool, also: &str) -> PdfPage {
|
||||
PdfPage {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! TIFF, BMP and Targa export through `document.export`: the options reach the files, impossible
|
||||
//! ones are refused, and the files open again.
|
||||
//! ones are refused, and the files open again; Targa files open and place.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -135,6 +135,152 @@ fn targa_depth_reaches_the_file() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The RGBA pixels of the image `document.open` or `file.place` stored for the first object of the
|
||||
/// active document's first layer.
|
||||
fn stored_pixels(s: &Session) -> image::RgbaImage {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let im = match &doc.layers[0].children().unwrap()[0].kind {
|
||||
vectorcraft_doc::NodeKind::Image(im) => im.clone(),
|
||||
other => panic!("expected an image, got {other:?}"),
|
||||
};
|
||||
image::load_from_memory(&doc.images[&im.key].bytes).unwrap().to_rgba8()
|
||||
}
|
||||
|
||||
/// A Targa header: no image id or color map, image type `kind`, `w` × `h` pixels of `depth` bits,
|
||||
/// image descriptor `descriptor`.
|
||||
fn targa_header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec<u8> {
|
||||
let mut f = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
f.extend(w.to_le_bytes());
|
||||
f.extend(h.to_le_bytes());
|
||||
f.extend([depth, descriptor]);
|
||||
f
|
||||
}
|
||||
|
||||
/// Targa files open and place at their pixel size with the transparency they store, by any of the
|
||||
/// four Targa extensions; copied in a file manager they paste as a bitmap; the dialogs list them.
|
||||
#[test]
|
||||
fn targa_files_open_and_place() {
|
||||
use crate::cmd::clipboard::{PASTE_ORDER, file_flavour};
|
||||
let mut s = session();
|
||||
let b64 = vectorcraft_format::base64_encode;
|
||||
// VectorCraft's own exports: the transparent corner stays transparent at 16 and 32 bits (24
|
||||
// bits are flattened on white) and the black rectangle stays black.
|
||||
for depth in [16, 24, 32] {
|
||||
let tga = export(&mut s, json!({"format": "tga", "depth": depth}));
|
||||
let r = s.execute("document.open", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["format"], &r["warnings"]), (&json!("tga"), &json!([])), "{depth} bits: {r}");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(60.0, 40.0));
|
||||
let px = stored_pixels(&s);
|
||||
assert_eq!(px.dimensions(), (60, 40));
|
||||
let corner = if depth == 24 { [255; 4] } else { [0, 0, 0, 0] };
|
||||
assert_eq!(px.get_pixel(5, 5).0[3], corner[3], "{depth} bits: the corner's alpha");
|
||||
if depth == 24 {
|
||||
assert_eq!(px.get_pixel(5, 5).0, corner, "flattened on white");
|
||||
}
|
||||
let black = px.get_pixel(40, 20).0;
|
||||
assert!(black[3] == 255 && black[..3].iter().all(|c| *c < 80), "{depth} bits: {black:?}");
|
||||
// The next export starts from the session's document again.
|
||||
s = session();
|
||||
}
|
||||
let tga = export(&mut s, json!({"format": "tga", "depth": 32}));
|
||||
for name in ["art.vda", "ART.ICB", "art.vst"] {
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!(r["format"], "tga", "{name}: {r}");
|
||||
}
|
||||
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("tga"), Some(1)), "{r}");
|
||||
assert_eq!(stored_pixels(&s).get_pixel(5, 5).0[3], 0, "placed with its transparency");
|
||||
// Copied in a file manager, a Targa file pastes as a bitmap.
|
||||
assert_eq!(file_flavour("art.tga", &tga, &PASTE_ORDER), Some("image/x-tga"));
|
||||
let r = s.execute("clipboard.importImage", &json!({"mime": "image/x-tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["count"], &r["pixelWidth"], &r["pixelHeight"]), (&json!(1), &json!(60), &json!(40)), "{r}");
|
||||
// The dialogs list the four extensions; the desktop packaging doesn't associate them.
|
||||
for e in ["tga", "vda", "icb", "vst"] {
|
||||
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
|
||||
}
|
||||
let targa = ("Targa", &["tga", "vda", "icb", "vst"][..]);
|
||||
assert!(open_filters().any(|f| f == targa) && place_filters().any(|f| f == targa));
|
||||
}
|
||||
|
||||
/// Targa pixels in every layout: run-length encoded rows stored top down (as the `image` crate
|
||||
/// writes them), a 16-bit file whose one attribute bit per pixel is its alpha, with runs, stored
|
||||
/// right to left and top down, 32-bit files with no attribute bits (opaque), with eight, and with
|
||||
/// each Attributes Type in an extension area, and color-mapped files whose 16-bit entries carry the
|
||||
/// attribute bit.
|
||||
#[test]
|
||||
fn targa_layouts_and_alpha_read() {
|
||||
let src = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 200, 50, if x == 1 { 128 } else { 255 }]));
|
||||
let mut rle = Vec::new();
|
||||
src.write_to(&mut std::io::Cursor::new(&mut rle), image::ImageFormat::Tga).unwrap();
|
||||
assert_eq!((rle[2], rle[17] & 0x20), (10, 0x20), "run-length encoded true color, stored top down");
|
||||
assert_eq!(raster_image(&rle).map(|r| image::load_from_memory(&r.blob.bytes).unwrap().to_rgba8()).unwrap(), src);
|
||||
|
||||
// 3 × 2, 5-5-5 color and an attribute bit, right to left (bit 4), top down (bit 5): a raw
|
||||
// packet of red, blue and a clear pixel for the first row, then a run of green.
|
||||
let mut f = targa_header(10, 3, 2, 16, 0x31);
|
||||
f.push(0x02);
|
||||
f.extend([0xfc00u16, 0x801f, 0x0000].iter().flat_map(|v| v.to_le_bytes()));
|
||||
f.push(0x82);
|
||||
f.extend(0x83e0u16.to_le_bytes());
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
let px = |x, y| img.get_pixel(x, y).0;
|
||||
assert_eq!((px(2, 0), px(1, 0), px(0, 0)[3]), ([255, 0, 0, 255], [0, 0, 255, 255], 0), "the first row, right to left");
|
||||
assert!((0..3).all(|x| px(x, 1) == [0, 255, 0, 255]), "the run");
|
||||
|
||||
// 2 × 1 at 32 bits with no attribute bits: the fourth byte isn't alpha, whatever it holds.
|
||||
for fourth in [[0, 0], [0, 255]] {
|
||||
let mut f = targa_header(2, 2, 1, 32, 0x20);
|
||||
f.extend([10, 20, 30, fourth[0], 40, 50, 60, fourth[1]]);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([30, 20, 10, 255], [60, 50, 40, 255]), "{fourth:?}");
|
||||
}
|
||||
// The same with eight attribute bits: the fourth byte is alpha, also when it is 0 everywhere.
|
||||
let mut f = targa_header(2, 2, 1, 32, 0x28);
|
||||
f.extend([10, 20, 30, 0, 40, 50, 60, 0]);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert!(img.pixels().all(|p| p.0[3] == 0));
|
||||
// The extension area's Attributes Type: undefined data (1, 2) isn't alpha, premultiplied colors
|
||||
// (4) are divided by their alpha, and alpha (3) or 0, the field left unset, keeps it.
|
||||
for (kind, want) in [(0, [50, 50, 50, 128]), (1, [50, 50, 50, 255]), (2, [50, 50, 50, 255]), (3, [50, 50, 50, 128]), (4, [100, 100, 100, 128])] {
|
||||
let mut f = targa_header(2, 1, 1, 32, 0x28);
|
||||
f.extend([50, 50, 50, 128]);
|
||||
let mut area = vec![0; 495];
|
||||
area[..2].copy_from_slice(&495u16.to_le_bytes());
|
||||
area[494] = kind;
|
||||
let at = f.len() as u32;
|
||||
f.extend(area);
|
||||
f.extend(at.to_le_bytes());
|
||||
f.extend([0; 4]);
|
||||
f.extend(b"TRUEVISION-XFILE.\0");
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert_eq!(img.get_pixel(0, 0).0, want, "Attributes Type {kind}");
|
||||
}
|
||||
|
||||
// Color-mapped, one attribute bit, top down: 16-bit entries 5 (clear black) and 6 (red), as a
|
||||
// raw row of 8-bit indices and as runs.
|
||||
for (kind, data) in [(1, vec![6, 5, 6]), (9, vec![0x80, 6, 0x00, 5, 0x80, 6])] {
|
||||
let mut f = vec![0, 1, kind, 5, 0, 2, 0, 16, 0, 0, 0, 0, 3, 0, 1, 0, 8, 0x21];
|
||||
f.extend([0x0000u16, 0xfc00].iter().flat_map(|v| v.to_le_bytes()));
|
||||
f.extend(data);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
let row: Vec<[u8; 4]> = (0..3).map(|x| img.get_pixel(x, 0).0).collect();
|
||||
assert_eq!(row, [[255, 0, 0, 255], [0, 0, 0, 0], [255, 0, 0, 255]], "image type {kind}");
|
||||
}
|
||||
|
||||
// A grayscale image the decoder could hold whose RGBA pixels pass the allowance is refused from
|
||||
// its header.
|
||||
let huge = targa_header(11, 32768, 32768, 8, 0);
|
||||
let e = raster_image(&huge).err().expect("refused");
|
||||
assert_eq!(e.to_string(), "an image of 32768 × 32768 pixels is too large to draw");
|
||||
|
||||
// Headers with values the specification doesn't define aren't Targa files.
|
||||
for bad in [targa_header(2, 0, 1, 24, 0), targa_header(1, 1, 1, 8, 0), targa_header(2, 1, 1, 12, 0), targa_header(2, 1, 1, 24, 0xc0)] {
|
||||
assert!(!super::tgaread::is_targa(&bad), "{bad:?}");
|
||||
}
|
||||
assert!(raster_image(b"nothing an image reader knows").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formats_are_listed_and_write_one_file_per_artboard() {
|
||||
let mut s = Session::new();
|
||||
@@ -143,7 +289,7 @@ fn the_formats_are_listed_and_write_one_file_per_artboard() {
|
||||
let find = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone();
|
||||
let (tiff, bmp, tga) = (find("tiff"), find("bmp"), find("tga"));
|
||||
assert_eq!((tiff["write"].as_bool(), tiff["read"].as_bool()), (Some(true), Some(true)));
|
||||
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(false)));
|
||||
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(true)));
|
||||
for (f, keys) in [
|
||||
(&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..]),
|
||||
(&bmp, &["colorModel", "depth", "fileFormat", "rle", "flipRows", "reduction", "dither"]),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Reading Targa files (`.tga`, `.vda`, `.icb`, `.vst`) for File › Open and File › Place, from the
|
||||
//! Truevision TGA File Format Specification, version 2.0. The `image` crate decodes the pixels.
|
||||
//! A Targa file starts with no signature, so [`is_targa`] checks its header's fields instead, and
|
||||
//! [`fix_alpha`] gives the decoded image the transparency the file stores. The file is untrusted:
|
||||
//! every read is checked.
|
||||
|
||||
/// Does `bytes` start with a Targa header? The fields must hold values the specification defines:
|
||||
/// a color map type of 0 or 1; an image type of 1, 2, 3, 9, 10 or 11 (color-mapped, true-color or
|
||||
/// black-and-white, raw or run-length encoded), with a color map for the color-mapped types; a
|
||||
/// color map entry size of 15, 16, 24 or 32 bits when there is a map; a width and a height; a pixel
|
||||
/// depth of 8, 15, 16, 24 or 32 bits; and bits 6 and 7 of the image descriptor clear.
|
||||
pub(super) fn is_targa(bytes: &[u8]) -> bool {
|
||||
let [_, map_type, image_type, _, _, _, _, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
|
||||
return false;
|
||||
};
|
||||
matches!(map_type, 0 | 1)
|
||||
&& matches!(image_type, 1 | 2 | 3 | 9 | 10 | 11)
|
||||
&& (map_type == 1 || !matches!(image_type, 1 | 9))
|
||||
&& (map_type == 0 || matches!(entry_bits, 15 | 16 | 24 | 32))
|
||||
&& u16::from_le_bytes([w0, w1]) > 0
|
||||
&& u16::from_le_bytes([h0, h1]) > 0
|
||||
&& matches!(depth, 8 | 15 | 16 | 24 | 32)
|
||||
&& descriptor & 0xc0 == 0
|
||||
}
|
||||
|
||||
/// Give `img`, decoded from Targa file `bytes`, the transparency the file stores. Bits 0 to 3 of
|
||||
/// the image descriptor give the number of attribute bits per pixel, and the extension area's
|
||||
/// Attributes Type, when the file has one, says what they hold:
|
||||
/// - with no attribute bits, or attribute data the Attributes Type calls undefined (1 or 2), the
|
||||
/// image is opaque, whatever a 32-bit pixel's fourth byte holds (the decoder reads it as alpha);
|
||||
/// - with one bit, a 16-bit true-color pixel takes that bit as its alpha (clear: transparent), and
|
||||
/// a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; the decoder drops
|
||||
/// the bit;
|
||||
/// - with more, the decoder's alpha stays, and colors the Attributes Type calls premultiplied (4)
|
||||
/// are divided by it.
|
||||
///
|
||||
/// An Attributes Type of 0 ("no alpha") with attribute bits in the descriptor counts as alpha.
|
||||
pub(super) fn fix_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
|
||||
let Some(bits) = bytes.get(17).map(|d| d & 0x0f) else { return };
|
||||
let kind = attributes_type(bytes);
|
||||
if bits == 0 || matches!(kind, Some(1 | 2)) {
|
||||
img.pixels_mut().for_each(|p| p.0[3] = 255);
|
||||
return;
|
||||
}
|
||||
if bits == 1 {
|
||||
one_bit_alpha(bytes, img);
|
||||
}
|
||||
if kind == Some(4) {
|
||||
for p in img.pixels_mut() {
|
||||
let a = u16::from(p.0[3]);
|
||||
for c in &mut p.0[..3] {
|
||||
// A fully transparent pixel keeps its colors.
|
||||
if let Some(v) = (u16::from(*c) * 255 + a / 2).checked_div(a) {
|
||||
*c = v.min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Attributes Type of a Targa 2.0 file's extension area: `None` without the footer's
|
||||
/// `TRUEVISION-XFILE.` signature, an extension area, or one of the 495 bytes the specification
|
||||
/// gives it.
|
||||
fn attributes_type(bytes: &[u8]) -> Option<u8> {
|
||||
let footer = bytes.len().checked_sub(26)?;
|
||||
if bytes.get(footer + 8..)? != b"TRUEVISION-XFILE.\0" {
|
||||
return None;
|
||||
}
|
||||
let at = usize::try_from(u32::from_le_bytes(bytes.get(footer..footer + 4)?.try_into().ok()?)).ok()?;
|
||||
// The area follows the header and ends before the footer; its first field is its size.
|
||||
if at < 18 || at.checked_add(495)? > footer || u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?]) != 495 {
|
||||
return None;
|
||||
}
|
||||
bytes.get(at + 494).copied()
|
||||
}
|
||||
|
||||
/// The attribute bit of each pixel of a Targa image with one attribute bit per pixel, as `img`'s
|
||||
/// alpha: the top bit of a 16-bit true-color pixel, or of the 16-bit color map entry an 8- or
|
||||
/// 16-bit index names, raw or run-length encoded. Rows and columns are stored in the order the
|
||||
/// image descriptor's bits 5 (top to bottom) and 4 (right to left) give. Other images, and pixels
|
||||
/// the data doesn't reach, keep the alpha they have.
|
||||
fn one_bit_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
|
||||
let [id_len, map_type, image_type, o0, o1, m0, m1, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = (u32::from(u16::from_le_bytes([w0, w1])), u32::from(u16::from_le_bytes([h0, h1])));
|
||||
let mapped = matches!(image_type, 1 | 9);
|
||||
// The number of bytes each pixel takes.
|
||||
let size = match image_type {
|
||||
2 | 10 if depth == 16 => 2,
|
||||
1 | 9 if map_type == 1 && entry_bits == 16 && matches!(depth, 8 | 16) => usize::from(depth / 8),
|
||||
_ => return,
|
||||
};
|
||||
if img.dimensions() != (w, h) {
|
||||
return;
|
||||
}
|
||||
let (first, entries) = (u16::from_le_bytes([o0, o1]), u16::from_le_bytes([m0, m1]));
|
||||
let map_at = 18 + usize::from(id_len);
|
||||
let map_len = if map_type == 1 { usize::from(entries) * usize::from(entry_bits).div_ceil(8) } else { 0 };
|
||||
// The attribute bit is the top bit of the little-endian pixel or color map entry.
|
||||
let attribute = |at: usize| -> Option<bool> {
|
||||
let px = bytes.get(at..at.checked_add(size)?)?;
|
||||
let high = if mapped {
|
||||
let index = match *px {
|
||||
[i] => u16::from(i),
|
||||
[lo, hi] => u16::from_le_bytes([lo, hi]),
|
||||
_ => return None,
|
||||
};
|
||||
let entry = index.checked_sub(first).filter(|e| *e < entries)?;
|
||||
*bytes.get(map_at + usize::from(entry) * 2 + 1)?
|
||||
} else {
|
||||
*px.get(1)?
|
||||
};
|
||||
Some(high & 0x80 != 0)
|
||||
};
|
||||
let (top_down, right_to_left) = (descriptor & 0x20 != 0, descriptor & 0x10 != 0);
|
||||
let total = u64::from(w) * u64::from(h);
|
||||
let mut set = |i: u64, opaque: bool| {
|
||||
let (row, col) = ((i / u64::from(w)) as u32, (i % u64::from(w)) as u32);
|
||||
let y = if top_down { row } else { h - 1 - row };
|
||||
let x = if right_to_left { w - 1 - col } else { col };
|
||||
img.get_pixel_mut(x, y).0[3] = if opaque { 255 } else { 0 };
|
||||
};
|
||||
let mut at = map_at + map_len;
|
||||
let mut i = 0;
|
||||
while i < total {
|
||||
if image_type < 9 {
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
set(i, opaque);
|
||||
(i, at) = (i + 1, at + size);
|
||||
continue;
|
||||
}
|
||||
let Some(&packet) = bytes.get(at) else { return };
|
||||
let count = u64::from(packet & 0x7f) + 1;
|
||||
at += 1;
|
||||
if packet & 0x80 != 0 {
|
||||
// A run packet repeats one pixel.
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
at += size;
|
||||
for _ in 0..count.min(total - i) {
|
||||
set(i, opaque);
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
for _ in 0..count.min(total - i) {
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
set(i, opaque);
|
||||
(i, at) = (i + 1, at + size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,6 +341,7 @@ pub(crate) fn transform_each_again(s: &mut Session, p: &Value) -> Result<Value>
|
||||
/// repeats; a dialog's preview records it as it commits (`Session::commit_interaction`).
|
||||
fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
check_numbers(p, &["scaleH", "scaleV", "moveH", "moveV", "rotate"], "object.transformEach")?;
|
||||
// Bounded so coordinates stay finite (and the document stays serializable).
|
||||
let sh = f64_or(p, "scaleH", 100.0).clamp(-100_000.0, 100_000.0);
|
||||
let sv = f64_or(p, "scaleV", 100.0).clamp(-100_000.0, 100_000.0);
|
||||
@@ -480,6 +481,7 @@ impl Rasterized {
|
||||
/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws
|
||||
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels.
|
||||
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str) -> Result<Rasterized> {
|
||||
check_numbers(p, &["ppi"], cmd)?;
|
||||
let ppi = f64_or(p, "ppi", doc.raster_effects_ppi);
|
||||
if !(1.0..=2400.0).contains(&ppi) {
|
||||
return Err(bad(cmd, "ppi must be between 1 and 2400"));
|
||||
@@ -600,6 +602,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
|
||||
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
|
||||
let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||||
check_numbers(p, &["width", "height", "spacingX", "spacingY"], C)?;
|
||||
let (nw, nh) = (f64_or(p, "width", b.width()).max(0.1), f64_or(p, "height", b.height()).max(0.1));
|
||||
let resize = Affine::translate(b.origin().to_vec2())
|
||||
* Affine::scale_non_uniform(nw / b.width().max(1e-9), nh / b.height().max(1e-9))
|
||||
@@ -671,8 +674,8 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return trim_image(s, id, (**im).clone());
|
||||
}
|
||||
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||||
let rect = match p.get("rect").and_then(Value::as_array) {
|
||||
Some(a) if a.len() == 4 => {
|
||||
let rect = match p.get("rect").filter(|v| !v.is_null()) {
|
||||
Some(Value::Array(a)) if a.len() == 4 => {
|
||||
let v: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
||||
if v.len() != 4 {
|
||||
return Err(bad(C, "rect must be [x, y, width, height]"));
|
||||
@@ -832,6 +835,7 @@ impl Session {
|
||||
|
||||
fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.createTrimMarks";
|
||||
check_numbers(p, &["offset", "length", "bleed", "weight"], C)?;
|
||||
let style = match str_param(p, "style") {
|
||||
Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
|
||||
None => s.crop_mark_style(),
|
||||
@@ -1001,6 +1005,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
L::Row => (1, n_ab),
|
||||
L::Column => (n_ab, 1),
|
||||
};
|
||||
check_numbers(p, &["spacing"], CMD)?;
|
||||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||||
let move_art = bool_or(p, "moveArtwork", true);
|
||||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||||
|
||||
@@ -7,6 +7,7 @@ use vectorcraft_doc::{NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
use super::draw2::usize_opt;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -37,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Direction Handle",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
|
||||
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
|
||||
has_doc,
|
||||
set_handle
|
||||
),
|
||||
@@ -100,11 +101,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape",
|
||||
[],
|
||||
None,
|
||||
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius (default: half the subpath's size)",
|
||||
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius, measured along the path (default: half the subpath's size)",
|
||||
has_doc,
|
||||
reshape
|
||||
),
|
||||
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor),
|
||||
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath?, segment, t: 0..1}", has_doc, insert_anchor),
|
||||
cmd!(
|
||||
"path.cutAtAnchors",
|
||||
"Cut Path at Selected Anchor Points",
|
||||
@@ -215,7 +216,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
|
||||
|
||||
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize;
|
||||
let out = str_param(p, "which") != Some("in");
|
||||
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
|
||||
@@ -680,18 +681,27 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some((i, dist)) if dist <= tol => i,
|
||||
_ => sp.insert_anchor(seg, t),
|
||||
};
|
||||
let origin = sp.anchors[grabbed].p;
|
||||
// Each anchor's distance along the path from the grabbed one, the shorter way round a
|
||||
// closed path: the lengths are measured once, as a path can have many anchors.
|
||||
let pos = sp.anchor_arc_positions();
|
||||
let total = pos.last().copied().unwrap_or(0.0);
|
||||
// The default reach is the subpath's size, as before, now measured along it.
|
||||
let r = radius.unwrap_or_else(|| {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
let b = vectorcraft_geom::Shape::bounding_box(&bp);
|
||||
(b.width().hypot(b.height()) / 2.0).max(1.0)
|
||||
});
|
||||
let from = pos.get(grabbed).copied().unwrap_or(0.0);
|
||||
let closed = sp.closed;
|
||||
for (i, a) in sp.anchors.iter_mut().enumerate() {
|
||||
let w = if i == grabbed {
|
||||
1.0
|
||||
} else {
|
||||
let q = (a.p.distance(origin) / r).min(1.0);
|
||||
let d = (pos.get(i).copied().unwrap_or(f64::INFINITY) - from).abs();
|
||||
let d = if closed { d.min(total - d) } else { d };
|
||||
// A distance or radius that isn't finite gives NaN here, which `min` reads as 1: no move.
|
||||
let q = (d / r).min(1.0);
|
||||
(1.0 - q * q).powi(2)
|
||||
};
|
||||
let v = delta * w;
|
||||
@@ -706,7 +716,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
|
||||
let idx = s.edit("Add Anchor Point", |d, _| {
|
||||
|
||||
@@ -89,6 +89,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
anchors_many
|
||||
),
|
||||
cmd!(
|
||||
"select.color",
|
||||
"Select by Color",
|
||||
[],
|
||||
None,
|
||||
"{fill?: colour, stroke?: colour (as paint.setFill's color; at least one)} select the objects whose fill (and stroke) reads as that colour in #rrggbb, as document.inspect reports colours, whatever its swatch or tint; with both, the objects that match both. No object needs to be selected first; Select > Reselect repeats it → {count}",
|
||||
has_doc,
|
||||
select_color
|
||||
),
|
||||
cmd!(
|
||||
"select.same.fillColor",
|
||||
"Fill Color",
|
||||
@@ -528,6 +537,29 @@ fn stroke_weight(n: &Node) -> f64 {
|
||||
if paint.is_none() { 0.0 } else { width }
|
||||
}
|
||||
|
||||
/// Select by Color: the objects whose fill and stroke read as the colours given, compared as the
|
||||
/// `#rrggbb` `document.inspect` reports, so a script can pick art by colour without an object id.
|
||||
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.color";
|
||||
let hex = |key: &str| match p.get(key).filter(|v| !v.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(v) => color_value(v).map(|c| Some(c.to_hex())).ok_or_else(|| bad(C, format!("`{key}`: not a colour: {v}"))),
|
||||
};
|
||||
let (fill, stroke) = (hex("fill")?, hex("stroke")?);
|
||||
if fill.is_none() && stroke.is_none() {
|
||||
return Err(bad(C, "give `fill`, `stroke` or both"));
|
||||
}
|
||||
let reads_as = |paint: Paint, hex: Option<&str>| hex.is_none_or(|h| paint.color().is_some_and(|c| c.to_hex() == h));
|
||||
select_where(s, C, p, |d, n| paint_reported(d, n) && reads_as(fill_of(n), fill.as_deref()) && reads_as(stroke_of(n).0, stroke.as_deref()))
|
||||
}
|
||||
|
||||
/// Does `document.inspect` report a fill and stroke for `n`? A compound path's are its own, though
|
||||
/// it holds its members, and its members report none.
|
||||
fn paint_reported(d: &Document, n: &Node) -> bool {
|
||||
let compound = |n: &Node| matches!(n.kind, NodeKind::Compound { .. });
|
||||
compound(n) || (!n.is_container() && !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(compound))
|
||||
}
|
||||
|
||||
fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
|
||||
@@ -87,10 +87,11 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!(s.menu.wand))
|
||||
}
|
||||
|
||||
/// The fill an object shows: type's comes from its characters (the first run speaks for all).
|
||||
/// The fill an object shows: type's comes from its characters (the first run speaks for all; type
|
||||
/// without any, the default character fill, as `document.inspect` reports it).
|
||||
pub(crate) fn fill_of(n: &Node) -> Paint {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_default(),
|
||||
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_else(|| vectorcraft_doc::CharStyle::default().fill),
|
||||
_ => n.appearance.fill_paint(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2343,6 +2343,29 @@ fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
|
||||
assert!(pts.iter().filter(|p| (p.y - 100.0).abs() < 1e-9).count() == 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_falloff_follows_arc_length_on_open_paths() {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute(
|
||||
"path.create",
|
||||
&json!({
|
||||
"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 200, "y": 0}, {"x": 300, "y": 0}],
|
||||
"closed": false
|
||||
}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
|
||||
let pts: Vec<_> = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert!((pts[3].y - 100.0).abs() < 1e-6, "grabbed end moves full delta: {pts:?}");
|
||||
assert!(pts[0].y.abs() < 1e-6, "far end stays put: {pts:?}");
|
||||
assert!(pts[2].y > 1.0 && pts[2].y < 100.0, "mid-anchor moves partially: {pts:?}");
|
||||
assert!(pts[1].y < pts[2].y, "nearer anchors move more: {pts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_tool_drag_creates_a_graph_and_asks_for_data() {
|
||||
let mut s = session();
|
||||
@@ -2429,6 +2452,29 @@ fn object_mosaic_refuses_columns_and_rows_that_are_not_whole_numbers() {
|
||||
assert_eq!(r["tiles"], json!(2));
|
||||
}
|
||||
|
||||
/// A new width or height, or a spacing, given as text or as another value that is not a number is an error naming the
|
||||
/// key, and no mosaic is made.
|
||||
#[test]
|
||||
fn object_mosaic_refuses_a_size_or_spacing_given_as_text() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 50.0);
|
||||
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
|
||||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let before = undo(&s);
|
||||
for (p, msg) in [
|
||||
(json!({"width": "200"}), "`width` must be a number, not \"200\""),
|
||||
(json!({"columns": 2, "height": "50 pt"}), "`height` must be a number, not \"50 pt\""),
|
||||
(json!({"spacingX": "4"}), "`spacingX` must be a number, not \"4\""),
|
||||
(json!({"spacingY": [4]}), "`spacingY` must be a number, not [4]"),
|
||||
] {
|
||||
let r = s.execute("object.createObjectMosaic", &p);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
|
||||
assert_eq!(undo(&s), before, "{p}");
|
||||
}
|
||||
let r = s.execute("object.createObjectMosaic", &json!({"columns": 2, "rows": 1, "width": 200, "spacingX": null})).unwrap();
|
||||
assert_eq!(r["tiles"], json!(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_to_pixel_rounds_drawing_and_moves() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -130,6 +130,62 @@ fn pencil_continues_selected_open_path() {
|
||||
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 100.0));
|
||||
}
|
||||
|
||||
/// Edit Selected Paths in the middle of a path (#2850): a Pencil stroke that starts and ends on a
|
||||
/// selected path replaces the section between, drawn either way; the path stays one path with
|
||||
/// its ends, and is one undo step.
|
||||
#[test]
|
||||
fn pencil_redraws_the_section_of_a_selected_path_between_its_ends() {
|
||||
let bump = [(100.0, 101.0), (125.0, 70.0), (150.0, 60.0), (175.0, 70.0), (200.0, 101.0)];
|
||||
for reversed in [false, true] {
|
||||
let mut s = session();
|
||||
let id = line(&mut s, 0.0, 100.0, 300.0, 100.0);
|
||||
let mut pts = bump.to_vec();
|
||||
if reversed {
|
||||
pts.reverse();
|
||||
}
|
||||
let undo = undo_steps(&s);
|
||||
drag(&mut s, "pencil", &pts, Mods::default());
|
||||
assert_eq!(paths(&s).len(), 1, "no new object");
|
||||
assert_eq!(undo_steps(&s), undo + 1);
|
||||
let sp = &path(&s, id).subpaths[0];
|
||||
assert!(!sp.closed);
|
||||
let ps: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
|
||||
assert!(near(ps[0], Point::new(0.0, 100.0)) && near(*ps.last().unwrap(), Point::new(300.0, 100.0)), "{ps:?}");
|
||||
// The section starts and ends on the line, where the stroke met it, and rises between.
|
||||
let on = |x: f64| ps.iter().any(|p| (p.x - x).abs() < 1.5 && (p.y - 100.0).abs() < 1e-6);
|
||||
assert!(on(100.0) && on(200.0), "{reversed}: {ps:?}");
|
||||
let top = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath()).y0;
|
||||
assert!(top < 75.0, "the bump: top {top}");
|
||||
assert!(ps.windows(2).all(|w| w[0].x <= w[1].x + 1.0), "in order along the line: {ps:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// On a closed path the stroke replaces the shorter way between where it starts and ends, and the
|
||||
/// path stays closed; a stroke that doesn't end on the path draws a new one.
|
||||
#[test]
|
||||
fn pencil_redraws_the_shorter_way_round_a_closed_path() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 200.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("path.freehand", &json!({"points": [[50, 1], [80, -25], [100, -30], [120, -25], [150, 1]], "redraw": {"id": id.0, "within": 4}}))
|
||||
.unwrap();
|
||||
assert_eq!(paths(&s).len(), 1);
|
||||
let sp = &path(&s, id).subpaths[0];
|
||||
assert!(sp.closed);
|
||||
let ps: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
|
||||
for corner in [(0.0, 0.0), (200.0, 0.0), (200.0, 100.0), (0.0, 100.0)] {
|
||||
assert!(ps.iter().any(|p| near(*p, Point::new(corner.0, corner.1))), "{corner:?} kept: {ps:?}");
|
||||
}
|
||||
let top = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath()).y0;
|
||||
assert!(top < -20.0, "the dip: top {top}");
|
||||
// Not ending on the path: a new path, the rectangle as it was.
|
||||
let before = path(&s, id);
|
||||
s.execute("path.freehand", &json!({"points": [[50, 100], [80, 150], [120, 160]], "redraw": {"id": id.0, "within": 4}})).unwrap();
|
||||
assert_eq!(paths(&s).len(), 2);
|
||||
assert_eq!(path(&s, id), before);
|
||||
assert!(s.execute("path.freehand", &json!({"points": [[0, 0], [1, 1]], "redraw": {"id": id.0, "within": -1}})).is_err());
|
||||
}
|
||||
|
||||
/// With Keep selected off, the path a stroke draws or continues is not selected afterwards, the
|
||||
/// rest of the selection stays, and the stroke is one undo step.
|
||||
#[test]
|
||||
@@ -811,6 +867,131 @@ fn draw2_commands_reject_bad_params() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A path of two open subpaths, each of three anchors from x 100 to x 300 with a middle anchor that
|
||||
/// has two handles: subpath 0 at y 100, subpath 1 at y 300.
|
||||
fn two_subpaths(s: &mut Session) -> NodeId {
|
||||
let id = curve(s);
|
||||
let sub = |y: f64| {
|
||||
let anchors = json!([
|
||||
{"x": 100, "y": y, "out": [130, y - 30.0]},
|
||||
{"x": 200, "y": y, "in": [170, y - 30.0], "out": [230, y - 30.0]},
|
||||
{"x": 300, "y": y, "in": [270, y - 30.0]},
|
||||
]);
|
||||
json!({"anchors": anchors, "closed": false})
|
||||
};
|
||||
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [sub(100.0), sub(300.0)]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` and `key` set to each of 1.5, -1 and "1": each call fails with an error that
|
||||
/// names `key`, and the document and the undo history stay as they were.
|
||||
fn refuses_an_index_that_is_not_whole(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
|
||||
for v in [json!(1.5), json!(-1), json!("1")] {
|
||||
let mut p = p.clone();
|
||||
p[key] = v;
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = undo_steps(s);
|
||||
let Err(e) = s.execute(cmd, &p) else { panic!("{cmd} {p} succeeded") };
|
||||
assert!(e.to_string().contains(&format!("`{key}`")), "{cmd} {p}: {e}");
|
||||
assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document");
|
||||
assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` on path `id` (made by [`two_subpaths`]) with `subpath: 1`, without `subpath`
|
||||
/// and with `subpath: null`, undoing each: the first changes subpath 1 alone, and the others change
|
||||
/// subpath 0 alone.
|
||||
fn edits_the_subpath_given(s: &mut Session, cmd: &str, p: &serde_json::Value, id: NodeId) {
|
||||
let before = path(s, id).subpaths;
|
||||
for (si, p) in [(1, with(p, json!({"subpath": 1}))), (0, p.clone()), (0, with(p, json!({"subpath": null})))] {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let after = path(s, id).subpaths;
|
||||
assert_eq!(after.len(), 2, "{cmd} {p}");
|
||||
assert!(after[si] != before[si] && after[1 - si] == before[1 - si], "{cmd} {p}: {after:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(s, id).subpaths, before, "{cmd} {p}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curvature_edit_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "op": "move", "anchor": 1, "x": 200, "y": 150});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.curvatureEdit", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.curvatureEdit", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 1});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.removeAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.removeAnchor", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 1, "to": "corner"});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.convertAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.convertAnchor", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_segment_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 20});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.reshapeSegment", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.reshapeSegment", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_refuses_a_subpath_anchor_or_segment_that_is_not_a_whole_number() {
|
||||
// A bad `anchor` beside a good `segment` is refused, and so is a bad `segment` beside a good
|
||||
// `anchor`.
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let at_anchor = json!({"id": id.0, "anchor": 1});
|
||||
let at_segment = json!({"id": id.0, "segment": 0, "t": 0.5});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "subpath");
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "subpath");
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "anchor");
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "segment");
|
||||
let before = path(&s, id).subpaths;
|
||||
for (si, p) in [(1, with(&at_anchor, json!({"subpath": 1}))), (0, at_anchor.clone()), (0, with(&at_anchor, json!({"subpath": null})))] {
|
||||
s.execute("path.split", &p).unwrap();
|
||||
let after = path(&s, id).subpaths;
|
||||
// The subpath split becomes two of two anchors each, in its place.
|
||||
let kept = if si == 1 { &after[0] } else { &after[2] };
|
||||
assert!(after.len() == 3 && after[si].anchors.len() == 2 && after[si + 1].anchors.len() == 2, "{p}: {after:?}");
|
||||
assert_eq!(kept, &before[1 - si], "{p}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, id).subpaths, before, "{p}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_handle_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 0, "which": "out", "x": 140, "y": 250});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.setHandle", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.setHandle", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "segment": 0, "t": 0.5});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.insertAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.insertAnchor", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
|
||||
// `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and
|
||||
|
||||
@@ -62,10 +62,14 @@ fn clicking_without_typing_leaves_no_text_and_no_history() {
|
||||
click(&mut s, 150.0, 100.0);
|
||||
s.select_tool("rectangle", v).unwrap();
|
||||
assert!(texts(&s).is_empty());
|
||||
// Click in two places: the first is gone when the second is placed.
|
||||
// Click in two places: the second click discards the first type and places none (#2871); a
|
||||
// third places new type.
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 300.0);
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert!(texts(&s).is_empty());
|
||||
assert!(!s.tool_wants_text());
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert_eq!(texts(&s).len(), 1);
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert!(texts(&s).is_empty());
|
||||
@@ -95,6 +99,25 @@ fn typed_text_stays_and_typing_it_away_leaves_nothing() {
|
||||
assert_eq!(undo_labels(&s), kept);
|
||||
}
|
||||
|
||||
/// A click on blank canvas while type is being edited ends the editing and makes no new type; the
|
||||
/// Type tool stays, and the next click places point type (#2871, #2854).
|
||||
#[test]
|
||||
fn a_click_away_from_typed_text_ends_editing_without_new_type() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.tool_text("Hi", v).unwrap();
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Hi"]);
|
||||
assert_eq!(s.tool_id(), "type");
|
||||
assert!(!s.tool_wants_text());
|
||||
assert_eq!(undo_labels(&s), ["Type", "Typing"]);
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert_eq!(texts(&s).len(), 2);
|
||||
assert!(s.tool_wants_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_stay_when_left_empty() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -371,7 +371,7 @@ fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() {
|
||||
assert_eq!(v["options"]["transparentImages"]["value"], "flatten", "{v}");
|
||||
}
|
||||
|
||||
/// #2692's files: an image clipped to a circle inside a circle's stroke, and an image with
|
||||
/// #2692's file, an image clipped to a circle inside a circle's stroke, and #2830's, an image with
|
||||
/// see-through pixels (which the save flattens into an image inside a clipping group), each with
|
||||
/// an orange square on the next layer.
|
||||
const CLIPPED_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="200" viewBox="0 0 400 200">
|
||||
@@ -389,14 +389,16 @@ const SEE_THROUGH_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmln
|
||||
<g id="above"><rect x="220" y="20" width="160" height="160" fill="#f7941d"/></g>
|
||||
</svg>"##;
|
||||
|
||||
/// #2692: the image inside a clipping group, clipped in the file or flattened by the save, is
|
||||
/// written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space inside
|
||||
/// each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`. The next
|
||||
/// #2692 and #2830: the image inside a clipping group, clipped in the file or flattened by the
|
||||
/// save, is written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space
|
||||
/// inside each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`.
|
||||
/// A line `F`, which the specification's examples do not show, follows `%AI5_EndRaster`. The next
|
||||
/// layer follows with the square's exact lines, and the file opens again with both layers, the
|
||||
/// clipping group, the image and the square.
|
||||
/// clipping group, the image, the square and, besides the clipping path, only the paths the SVG
|
||||
/// holds.
|
||||
#[test]
|
||||
fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
|
||||
for (svg, layer) in [(CLIPPED_IMAGE, "clipped-image"), (SEE_THROUGH_IMAGE, "alpha-image")] {
|
||||
for (svg, layer, paths) in [(CLIPPED_IMAGE, "clipped-image", 2), (SEE_THROUGH_IMAGE, "alpha-image", 1)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||||
@@ -411,7 +413,8 @@ fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
|
||||
let end = at + lines[at..].iter().position(|l| *l == "%AI5_EndRaster").unwrap();
|
||||
let (last, full) = lines[at + 1..end].split_last().unwrap();
|
||||
assert!(full.iter().all(|l| l.len() == 61) && (2..=61).contains(&last.len()), "{layer}");
|
||||
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\nLB\n%AI5_EndLayer--\n"), "{text}");
|
||||
assert_eq!(lines[end + 1], "F", "{layer}");
|
||||
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nQ\nLB\n%AI5_EndLayer--\n"), "{text}");
|
||||
let square = "%AI5_BeginLayer\n1 1 1 1 0 0 1 255 79 79 Lb\n(above) Ln\n0 A\n0.9686 0.5804 0.1137 Xa\n0 O\n0 XR\n\
|
||||
220 180 m\n380 180 L\n380 20 L\n220 20 L\n220 180 L\nf\nLB\n%AI5_EndLayer--\n%%PageTrailer\n";
|
||||
assert!(text.contains(square), "{text}");
|
||||
@@ -419,6 +422,9 @@ fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(layer_names(d), [layer, "above"]);
|
||||
assert_eq!(images(d), (1, 1), "{layer}");
|
||||
let mut drawn = 0;
|
||||
d.walk(|n| drawn += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: false, .. })));
|
||||
assert_eq!(drawn, paths, "{layer}");
|
||||
assert_eq!(colour_at(d, 300.0, 100.0), [247, 148, 29], "{layer}");
|
||||
assert_ne!(colour_at(d, 100.0, 100.0), [255, 255, 255], "{layer}");
|
||||
}
|
||||
|
||||
@@ -88,6 +88,86 @@ fn linked_files_on_other_computers_are_never_read() {
|
||||
assert!(s.link_stamps.keys().all(|(_, p)| !far.contains(p)), "{:?}", s.link_stamps.keys());
|
||||
}
|
||||
|
||||
/// The final path Windows gives a folder of the drive `Z:` mapped to `\\server\share`. The
|
||||
/// share's root folder has no trailing separator (`\\?\UNC\server\share`).
|
||||
fn on_share(folder: &str) -> Option<std::path::PathBuf> {
|
||||
let rest = folder.get(2..)?.trim_start_matches(['\\', '/']).replace('/', "\\");
|
||||
let root = r"\\?\UNC\server\share";
|
||||
Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_on_a_mapped_drive_is_on_the_share_behind_it() {
|
||||
use crate::cmd::fileio::share_of;
|
||||
// craft-docpath refuses a link on the share when the document is given by its drive letter.
|
||||
let link = r"\\server\share\img\logo.png";
|
||||
assert_eq!(craft_docpath::resolve(link, Some(std::path::Path::new(r"Z:\jobs\doc.vectorcraft"))), Err(craft_docpath::Refused::Network));
|
||||
assert!(craft_docpath::resolve(link, Some(std::path::Path::new(r"\\server\share\jobs\doc.vectorcraft"))).is_ok());
|
||||
|
||||
// The document's path on the share, from its folder's final path.
|
||||
let mut asked = vec![];
|
||||
let mut ask = |folder: &str| {
|
||||
asked.push(folder.to_string());
|
||||
on_share(folder)
|
||||
};
|
||||
assert_eq!(share_of(r"Z:\jobs\doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\jobs\doc.vectorcraft"));
|
||||
assert_eq!(share_of(r"Z:\doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\doc.vectorcraft"));
|
||||
assert_eq!(share_of("Z:/jobs/doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\jobs\doc.vectorcraft"));
|
||||
assert_eq!(asked, [r"Z:\jobs", r"Z:\", "Z:/jobs"]);
|
||||
let unc = |p: &'static str| move |_: &str| Some(std::path::PathBuf::from(p));
|
||||
assert_eq!(share_of(r"Z:\doc.vectorcraft", unc(r"\\?\unc\server\share\")).as_deref(), Some(r"\\server\share\doc.vectorcraft"));
|
||||
|
||||
// A folder on a local volume (also behind a `subst` drive), a folder that can't be opened,
|
||||
// and a final path without a share name give no path on a share.
|
||||
assert_eq!(share_of(r"C:\art\doc.vectorcraft", unc(r"\\?\C:\art")), None);
|
||||
assert_eq!(share_of(r"S:\doc.vectorcraft", unc(r"\\?\D:\real")), None);
|
||||
assert_eq!(share_of(r"Z:\gone\doc.vectorcraft", |_| None), None);
|
||||
assert_eq!(share_of(r"Z:\doc.vectorcraft", unc(r"\\?\UNC\server")), None);
|
||||
// A document given by its share, or not by a drive, is not looked up.
|
||||
for doc in [
|
||||
r"\\server\share\doc.vectorcraft",
|
||||
r"\\?\Z:\doc.vectorcraft",
|
||||
"/Users/a/doc.vectorcraft",
|
||||
"doc.vectorcraft",
|
||||
"Z:doc.vectorcraft",
|
||||
r"Z:\jobs\",
|
||||
] {
|
||||
assert_eq!(share_of(doc, |f: &str| panic!("{doc}: looked up {f}")), None, "{doc}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn links_on_a_mapped_drives_share_are_followed() {
|
||||
use crate::cmd::fileio::{document_path_with, share_of};
|
||||
let doc = Some(r"Z:\jobs\doc.vectorcraft");
|
||||
let share = |d: &str| share_of(d, on_share);
|
||||
// A link on the share behind the drive is followed; one on another share still isn't.
|
||||
assert_eq!(document_path_with(r"\\server\share\img\logo.png", doc, share).as_deref(), Ok(r"\\server\share\img\logo.png"));
|
||||
assert_eq!(document_path_with(r"\\SERVER\Share\logo.png", doc, share).as_deref(), Ok(r"\\SERVER\Share\logo.png"));
|
||||
assert_eq!(document_path_with(r"\\other\share\logo.png", doc, share), Err(craft_docpath::Refused::Network));
|
||||
assert_eq!(document_path_with("file://server/share/logo.png", doc, share), Err(craft_docpath::Refused::Network));
|
||||
// On a local volume, the link stays refused.
|
||||
assert_eq!(document_path_with(r"\\server\share\logo.png", doc, |_| None), Err(craft_docpath::Refused::Network));
|
||||
// Links that were followed resolve as before, without looking the share up.
|
||||
let unasked = |d: &str| -> Option<String> { panic!("looked up {d}") };
|
||||
assert_eq!(document_path_with(r"img\logo.png", doc, unasked).as_deref(), Ok(r"Z:\jobs\img\logo.png"));
|
||||
assert_eq!(document_path_with(r"D:\art\logo.png", doc, unasked).as_deref(), Ok(r"D:\art\logo.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_off_a_mapped_drive_keeps_its_path() {
|
||||
use crate::cmd::fileio::share_path;
|
||||
let dir = Folder::new("docpath-share");
|
||||
// A folder on this computer's own disk, and one that doesn't exist.
|
||||
assert_eq!(share_path(&dir.file("doc.vectorcraft")), None);
|
||||
assert_eq!(share_path(&dir.file("gone/doc.vectorcraft")), None);
|
||||
// Elsewhere than Windows, no document is looked up.
|
||||
if cfg!(not(windows)) {
|
||||
assert_eq!(share_path(r"Z:\jobs\doc.vectorcraft"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_images_on_other_computers_are_never_read() {
|
||||
let dir = Folder::new("docpath-svg");
|
||||
|
||||
@@ -157,6 +157,42 @@ fn a_linked_photoshop_document_places_and_updates() {
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
|
||||
}
|
||||
|
||||
/// An uncompressed 24-bit `w`×`h` Targa file of one color.
|
||||
fn targa(w: u16, h: u16, rgb: [u8; 3]) -> Vec<u8> {
|
||||
let mut f = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
f.extend(w.to_le_bytes());
|
||||
f.extend(h.to_le_bytes());
|
||||
f.extend([24, 0]);
|
||||
let [r, g, b] = rgb;
|
||||
f.extend(std::iter::repeat_n([b, g, r], usize::from(w) * usize::from(h)).flatten());
|
||||
f
|
||||
}
|
||||
|
||||
/// A Targa file placed linked shows its pixels and updates when it changes, and a link can be
|
||||
/// relinked to one.
|
||||
#[test]
|
||||
fn a_linked_targa_file_places_updates_and_relinks() {
|
||||
let dir = Folder::new("tga");
|
||||
let pic = dir.file("art/photo.tga");
|
||||
write(&pic, &targa(600, 300, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
assert_eq!(image(&s, id).link.unwrap().path, pic);
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
write(&pic, &targa(400, 400, BLUE));
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [id.0], "missing": []}));
|
||||
let im = image(&s, id);
|
||||
assert_eq!((im.width, im.height), (400, 400));
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
|
||||
let other = dir.file("other/red.vst");
|
||||
write(&other, &targa(300, 100, RED));
|
||||
let r = s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap();
|
||||
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}));
|
||||
let im = image(&s, id);
|
||||
assert_eq!((im.width, im.height, im.link.unwrap().path), (300, 100, other));
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_relative_path_finds_the_file_after_the_folder_moves() {
|
||||
let a = Folder::new("move-a");
|
||||
|
||||
@@ -83,6 +83,13 @@ fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than a number.
|
||||
fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) {
|
||||
let r = s.execute(cmd, p);
|
||||
let want = format!("`{key}` must be a number, not {}", p[key]);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
|
||||
}
|
||||
|
||||
// ---------- Object → Lock / Hide ----------
|
||||
|
||||
#[test]
|
||||
@@ -399,6 +406,29 @@ fn transform_each_refuses_a_reference_or_seed_that_is_not_a_whole_number() {
|
||||
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 50.0, 50.0));
|
||||
}
|
||||
|
||||
/// Transform Each refuses a scale, move or rotation given as text or as another value that is not a
|
||||
/// number, and the object stays as it was; numbers for the scales and the horizontal move transform it.
|
||||
#[test]
|
||||
fn transform_each_refuses_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
let before = (undo_len(&s), bounds(&s, a));
|
||||
for (key, p) in [
|
||||
("scaleH", json!({"scaleH": "50"})),
|
||||
("scaleV", json!({"scaleH": 50, "scaleV": "50%"})),
|
||||
("moveH", json!({"moveH": "10"})),
|
||||
("moveV", json!({"moveH": 10, "moveV": [5]})),
|
||||
("rotate", json!({"rotate": "45"})),
|
||||
("rotate", json!({"moveH": 10, "rotate": true})),
|
||||
] {
|
||||
refuses_non_number(&mut s, "object.transformEach", &p, key);
|
||||
assert_eq!((undo_len(&s), bounds(&s, a)), before, "{p}");
|
||||
}
|
||||
s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50, "moveH": 10, "moveV": null})).unwrap();
|
||||
assert_eq!(bounds(&s, a), Rect::new(35.0, 25.0, 85.0, 75.0));
|
||||
}
|
||||
|
||||
/// #1910: Transform Each's Reflect X flips left-right; Reflect Y flips top-bottom (same as
|
||||
/// Effect › Distort › Transform and Illustrator's Transform Each checkboxes).
|
||||
#[test]
|
||||
@@ -571,6 +601,22 @@ fn rasterize_replaces_selection_with_image() {
|
||||
assert_eq!(selected(&s), vec![img]);
|
||||
}
|
||||
|
||||
/// Rasterize refuses a resolution given as text or as another value that is not a number, and the
|
||||
/// selected art stays as it was.
|
||||
#[test]
|
||||
fn rasterize_refuses_a_ppi_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
|
||||
sel(&mut s, &[a]);
|
||||
let before = (undo_len(&s), node(&s, a));
|
||||
for p in [json!({"ppi": "144"}), json!({"ppi": "300 ppi", "padding": 0}), json!({"ppi": [72]})] {
|
||||
refuses_non_number(&mut s, "object.rasterize", &p, "ppi");
|
||||
assert_eq!((undo_len(&s), node(&s, a)), before, "{p}");
|
||||
}
|
||||
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 144, "padding": 0})).unwrap());
|
||||
assert!(matches!(node(&s, img).kind, NodeKind::Image(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_says_when_an_image_could_not_be_decoded() {
|
||||
let mut s = session();
|
||||
@@ -608,6 +654,25 @@ fn crop_image_to_rect() {
|
||||
assert!(s.execute("object.cropImage", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Crop Image refuses a `rect` that is not an array, such as text, and the image stays as it was; a
|
||||
/// null `rect` crops to the artboard as a missing one does.
|
||||
#[test]
|
||||
fn crop_image_refuses_a_rect_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, -20.0, -20.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
|
||||
let before = (undo_len(&s), node(&s, img));
|
||||
for p in [json!({"rect": "0, 0, 20, 20"}), json!({"rect": {"x": 0, "y": 0, "width": 20, "height": 20}}), json!({"rect": 20})] {
|
||||
let r = s.execute("object.cropImage", &p);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "rect must be [x, y, width, height]"), "{p}: {r:?}");
|
||||
assert_eq!((undo_len(&s), node(&s, img)), before, "{p}");
|
||||
}
|
||||
s.execute("object.cropImage", &json!({"rect": null})).unwrap();
|
||||
let b = bounds(&s, img);
|
||||
assert!(close(b.x0, 0.0) && close(b.y0, 0.0), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_disabled_without_image() {
|
||||
let mut s = session();
|
||||
@@ -656,6 +721,30 @@ fn trim_marks_around_selection() {
|
||||
assert!(close(b.x0, vb.x0 - 30.0) && close(b.y1, vb.y1 + 30.0), "{b:?} {vb:?}");
|
||||
}
|
||||
|
||||
/// Create Trim Marks refuses an offset, length, bleed or weight given as text or as another value
|
||||
/// that is not a number, around the selection and around the artboards, and makes no marks.
|
||||
#[test]
|
||||
fn trim_marks_refuse_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
let calls = [
|
||||
("offset", json!({"offset": "10"})),
|
||||
("length", json!({"length": "20 pt"})),
|
||||
("bleed", json!({"style": "japanese", "bleed": "3"})),
|
||||
("weight", json!({"weight": true})),
|
||||
("offset", json!({"allArtboards": true, "offset": {"value": 10}})),
|
||||
];
|
||||
for ids in [vec![a], vec![]] {
|
||||
sel(&mut s, &ids);
|
||||
let before = (undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len());
|
||||
for (key, p) in &calls {
|
||||
refuses_non_number(&mut s, "object.createTrimMarks", p, key);
|
||||
assert_eq!((undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len()), before, "{p}");
|
||||
}
|
||||
}
|
||||
s.execute("object.createTrimMarks", &json!({"offset": 10, "length": 20, "bleed": null})).unwrap();
|
||||
}
|
||||
|
||||
// ---------- Convert to Shape ----------
|
||||
|
||||
#[test]
|
||||
@@ -791,6 +880,22 @@ fn rearrange_refuses_columns_that_are_not_a_whole_number() {
|
||||
assert_eq!((r["rows"].clone(), r["columns"].clone()), (json!(2), json!(1)));
|
||||
}
|
||||
|
||||
/// Rearrange All Artboards refuses a spacing given as text or as another value that is not a number,
|
||||
/// and the artboards stay where they were.
|
||||
#[test]
|
||||
fn rearrange_refuses_a_spacing_given_as_text() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
|
||||
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
|
||||
let before = (boards(&s), undo_len(&s));
|
||||
for p in [json!({"spacing": "50"}), json!({"columns": 1, "spacing": "50 pt"}), json!({"layout": "row", "spacing": false})] {
|
||||
refuses_non_number(&mut s, "artboard.rearrange", &p, "spacing");
|
||||
assert_eq!((boards(&s), undo_len(&s)), before, "{p}");
|
||||
}
|
||||
s.execute("artboard.rearrange", &json!({"columns": 1, "spacing": 50})).unwrap();
|
||||
assert_eq!(boards(&s)[1], Rect::new(0.0, 650.0, 400.0, 950.0));
|
||||
}
|
||||
|
||||
/// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an
|
||||
/// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from
|
||||
/// outside or on no artboard doesn't count, and an artboard with no art stays as it is.
|
||||
@@ -1026,6 +1131,55 @@ fn select_same_paint_reads_type_characters() {
|
||||
assert_eq!(same(&mut s, t1, "select.same.fillAndStroke"), vec![t1, r]);
|
||||
}
|
||||
|
||||
/// Select by Color needs no object to start from: it matches fills and strokes as
|
||||
/// `document.inspect` reports them, `#rrggbb`.
|
||||
#[test]
|
||||
fn select_color_matches_fill_and_stroke_without_a_selection() {
|
||||
let mut s = session();
|
||||
let [a, b, c] = [0.0, 20.0, 40.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0));
|
||||
let t = text(&mut s, "a");
|
||||
fill(&mut s, a, "#000000");
|
||||
fill(&mut s, t, "#000000");
|
||||
// A grey as a PDF stores it reads as #fefefe.
|
||||
s.execute("paint.setFill", &json!({"ids": [b.0], "color": [0.996, 0.996, 0.996]})).unwrap();
|
||||
fill(&mut s, c, "#ff0000");
|
||||
s.execute("paint.setStroke", &json!({"ids": [a.0, c.0], "color": "#0000ff"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let pick = |s: &mut Session, p: Value| {
|
||||
s.execute("select.color", &p).unwrap();
|
||||
let mut v = selected(s);
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t]);
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#fefefe"})), vec![b]);
|
||||
assert_eq!(pick(&mut s, json!({"stroke": "#0000ff"})), vec![a, c]);
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000", "stroke": "#0000ff"})), vec![a]);
|
||||
// Select > Reselect repeats it.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("select.reselect", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a]);
|
||||
// A compound path reads as its own paint, never its members'.
|
||||
let outer = rect(&mut s, 100.0, 0.0, 50.0, 50.0);
|
||||
let inner = rect(&mut s, 110.0, 10.0, 20.0, 20.0);
|
||||
fill(&mut s, outer, "#000000");
|
||||
fill(&mut s, inner, "#000000");
|
||||
sel(&mut s, &[outer, inner]);
|
||||
let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t, compound]);
|
||||
fill(&mut s, compound, "#ff0000");
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t]);
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#ff0000"})), vec![c, compound]);
|
||||
// Type without characters reads as the default character fill, black.
|
||||
let empty = text(&mut s, "x");
|
||||
if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(empty).map(|n| &mut n.kind) {
|
||||
t.runs.clear();
|
||||
}
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t, empty]);
|
||||
assert!(s.execute("select.color", &json!({})).is_err());
|
||||
assert!(s.execute("select.color", &json!({"fill": "not a colour"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_direction_handles_selects_all_anchors() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! report mixed values, and saves and exports never carry the editing layer.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_render::{RenderOptions, Renderer};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::maskedit::MASK_EDIT_LAYER;
|
||||
@@ -46,6 +48,21 @@ fn data(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// #2727: the canvas passes `isolation` while mask art is edited; the editing layer is not drawn on
|
||||
/// the page, so isolation dimming must not run or the artwork washes out.
|
||||
#[test]
|
||||
fn mask_editing_does_not_dim_the_canvas() {
|
||||
let mut s = session();
|
||||
editing_mask(&mut s);
|
||||
let st = s.doc().unwrap();
|
||||
let layer = st.isolation.expect("mask editing isolates the temporary layer");
|
||||
let render = |opts: &RenderOptions| Renderer::new().render(&st.doc, 300, 300, Affine::IDENTITY, opts);
|
||||
let plain = render(&RenderOptions::default());
|
||||
let canvas = render(&RenderOptions { isolated: Some(layer), ..Default::default() });
|
||||
assert_eq!(plain.pixel(50, 100), canvas.pixel(50, 100), "inside the mask");
|
||||
assert_eq!(plain.pixel(250, 100), canvas.pixel(250, 100), "outside the mask");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_options_act_on_the_masked_object_while_editing() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1181,7 +1181,10 @@ fn new_type_starts_with_placeholder_text() {
|
||||
gesture(&mut s, v, &[(Down, 100.0, 150.0), (Drag, 300.0, 300.0), (Up, 300.0, 300.0)]);
|
||||
assert!(text(&s).len() > placeholder.len());
|
||||
set_pref(&mut s, "placeholderText", json!(false));
|
||||
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
|
||||
// The first click ends editing the area type (#2871); the second places point type.
|
||||
for _ in 0..2 {
|
||||
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
|
||||
}
|
||||
assert_eq!(text(&s), "");
|
||||
}
|
||||
|
||||
|
||||
@@ -1428,6 +1428,57 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// Targa files, which start with no signature: run-length encoded 24 bits (as the `image` crate
|
||||
/// writes them), a 16-bit export with a one-bit alpha, and by hand a 16-bit run-length encoded file
|
||||
/// stored right to left and top down, a run-length encoded color-mapped one with 16-bit entries,
|
||||
/// and a 32-bit one with an extension area.
|
||||
fn targa_samples() -> Vec<(&'static str, Vec<u8>)> {
|
||||
let mut rle = vec![];
|
||||
image::RgbImage::from_pixel(5, 3, image::Rgb([200, 40, 10])).write_to(&mut std::io::Cursor::new(&mut rle), image::ImageFormat::Tga).unwrap();
|
||||
let mut s = rich_session();
|
||||
let r = s.execute("document.export", &json!({"format": "tga", "depth": 16, "ppi": 2})).unwrap();
|
||||
let export = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let mut alpha = vec![0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 2, 0, 16, 0x31, 0x02, 0x00, 0xfc, 0x1f, 0x80, 0x00, 0x00];
|
||||
alpha.extend([0x82, 0xe0, 0x83]);
|
||||
let mut mapped = vec![0, 1, 9, 5, 0, 2, 0, 16, 0, 0, 0, 0, 3, 0, 2, 0, 8, 0x21, 0x00, 0x00, 0x00, 0xfc];
|
||||
mapped.extend([0x80, 6, 0x00, 5, 0x83, 6]);
|
||||
let mut extended = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 0x28, 50, 50, 50, 128];
|
||||
let mut area = vec![0; 495];
|
||||
area[..2].copy_from_slice(&495u16.to_le_bytes());
|
||||
area[494] = 4;
|
||||
extended.extend(area);
|
||||
extended.extend(22u32.to_le_bytes());
|
||||
extended.extend([0; 4]);
|
||||
extended.extend(b"TRUEVISION-XFILE.\0");
|
||||
vec![("a.tga", rle), ("b.tga", export), ("c.tga", alpha), ("d.vda", mapped), ("e.vst", extended)]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// Mutated Targa files (headers, run-length packets, color maps, the extension area and the
|
||||
/// footer) never crash placing them.
|
||||
#[test]
|
||||
fn mutated_targa_files_place_without_panics(which in 0usize..5, cut in 0usize..600, edits in prop::collection::vec((0usize..560, any::<u8>()), 0..12)) {
|
||||
let (name, mut bytes) = targa_samples().swap_remove(which);
|
||||
for (at, b) in edits {
|
||||
if let Some(x) = bytes.get_mut(at) {
|
||||
*x = b;
|
||||
}
|
||||
}
|
||||
bytes.truncate(cut.max(8));
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8});
|
||||
let _ = no_panic(s.execute("file.place.info", &p));
|
||||
let _ = no_panic(s.execute("file.place", &p));
|
||||
let _ = no_panic(s.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})));
|
||||
});
|
||||
prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SVG saved with hidden layers and editing data ----------
|
||||
|
||||
/// The rich document with a hidden layer, saved as SVG with its editing data.
|
||||
@@ -1741,6 +1792,27 @@ proptest! {
|
||||
let text = mutate_text(&ccitt_images_eps(), cut, &edits);
|
||||
survive("mutated EPS CCITT images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// TIFF-predicted Flate data (Predictor 2) at every depth the filter accepts, with any bytes, 1 to 5
|
||||
/// colors and 1 to 39 columns, read by images of up to 23 × 7 pixels at 1, 2, 4, 8, 12 or 16 bits:
|
||||
/// read as an image, or as an error.
|
||||
#[test]
|
||||
fn eps_tiff_predicted_images_never_panic(
|
||||
data in prop::collection::vec(any::<u8>(), 0..300),
|
||||
bpc in prop::sample::select(vec![1u8, 2, 4, 8, 16]),
|
||||
colors in 1u8..6,
|
||||
columns in 1u16..40,
|
||||
image_bpc in prop::sample::select(vec![1u8, 2, 4, 8, 12, 16]),
|
||||
(width, height) in (1u8..24, 1u8..8),
|
||||
) {
|
||||
let hex: String = deflated(&data).iter().map(|b| format!("{b:02x}")).collect();
|
||||
let text = format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n40 30 scale {width} {height} {image_bpc} [{width} 0 0 -{height} 0 {height}] \
|
||||
currentfile /ASCIIHexDecode filter << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns {columns} >> /FlateDecode filter image\n\
|
||||
{hex}>\nshowpage\n%%EOF\n"
|
||||
);
|
||||
survive("TIFF-predicted EPS images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// An EPS with two 8 × 3 CCITT fax images that the PostScript interpreter reads, coded by hand from
|
||||
@@ -1851,7 +1923,8 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
|
||||
"status", "token", "bytesavailable", "resetfile", "pdfmark", "execform", "nulldevice", "strokepath", "pathforall",
|
||||
"currenthsbcolor", "currentcolorrendering", "gcheck", "rootfont", "setcachedevice2", "writestring", "(%stdout) (w) file",
|
||||
"//x", "{ //dup }", "/ReusableStreamDecode", "currentfile /ASCII85Decode filter /ReusableStreamDecode filter",
|
||||
"<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>",
|
||||
"<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /Predictor 2 /Colors 3 /BitsPerComponent 4 /Columns 3 >> /LZWDecode",
|
||||
"<< /Predictor 2 /BitsPerComponent 16 /Columns 5 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>",
|
||||
// Tiling patterns, uncoloured ones and patterns drawing patterns.
|
||||
"<< /PatternType 1 /PaintType 1 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop 0 0 3 3 rectfill } >> matrix makepattern",
|
||||
"<< /PatternType 1 /PaintType 2 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop dup setpattern 0 0 3 3 rectfill } >>",
|
||||
|
||||
@@ -2012,9 +2012,15 @@ impl<'a> Reader<'a> {
|
||||
/// An image of operands `vals` and samples `data`.
|
||||
fn image_from(&mut self, vals: Vec<V>, data: &[u8]) -> Result<(), String> {
|
||||
let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default();
|
||||
let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else { return Ok(()) };
|
||||
let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let n = Self::nums(&vals);
|
||||
let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else { return Ok(()) };
|
||||
let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let (w, h) = (*w as u64, *h as u64);
|
||||
if w == 0 || h == 0 || w.saturating_mul(h) > MAX_PIXELS || ![1.0, 8.0].contains(bits) {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
@@ -2080,7 +2086,10 @@ impl<'a> Reader<'a> {
|
||||
rgba.extend_from_slice(&[px[0], px[1], px[2], a]);
|
||||
}
|
||||
}
|
||||
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { return Ok(()) };
|
||||
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let mut png = vec![];
|
||||
if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
@@ -2096,10 +2105,14 @@ impl<'a> Reader<'a> {
|
||||
// The matrix is an art-space matrix (y up) applied to the pixels with their rows running
|
||||
// downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead
|
||||
// gives the same place only when the image isn't rotated (b = c = 0).
|
||||
let [a, b, c, d, tx, ty] = m else { return Ok(()) };
|
||||
let [a, b, c, d, tx, ty] = m else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let to_doc = self.space();
|
||||
let xf = to_doc * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP;
|
||||
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
}
|
||||
let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf));
|
||||
|
||||
@@ -677,6 +677,40 @@ fn images_with_alpha_channels() {
|
||||
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
|
||||
}
|
||||
|
||||
/// Editing data `data` opens without its one image and with the warning "an image VectorCraft can't read was left out" once.
|
||||
fn image_left_out_with_a_warning(data: &[u8]) {
|
||||
let r = read(data).unwrap();
|
||||
assert!(art(&r.doc).is_empty(), "{:#?}", art(&r.doc));
|
||||
let left_out = r.warnings.iter().filter(|w| *w == "an image VectorCraft can't read was left out").count();
|
||||
assert_eq!(left_out, 1, "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_without_a_matrix_is_left_out_with_a_warning() {
|
||||
image_left_out_with_a_warning(&image("", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_with_too_few_operands_is_left_out_with_a_warning() {
|
||||
// The operands hold 10 numbers after the matrix, one short of the binary flag.
|
||||
let text = String::from_utf8(image("[ 1 0 0 1 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), b"abc", 3)).unwrap();
|
||||
let data = text.replacen("] 0 0 1 1 1 1 8 3 0 0 1 0 4 4 0 0\n", "] 0 0 1 1 1 1 8 3 0 0\n", 1);
|
||||
assert_ne!(data, text);
|
||||
image_left_out_with_a_warning(data.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_whose_matrix_isnt_six_numbers_is_left_out_with_a_warning() {
|
||||
image_left_out_with_a_warning(&image("[ 1 0 0 1 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_whose_matrix_has_no_area_is_left_out_with_a_warning() {
|
||||
image_left_out_with_a_warning(&image("[ 0 0 0 0 0 0 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
|
||||
// This matrix maps the image onto a line.
|
||||
image_left_out_with_a_warning(&image("[ 1 2 2 4 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
|
||||
}
|
||||
|
||||
/// The raster matrix `[a b c d tx ty]` is an art-space matrix (y up) applied to the image's pixels
|
||||
/// with their rows running downwards, `(u, -v)`: art x = a·u − c·v + tx, art y = b·u − d·v + ty.
|
||||
/// Without rotation (b = c = 0) that is the same as flipping the matrix's result; a rotated image
|
||||
|
||||
@@ -324,16 +324,46 @@ impl Predictor {
|
||||
// Bytes per pixel (at least one) is how far back the left neighbour is.
|
||||
let bpp = (self.colors * self.bpc).div_ceil(8).max(1);
|
||||
if self.kind == 2 {
|
||||
// Horizontal differencing (ISO 32000-1, §7.4.4.4): each component is stored as its
|
||||
// difference from the same component of the pixel to its left, wrapping within its bit
|
||||
// width. Each row starts again, and the padding bits at a row's end stay as they are.
|
||||
let mut out = data.to_vec();
|
||||
// Horizontal differencing, for 8-bit components (the common case).
|
||||
if self.bpc == 8 {
|
||||
for line in out.chunks_mut(row.max(1)) {
|
||||
for i in bpp..line.len() {
|
||||
let left = line.get(i - bpp).copied().unwrap_or(0);
|
||||
if let Some(b) = line.get_mut(i) {
|
||||
*b = b.wrapping_add(left);
|
||||
for line in out.chunks_mut(row.max(1)) {
|
||||
match self.bpc {
|
||||
8 => {
|
||||
for i in bpp..line.len() {
|
||||
let left = line.get(i - bpp).copied().unwrap_or(0);
|
||||
if let Some(b) = line.get_mut(i) {
|
||||
*b = b.wrapping_add(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 16-bit components are big-endian and add as 16-bit numbers.
|
||||
16 => {
|
||||
let byte = |line: &[u8], i: usize| line.get(i).copied().unwrap_or(0);
|
||||
for i in (bpp..line.len().saturating_sub(1)).step_by(2) {
|
||||
let left = u16::from_be_bytes([byte(line, i - bpp), byte(line, i - bpp + 1)]);
|
||||
let sum = u16::from_be_bytes([byte(line, i), byte(line, i + 1)]).wrapping_add(left);
|
||||
if let Some(w) = line.get_mut(i..i + 2) {
|
||||
w.copy_from_slice(&sum.to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1, 2 or 4 bits: components are packed from each byte's high bit.
|
||||
bits @ (1 | 2 | 4) => {
|
||||
let mask = (1u8 << bits) - 1;
|
||||
let shift = |k: usize| 8 - bits - k * bits % 8;
|
||||
let sample = |line: &[u8], k: usize| line.get(k * bits / 8).map_or(0, |b| (b >> shift(k)) & mask);
|
||||
let samples = (line.len() * 8 / bits).min(self.colors * self.columns);
|
||||
for k in self.colors..samples {
|
||||
let sum = sample(line, k).wrapping_add(sample(line, k - self.colors)) & mask;
|
||||
if let Some(b) = line.get_mut(k * bits / 8) {
|
||||
*b = (*b & !(mask << shift(k))) | (sum << shift(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Predictor::of` accepts no other depth.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -186,13 +186,27 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
||||
import_with(bytes, true)
|
||||
}
|
||||
|
||||
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
|
||||
/// (else what its page prints, as any EPS).
|
||||
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
|
||||
/// The PostScript of an EPS or PostScript file (behind its binary header, if it has one).
|
||||
fn program(bytes: &[u8]) -> Result<&[u8], String> {
|
||||
let (ps, _) = crate::sections(bytes).ok_or("the file's preview header points outside the file")?;
|
||||
if !ps.starts_with(b"%!") {
|
||||
return Err("this is not a PostScript file".into());
|
||||
}
|
||||
Ok(ps)
|
||||
}
|
||||
|
||||
/// An EPS or PostScript file's page at the origin, read from its comments without running its
|
||||
/// program: its bounding box (`%%HiResBoundingBox` when it has one), a US Letter page without
|
||||
/// one. [`import_with`] runs the program on the same page when it reads what the page prints.
|
||||
pub fn page(bytes: &[u8]) -> Result<Rect, String> {
|
||||
let [llx, lly, urx, ury] = Dsc::read(program(bytes)?).bbox.unwrap_or(LETTER);
|
||||
Ok(Rect::new(0.0, 0.0, urx - llx, ury - lly))
|
||||
}
|
||||
|
||||
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
|
||||
/// (else what its page prints, as any EPS).
|
||||
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
|
||||
let ps = program(bytes)?;
|
||||
let dsc = Dsc::read(ps);
|
||||
// A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no
|
||||
// type), and its program has all of it, which is read as the format (as before the file
|
||||
|
||||
@@ -408,3 +408,57 @@ fn predicted_rows_of_an_odd_width() {
|
||||
let want: Vec<[u8; 4]> = raw.iter().flat_map(|row| (0..3).map(move |x| [row[3 * x], row[3 * x + 1], row[3 * x + 2], 255])).collect();
|
||||
assert_eq!(pixels(&r, 3, 5), want);
|
||||
}
|
||||
|
||||
/// Rows of `bpc`-bit samples packed from the high bit (16-bit ones big-endian), each row padded to
|
||||
/// a whole byte.
|
||||
fn packed(rows: &[Vec<u32>], bpc: usize) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
for row in rows {
|
||||
let mut bytes = vec![0u8; (row.len() * bpc).div_ceil(8)];
|
||||
for (k, v) in row.iter().enumerate() {
|
||||
if bpc == 16 {
|
||||
bytes[2 * k..2 * k + 2].copy_from_slice(&(*v as u16).to_be_bytes());
|
||||
} else {
|
||||
let bit = k * bpc;
|
||||
bytes[bit / 8] |= (*v as u8) << (8 - bpc - bit % 8);
|
||||
}
|
||||
}
|
||||
out.extend(bytes);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A TIFF-predicted Flate stream (Predictor 2, horizontal differencing: ISO 32000-1 §7.4.4.4) at
|
||||
/// 1, 2, 4, 8 and 16 bits a component, gray and RGB, five pixels a row: each component is the
|
||||
/// difference from the same component of the pixel to its left, within its bit width, and each
|
||||
/// row starts again. The filter gives back the samples before differencing, read here as 8-bit
|
||||
/// gray pixels one byte each.
|
||||
#[test]
|
||||
fn tiff_predicted_rows_at_every_depth() {
|
||||
let columns = 5;
|
||||
let mut wrong = vec![];
|
||||
for bpc in [1, 2, 4, 8, 16] {
|
||||
for colors in [1, 3] {
|
||||
let max = (1u32 << bpc) - 1;
|
||||
let rows: Vec<Vec<u32>> =
|
||||
(0..3u32).map(|y| (0..(columns * colors) as u32).map(|i| (i * i * 40_503 + i * 7 + y * 12_345 + 1) & max).collect()).collect();
|
||||
let differenced: Vec<Vec<u32>> = rows
|
||||
.iter()
|
||||
.map(|row| (0..row.len()).map(|i| if i < colors { row[i] } else { row[i].wrapping_sub(row[i - colors]) & max }).collect())
|
||||
.collect();
|
||||
let (want, data) = (packed(&rows, bpc), packed(&differenced, bpc));
|
||||
let width = want.len() / 3;
|
||||
let r = read(&format!(
|
||||
"30 30 scale << /ImageType 1 /Width {width} /Height 3 /BitsPerComponent 8 /Decode [0 1] /ImageMatrix [{width} 0 0 -3 0 3] \
|
||||
/DataSource currentfile /ASCII85Decode filter << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns {columns} >> \
|
||||
/FlateDecode filter >> image\n{}",
|
||||
crate::ps::ascii85(&crate::ps::deflate(&data))
|
||||
));
|
||||
let got: Vec<u8> = pixels(&r, width as u32, 3).iter().map(|p| p[0]).collect();
|
||||
if got != want {
|
||||
wrong.push(format!("{bpc} bits, {colors} colors: {got:?}, not {want:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
+86
-32
@@ -29,7 +29,7 @@
|
||||
//! `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB` open) to stroke it too.
|
||||
//! - Images (§8.1, §8.4): `%AI5_File:`, `%AI5_BeginRaster`,
|
||||
//! `[ a b c d tx ty ] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in hex on comment lines of 60
|
||||
//! digits, `%AI5_EndRaster`.
|
||||
//! digits, `%AI5_EndRaster`, then a line `F` that §8.4's examples do not show.
|
||||
//! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx
|
||||
//! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each
|
||||
//! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`,
|
||||
@@ -50,7 +50,9 @@
|
||||
//! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden
|
||||
//! layer they stay where they are). Type that 8-bit text can't hold (characters outside
|
||||
//! Windows-1252, vertical type, inline graphics, gradient or pattern fills) is written as its
|
||||
//! outlines, and all type with [`LegacyAiOptions::outline_text`].
|
||||
//! outlines, and all type with [`LegacyAiOptions::outline_text`]. Layer, spot color and gradient
|
||||
//! names and the title are written in Windows-1252 too, and a character outside it is written as
|
||||
//! `?`. Two spot colors or two gradients whose names become the same that way are numbered (`? 2`).
|
||||
//!
|
||||
//! The prolog is a procedure set of the writer's own ([`PROLOG`]) that gives the operators their
|
||||
//! meaning in PostScript, so the file also prints and previews as a PostScript program (its
|
||||
@@ -87,6 +89,8 @@ pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above
|
||||
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
|
||||
pub const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is written opaque: legacy .ai files have none";
|
||||
pub const LAYER_NAME: &str = "characters outside Windows Latin-1 in layer names are written as ? (legacy .ai files can't hold them)";
|
||||
pub const TITLE_AND_COLOR_NAMES: &str =
|
||||
"characters outside Windows Latin-1 in the title and in spot color and gradient names are written as ? (legacy .ai files can't hold them)";
|
||||
pub const NAMES: &str = "object names are left out (legacy .ai files name only layers)";
|
||||
const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape";
|
||||
const FREEFORM: &str = "freeform gradients are written as their average colour";
|
||||
@@ -163,11 +167,12 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
|
||||
.filter(|b| b.is_finite())
|
||||
.reduce(|a, b| a.union(b))
|
||||
.unwrap_or(Rect::new(0.0, below, width, top));
|
||||
let title = w.latin_name(&o.title, TITLE_AND_COLOR_NAMES);
|
||||
let mut s = String::new();
|
||||
s.push_str(PS_HEADER);
|
||||
s.push('\n');
|
||||
let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION"));
|
||||
let _ = writeln!(s, "%%Title: {}", ps::string(&o.title));
|
||||
let _ = writeln!(s, "%%Title: {}", latin_string(&title));
|
||||
if let Some(t) = o.created {
|
||||
let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t);
|
||||
let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})");
|
||||
@@ -179,12 +184,12 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
|
||||
s.push('\n');
|
||||
s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n");
|
||||
for (i, (name, _)) in w.spots.iter().enumerate() {
|
||||
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::string(name));
|
||||
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, latin_string(name));
|
||||
}
|
||||
for (i, (name, cmyk)) in w.spots.iter().enumerate() {
|
||||
s.push_str(if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " });
|
||||
ps::push_nums(&mut s, &cmyk.map(f64::from));
|
||||
let _ = writeln!(s, "{}", ps::string(name));
|
||||
let _ = writeln!(s, "{}", latin_string(name));
|
||||
}
|
||||
for (i, (name, _)) in w.fonts.iter().enumerate() {
|
||||
let _ = writeln!(s, "{} {name}", if i == 0 { "%%DocumentFonts:" } else { "%%+" });
|
||||
@@ -335,7 +340,7 @@ const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header com
|
||||
/// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops
|
||||
/// last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`, `%AI5_EndGradient`.
|
||||
fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) {
|
||||
let name = ps::string(name);
|
||||
let name = latin_string(name);
|
||||
let kind = if g.kind == GradientKind::Radial { 1 } else { 0 };
|
||||
let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {kind} {} Bd\n[", g.stops.len());
|
||||
for stop in g.stops.iter().rev() {
|
||||
@@ -356,7 +361,7 @@ fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) {
|
||||
}
|
||||
Ink::Spot { cmyk, name, tint } => {
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let _ = write!(s, "{} ", ps::string(&name));
|
||||
let _ = write!(s, "{} ", latin_string(w.spot_name(&name)));
|
||||
ps::push_num(s, f64::from(1.0 - tint));
|
||||
s.push_str(" 3 ");
|
||||
}
|
||||
@@ -393,6 +398,13 @@ fn invertible(a: Affine) -> bool {
|
||||
det.is_finite() && det.abs() > 1e-12
|
||||
}
|
||||
|
||||
/// Name `name` as a PostScript string of its characters' Windows-1252 codes (a character outside
|
||||
/// Windows-1252 is left out: [`Writer::latin_name`] replaces them first).
|
||||
fn latin_string(name: &str) -> String {
|
||||
let bytes: Vec<u8> = name.chars().filter_map(latin::encode).collect();
|
||||
ps::bytes(&bytes)
|
||||
}
|
||||
|
||||
struct Writer<'a> {
|
||||
doc: &'a Document,
|
||||
/// The document → art space.
|
||||
@@ -401,8 +413,10 @@ struct Writer<'a> {
|
||||
body: String,
|
||||
/// Gradient definitions, by the name objects use.
|
||||
gradients: Vec<(String, Gradient)>,
|
||||
/// Spot colours used: name and full CMYK equivalent.
|
||||
/// Spot colors used: the name written and full CMYK equivalent.
|
||||
spots: Vec<(String, [f32; 4])>,
|
||||
/// The name each spot color (by its swatch's name) is written with ([`Self::spot`]).
|
||||
spot_names: Vec<(String, String)>,
|
||||
warnings: Vec<String>,
|
||||
/// The lock state the last `A` set.
|
||||
locked: bool,
|
||||
@@ -430,6 +444,7 @@ impl<'a> Writer<'a> {
|
||||
body: String::new(),
|
||||
gradients: vec![],
|
||||
spots: vec![],
|
||||
spot_names: vec![],
|
||||
warnings: vec![],
|
||||
locked: false,
|
||||
nest: 0,
|
||||
@@ -454,23 +469,62 @@ impl<'a> Writer<'a> {
|
||||
|
||||
// ---------- layers and objects ----------
|
||||
|
||||
/// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it
|
||||
/// is written as `?`, with a warning.
|
||||
fn layer_name(&mut self, name: &str) -> String {
|
||||
/// `name` with each character outside Windows-1252 (the encoding type and names are written
|
||||
/// in) replaced by `?`; adds the warning `warning` when it replaces one.
|
||||
fn latin_name(&mut self, name: &str, warning: &str) -> String {
|
||||
let mut lost = false;
|
||||
let bytes: Vec<u8> = name
|
||||
let kept = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
latin::encode(c).unwrap_or_else(|| {
|
||||
if latin::encode(c).is_some() {
|
||||
c
|
||||
} else {
|
||||
lost = true;
|
||||
b'?'
|
||||
})
|
||||
'?'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if lost {
|
||||
self.warn(LAYER_NAME);
|
||||
self.warn(warning);
|
||||
}
|
||||
ps::bytes(&bytes)
|
||||
kept
|
||||
}
|
||||
|
||||
/// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it
|
||||
/// is written as `?`, with a warning.
|
||||
fn layer_name(&mut self, name: &str) -> String {
|
||||
latin_string(&self.latin_name(name, LAYER_NAME))
|
||||
}
|
||||
|
||||
/// Note spot color `name` (its swatch's name), of full CMYK equivalent `cmyk`, as a custom
|
||||
/// color of the document, and give the name it is written with. Its characters outside
|
||||
/// Windows-1252 are written as `?` (with a warning), and it is numbered when another spot color
|
||||
/// is already written with that name; the name is the same each time.
|
||||
fn spot(&mut self, name: &str, cmyk: [f32; 4]) -> String {
|
||||
let written = match self.spot_names.iter().find(|(n, _)| n == name) {
|
||||
Some((_, w)) => w.clone(),
|
||||
None => {
|
||||
let base = self.latin_name(name, TITLE_AND_COLOR_NAMES);
|
||||
let mut written = base.clone();
|
||||
let mut i = 1;
|
||||
while self.spot_names.iter().any(|(_, w)| *w == written) {
|
||||
i += 1;
|
||||
written = format!("{base} {i}");
|
||||
}
|
||||
self.spot_names.push((name.to_string(), written.clone()));
|
||||
written
|
||||
}
|
||||
};
|
||||
if !self.spots.iter().any(|(n, _)| *n == written) {
|
||||
self.spots.push((written.clone(), cmyk));
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// The name spot color `name` (its swatch's name) is written with, once [`Self::spot`] has
|
||||
/// noted it.
|
||||
fn spot_name<'n>(&'n self, name: &'n str) -> &'n str {
|
||||
self.spot_names.iter().find(|(n, _)| n == name).map_or(name, |(_, w)| w.as_str())
|
||||
}
|
||||
|
||||
/// A top-level layer. False when it isn't a layer.
|
||||
@@ -965,7 +1019,10 @@ impl<'a> Writer<'a> {
|
||||
|
||||
/// Set the fill (or with `stroke`, the stroke) colour to solid paint `p`.
|
||||
fn color(&mut self, p: &Paint, stroke: bool) {
|
||||
let ink = self.ink(p);
|
||||
let ink = match self.ink(p) {
|
||||
Ink::Spot { cmyk, name, tint } => Ink::Spot { name: self.spot(&name, cmyk), cmyk, tint },
|
||||
ink => ink,
|
||||
};
|
||||
let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill };
|
||||
let s = &mut self.body;
|
||||
match ink {
|
||||
@@ -983,13 +1040,10 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
Ink::Spot { cmyk, name, tint } => {
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let _ = write!(s, "{} ", ps::string(&name));
|
||||
let _ = write!(s, "{} ", latin_string(&name));
|
||||
// The format's tint is how much lighter than the full colour (0: full).
|
||||
ps::push_num(s, f64::from(1.0 - tint));
|
||||
let _ = writeln!(s, " {}", op("x", "X"));
|
||||
if !self.spots.iter().any(|(n, _)| *n == name) {
|
||||
self.spots.push((name, cmyk));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1009,10 +1063,8 @@ impl<'a> Writer<'a> {
|
||||
if let Some(sw) = &stop.swatch {
|
||||
// Spot colours in stops are custom colours too.
|
||||
let p = Paint::Solid { color: stop.color, swatch: Some(sw.clone()), tint: stop.tint };
|
||||
if let Ink::Spot { cmyk, name, .. } = self.ink(&p)
|
||||
&& !self.spots.iter().any(|(n, _)| *n == name)
|
||||
{
|
||||
self.spots.push((name, cmyk));
|
||||
if let Ink::Spot { cmyk, name, .. } = self.ink(&p) {
|
||||
self.spot(&name, cmyk);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1036,21 +1088,21 @@ impl<'a> Writer<'a> {
|
||||
s.push_str("Bh\n");
|
||||
}
|
||||
s.push_str("1 ");
|
||||
s.push_str(&ps::string(&name));
|
||||
s.push_str(&latin_string(&name));
|
||||
s.push(' ');
|
||||
ps::push_nums(&mut s, &[start.x, start.y, angle, length]);
|
||||
s.push_str("1 0 0 1 0 0 Bg\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// The name of the definition of `g` (its swatch's name when it has one), defined the first
|
||||
/// time.
|
||||
/// The name of the definition of `g` (its swatch's name when it has one, with each character
|
||||
/// outside Windows-1252 written as `?` and a warning), defined the first time.
|
||||
fn define(&mut self, g: &Gradient, swatch: Option<&str>) -> String {
|
||||
if let Some((name, _)) = self.gradients.iter().find(|(_, d)| d == g) {
|
||||
return name.clone();
|
||||
}
|
||||
let taken = |n: &str, defs: &[(String, Gradient)]| defs.iter().any(|(m, _)| m == n);
|
||||
let base = swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient").to_string();
|
||||
let base = self.latin_name(swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient"), TITLE_AND_COLOR_NAMES);
|
||||
let mut name = base.clone();
|
||||
let mut i = 1;
|
||||
while taken(&name, &self.gradients) {
|
||||
@@ -1227,7 +1279,9 @@ impl<'a> Writer<'a> {
|
||||
/// An image (§8): `%AI5_File:`, `%AI5_BeginRaster`, its matrix (an art-space matrix applied to
|
||||
/// the pixels with their rows running downwards, `(u, -v)`) as `[ a b c d tx ty ]` and its
|
||||
/// operands with `XI`, its RGB samples in hex on comment lines of [`HEX_LINE`] digits,
|
||||
/// `%AI5_EndRaster`.
|
||||
/// `%AI5_EndRaster`, then `F`. §8.4's examples have no `F`; without it, an app reading the file
|
||||
/// can leave out an image in a clipping group and the art after it. No path is open there, so
|
||||
/// the `F` paints nothing.
|
||||
fn image(&mut self, im: &ImageObject) {
|
||||
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
let m = self.to_art * im.xf * flip;
|
||||
@@ -1281,7 +1335,7 @@ impl<'a> Writer<'a> {
|
||||
ps::push_nums(s, &[a, b, c, d, e, f]);
|
||||
let _ = writeln!(s, "] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI");
|
||||
s.push_str(&hex);
|
||||
s.push_str("%AI5_EndRaster\n");
|
||||
s.push_str("%AI5_EndRaster\nF\n");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ mod ps;
|
||||
mod scene;
|
||||
mod tiff;
|
||||
|
||||
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai};
|
||||
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai, page};
|
||||
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
|
||||
pub use print::{PrintJob, PrintPage, print};
|
||||
|
||||
|
||||
@@ -316,14 +316,15 @@ fn images_below_a_square(images: bool) -> Document {
|
||||
doc
|
||||
}
|
||||
|
||||
/// Each image the file holds (§8.4): the lines from `%AI5_File:` to `%AI5_EndRaster`.
|
||||
/// Each image the file holds (§8.4): the lines from `%AI5_File:` to the line after
|
||||
/// `%AI5_EndRaster`.
|
||||
fn rasters(text: &str) -> Vec<Vec<&str>> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut out = vec![];
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
if *l == "%AI5_File:" {
|
||||
let end = lines[i..].iter().position(|l| *l == "%AI5_EndRaster").map(|n| i + n).unwrap();
|
||||
out.push(lines[i..=end].to_vec());
|
||||
out.push(lines[i..=(end + 1).min(lines.len() - 1)].to_vec());
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -332,8 +333,9 @@ fn rasters(text: &str) -> Vec<Vec<&str>> {
|
||||
/// Every image, in a clipping group, a group or neither, is written as §8.1's syntax and §8.4's
|
||||
/// examples write one: `%AI5_File:`, `%AI5_BeginRaster`, the matrix with a space inside each
|
||||
/// bracket and the operands on the `XI` line, the samples in hex on comment lines of 60 digits,
|
||||
/// `%AI5_EndRaster`. The art after the images is written as it is without them, and the file
|
||||
/// reads back with its layers, clipping group, images and square (#2692).
|
||||
/// `%AI5_EndRaster`. A line `F`, which the examples do not show, follows each image. The art
|
||||
/// after the images is written as it is without them, and the file reads back with its layers,
|
||||
/// clipping group, images and square (#2692, #2830).
|
||||
#[test]
|
||||
fn images_are_written_in_the_form_the_specification_shows() {
|
||||
let out = write(&images_below_a_square(true));
|
||||
@@ -341,8 +343,8 @@ fn images_are_written_in_the_form_the_specification_shows() {
|
||||
let images = rasters(&text);
|
||||
assert_eq!(images.len(), 3, "{text}");
|
||||
for lines in &images {
|
||||
let [file, begin, xi, data @ .., end] = lines.as_slice() else { panic!("{lines:?}") };
|
||||
assert_eq!((*file, *begin, *end), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster"));
|
||||
let [file, begin, xi, data @ .., end, paint] = lines.as_slice() else { panic!("{lines:?}") };
|
||||
assert_eq!((*file, *begin, *end, *paint), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster", "F"));
|
||||
let matrix = xi.strip_prefix("[ ").and_then(|l| l.strip_suffix(" ] 0 0 16 16 16 16 8 3 0 0 0 0 XI")).unwrap_or_else(|| panic!("{xi}"));
|
||||
assert_eq!(matrix.split(' ').filter(|v| v.parse::<f64>().is_ok()).count(), 6, "{xi}");
|
||||
let digits: Vec<&str> = data.iter().map(|l| l.strip_prefix('%').unwrap_or_else(|| panic!("{l}"))).collect();
|
||||
@@ -352,9 +354,10 @@ fn images_are_written_in_the_form_the_specification_shows() {
|
||||
assert_eq!(digits.concat().len(), 16 * 16 * 3 * 2);
|
||||
}
|
||||
// The clipping group: `q`, the clipping path painted `h W n` (§5.6.2), the image, `Q`; the
|
||||
// group: `u`, the image, `U`.
|
||||
assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\n"), "{text}");
|
||||
assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nU\n"), "{text}");
|
||||
// group: `u`, the image, `U`; the loose image, then the layer's end.
|
||||
assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nQ\n"), "{text}");
|
||||
assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nU\n"), "{text}");
|
||||
assert!(text.contains("\n%AI5_EndRaster\nF\nLB\n"), "{text}");
|
||||
let layer = |t: &str| -> String {
|
||||
let at = t.find("(Above) Ln\n").unwrap();
|
||||
t[at..].split("%AI5_EndLayer--").next().unwrap().to_string()
|
||||
@@ -367,8 +370,11 @@ fn images_are_written_in_the_form_the_specification_shows() {
|
||||
let below = children(&doc.layers[0]);
|
||||
assert_eq!(below.len(), 3, "{below:#?}");
|
||||
let NodeKind::Group { children: window, clip: true } = &below[0].kind else { panic!("{:?}", below[0].kind) };
|
||||
assert_eq!(window.len(), 2, "{window:#?}");
|
||||
assert!(matches!(window[0].kind, NodeKind::Path { clipping: true, .. }) && matches!(window[1].kind, NodeKind::Image(_)), "{window:#?}");
|
||||
assert!(matches!(&below[1].kind, NodeKind::Group { children, clip: false } if matches!(children[0].kind, NodeKind::Image(_))));
|
||||
assert!(
|
||||
matches!(&below[1].kind, NodeKind::Group { children, clip: false } if children.len() == 1 && matches!(children[0].kind, NodeKind::Image(_)))
|
||||
);
|
||||
assert!(matches!(below[2].kind, NodeKind::Image(_)));
|
||||
let above = children(&doc.layers[1]);
|
||||
assert_eq!(above.len(), 1);
|
||||
@@ -606,6 +612,127 @@ fn spot_colours_are_custom_colours() {
|
||||
assert!((tint - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// A document with a spot color swatch for each of `spots`, a square filled with each at half
|
||||
/// tint, and a square whose linear gradient fill (the first spot color to white) names the
|
||||
/// gradient swatch `gradient` (the document holds no such swatch; the writer reads only the
|
||||
/// fill's name).
|
||||
fn spots_and_gradient(spots: &[&str], gradient: &str) -> Document {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
for (i, name) in spots.iter().enumerate() {
|
||||
let c = i as f32 * 0.5;
|
||||
doc.swatches.push(vectorcraft_color::Swatch {
|
||||
name: (*name).into(),
|
||||
paint: Paint::solid(Color::cmyk(c, 0.5, 1.0, 0.0)),
|
||||
global: true,
|
||||
spot: true,
|
||||
});
|
||||
let fill = Paint::Solid { color: Color::cmyk(c / 2.0, 0.25, 0.5, 0.0), swatch: Some((*name).into()), tint: 0.5 };
|
||||
let x = 10.0 + 40.0 * i as f64;
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 30.0, 40.0)), fill, Paint::None);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let mut first = GradientStop::new(0.0, Color::cmyk(0.0, 0.5, 1.0, 0.0));
|
||||
first.swatch = Some(spots[0].into());
|
||||
let g = Gradient { kind: GradientKind::Linear, stops: vec![first, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))] };
|
||||
let mut paint = GradientPaint::new(g);
|
||||
paint.swatch = Some(gradient.into());
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 50.0, 50.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// `doc` written with the title `title`, and run as a PostScript program ([`prints_as`]).
|
||||
fn write_titled(doc: &Document, title: &str) -> LegacyAiOutput {
|
||||
let o = LegacyAiOptions { title: title.into(), created: Some(0), ..LegacyAiOptions::default() };
|
||||
let out = export_legacy_ai(doc, &o).unwrap();
|
||||
prints_as(&out.bytes, doc, &o);
|
||||
out
|
||||
}
|
||||
|
||||
/// The spot colors and gradients of the objects of `doc`'s first layer, in paint order: each
|
||||
/// solid fill's swatch, and each gradient fill's swatch with its first stop's.
|
||||
fn inks(doc: &Document) -> Vec<Option<String>> {
|
||||
let mut out = vec![];
|
||||
for n in children(&doc.layers[0]) {
|
||||
match n.appearance.fill_paint() {
|
||||
Paint::Solid { swatch, .. } => out.push(swatch.clone()),
|
||||
Paint::Gradient(g) => {
|
||||
out.push(g.swatch.clone());
|
||||
out.push(g.gradient.stops.first().and_then(|s| s.swatch.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The title, spot color names and gradient names are written in Windows-1252, as layer names
|
||||
/// are: é is 351 (octal) in the header's custom colors, each `x`, the gradient's definition, its
|
||||
/// stops and `Bg`. The spot color and gradient names read back as they were.
|
||||
#[test]
|
||||
fn spot_gradient_and_title_names_are_written_in_windows_1252() {
|
||||
let doc = spots_and_gradient(&["Caf\u{e9}"], "D\u{e9}grad\u{e9}");
|
||||
let out = write_titled(&doc, "R\u{e9}sum\u{e9}");
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
for needle in [
|
||||
"%%Title: (R\\351sum\\351)\n",
|
||||
"%%DocumentCustomColors: (Caf\\351)\n",
|
||||
"%%CMYKCustomColor: 0 0.5 1 0 (Caf\\351)\n",
|
||||
"0 0.5 1 0 (Caf\\351) 0.5 x\n",
|
||||
"%AI5_BeginGradient: (D\\351grad\\351)\n(D\\351grad\\351) 0 2 Bd\n",
|
||||
"0 0.5 1 0 (Caf\\351) 0 3 50 0 %_Bs\n",
|
||||
"1 (D\\351grad\\351) ",
|
||||
] {
|
||||
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
|
||||
}
|
||||
assert!(!text.contains("\\303"), "{text}");
|
||||
assert!(!out.warnings.iter().any(|w| w == TITLE_AND_COLOR_NAMES), "{:?}", out.warnings);
|
||||
let read = crate::import(&out.bytes).unwrap().document;
|
||||
let names = inks(&read);
|
||||
let want = ["Caf\u{e9}", "D\u{e9}grad\u{e9}", "Caf\u{e9}"].map(|n| Some(n.to_string()));
|
||||
assert_eq!(names, want);
|
||||
}
|
||||
|
||||
/// A character Windows-1252 can't hold is written as `?`, with one warning for all of these
|
||||
/// names. Names that become the same are numbered, so the header, each `x` and each `Bg` still
|
||||
/// tell the colors and gradients apart.
|
||||
#[test]
|
||||
fn names_outside_windows_1252_are_replaced_and_kept_apart() {
|
||||
// 赤 and 青 (spot colors), 渐变 and グラ (gradients), 日本 (the title).
|
||||
let mut doc = spots_and_gradient(&["\u{8d64}", "\u{9752}"], "\u{6e10}\u{53d8}");
|
||||
let g = Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))],
|
||||
};
|
||||
let mut paint = GradientPaint::new(g);
|
||||
paint.swatch = Some("\u{30b0}\u{30e9}".into());
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(60.0, 50.0, 100.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = write_titled(&doc, "\u{65e5}\u{672c}");
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
for needle in [
|
||||
"%%Title: (??)\n",
|
||||
"%%DocumentCustomColors: (?)\n%%+ (? 2)\n",
|
||||
"%%CMYKCustomColor: 0 0.5 1 0 (?)\n%%+ 0.5 0.5 1 0 (? 2)\n",
|
||||
"0 0.5 1 0 (?) 0.5 x\n",
|
||||
"0.5 0.5 1 0 (? 2) 0.5 x\n",
|
||||
"(??) 0 2 Bd\n",
|
||||
"(?? 2) 0 2 Bd\n",
|
||||
"0 0.5 1 0 (?) 0 3 50 0 %_Bs\n",
|
||||
"1 (??) ",
|
||||
"1 (?? 2) ",
|
||||
] {
|
||||
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
|
||||
}
|
||||
assert_eq!(out.warnings.iter().filter(|w| *w == TITLE_AND_COLOR_NAMES).count(), 1, "{:?}", out.warnings);
|
||||
assert!(!out.warnings.iter().any(|w| w == LAYER_NAME), "{:?}", out.warnings);
|
||||
let read = crate::import(&out.bytes).unwrap().document;
|
||||
let want = [Some("?"), Some("? 2"), Some("??"), Some("?"), Some("?? 2"), None].map(|n| n.map(String::from));
|
||||
assert_eq!(inks(&read), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losses_are_warned() {
|
||||
let mut doc = sample();
|
||||
|
||||
@@ -221,6 +221,19 @@ impl SubPath {
|
||||
let b = &self.anchors[(i + 1) % n];
|
||||
!a.has_out() && !b.has_in()
|
||||
}
|
||||
/// Arc-length at each anchor from the start of this subpath (one entry per anchor).
|
||||
pub fn anchor_arc_positions(&self) -> Vec<f64> {
|
||||
let n = self.anchors.len();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let mut pos = vec![0.0];
|
||||
for i in 0..self.segment_count() {
|
||||
let len = kurbo::ParamCurveArclen::arclen(&self.segment(i), 1e-3);
|
||||
pos.push(pos.last().copied().unwrap_or(0.0) + len);
|
||||
}
|
||||
pos
|
||||
}
|
||||
pub fn to_bezpath_into(&self, out: &mut BezPath) {
|
||||
let Some(first) = self.anchors.first() else { return };
|
||||
out.move_to(first.p);
|
||||
|
||||
@@ -92,6 +92,7 @@ fn export_tool_lists_engine_formats_and_options() {
|
||||
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
|
||||
assert!(open["description"].as_str().unwrap().contains(".ait"));
|
||||
assert!(open["description"].as_str().unwrap().contains(".eps, .epsf, .ps, "), "{open}");
|
||||
assert!(open["description"].as_str().unwrap().contains(".bmp, .tga, .vda, .icb, .vst, "), "{open}");
|
||||
}
|
||||
|
||||
/// A fake app that records `engine.execute` calls and answers each with an empty object.
|
||||
@@ -132,6 +133,17 @@ fn export_is_the_same_call_in_the_app_and_headless() {
|
||||
assert_eq!(direct["dataBase64"], r["dataBase64"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_raster_scale_leaves_other_formats_alone() {
|
||||
// DXF's own `scale` (drawing units per mm) comes through `options`; the raster one would resize
|
||||
// the drawing and leave its units unset.
|
||||
let mut h = two_boards();
|
||||
let dxf = |h: &mut Headless, args: Value| text(&call_tool(h, "export", &args))["dataBase64"].clone();
|
||||
let plain = dxf(&mut h, json!({"format": "dxf"}));
|
||||
assert!(dxf(&mut h, json!({"format": "dxf", "scale": 2})) == plain, "the raster scale changed the DXF");
|
||||
assert!(dxf(&mut h, json!({"format": "dxf", "options": {"scale": 2}})) != plain, "the DXF scale was dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_native_keeps_the_document_path() {
|
||||
let mut h = two_boards();
|
||||
|
||||
+16
-4
@@ -2,7 +2,7 @@
|
||||
//! [`Backend`].
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format, writable_format};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
@@ -1003,9 +1003,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
params.remove(k);
|
||||
}
|
||||
}
|
||||
for k in ["path", "format", "scale", "artboard", "range", "outlineText"] {
|
||||
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
||||
params.insert(k.into(), v.clone());
|
||||
for k in named_export_params(a) {
|
||||
if let Some(v) = a.get(*k).filter(|v| !v.is_null()) {
|
||||
params.insert((*k).into(), v.clone());
|
||||
}
|
||||
}
|
||||
let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" };
|
||||
@@ -1054,6 +1054,18 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
}
|
||||
}
|
||||
|
||||
/// The named `export` arguments passed on to `document.export`. `scale` is raster pixels per
|
||||
/// point, so only a raster format gets it: DXF's own (drawing units per unit, given in `options`)
|
||||
/// would resize the drawing and leave its units unset.
|
||||
fn named_export_params(a: &Args) -> &'static [&'static str] {
|
||||
let format = writable_format(a.get("format").and_then(Value::as_str), a.get("path").and_then(Value::as_str));
|
||||
if format.is_ok_and(|f| f.raster) {
|
||||
&["path", "format", "scale", "artboard", "range", "outlineText"]
|
||||
} else {
|
||||
&["path", "format", "artboard", "range", "outlineText"]
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an
|
||||
/// `isError` result so the model can read and correct them.
|
||||
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
|
||||
@@ -13,6 +13,7 @@ use krilla::metadata::DateTime;
|
||||
|
||||
use crate::PdfError;
|
||||
use crate::import_color::items;
|
||||
use crate::import_scan::text_string;
|
||||
use crate::lab_spot::find;
|
||||
|
||||
/// The embedded file holding the native document (its name among the PDF's attachments).
|
||||
@@ -84,8 +85,9 @@ pub(crate) fn editing_in(pdf: &Pdf) -> Option<Editing> {
|
||||
let files = spec.get::<Dict<'_>>(b"EF")?;
|
||||
let stream = files.get::<Stream<'_>>(b"F").or_else(|| files.get::<Stream<'_>>(b"UF"))?;
|
||||
let data = stream.decoded().map(|d| d.into_owned()).unwrap_or_default();
|
||||
// The hash ends the description; editing data described otherwise is trusted.
|
||||
let desc = spec.get::<PdfString<'_>>(b"Desc").map(|d| String::from_utf8_lossy(d.as_bytes()).into_owned()).unwrap_or_default();
|
||||
// The hash ends the description (a text string, in any of its encodings); editing data
|
||||
// described otherwise is trusted.
|
||||
let desc = spec.get::<PdfString<'_>>(b"Desc").map(|d| text_string(d.as_bytes())).unwrap_or_default();
|
||||
let intact = match desc.rsplit_once("hash ") {
|
||||
Some((_, hash)) => hash.trim() == page_hash(pdf),
|
||||
None => true,
|
||||
@@ -119,11 +121,12 @@ fn editing_spec<'a>(node: &Dict<'a>, depth: usize, seen: &mut HashSet<ObjectIden
|
||||
})
|
||||
}
|
||||
|
||||
/// A file specification naming the editing file (current or legacy name).
|
||||
/// A file specification naming the editing file (current or legacy name), in any text string
|
||||
/// encoding.
|
||||
fn is_editing_file(spec: &Dict<'_>) -> bool {
|
||||
[&b"UF"[..], b"F"].into_iter().filter_map(|k| spec.get::<PdfString<'_>>(k)).any(|name| {
|
||||
let name = name.as_bytes();
|
||||
name == EDITING_FILE.as_bytes() || name == LEGACY_EDITING_FILE.as_bytes()
|
||||
let name = text_string(name.as_bytes());
|
||||
name == EDITING_FILE || name == LEGACY_EDITING_FILE
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,35 @@ fn changed_pages_are_noticed() {
|
||||
assert!(editing(&r.bytes).unwrap().intact);
|
||||
}
|
||||
|
||||
/// `pdf` with its editing file's description rewritten by another app as "hash " and the hash it
|
||||
/// held, in the text string encoding `bom` starts (UTF-16BE or UTF-8), padded with spaces to its
|
||||
/// length.
|
||||
fn described(pdf: &[u8], bom: &[u8]) -> Vec<u8> {
|
||||
let from = b"(VectorCraft editing data; page hash ";
|
||||
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description"));
|
||||
let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17);
|
||||
let mut desc = [b"(", bom].concat();
|
||||
for &c in b"hash ".iter().chain(hash) {
|
||||
if bom.len() == 2 {
|
||||
desc.push(0);
|
||||
}
|
||||
desc.push(c);
|
||||
}
|
||||
desc.push(b')');
|
||||
desc.resize(len, b' ');
|
||||
patched(pdf, &pdf[at..at + len], &desc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hash_is_read_from_a_description_in_any_encoding() {
|
||||
let r = pdf_with(Some(b"native"), false);
|
||||
let resized = patched(&r.bytes, b"/MediaBox[0 0 100 100]", b"/MediaBox[0 0 100 101]");
|
||||
for bom in [&[0xFE, 0xFF][..], &[0xEF, 0xBB, 0xBF]] {
|
||||
assert!(editing(&described(&r.bytes, bom)).unwrap().intact, "{bom:?}: the same pages");
|
||||
assert!(!editing(&described(&resized, bom)).unwrap().intact, "{bom:?}: another page size");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legacy_name_is_read_and_plain_pdfs_have_no_editing_data() {
|
||||
let r = pdf_with(Some(b"legacy"), false);
|
||||
@@ -153,3 +182,20 @@ fn direct_name_tree_kids_are_all_searched() {
|
||||
let e = editing(&raw_pdf(&objects)).unwrap();
|
||||
assert_eq!(e.data, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_name_written_as_utf16_is_found() {
|
||||
let utf16: String = EDITING_FILE.bytes().map(|c| format!("00{c:02X}")).collect();
|
||||
for key in ["UF", "F"] {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 3 0 R >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [6 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Names [(a) 4 0 R] >>".to_string(),
|
||||
format!("<< /Type /Filespec /{key} <FEFF{utf16}> /EF << /F 5 0 R >> >>"),
|
||||
"<< /Length 5 >>\nstream\nhello\nendstream".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>".to_string(),
|
||||
];
|
||||
let e = editing(&raw_pdf(&objects)).unwrap_or_else(|| panic!("/{key}: not found"));
|
||||
assert_eq!(e.data, b"hello", "/{key}");
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -584,7 +584,7 @@ thread_local! {
|
||||
/// Document Raster Effects Settings › Resolution of `doc`, in ppi (1 to 2400; 72 when it isn't a
|
||||
/// number).
|
||||
fn doc_raster_ppi(doc: &Document) -> f64 {
|
||||
Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
|
||||
doc.raster_ppi()
|
||||
}
|
||||
|
||||
/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the
|
||||
@@ -1052,7 +1052,9 @@ impl Renderer {
|
||||
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved)
|
||||
}
|
||||
None => {
|
||||
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
|
||||
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
|
||||
let grow = if matches!(fx, RasterFx::OuterGlow { .. }) { 18.0 / doc_raster_ppi(f.doc) } else { 0.0 };
|
||||
let spread = (blur.max(0.0) * 1.5 + grow) / f.px + 2.0;
|
||||
let reach = reach + Vec2::new(dx, dy);
|
||||
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread);
|
||||
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
||||
@@ -1112,6 +1114,7 @@ impl Renderer {
|
||||
// An Outer Glow blurs a solid silhouette: wherever the art has any alpha, as AI files draw a glow round a glow.
|
||||
let solid = matches!(fx, RasterFx::OuterGlow { .. });
|
||||
let mut a: Vec<f32> = pm.data().iter().map(|p| if solid && p.a > 0 { 255.0 } else { p.a as f32 }).collect();
|
||||
effects::pixel::dilate_plane(&mut a, wu, hu, grow / f.px / scale);
|
||||
effects::pixel::blur_plane(&mut a, wu, hu, sigma);
|
||||
let [cr, cg, cb] = f.ink.rgb(&color);
|
||||
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
|
||||
@@ -2589,6 +2592,21 @@ mod tests {
|
||||
assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outer_glow_grows_its_silhouette_by_a_quarter_raster_pixel() {
|
||||
// GUARDS: the glows AI files export measure as the solid silhouette grown by a quarter of a pixel
|
||||
// of the document's raster grid, then blurred: a quarter point at 72 ppi, next to nothing at 720.
|
||||
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
|
||||
let green_outside = |ppi: f64| {
|
||||
let mut d =
|
||||
doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]);
|
||||
d.raster_effects_ppi = ppi;
|
||||
render(&d).pixel(27, 50)[1]
|
||||
};
|
||||
let (coarse, fine) = (green_outside(72.0), green_outside(720.0));
|
||||
assert!(coarse + 3 < fine, "the red glow is stronger at 72 ppi: 72 ppi {coarse}, 720 ppi {fine}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glow_glows_round_the_glows_before_it_in_the_stack() {
|
||||
// GUARDS: the second glow is of the art with the first glow, not of the bare art, as AI files and the SVG export draw it.
|
||||
|
||||
@@ -1222,7 +1222,9 @@ impl Renderer {
|
||||
/// In isolation mode, while drawing outside the isolated container and the art dimmed around
|
||||
/// it: the isolated container and its layers and groups, from the top layer down to it.
|
||||
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
|
||||
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
|
||||
// Opacity-mask editing isolates the temporary layer for tools; that layer is not painted on
|
||||
// the page (the mask applies on the object), so canvas dimming would wash out everything.
|
||||
let isolated = f.opts.isolated.filter(|_| f.doc.mask_edit.is_none() && !self.isolation_settled)?;
|
||||
let doc = std::ptr::from_ref(f.doc) as usize;
|
||||
if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) {
|
||||
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
|
||||
|
||||
@@ -1651,7 +1651,11 @@ impl Writer<'_> {
|
||||
return 0;
|
||||
}
|
||||
let bounds = n.visual_bounds_with(&self.doc.symbol_boxes());
|
||||
let reach: f64 = fx.iter().map(|f| f.outset(bounds.unwrap_or_default())).sum::<f64>() + 2.0;
|
||||
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
|
||||
let grow = 18.0 / self.doc.raster_ppi();
|
||||
let reach: f64 =
|
||||
fx.iter().map(|f| f.outset(bounds.unwrap_or_default()) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }).sum::<f64>()
|
||||
+ 2.0;
|
||||
let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
|
||||
let mut opened = 0;
|
||||
for f in fx.iter().rev() {
|
||||
@@ -1691,9 +1695,10 @@ impl Writer<'_> {
|
||||
self.num(*dy),
|
||||
flood(color, *opacity)
|
||||
),
|
||||
// The glow blurs a solid silhouette: alpha × 255 makes any alpha full.
|
||||
// The glow blurs a solid silhouette (alpha × 255 makes any alpha full) grown by a quarter raster pixel.
|
||||
RasterFx::OuterGlow { opacity, blur, color, .. } => format!(
|
||||
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"linear\" slope=\"255\"/></feComponentTransfer><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"linear\" slope=\"255\"/></feComponentTransfer><feMorphology operator=\"dilate\" radius=\"{}\"/><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
fmt_num(grow, 3),
|
||||
sd(*blur),
|
||||
flood(color, *opacity)
|
||||
),
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use usvg::filter::{ColorInterpolation, ColorMatrixKind, CompositeOperator, Filter, Input, Kind, TransferFunction};
|
||||
use usvg::filter::{ColorInterpolation, ColorMatrixKind, CompositeOperator, Filter, Input, Kind, MorphologyOperator, TransferFunction};
|
||||
use usvg::roxmltree;
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::Effect;
|
||||
@@ -177,8 +177,9 @@ fn color(c: usvg::Color) -> Color {
|
||||
Color::rgb8(c.red, c.green, c.blue)
|
||||
}
|
||||
|
||||
/// Evaluate one filter's primitives; `None` when one does something the effects can't.
|
||||
fn eval(f: &Filter) -> Option<V> {
|
||||
/// Evaluate one filter's primitives, `k` the scale from the filter's user space to the document;
|
||||
/// `None` when one does something the effects can't.
|
||||
fn eval(f: &Filter, k: f64) -> Option<V> {
|
||||
let mut results: HashMap<&str, V> = HashMap::new();
|
||||
let mut last = V::Graphic;
|
||||
for p in f.primitives() {
|
||||
@@ -238,6 +239,18 @@ fn eval(f: &Filter) -> Option<V> {
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
// Our Outer Glow's silhouette grown by a quarter raster pixel (`export`): a dilation of a
|
||||
// plain silhouette by up to that much at 72 ppi is that step.
|
||||
Kind::Morphology(m)
|
||||
if m.operator() == MorphologyOperator::Dilate
|
||||
&& m.radius_x().get() == m.radius_y().get()
|
||||
&& f64::from(m.radius_x().get()) * k <= 0.25 + 1e-6 =>
|
||||
{
|
||||
match input(m.input())? {
|
||||
V::Alpha(s) if s.plain() => V::Alpha(s),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Kind::DropShadow(d) if matches!(input(d.input())?, V::Graphic) => {
|
||||
let shape =
|
||||
Shape { sd: sd(d.std_dev_x().get(), d.std_dev_y().get()), dx: d.dx() as f64, dy: d.dy() as f64, moved: true, inverted: false };
|
||||
@@ -264,7 +277,7 @@ pub(super) fn effects(filters: &[Arc<Filter>], ts: Affine, blends: &HashMap<Stri
|
||||
let blur = |sd: f64| num(2.0 * sd * k);
|
||||
let mode = blends.get(f.id()).map_or("normal", String::as_str);
|
||||
let paint = |c: Color, o: f32| (c.to_hex(), num(o as f64 * 100.0));
|
||||
let (id, params) = match eval(f)? {
|
||||
let (id, params) = match eval(f, k)? {
|
||||
V::Graphic => continue,
|
||||
V::Blurred(sd, _) => ("blur.gaussian", json!({ "radius": blur(sd) })),
|
||||
V::Feathered(sd) => ("stylize.feather", json!({ "radius": blur(sd) })),
|
||||
|
||||
@@ -27,6 +27,9 @@ pub struct GestureTool {
|
||||
began: bool,
|
||||
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
|
||||
extend: Option<(NodeId, bool)>,
|
||||
/// Pencil/Paintbrush starting on a selected path away from its ends: (path, Edit Selected
|
||||
/// Paths' distance in points); the stroke redraws the section it ends on (#2850).
|
||||
redraw: Option<(NodeId, f64)>,
|
||||
/// Fit tolerance in points (Pencil/Paintbrush/Smooth "Fidelity").
|
||||
pub fidelity: f64,
|
||||
/// Brush / eraser diameter in points.
|
||||
@@ -66,6 +69,7 @@ impl GestureTool {
|
||||
active: false,
|
||||
began: false,
|
||||
extend: None,
|
||||
redraw: None,
|
||||
fidelity,
|
||||
size: 10.0,
|
||||
fill: false,
|
||||
@@ -143,6 +147,18 @@ impl GestureTool {
|
||||
None
|
||||
}
|
||||
|
||||
/// The selected path whose outline is near `p` (Edit Selected Paths, off at 0 pixels).
|
||||
fn find_redraw(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, f64)> {
|
||||
let tol = cx.tol(self.edit_within);
|
||||
if self.edit_within <= 0.0 || !tol.is_finite() {
|
||||
return None;
|
||||
}
|
||||
cx.selection.objects.iter().find_map(|id| {
|
||||
let pd = cx.doc.node(*id).and_then(|n| n.path_data()).filter(|_| cx.doc.is_editable(*id))?;
|
||||
pd.nearest(p).filter(|n| n.4 <= tol).map(|_| (*id, tol))
|
||||
})
|
||||
}
|
||||
|
||||
/// Parameters of `path.freehand` for the current stroke.
|
||||
pub fn freehand_params(&self, closed: bool) -> Value {
|
||||
let mut v = json!({
|
||||
@@ -155,6 +171,8 @@ impl GestureTool {
|
||||
});
|
||||
if let Some((id, start)) = self.extend {
|
||||
v["extend"] = json!({"id": id.0, "end": if start { "start" } else { "end" }});
|
||||
} else if let Some((id, within)) = self.redraw {
|
||||
v["redraw"] = json!({"id": id.0, "within": within});
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -211,8 +229,9 @@ impl Tool for GestureTool {
|
||||
self.active = true;
|
||||
self.began = false;
|
||||
self.extend = if self.draws_path() { self.find_extend(cx, p) } else { None };
|
||||
// A path continued starts at its end; a new one where it snaps.
|
||||
let start = if self.snaps() && self.extend.is_none() { self.snap.press(cx, p, &[], None) } else { p };
|
||||
self.redraw = if self.draws_path() && self.extend.is_none() { self.find_redraw(cx, p) } else { None };
|
||||
// A path continued starts at its end, one redrawn on it; a new one where it snaps.
|
||||
let start = if self.snaps() && self.extend.is_none() && self.redraw.is_none() { self.snap.press(cx, p, &[], None) } else { p };
|
||||
self.clear();
|
||||
self.push(start, ev.pressure);
|
||||
vec![]
|
||||
@@ -260,6 +279,7 @@ impl Tool for GestureTool {
|
||||
self.began = false;
|
||||
self.clear();
|
||||
self.extend = None;
|
||||
self.redraw = None;
|
||||
out
|
||||
}
|
||||
_ => vec![],
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
//!
|
||||
//! Type: click places point type, drag draws an area-type frame, clicking into existing text
|
||||
//! places the caret. Area Type / Type on a Path: click a path to turn it into a text frame or a
|
||||
//! baseline (`text.createInPath`). Point type placed by a click and left empty is discarded when
|
||||
//! editing ends (`text.discardEmpty`); frames keep their shape. Where new type goes (the click, the
|
||||
//! frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too.
|
||||
//! baseline (`text.createInPath`). While type is being edited, a click that lands on no type only
|
||||
//! ends the editing, and the next click makes new type. Point type placed by a click and left
|
||||
//! empty is discarded when editing ends (`text.discardEmpty`); frames keep their shape. Where new
|
||||
//! type goes (the click, the frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too.
|
||||
//!
|
||||
//! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends
|
||||
//! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a
|
||||
@@ -271,14 +272,21 @@ impl TypeTool {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The button released after a press at `start` (new type goes at `at`).
|
||||
/// The button released after a press at `start` (new type goes at `at`). While type is being
|
||||
/// edited, a click that lands on no type only ends the editing; the next click makes new type
|
||||
/// (#2871). A drag that draws an area-type frame makes it either way.
|
||||
fn on_up(&mut self, cx: &ToolContext, start: Point, at: Point) -> Vec<Action> {
|
||||
// Click into existing text: place the caret.
|
||||
if let Some(out) = self.edit_at(cx, start) {
|
||||
return out;
|
||||
}
|
||||
let was_editing = self.editing.is_some_and(|id| Self::text(cx, id).is_some());
|
||||
let mut out = self.finish(cx);
|
||||
let drag = self.drag.take();
|
||||
let area = drag.map(|d| Rect::from_points(at, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
|
||||
if was_editing && area.is_none() {
|
||||
return out;
|
||||
}
|
||||
// Area Type / Type on a Path: click a path.
|
||||
if self.mode != Mode::Type && drag.is_none() {
|
||||
let on_path = self.mode == Mode::OnPath;
|
||||
@@ -292,7 +300,6 @@ impl TypeTool {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
let area = drag.map(|d| Rect::from_points(at, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
|
||||
let mut params = match area {
|
||||
Some(r) => json!({"x": r.x0, "y": r.y0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
|
||||
None => json!({"x": at.x, "y": at.y, "text": ""}),
|
||||
|
||||
@@ -401,6 +401,108 @@ fn escape_returns_to_previous_tool_when_set() {
|
||||
assert_eq!(acts2.last(), Some(&Action::SwitchTool("selection".into())));
|
||||
}
|
||||
|
||||
/// A click from `tool` at (x, y) → the actions of the press and the release.
|
||||
fn click(tool: &mut TypeTool, c: &ToolContext, x: f64, y: f64) -> Vec<Action> {
|
||||
let mut acts = tool.pointer(c, &PointerEvent::new(PointerKind::Down, x, y));
|
||||
acts.extend(tool.pointer(c, &PointerEvent::new(PointerKind::Up, x, y)));
|
||||
acts
|
||||
}
|
||||
|
||||
fn creates(acts: &[Action]) -> bool {
|
||||
acts.iter().any(|a| matches!(a, Action::Exec(cmd, _) if cmd == "text.create" || cmd == "text.createInPath"))
|
||||
}
|
||||
|
||||
/// While type is being edited, a click on blank canvas ends the editing and makes no new type;
|
||||
/// the Type tool stays, and the next click makes point type (#2871, #2854). The same holds when
|
||||
/// the editing began with a double-click from a selection tool.
|
||||
#[test]
|
||||
fn a_click_away_from_the_type_being_edited_only_ends_editing() {
|
||||
for prev in [None, Some("selection")] {
|
||||
let (d, _, mut tool) = editing("Hello");
|
||||
if let Some(prev) = prev {
|
||||
tool.set_option("prevTool", &json!(prev));
|
||||
}
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &sel, &p);
|
||||
let acts = click(&mut tool, &c, 400.0, 400.0);
|
||||
assert!(!creates(&acts), "{prev:?}: {acts:?}");
|
||||
assert!(!acts.iter().any(|a| matches!(a, Action::SwitchTool(_))), "{prev:?}: {acts:?}");
|
||||
assert_eq!((tool.id(), tool.editing), ("type", None));
|
||||
assert_eq!(tool.prev_tool.as_deref(), prev);
|
||||
let acts = click(&mut tool, &c, 400.0, 400.0);
|
||||
assert!(
|
||||
acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 400.0 && v.get("area").is_none())),
|
||||
"{acts:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Point type a click placed and left empty is still discarded by the click away, and that click
|
||||
/// places no other type.
|
||||
#[test]
|
||||
fn a_click_away_from_empty_new_type_discards_it_and_makes_none() {
|
||||
let (d, id, mut tool) = editing("");
|
||||
tool.fresh = Some(id);
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let acts = click(&mut tool, &cx(&d, &sel, &p), 400.0, 400.0);
|
||||
assert_eq!(acts, vec![Action::Exec("text.discardEmpty".into(), json!({"id": id.0}))]);
|
||||
}
|
||||
|
||||
/// Area Type and Type on a Path: a click on a path while other type is being edited ends the
|
||||
/// editing only; the next click turns the path into a frame or a baseline.
|
||||
#[test]
|
||||
fn a_click_on_a_path_while_editing_only_ends_editing() {
|
||||
let (mut d, rect) = crate::testutil::doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let t = TextObject::point(Point::new(300.0, 400.0), "Hello", CharStyle { size: 20.0, ..Default::default() });
|
||||
d.insert(Some(l), 1, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &sel, &p);
|
||||
for (tool_id, at) in [("areaType", (100.0, 150.0)), ("typeOnPath", (150.0, 100.0))] {
|
||||
let mut tool = TypeTool::new(tool_id);
|
||||
tool.start_editing(id, 0);
|
||||
let acts = click(&mut tool, &c, at.0, at.1);
|
||||
assert!(!creates(&acts), "{tool_id}: {acts:?}");
|
||||
assert_eq!(tool.editing, None);
|
||||
let acts = click(&mut tool, &c, at.0, at.1);
|
||||
assert!(
|
||||
acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.createInPath" && v["path"] == json!(rect.0))),
|
||||
"{tool_id}: {acts:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// While type is being edited, a drag that draws an area-type frame still makes it, and a click
|
||||
/// on other type edits that type at once.
|
||||
#[test]
|
||||
fn a_drag_or_a_click_on_other_type_while_editing_acts_at_once() {
|
||||
let (mut d, id, mut tool) = editing("Hello");
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let acts = {
|
||||
let c = cx(&d, &sel, &p);
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, 400.0, 400.0));
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 400.0, 400.0))
|
||||
};
|
||||
let area = json!({"width": 100.0, "height": 100.0});
|
||||
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["area"] == area)), "{acts:?}");
|
||||
let l = d.layers[0].id;
|
||||
let other = d.alloc_id();
|
||||
let t = TextObject::point(Point::new(100.0, 300.0), "Other", CharStyle { size: 20.0, ..Default::default() });
|
||||
d.insert(Some(l), 1, Node::new(other, NodeKind::Text(Box::new(t)))).unwrap();
|
||||
let c = cx(&d, &sel, &p);
|
||||
let t = TypeTool::text(&c, other).unwrap().clone();
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), &t);
|
||||
let (top, bot) = vectorcraft_text::caret_position(&lay, 2);
|
||||
let at = t.xf * top.midpoint(bot);
|
||||
tool.start_editing(id, 0);
|
||||
let acts = click(&mut tool, &c, at.x, at.y);
|
||||
assert!(!creates(&acts), "{acts:?}");
|
||||
assert!(acts.contains(&Action::Exec("select.set".into(), json!({"ids": [other.0]}))), "{acts:?}");
|
||||
assert_eq!(tool.editing, Some(other));
|
||||
}
|
||||
|
||||
/// Selected area type whose text doesn't fit its frame shows the red out port with any tool, not
|
||||
/// only the Type tools (#2323); text that fits shows none.
|
||||
#[test]
|
||||
|
||||
@@ -6711,11 +6711,12 @@ mod tests {
|
||||
fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let other = app.session.execute("text.create", &json!({"x": 300, "y": 250, "text": "Other", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
typing_frame(&mut app, &ctx, vec![]);
|
||||
app.select_tool("type");
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let (a, b) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(300.0, 250.0)));
|
||||
let (a, b) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(310.0, 245.0)));
|
||||
let click =
|
||||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), click(a, true)]);
|
||||
@@ -6723,17 +6724,29 @@ mod tests {
|
||||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||||
typing_frame(&mut app, &ctx, vec![preedit("しょうこ", 4)]);
|
||||
// macOS sends nothing for a click away; the tool keeps the marked text and the IME is
|
||||
// told to drop its composition.
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b), click(b, true)]);
|
||||
let ime = typing_frame(&mut app, &ctx, vec![click(b, false)]);
|
||||
// told to drop its composition. A press on other type edits that type at once.
|
||||
let ime = typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b), click(b, true)]);
|
||||
assert_eq!(plain(&app, id), "しょうこ");
|
||||
assert!(!app.session.tool_composing());
|
||||
assert!(ime.expect("editing the new text").should_interrupt_composition);
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(other)]);
|
||||
assert!(ime.expect("editing the other text").should_interrupt_composition);
|
||||
assert!(app.ime_marked.is_none());
|
||||
assert!(app.take_ime_discard(), "the host tells the system IME to drop its marked text");
|
||||
typing_frame(&mut app, &ctx, vec![click(b, false)]);
|
||||
// Interrupted once, not every frame.
|
||||
assert!(!typing_frame(&mut app, &ctx, vec![]).unwrap().should_interrupt_composition);
|
||||
assert!(!app.take_ime_discard());
|
||||
// A click on blank canvas ends the editing (#2871): the marked text stays, the frame asks
|
||||
// for no IME, and the host tells the system IME to drop its composition.
|
||||
let blank = xf.to_screen(Point::new(150.0, 200.0));
|
||||
typing_frame(&mut app, &ctx, vec![preedit("が", 1)]);
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(blank), click(blank, true)]);
|
||||
let ime = typing_frame(&mut app, &ctx, vec![click(blank, false)]);
|
||||
assert!(plain(&app, other).contains('が'), "{}", plain(&app, other));
|
||||
assert!(!app.session.tool_composing() && !app.session.tool_wants_text());
|
||||
assert!(ime.is_none());
|
||||
assert!(app.ime_marked.is_none());
|
||||
assert!(app.take_ime_discard());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -390,14 +390,14 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
|
||||
/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited
|
||||
/// "Name* @ 66.67 % (<Opacity Mask>/Opacity Mask)".
|
||||
fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool) -> String {
|
||||
fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool, display_print_size: bool) -> String {
|
||||
let mode = if d.doc.mask_edit.is_some() {
|
||||
format!("<{0}>/{0}", tl!("Opacity Mask"))
|
||||
} else {
|
||||
let color = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" };
|
||||
format!("{color}/{}", if outline { tl!("Outline") } else { tl!("Preview") })
|
||||
};
|
||||
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %"))
|
||||
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom, display_print_size).replace('%', " %"))
|
||||
}
|
||||
|
||||
/// Whether a document tab's × comes before its title (macOS) rather than after it (Windows,
|
||||
@@ -435,7 +435,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
.enumerate()
|
||||
.map(|(i, d)| {
|
||||
let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0);
|
||||
let title = tab_title(d, zoom, app.ui.view.outline);
|
||||
let title = tab_title(d, zoom, app.ui.view.outline, app.session.prefs.display_print_size);
|
||||
let color = if Some(i) == active { t.text_strong } else { t.text_dim };
|
||||
(title.clone(), ui.painter().layout_no_wrap(title, theme::semibold(title_size), color))
|
||||
})
|
||||
@@ -653,11 +653,17 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0);
|
||||
egui::ComboBox::from_id_salt("zoom-combo").selected_text(egui::RichText::new(zoom_label(zoom)).size(11.5)).width(76.0).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
let print = app.session.prefs.display_print_size;
|
||||
let shown = crate::state::zoom_display_percent(zoom, print);
|
||||
egui::ComboBox::from_id_salt("zoom-combo")
|
||||
.selected_text(egui::RichText::new(zoom_label(zoom, print)).size(11.5))
|
||||
.width(76.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for z in ZOOM_STOPS.iter().rev() {
|
||||
if ui.selectable_label((z / 100.0 - zoom).abs() < 1e-4, zoom_label(z / 100.0)).clicked() {
|
||||
if ui
|
||||
.selectable_label((z - shown).abs() < 0.01, zoom_label(crate::state::zoom_from_display_percent(*z, print), print))
|
||||
.clicked()
|
||||
{
|
||||
app.run("view.setZoom", json!({"zoom": z})).ok();
|
||||
}
|
||||
}
|
||||
@@ -668,8 +674,7 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if ui.selectable_label(false, tl!("Fit All")).clicked() {
|
||||
app.run("view.fitAll", json!({})).ok();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
let rot = app.view().map(|v| v.rotation).unwrap_or(0.0);
|
||||
egui::ComboBox::from_id_salt("rot-combo").selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)).width(52.0).show_ui(
|
||||
ui,
|
||||
@@ -1139,6 +1144,16 @@ mod tests {
|
||||
assert!(app.run("view.goToArtboard", json!({"index": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
/// storytold/vectorcraft#2719: status-bar 100% matches Actual Size when Display Print Size is on.
|
||||
#[test]
|
||||
fn set_zoom_100_matches_actual_size_with_display_print_size() {
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.session.execute("prefs.set", &json!({"key": "displayPrintSize", "value": true})).unwrap();
|
||||
app.run("view.setZoom", json!({"zoom": 100})).unwrap();
|
||||
assert!((app.view().unwrap().zoom - 96.0 / 72.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
/// Screen readers name the artboard navigator's four buttons, the ones that lead nowhere from
|
||||
/// the first artboard disabled.
|
||||
#[test]
|
||||
@@ -1413,7 +1428,7 @@ mod tests {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
let title = |s: &Session| super::tab_title(s.active().unwrap(), 1.0, false);
|
||||
let title = |s: &Session| super::tab_title(s.active().unwrap(), 1.0, false, false);
|
||||
assert!(title(&s).ends_with("(RGB/Preview)"), "{}", title(&s));
|
||||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
assert!(title(&s).ends_with("(<Opacity Mask>/Opacity Mask)"), "{}", title(&s));
|
||||
|
||||
@@ -241,10 +241,12 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(12.0, 12.0);
|
||||
let (w, h, preset, units) = (d.f64("width", 0.0), d.f64("height", 0.0), d.str("preset"), unit(d));
|
||||
for s in &list {
|
||||
// The cards show the chosen orientation (square sizes have none) and the units chosen
|
||||
// in Preset Details; choosing one keeps both.
|
||||
// The built-in presets' cards show the chosen orientation (square sizes have none)
|
||||
// and the units chosen in Preset Details; choosing one keeps both. Recent and Saved
|
||||
// show each entry as it was made, so a portrait and a landscape A3 are two cards
|
||||
// (#2860).
|
||||
let mut s = DocSettings { units, ..s.clone() };
|
||||
if (w > h && s.height > s.width) || (h > w && s.width > s.height) {
|
||||
if cat > 1 && ((w > h && s.height > s.width) || (h > w && s.width > s.height)) {
|
||||
std::mem::swap(&mut s.width, &mut s.height);
|
||||
}
|
||||
let selected = d.fields.contains_key(APPLIED) && s.name == preset && s.width == w && s.height == h;
|
||||
@@ -636,6 +638,56 @@ mod tests {
|
||||
app.ui.dialog.as_ref().unwrap().fields.get(k).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Recent shows each entry as it was made: a portrait and a landscape A3 are two cards of
|
||||
/// their own orientations, and only the chosen one is selected (#2860).
|
||||
#[test]
|
||||
fn recent_cards_keep_the_orientation_they_were_made_in() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let a3 = vectorcraft_engine::cmd::newdoc::find(&app.session, "A3").unwrap();
|
||||
let landscape = vectorcraft_engine::cmd::newdoc::DocSettings { width: a3.height, height: a3.width, ..a3.clone() };
|
||||
app.session.prefs.recent_new_docs = vec![landscape, a3];
|
||||
app.run("file.newDialog", json!({})).unwrap();
|
||||
assert_eq!(field(&app, "category"), json!("Recent"));
|
||||
// Frames half a second apart on one context: the first lays the dialog out, and its fade
|
||||
// in is over by the last.
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut time = 0.0;
|
||||
let mut run = |app: &mut VectorcraftApp| {
|
||||
time += 0.5;
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0)));
|
||||
let raw = egui::RawInput { screen_rect, time: Some(time), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
};
|
||||
run(&mut app);
|
||||
run(&mut app);
|
||||
let out = run(&mut app);
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<egui::Shape>) {
|
||||
match s {
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
s => out.push(s.clone()),
|
||||
}
|
||||
}
|
||||
let mut shapes = vec![];
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut shapes));
|
||||
// The page thumbnails (white, 60 on their long side) and the selected card's outline.
|
||||
let thumbs: Vec<egui::Vec2> = shapes
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
egui::Shape::Rect(r) if r.fill == egui::Color32::WHITE && (r.rect.width().max(r.rect.height()) - 60.0).abs() < 0.01 => {
|
||||
Some(r.rect.size())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!((thumbs.iter().filter(|s| s.x > s.y).count(), thumbs.iter().filter(|s| s.y > s.x).count()), (1, 1), "{thumbs:?}");
|
||||
let selected =
|
||||
shapes.iter().filter(|s| matches!(s, egui::Shape::Rect(r) if r.stroke.width == 2.0 && r.rect.size() == egui::vec2(146.0, 150.0))).count();
|
||||
assert_eq!(selected, 1, "one card chosen");
|
||||
}
|
||||
|
||||
/// A double-click on a preset card creates the document from it (#663); a click only chooses it.
|
||||
#[test]
|
||||
fn a_double_clicked_preset_creates_the_document() {
|
||||
|
||||
@@ -173,7 +173,7 @@ fn has_warnings(r: &Value) -> bool {
|
||||
r["warnings"].as_array().is_some_and(|w| !w.is_empty())
|
||||
}
|
||||
|
||||
/// `file.saveForWeb.browser`: the HTML page and its images in a temporary folder, opened in the
|
||||
/// `file.saveForWeb.browser`: the HTML page and its images in a new temporary folder, opened in the
|
||||
/// default browser (desktop). When the export has warnings, the status bar shows the first in
|
||||
/// place of "Opened …".
|
||||
pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
@@ -196,12 +196,39 @@ pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Val
|
||||
Ok(json!({ "path": path, "warnings": r["warnings"] }))
|
||||
}
|
||||
|
||||
/// The folder browser previews go to.
|
||||
/// The age at which a later browser preview removes an earlier preview's folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const PREVIEW_KEEP: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||
|
||||
/// The folder a browser preview goes to: a new one for each preview, in the temporary folder's
|
||||
/// `VectorCraft Save for Web`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn temp_folder() -> Result<String, String> {
|
||||
let dir = std::env::temp_dir().join("VectorCraft Save for Web");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
Ok(dir.to_string_lossy().replace('\\', "/"))
|
||||
preview_folder(&std::env::temp_dir().join("VectorCraft Save for Web"), PREVIEW_KEEP)
|
||||
}
|
||||
|
||||
/// A new folder in `root`, named after the process id and a count of the process's previews. A
|
||||
/// name that is already taken, by an earlier process with the same id, is skipped for the next
|
||||
/// count. Entries of `root` last changed `keep` or longer ago are removed first.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn preview_folder(root: &std::path::Path, keep: std::time::Duration) -> Result<String, String> {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
for e in std::fs::read_dir(root).into_iter().flatten().flatten() {
|
||||
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age >= keep);
|
||||
if old {
|
||||
// Best effort: one that can't be removed now is tried again by the next preview.
|
||||
let _ = if e.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(e.path()) } else { std::fs::remove_file(e.path()) };
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(root).map_err(|e| format!("{}: {e}", root.display()))?;
|
||||
loop {
|
||||
let dir = root.join(format!("{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
|
||||
match std::fs::create_dir(&dir) {
|
||||
Ok(()) => return Ok(dir.to_string_lossy().replace('\\', "/")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(e) => return Err(format!("{}: {e}", dir.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -141,6 +141,68 @@ fn preview_in_browser_opens_the_page() {
|
||||
assert!(log.last().unwrap().starts_with("open file://") && log.last().unwrap().ends_with("Untitled-1.html"), "{log:?}");
|
||||
}
|
||||
|
||||
/// Each preview has its own folder: a second one, from another app or the same one, leaves the
|
||||
/// first page and its image as they were.
|
||||
#[test]
|
||||
fn a_later_browser_preview_leaves_an_earlier_one_alone() {
|
||||
let dir = vectorcraft_testkit::temp_dir("sfw-browser-two");
|
||||
let (mut first, _) = app(&dir, false);
|
||||
let (mut second, _) = app(&dir, false);
|
||||
let page = |r: serde_json::Value| r["path"].as_str().unwrap().to_string();
|
||||
let jpg = page(first.run("file.saveForWeb.browser", json!({"format": "jpg"})).unwrap());
|
||||
let gif = page(second.run("file.saveForWeb.browser", json!({"format": "gif"})).unwrap());
|
||||
let png = page(first.run("file.saveForWeb.browser", json!({"format": "png24"})).unwrap());
|
||||
assert!(jpg != gif && jpg != png && gif != png, "{jpg} {gif} {png}");
|
||||
let root = std::env::temp_dir().join("VectorCraft Save for Web").to_string_lossy().replace('\\', "/");
|
||||
for (page, image) in [(&jpg, "images/Untitled-1.jpg"), (&gif, "images/Untitled-1.gif"), (&png, "images/Untitled-1.png")] {
|
||||
assert!(page.starts_with(&root) && page.ends_with("/Untitled-1.html"), "{page}");
|
||||
assert!(std::fs::read_to_string(page).unwrap().contains(image), "{page}");
|
||||
let folder = std::path::Path::new(page).parent().unwrap();
|
||||
assert!(folder.join(image).exists(), "{page}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Making a preview's folder removes the folders and files in its root that are `keep` old or
|
||||
/// older, and keeps younger ones.
|
||||
#[test]
|
||||
fn a_browser_preview_removes_old_previews() {
|
||||
let root = vectorcraft_testkit::temp_dir("sfw-browser-old").join("previews");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let earlier = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap();
|
||||
std::fs::create_dir_all(format!("{earlier}/images")).unwrap();
|
||||
std::fs::write(format!("{earlier}/images/Untitled-1.gif"), b"GIF89a").unwrap();
|
||||
std::fs::write(root.join("Untitled-1.html"), b"<html>").unwrap();
|
||||
// The folder and the page are younger than an hour, so both stay.
|
||||
let kept = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap();
|
||||
assert!(std::path::Path::new(&earlier).exists() && root.join("Untitled-1.html").exists());
|
||||
// With a `keep` of zero every entry is old enough: all three go, and only the new folder is left.
|
||||
let next = save_for_web::preview_folder(&root, std::time::Duration::ZERO).unwrap();
|
||||
assert!(!std::path::Path::new(&earlier).exists() && !std::path::Path::new(&kept).exists() && !root.join("Untitled-1.html").exists());
|
||||
assert!(std::path::Path::new(&next).is_dir());
|
||||
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
/// A folder that an earlier process with the same id left, younger than `keep`, is not reused:
|
||||
/// the preview makes a new, empty one.
|
||||
#[test]
|
||||
fn a_browser_preview_skips_a_folder_an_earlier_process_left() {
|
||||
let root = vectorcraft_testkit::temp_dir("sfw-browser-reused").join("previews");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let keep = std::time::Duration::from_secs(3600);
|
||||
let earlier = save_for_web::preview_folder(&root, keep).unwrap();
|
||||
let count: u64 = earlier.rsplit('-').next().unwrap().parse().unwrap();
|
||||
// The next 200 names, as an earlier process with the same id would have left them.
|
||||
for n in count + 1..=count + 200 {
|
||||
let left = root.join(format!("{}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(left.join("images")).unwrap();
|
||||
std::fs::write(left.join("Untitled-1.html"), b"<html>").unwrap();
|
||||
}
|
||||
let next = save_for_web::preview_folder(&root, keep).unwrap();
|
||||
assert_ne!(next, earlier);
|
||||
assert_eq!(std::fs::read_dir(&next).unwrap().count(), 0, "{next}");
|
||||
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 202);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_says_in_the_status_bar_when_an_image_could_not_be_decoded() {
|
||||
const NOTE: &str = "with 1 note(s): an image couldn't be decoded and is missing from the picture";
|
||||
|
||||
@@ -3726,6 +3726,8 @@ axis Both И двете
|
||||
@msg `{key}` must be a #rrggbb color `{key}` трябва да бъде цвят във формат #rrggbb
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` трябва да бъде цвят във формат #rrggbb
|
||||
@msg `{key}` must be a number `{key}` трябва да бъде число
|
||||
@msg `{key}` must be a number, not {v} `{key}` трябва да бъде число, а не {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} трябва да бъде Unix време в секунди (цяло число) или null
|
||||
@msg `{key}` must be a string `{key}` трябва да бъде низ
|
||||
@msg `{key}` must be a whole number `{key}` трябва да бъде цяло число
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` трябва да бъде цяло число между {min} и {max} (получено е {x})
|
||||
@@ -3852,6 +3854,7 @@ axis Both И двете
|
||||
@msg missing `tool` липсва `tool`
|
||||
@msg missing index липсва индекс
|
||||
@msg missing number `{key}` липсва число `{key}`
|
||||
@msg missing integer `{key}` липсва цяло число `{key}`
|
||||
@msg multiple windows land with M11.5 множество прозорци се зареждат с M11.5
|
||||
@msg name the file именувайте файла
|
||||
@msg no CSS to copy: select objects with CSS of their own няма CSS за копиране: изберете обекти със собствен CSS
|
||||
@@ -4283,6 +4286,9 @@ axis Both И двете
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: резултатът от стъпка {i} няма такава стойност (напишете $$ за литерален знак $)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: няма стъпка {i} преди тази (стъпките се броят от 0; напишете $$ за литерален знак $)
|
||||
@msg object ids are non-negative integers, not {v} идентификаторите на обектите са неотрицателни цели числа, а не {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} трябва да бъде масив от идентификатори на обекти, които са неотрицателни цели числа
|
||||
@msg invalid object id {value}: expected a non-negative integer невалиден идентификатор на обект {value}: очаква се неотрицателно цяло число
|
||||
@msg no such object id {value} няма обект с идентификатор {value}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за четене: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за запис: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} пътят за автоматизация е отхвърлен: извън корена за четене {root}: {path}
|
||||
@@ -5170,3 +5176,5 @@ theme Appearance Външен вид
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Минималната стойност не може да надвишава желаната, нито желаната – максималната.
|
||||
Justification Подравняване
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them променливите и наборите от данни на файла не бяха прочетени (променливи: {variables}, набори от данни: {data_sets}): панелът „Променливи“ не ги показва
|
||||
# Select by Color (select.color)
|
||||
Select by Color Избиране по цвят
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4104,6 +4104,8 @@ imageTrace Outline Obrys
|
||||
@msg `{key}` must be a #rrggbb color `{key}` musí být barva #rrggbb
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` musí být barva #rrggbb
|
||||
@msg `{key}` must be a number `{key}` musí být číslo
|
||||
@msg `{key}` must be a number, not {v} `{key}` musí být číslo, ne {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} musí být unixový čas v sekundách (celé číslo) nebo null
|
||||
@msg `{key}` must be a string `{key}` musí být řetězec
|
||||
@msg `{key}` must be a whole number `{key}` musí být celé číslo
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` musí být celé číslo mezi {min} a {max} (zadáno {x})
|
||||
@@ -4234,6 +4236,7 @@ imageTrace Outline Obrys
|
||||
@msg missing `tool` chybí `tool`
|
||||
@msg missing index chybí index
|
||||
@msg missing number `{key}` chybí číslo `{key}`
|
||||
@msg missing integer `{key}` chybí celé číslo `{key}`
|
||||
@msg multiple windows land with M11.5 více oken přijde s M11.5
|
||||
@msg name the file pojmenujte soubor
|
||||
@msg no CSS to copy: select objects with CSS of their own žádné CSS ke kopírování: vyberte objekty s vlastním CSS
|
||||
@@ -4356,6 +4359,7 @@ imageTrace Outline Obrys
|
||||
@msg object {id} is not a selected top-level object objekt {id} není vybraný objekt nejvyšší úrovně
|
||||
@msg page size {w} × {h} velikost stránky {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) stránka {0} neexistuje (PDF jich má {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: stránka {n} neexistuje (soubor má 1)
|
||||
@msg paste unavailable vložení není k dispozici
|
||||
@msg path is already closed cesta už je uzavřená
|
||||
@msg paths contain invalid coordinates cesty obsahují neplatné souřadnice
|
||||
@@ -4629,6 +4633,9 @@ imageTrace Outline Obrys
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: výsledek kroku {i} takovou hodnotu nemá (pro samotný znak $ napište $$)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: před tímto krokem není krok {i} (kroky se číslují od 0; pro samotný znak $ napište $$)
|
||||
@msg object ids are non-negative integers, not {v} ID objektů jsou nezáporná celá čísla, ne {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} musí být pole ID objektů, která jsou nezáporná celá čísla
|
||||
@msg invalid object id {value}: expected a non-negative integer neplatné ID objektu {value}: očekává se nezáporné celé číslo
|
||||
@msg no such object id {value} objekt s ID {value} neexistuje
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění ke čtení: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění k zápisu: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} cesta automatizace odmítnuta: mimo kořen pro čtení {root}: {path}
|
||||
@@ -5205,3 +5212,5 @@ mark-style Japanese Japonské
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum nesmí překročit požadovanou hodnotu ani požadovaná hodnota maximum.
|
||||
Justification Zarovnání do bloku
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them proměnné a sady dat souboru nebyly načteny (proměnné: {variables}, sady dat: {data_sets}): panel Proměnné je nezobrazuje
|
||||
# Select by Color (select.color)
|
||||
Select by Color Vybrat podle barvy
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3855,6 +3855,8 @@ imageTrace Outline Kontur
|
||||
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` skal være `auto` eller en sprogkode som `en` eller `zh-hant`
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` skal være en #rrggbb-farve
|
||||
@msg `{key}` must be a number `{key}` skal være et tal
|
||||
@msg `{key}` must be a number, not {v} `{key}` skal være et tal, ikke {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} skal være Unix-tid i sekunder (et heltal) eller null
|
||||
@msg `{key}` must be a string `{key}` skal være en streng
|
||||
@msg `{key}` must be a whole number `{key}` skal være et heltal
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` skal være et heltal mellem {min} og {max} (fik {x})
|
||||
@@ -3974,6 +3976,8 @@ imageTrace Outline Kontur
|
||||
@msg missing `name` `name` mangler
|
||||
@msg missing `tool` `tool` mangler
|
||||
@msg missing index indeks mangler
|
||||
@msg missing number `{key}` tallet `{key}` mangler
|
||||
@msg missing integer `{key}` heltallet `{key}` mangler
|
||||
@msg multiple windows land with M11.5 flere vinduer lander med M11.5
|
||||
@msg name the file giv filen et navn
|
||||
@msg no CSS to copy: select objects with CSS of their own ingen CSS at kopiere: markér objekter med deres egen CSS
|
||||
@@ -4356,6 +4360,9 @@ imageTrace Outline Kontur
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: resultatet af trin {i} har ingen sådan værdi (skriv $$ for at få et bogstaveligt $)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: der findes intet trin {i} før dette (trin nummereres fra 0; skriv $$ for at få et bogstaveligt $)
|
||||
@msg object ids are non-negative integers, not {v} objekt-id'er er ikke-negative heltal, ikke {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} skal være et array af objekt-id'er, der er ikke-negative heltal
|
||||
@msg invalid object id {value}: expected a non-negative integer ugyldigt objekt-id {value}: et ikke-negativt heltal var forventet
|
||||
@msg no such object id {value} der findes intet objekt med id {value}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: læserettighed mangler: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: skriverettighed mangler: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} automatiseringssti afvist: uden for læseroden {root}: {path}
|
||||
@@ -5188,3 +5195,5 @@ swatch Bright Violet Kraftig violet
|
||||
Full Justify Lige margener
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum må ikke overstige Ønsket, og Ønsket ikke Maksimum.
|
||||
Justification Justering
|
||||
# Select by Color (select.color)
|
||||
Select by Color Markér efter farve
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3739,6 +3739,8 @@ theme Appearance Erscheinungsbild
|
||||
@msg `{key}` must be a #rrggbb color `{key}` muss eine Farbe der Form #rrggbb sein
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` muss eine Farbe der Form #rrggbb sein
|
||||
@msg `{key}` must be a number `{key}` muss eine Zahl sein
|
||||
@msg `{key}` must be a number, not {v} `{key}` muss eine Zahl sein, nicht {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} muss eine Unix-Zeit in Sekunden (eine ganze Zahl) oder null sein
|
||||
@msg `{key}` must be a string `{key}` muss eine Zeichenfolge sein
|
||||
@msg `{key}` must be a whole number `{key}` muss eine ganze Zahl sein
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` muss eine ganze Zahl zwischen {min} und {max} sein (erhalten: {x})
|
||||
@@ -3869,6 +3871,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg missing `tool` `tool` fehlt
|
||||
@msg missing index der Index fehlt
|
||||
@msg missing number `{key}` die Zahl `{key}` fehlt
|
||||
@msg missing integer `{key}` die ganze Zahl `{key}` fehlt
|
||||
@msg multiple windows land with M11.5 mehrere Fenster kommen mit M11.5
|
||||
@msg name the file benennen Sie die Datei
|
||||
@msg no CSS to copy: select objects with CSS of their own kein CSS zum Kopieren: Wählen Sie Objekte mit eigenem CSS aus
|
||||
@@ -3993,6 +3996,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg object {id} is not a selected top-level object Objekt {id} ist kein ausgewähltes Objekt der obersten Stufe
|
||||
@msg page size {w} × {h} Seitengröße {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) Seite {0} ist nicht vorhanden (die PDF-Datei hat {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: Seite {n} ist nicht vorhanden (die Datei hat 1)
|
||||
@msg paste unavailable Einfügen nicht verfügbar
|
||||
@msg path is already closed der Pfad ist bereits geschlossen
|
||||
@msg paths contain invalid coordinates die Pfade enthalten ungültige Koordinaten
|
||||
@@ -4302,6 +4306,9 @@ theme Appearance Erscheinungsbild
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: Das Ergebnis von Schritt {i} hat keinen solchen Wert (schreiben Sie $$ für ein literales $)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: Vor diesem Schritt gibt es keinen Schritt {i} (Schritte zählen ab 0; schreiben Sie $$ für ein literales $)
|
||||
@msg object ids are non-negative integers, not {v} Objekt-IDs sind nicht negative ganze Zahlen, nicht {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} muss ein Array von Objekt-IDs sein, die nicht negative ganze Zahlen sind
|
||||
@msg invalid object id {value}: expected a non-negative integer ungültige Objekt-ID {value}: erwartet wird eine nicht negative ganze Zahl
|
||||
@msg no such object id {value} kein Objekt mit der ID {value}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} Dateisystemzugriff für die Automatisierung ist nicht gewährt: Die Leseberechtigung fehlt: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} Dateisystemzugriff für die Automatisierung ist nicht gewährt: Die Schreibberechtigung fehlt: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} Automatisierungspfad abgelehnt: außerhalb des Lesestammordners {root}: {path}
|
||||
@@ -4385,6 +4392,9 @@ theme Appearance Erscheinungsbild
|
||||
@msg not a PSD file keine PSD-Datei
|
||||
@msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode PSD-Dateien im Mehrkanalmodus können nicht gelesen werden: Speichern Sie die Datei im RGB-, CMYK- oder Graustufenmodus
|
||||
@msg the PSD file has an unknown colour mode ({m}) die PSD-Datei hat einen unbekannten Farbmodus ({m})
|
||||
@msg the PSD file is cut short die PSD-Datei ist unvollständig
|
||||
@msg the PSD file's header is damaged der Header der PSD-Datei ist beschädigt
|
||||
@msg the PSD file's image data can't be read: {e} die Bilddaten der PSD-Datei können nicht gelesen werden: {e}
|
||||
@msg the PSD file's image data uses an unknown compression ({m}) die Bilddaten der PSD-Datei verwenden eine unbekannte Komprimierung ({m})
|
||||
@msg the PSD image is too large to read ({_1} × {_2} pixels) das PSD-Bild ist zu groß zum Lesen ({_1} × {_2} Pixel)
|
||||
@msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden
|
||||
@@ -4636,6 +4646,7 @@ lighting Light Licht
|
||||
Top Right Oben rechts
|
||||
Bottom Right Unten rechts
|
||||
effect Regular Regelmäßig
|
||||
effect Soft Weich
|
||||
Bottom Left Unten links
|
||||
# Corner Radius: a field per corner of a live rectangle.
|
||||
Single Corner Radius Einheitlicher Eckenradius
|
||||
@@ -4781,3 +4792,5 @@ effect Regular Regelmäßig
|
||||
Minimum Minimum
|
||||
Justification Abstände
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them die Variablen und Datensätze der Datei wurden nicht gelesen (Variablen: {variables}, Datensätze: {data_sets}): Das Bedienfeld „Variablen“ listet sie nicht auf
|
||||
# Select by Color (select.color)
|
||||
Select by Color Nach Farbe auswählen
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2097,6 +2097,8 @@ axis Both Και τα δύο
|
||||
@msg `{key}` must be a #rrggbb color Το `{key}` πρέπει να είναι χρώμα #rrggbb
|
||||
@msg `{key}` must be a #rrggbb colour Το `{key}` πρέπει να είναι χρώμα #rrggbb
|
||||
@msg `{key}` must be a number Το `{key}` πρέπει να είναι αριθμός
|
||||
@msg `{key}` must be a number, not {v} Το `{key}` πρέπει να είναι αριθμός, όχι {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key}: πρέπει να είναι χρόνος Unix σε δευτερόλεπτα (ακέραιος) ή null
|
||||
@msg `{key}` must be a string Το `{key}` πρέπει να είναι συμβολοσειρά
|
||||
@msg `{key}` must be a whole number Το `{key}` πρέπει να είναι ακέραιος αριθμός
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) Το `{key}` πρέπει να είναι ακέραιος από {min} έως {max} (δόθηκε {x})
|
||||
@@ -3909,6 +3911,7 @@ theme Appearance Εμφάνιση
|
||||
@msg missing `tool` λείπει το `tool`
|
||||
@msg missing index λείπει ο δείκτης
|
||||
@msg missing number `{key}` λείπει ο αριθμός `{key}`
|
||||
@msg missing integer `{key}` λείπει ο ακέραιος `{key}`
|
||||
@msg multiple windows land with M11.5 τα πολλαπλά παράθυρα θα προστεθούν στην έκδοση M11.5
|
||||
@msg name the file δώστε όνομα στο αρχείο
|
||||
@msg no CSS to copy: select objects with CSS of their own δεν υπάρχει CSS για αντιγραφή: επιλέξτε αντικείμενα με δικό τους CSS
|
||||
@@ -4032,6 +4035,7 @@ theme Appearance Εμφάνιση
|
||||
@msg object {id} is not a selected top-level object το αντικείμενο {id} δεν είναι επιλεγμένο αντικείμενο ανώτατης βαθμίδας
|
||||
@msg page size {w} × {h} μέγεθος σελίδας {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) η σελίδα {0} δεν υπάρχει (το PDF έχει {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: η σελίδα {n} δεν υπάρχει (το αρχείο έχει 1)
|
||||
@msg paste unavailable η επικόλληση δεν είναι διαθέσιμη
|
||||
@msg path is already closed η διαδρομή είναι ήδη κλειστή
|
||||
@msg paths contain invalid coordinates οι διαδρομές περιέχουν μη έγκυρες συντεταγμένες
|
||||
@@ -4333,6 +4337,9 @@ theme Appearance Εμφάνιση
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: το αποτέλεσμα του βήματος {i} δεν έχει τέτοια τιμή (γράψτε $$ για κυριολεκτικό $)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: δεν υπάρχει βήμα {i} πριν από αυτό (η αρίθμηση αρχίζει από 0· γράψτε $$ για κυριολεκτικό $)
|
||||
@msg object ids are non-negative integers, not {v} τα αναγνωριστικά αντικειμένων πρέπει να είναι μη αρνητικοί ακέραιοι, όχι {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key}: πρέπει να είναι πίνακας αναγνωριστικών αντικειμένων που είναι μη αρνητικοί ακέραιοι
|
||||
@msg invalid object id {value}: expected a non-negative integer μη έγκυρο αναγνωριστικό αντικειμένου {value}: αναμένεται μη αρνητικός ακέραιος
|
||||
@msg no such object id {value} δεν υπάρχει αντικείμενο με αναγνωριστικό {value}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα ανάγνωσης: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα εγγραφής: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός ρίζας ανάγνωσης {root}: {path}
|
||||
@@ -5157,3 +5164,5 @@ effect Points Σημεία
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Το ελάχιστο δεν πρέπει να υπερβαίνει το επιθυμητό, ούτε το επιθυμητό το μέγιστο.
|
||||
Justification Πλήρης στοίχιση
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them οι μεταβλητές και τα σύνολα δεδομένων του αρχείου δεν διαβάστηκαν (μεταβλητές: {variables}, σύνολα δεδομένων: {data_sets}): ο πίνακας Μεταβλητές δεν τα εμφανίζει
|
||||
# Select by Color (select.color)
|
||||
Select by Color Επιλογή βάσει χρώματος
|
||||
|
Can't render this file because it contains an unexpected character in line 2006 and column 19.
|
@@ -3802,6 +3802,8 @@ axis Both Ambos
|
||||
@msg `{key}` must be a #rrggbb color `{key}` debe ser un color #rrggbb
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` debe ser un color #rrggbb
|
||||
@msg `{key}` must be a number `{key}` debe ser un número
|
||||
@msg `{key}` must be a number, not {v} `{key}` debe ser un número, no {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} debe ser un tiempo Unix en segundos (un entero) o null
|
||||
@msg `{key}` must be a string `{key}` debe ser un texto
|
||||
@msg `{key}` must be a whole number `{key}` debe ser un número entero
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` debe ser un número entero entre {min} y {max} (se recibió {x})
|
||||
@@ -3932,6 +3934,7 @@ axis Both Ambos
|
||||
@msg missing `tool` falta `tool`
|
||||
@msg missing index falta index
|
||||
@msg missing number `{key}` falta el número `{key}`
|
||||
@msg missing integer `{key}` falta el número entero `{key}`
|
||||
@msg multiple windows land with M11.5 las ventanas múltiples llegan con M11.5
|
||||
@msg name the file ponle nombre al archivo
|
||||
@msg no CSS to copy: select objects with CSS of their own no hay CSS que copiar: selecciona objetos con CSS propio
|
||||
@@ -4055,6 +4058,7 @@ axis Both Ambos
|
||||
@msg object {id} is not a selected top-level object el objeto {id} no es un objeto seleccionado de nivel superior
|
||||
@msg page size {w} × {h} tamaño de página {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) la página {0} no existe (el PDF tiene {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: la página {n} no existe (el archivo tiene 1)
|
||||
@msg paste unavailable pegar no está disponible
|
||||
@msg path is already closed el trazado ya está cerrado
|
||||
@msg paths contain invalid coordinates los trazados contienen coordenadas no válidas
|
||||
@@ -4351,6 +4355,9 @@ axis Both Ambos
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: el resultado del paso {i} no tiene ese valor (escriba $$ para un $ literal)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: no hay ningún paso {i} antes de este (los pasos se cuentan desde 0; escriba $$ para un $ literal)
|
||||
@msg object ids are non-negative integers, not {v} los identificadores de objeto son enteros no negativos, no {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} debe ser una lista de identificadores de objeto que sean enteros no negativos
|
||||
@msg invalid object id {value}: expected a non-negative integer identificador de objeto no válido {value}: se esperaba un entero no negativo
|
||||
@msg no such object id {value} no existe el objeto con el identificador {value}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de lectura: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de escritura: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de lectura {root}: {path}
|
||||
@@ -5147,3 +5154,5 @@ mark-style Japanese Japonesas
|
||||
Minimum must not exceed Desired, nor Desired Maximum. El mínimo no puede superar el deseado, ni el deseado el máximo.
|
||||
Justification Justificación
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them no se leyeron las variables ni los conjuntos de datos del archivo (variables: {variables}, conjuntos de datos: {data_sets}): el panel Variables no los muestra
|
||||
# Select by Color (select.color)
|
||||
Select by Color Seleccionar por color
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4031,6 +4031,8 @@ imageTrace Outline Ääriviiva
|
||||
@msg `{key}` must be a #rrggbb color `{key}` on oltava #rrggbb-väri
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` on oltava #rrggbb-väri
|
||||
@msg `{key}` must be a number `{key}` on oltava luku
|
||||
@msg `{key}` must be a number, not {v} `{key}` on oltava luku, ei {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} on oltava Unix-aika sekunteina (kokonaisluku) tai null
|
||||
@msg `{key}` must be a string `{key}` on oltava merkkijono
|
||||
@msg `{key}` must be a whole number `{key}` on oltava kokonaisluku
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` on oltava kokonaisluku väliltä {min}–{max} (saatiin {x})
|
||||
@@ -4157,6 +4159,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg missing `tool` `tool` puuttuu
|
||||
@msg missing index indeksi puuttuu
|
||||
@msg missing number `{key}` luku `{key}` puuttuu
|
||||
@msg missing integer `{key}` kokonaisluku `{key}` puuttuu
|
||||
@msg multiple windows land with M11.5 useat ikkunat tulevat versiossa M11.5
|
||||
@msg name the file anna tiedostolle nimi
|
||||
@msg no CSS to copy: select objects with CSS of their own ei kopioitavaa CSS:ää: valitse objekteja, joilla on oma CSS
|
||||
@@ -4279,6 +4282,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg object {id} is not a selected top-level object objekti {id} ei ole valittu ylimmän tason objekti
|
||||
@msg page size {w} × {h} sivun koko {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) sivua {0} ei ole olemassa (PDF-tiedostossa on {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: sivua {n} ei ole olemassa (tiedostossa on 1)
|
||||
@msg paste unavailable liittäminen ei ole käytettävissä
|
||||
@msg path is already closed polku on jo suljettu
|
||||
@msg paths contain invalid coordinates polut sisältävät virheellisiä koordinaatteja
|
||||
@@ -4551,6 +4555,9 @@ imageTrace Outline Ääriviiva
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: vaiheen {i} tuloksessa ei ole tällaista arvoa (kirjoita $$, jos tarkoitat merkkiä $)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: ennen tätä ei ole vaihetta {i} (vaiheet numeroidaan 0:sta; kirjoita $$, jos tarkoitat merkkiä $)
|
||||
@msg object ids are non-negative integers, not {v} objektitunnukset ovat ei-negatiivisia kokonaislukuja, ei {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} on oltava taulukko objektitunnuksia, jotka ovat ei-negatiivisia kokonaislukuja
|
||||
@msg invalid object id {value}: expected a non-negative integer virheellinen objektitunnus {value}: odotettiin ei-negatiivista kokonaislukua
|
||||
@msg no such object id {value} objektia tunnuksella {value} ei ole
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: lukuoikeus puuttuu: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: kirjoitusoikeus puuttuu: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} automaation polku hylättiin: lukujuuren {root} ulkopuolella: {path}
|
||||
@@ -5169,3 +5176,5 @@ mark-style Japanese Japanilainen
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimi ei saa ylittää haluttua arvoa eikä haluttu arvo maksimia.
|
||||
Justification Tasaus
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them tiedoston muuttujia ja tietojoukkoja ei luettu (muuttujia: {variables}, tietojoukkoja: {data_sets}): Muuttujat-paneeli ei näytä niitä
|
||||
# Select by Color (select.color)
|
||||
Select by Color Valitse värin mukaan
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3799,6 +3799,8 @@ axis Both Les deux
|
||||
@msg `{key}` must be a #rrggbb color `{key}` doit être une couleur #rrggbb
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` doit être une couleur #rrggbb
|
||||
@msg `{key}` must be a number `{key}` doit être un nombre
|
||||
@msg `{key}` must be a number, not {v} `{key}` doit être un nombre, pas {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} doit être un temps Unix en secondes (un entier) ou null
|
||||
@msg `{key}` must be a string `{key}` doit être une chaîne
|
||||
@msg `{key}` must be a whole number `{key}` doit être un nombre entier
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` doit être un nombre entier compris entre {min} et {max} (reçu : {x})
|
||||
@@ -3929,6 +3931,7 @@ axis Both Les deux
|
||||
@msg missing `tool` `tool` manquant
|
||||
@msg missing index index manquant
|
||||
@msg missing number `{key}` nombre `{key}` manquant
|
||||
@msg missing integer `{key}` nombre entier `{key}` manquant
|
||||
@msg multiple windows land with M11.5 les fenêtres multiples arrivent avec M11.5
|
||||
@msg name the file nommez le fichier
|
||||
@msg no CSS to copy: select objects with CSS of their own aucun CSS à copier : sélectionnez des objets ayant leur propre CSS
|
||||
@@ -4052,6 +4055,7 @@ axis Both Les deux
|
||||
@msg object {id} is not a selected top-level object l’objet {id} n’est pas un objet sélectionné de premier niveau
|
||||
@msg page size {w} × {h} format de page {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) la page {0} n’existe pas (le PDF en a {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail : la page {n} n’existe pas (le fichier en a 1)
|
||||
@msg paste unavailable collage indisponible
|
||||
@msg path is already closed le tracé est déjà fermé
|
||||
@msg paths contain invalid coordinates les tracés contiennent des coordonnées non valides
|
||||
@@ -4362,6 +4366,9 @@ axis Both Les deux
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}` : le résultat de l’étape {i} n’a pas cette valeur (écrivez $$ pour un $ littéral)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}` : il n’y a pas d’étape {i} avant celle-ci (les étapes sont comptées à partir de 0 ; écrivez $$ pour un $ littéral)
|
||||
@msg object ids are non-negative integers, not {v} les identifiants d’objet sont des entiers positifs ou nuls, pas {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} doit être un tableau d’identifiants d’objet, entiers positifs ou nuls
|
||||
@msg invalid object id {value}: expected a non-negative integer identifiant d’objet {value} non valide (un entier positif ou nul est attendu)
|
||||
@msg no such object id {value} aucun objet ne porte l’identifiant {value}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation de lecture : {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation d’écriture : {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} chemin d’automatisation refusé : hors du dossier racine de lecture {root} : {path}
|
||||
@@ -5204,3 +5211,5 @@ theme Appearance Apparence
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Le minimum ne doit pas dépasser la valeur optimale, ni la valeur optimale le maximum.
|
||||
Justification Justification
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them les variables et jeux de données du fichier n’ont pas été lus (variables : {variables}, jeux de données : {data_sets}) : le panneau Variables ne les affiche pas
|
||||
# Select by Color (select.color)
|
||||
Select by Color Sélectionner par couleur
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4323,6 +4323,8 @@ imageTrace Outline Körvonal
|
||||
@msg `{key}` must be a #rrggbb color `{key}`: #rrggbb szín kell
|
||||
@msg `{key}` must be a #rrggbb colour `{key}`: #rrggbb szín kell
|
||||
@msg `{key}` must be a number `{key}`: szám kell
|
||||
@msg `{key}` must be a number, not {v} `{key}`: szám kell, ez nem az: {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key}: Unix-idő kell másodpercben (egész szám) vagy null
|
||||
@msg `{key}` must be a string `{key}`: szöveg kell
|
||||
@msg `{key}` must be a whole number `{key}`: egész szám kell
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}`: {min} és {max} közötti egész szám kell (kapott érték: {x})
|
||||
@@ -4569,6 +4571,7 @@ imageTrace Outline Körvonal
|
||||
@msg missing `tool` hiányzik: `tool`
|
||||
@msg missing index hiányzik az index
|
||||
@msg missing number `{key}` hiányzik a szám: `{key}`
|
||||
@msg missing integer `{key}` hiányzik az egész szám: `{key}`
|
||||
@msg missing panel layout hiányzik a panelelrendezés
|
||||
@msg move requires a destination panel az áthelyezéshez célpanel kell
|
||||
@msg move requires an existing destination panel az áthelyezéshez létező célpanel kell
|
||||
@@ -4698,6 +4701,9 @@ imageTrace Outline Körvonal
|
||||
@msg nothing to warp nincs mit torzítani
|
||||
@msg nothing to wrap nincs mit körbefuttatni
|
||||
@msg object ids are non-negative integers, not {v} az objektumazonosító nemnegatív egész szám, ez nem az: {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key}: objektumazonosítók tömbje kell, mindegyik nemnegatív egész szám
|
||||
@msg invalid object id {value}: expected a non-negative integer érvénytelen objektumazonosító: {value} (nemnegatív egész szám kell)
|
||||
@msg no such object id {value} nincs ilyen objektumazonosító: {value}
|
||||
@msg object {0} cannot have children ennek az objektumnak nem lehetnek gyermekei: {0}
|
||||
@msg object {id} is not a path az objektum nem útvonal: {id}
|
||||
@msg object {id} is not a selected top-level object az objektum nem kijelölt legfelső szintű objektum: {id}
|
||||
@@ -4713,6 +4719,7 @@ imageTrace Outline Körvonal
|
||||
@msg Packaged {n} file(s){note} Becsomagolt fájlok: {n}{note}
|
||||
@msg page size {w} × {h} oldalméret: {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) nincs ilyen oldal: {0} (a PDF oldalszáma: {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: nincs ilyen oldal: {n} (a fájl oldalszáma: 1)
|
||||
@msg panel group is empty a panelcsoport üres
|
||||
@msg panel must name a registered panel a panel egy regisztrált panelt kell megnevezzen
|
||||
@msg paste unavailable a beillesztés nem érhető el
|
||||
@@ -5191,3 +5198,5 @@ imageTrace Outline Körvonal
|
||||
Minimum must not exceed Desired, nor Desired Maximum. A minimum nem haladhatja meg a kívánt értéket, sem a kívánt érték a maximumot.
|
||||
Justification Sorkizárás
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them a fájl változói és adatkészletei nem lettek beolvasva (változók: {variables}, adatkészletek: {data_sets}): a Változók panel nem listázza őket
|
||||
# Select by Color (select.color)
|
||||
Select by Color Kijelölés szín szerint
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3855,6 +3855,8 @@ imageTrace Outline Kontur
|
||||
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` harus `auto` atau kode bahasa seperti `en` atau `zh-hant`
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` harus berupa warna #rrggbb
|
||||
@msg `{key}` must be a number `{key}` harus berupa angka
|
||||
@msg `{key}` must be a number, not {v} `{key}` harus berupa angka, bukan {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} harus berupa waktu Unix dalam detik (bilangan bulat) atau null
|
||||
@msg `{key}` must be a string `{key}` harus berupa string
|
||||
@msg `{key}` must be a whole number `{key}` harus berupa bilangan bulat
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` harus berupa bilangan bulat antara {min} dan {max} (diperoleh {x})
|
||||
@@ -3974,6 +3976,8 @@ imageTrace Outline Kontur
|
||||
@msg missing `name` `name` tidak ada
|
||||
@msg missing `tool` `tool` tidak ada
|
||||
@msg missing index indeks tidak ada
|
||||
@msg missing number `{key}` angka `{key}` tidak ada
|
||||
@msg missing integer `{key}` bilangan bulat `{key}` tidak ada
|
||||
@msg multiple windows land with M11.5 beberapa jendela hadir bersama M11.5
|
||||
@msg name the file beri nama file
|
||||
@msg no CSS to copy: select objects with CSS of their own tidak ada CSS untuk disalin: pilih objek yang memiliki CSS sendiri
|
||||
@@ -4356,6 +4360,9 @@ imageTrace Outline Kontur
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: hasil langkah {i} tidak memiliki nilai seperti itu (tulis $$ untuk $ literal)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: tidak ada langkah {i} sebelum langkah ini (langkah dihitung mulai dari 0; tulis $$ untuk $ literal)
|
||||
@msg object ids are non-negative integers, not {v} id objek adalah bilangan bulat non-negatif, bukan {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} harus berupa larik id objek yang berupa bilangan bulat non-negatif
|
||||
@msg invalid object id {value}: expected a non-negative integer id objek {value} tidak valid: harus berupa bilangan bulat non-negatif
|
||||
@msg no such object id {value} tidak ada objek dengan id {value}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas baca tidak ada: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas tulis tidak ada: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} jalur otomatisasi ditolak: di luar akar baca {root}: {path}
|
||||
@@ -5188,3 +5195,5 @@ swatch Bright Violet Violet cerah
|
||||
Full Justify Rata Penuh
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum tidak boleh melebihi Diinginkan, dan Diinginkan tidak boleh melebihi Maksimum.
|
||||
Justification Justifikasi
|
||||
# Select by Color (select.color)
|
||||
Select by Color Pilih berdasarkan warna
|
||||
|
Can't render this file because it contains an unexpected character in line 3768 and column 19.
|
@@ -3841,6 +3841,8 @@ axis Both Entrambi
|
||||
@msg `{key}` must be a #rrggbb color `{key}` deve essere un colore #rrggbb
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` deve essere un colore #rrggbb
|
||||
@msg `{key}` must be a number `{key}` deve essere un numero
|
||||
@msg `{key}` must be a number, not {v} `{key}` deve essere un numero, non {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} deve essere un tempo Unix in secondi (un intero) o null
|
||||
@msg `{key}` must be a string `{key}` deve essere una stringa
|
||||
@msg `{key}` must be a whole number `{key}` deve essere un numero intero
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` deve essere un numero intero tra {min} e {max} (ricevuto {x})
|
||||
@@ -3971,6 +3973,7 @@ axis Both Entrambi
|
||||
@msg missing `tool` manca `tool`
|
||||
@msg missing index manca index
|
||||
@msg missing number `{key}` manca il numero `{key}`
|
||||
@msg missing integer `{key}` manca il numero intero `{key}`
|
||||
@msg multiple windows land with M11.5 le finestre multiple arriveranno con M11.5
|
||||
@msg name the file assegna un nome al file
|
||||
@msg no CSS to copy: select objects with CSS of their own nessun CSS da copiare: seleziona oggetti con un CSS proprio
|
||||
@@ -4094,6 +4097,7 @@ axis Both Entrambi
|
||||
@msg object {id} is not a selected top-level object l'oggetto {id} non è un oggetto selezionato di primo livello
|
||||
@msg page size {w} × {h} dimensioni pagina {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) la pagina {0} non esiste (il PDF ne ha {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: la pagina {n} non esiste (il file ne ha 1)
|
||||
@msg paste unavailable impossibile incollare
|
||||
@msg path is already closed il tracciato è già chiuso
|
||||
@msg paths contain invalid coordinates i tracciati contengono coordinate non valide
|
||||
@@ -4390,6 +4394,9 @@ axis Both Entrambi
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: il risultato del passaggio {i} non ha questo valore (scrivi $$ per un $ letterale)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: non c’è un passaggio {i} prima di questo (i passaggi si contano da 0; scrivi $$ per un $ letterale)
|
||||
@msg object ids are non-negative integers, not {v} gli ID oggetto sono interi non negativi, non {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} deve essere un array di ID oggetto interi non negativi
|
||||
@msg invalid object id {value}: expected a non-negative integer ID oggetto {value} non valido: è richiesto un intero non negativo
|
||||
@msg no such object id {value} nessun oggetto con ID {value}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di lettura: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di scrittura: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di lettura {root}: {path}
|
||||
@@ -4819,3 +4826,5 @@ lighting Light Luce
|
||||
Minimum Minimo
|
||||
Justification Giustificazione
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them le variabili e i set di dati del file non sono stati letti (variabili: {variables}, set di dati: {data_sets}): il pannello Variabili non li elenca
|
||||
# Select by Color (select.color)
|
||||
Select by Color Seleziona per colore
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3927,6 +3927,8 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg `freeform.points` must be an array `freeform.points` には配列を指定してください
|
||||
@msg `freeform.lines` must be an array `freeform.lines` には配列を指定してください
|
||||
@msg `{key}` must be a number `{key}` には数値を指定してください
|
||||
@msg `{key}` must be a number, not {v} `{key}` には数値を指定してください ({v} は使えません)
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} には Unix 時間の秒数 (整数) または null を指定してください
|
||||
@msg a freeform gradient keeps at least one point フリーフォームグラデーションには少なくとも 1 つのポイントが必要です
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` フリーフォームのポイント {i} には `at` [x, y] と有効な `color` が必要です
|
||||
@msg freeform line {i} must be an array of point indices フリーフォームのライン {i} にはポイントのインデックスの配列を指定してください
|
||||
@@ -4166,6 +4168,7 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg cannot read PDF: {0} PDF を読み込めません: {0}
|
||||
@msg invalid PDF setting: {0} PDF の設定が正しくありません: {0}
|
||||
@msg page {0} does not exist (the PDF has {1}) ページ {0} は存在しません (この PDF のページ数は {1} です)
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: ページ {n} は存在しません (このファイルのページ数は 1 です)
|
||||
@msg the PDF has no pages PDF にページがありません
|
||||
@msg the PDF is password-protected: give its password この PDF はパスワードで保護されています: パスワードを入力してください
|
||||
@msg the PDF password is wrong PDF のパスワードが正しくありません
|
||||
@@ -4318,6 +4321,7 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg missing `tool` `tool` が指定されていません
|
||||
@msg missing index index が指定されていません
|
||||
@msg missing number `{key}` 数値 `{key}` が指定されていません
|
||||
@msg missing integer `{key}` 整数 `{key}` が指定されていません
|
||||
@msg multiple windows land with M11.5 複数ウィンドウは M11.5 で対応予定です
|
||||
@msg no PNG encoder PNG エンコーダーがありません
|
||||
@msg no document open ドキュメントが開かれていません
|
||||
@@ -4428,6 +4432,9 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ステップ {i} の結果にその値はありません ($ そのものを書くには $$ と入力してください)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: この前にステップ {i} はありません (ステップは 0 から数えます。$ そのものを書くには $$ と入力してください)
|
||||
@msg object ids are non-negative integers, not {v} オブジェクト ID は 0 以上の整数です ({v} は使えません)
|
||||
@msg {key} must be an array of non-negative integer object ids {key} にはオブジェクト ID (0 以上の整数) の配列を指定してください
|
||||
@msg invalid object id {value}: expected a non-negative integer オブジェクト ID {value} は正しくありません: 0 以上の整数を指定してください
|
||||
@msg no such object id {value} ID が {value} のオブジェクトは見つかりません
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 読み込み権限がありません: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 書き込み権限がありません: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} 自動化のパスを拒否しました: 読み込みルート {root} の外です: {path}
|
||||
@@ -4859,3 +4866,5 @@ lighting Light ライト
|
||||
Minimum 最小
|
||||
Justification 文字組み
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them ファイルの変数とデータセットは読み込まれませんでした (変数: {variables}、データセット: {data_sets})。変数パネルには表示されません
|
||||
# Select by Color (select.color)
|
||||
Select by Color カラーで選択
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3618,6 +3618,8 @@ effect Regular ჩვეულებრივი
|
||||
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` უნდა იყოს `auto` ან ენის კოდი, მაგალითად `en` ან `zh-hant`
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` უნდა იყოს #rrggbb ფერი
|
||||
@msg `{key}` must be a number `{key}` უნდა იყოს რიცხვი
|
||||
@msg `{key}` must be a number, not {v} `{key}` უნდა იყოს რიცხვი და არა {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} უნდა იყოს Unix-დრო წამებში (მთელი რიცხვი) ან null
|
||||
@msg `{key}` must be a string `{key}` უნდა იყოს სტრიქონი
|
||||
@msg `{key}` must be a whole number `{key}` უნდა იყოს მთელი რიცხვი
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` უნდა იყოს მთელი რიცხვი {min}-დან {max}-მდე (მიღებულია {x})
|
||||
@@ -4108,6 +4110,9 @@ effect Regular ჩვეულებრივი
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ნაბიჯ {i}-ის შედეგს ასეთი მნიშვნელობა არ აქვს (ლიტერალური $-ისთვის დაწერეთ $$)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: ამის წინ ნაბიჯი {i} არ არის (ნაბიჯები 0-დან ითვლება; ლიტერალური $-ისთვის დაწერეთ $$)
|
||||
@msg object ids are non-negative integers, not {v} ობიექტების id არაუარყოფითი მთელი რიცხვებია და არა {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} უნდა იყოს ობიექტების id-ების მასივი, სადაც ყოველი id არაუარყოფითი მთელი რიცხვია
|
||||
@msg invalid object id {value}: expected a non-negative integer ობიექტის არასწორი id {value}: მოსალოდნელია არაუარყოფითი მთელი რიცხვი
|
||||
@msg no such object id {value} ობიექტი id-ით {value} არ არსებობს
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: წაკითხვის უფლება არ არის: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: ჩაწერის უფლება არ არის: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირის {root} გარეთაა: {path}
|
||||
@@ -5072,6 +5077,7 @@ imageTrace Outline კონტური
|
||||
@msg library panel `{given}` isn't open ბიბლიოთეკის პანელი `{given}` გახსნილი არ არის
|
||||
@msg mask art ends outside a mask dictionary ნიღბის ნახატი ნიღბის ლექსიკონის გარეთ მთავრდება
|
||||
@msg missing number `{key}` აკლია რიცხვი `{key}`
|
||||
@msg missing integer `{key}` აკლია მთელი რიცხვი `{key}`
|
||||
@msg no such spine point ხერხემლის ასეთი წერტილი არ არსებობს
|
||||
@msg no such spine segment ხერხემლის ასეთი სეგმენტი არ არსებობს
|
||||
@msg not a blend ეს გადასვლა არ არის
|
||||
@@ -5097,8 +5103,8 @@ imageTrace Outline კონტური
|
||||
Full Justify სრული ორმხრივი სწორება
|
||||
Glyph Scaling: გლიფების მასშტაბირება:
|
||||
Justification სწორება
|
||||
Letter Spacing: ასოთა დაშორება:
|
||||
Word Spacing: სიტყვათა დაშორება:
|
||||
Letter Spacing: ასოებს შორის დაშორება:
|
||||
Word Spacing: სიტყვებს შორის დაშორება:
|
||||
Minimum must not exceed Desired, nor Desired Maximum. მინიმალური არ უნდა აღემატებოდეს სასურველს, ხოლო სასურველი — მაქსიმალურს.
|
||||
Single Word Justification: ერთი სიტყვის სწორება:
|
||||
Apply {name} სტილის „{name}“ გამოყენება
|
||||
@@ -5108,7 +5114,10 @@ imageTrace Outline კონტური
|
||||
@msg no history to clear გასასუფთავებელი ისტორია არ არის
|
||||
@msg {_1} files are written by file.saveAsTemplate {_1} ფაილებს წერს file.saveAsTemplate
|
||||
@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 წერს ძველი ფორმატის .ai ფაილებს; .ait შაბლონი ყოველთვის PDF-თან თავსებადია
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them ფაილის ცვლადები და მონაცემთა ნაკრებები ვერ წაიკითხა (ცვლადები: {variables}, მონაცემთა ნაკრებები: {data_sets}): პანელი „ცვლადები“ მათ არ აჩვენებს
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them ფაილის ცვლადებისა და მონაცემთა ნაკრებების წაკითხვა ვერ მოხერხდა (ცვლადები: {variables}, მონაცემთა ნაკრებები: {data_sets}): პანელი „ცვლადები“ მათ არ აჩვენებს
|
||||
@msg its zlib compression is damaged მისი zlib შეკუმშვა დაზიანებულია
|
||||
@msg its Zstandard compression is damaged მისი Zstandard შეკუმშვა დაზიანებულია
|
||||
@msg it decompresses to more than {_1} MB გაშლისას {_1} MB-ს აღემატება
|
||||
# Select by Color (select.color)
|
||||
Select by Color მონიშვნა ფერის მიხედვით
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: გვერდი {n} არ არსებობს (ფაილს 1 გვერდი აქვს)
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3859,6 +3859,8 @@ theme Appearance 외관
|
||||
@msg `freeform.points` must be an array `freeform.points`는 배열이어야 합니다
|
||||
@msg `freeform.lines` must be an array `freeform.lines`는 배열이어야 합니다
|
||||
@msg `{key}` must be a number `{key}`은(는) 숫자여야 합니다
|
||||
@msg `{key}` must be a number, not {v} `{key}`은(는) 숫자여야 하며 {v}은(는) 안 됩니다
|
||||
@msg {key} must be Unix seconds (an integer) or null {key}은(는) 초 단위 Unix 시간(정수) 또는 null이어야 합니다
|
||||
@msg a freeform gradient keeps at least one point 자유형 그레이디언트에는 점이 하나 이상 있어야 합니다
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` 자유형 점 {i}에는 `at` [x, y]과 올바른 `color`가 필요합니다
|
||||
@msg freeform line {i} must be an array of point indices 자유형 선 {i}은(는) 점 인덱스 배열이어야 합니다
|
||||
@@ -4098,6 +4100,7 @@ theme Appearance 외관
|
||||
@msg cannot read PDF: {0} PDF를 읽을 수 없습니다: {0}
|
||||
@msg invalid PDF setting: {0} 잘못된 PDF 설정: {0}
|
||||
@msg page {0} does not exist (the PDF has {1}) 페이지 {0}이(가) 없습니다(PDF의 페이지 수: {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: 페이지 {n}이(가) 없습니다(파일의 페이지 수: 1)
|
||||
@msg the PDF has no pages PDF에 페이지가 없습니다
|
||||
@msg the PDF is password-protected: give its password PDF가 암호로 보호되어 있습니다: 암호를 입력하세요
|
||||
@msg the PDF password is wrong PDF 암호가 올바르지 않습니다
|
||||
@@ -4250,6 +4253,7 @@ theme Appearance 외관
|
||||
@msg missing `tool` `tool`이 없습니다
|
||||
@msg missing index index가 없습니다
|
||||
@msg missing number `{key}` 숫자 `{key}`이(가) 없습니다
|
||||
@msg missing integer `{key}` 정수 `{key}`이(가) 없습니다
|
||||
@msg multiple windows land with M11.5 여러 창은 M11.5에서 지원됩니다
|
||||
@msg no PNG encoder PNG 인코더가 없습니다
|
||||
@msg no document open 열린 문서가 없습니다
|
||||
@@ -4360,6 +4364,9 @@ theme Appearance 외관
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: 단계 {i}의 결과에 해당 값이 없습니다(문자 그대로의 $는 $$로 쓰세요)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: 이 단계 앞에 단계 {i}이(가) 없습니다(단계는 0부터 셉니다. 문자 그대로의 $는 $$로 쓰세요)
|
||||
@msg object ids are non-negative integers, not {v} 개체 id는 음이 아닌 정수여야 하며 {v}은(는) 안 됩니다
|
||||
@msg {key} must be an array of non-negative integer object ids {key}은(는) 음이 아닌 정수인 개체 id의 배열이어야 합니다
|
||||
@msg invalid object id {value}: expected a non-negative integer 개체 id {value}이(가) 올바르지 않습니다: 음이 아닌 정수여야 합니다
|
||||
@msg no such object id {value} id가 {value}인 개체가 없습니다
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 읽기 권한이 없습니다: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 쓰기 권한이 없습니다: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} 자동화 경로가 거부되었습니다: 읽기 루트 {root} 밖입니다: {path}
|
||||
@@ -4782,3 +4789,5 @@ lighting Light 광원
|
||||
Minimum 최소
|
||||
Justification 양끝 맞춤
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them 파일의 변수와 데이터 세트를 읽지 않았습니다(변수: {variables}, 데이터 세트: {data_sets}). 변수 패널에 표시되지 않습니다
|
||||
# Select by Color (select.color)
|
||||
Select by Color 색상별 선택
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -219,8 +219,8 @@ pub static LANGUAGES: [LangInfo; 24] = [
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
// Traditional Chinese in the vocabulary used in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*`
|
||||
// locales all resolve here (see `candidates`).
|
||||
// Traditional Chinese in the vocabulary used in Taiwan: the whole interface and the status and
|
||||
// error messages; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` locales all resolve here (see `candidates`).
|
||||
LangInfo {
|
||||
code: "zh-hant",
|
||||
name: "繁體中文",
|
||||
|
||||
@@ -3869,6 +3869,8 @@ imageTrace Outline Contour
|
||||
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` moet `auto` zijn of een taalcode zoals `en` of `zh-hant`
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` moet een #rrggbb-kleur zijn
|
||||
@msg `{key}` must be a number `{key}` moet een getal zijn
|
||||
@msg `{key}` must be a number, not {v} `{key}` moet een getal zijn, niet {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} moet Unix-tijd in seconden (een geheel getal) of null zijn
|
||||
@msg `{key}` must be a string `{key}` moet een string zijn
|
||||
@msg `{key}` must be a whole number `{key}` moet een geheel getal zijn
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` moet een geheel getal tussen {min} en {max} zijn (kreeg {x})
|
||||
@@ -4370,6 +4372,9 @@ imageTrace Outline Contour
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: het resultaat van stap {i} heeft geen dergelijke waarde (schrijf $$ voor een letterlijke $)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: er is geen stap {i} vóór deze stap (stappen tellen vanaf 0; schrijf $$ voor een letterlijke $)
|
||||
@msg object ids are non-negative integers, not {v} object-id's zijn niet-negatieve gehele getallen, niet {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} moet een array van object-id's zijn die niet-negatieve gehele getallen zijn
|
||||
@msg invalid object id {value}: expected a non-negative integer ongeldige object-id {value}: een niet-negatief geheel getal verwacht
|
||||
@msg no such object id {value} er is geen object met id {value}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de leesbevoegdheid ontbreekt: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de schrijfbevoegdheid ontbreekt: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} automatiseringspad afgewezen: buiten de leesroot {root}: {path}
|
||||
@@ -5198,6 +5203,7 @@ swatch Bright Violet Felviolet
|
||||
@msg index must be ≥ 0 index moet ≥ 0 zijn
|
||||
@msg mask art ends outside a mask dictionary maskerillustratie eindigt buiten een maskerwoordenboek
|
||||
@msg missing number `{key}` getal `{key}` ontbreekt
|
||||
@msg missing integer `{key}` geheel getal `{key}` ontbreekt
|
||||
@msg no such spine point dat aspunt bestaat niet
|
||||
@msg no such spine segment dat assegment bestaat niet
|
||||
@msg not a blend geen vermenging
|
||||
@@ -5231,3 +5237,5 @@ swatch Bright Violet Felviolet
|
||||
Full Justify Volledig uitvullen
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum mag niet groter zijn dan Gewenst, en Gewenst niet groter dan Maximum.
|
||||
Justification Uitvullen
|
||||
# Select by Color (select.color)
|
||||
Select by Color Selecteren op kleur
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3926,6 +3926,8 @@ imageTrace Outline Kontur
|
||||
@msg `{key}` must be a #rrggbb color `{key}` musi być kolorem #rrggbb
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` musi być kolorem #rrggbb
|
||||
@msg `{key}` must be a number `{key}` musi być liczbą
|
||||
@msg `{key}` must be a number, not {v} `{key}` musi być liczbą, nie {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} musi być czasem Unix w sekundach (liczbą całkowitą) lub null
|
||||
@msg `{key}` must be a string `{key}` musi być ciągiem znaków
|
||||
@msg `{key}` must be a whole number `{key}` musi być liczbą całkowitą
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` musi być liczbą całkowitą od {min} do {max} (podano {x})
|
||||
@@ -4056,6 +4058,7 @@ imageTrace Outline Kontur
|
||||
@msg missing `tool` brak `tool`
|
||||
@msg missing index brak indeksu
|
||||
@msg missing number `{key}` brak liczby `{key}`
|
||||
@msg missing integer `{key}` brak liczby całkowitej `{key}`
|
||||
@msg multiple windows land with M11.5 wiele okien pojawi się w M11.5
|
||||
@msg name the file nadaj nazwę plikowi
|
||||
@msg no CSS to copy: select objects with CSS of their own brak CSS do skopiowania: zaznacz obiekty z własnym CSS
|
||||
@@ -4180,6 +4183,7 @@ imageTrace Outline Kontur
|
||||
@msg object {id} is not a selected top-level object obiekt {id} nie jest zaznaczonym obiektem najwyższego poziomu
|
||||
@msg page size {w} × {h} rozmiar strony {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) strona {0} nie istnieje (PDF ma {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: strona {n} nie istnieje (plik ma 1)
|
||||
@msg paste unavailable wklejanie niedostępne
|
||||
@msg path is already closed ścieżka jest już zamknięta
|
||||
@msg paths contain invalid coordinates ścieżki zawierają nieprawidłowe współrzędne
|
||||
@@ -4454,6 +4458,9 @@ imageTrace Outline Kontur
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: wynik kroku {i} nie ma takiej wartości (wpisz $$, aby uzyskać znak $)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: przed tym krokiem nie ma kroku {i} (kroki liczone od 0; wpisz $$, aby uzyskać znak $)
|
||||
@msg object ids are non-negative integers, not {v} identyfikatory obiektów to nieujemne liczby całkowite, nie {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} musi być tablicą identyfikatorów obiektów, które są nieujemnymi liczbami całkowitymi
|
||||
@msg invalid object id {value}: expected a non-negative integer nieprawidłowy identyfikator obiektu {value}: oczekiwano nieujemnej liczby całkowitej
|
||||
@msg no such object id {value} brak obiektu o identyfikatorze {value}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do odczytu: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do zapisu: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} odrzucono ścieżkę automatyzacji: poza katalogiem głównym odczytu {root}: {path}
|
||||
@@ -5057,3 +5064,5 @@ mark-style Japanese Japońskie
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum nie może przekraczać wartości optymalnej, a wartość optymalna maksimum.
|
||||
Justification Justowanie
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them zmienne i zestawy danych pliku nie zostały wczytane (zmienne: {variables}, zestawy danych: {data_sets}): panel Zmienne ich nie wyświetla
|
||||
# Select by Color (select.color)
|
||||
Select by Color Zaznacz według koloru
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3391,6 +3391,8 @@ axis Both Ambos
|
||||
@msg `{key}` must be a #rrggbb color `{key}` deve ser uma cor #rrggbb
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` deve ser uma cor #rrggbb
|
||||
@msg `{key}` must be a number `{key}` deve ser um número
|
||||
@msg `{key}` must be a number, not {v} `{key}` deve ser um número, não {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} deve ser um tempo Unix em segundos (um inteiro) ou null
|
||||
@msg `{key}` must be a string `{key}` deve ser uma string
|
||||
@msg `{key}` must be a whole number `{key}` deve ser um número inteiro
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` deve ser um número inteiro entre {min} e {max} (recebido {x})
|
||||
@@ -3515,6 +3517,7 @@ axis Both Ambos
|
||||
@msg missing `tool` falta `tool`
|
||||
@msg missing index falta o índice
|
||||
@msg missing number `{key}` falta o número `{key}`
|
||||
@msg missing integer `{key}` falta o número inteiro `{key}`
|
||||
@msg multiple windows land with M11.5 várias janelas chegam com o M11.5
|
||||
@msg name the file dê um nome ao arquivo
|
||||
@msg no CSS to copy: select objects with CSS of their own não há CSS para copiar: selecione objetos que tenham CSS próprio
|
||||
@@ -3636,6 +3639,7 @@ axis Both Ambos
|
||||
@msg object {id} is not a path o objeto {id} não é um traçado
|
||||
@msg page size {w} × {h} tamanho da página {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) a página {0} não existe (o PDF tem {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: a página {n} não existe (o arquivo tem 1)
|
||||
@msg paste unavailable colar indisponível
|
||||
@msg path is already closed o traçado já está fechado
|
||||
@msg paths contain invalid coordinates os traçados contêm coordenadas inválidas
|
||||
@@ -3904,6 +3908,9 @@ axis Both Ambos
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: o resultado da etapa {i} não tem esse valor (escreva $$ para um $ literal)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: não há etapa {i} antes desta (as etapas contam a partir de 0; escreva $$ para um $ literal)
|
||||
@msg object ids are non-negative integers, not {v} os ids de objeto são inteiros não negativos, não {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} deve ser uma matriz de ids de objeto que sejam inteiros não negativos
|
||||
@msg invalid object id {value}: expected a non-negative integer id de objeto inválido {value}: era esperado um inteiro não negativo
|
||||
@msg no such object id {value} não existe objeto com o id {value}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de leitura: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de gravação: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} caminho da automação rejeitado: fora da raiz de leitura {root}: {path}
|
||||
@@ -5122,3 +5129,5 @@ mark-style Japanese Japonesas
|
||||
Minimum must not exceed Desired, nor Desired Maximum. O mínimo não pode passar do desejado, nem o desejado do máximo.
|
||||
Justification Justificação
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them as variáveis e os conjuntos de dados do arquivo não foram lidos (variáveis: {variables}, conjuntos de dados: {data_sets}): o painel Variáveis não os lista
|
||||
# Select by Color (select.color)
|
||||
Select by Color Selecionar por Cor
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3837,6 +3837,8 @@ axis Both Оба
|
||||
@msg `{key}` must be a #rrggbb color `{key}` должно быть цветом #rrggbb
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` должно быть цветом #rrggbb
|
||||
@msg `{key}` must be a number `{key}` должно быть числом
|
||||
@msg `{key}` must be a number, not {v} `{key}` должно быть числом, а не {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} должно быть временем Unix в секундах (целым числом) или null
|
||||
@msg `{key}` must be a string `{key}` должно быть строкой
|
||||
@msg `{key}` must be a whole number `{key}` должно быть целым числом
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` должно быть целым числом от {min} до {max} (получено {x})
|
||||
@@ -3967,6 +3969,7 @@ axis Both Оба
|
||||
@msg missing `tool` отсутствует `tool`
|
||||
@msg missing index отсутствует index
|
||||
@msg missing number `{key}` отсутствует число `{key}`
|
||||
@msg missing integer `{key}` отсутствует целое число `{key}`
|
||||
@msg multiple windows land with M11.5 несколько окон появятся в M11.5
|
||||
@msg name the file дайте файлу имя
|
||||
@msg no CSS to copy: select objects with CSS of their own нет CSS для копирования: выделите объекты с собственным CSS
|
||||
@@ -4090,6 +4093,7 @@ axis Both Оба
|
||||
@msg object {id} is not a selected top-level object объект {id} не является выделенным объектом верхнего уровня
|
||||
@msg page size {w} × {h} размер страницы {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) страница {0} не существует (в PDF {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: страница {n} не существует (в файле 1)
|
||||
@msg paste unavailable вставка недоступна
|
||||
@msg path is already closed контур уже замкнут
|
||||
@msg paths contain invalid coordinates контуры содержат недопустимые координаты
|
||||
@@ -4388,6 +4392,9 @@ axis Both Оба
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: в результате шага {i} нет такого значения (чтобы написать сам $, введите $$)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед этим шагом нет шага {i} (шаги считаются с 0; чтобы написать сам $, введите $$)
|
||||
@msg object ids are non-negative integers, not {v} идентификаторы объектов — неотрицательные целые числа, а не {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} должно быть массивом идентификаторов объектов, которые являются неотрицательными целыми числами
|
||||
@msg invalid object id {value}: expected a non-negative integer недопустимый идентификатор объекта {value}: ожидается неотрицательное целое число
|
||||
@msg no such object id {value} объект с идентификатором {value} не существует
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на чтение: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на запись: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} путь автоматизации отклонён: вне корневой папки для чтения {root}: {path}
|
||||
@@ -4815,3 +4822,5 @@ lighting Light Свет
|
||||
Minimum Минимум
|
||||
Justification Выключка
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them переменные и наборы данных файла не прочитаны (переменных: {variables}, наборов данных: {data_sets}): панель «Переменные» их не показывает
|
||||
# Select by Color (select.color)
|
||||
Select by Color Выделить по цвету
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3731,6 +3731,8 @@ axis Both Båda
|
||||
@msg `{key}` must be a #rrggbb color `{key}` måste vara en färg i formatet #rrggbb
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` måste vara en färg i formatet #rrggbb
|
||||
@msg `{key}` must be a number `{key}` måste vara ett tal
|
||||
@msg `{key}` must be a number, not {v} `{key}` måste vara ett tal, inte {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} måste vara Unix-tid i sekunder (ett heltal) eller null
|
||||
@msg `{key}` must be a string `{key}` måste vara en sträng
|
||||
@msg `{key}` must be a whole number `{key}` måste vara ett heltal
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` måste vara ett heltal mellan {min} och {max} (fick {x})
|
||||
@@ -3857,6 +3859,7 @@ axis Both Båda
|
||||
@msg missing `tool` `tool` saknas
|
||||
@msg missing index index saknas
|
||||
@msg missing number `{key}` talet `{key}` saknas
|
||||
@msg missing integer `{key}` heltalet `{key}` saknas
|
||||
@msg multiple windows land with M11.5 flera fönster kommer med M11.5
|
||||
@msg name the file namnge filen
|
||||
@msg no CSS to copy: select objects with CSS of their own ingen CSS att kopiera: markera objekt som har egen CSS
|
||||
@@ -3980,6 +3983,7 @@ axis Both Båda
|
||||
@msg object {id} is not a selected top-level object objekt {id} är inte ett markerat objekt på toppnivå
|
||||
@msg page size {w} × {h} sidstorlek {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) sidan {0} finns inte (PDF-filen har {1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: sidan {n} finns inte (filen har 1)
|
||||
@msg paste unavailable inklistring är inte tillgänglig
|
||||
@msg path is already closed banan är redan sluten
|
||||
@msg paths contain invalid coordinates banorna innehåller ogiltiga koordinater
|
||||
@@ -4287,6 +4291,9 @@ axis Both Båda
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: resultatet från steg {i} har inget sådant värde (skriv $$ för ett bokstavligt $)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: det finns inget steg {i} före det här (stegen räknas från 0; skriv $$ för ett bokstavligt $)
|
||||
@msg object ids are non-negative integers, not {v} objekt-id:n är icke-negativa heltal, inte {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} måste vara en array med objekt-id:n som är icke-negativa heltal
|
||||
@msg invalid object id {value}: expected a non-negative integer ogiltigt objekt-id {value}: ett icke-negativt heltal förväntades
|
||||
@msg no such object id {value} det finns inget objekt med id {value}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} automatiseringen har inte fått åtkomst till filsystemet: läsbehörighet saknas: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} automatiseringen har inte fått åtkomst till filsystemet: skrivbehörighet saknas: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} automatiseringssökvägen avvisades: utanför läsroten {root}: {path}
|
||||
@@ -5161,3 +5168,5 @@ theme Appearance Utseende
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum får inte överstiga Önskat, och Önskat inte Maximum.
|
||||
Justification Justering
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them filens variabler och datauppsättningar lästes inte (variabler: {variables}, datauppsättningar: {data_sets}): panelen Variabler visar dem inte
|
||||
# Select by Color (select.color)
|
||||
Select by Color Markera efter färg
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1823,9 +1823,53 @@ fn select_key_param_error_is_translated() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The errors of the parameter checks that many commands share (a number given as text, a missing
|
||||
/// number or index, a fraction for a whole number, `ids` that aren't a list of object ids, an
|
||||
/// object id that is negative or names no object, a date that isn't Unix seconds) are translated
|
||||
/// whole by their own rows in each language of [`COMPLETE_MESSAGES`], in Bulgarian, and in Danish,
|
||||
/// Indonesian and Dutch. The value in the message, such as a quoted `"60 mm"`, is shown as given.
|
||||
#[test]
|
||||
fn shared_param_check_errors_are_translated() {
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::EngineError;
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
// A command, its params, the English template of its error and the template's values.
|
||||
type Case = (&'static str, serde_json::Value, &'static str, &'static [(&'static str, &'static str)]);
|
||||
let cases: [Case; 10] = [
|
||||
("object.move", json!({"dx": "60 mm"}), "`{key}` must be a number, not {v}", &[("key", "dx"), ("v", r#""60 mm""#)]),
|
||||
("object.rotate", json!({"angle": true}), "`{key}` must be a number, not {v}", &[("key", "angle"), ("v", "true")]),
|
||||
("object.setBounds", json!({"width": [1, 2]}), "`{key}` must be a number, not {v}", &[("key", "width"), ("v", "[1,2]")]),
|
||||
("shape.rectangle", json!({"x": 0, "y": 0, "width": 10}), "missing number `{key}`", &[("key", "height")]),
|
||||
("appearance.setItem", json!({}), "missing integer `{key}`", &[("key", "index")]),
|
||||
("object.transformEach", json!({"reference": 2.5}), "`{key}` must be a whole number", &[("key", "reference")]),
|
||||
("edit.clear", json!({"ids": "all"}), "{key} must be an array of non-negative integer object ids", &[("key", "ids")]),
|
||||
("edit.clear", json!({"ids": [-1]}), "invalid object id {value}: expected a non-negative integer", &[("value", "-1")]),
|
||||
("edit.clear", json!({"ids": [99999]}), "no such object id {value}", &[("value", "99999")]),
|
||||
("file.new", json!({"created": "yesterday"}), "{key} must be Unix seconds (an integer) or null", &[("key", "created")]),
|
||||
];
|
||||
let mut untranslated = Vec::new();
|
||||
for (cmd, p, template, values) in cases {
|
||||
let e = s.execute(cmd, &p).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::BadParams { msg, .. } if *msg == fmt(template, values)), "{cmd} {p}: {e}");
|
||||
for code in COMPLETE_MESSAGES.iter().chain(&["bg", "da", "id", "nl"]) {
|
||||
let lang = Lang::from_code(code).unwrap();
|
||||
let (entries, _) = parse_entries(lang.0.source);
|
||||
let row = entries.iter().find(|(ctx, src, _)| ctx == "@msg" && src == template);
|
||||
let shown = message(lang, &e.to_string());
|
||||
if shown.contains("invalid parameters") || !row.is_some_and(|(_, _, tr)| shown.contains(&fmt(tr, values))) {
|
||||
untranslated.push(format!("{code}: {shown}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(untranslated.is_empty(), "untranslated parameter check errors:\n{}", untranslated.join("\n"));
|
||||
}
|
||||
|
||||
/// Languages whose catalogs cover every status and error message.
|
||||
const COMPLETE_MESSAGES: &[&str] =
|
||||
&["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ka", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "zh-hans", "fi"];
|
||||
&["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ka", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "zh-hans", "zh-hant", "fi"];
|
||||
|
||||
/// Crates whose error and status messages reach the status bar.
|
||||
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"];
|
||||
|
||||
@@ -3825,6 +3825,8 @@ axis Both Обидві
|
||||
@msg `{key}` must be a #rrggbb color `{key}` має бути кольором #rrggbb
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` має бути кольором #rrggbb
|
||||
@msg `{key}` must be a number `{key}` має бути числом
|
||||
@msg `{key}` must be a number, not {v} `{key}` має бути числом, а не {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} має бути часом Unix у секундах (цілим числом) або null
|
||||
@msg `{key}` must be a string `{key}` має бути рядком
|
||||
@msg `{key}` must be a whole number `{key}` має бути цілим числом
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` має бути цілим числом від {min} до {max} (отримано {x})
|
||||
@@ -3955,6 +3957,7 @@ axis Both Обидві
|
||||
@msg missing `tool` відсутня `tool`
|
||||
@msg missing index відсутня index
|
||||
@msg missing number `{key}` відсутнє число `{key}`
|
||||
@msg missing integer `{key}` відсутнє ціле число `{key}`
|
||||
@msg multiple windows land with M11.5 кілька вікон з’являться в M11.5
|
||||
@msg name the file дайте файлу ім’я
|
||||
@msg no CSS to copy: select objects with CSS of their own ні CSS для копіювання: виділіть об’єкти з власним CSS
|
||||
@@ -4078,6 +4081,7 @@ axis Both Обидві
|
||||
@msg object {id} is not a selected top-level object об’єкт {id} не є виділеним об’єктом верхнього рівня
|
||||
@msg page size {w} × {h} розмір сторінки {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) сторінка {0} не існує (в PDF{1})
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: сторінка {n} не існує (у файлі 1)
|
||||
@msg paste unavailable вставка недоступна
|
||||
@msg path is already closed контур вже замкнутий
|
||||
@msg paths contain invalid coordinates контури містять неприпустимі координати
|
||||
@@ -4374,6 +4378,9 @@ axis Both Обидві
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: у результаті кроку {i} немає такого значення (щоб написати сам $, введіть $$)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед цим кроком немає кроку {i} (кроки рахуються з 0; щоб написати сам $, введіть $$)
|
||||
@msg object ids are non-negative integers, not {v} ідентифікатори об’єктів — невід’ємні цілі числа, а не {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} має бути масивом ідентифікаторів об’єктів, які є невід’ємними цілими числами
|
||||
@msg invalid object id {value}: expected a non-negative integer неприпустимий ідентифікатор об’єкта {value}: очікується невід’ємне ціле число
|
||||
@msg no such object id {value} об’єкт з ідентифікатором {value} не існує
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на читання: {path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на запис: {path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для читання {root}: {path}
|
||||
@@ -4799,3 +4806,5 @@ lighting Light Світло
|
||||
Minimum Мінімум
|
||||
Justification Вирівнювання за шириною
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them змінні та набори даних файлу не прочитано (змінних: {variables}, наборів даних: {data_sets}): панель «Змінні» їх не показує
|
||||
# Select by Color (select.color)
|
||||
Select by Color Виділити за кольором
|
||||
|
Can't render this file because it contains an unexpected character in line 725 and column 40.
|
@@ -3971,6 +3971,8 @@ theme Appearance Giao diện
|
||||
@msg `{key}` must be a #rrggbb color `{key}` phải là màu #rrggbb
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` phải là màu #rrggbb
|
||||
@msg `{key}` must be a number `{key}` phải là một số
|
||||
@msg `{key}` must be a number, not {v} `{key}` phải là một số, không phải {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} phải là thời gian Unix tính bằng giây (số nguyên) hoặc null
|
||||
@msg `{key}` must be a string `{key}` phải là một chuỗi
|
||||
@msg `{key}` must be a whole number `{key}` phải là số nguyên
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` phải là số nguyên từ {min} đến {max} (nhận được {x})
|
||||
@@ -4215,6 +4217,7 @@ theme Appearance Giao diện
|
||||
@msg missing `tool` thiếu `tool`
|
||||
@msg missing index thiếu chỉ mục
|
||||
@msg missing number `{key}` thiếu số `{key}`
|
||||
@msg missing integer `{key}` thiếu số nguyên `{key}`
|
||||
@msg missing panel layout thiếu bố cục bảng
|
||||
@msg move requires a destination panel move cần bảng đích
|
||||
@msg move requires an existing destination panel move cần bảng đích tồn tại
|
||||
@@ -4347,6 +4350,9 @@ theme Appearance Giao diện
|
||||
@msg nothing to warp không có gì để biến dạng
|
||||
@msg nothing to wrap không có gì để bọc
|
||||
@msg object ids are non-negative integers, not {v} id đối tượng là số nguyên không âm, không phải {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} phải là một mảng id đối tượng là số nguyên không âm
|
||||
@msg invalid object id {value}: expected a non-negative integer id đối tượng {value} không hợp lệ: cần một số nguyên không âm
|
||||
@msg no such object id {value} không có đối tượng nào có id {value}
|
||||
@msg object {0} cannot have children đối tượng {0} không thể có phần tử con
|
||||
@msg object {id} is not a path đối tượng {id} không phải là đường dẫn
|
||||
@msg object {id} is not a selected top-level object đối tượng {id} không phải là đối tượng cấp cao nhất đã chọn
|
||||
@@ -4362,6 +4368,7 @@ theme Appearance Giao diện
|
||||
@msg Packaged {n} file(s){note} Đã đóng gói {n} tệp{note}
|
||||
@msg page size {w} × {h} kích thước trang {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) trang {0} không tồn tại (PDF có {1} trang)
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail: trang {n} không tồn tại (tệp có 1 trang)
|
||||
@msg panel group is empty nhóm bảng trống
|
||||
@msg panel must name a registered panel panel phải đặt tên bảng đã đăng ký
|
||||
@msg paste unavailable dán không khả dụng
|
||||
@@ -4799,3 +4806,5 @@ theme Appearance Giao diện
|
||||
Minimum Tối thiểu
|
||||
Justification Căn đều
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them các biến và tập dữ liệu của tệp chưa được đọc (biến: {variables}, tập dữ liệu: {data_sets}): bảng Biến không liệt kê chúng
|
||||
# Select by Color (select.color)
|
||||
Select by Color Chọn theo màu
|
||||
|
Can't render this file because it contains an unexpected character in line 3948 and column 10.
|
@@ -1767,6 +1767,7 @@ file-filter Templates 模板
|
||||
@msg a key object sits on that point 该点上有关键对象
|
||||
@msg a spine keeps at least two points 脊线至少保留两个点
|
||||
@msg missing number `{key}` 缺少数字 `{key}`
|
||||
@msg missing integer `{key}` 缺少整数 `{key}`
|
||||
@msg index must be ≥ 0 index 不能为负数
|
||||
@msg x and y must be finite x 和 y 必须是有限值
|
||||
@msg t, dx and dy must be finite t、dx 和 dy 必须是有限值
|
||||
@@ -3678,6 +3679,8 @@ imageTrace Outline 轮廓
|
||||
@msg `freeform.points` must be an array `freeform.points` 必须是数组
|
||||
@msg `freeform.lines` must be an array `freeform.lines` 必须是数组
|
||||
@msg `{key}` must be a number `{key}` 必须是数字
|
||||
@msg `{key}` must be a number, not {v} `{key}` 必须是数字,而不是 {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} 必须是以秒为单位的 Unix 时间 (整数) 或 null
|
||||
@msg a freeform gradient keeps at least one point 任意形状渐变至少保留一个点
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` 任意形状点 {i} 需要 `at` [x, y] 和有效的 `color`
|
||||
@msg freeform line {i} must be an array of point indices 任意形状线 {i} 必须是由点索引组成的数组
|
||||
@@ -3911,6 +3914,7 @@ imageTrace Outline 轮廓
|
||||
@msg cannot read PDF: {0} 无法读取 PDF:{0}
|
||||
@msg invalid PDF setting: {0} PDF 设置无效:{0}
|
||||
@msg page {0} does not exist (the PDF has {1}) 页面 {0} 不存在 (该 PDF 有 {1} 页)
|
||||
@msg thumbnail: no page {n} (the file has 1) thumbnail:页面 {n} 不存在 (该文件有 1 页)
|
||||
@msg the PDF has no pages 该 PDF 没有页面
|
||||
@msg the PDF is password-protected: give its password 该 PDF 受密码保护:请提供其密码
|
||||
@msg the PDF password is wrong PDF 密码错误
|
||||
@@ -4164,6 +4168,9 @@ imageTrace Outline 轮廓
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`:步骤 {i} 的结果中没有此值 (要表示 $ 本身,请写 $$)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`:此步骤之前没有步骤 {i} (步骤从 0 开始计数;要表示 $ 本身,请写 $$)
|
||||
@msg object ids are non-negative integers, not {v} 对象 id 是非负整数,而不是 {v}
|
||||
@msg {key} must be an array of non-negative integer object ids {key} 必须是由非负整数对象 id 组成的数组
|
||||
@msg invalid object id {value}: expected a non-negative integer 无效的对象 id {value}:应为非负整数
|
||||
@msg no such object id {value} 没有 id 为 {value} 的对象
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} 未授予自动化文件系统访问权限:缺少读取权限:{path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} 未授予自动化文件系统访问权限:缺少写入权限:{path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} 已拒绝自动化路径:位于读取根目录 {root} 之外:{path}
|
||||
@@ -5041,3 +5048,5 @@ lighting Light 光源
|
||||
Minimum 最小值
|
||||
Justification 对齐设置
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them 文件的变量和数据集未被读取(变量:{variables},数据集:{data_sets}):“变量”面板不会列出它们
|
||||
# Select by Color (select.color)
|
||||
Select by Color 按颜色选择
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1548,6 +1548,735 @@ file-filter Templates 範本
|
||||
Keyboard Shortcut Conflicts 鍵盤快速鍵衝突
|
||||
Keyboard Shortcut Set 鍵盤快速鍵組
|
||||
Keyboard Shortcuts… 鍵盤快速鍵…
|
||||
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
|
||||
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
|
||||
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
|
||||
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
|
||||
# Traditional Chinese: no final 。 unless the English ends with a period; a space between Chinese text and
|
||||
# an adjacent Latin word, number or {placeholder}; 、 between the items of a list; 「」 around the name of
|
||||
# a panel, menu item or setting.
|
||||
@msg `{key}` must be an array of these names: {_1} `{key}` 必須是由下列名稱組成的陣列:{_1}
|
||||
@msg Reset {_1} 重設 {_1}
|
||||
@msg Saving 正在儲存
|
||||
@msg Saving {_1} 正在儲存 {_1}
|
||||
@msg Sent to {_1} 已傳送至 {_1}
|
||||
@msg `to` must be "corner" or "smooth" `to` 必須是 "corner" 或 "smooth"
|
||||
@msg a layer couldn't be read 有一個圖層無法讀取
|
||||
@msg it has more than one artboard 它有多個工作區域
|
||||
@msg its editing data has no layers 其編輯資料中沒有圖層
|
||||
@msg its editing data is damaged 其編輯資料已損毀
|
||||
@msg its zlib compression is damaged 其 zlib 壓縮已損毀
|
||||
@msg its Zstandard compression is damaged 其 Zstandard 壓縮已損毀
|
||||
@msg it decompresses to more than {_1} MB 解壓縮後超過 {_1} MB
|
||||
@msg its type is to come in as outlines, which only its page has 其文字需以外框匯入,而只有其頁面含有外框
|
||||
@msg {_1} files {_1} 檔案
|
||||
@msg A browser with WebGPU or WebGL2 is required. 需要支援 WebGPU 或 WebGL2 的瀏覽器。
|
||||
@msg Loading VectorCraft… 正在載入 VectorCraft…
|
||||
@msg Reload the page to go on (changes not saved yet may be lost). 請重新載入頁面以繼續 (尚未儲存的變更可能會遺失)。
|
||||
@msg The graphics were lost and couldn't be restarted. 圖形已遺失,且無法重新啟動。
|
||||
@msg The graphics were lost and have been restarted 圖形已遺失,現已重新啟動
|
||||
@msg VectorCraft failed to start: {e} VectorCraft 無法啟動:{e}
|
||||
@msg Your unsaved changes are kept in this browser: reload the page to get them back. 您未儲存的變更保留在此瀏覽器中:重新載入頁面即可取回。
|
||||
@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} 最先嘗試的圖形處理器無法顯示視窗,因此 VectorCraft 已改在 {summary} 上重新啟動
|
||||
@msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次啟動時在 {name} 上於視窗出現前結束,因此這次在 {summary} 上啟動。若要再次嘗試該圖形處理器,請變更「偏好設定 › 效能 › 圖形處理器」,或設定 WGPU_BACKEND 後再啟動
|
||||
@msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次啟動時在列出圖形處理器期間結束,因此這次只以 OpenGL 在 {summary} 上啟動。若要再次嘗試其他圖形後端,請變更「偏好設定 › 效能 › 圖形處理器」,或設定 WGPU_BACKEND 後再啟動
|
||||
@msg not a DXF drawing 不是 DXF 圖面
|
||||
@msg scale must be a number from {_1} to {_2}, not {s} 比例必須是 {_1} 到 {_2} 之間的數字,而非 {s}
|
||||
@msg the artboard to fit must be a positive size up to {_1} pt 要符合的工作區域尺寸必須為正數,且不超過 {_1} pt
|
||||
@msg the drawing has no layout `{_1}` (it has {_2}) 圖面中沒有配置 `{_1}` (它有 {_2})
|
||||
@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that 這是 DWG 圖面,無法讀取:請在 CAD 應用程式中將其儲存為 ASCII DXF,再開啟該檔案
|
||||
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard 以此比例,圖面為 {w} × {h} pt,超過畫布所能容納:請選擇較小的比例,或使其符合工作區域
|
||||
@msg the scale is not a usable number 比例不是可用的數字
|
||||
@msg the drawing is too large to open 圖面過大,無法開啟
|
||||
@msg scale must be a number from {_1} to {_2}, not {_3} 比例必須是 {_1} 到 {_2} 之間的數字,而非 {_3}
|
||||
@msg image '{key}' has no pixels and was left out 影像 '{key}' 沒有像素,已略過
|
||||
@msg Embedded {n} image(s) 已內嵌 {n} 個影像
|
||||
@msg object {0} cannot have children 物件 {0} 不能有子物件
|
||||
@msg range `{s}` is empty 範圍 `{s}` 是空的
|
||||
@msg range `{s}`: `{part}` names no number 範圍 `{s}`:`{part}` 沒有指定數字
|
||||
@msg range `{s}`: `{part}` runs backwards 範圍 `{s}`:`{part}` 的順序顛倒
|
||||
@msg range `{s}`: `{t}` is not a number from 1 to {count} 範圍 `{s}`:`{t}` 不是 1 到 {count} 之間的數字
|
||||
@msg not a graphic style library (format `{_1}`) 不是繪圖樣式資料庫 (格式為 `{_1}`)
|
||||
@msg Show All Hidden Attributes: nothing is hidden 顯示所有隱藏屬性:沒有隱藏的項目
|
||||
@msg Expand: no brush strokes selected 展開:未選取筆刷筆畫
|
||||
@msg no brush named `{name}` 沒有名為 `{name}` 的筆刷
|
||||
@msg no brush selected 未選取筆刷
|
||||
@msg no symbol instances selected 未選取符號實體
|
||||
@msg select a symbol instance 請選取符號實體
|
||||
@msg the art has no bounds 圖稿沒有邊界
|
||||
@msg Image Trace: the traced image is missing 影像描圖:找不到描圖的影像
|
||||
@msg Live Paint Merge: select a Live Paint group and the paths to add 即時上色合併:請選取即時上色群組和要加入的路徑
|
||||
@msg Live Paint: select paths or compound paths 即時上色:請選取路徑或複合路徑
|
||||
@msg Live Paint: these paths are too degenerate to paint 即時上色:這些路徑過度退化,無法上色
|
||||
@msg Shape Builder: no region under the pointer 形狀建立:指標下方沒有區域
|
||||
@msg Shape Builder: select paths or compound paths 形狀建立:請選取路徑或複合路徑
|
||||
@msg image data `{_1}` is missing 找不到影像資料 `{_1}`
|
||||
@msg no Live Paint edge near that point 該點附近沒有即時上色邊緣
|
||||
@msg no Live Paint face at that point 該點上沒有即時上色表面
|
||||
@msg select a Live Paint group 請選取即時上色群組
|
||||
@msg select an Image Trace object 請選取影像描圖物件
|
||||
@msg select objects, not layers 請選取物件,而非圖層
|
||||
@msg Blend colors: select at least three objects with filled colours 漸變顏色:請選取至少三個有填色的物件
|
||||
@msg no saved themes 沒有已儲存的主題
|
||||
@msg `{keep}` is not a side of this axis `{keep}` 不是此軸的一側
|
||||
@msg unknown side `{other}` (left|right|top|bottom) 未知的邊 `{other}` (left|right|top|bottom)
|
||||
@msg an object in perspective would leave the edited grid 透視中的物件會離開所編輯的格點
|
||||
@msg nothing to warp 沒有可彎曲的內容
|
||||
@msg select an object in perspective 請選取透視中的物件
|
||||
@msg select the artwork to warp 請選取要彎曲的圖稿
|
||||
@msg select the objects to transform in perspective 請選取要在透視中變形的物件
|
||||
@msg select type in perspective 請選取透視中的文字
|
||||
@msg the object has no geometry 物件沒有幾何形狀
|
||||
@msg the object has no stroke 物件沒有筆畫
|
||||
@msg the object is beyond the plane's horizon 物件位於平面的地平線之外
|
||||
@msg the object isn't in perspective 物件不在透視中
|
||||
@msg the object isn't on a perspective plane 物件不在透視平面上
|
||||
@msg the object would cross the horizon of the perspective plane 物件會越過透視平面的地平線
|
||||
@msg the objects would leave the grid 物件會離開格點
|
||||
@msg the pointer is beyond the plane's horizon 指標位於平面的地平線之外
|
||||
@msg the selection isn't in perspective 選取範圍不在透視中
|
||||
@msg the stroke has no weight 筆畫沒有寬度
|
||||
@msg {command} didn't create an object {command} 未建立物件
|
||||
@msg Make Guides: select paths 製作參考線:請選取路徑
|
||||
@msg Release Guides: no guides 釋放參考線:沒有參考線
|
||||
@msg guides are locked 參考線已鎖定
|
||||
@msg no saved selection recalled 未叫出任何已儲存的選取範圍
|
||||
@msg no saved selections 沒有已儲存的選取範圍
|
||||
@msg no such artboard 沒有這個工作區域
|
||||
@msg cannot split an open path at its end point 無法在開放路徑的端點處分割路徑
|
||||
@msg closing needs three anchors 封閉需要三個錨點
|
||||
@msg empty brush stroke 筆刷筆畫是空的
|
||||
@msg no such anchor 沒有這個錨點
|
||||
@msg no such segment 沒有這個線段
|
||||
@msg no such subpath 沒有這個子路徑
|
||||
@msg object {id} is not a path 物件 {id} 不是路徑
|
||||
@msg object {id} is not a selected top-level object 物件 {id} 不是已選取的最上層物件
|
||||
@msg nothing to redo 沒有可重做的動作
|
||||
@msg nothing to undo 沒有可復原的動作
|
||||
@msg no history to clear 沒有可清除的步驟記錄
|
||||
@msg Expand Appearance: the selection has a basic appearance 展開外觀:選取範圍只有基本外觀
|
||||
@msg select (or target) objects whose appearance isn't basic 請選取 (或指定目標) 外觀不是基本外觀的物件
|
||||
@msg {C}: no effect at index {from} {C}:索引 {from} 處沒有效果
|
||||
@msg Expand: no gradient fills 展開:沒有漸層填色
|
||||
@msg Expand: nothing to expand 展開:沒有可展開的內容
|
||||
@msg batch step {_1} (`{id}`) failed: {e} 批次步驟 {_1} (`{id}`) 失敗:{e}
|
||||
@msg BMP files are RGB or grayscale: colorModel rgb or gray BMP 檔案為 RGB 或灰階:colorModel rgb 或 gray
|
||||
@msg Targa files are RGB: colorModel rgb Targa 檔案為 RGB:colorModel rgb
|
||||
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`:none、art 或 type
|
||||
@msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}:transparent、white、black 或顏色,例如 "#ff8800"
|
||||
@msg no artboard {i} (0-based; the document has {count}) 沒有工作區域 {i} (從 0 起算;文件有 {count} 個)
|
||||
@msg no artboards named 未指定工作區域
|
||||
@msg the document has no artboard 文件沒有工作區域
|
||||
@msg this format holds one artboard: export several as PDF or with document.exportForScreens 此格式只能容納一個工作區域:若要匯出多個,請匯出為 PDF 或使用 document.exportForScreens
|
||||
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) 此檔案儲存時未啟用 PDF 相容性,且無法讀取其編輯資料 ({why})
|
||||
@msg the export wrote no file 匯出未寫入任何檔案
|
||||
@msg {_1} files can be opened but not written (see document.formats) {_1} 檔案可以開啟,但無法寫入 (請參閱 document.formats)
|
||||
@msg {_1} files are written by file.saveAsTemplate {_1} 檔案由 file.saveAsTemplate 寫入
|
||||
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}:此處沒有檔案系統 (若要開啟,請傳入 dataBase64;不提供路徑即可取得 dataBase64)
|
||||
@msg Save writes {_1}, not {_2}: use document.export for other formats 「儲存」會寫入 {_1},而非 {_2}:其他格式請使用 document.export
|
||||
@msg no changes since the last save 自上次儲存後沒有變更
|
||||
@msg unknown format `{f}` (Save writes {_1}) 未知的格式 `{f}` (「儲存」會寫入 {_1})
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` 必須是由 SVG 選項組成的物件 (請參閱 document.formats)
|
||||
@msg no end record 沒有結束記錄
|
||||
@msg not a stored central record 不是未壓縮 (stored) 的中央目錄記錄
|
||||
@msg truncated 已截斷
|
||||
@msg {name}: CRC mismatch {name}:CRC 不符
|
||||
@msg {name}: no local header {name}:沒有區域檔頭
|
||||
@msg Flatten Transparency: select objects to flatten 透明度平面化:請選取要平面化的物件
|
||||
@msg Flatten Transparency: {e} 透明度平面化:{e}
|
||||
@msg balance must be between 0 and 100 平衡必須介於 0 到 100 之間
|
||||
@msg resolutions must be between 1 and 2400 ppi 解析度必須介於 1 到 2400 ppi 之間
|
||||
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) 未知的預設集 `{name}` (high、medium、low 或已儲存的預設集:請參閱 flattener.presets.list)
|
||||
@msg `at` must be [x, y] `at` 必須是 [x, y]
|
||||
@msg `color` must be a colour `color` 必須是顏色
|
||||
@msg `freeform.points` must be an array `freeform.points` 必須是陣列
|
||||
@msg `freeform.lines` must be an array `freeform.lines` 必須是陣列
|
||||
@msg `{key}` must be a number `{key}` 必須是數字
|
||||
@msg a freeform gradient keeps at least one point 任意形狀漸層至少要保留一個點
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` 任意形狀點 {i} 需要 `at` [x, y] 和有效的 `color`
|
||||
@msg freeform line {i} must be an array of point indices 任意形狀線條 {i} 必須是由點索引組成的陣列
|
||||
@msg no point {i} (the gradient has {_1}) 沒有點 {i} (此漸層有 {_1} 個)
|
||||
@msg no segment {segment} on line {line} 線條 {line} 上沒有線段 {segment}
|
||||
@msg unknown freeform mode `{m}` (points, lines) 未知的任意形狀模式 `{m}` (points、lines)
|
||||
@msg `aspect` must be a number (%) `aspect` 必須是數字 (%)
|
||||
@msg `gradient` must be an object `gradient` 必須是物件
|
||||
@msg `mode` applies to freeform gradients `mode` 只適用於任意形狀漸層
|
||||
@msg `stops` must be an array `stops` 必須是陣列
|
||||
@msg `{k}` must be [x, y] `{k}` 必須是 [x, y]
|
||||
@msg a gradient needs at least two stops 漸層至少需要兩個色標
|
||||
@msg give both `start` and `end` [x, y] (or neither) 請同時提供 `start` 和 `end` [x, y] (或兩者都不提供)
|
||||
@msg stop {i} needs `offset` 色標 {i} 需要 `offset`
|
||||
@msg stop {i} needs a valid `color` 色標 {i} 需要有效的 `color`
|
||||
@msg stop {i}: `{k}` must be a number 色標 {i}:`{k}` 必須是數字
|
||||
@msg unknown gradient kind `{k}` (linear, radial, freeform) 未知的漸層類型 `{k}` (linear、radial、freeform)
|
||||
@msg the art could not be sampled 無法對圖稿取樣
|
||||
@msg the art is too light for any dots 圖稿太淡,無法產生任何網點
|
||||
@msg the document has no symbols 文件中沒有符號
|
||||
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` 不是顏色 (#rrggbb 或預設集名稱)
|
||||
@msg a colour index is 0 to {_1}, not {n} 顏色索引應為 0 到 {_1},而非 {n}
|
||||
@msg a colour is an index, #rrggbb or a preset name, not {v} 顏色應為索引、#rrggbb 或預設集名稱,而非 {v}
|
||||
@msg a document needs at least one layer 文件至少需要一個圖層
|
||||
@msg no artwork 沒有圖稿
|
||||
@msg no selection bounds 選取範圍沒有邊界
|
||||
@msg the layer has no objects to clip by 圖層中沒有可用來剪裁的物件
|
||||
@msg the layer or group is locked 圖層或群組已鎖定
|
||||
@msg the guide is on a hidden or locked layer 參考線位於隱藏或鎖定的圖層上
|
||||
@msg the target layer is locked or hidden 目標圖層已鎖定或隱藏
|
||||
@msg the layer to flatten into is hidden 要平面化到的圖層已隱藏
|
||||
@msg there are no objects to release 沒有可釋放的物件
|
||||
@msg no library `{id}` 沒有資料庫 `{id}`
|
||||
@msg {path}: not a file {path}:不是檔案
|
||||
@msg {path}: {e} {path}:{e}
|
||||
@msg can't write .{ext} images 無法寫入 .{ext} 影像
|
||||
@msg Replace Spine: select a blend and a path 取代脊線:請選取漸變物件和路徑
|
||||
@msg nothing to wrap 沒有可繞圖的內容
|
||||
@msg select a blend 請選取漸變物件
|
||||
@msg select a gradient mesh 請選取漸層網格
|
||||
@msg the object has no opacity mask 物件沒有不透明度遮色片
|
||||
@msg Convert to Artboards: selection has no area 轉換為工作區域:選取範圍沒有面積
|
||||
@msg Convert to Shape: no rectangles or ellipses selected 轉換為形狀:未選取矩形或橢圓形
|
||||
@msg image would be too large; lower the resolution 影像會過大;請降低解析度
|
||||
@msg clipboard is empty 剪貼簿是空的
|
||||
@msg no anchor points selected 未選取錨點
|
||||
@msg select at least two objects 請選取至少兩個物件
|
||||
@msg no saved presets 沒有已儲存的預設集
|
||||
@msg compound paths can only contain paths 複合路徑只能包含路徑
|
||||
@msg no bounds 沒有邊界
|
||||
@msg no current layer 沒有目前圖層
|
||||
@msg no key object 沒有關鍵物件
|
||||
@msg no previous transform 沒有上一次的變形
|
||||
@msg nothing to align 沒有可對齊的內容
|
||||
@msg select a clip group 請選取剪裁群組
|
||||
@msg select two or more objects to distribute 請選取兩個以上要均分的物件
|
||||
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis 必須是 "vertical"、"horizontal" 或以度為單位的角度,而非 `{v}`
|
||||
@msg give horizontal or vertical 請提供 horizontal 或 vertical
|
||||
@msg selection has no bounds 選取範圍沒有邊界
|
||||
@msg the top object must be a path, compound path or text object to use as a clipping mask 最上層物件必須是路徑、複合路徑或文字物件,才能用作剪裁遮色片
|
||||
@msg no target object has an opacity mask: select one or give `id` 沒有任何目標物件有不透明度遮色片:請選取一個,或提供 `id`
|
||||
@msg the object already has an opacity mask 物件已有不透明度遮色片
|
||||
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` 是印刷色:色調只適用於全域色和特別色
|
||||
@msg `{name}` isn't a colour swatch `{name}` 不是顏色色票
|
||||
@msg a document needs at least one artboard 文件至少需要一個工作區域
|
||||
@msg empty path 路徑是空的
|
||||
@msg join needs open paths 連接需要開放路徑
|
||||
@msg path is already closed 路徑已封閉
|
||||
@msg select paths to join 請選取要連接的路徑
|
||||
@msg direct-select anchor points to cut at (not the end points of open paths) 請直接選取要剪開的錨點 (不能是開放路徑的端點)
|
||||
@msg select paths or anchor points 請選取路徑或錨點
|
||||
@msg Divide Objects Below: select a path 分割下方物件:請選取路徑
|
||||
@msg Divide Objects Below: select exactly one path 分割下方物件:請只選取一條路徑
|
||||
@msg Offset Path: select paths 位移複製:請選取路徑
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) 位移複製:位移後所選路徑不留任何部分 (內縮量大於形狀)
|
||||
@msg Outline Stroke: no stroked paths selected 外框筆畫:未選取有筆畫的路徑
|
||||
@msg Split Into Grid: select paths large enough for the grid 分割為格線:請選取大到足以容納格線的路徑
|
||||
@msg empty cutter 切割物件是空的
|
||||
@msg paths contain invalid coordinates 路徑包含無效的座標
|
||||
@msg {label} produced an empty result {label} 產生了空的結果
|
||||
@msg {label}: lost track of a shape's style {label}:遺失了某個形狀的樣式
|
||||
@msg {label}: select paths {label}:請選取路徑
|
||||
@msg {label}: select paths, compound paths, groups or text {label}:請選取路徑、複合路徑、群組或文字
|
||||
@msg already editing a pattern 已在編輯圖樣
|
||||
@msg no pattern `{name}` 沒有圖樣 `{name}`
|
||||
@msg not in pattern editing mode 不在圖樣編輯模式中
|
||||
@msg pattern missing 找不到圖樣
|
||||
@msg select a repeat 請選取重複物件
|
||||
@msg select an Intertwine group 請選取交織群組
|
||||
@msg select the art to repeat 請選取要重複的圖稿
|
||||
@msg the document has no patterns 文件中沒有圖樣
|
||||
@msg no artboard 沒有工作區域
|
||||
@msg installing from a path is not available on the web; pass the module as `dataBase64` 網頁版無法從路徑安裝;請以 `dataBase64` 傳入模組
|
||||
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` 必須是 `auto` 或語言代碼,例如 `en` 或 `zh-hant`
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` 必須是 #rrggbb 顏色
|
||||
@msg `{key}` must be a string `{key}` 必須是字串
|
||||
@msg `{key}` must be a whole number `{key}` 必須是整數
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` 必須是介於 {min} 和 {max} 之間的整數 (收到 {x})
|
||||
@msg `{key}` must be between {min} and {max} (got {x}) `{key}` 必須介於 {min} 和 {max} 之間 (收到 {x})
|
||||
@msg `{key}` must be one of {_1} `{key}` 必須是 {_1} 其中之一
|
||||
@msg unknown preference `{key}` 未知的偏好設定 `{key}`
|
||||
@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 「資料復原」沒有可存放拷貝的位置:請設定「偏好設定 → 檔案處理 → 資料復原 → 資料夾」 (recoveryFolder)
|
||||
@msg `{area}` is no recovery area name `{area}` 不是復原區域名稱
|
||||
@msg `{name}` is no recovery entry name `{name}` 不是復原項目名稱
|
||||
@msg no clock to keep a heartbeat by 沒有可用來維持心跳的時鐘
|
||||
@msg no free recovery area 沒有可用的復原區域
|
||||
@msg no native format 沒有原生格式
|
||||
@msg nothing encoded 沒有編碼任何內容
|
||||
@msg recovery copy `{file}`: {e} 復原拷貝 `{file}`:{e}
|
||||
@msg {name}: no such recovery entry {name}:沒有這個復原項目
|
||||
@msg Combine Slices: the slices have no bounds 合併切片:切片沒有邊界
|
||||
@msg Create from Guides: the document has no artboard 從參考線建立:文件沒有工作區域
|
||||
@msg Create from Guides: the guides make more than {MAX_PIECES} slices 從參考線建立:參考線會產生超過 {MAX_PIECES} 個切片
|
||||
@msg Create from Selection: the selection has no area 從選取範圍建立:選取範圍沒有面積
|
||||
@msg Release: no layer to put the rectangle in 釋放:沒有可放置矩形的圖層
|
||||
@msg a slice size must be a positive number of points 切片尺寸必須是正數 (以點為單位)
|
||||
@msg divide into 1 to {MAX_PIECES} slices 請分割為 1 到 {MAX_PIECES} 個切片
|
||||
@msg no slice selected 未選取切片
|
||||
@msg the document has no slices 文件中沒有切片
|
||||
@msg no saved profiles 沒有已儲存的描述檔
|
||||
@msg select a stroke with a variable width 請選取寬度可變的筆畫
|
||||
@msg the selected stroke's profile is already listed as {_1} 所選筆畫的描述檔已列為 {_1}
|
||||
@msg a graphic style named `{to}` already exists 名為 `{to}` 的繪圖樣式已存在
|
||||
@msg no graphic style `{name}` 沒有繪圖樣式 `{name}`
|
||||
@msg node {_1} is not text 節點 {_1} 不是文字
|
||||
@msg Text Wrap: select a wrap object 文字繞圖:請選取繞圖物件
|
||||
@msg layers can't be wrap objects 圖層不能作為繞圖物件
|
||||
@msg no threaded text selected 未選取串連文字
|
||||
@msg Create Outlines: select text objects 建立外框:請選取文字物件
|
||||
@msg paste unavailable 無法貼上
|
||||
@msg Save for Web: the image is empty 儲存為網頁用:影像是空的
|
||||
@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}:`{h}` 不是像 "#ff8800" 這樣的顏色
|
||||
@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}:最多 {MAX_TABLE} 種顏色
|
||||
@msg no artboard {i} (0-based; the document has {_1}) 沒有工作區域 {i} (從 0 起算;文件有 {_1} 個)
|
||||
@msg no selected slice in the image: select slices, or save all of them 影像中沒有已選取的切片:請選取切片,或全部儲存
|
||||
@msg nothing to save: the document has no visible art 沒有可儲存的內容:文件中沒有可見的圖稿
|
||||
@msg percent must be above 0 and at most 6400, not {p} 百分比必須大於 0 且不超過 6400,而非 {p}
|
||||
@msg unknown preset `{name}` (see webExport.presets.list) 未知的預設集 `{name}` (請參閱 webExport.presets.list)
|
||||
@msg width and height are at least 1 pixel 寬度和高度至少為 1 像素
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` 必須是 1、3 或 5
|
||||
@msg `{path}` must be a bool or an object of bools `{path}` 必須是布林值或由布林值組成的物件
|
||||
@msg cannot distort a zero-size bounding box 無法扭曲尺寸為零的邊框
|
||||
@msg nothing to distort 沒有可扭曲的內容
|
||||
@msg unknown option `{path}.{k}` 未知的選項 `{path}.{k}`
|
||||
@msg command `{0}` is not available right now: {1} 指令 `{0}` 目前無法使用:{1}
|
||||
@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` 發生內部錯誤:{msg} (文件維持原狀;請回報此錯誤)
|
||||
@msg invalid parameters for `{cmd}`: {msg} `{cmd}` 的參數無效:{msg}
|
||||
@msg no active document 沒有作用中的文件
|
||||
@msg no interaction in progress 沒有進行中的互動
|
||||
@msg no such object {0} 沒有物件 {0}
|
||||
@msg result would exceed the canvas (coordinates out of range) 結果會超出畫布 (座標超出範圍)
|
||||
@msg objects can't be nested more than 32 deep 物件的巢狀層級不能超過 32 層
|
||||
@msg the SVG's elements are nested more than {0} deep SVG 的元素巢狀超過 {0} 層
|
||||
@msg unknown command `{0}` 未知的指令 `{0}`
|
||||
@msg `{what}` closes nothing `{what}` 沒有可結束的內容
|
||||
@msg `{what}` closes something it didn't open `{what}` 結束了並非由它開始的內容
|
||||
@msg a path has too many points 有一條路徑的點過多
|
||||
@msg it ends inside a layer 它在圖層內部結束
|
||||
@msg it has art outside its layers 它有位於圖層之外的圖稿
|
||||
@msg it has more than {MAX_NODES} objects 它有超過 {MAX_NODES} 個物件
|
||||
@msg it has no layers 它沒有圖層
|
||||
@msg it has too many values in a row 它連續的值過多
|
||||
@msg it has {_1}, which VectorCraft doesn't read from it yet 它含有 {_1},VectorCraft 目前尚無法從中讀取
|
||||
@msg its objects are nested too deeply 其物件巢狀層級過深
|
||||
@msg its section ending with `%{end}` doesn't end 其以 `%{end}` 結尾的區段沒有結束
|
||||
@msg an image VectorCraft can't read was left out 已略過 VectorCraft 無法讀取的影像
|
||||
@msg an image whose data is cut short was left out 已略過資料不完整的影像
|
||||
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 檔案中未定義漸層「{name}」:已改用單色替代
|
||||
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 具有多個填色或筆畫、效果或筆刷的物件已依其繪製外觀匯入 ({_1}):其外觀目前尚無法編輯
|
||||
@msg patterns nested too deeply are filled with mid-grey 巢狀層級過深的圖樣以中灰色填滿
|
||||
@msg shadings of an unknown type were left out 已略過類型未知的著色
|
||||
@msg shadings whose functions are of an unknown type are filled with their middle colour 函數類型未知的著色以其中間顏色填滿
|
||||
@msg the file's preview header points outside the file 檔案的預覽檔頭指向檔案之外
|
||||
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it 無法讀取此 PostScript 檔案 ({why}):請在建立它的應用程式中將其儲存為 PDF 或 SVG
|
||||
@msg this is not a PostScript file 這不是 PostScript 檔案
|
||||
@msg a PostScript error ({name} in `{op}`) PostScript 錯誤 ({name},位於 `{op}`)
|
||||
@msg a PostScript error ({name} in `{op}`), in {procs} PostScript 錯誤 ({name},位於 `{op}`),在 {procs} 中
|
||||
@msg a PostScript error ({name} in `{op}`: {at}) PostScript 錯誤 ({name},位於 `{op}`:{at})
|
||||
@msg a PostScript error ({name} in `{op}`: {at}), in {procs} PostScript 錯誤 ({name},位於 `{op}`:{at}),在 {procs} 中
|
||||
@msg a PostScript error ({name}) PostScript 錯誤 ({name})
|
||||
@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know 它使用了 `{at}`,VectorCraft 的 PostScript 讀取器無法辨識
|
||||
@msg the file was read up to an error ({why}): what follows it was left out 檔案已讀取到發生錯誤處 ({why}):之後的內容已略過
|
||||
@msg this file's PostScript couldn't be read ({why}): {what} 無法讀取此檔案的 PostScript ({why}):{what}
|
||||
@msg it has no page 它沒有頁面
|
||||
@msg it has too many text objects 它的文字物件過多
|
||||
@msg it has too much text 它的文字過多
|
||||
@msg its editing data doesn't say where its art is 其編輯資料未指出其圖稿的位置
|
||||
@msg its editing data doesn't say where its artboard is 其編輯資料未指出其工作區域的位置
|
||||
@msg its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page 其編輯資料屬於 {w} × {h} pt 的頁面,而不是檔案本身 {pw} × {ph} pt 的頁面
|
||||
@msg its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale} 其編輯資料屬於 {w} × {h} pt 的頁面,即比例為 {scale} 的檔案 {pw} × {ph} pt 頁面
|
||||
@msg its page has text, and its layers have no text objects to put it in 其頁面中有文字,但其圖層中沒有可放置這些文字的文字物件
|
||||
@msg the art on them differs from the page's in {_1}% of it 其上的圖稿與頁面的圖稿有 {_1}% 不同
|
||||
@msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same 其上的圖稿與頁面的圖稿有 {_1}% 不同:仍從其編輯資料匯入
|
||||
@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer 未讀取檔案的圖層 ({_1}):檔案以其頁面匯入,位於單一圖層中
|
||||
@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part 未從檔案的編輯資料讀取圖層 ({_1}):圖層來自其 PDF 部分
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them 未讀取檔案的變數與資料集 (變數:{variables},資料集:{data_sets}):「變數」面板不會列出它們
|
||||
@msg its Windows metafile preview was opened instead, which may simplify the art 已改為開啟其 Windows 中繼檔預覽,圖稿可能因此簡化
|
||||
@msg its preview was placed instead, at screen resolution 已改為以螢幕解析度置入其預覽
|
||||
@msg shadings of more than 4096 patches or triangles were left out 已略過超過 4096 個面片或三角形的著色
|
||||
@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} 它在 {procs} 中使用了 `{at}`,VectorCraft 的 PostScript 讀取器無法辨識
|
||||
@msg the export region is not finite 匯出區域不是有限值
|
||||
@msg a page out of range 頁面超出範圍
|
||||
@msg a page's placement is not finite 頁面的位置不是有限值
|
||||
@msg nothing to print 沒有可列印的內容
|
||||
@msg page size {w} × {h} 頁面尺寸 {w} × {h}
|
||||
@msg the preview image is empty 預覽影像是空的
|
||||
@msg the thumbnail image is malformed 縮圖影像格式錯誤
|
||||
@msg can't encode the document: {0} 無法編碼文件:{0}
|
||||
@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} 無法儲存版本 {0}:VectorCraft 寫入的版本為 {MIN_VERSION} 到 {VERSION}
|
||||
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) 檔案版本 {0} 比此 VectorCraft 支援的版本 ({VERSION}) 更新
|
||||
@msg invalid image data for `{0}` `{0}` 的影像資料無效
|
||||
@msg not a VectorCraft file: {0} 不是 VectorCraft 檔案:{0}
|
||||
@msg the compressed file unpacks to more than {_1} MB 壓縮檔解壓縮後超過 {_1} MB
|
||||
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files 版本 {0} 的檔案無法壓縮:只有版本 {COMPRESSED_SINCE} 及更新版本才能開啟壓縮檔
|
||||
@msg a bitmap is empty 有一個點陣圖是空的
|
||||
@msg a bitmap is too large to read 有一個點陣圖過大,無法讀取
|
||||
@msg compressed (RLE) bitmaps can't be read yet 目前尚無法讀取壓縮 (RLE) 點陣圖
|
||||
@msg the image's pixels don't match its size 影像的像素與其尺寸不符
|
||||
@msg {bpp}-bit bitmaps can't be read 無法讀取 {bpp} 位元點陣圖
|
||||
@msg not an EMF or WMF file 不是 EMF 或 WMF 檔案
|
||||
@msg its layers use {_1}, which aren't read yet 其圖層使用了 {_1},目前尚無法讀取
|
||||
@msg the EMF header is damaged EMF 檔頭已損毀
|
||||
@msg damaged 已損毀
|
||||
@msg the WMF header is damaged WMF 檔頭已損毀
|
||||
@msg the export region is empty 匯出區域是空的
|
||||
@msg a stream runs past the end 有一個資料流超出結尾
|
||||
@msg a stream's length is wrong 有一個資料流的長度錯誤
|
||||
@msg a stream's length isn't a number 有一個資料流的長度不是數字
|
||||
@msg bad stream length 資料流長度錯誤
|
||||
@msg PDF writer error: {0} PDF 寫入器錯誤:{0}
|
||||
@msg artboard {0} does not exist 工作區域 {0} 不存在
|
||||
@msg cannot read PDF: {0} 無法讀取 PDF:{0}
|
||||
@msg invalid PDF setting: {0} PDF 設定無效:{0}
|
||||
@msg page {0} does not exist (the PDF has {1}) 頁面 {0} 不存在 (此 PDF 有 {1} 頁)
|
||||
@msg the PDF has no pages 此 PDF 沒有頁面
|
||||
@msg the PDF is password-protected: give its password 此 PDF 受密碼保護:請提供其密碼
|
||||
@msg the PDF password is wrong PDF 密碼錯誤
|
||||
@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG 此檔案只含有預留位置頁面,而沒有其圖稿 (儲存時未啟用 PDF 相容性):請啟用 PDF 相容性後重新儲存,或儲存為 PDF 或 SVG
|
||||
@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS 這是 PostScript 檔案 (以舊格式或未啟用 PDF 相容性儲存的 .ai,或 EPS 檔案),而非 PDF:「檔案 › 開啟」和「檔案 › 置入」會將其當作 EPS 讀取
|
||||
@msg unsupported: {0} 不支援:{0}
|
||||
@msg a page is missing 找不到某個頁面
|
||||
@msg a page tree kid isn't a reference 有一個頁面樹子節點不是參照
|
||||
@msg a page tree node has no kids 有一個頁面樹節點沒有子節點
|
||||
@msg an object can't be read 無法讀取某個物件
|
||||
@msg bad page tree 頁面樹錯誤
|
||||
@msg bad trailer 檔尾錯誤
|
||||
@msg no catalog 沒有目錄
|
||||
@msg no cross-reference table 沒有交互參照表
|
||||
@msg no pages 沒有頁面
|
||||
@msg no plug-in {0} is installed 未安裝增效模組 {0}
|
||||
@msg not a valid plug-in module: {0} 不是有效的增效模組:{0}
|
||||
@msg plug-in ABI: {0} 增效模組 ABI:{0}
|
||||
@msg plug-in exceeded its {0} 增效模組超出其 {0}
|
||||
@msg plug-in failed: {0} 增效模組失敗:{0}
|
||||
@msg plug-in manifest: {0} 增效模組資訊清單:{0}
|
||||
@msg plug-in output: {0} 增效模組輸出:{0}
|
||||
@msg plug-in parameters: {0} 增效模組參數:{0}
|
||||
@msg plug-in trapped: {0} 增效模組執行時發生錯誤 (trap):{0}
|
||||
@msg is not an image VectorCraft reads 不是 VectorCraft 可讀取的影像
|
||||
@msg not found 找不到
|
||||
@msg SVG parse error: {0} SVG 剖析錯誤:{0}
|
||||
@msg decimals must be {_1}–{_2} (got {_3}) 小數位數必須為 {_1}–{_2} (收到 {_3})
|
||||
@msg Exporting {_1} 正在匯出 {_1}
|
||||
@msg Exported {what} 已匯出 {what}
|
||||
@msg "{0}" is not a colour: write it as #rrggbb "{0}" 不是顏色:請寫成 #rrggbb
|
||||
@msg could not decode image: {0} 無法解碼影像:{0}
|
||||
@msg image is empty 影像是空的
|
||||
@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) 影像為 {width} × {height} 像素,超過「影像描圖」可接受的上限 ({max} 百萬像素)
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead 這不是單色標誌 (其實色區域只有 {_1}% 符合少數幾種單色),因此「扁平標誌」模式會把其中的漸層變成色塊:請改用「顏色」模式
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset 此描圖會產生超過 {max} 個錨點,超過文件可流暢顯示的數量:請提高「雜訊」、降低「路徑」或「顏色」,或選擇精確度較低的預設集
|
||||
@msg internal error: {msg} (please report this bug) 內部錯誤:{msg} (請回報此錯誤)
|
||||
@msg the background worker stopped 背景處理程序已停止
|
||||
@msg {_1} failed: {e} {_1} 失敗:{e}
|
||||
@msg {label}… {label}…
|
||||
@msg no blend options 沒有漸變選項
|
||||
@msg bad color {v} 顏色 {v} 無效
|
||||
@msg the Color Picker is not open 「顏色選擇器」未開啟
|
||||
@msg the screen color sampler is already open 螢幕取色器已開啟
|
||||
@msg this system has no screen color sampler 此系統沒有螢幕取色器
|
||||
@msg Corners edits the corners of a path 「轉角」用於編輯路徑的轉角
|
||||
@msg give corners of the path: anchors where its sides meet at an angle 請提供路徑的轉角:其各邊以角度相交處的錨點
|
||||
@msg select one path, or give its `id` 請選取一條路徑,或提供其 `id`
|
||||
@msg the drawing is no longer loaded 圖面已不再載入
|
||||
@msg No saved selections (Select → Save Selection…) 沒有已儲存的選取範圍 (選取 → 儲存選取範圍…)
|
||||
@msg effect {k} is `{_1}`, not `{id}` 效果 {k} 是 `{_1}`,而非 `{id}`
|
||||
@msg no effect at index {k} 索引 {k} 處沒有效果
|
||||
@msg unknown effect `{id}` 未知的效果 `{id}`
|
||||
@msg `{id}` has no envelope dialog `{id}` 沒有封套對話框
|
||||
@msg select an envelope 請選取封套
|
||||
@msg no format to export to 沒有可匯出的格式
|
||||
@msg nothing to export: the document has no visible art (or turn on Use Artboards) 沒有可匯出的內容:文件中沒有可見的圖稿 (或請開啟「使用工作區域」)
|
||||
@msg add a format to export 請新增要匯出的格式
|
||||
@msg check an asset to export 請勾選要匯出的資產
|
||||
@msg `{tool}` isn't a freehand tool `{tool}` 不是手繪工具
|
||||
@msg bad colour `{hex}` (#rrggbb) 顏色 `{hex}` 無效 (#rrggbb)
|
||||
@msg no gradient stop or freeform point is selected 未選取漸層色標或任意形狀點
|
||||
@msg no graphic style `{n}` 沒有繪圖樣式 `{n}`
|
||||
@msg select two or more graphic styles to merge 請選取兩個以上要合併的繪圖樣式
|
||||
@msg the PDF is no longer loaded 此 PDF 已不再載入
|
||||
@msg no such layer or object 沒有這個圖層或物件
|
||||
@msg `{tool}` isn't a Liquify tool `{tool}` 不是液化工具
|
||||
@msg {_1} linked file(s) can't be read here: their previews show 此處無法讀取 {_1} 個連結檔案:顯示的是其預覽
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions 有 {_1} 個連結影像已在磁碟上變更:「更新連結」可顯示新版本
|
||||
@msg pick a colour, gradient or pattern to save as a swatch 請挑選要儲存為色票的顏色、漸層或圖樣
|
||||
@msg Packaged {n} file(s) in {folder}{note} 已將 {n} 個檔案封裝到 {folder}{note}
|
||||
@msg Packaged {n} file(s){note} 已封裝 {n} 個檔案{note}
|
||||
@msg choose a location for the package 請選擇封裝的位置
|
||||
@msg missing `folder` 未提供 `folder`
|
||||
@msg the package came back damaged 傳回的封裝檔案已損毀
|
||||
@msg no preset editor 沒有預設集編輯器
|
||||
@msg no perspective grid preset named `{name}` 沒有名為 `{name}` 的透視格點預設集
|
||||
@msg no active perspective plane 沒有作用中的透視平面
|
||||
@msg plane must be left, right or ground plane 必須是 left、right 或 ground
|
||||
@msg missing `id` (see plugin.list) 未提供 `id` (請參閱 plugin.list)
|
||||
@msg no plug-in {id} is installed 未安裝增效模組 {id}
|
||||
@msg {_1} is a live effect (Effect › Plug-ins) {_1} 是即時效果 (效果 › 增效模組)
|
||||
@msg a preset named `{_1}` exists 名為 `{_1}` 的預設集已存在
|
||||
@msg a swatch or color group named `{n}` exists 名為 `{n}` 的色票或顏色群組已存在
|
||||
@msg no print preset named `{base}` 沒有名為 `{base}` 的列印預設集
|
||||
@msg no print preset named `{name}` 沒有名為 `{name}` 的列印預設集
|
||||
@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) 沒有名為 `{name}` 的已儲存列印預設集 ({DEFAULT_PRESET} 受保護:請以它為基礎新增一個)
|
||||
@msg print settings are not an object 列印設定不是物件
|
||||
@msg no colour group `{g}` 沒有顏色群組 `{g}`
|
||||
@msg select artwork (or a colour group) to recolor 請選取要重新上色的圖稿 (或顏色群組)
|
||||
@msg Downloaded {_1} file(s) 已下載 {_1} 個檔案
|
||||
@msg Preview in Browser needs the desktop app (the web downloads files instead) 「在瀏覽器中預覽」需要桌面應用程式 (網頁版會改為下載檔案)
|
||||
@msg no file system here 此處沒有檔案系統
|
||||
@msg nothing to preview 沒有可預覽的內容
|
||||
@msg Saved {n} files beside {path} 已在 {path} 旁儲存 {n} 個檔案
|
||||
@msg `{_1}` is no save command `{_1}` 不是儲存指令
|
||||
@msg name the file 請為檔案命名
|
||||
@msg unknown format 未知的格式
|
||||
@msg PDF settings are not an object PDF 設定不是物件
|
||||
@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) 沒有名為 `{name}` 的已儲存 PDF 預設集 (內建預設集為唯讀:請以它們為基礎新增一個)
|
||||
@msg Saved to User Defined: {_1} 已儲存至「使用者定義」:{_1}
|
||||
@msg choose a direction to divide in 請選擇分割方向
|
||||
@msg choice must be "merge" or "add", not `{choice}` choice 必須是 "merge" 或 "add",而非 `{choice}`
|
||||
@msg `{name}` has no options `{name}` 沒有選項
|
||||
@msg no swatch `{name}` 沒有色票 `{name}`
|
||||
@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` 不是顏色 (#rrggbb 或預設集名稱)
|
||||
@msg give the path `id` and the width point `index` 請提供路徑的 `id` 和寬度點的 `index`
|
||||
@msg no such width point 沒有這個寬度點
|
||||
@msg onto must name a panel floating in another group onto 必須指定浮動在另一個群組中的面板
|
||||
@msg below and above must name a panel floating in another group below 和 above 必須指定浮動在另一個群組中的面板
|
||||
@msg give only one of onto, below and above onto、below 和 above 只能提供其中一個
|
||||
@msg column must be true or a number from 1 column 必須是 true 或從 1 起算的數字
|
||||
@msg unknown panel `{raw}` 未知的面板 `{raw}`
|
||||
@msg x and y must both be numbers x 和 y 都必須是數字
|
||||
@msg Downloaded {what} 已下載 {what}
|
||||
@msg Exported {count} file(s) to {folder} 已將 {count} 個檔案匯出至 {folder}
|
||||
@msg Imported {what}: {_1} 已匯入 {what}:{_1}
|
||||
@msg Installed plug-in {_1} 已安裝增效模組 {_1}
|
||||
@msg Opened with {_1} note(s): {_2} 已開啟,附有 {_1} 則註解:{_2}
|
||||
@msg Saved {path} 已儲存 {path}
|
||||
@msg no app to open files here 此處沒有可開啟檔案的應用程式
|
||||
@msg no data 沒有資料
|
||||
@msg no document 沒有文件
|
||||
@msg no file manager here 此處沒有檔案管理員
|
||||
@msg no revert command 沒有回復指令
|
||||
@msg the Save PDF dialog didn't open 「儲存 PDF」對話框未開啟
|
||||
@msg the document has never been saved 文件從未儲存過
|
||||
@msg the export returned unreadable data 匯出傳回了無法讀取的資料
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg Exported {what} with {_1} note(s): {first} 已匯出 {what},附有 {_1} 則註解:{first}
|
||||
@msg Saved {path} with {_1} note(s): {first} 已儲存 {path},附有 {_1} 則註解:{first}
|
||||
@msg Couldn't open {name}: {e} 無法開啟 {name}:{e}
|
||||
@msg Couldn't read {name}: {e} 無法讀取 {name}:{e}
|
||||
@msg Internal error: {msg} (please report this bug) 內部錯誤:{msg} (請回報此錯誤)
|
||||
@msg Opened {url} 已開啟 {url}
|
||||
@msg No saved views (View → New View…) 沒有已儲存的檢視 (檢視 → 新增檢視…)
|
||||
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` 必須是選單項目會開啟對話框的指令 (請參閱 ui.menuDialog)
|
||||
@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` 必須是 grayscale、rgb、hsb、cmyk 或 websafe
|
||||
@msg `{key}` must be true or false `{key}` 必須是 true 或 false
|
||||
@msg brightness must be dark|mediumDark|mediumLight|light brightness 必須是 dark|mediumDark|mediumLight|light
|
||||
@msg index must be a number or first, previous, next or last index 必須是數字,或 first、previous、next 或 last
|
||||
@msg index must be a number or first, previous, next or last, not `{other}` index 必須是數字,或 first、previous、next 或 last,而非 `{other}`
|
||||
@msg lang must be one of {_1} lang 必須是 {_1} 其中之一
|
||||
@msg missing `name` 未提供 `name`
|
||||
@msg missing `tool` 未提供 `tool`
|
||||
@msg missing index 未提供 index
|
||||
@msg multiple windows land with M11.5 多個視窗將於 M11.5 推出
|
||||
@msg no PNG encoder 沒有 PNG 編碼器
|
||||
@msg no document open 沒有開啟的文件
|
||||
@msg no effect applied yet 尚未套用任何效果
|
||||
@msg no such perspective grid preset 沒有這個透視格點預設集
|
||||
@msg no such recent file 沒有這個最近使用的檔案
|
||||
@msg no such recent font 沒有這個最近使用的字體
|
||||
@msg no such saved selection 沒有這個已儲存的選取範圍
|
||||
@msg no such user library 沒有這個使用者資料庫
|
||||
@msg no such view 沒有這個檢視
|
||||
@msg printing isn't available here 此處無法列印
|
||||
@msg select an image 請選取影像
|
||||
@msg the document has no artboards 文件沒有工作區域
|
||||
@msg tool (a tool id) is required 需要 tool (工具 id)
|
||||
@msg unknown tool `{t}` 未知的工具 `{t}`
|
||||
@msg no such action 沒有這個動作
|
||||
@msg choose a folder to export to 請選擇要匯出至的資料夾
|
||||
@msg the selection has no URL (or they differ) 選取範圍沒有 URL (或其 URL 各不相同)
|
||||
@msg select some type first 請先選取一些文字
|
||||
@msg {_1} font families available 有 {_1} 個可用的字體家族
|
||||
@msg {_1} has no {_2} style {_1} 沒有 {_2} 樣式
|
||||
@msg Copied the CSS of {rules} object(s) 已複製 {rules} 個物件的 CSS
|
||||
@msg Copied the script report 已複製指令碼報告
|
||||
@msg Exported {path} 已匯出 {path}
|
||||
@msg no CSS to copy: select objects with CSS of their own 沒有可複製的 CSS:請選取有自己 CSS 的物件
|
||||
@msg unknown highlight `{h}` 未知的反白顯示 `{h}`
|
||||
@msg unknown overprints `{o}` (preserve, simulate or discard) 未知的疊印方式 `{o}` (preserve、simulate 或 discard)
|
||||
@msg unknown preset `{name}` (see flattener.presets.list) 未知的預設集 `{name}` (請參閱 flattener.presets.list)
|
||||
@msg Select text or click into text with the Type tool to insert glyphs 請選取文字,或使用文字工具在文字中按一下,以插入字符
|
||||
@msg `library` is a library id or name (see {list}) `library` 是資料庫 id 或名稱 (請參閱 {list})
|
||||
@msg no library `{key}` (see {list}) 沒有資料庫 `{key}` (請參閱 {list})
|
||||
@msg select a linked image: an embedded one has no file 請選取連結的影像:內嵌的影像沒有檔案
|
||||
@msg {path} can't be found: relink it first 找不到 {path}:請先重新連結
|
||||
@msg Embedded {n} image(s); {missing} missing: relink them first 已內嵌 {n} 個影像;遺失 {missing} 個:請先重新連結
|
||||
@msg Relinked {_1} image(s) 已重新連結 {_1} 個影像
|
||||
@msg Relinked {n} image(s) 已重新連結 {n} 個影像
|
||||
@msg Relinked {n} image(s); not in that folder: {_1} 已重新連結 {n} 個影像;不在該資料夾中:{_1}
|
||||
@msg Unembedded to {_1} 已取消內嵌至 {_1}
|
||||
@msg Updated {_1} image(s) 已更新 {_1} 個影像
|
||||
@msg no swatch library `{l}` (see swatch.library.list) 沒有色票資料庫 `{l}` (請參閱 swatch.library.list)
|
||||
@msg no user library folder here 此處沒有使用者資料庫資料夾
|
||||
@msg Couldn't place {name}: the document it was dropped on was closed 無法置入 {name}:放置它的文件已關閉
|
||||
@msg no files to place 沒有要置入的檔案
|
||||
@msg paths: give at least one file paths:請提供至少一個檔案
|
||||
@msg Couldn't place {name}: {e} 無法置入 {name}:{e}
|
||||
@msg no print service 沒有列印服務
|
||||
@msg the print job has no data 列印工作沒有資料
|
||||
@msg there are no printer settings to open here 此處沒有可開啟的印表機設定
|
||||
@msg Couldn't save recovery data: {e} 無法儲存復原資料:{e}
|
||||
@msg Saved recovery data for {title} 已儲存 {title} 的復原資料
|
||||
@msg Discarded {_1} recovered document(s) 已捨棄 {_1} 份復原的文件
|
||||
@msg Restored {_1} recovered document(s): save them to keep them 已還原 {_1} 份復原的文件:請儲存以保留
|
||||
@msg Saving recovery data for {_1} 正在儲存 {_1} 的復原資料
|
||||
@msg This shortcut file is in a form VectorCraft can't read. VectorCraft 無法讀取此快速鍵檔案的格式。
|
||||
@msg bad shortcut 快速鍵無效
|
||||
@msg can't parse shortcut `{c}` 無法剖析快速鍵 `{c}`
|
||||
@msg can't parse shortcut `{s}` for `{k}` 無法剖析 `{k}` 的快速鍵 `{s}`
|
||||
@msg no dialog open 沒有開啟的對話框
|
||||
@msg no file reader 沒有檔案讀取器
|
||||
@msg no file writer 沒有檔案寫入器
|
||||
@msg not a VectorCraft shortcut set (missing `overrides`) 不是 VectorCraft 快速鍵組 (沒有 `overrides`)
|
||||
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace 不是快速鍵組:文字開頭不是斜線名稱和左大括號
|
||||
@msg shortcut for `{k}` must be a string `{k}` 的快速鍵必須是字串
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) 工具快速鍵必須是單一按鍵 (可搭配 Shift)
|
||||
@msg unknown preset (one of {_1}) 未知的預設集 (應為 {_1} 其中之一)
|
||||
@msg unknown tool or command `{key}` 未知的工具或指令 `{key}`
|
||||
@msg {_1} is already used by {_2} {_1} 已由 {_2} 使用
|
||||
@msg Couldn't copy to the system clipboard: {e} 無法複製到系統剪貼簿:{e}
|
||||
@msg Couldn't paste from the clipboard: {e} 無法從剪貼簿貼上:{e}
|
||||
@msg nothing selected 未選取任何物件
|
||||
@msg the {tool} tool has no options {tool} 工具沒有選項
|
||||
@msg unknown tool `{tool}` 未知的工具 `{tool}`
|
||||
@msg {tool} isn't in a toolbar group of several tools {tool} 不在包含多個工具的工具列群組中
|
||||
@msg no such document 沒有這個文件
|
||||
@msg {_1}: not saved, so not closed {_1}:未儲存,因此未關閉
|
||||
@msg `{name}` is a built-in workspace; choose another name `{name}` 是內建工作區;請選擇其他名稱
|
||||
@msg `{to}` is not an available name `{to}` 不是可用的名稱
|
||||
@msg no user workspace `{from}` 沒有使用者工作區 `{from}`
|
||||
@msg unknown workspace (one of {_1}) 未知的工作區 (應為 {_1} 其中之一)
|
||||
@msg workspace name can't be empty 工作區名稱不能是空的
|
||||
@msg No fonts are missing in this document 此文件中沒有遺失的字體
|
||||
@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft 不會搜尋其他應用程式或系統的資料夾:{path}
|
||||
@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft 不會搜尋「使用者」中的 Shared 或 Public 資料夾:{path}
|
||||
@msg {path} is not a folder {path} 不是資料夾
|
||||
@msg the search couldn't start: try again in a moment 無法開始搜尋:請稍後再試
|
||||
@msg searching a folder isn't available on the web 網頁版無法搜尋資料夾
|
||||
@msg every search thread stopped on an internal error (please report this bug) 所有搜尋執行緒都因內部錯誤而停止 (請回報此錯誤)
|
||||
@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it 此處未設定 VectorCraft 的字體資料夾:桌面應用程式和 vectorcraft-cli 會設定它
|
||||
@msg choose the font files to add 請選擇要加入的字體檔案
|
||||
@msg {n} font file(s) are now in {folder} {n} 個字體檔案現已位於 {folder}
|
||||
@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} {n} 個字體檔案現已位於 {folder};有 {failed} 個無法加入:{reason}
|
||||
@msg {n} font(s) aren't available here: their text shows in another font 此處沒有 {n} 種字體:使用這些字體的文字會以其他字體顯示
|
||||
@msg not a file 不是檔案
|
||||
@msg not a font file 不是字體檔案
|
||||
@msg that document is no longer open 該文件已不再開啟
|
||||
@msg larger than 256 MB 大於 256 MB
|
||||
@msg the files come to more than 1 GB: add fewer at a time 檔案總計超過 1 GB:請每次少加入一些
|
||||
@msg every numbered name for it is taken in the Fonts folder 字體資料夾中所有編號名稱都已被使用
|
||||
@msg every numbered name for {name} is taken in the document's folder 文件所在資料夾中 {name} 的所有編號名稱都已被使用
|
||||
@msg No linked files to look for 沒有要尋找的連結檔案
|
||||
@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} 已重新連結 {n} 個影像;無法重新連結 {name}:{reason}
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步驟參照 {r},只有 `run` 和 `command_batch` 能解析它
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` 必須是由物件 id 組成的陣列,而非 {v}
|
||||
@msg `corners` must be an array of anchor indices, not {v} `corners` 必須是由錨點索引組成的陣列,而非 {v}
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`:步驟 {i} 的結果中沒有此值 (若要表示 $ 本身,請寫 $$)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`:此步驟之前沒有步驟 {i} (步驟從 0 起算;若要表示 $ 本身,請寫 $$)
|
||||
@msg object ids are non-negative integers, not {v} 物件 id 是非負整數,而非 {v}
|
||||
@msg automation filesystem access is not granted: read authority is absent: {path} 未授予自動化檔案系統存取權:沒有讀取權限:{path}
|
||||
@msg automation filesystem access is not granted: write authority is absent: {path} 未授予自動化檔案系統存取權:沒有寫入權限:{path}
|
||||
@msg automation path rejected: outside the read root {root}: {path} 已拒絕自動化路徑:位於讀取根目錄 {root} 之外:{path}
|
||||
@msg automation path rejected: outside the write root {root}: {path} 已拒絕自動化路徑:位於寫入根目錄 {root} 之外:{path}
|
||||
@msg automation path rejected: path is empty 已拒絕自動化路徑:路徑是空的
|
||||
@msg automation path rejected: a link that leads nowhere: {path} 已拒絕自動化路徑:連結指向不存在的位置:{path}
|
||||
@msg automation path rejected: cannot resolve {path}: {e} 已拒絕自動化路徑:無法解析 {path}:{e}
|
||||
@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} 已拒絕自動化路徑:在不存在的資料夾下往上層周遊:{path}
|
||||
@msg automation path rejected: reserved device names are not allowed: {path} 已拒絕自動化路徑:不允許使用保留的裝置名稱:{path}
|
||||
@msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode 無法讀取多重色版 PSD 檔案:請以 RGB、CMYK 或灰階模式儲存
|
||||
@msg not a PSD file 不是 PSD 檔案
|
||||
@msg the PSD file has an unknown colour mode ({m}) PSD 檔案的色彩模式不明 ({m})
|
||||
@msg the PSD file's image data uses an unknown compression ({m}) PSD 檔案的影像資料使用了不明的壓縮方式 ({m})
|
||||
@msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 影像過大,無法讀取 ({_1} × {_2} 像素)
|
||||
@msg zipped 32-bit PSD image data with prediction can't be read 無法讀取使用預測的 ZIP 壓縮 32 位元 PSD 影像資料
|
||||
@msg {depth}-bit PSD files in this colour mode can't be read 無法讀取此色彩模式的 {depth} 位元 PSD 檔案
|
||||
@msg the PSD file is cut short PSD 檔案不完整
|
||||
@msg the PSD file's header is damaged PSD 檔頭已損毀
|
||||
@msg the PSD file's image data can't be read: {e} 無法讀取 PSD 檔案的影像資料:{e}
|
||||
@msg Imported library {name} 已匯入資料庫 {name}
|
||||
@msg invalid restored panel group 還原的面板群組無效
|
||||
@msg Invalid panel layout; restored the default dock 面板配置無效;已還原預設停駐區
|
||||
@msg action is required 需要 action
|
||||
@msg anchor must name a registered panel anchor 必須指定已註冊的面板
|
||||
@msg before must name a panel before 必須指定面板
|
||||
@msg docking action contains an unknown panel or an excessive split path 停駐動作包含未知的面板或過長的分割路徑
|
||||
@msg group must be true or false group 必須是 true 或 false
|
||||
@msg move requires a destination panel 移動需要目的地面板
|
||||
@msg move requires an existing destination panel 移動需要現有的目的地面板
|
||||
@msg onto must name a floating panel onto 必須指定浮動面板
|
||||
@msg panel group is empty 面板群組是空的
|
||||
@msg panel must name a registered panel panel 必須指定已註冊的面板
|
||||
@msg saved panel layout is invalid; reset the workspace first 已儲存的面板配置無效;請先重設工作區
|
||||
@msg x and y must be provided together x 和 y 必須同時提供
|
||||
@msg zone must be a string zone 必須是字串
|
||||
@msg zone must be tab, center, left, right, top or bottom zone 必須是 tab、center、left、right、top 或 bottom
|
||||
@msg {key} must be a finite coordinate {key} 必須是有限的座標
|
||||
@msg invalid native panel set 原生面板組無效
|
||||
@msg missing panel layout 沒有面板配置
|
||||
@msg unsupported native docking operation 不支援的原生停駐操作
|
||||
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) 壓縮檔解壓縮後超過 VectorCraft 的讀取上限 (文件 {_1} MB,每個影像 {_2} MB)
|
||||
@msg the document is too large to save compressed ({0}): save it with Use Compression off 文件過大,無法壓縮儲存 ({0}):請關閉「使用壓縮」後儲存
|
||||
@msg the file stopped being read (please report this bug) 檔案讀取已中斷 (請回報此錯誤)
|
||||
@msg the image can't be read as a texture 無法將影像當作紋理讀取
|
||||
@msg give a fraction `t` from 0 to 1 請提供 0 到 1 之間的比例 `t`
|
||||
@msg give the path `id` 請提供路徑的 `id`
|
||||
@msg invalid stroke widths 筆畫寬度無效
|
||||
@msg object has no weighted stroke 物件沒有具寬度的筆畫
|
||||
@msg Angles require two straight segments 角度需要兩條直線段
|
||||
@msg Choose two different points 請選擇兩個不同的點
|
||||
@msg Choose two non-parallel straight segments 請選擇兩條不平行的直線段
|
||||
@msg Choose two nonzero angle arms 請選擇兩條長度不為零的角邊
|
||||
@msg Dimension point is outside the canvas 尺寸標註點位於畫布之外
|
||||
@msg Dimension size is outside its supported range 尺寸標註大小超出支援的範圍
|
||||
@msg First measured segment no longer exists 第一條測量線段已不存在
|
||||
@msg Invalid dimension distance 尺寸標註距離無效
|
||||
@msg Invalid dimension font name 尺寸標註字體名稱無效
|
||||
@msg Invalid drawing scale 圖紙比例無效
|
||||
@msg Invalid real scale 實際比例無效
|
||||
@msg Measured anchor no longer exists 測量的錨點已不存在
|
||||
@msg Measured segment no longer exists 測量的線段已不存在
|
||||
@msg Non-finite dimension 尺寸標註不是有限值
|
||||
@msg Scale must be drawing:real, for example 1:10 比例必須為 圖紙:實際,例如 1:10
|
||||
@msg Scale ratio must be between 1:1000000 and 1000000:1 比例必須介於 1:1000000 和 1000000:1 之間
|
||||
@msg Second measured segment no longer exists 第二條測量線段已不存在
|
||||
@msg Unknown dimension mode 未知的尺寸標註模式
|
||||
@msg Unknown dimension option {k} 未知的尺寸標註選項 {k}
|
||||
@msg span must be "object" or "selection", got {v} span 必須是 "object" 或 "selection",收到 {v}
|
||||
@msg span: "selection" places a `gradient`, not a swatch span: "selection" 放置的是 `gradient`,而非色票
|
||||
@msg span: "selection" places a gradient: give `gradient` span: "selection" 會放置漸層:請提供 `gradient`
|
||||
@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" 放置線性或放射狀漸層,而非任意形狀漸層
|
||||
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" 會自行放置漸層:請不要提供 `start` 和 `end` (它們已依文件座標放置漸層)
|
||||
@msg Batch: {count} processed 批次:已處理 {count} 個
|
||||
@msg Batch: {count} processed, {failed} failed 批次:已處理 {count} 個,失敗 {failed} 個
|
||||
@msg choose a destination folder 請選擇目的地資料夾
|
||||
@msg choose a source folder 請選擇來源資料夾
|
||||
@msg open a document with data sets 請開啟含有資料集的文件
|
||||
@msg the action has no steps 動作沒有步驟
|
||||
@msg cancelled; nothing was changed 已取消;未做任何變更
|
||||
@msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it 「儲存」會寫入 {_1},而非 {_2}:若要寫入該格式,請使用 file.saveAsTemplate
|
||||
@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 會寫入舊版 .ai 檔案;.ait 範本一律與 PDF 相容
|
||||
@msg a mask has more than one art block 遮色片含有多個圖稿區塊
|
||||
@msg mask art ends outside a mask dictionary 遮色片圖稿在遮色片字典之外結束
|
||||
@msg `{key}` must be a #rrggbb color `{key}` 必須是 #rrggbb 格式的顏色
|
||||
@msg a linked image can't become a gradient mesh: embed it first 連結的影像無法轉換為漸層網格:請先內嵌
|
||||
@msg the image has no usable size or position 影像沒有可用的尺寸或位置
|
||||
@msg the image's pixels can't be read 無法讀取影像的像素
|
||||
@msg Persistent libraries not found: {files} 找不到持久資料庫:{files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` 不是資料庫面板 (請參閱 window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open 資料庫面板 `{given}` 未開啟
|
||||
@msg too many library panels were opened in this session 本次工作階段開啟的資料庫面板過多
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 影像為 {_1} × {_2} 像素,有一邊超過 {LARGE_TRACE_SIDE}:仍會描圖,但速度較慢。使用精確度較低的預設集或較小的影像拷貝,描圖會較快。
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 影像已以 {_1} × {_2} 像素描圖,由 {_3} × {_4} 縮小而來 (最多 {_5} 百萬像素)。
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one 「編輯原稿」只能開啟影像與文件檔案;「{name}」不屬於這兩類
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” 只有 http、https 和 mailto URL 會在瀏覽器中開啟,「{url}」不會
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} 會開啟另一個應用程式:只有在鍵盤前操作的人才能執行
|
||||
@msg “{name}” is not a file 「{name}」不是檔案
|
||||
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
|
||||
Import Shortcut Set 匯入快速鍵組
|
||||
Import Shortcut Set… 匯入快速鍵組…
|
||||
@@ -1847,6 +2576,19 @@ file-filter Templates 範本
|
||||
Move Slices 移動切片
|
||||
Move Spine Point 移動脊線點
|
||||
Reshape Spine Segment 改變脊線線段外框
|
||||
@msg select a blend or give `id` 請選取漸變物件,或提供 `id`
|
||||
@msg not a blend 不是漸變物件
|
||||
@msg the blend has no spine 漸變物件沒有脊線
|
||||
@msg the spine has no segments 脊線沒有線段
|
||||
@msg no such spine point 沒有這個脊線點
|
||||
@msg no such spine segment 沒有這個脊線線段
|
||||
@msg a key object sits on that point 該點上有關鍵物件
|
||||
@msg a spine keeps at least two points 脊線至少要保留兩個點
|
||||
@msg missing number `{key}` 未提供數字 `{key}`
|
||||
@msg index must be ≥ 0 index 必須 ≥ 0
|
||||
@msg x and y must be finite x 和 y 必須是有限值
|
||||
@msg t, dx and dy must be finite t、dx 和 dy 必須是有限值
|
||||
@msg handle must be in or out handle 必須是 in 或 out
|
||||
Move Swatch 移動色票
|
||||
Move Up 上移
|
||||
Move… 移動…
|
||||
@@ -3421,6 +4163,7 @@ mark-style Japanese 日式
|
||||
Delete Variable 刪除變數
|
||||
Lock Variables 鎖定變數
|
||||
Unlock Variables 解除鎖定變數
|
||||
@msg variables are locked 變數已鎖定
|
||||
List Variables 列出變數
|
||||
Bind Variable 綁定變數
|
||||
Unbind Variable 解除變數綁定
|
||||
@@ -4094,3 +4837,11 @@ lighting Light 光源
|
||||
Minimum must not exceed Desired, nor Desired Maximum. 最小值不可超過所需值,所需值不可超過最大值。
|
||||
Minimum 最小值
|
||||
Justification 對齊設定
|
||||
# Select by Color (select.color)
|
||||
Select by Color 依顏色選取
|
||||
@msg missing integer `{key}` 未提供整數 `{key}`
|
||||
@msg `{key}` must be a number, not {v} `{key}` 必須是數字,而非 {v}
|
||||
@msg {key} must be Unix seconds (an integer) or null {key} 必須是以秒為單位的 Unix 時間 (整數) 或 null
|
||||
@msg {key} must be an array of non-negative integer object ids {key} 必須是由非負整數物件 id 組成的陣列
|
||||
@msg invalid object id {value}: expected a non-negative integer 無效的物件 id {value}:應為非負整數
|
||||
@msg no such object id {value} 沒有 id 為 {value} 的物件
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -708,6 +708,7 @@ impl VectorcraftApp {
|
||||
|
||||
/// [`Self::run`] it, inside what asks for file dialogs.
|
||||
fn run_now(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size);
|
||||
self.run_count = self.run_count.wrapping_add(1);
|
||||
// In debug builds the app's own calls are held to the params descriptions too (#1442): a
|
||||
// panel passing a key its command doesn't read shows up in the tests.
|
||||
@@ -1420,6 +1421,7 @@ impl VectorcraftApp {
|
||||
}
|
||||
|
||||
fn ui_frame(&mut self, ui: &mut egui::Ui) {
|
||||
crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size);
|
||||
let ctx = ui.ctx().clone();
|
||||
if !self.fonts_ready {
|
||||
ctx.request_repaint();
|
||||
|
||||
@@ -1249,6 +1249,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
"view.snapToPoint" => flag(&mut app.ui.view.snap_to_point),
|
||||
"view.zoomIn" | "view.zoomOut" => {
|
||||
let up = id == "view.zoomIn";
|
||||
let print = app.session.prefs.display_print_size;
|
||||
// Selection & Anchor Display › Zoom to Selection: the selection comes to the middle.
|
||||
let focus = if app.session.prefs.zoom_to_selection { app.selection_box().map(|b| b.center()) } else { None };
|
||||
match app.view_mut() {
|
||||
@@ -1257,13 +1258,14 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
if let Some(c) = focus {
|
||||
v.center = c;
|
||||
}
|
||||
Ok(json!({"zoom": v.zoom * 100.0}))
|
||||
Ok(json!({"zoom": crate::state::zoom_display_percent(v.zoom, print)}))
|
||||
}
|
||||
None => Err("no document".into()),
|
||||
}
|
||||
}
|
||||
"view.setZoom" => {
|
||||
let z = p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0) / 100.0;
|
||||
let print = app.session.prefs.display_print_size;
|
||||
let z = crate::state::zoom_from_display_percent(p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0), print);
|
||||
let center =
|
||||
p.get("center").and_then(Value::as_array).and_then(|a| Some(vectorcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
match app.view_mut() {
|
||||
|
||||
@@ -504,6 +504,11 @@ struct PanelContent<'a> {
|
||||
|
||||
impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
|
||||
fn body(&mut self, ui: &mut egui::Ui, panel: &String) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let back = ui.max_rect();
|
||||
if back.is_positive() {
|
||||
ui.painter().rect_filled(back, 0.0, t.panel);
|
||||
}
|
||||
let width = ui.available_width();
|
||||
let height = (ui.available_height() - 20.0).max(0.0);
|
||||
if crate::state::DockTab::from_id(panel).is_some() {
|
||||
@@ -637,10 +642,13 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
|
||||
let fills: Vec<_> = display
|
||||
.floating
|
||||
.iter()
|
||||
.filter_map(|group| group.panels.first())
|
||||
.map(|first| {
|
||||
.filter_map(|group| {
|
||||
let first = group.panels.first()?;
|
||||
let layer = egui::LayerId::new(egui::Order::Middle, area.with(("floating", first)));
|
||||
(layer, ui.ctx().layer_painter(layer).add(egui::Shape::Noop))
|
||||
let [x, y, w, h] = group.rect;
|
||||
let layout_rect = egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(w, h));
|
||||
let fill = ui.ctx().layer_painter(layer).add(egui::Shape::Noop);
|
||||
Some((layer, layout_rect, fill))
|
||||
})
|
||||
.collect();
|
||||
let mut draw = |ui: &mut egui::Ui| {
|
||||
@@ -787,8 +795,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
|
||||
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::group_rect_id(first), rect));
|
||||
}
|
||||
}
|
||||
for (layer, fill) in fills {
|
||||
if let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(layer.id)) {
|
||||
for (layer, layout_rect, fill) in fills {
|
||||
let rect = ui.ctx().memory(|memory| memory.area_rect(layer.id)).filter(|rect| rect.is_positive()).unwrap_or(layout_rect);
|
||||
if rect.is_positive() {
|
||||
ui.ctx().layer_painter(layer).set(fill, egui::Shape::rect_filled(rect, 0.0, t.panel));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,7 +616,7 @@ fn the_window_menu_item_hides_a_shown_panel_and_shows_it_again() {
|
||||
}
|
||||
|
||||
/// A floating group lies on the canvas, not on the dock's card, so its tab strip and body paint
|
||||
/// their own opaque background (#2421).
|
||||
/// their own opaque background (#2421, #2646).
|
||||
#[test]
|
||||
fn floating_group_paints_an_opaque_background() {
|
||||
let mut app = app();
|
||||
@@ -641,6 +641,27 @@ fn floating_group_paints_an_opaque_background() {
|
||||
assert!(opaque_at(pos2(690.0, 610.0)), "body is transparent");
|
||||
}
|
||||
|
||||
/// Undocking a tabbed dock panel leaves an opaque backdrop even in the empty part of its body (#2646).
|
||||
#[test]
|
||||
fn undocked_layers_panel_body_is_opaque() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({"panel":"layers","x":300,"y":160,"width":300,"height":420})).unwrap();
|
||||
let h = harness(app, vec2(1280.0, 800.0));
|
||||
fn topmost(shape: &egui::Shape, p: Pos2, found: &mut Option<egui::Color32>) {
|
||||
match shape {
|
||||
egui::Shape::Rect(r) if r.rect.contains(p) && r.rect.width() >= 80.0 && r.stroke.is_empty() && r.fill.a() > 0 => *found = Some(r.fill),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| topmost(s, p, found)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let opaque_at = |p: Pos2| {
|
||||
let mut found = None;
|
||||
h.output().shapes.iter().for_each(|s| topmost(&s.shape, p, &mut found));
|
||||
found.is_some_and(|c| c.a() == 255)
|
||||
};
|
||||
assert!(opaque_at(pos2(580.0, 520.0)), "empty layers body shows the canvas");
|
||||
}
|
||||
|
||||
/// Library panels (#2603) float in the shared layout titled by their libraries' names, and their
|
||||
/// menu's Persistent item keeps them for the next launch.
|
||||
#[test]
|
||||
|
||||
+48
-10
@@ -62,25 +62,53 @@ pub const ROTATE_VIEW_ANGLES: [(&str, f64); 12] = [
|
||||
("-90°", -90.0),
|
||||
];
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Zoom stop stepping reads this (set each UI frame and before commands) so canvas wheel zoom
|
||||
/// matches the status bar when Display Print Size at 100% Zoom is on.
|
||||
static ZOOM_STOPS_PRINT_SIZE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn sync_zoom_stops_display_print_size(v: bool) {
|
||||
ZOOM_STOPS_PRINT_SIZE.store(v, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Illustrator's preset zoom stops, in percent.
|
||||
pub const ZOOM_STOPS: [f64; 26] = [
|
||||
3.13, 4.17, 6.25, 8.33, 12.5, 16.67, 25.0, 33.33, 50.0, 66.67, 100.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1200.0, 1600.0, 2400.0, 3200.0,
|
||||
4800.0, 6400.0, 12800.0, 25600.0, 64000.0,
|
||||
];
|
||||
|
||||
/// Document zoom at 100% in the status bar: one screen point per document point, or with
|
||||
/// Preferences › General › Display Print Size at 100% Zoom, one document inch in 96 screen points.
|
||||
pub fn zoom_baseline(display_print_size: bool) -> f64 {
|
||||
if display_print_size { 96.0 / 72.0 } else { 1.0 }
|
||||
}
|
||||
|
||||
pub fn zoom_display_percent(z: f64, display_print_size: bool) -> f64 {
|
||||
z / zoom_baseline(display_print_size) * 100.0
|
||||
}
|
||||
|
||||
pub fn zoom_from_display_percent(pct: f64, display_print_size: bool) -> f64 {
|
||||
pct / 100.0 * zoom_baseline(display_print_size)
|
||||
}
|
||||
|
||||
pub fn next_zoom(z: f64, up: bool) -> f64 {
|
||||
let pct = z * 100.0;
|
||||
next_zoom_with_display(z, up, ZOOM_STOPS_PRINT_SIZE.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
pub fn next_zoom_with_display(z: f64, up: bool, display_print_size: bool) -> f64 {
|
||||
let pct = zoom_display_percent(z, display_print_size);
|
||||
let stop = if up {
|
||||
ZOOM_STOPS.iter().find(|s| **s > pct + 0.01).copied().unwrap_or(64000.0)
|
||||
} else {
|
||||
ZOOM_STOPS.iter().rev().find(|s| **s < pct - 0.01).copied().unwrap_or(3.13)
|
||||
};
|
||||
stop / 100.0
|
||||
zoom_from_display_percent(stop, display_print_size)
|
||||
}
|
||||
|
||||
/// Format like Illustrator's tab/zoom field: up to 2 decimals, no trailing zeros.
|
||||
pub fn zoom_label(z: f64) -> String {
|
||||
let s = format!("{:.2}", z * 100.0);
|
||||
pub fn zoom_label(z: f64, display_print_size: bool) -> String {
|
||||
let s = format!("{:.2}", zoom_display_percent(z, display_print_size));
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
format!("{s}%")
|
||||
}
|
||||
@@ -821,12 +849,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn zoom_steps() {
|
||||
assert_eq!(next_zoom(1.0, true), 1.5);
|
||||
assert!((next_zoom(1.0, false) - 0.6667).abs() < 1e-9);
|
||||
assert_eq!(next_zoom(640.0, true), 640.0);
|
||||
assert_eq!(zoom_label(0.6667), "66.67%");
|
||||
assert_eq!(zoom_label(1.0), "100%");
|
||||
assert_eq!(zoom_label(0.125), "12.5%");
|
||||
assert_eq!(next_zoom_with_display(1.0, true, false), 1.5);
|
||||
assert!((next_zoom_with_display(1.0, false, false) - 0.6667).abs() < 1e-9);
|
||||
assert_eq!(next_zoom_with_display(640.0, true, false), 640.0);
|
||||
assert_eq!(zoom_label(0.6667, false), "66.67%");
|
||||
assert_eq!(zoom_label(1.0, false), "100%");
|
||||
assert_eq!(zoom_label(0.125, false), "12.5%");
|
||||
}
|
||||
|
||||
/// storytold/vectorcraft#2719: 100% in the zoom field matches Actual Size when Display Print Size is on.
|
||||
#[test]
|
||||
fn zoom_percent_honors_display_print_size_baseline() {
|
||||
let b = zoom_baseline(true);
|
||||
assert!((zoom_from_display_percent(100.0, true) - b).abs() < 1e-12);
|
||||
assert_eq!(zoom_from_display_percent(100.0, false), 1.0);
|
||||
assert_eq!(zoom_label(b, true), "100%");
|
||||
assert_eq!(next_zoom_with_display(b, true, true), zoom_from_display_percent(150.0, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -447,16 +447,95 @@ fn arrow_keys_step_a_focused_field() {
|
||||
(Key::ArrowUp, Modifiers::NONE, 5.0),
|
||||
(Key::ArrowUp, Modifiers::SHIFT, 15.0),
|
||||
(Key::ArrowDown, Modifiers::COMMAND, 14.9),
|
||||
(Key::ArrowDown, Modifiers::NONE, 13.9),
|
||||
(Key::ArrowDown, Modifiers::NONE, 14.0),
|
||||
] {
|
||||
assert!(f.frame(vec![key(k, mods)], &draw).is_some(), "{k:?} {mods:?}");
|
||||
assert!((mm.get() - want).abs() < 1e-6, "{k:?} {mods:?}: {}", mm.get());
|
||||
}
|
||||
assert_eq!(f.selection(), Some((0, "13.9 mm".len())), "the new value is selected");
|
||||
assert_eq!(f.selection(), Some((0, "14 mm".len())), "the new value is selected");
|
||||
// Enter after the steps commits nothing more.
|
||||
assert_eq!(f.frame(vec![enter()], &draw), None);
|
||||
}
|
||||
|
||||
/// #2881, #2793: ↑/↓ and the wheel step a fractional value to the next whole unit in their
|
||||
/// direction (5.1 mm: up to 6, down to 5), and Ctrl/Cmd steps to the next tenth; Shift adds or takes
|
||||
/// ten and keeps the fraction. In each field's own unit: millimeters, points, percent, degrees.
|
||||
#[test]
|
||||
fn steps_go_to_the_next_whole_unit_or_tenth() {
|
||||
use egui::MouseWheelUnit::Line;
|
||||
let u = Unit::Millimeters;
|
||||
let mm = Cell::new(0.0);
|
||||
let mm_field = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(u.to_pt(mm.get())), u, 80.0).inspect(|&v| mm.set(u.from_pt(v)));
|
||||
let mut f = Field::new();
|
||||
f.frame(vec![], &mm_field);
|
||||
f.frame(f.click(1), &mm_field);
|
||||
f.frame(vec![], &mm_field);
|
||||
let at = f.rect.get().center();
|
||||
// From `from` (set from outside, which the focused field follows), the value `events` give.
|
||||
let mut step = |from: f64, events: Vec<Event>| {
|
||||
mm.set(from);
|
||||
f.frame(vec![], &mm_field);
|
||||
assert!(f.frame(events, &mm_field).is_some(), "from {from}");
|
||||
// The field shows the new value before the next one is set.
|
||||
f.frame(vec![], &mm_field);
|
||||
mm.get()
|
||||
};
|
||||
let arrow = |k, mods| vec![key(k, mods)];
|
||||
let turn = |dy, mods| wheel(at, Line, dy, mods);
|
||||
for (from, events, want) in [
|
||||
(5.1, arrow(Key::ArrowUp, Modifiers::NONE), 6.0),
|
||||
(5.1, arrow(Key::ArrowDown, Modifiers::NONE), 5.0),
|
||||
(5.0, arrow(Key::ArrowUp, Modifiers::NONE), 6.0),
|
||||
(5.0, arrow(Key::ArrowDown, Modifiers::NONE), 4.0),
|
||||
(-2.5, arrow(Key::ArrowUp, Modifiers::NONE), -2.0),
|
||||
(-2.5, arrow(Key::ArrowDown, Modifiers::NONE), -3.0),
|
||||
(5.13, arrow(Key::ArrowUp, Modifiers::COMMAND), 5.2),
|
||||
(5.13, arrow(Key::ArrowDown, Modifiers::COMMAND), 5.1),
|
||||
(5.1, arrow(Key::ArrowUp, Modifiers::SHIFT), 15.1),
|
||||
(5.1, arrow(Key::ArrowDown, Modifiers::SHIFT), -4.9),
|
||||
(5.1, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowUp, Modifiers::NONE)], 7.0),
|
||||
// Several presses in one frame step in the order they came.
|
||||
(5.1, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowDown, Modifiers::NONE)], 5.0),
|
||||
(5.13, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowUp, Modifiers::COMMAND)], 6.1),
|
||||
(24.8, turn(1.0, Modifiers::NONE), 25.0),
|
||||
(24.8, turn(-1.0, Modifiers::NONE), 24.0),
|
||||
(24.8, turn(2.0, Modifiers::NONE), 26.0),
|
||||
(24.8, turn(-2.0, Modifiers::NONE), 23.0),
|
||||
(24.0, turn(1.0, Modifiers::NONE), 25.0),
|
||||
(24.83, turn(1.0, Modifiers::COMMAND), 24.9),
|
||||
(24.8, turn(1.0, Modifiers::SHIFT), 34.8),
|
||||
] {
|
||||
let got = step(from, events);
|
||||
assert!((got - want).abs() < 1e-6, "{from} mm to {got}, not {want}");
|
||||
}
|
||||
// A points field steps whole points.
|
||||
let pt = Cell::new(12.25);
|
||||
let pt_field = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(pt.get()), Unit::Points, 80.0).inspect(|&v| pt.set(v));
|
||||
let mut f = Field::new();
|
||||
f.frame(vec![], &pt_field);
|
||||
f.frame(f.click(1), &pt_field);
|
||||
f.frame(vec![], &pt_field);
|
||||
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &pt_field), Some(13.0));
|
||||
// Percent and degree fields step whole percents and degrees.
|
||||
for suffix in ["%", "°"] {
|
||||
let mut f = Field::new();
|
||||
let value = Cell::new(37.5);
|
||||
let plain = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", value.get(), suffix, 2, 80.0).inspect(|&v| value.set(v));
|
||||
f.frame(vec![], &plain);
|
||||
f.frame(f.click(1), &plain);
|
||||
f.frame(vec![], &plain);
|
||||
for (k, mods, want) in [
|
||||
(Key::ArrowDown, Modifiers::NONE, 37.0),
|
||||
(Key::ArrowUp, Modifiers::NONE, 38.0),
|
||||
(Key::ArrowUp, Modifiers::COMMAND, 38.1),
|
||||
(Key::ArrowUp, Modifiers::NONE, 39.0),
|
||||
] {
|
||||
assert_eq!(f.frame(vec![key(k, mods)], &plain), Some(want), "{suffix} {k:?} {mods:?}");
|
||||
f.frame(vec![], &plain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain fields (degrees, percent, counts) step too, at their precision.
|
||||
#[test]
|
||||
fn arrow_keys_step_plain_fields() {
|
||||
@@ -498,17 +577,17 @@ fn the_wheel_steps_a_focused_field_under_the_pointer() {
|
||||
f.frame(f.click(1), &draw);
|
||||
f.frame(vec![], &draw);
|
||||
for (dy, mods, want) in
|
||||
[(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 23.9)]
|
||||
[(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 24.0)]
|
||||
{
|
||||
assert!(f.frame(wheel(at, Line, dy, mods), &draw).is_some(), "{dy} {mods:?}");
|
||||
assert!((mm.get() - want).abs() < 1e-6, "{dy} {mods:?}: {}", mm.get());
|
||||
}
|
||||
assert_eq!(f.selection(), Some((0, "23.9 mm".len())), "the new value is selected");
|
||||
assert_eq!(f.selection(), Some((0, "24 mm".len())), "the new value is selected");
|
||||
// Points: a step for each line's worth, what is left carried to the next turn.
|
||||
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
assert_eq!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw), None);
|
||||
assert!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw).is_some());
|
||||
assert!((mm.get() - 24.9).abs() < 1e-6, "{}", mm.get());
|
||||
assert!((mm.get() - 25.0).abs() < 1e-6, "{}", mm.get());
|
||||
assert_eq!(f.frame(wheel(Pos2::new(390.0, 190.0), Line, 1.0, Modifiers::NONE), &draw), None, "the pointer elsewhere");
|
||||
// Plain fields (degrees, percent, counts) too.
|
||||
let mut f = Field::new();
|
||||
|
||||
@@ -260,15 +260,31 @@ pub(crate) fn round_to(v: f64, decimals: i32) -> f64 {
|
||||
}
|
||||
|
||||
/// One step of a numeric field with each modifier, most specific first (a plain pattern would also
|
||||
/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one.
|
||||
/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. A Ctrl/Cmd or plain step goes to
|
||||
/// the next multiple of its size ([`step_to_multiple`]); a Shift step adds or takes ten.
|
||||
const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)];
|
||||
|
||||
/// `v` moved `n` steps of `size` (down when `n` is negative): the first step goes to the next
|
||||
/// multiple of `size` in its direction and each further step adds or takes one `size`. By 1, 5.1
|
||||
/// goes up to 6 and down to 5, and 5 goes up to 6 and down to 4. A value within a millionth of a
|
||||
/// step of a multiple counts as that multiple.
|
||||
fn step_to_multiple(v: f64, size: f64, n: f64) -> f64 {
|
||||
if n == 0.0 {
|
||||
return v;
|
||||
}
|
||||
let q = v / size;
|
||||
let from = if n > 0.0 { (q + 1e-6).floor() } else { (q - 1e-6).ceil() };
|
||||
(from + n) * size
|
||||
}
|
||||
|
||||
/// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf`
|
||||
/// shows (`read` parses it, `show` formats it) by one per press or wheel notch ([`STEPS`]: Shift
|
||||
/// ten, Ctrl/Cmd a tenth), applied at once as in Illustrator's panels and dialogs. The new text
|
||||
/// replaces `buf`, all selected so typing replaces it. Returns the new number when a step was
|
||||
/// taken. While the pointer is over the focused field the wheel is the field's (the panel under it
|
||||
/// doesn't scroll); an unfocused field leaves the wheel to the panel and the canvas.
|
||||
/// shows (`read` parses it, `show` formats it) per press or wheel notch ([`STEPS`]): a plain step
|
||||
/// goes to the next whole number in its direction (5.1 up to 6, down to 5), a Ctrl/Cmd step goes to
|
||||
/// the next tenth, and a Shift step adds or takes ten. The new value is applied at once, as in
|
||||
/// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing
|
||||
/// replaces it. Returns the new number when a step was taken. While the pointer is over the focused
|
||||
/// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the
|
||||
/// wheel to the panel and the canvas.
|
||||
fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> Option<f64>, show: impl Fn(f64) -> String) -> Option<f64> {
|
||||
// Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the
|
||||
// window to report keyboard focus.
|
||||
@@ -281,41 +297,51 @@ fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) ->
|
||||
// left of a notch carries over to the next turn.
|
||||
let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel"));
|
||||
let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 };
|
||||
let mut steps = 0.0;
|
||||
// Each press and each turn of whole notches in the order they came: its modifiers and size from
|
||||
// [`STEPS`], and how many steps up (down when negative).
|
||||
let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new();
|
||||
ui.input_mut(|i| {
|
||||
for (mods, size) in STEPS {
|
||||
let up = i.count_and_consume_key(mods, Key::ArrowUp) as f64;
|
||||
let down = i.count_and_consume_key(mods, Key::ArrowDown) as f64;
|
||||
steps += size * (up - down);
|
||||
}
|
||||
if !wheel {
|
||||
return;
|
||||
}
|
||||
i.events.retain(|e| {
|
||||
let Event::MouseWheel { unit, delta, modifiers, .. } = e else { return true };
|
||||
let Some((_, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true };
|
||||
// Shift may turn the wheel sideways (the Mac does).
|
||||
let d = if modifiers.shift { delta.x + delta.y } else { delta.y };
|
||||
notches += match unit {
|
||||
MouseWheelUnit::Point => d / line.max(1.0),
|
||||
MouseWheelUnit::Line | MouseWheelUnit::Page => d,
|
||||
let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true };
|
||||
let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true };
|
||||
let n = match e {
|
||||
Event::Key { key: Key::ArrowUp, pressed: true, .. } => 1.0,
|
||||
Event::Key { key: Key::ArrowDown, pressed: true, .. } => -1.0,
|
||||
Event::MouseWheel { unit, delta, .. } if wheel => {
|
||||
// Shift may turn the wheel sideways (the Mac does).
|
||||
let d = if modifiers.shift { delta.x + delta.y } else { delta.y };
|
||||
notches += match unit {
|
||||
MouseWheelUnit::Point => d / line.max(1.0),
|
||||
MouseWheelUnit::Line | MouseWheelUnit::Page => d,
|
||||
};
|
||||
let whole = notches.trunc();
|
||||
notches -= whole;
|
||||
f64::from(whole)
|
||||
}
|
||||
_ => return true,
|
||||
};
|
||||
let whole = notches.trunc();
|
||||
notches -= whole;
|
||||
steps += size * f64::from(whole);
|
||||
if n != 0.0 {
|
||||
steps.push((mods, size, n));
|
||||
}
|
||||
false
|
||||
});
|
||||
// The rest of a turn egui spreads over the next frames scrolls nothing either.
|
||||
i.smooth_scroll_delta = egui::Vec2::ZERO;
|
||||
if wheel {
|
||||
// The rest of a turn egui spreads over the next frames scrolls nothing either.
|
||||
i.smooth_scroll_delta = egui::Vec2::ZERO;
|
||||
}
|
||||
});
|
||||
if wheel {
|
||||
ui.data_mut(|d| d.insert_temp(notch_id, notches));
|
||||
}
|
||||
if steps == 0.0 {
|
||||
if steps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut v = read(buf)?;
|
||||
for (mods, size, n) in steps {
|
||||
v = if mods == egui::Modifiers::SHIFT { v + size * n } else { step_to_multiple(v, size, n) };
|
||||
}
|
||||
// Rounded as fields show it.
|
||||
let v = round_to(read(buf)? + steps, STEP_DECIMALS);
|
||||
let v = round_to(v, STEP_DECIMALS);
|
||||
*buf = show(v);
|
||||
ui.data_mut(|d| d.insert_temp(resp.id, buf.clone()));
|
||||
select_all(ui, resp.id, buf);
|
||||
@@ -2554,6 +2580,41 @@ mod spinner_modifier_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod step_to_multiple_tests {
|
||||
use super::*;
|
||||
|
||||
/// #2881: the first step goes to the next multiple of its size in its direction, and each further
|
||||
/// step adds or takes one size.
|
||||
#[test]
|
||||
fn a_step_goes_to_the_next_multiple_of_its_size() {
|
||||
for (v, size, n, want) in [
|
||||
(5.1, 1.0, 1.0, 6.0),
|
||||
(5.1, 1.0, -1.0, 5.0),
|
||||
(5.0, 1.0, 1.0, 6.0),
|
||||
(5.0, 1.0, -1.0, 4.0),
|
||||
(-2.5, 1.0, 1.0, -2.0),
|
||||
(-2.5, 1.0, -1.0, -3.0),
|
||||
(24.8, 1.0, 2.0, 26.0),
|
||||
(24.8, 1.0, -2.0, 23.0),
|
||||
(5.13, 0.1, 1.0, 5.2),
|
||||
(5.13, 0.1, -1.0, 5.1),
|
||||
(5.1, 1.0, 0.0, 5.1),
|
||||
] {
|
||||
assert!((step_to_multiple(v, size, n) - want).abs() < 1e-9, "{v} by {size} × {n}: {}", step_to_multiple(v, size, n));
|
||||
}
|
||||
}
|
||||
|
||||
/// A value a float error away from a multiple moves a whole step from it: 0.7 is 6.999… tenths.
|
||||
#[test]
|
||||
fn a_value_next_to_a_multiple_moves_a_whole_step() {
|
||||
assert!((step_to_multiple(0.7, 0.1, 1.0) - 0.8).abs() < 1e-9);
|
||||
assert!((step_to_multiple(0.3, 0.1, -1.0) - 0.2).abs() < 1e-9);
|
||||
assert!((step_to_multiple(5.000_000_1, 1.0, 1.0) - 6.0).abs() < 1e-9);
|
||||
assert!((step_to_multiple(4.999_999_9, 1.0, -1.0) - 4.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod menu_room_tests {
|
||||
use super::*;
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user