Merge main into feat/plugin-importers

This commit is contained in:
dotCooCoo
2026-11-08 09:41:06 -08:00
128 changed files with 5752 additions and 255 deletions
+12
View File
@@ -368,6 +368,18 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `crates/engine/tests/fixtures/jpeg2000/checker.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `3de9c2bb5b8aaec241d939b795002a68eb3d50e41d51c43368000804ad3de057` |
| `crates/engine/tests/fixtures/jpeg2000/checker-raw.j2k` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `76fa51ab8ccbc2534098b26b8e7fe16dd886e2b6298ba52d06a6b1bde9b61d9e` |
| `crates/engine/tests/fixtures/jpeg2000/checker-300ppi.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `1b1aeed8975d3cdc832da6ecf5855852d07d0c490105121e61a8aefb31af009b` |
| `crates/engine/tests/fixtures/heif/quadrants.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 red, green, blue and white quadrants encoded with libheif's `heif-enc` 1.23.2 (x265); SHA-256 `122d14963fe899daa736cd216902cf4fc8f2192b3a0ba81f94004adcd71a0ff5` |
| `crates/engine/tests/fixtures/heif/quadrants-alpha.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with a transparent right half, encoded with `heif-enc` 1.23.2; SHA-256 `55b5a537401ead742714c3a1bc526559ec8c1ba97cdb7f43ac63f4037167475b` |
| `crates/engine/tests/fixtures/heif/quadrants-10bit.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants at 10 bits, encoded with `heif-enc` 1.23.2; SHA-256 `11014848c6a838e5799d87e034eff1b06b816712ed70ba451bd582d72741774f` |
| `crates/engine/tests/fixtures/heif/quadrants-grid.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 128 × 128 quadrants as a 2 × 2 grid of 64 × 64 tiles, encoded with `heif-enc` 1.23.2; SHA-256 `5802f8715110954eb9c58d4bdcc10d1200426a48cf726f1b88dfb60da825ba7a` |
| `crates/engine/tests/fixtures/heif/quadrants-300ppi.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with EXIF resolution tags of 300 ppi, encoded with `heif-enc` 1.23.2; SHA-256 `6a344a6de04fc5fceaee46b6e0028b12478c90bdcb6f6f14ed8c86a44cb203d7` |
| `crates/engine/tests/fixtures/heif/wide-rotated.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 16 red and blue halves stored with a quarter-turn rotation, encoded with `heif-enc` 1.23.2; SHA-256 `e3a2c3a9beb4066c13d6deb8e1311805c6aac485fde8784f1ee317dd95b89973` |
| `crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the rotated halves with EXIF resolution tags of 300 ppi across and 150 ppi down, encoded with `heif-enc` 1.23.2; SHA-256 `79ad7945cd2c1ea9d3e70ac9b93d9a162fd8ce37541a0f3de44e12f04745921d` |
| `crates/engine/tests/fixtures/heif/tones.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants of four mid tones, encoded with `heif-enc` 1.23.2; SHA-256 `29ed7d75fbfbc4c298eb2da3a6b62625a150cd6bfe5b7ff1d847437501b1b4b1` |
| `crates/engine/tests/fixtures/heif/tones-p3-nclx.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones labeled Display P3 in an `nclx` color box, encoded with `heif-enc` 1.23.2; SHA-256 `baeb32ffa58ac1ac29a00c9e388b9281f4acb4695c4681e4933d0ad061447101` |
| `crates/engine/tests/fixtures/heif/tones-p3-icc.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile (`vectorcraft_color::cms::icc_bytes`), encoded with `heif-enc` 1.23.2; SHA-256 `472e1a123b9515926a8985a04c3f680387e8c410dbf8f7ff0cfb8cb99c89e93b` |
| `crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile as their only color box, encoded with `heif-enc` 1.23.2; SHA-256 `2654fdd21e56641107db20b3f4d0a66a86420ac040d42ddf55e20f7e789d5c55` |
| `crates/engine/tests/fixtures/heif/quadrants-grid16.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each cropped from a 64 × 64 picture, encoded with `heif-enc` 1.23.2; SHA-256 `bd114ee66cdac10142729dc9dddfa1cd96993a5cefa54c2f25b65496652f007f` |
## Assets embedded by the PDF backend
Generated
+11
View File
@@ -2286,6 +2286,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heic-rs"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
dependencies = [
"rayon",
]
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -6246,10 +6255,12 @@ dependencies = [
"crc32fast",
"flate2",
"hayro-jpeg2000",
"heic-rs",
"image",
"kurbo 0.13.1",
"log",
"proptest",
"rayon",
"roxmltree 0.21.2",
"rustix 1.1.6",
"ruzstd",
+4 -1
View File
@@ -14,7 +14,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -34,6 +34,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| | Value | Kind |
|---|---|---|
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) |
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
@@ -134,6 +135,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged.
- 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged.
- 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged.
- 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged.
@@ -223,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
| 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. |
| 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. |
+1 -1
View File
@@ -16,7 +16,7 @@ workspace = true
[dependencies]
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] }
vectorcraft-format = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-svg = { workspace = true }
+1 -1
View File
@@ -14,7 +14,7 @@ name = "vectorcraft-web"
path = "src/main.rs"
[dependencies]
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] }
vectorcraft-ui-egui = { workspace = true }
# The page command API (storytold/craft-libs#413): the request plumbing behind window.vectorcraft.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
+1 -1
View File
@@ -15,7 +15,7 @@ wgpu = ["eframe/wgpu", "dep:winsafe"]
accessibility = ["eframe/accesskit"]
[dependencies]
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-text = { workspace = true }
# Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app)
+1
View File
@@ -467,6 +467,7 @@ fn main() -> std::process::ExitCode {
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
app.custom_titlebar = custom_titlebar;
app.desktop_frame = !custom_titlebar;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx).with_automation_roots(roots.clone());
+6 -3
View File
@@ -233,13 +233,16 @@ impl ParaList {
/// The list that the Type tool starts when `typed` and a space are typed at the start of a
/// paragraph: "1." or "1)" starts a numbered list in 1, 2, 3, "A." or "A)" starts one in A, B,
/// C, and "*", "#", ">" or "+" starts a bulleted list with the filled dot. Any other text starts
/// none.
/// C, and "*" starts a bulleted list with the filled dot, "+" one with the hollow circle, "#"
/// one with the filled square and ">" one with the dash. Any other text starts none.
pub fn typed(typed: &str) -> Option<ParaList> {
match typed {
"1." | "1)" => Some(Self::numbered(NumberStyle::Arabic)),
"A." | "A)" => Some(Self::numbered(NumberStyle::UpperLetters)),
"*" | "#" | ">" | "+" => Some(Self::bulleted(Bullet::Dot)),
"*" => Some(Self::bulleted(Bullet::Dot)),
"+" => Some(Self::bulleted(Bullet::Circle)),
"#" => Some(Self::bulleted(Bullet::Square)),
">" => Some(Self::bulleted(Bullet::Dash)),
_ => None,
}
}
+8 -7
View File
@@ -212,22 +212,23 @@ fn number_styles_write_their_numbers_and_roll_over() {
}
/// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph
/// start a numbered list, and "*", "#", ">" and "+" and a space start a bulleted list with the
/// filled dot. Other text, the same characters later in a paragraph or without the space, and
/// a paragraph already in a list start nothing.
/// start a numbered list, and "*", "+", "#" and ">" and a space start a bulleted list with the
/// filled dot, the hollow circle, the filled square and the dash. Other text, the same characters
/// later in a paragraph or without the space, and a paragraph already in a list start nothing.
#[test]
fn typed_list_starts_are_found_at_a_paragraphs_start() {
let (arabic, letters, dot) =
(ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot));
let typed = ["1.", "1)", "A.", "A)", "*", "#", ">", "+"].map(ParaList::typed);
assert_eq!(typed, [arabic, arabic, letters, letters, dot, dot, dot, dot].map(Some));
let (circle, square, dash) = (ParaList::bulleted(Bullet::Circle), ParaList::bulleted(Bullet::Square), ParaList::bulleted(Bullet::Dash));
let typed = ["1.", "1)", "A.", "A)", "*", "+", "#", ">"].map(ParaList::typed);
assert_eq!(typed, [arabic, arabic, letters, letters, dot, circle, square, dash].map(Some));
for other in ["", "2.", "a.", "a)", "I.", "1", "A", "1:", "-", "•", "1.)", " 1.", "*1", "1."] {
assert_eq!(ParaList::typed(other), None, "{other:?}");
}
let mut t = TextObject::point(Point::ZERO, "Fruit\n1. Apples\nA) x * y\n+ ", CharStyle::default());
assert_eq!(t.typed_list_at(9), Some((6, arabic)));
assert_eq!(t.typed_list_at(19), Some((16, letters)));
assert_eq!(t.typed_list_at(27), Some((25, dot)));
assert_eq!(t.typed_list_at(27), Some((25, circle)));
// Not at the paragraph's start, not right after the space, or past the end.
for at in [0, 5, 8, 10, 15, 23, 25, 26, 99] {
assert_eq!(t.typed_list_at(at), None, "{at}");
@@ -245,7 +246,7 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() {
#[test]
fn typed_list_starts_skip_a_continued_paragraph() {
let dot = ParaList::bulleted(Bullet::Dot);
let mut t = TextObject::point(Point::ZERO, "* Pears\n# ", CharStyle::default());
let mut t = TextObject::point(Point::ZERO, "* Pears\n* ", CharStyle::default());
assert_eq!(t.typed_list_at(2), Some((0, dot)));
t.first_para_continues = true;
assert_eq!(t.typed_list_at(2), None, "a continued paragraph starts no list");
+12 -1
View File
@@ -9,10 +9,14 @@ description = "VectorCraft engine: sessions, history, selection, the command reg
workspace = true
[features]
default = ["bundled-fonts"]
default = ["bundled-fonts", "heif"]
# The fonts vectorcraft-text bundles (see its `bundled-fonts`). Embedders that supply every font at
# run time (`vectorcraft_text::FontDb::add_font`) build with `default-features = false`.
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
# HEIC and HEIF images open and place, decoded by heic-rs (an HEVC decoder). A build without this
# feature still recognizes the files and refuses them with a message. The apps list it beside
# `bundled-fonts`.
heif = ["dep:heic-rs", "dep:rayon"]
[dependencies]
# Which paths stored in documents may be opened.
@@ -37,6 +41,8 @@ vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] }
# Pure Rust, no unsafe; pinned at the version photocraft-heif uses.
heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], optional = true }
flate2 = { workspace = true }
chrono = { workspace = true }
# The XML parts of Word documents placed as type (place/docx.rs).
@@ -54,6 +60,11 @@ log = { workspace = true }
[target.'cfg(unix)'.dependencies]
rustix = { workspace = true, features = ["std", "process"] }
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"], optional = true }
rayon = { version = "1.12", optional = true }
[dev-dependencies]
vectorcraft-affinity = { workspace = true, features = ["synth"] }
crc32fast = { workspace = true }
+2 -2
View File
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make and Expand",
["Object", "Image Trace"],
None,
"{…as imageTrace.make} same as imageTrace.make, then expand → {id, paths, anchors, colors}",
"{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, true)
),
@@ -171,7 +171,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand",
["Object", "Image Trace"],
None,
"{id?, ids?: [id…] (instead of id)} keep only the traced paths (drop the source image) → {ids}",
"{id?, ids?: [id…] (instead of id), byColor?: false (each color's paths in a group of their own, named by the swatch the color links to or as #rrggbb, gradient fills in one group named Gradients; the groups stack by their lowest path and keep their paths' order; a color whose paths overlap another color's from above and below gets a group for each part, so the art draws the same; refused for a trace made with autoGrouping and for a trace with a live Pathfinder effect)} keep only the traced paths (drop the source image) → {ids}",
has_selection_or_ids,
trace_expand
),
+346 -1
View File
@@ -4,10 +4,16 @@ use vectorcraft_color::Swatch;
use vectorcraft_doc::ImageObject;
use super::{
Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point,
Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, Rect,
Result, Session, StrokeLayer, TRACE_NAME, TraceView, Value, bad, fill_only, ids_json, json, root_ids, shape_node, str_param, switch_or, tr,
};
#[cfg(test)]
thread_local! {
static BOX_TESTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
static CANDIDATE_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
// ---------- Image Trace ----------
fn is_trace_group(n: &Node) -> bool {
@@ -16,6 +22,8 @@ fn is_trace_group(n: &Node) -> bool {
&& n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_))))
}
const LIVE_PATHFINDER_BY_COLOR: &str = "byColor can't regroup a trace with a live Pathfinder effect: expand without byColor";
/// The `view` param ([`TraceView`] id), if given.
fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) };
@@ -368,6 +376,156 @@ fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec
roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect()
}
/// The `byColor` param of an Expand (false when not given).
fn by_color_param(cmd: &str, p: &Value) -> Result<bool> {
match p.get("byColor").filter(|v| !v.is_null()) {
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "byColor must be true or false")),
None => Ok(false),
}
}
/// Expand by color: an expanded trace's `items` (bottom-most first) in a group for each paint, a
/// stroked line counted by its stroke. A solid color's group is named by the swatch it links to (a
/// tint with its percentage) or as `#rrggbb`, and gradient fills share one group, "Gradients". The
/// groups stack in the order of the lowest item in each, and each keeps its items' order. When the
/// paths meet, an item joins its paint's last group only if no item of a later group meets it
/// (by the boxes of everything they paint, effects included, within one point); otherwise it
/// starts another group of that paint, which keeps the stacking order of those items. Items
/// farther apart can still share antialiased pixels at small view or raster-export scales, so
/// their edge pixels may change. A group among `items` (Auto Grouping) is refused.
fn color_groups(cmd: &str, d: &mut Document, items: Vec<Arc<Node>>) -> Result<Vec<Arc<Node>>> {
use std::collections::{BTreeMap, HashMap};
use std::ops::Bound::{Excluded, Unbounded};
use vectorcraft_color::tint_percent;
// Grouped by the paint: unlinked (its #rrggbb), linked (the swatch and its exact tint), a
// gradient, or another paint (its label). The name is apart from it: a swatch may be named as
// a color's `#rrggbb` or as "Gradients".
type PaintKey = (u8, String, u32);
struct Run {
name: String,
members: Vec<Arc<Node>>,
}
// Boxes closer than a point count as overlapping: their antialiased edges can share pixels.
let meet = |a: &Rect, b: &Rect| {
#[cfg(test)]
BOX_TESTS.with(|n| n.set(n.get() + 1));
let b = b.inflate(1.0, 1.0);
a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
};
// Choose a cell size from the painted extent and path count. A box over more than eight cells
// across but at most eight down (a stripe) is indexed by its rows, one the other way round by
// its columns, and one over more than eight both ways stays in a separate list, keeping the
// index bounded even when a path covers most of the document.
let items: Vec<_> = items
.into_iter()
.map(|item| {
let bounds = vectorcraft_render::painted_bounds(d, &item);
(item, bounds)
})
.collect();
let extent = items.iter().filter_map(|(_, b)| *b).reduce(|a, b| a.union(b));
let cell_size = extent.map_or(1.0, |r| ((r.x1 - r.x0).abs() * (r.y1 - r.y0).abs() / items.len().max(1) as f64).sqrt().max(1.0));
// The first and last column and row of the cells `r` covers; `None` when it isn't finite.
let span = |r: Rect| -> Option<(i64, i64, i64, i64)> {
let coord = |v: f64| (v / cell_size).floor() as i64;
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then(|| (coord(r.x0), coord(r.y0), coord(r.x1), coord(r.y1)))
};
let count = |first: i64, last: i64| i128::from(last) - i128::from(first) + 1;
// A box over more cells than this is checked against every box.
const MAX_CELLS: i128 = 1024;
let mut groups: Vec<Run> = vec![];
let mut latest: HashMap<PaintKey, usize> = HashMap::new();
// An item can join an earlier group, so insertion order is not group-index order. Index
// boxes by group to skip older groups without scanning their entries.
let mut spatial: HashMap<(i64, i64), BTreeMap<usize, Vec<Rect>>> = HashMap::new();
let mut rows: HashMap<i64, BTreeMap<usize, Vec<Rect>>> = HashMap::new();
let mut columns: HashMap<i64, BTreeMap<usize, Vec<Rect>>> = HashMap::new();
let mut large: BTreeMap<usize, Vec<Rect>> = BTreeMap::new();
let mut all_boxes: BTreeMap<usize, Vec<Rect>> = BTreeMap::new();
for (item, bounds) in items {
if matches!(item.kind, NodeKind::Group { .. }) {
return Err(bad(cmd, "byColor can't split Auto Grouping groups: trace without Auto Grouping"));
}
let paint = match item.appearance.fill_paint() {
Paint::None => item.appearance.stroke_paint(),
fill => fill,
};
let (key, name) = match &paint {
Paint::Solid { swatch: Some(n), tint, .. } => {
let percent = tint_percent(*tint);
let name = if *tint < 1.0 { format!("{n} {percent}%") } else { n.clone() };
((1, n.clone(), tint.to_bits()), name)
}
Paint::Solid { color, .. } => ((0, color.to_hex(), 0), color.to_hex()),
Paint::Gradient(_) => ((2, String::new(), 0), "Gradients".to_string()),
other => ((3, other.label(), 0), other.label()),
};
// Joining an earlier group moves the item under every later group's items.
let hidden_by_later = |at: usize| {
bounds.is_some_and(|b| {
let overlaps_later = |boxes: &BTreeMap<usize, Vec<Rect>>| {
boxes.range((Excluded(at), Unbounded)).rev().any(|(_, group_boxes)| {
group_boxes.iter().rev().any(|box_bounds| {
#[cfg(test)]
CANDIDATE_VISITS.with(|n| n.set(n.get() + 1));
meet(box_bounds, &b)
})
})
};
match span(b) {
Some((x0, y0, x1, y1)) if count(x0, x1) * count(y0, y1) <= MAX_CELLS => {
(y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))).filter_map(|c| spatial.get(&c)).any(overlaps_later)
|| (y0..=y1).filter_map(|y| rows.get(&y)).any(overlaps_later)
|| (x0..=x1).filter_map(|x| columns.get(&x)).any(overlaps_later)
|| overlaps_later(&large)
}
_ => overlaps_later(&all_boxes),
}
})
};
let at = latest.get(&key).copied().filter(|&at| !hidden_by_later(at));
let mut started = Run { name, members: vec![] };
let joins = at.and_then(|at| groups.get_mut(at));
let new = joins.is_none();
let g = joins.unwrap_or(&mut started);
g.members.push(item);
if new {
groups.push(started);
latest.insert(key, groups.len() - 1);
}
if let Some(b) = bounds {
let index = if new { groups.len() - 1 } else { at.unwrap_or_default() };
all_boxes.entry(index).or_default().push(b);
match span(b.inflate(1.0, 1.0)) {
Some((x0, y0, x1, y1)) if count(x0, x1) <= 8 && count(y0, y1) <= 8 => {
for place in (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))) {
spatial.entry(place).or_default().entry(index).or_default().push(b);
}
}
Some((_, y0, _, y1)) if count(y0, y1) <= 8 => {
for y in y0..=y1 {
rows.entry(y).or_default().entry(index).or_default().push(b);
}
}
Some((x0, _, x1, _)) if count(x0, x1) <= 8 => {
for x in x0..=x1 {
columns.entry(x).or_default().entry(index).or_default().push(b);
}
}
_ => large.entry(index).or_default().push(b),
}
}
}
Ok(groups
.into_iter()
.map(|run| {
let mut g = Node::group(d.alloc_id(), run.members);
g.name = Some(run.name);
Arc::new(g)
})
.collect())
}
/// What a trace depends on: the object traced (an image, or an Image Trace object traced again),
/// its image, and the settings it is traced with. The tracer makes the same paths of the same
/// inputs, so a trace made away from the command ([`TraceRun`]) stands in for the command's own.
@@ -398,6 +556,10 @@ struct Prepared {
fn prepare(s: &Session, c: &str, p: &Value) -> Result<Prepared> {
let mut params = trace_params(s, c, p)?;
let view = view_param(c, p)?;
let by_color = c == "imageTrace.makeAndExpand" && by_color_param(c, p)?;
if by_color && params.auto_grouping {
return Err(bad(c, "byColor can't split Auto Grouping groups: trace without Auto Grouping"));
}
let params_json = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
// The preset's name if the parameters are exactly that preset's or its enhanced version's (a
// saved preset's under its saved name), else "Custom".
@@ -428,6 +590,9 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result<Prepared> {
})
.ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))?
};
if by_color && s.doc()?.doc.node(target).is_some_and(vectorcraft_render::effects::has_pathfinder) {
return Err(bad(c, LIVE_PATHFINDER_BY_COLOR));
}
let blob = s.doc()?.doc.images.get(&image.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", image.key)))?;
let bytes = Arc::clone(&blob.bytes);
let library = trace_library(c, s, &mut params)?;
@@ -533,6 +698,7 @@ impl TraceRun {
pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" };
let Prepared { inputs, trace, view, params_json, preset_label, library, bytes } = prepare(s, c, p)?;
let group_by_color = expand && by_color_param(c, p)?;
// A saved preset's settings join the journal entry, so a replay traces as this run did after
// the preset is changed, renamed or deleted.
let named = str_param(p, "preset").unwrap_or("Default");
@@ -608,6 +774,9 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
traced.push(n);
}
children.extend(grouped(d, traced, &res.parents));
if group_by_color {
children = color_groups(c, d, children)?;
}
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
@@ -706,7 +875,20 @@ pub(super) fn trace_release(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "imageTrace.expand";
let by_color = by_color_param(C, p)?;
let targets = trace_targets(s, p)?;
if by_color {
// A trace made with Auto Grouping is refused even when none of its paths were grouped.
let d = &s.doc()?.doc;
let grouped = |n: &Node| n.extras().trace.as_deref().and_then(|t| t.pointer("/params/autoGrouping")).and_then(Value::as_bool) == Some(true);
if targets.iter().any(|g| d.node(*g).is_some_and(grouped)) {
return Err(bad(C, "byColor can't split Auto Grouping groups: trace without Auto Grouping"));
}
if targets.iter().any(|g| d.node(*g).is_some_and(vectorcraft_render::effects::has_pathfinder)) {
return Err(bad(C, LIVE_PATHFINDER_BY_COLOR));
}
}
s.edit("Expand Image Trace", |d, sel| {
for g in &targets {
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
@@ -715,6 +897,15 @@ pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(ch) = n.children_mut() {
ch.retain(|c| !matches!(c.kind, NodeKind::Image(_)));
}
if by_color {
let items = d.node(*g).and_then(Node::children).cloned().unwrap_or_default();
// The paths may have been moved or reshaped since the trace was made, so any trace's
// paths are taken to overlap.
let groups = color_groups(C, d, items)?;
if let Some(ch) = d.node_mut(*g).and_then(Node::children_mut) {
*ch = groups;
}
}
}
sel.set(targets.iter().copied());
Ok(())
@@ -722,6 +913,160 @@ pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&targets) }))
}
#[cfg(test)]
mod color_group_tests {
use super::*;
use vectorcraft_geom::{PathData, SubPath};
fn area(d: &mut Document, x0: f64, y0: f64, x1: f64, y1: f64, color: Color) -> Arc<Node> {
let path = PathData::single(SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true));
Arc::new(Node::path(d.alloc_id(), path, fill_only(Paint::solid(color))))
}
#[test]
fn abutting_color_groups_preserve_order_at_shared_edges() {
let mut d = Document::new(100.0, 100.0);
let a = Color::rgb(1.0, 0.0, 0.0);
let b = Color::rgb(0.0, 0.0, 1.0);
let items = vec![area(&mut d, 0.0, 0.0, 20.0, 20.0, a), area(&mut d, 20.0, 0.0, 40.0, 20.0, b), area(&mut d, 40.0, 0.0, 60.0, 20.0, a)];
let original: Vec<_> = items.iter().map(|n| n.id).collect();
let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap();
let actual: Vec<_> = groups.iter().flat_map(|g| g.children().unwrap().iter().map(|n| n.id)).collect();
assert_eq!(actual, original, "shared-edge paths changed stacking order");
}
#[test]
fn dense_color_groups_use_bounded_box_tests() {
let mut d = Document::new(200.0, 200.0);
let a = Color::rgb(1.0, 0.0, 0.0);
let b = Color::rgb(0.0, 0.0, 1.0);
let items = (0..4000)
.map(|i| {
if i % 2 == 0 {
let x = 100.0 + (i % 40) as f64 * 0.01;
area(&mut d, x, 100.0, x + 0.1, 100.1, a)
} else {
let x = if i % 4 == 1 { 0.0 } else { 198.0 };
area(&mut d, x, x, x + 2.0, x + 2.0, b)
}
})
.collect();
BOX_TESTS.with(|n| n.set(0));
let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap();
let tests = BOX_TESTS.with(std::cell::Cell::get);
assert_eq!(groups.iter().map(|g| g.children().map_or(0, Vec::len)).sum::<usize>(), 4000);
assert!(tests < 4000 * 32, "{tests} box tests for 4000 paths");
}
#[test]
fn overlapping_alternating_colors_visit_bounded_candidates() {
const COUNT: usize = 3000;
let mut d = Document::new(100.0, 100.0);
let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)];
let items = (0..COUNT).map(|i| area(&mut d, 10.0, 10.0, 20.0, 20.0, colors[i % 2])).collect();
CANDIDATE_VISITS.with(|n| n.set(0));
let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap();
let visits = CANDIDATE_VISITS.with(std::cell::Cell::get);
assert_eq!(groups.len(), COUNT);
assert!(visits < COUNT * 16, "{visits} candidate visits for {COUNT} paths");
}
#[test]
fn separate_stripes_of_alternating_colors_visit_bounded_candidates() {
const COUNT: usize = 2000;
let mut d = Document::new(1000.0, 4000.0);
let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)];
for tall in [false, true] {
let items = (0..COUNT)
.map(|i| {
let at = i as f64 * 2.0;
if tall { area(&mut d, at, 0.0, at + 0.5, 1000.0, colors[i % 2]) } else { area(&mut d, 0.0, at, 1000.0, at + 0.5, colors[i % 2]) }
})
.collect();
CANDIDATE_VISITS.with(|n| n.set(0));
let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap();
let visits = CANDIDATE_VISITS.with(std::cell::Cell::get);
assert_eq!(groups.len(), 2, "tall {tall}");
assert!(visits < COUNT * 32, "tall {tall}: {visits} candidate visits for {COUNT} stripes");
}
}
#[test]
fn spatial_color_groups_match_simple_scan() {
let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)];
for layout in 0..6 {
let mut d = Document::new(200.0, 200.0);
let items: Vec<_> = (0..90)
.map(|i| {
let x = match layout {
0 => i as f64 * 2.0,
1 => (i % 10) as f64 * 5.0,
2 => (i % 7) as f64 * 3.0,
3 => (i % 13) as f64 * 9.0,
4 => (i % 4) as f64 * 10.0,
_ => i as f64 * 1.5,
};
let y = match layout {
0 => 0.0,
1 => (i / 10) as f64 * 5.0,
2 => (i / 7) as f64 * 3.0,
3 => (i / 13) as f64 * 9.0,
4 => i as f64 * 1.5,
_ => (i % 4) as f64 * 10.0,
};
// Layouts 4 and 5: wide and tall stripes, some meeting their neighbors.
let (w, h) = match layout {
4 => (150.0, 1.0),
5 => (1.0, 150.0),
_ => (3.0, 3.0),
};
area(&mut d, x, y, x + w, y + h, colors[i % 3])
})
.collect();
let mut expected: Vec<(String, Vec<(NodeId, Rect)>)> = vec![];
for item in &items {
let key = item.appearance.fill_paint().color().unwrap().to_hex();
let bounds = vectorcraft_render::painted_bounds(&d, item).unwrap();
let previous = expected.iter().rposition(|(k, _)| *k == key);
let can_join = previous.filter(|&at| {
!expected.iter().skip(at + 1).any(|(_, boxes)| {
boxes.iter().any(|(_, m)| {
let b = bounds.inflate(1.0, 1.0);
m.x0 < b.x1 && b.x0 < m.x1 && m.y0 < b.y1 && b.y0 < m.y1
})
})
});
match can_join.and_then(|at| expected.get_mut(at)) {
Some((_, members)) => members.push((item.id, bounds)),
None => expected.push((key, vec![(item.id, bounds)])),
}
}
let actual = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap();
let actual: Vec<Vec<_>> = actual.iter().map(|g| g.children().unwrap().iter().map(|n| n.id).collect()).collect();
let expected: Vec<Vec<_>> = expected.into_iter().map(|(_, members)| members.into_iter().map(|(id, _)| id).collect()).collect();
assert_eq!(actual, expected, "layout {layout}");
}
}
#[test]
fn an_abutting_gradient_stays_above_the_solid_area_it_borders() {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops.clone()))));
let mut d = Document::new(100.0, 100.0);
let mut area = |x0: f64, x1: f64, paint: Paint| {
let path =
PathData::single(SubPath::polyline(&[Point::new(x0, 0.0), Point::new(x1, 0.0), Point::new(x1, 60.0), Point::new(x0, 60.0)], true));
Arc::new(Node::path(d.alloc_id(), path, fill_only(paint)))
};
// A gradient, a solid area beside it and a second gradient beside that, bottom to top.
let items = vec![area(0.0, 50.0, ramp()), area(50.0, 80.0, Paint::solid(Color::rgb(0.2, 0.6, 0.2))), area(80.0, 100.0, ramp())];
let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap();
let names: Vec<_> = groups.iter().map(|g| g.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["Gradients", "#339933", "Gradients"]);
}
}
#[cfg(test)]
mod trace_limit_tests {
use super::{MAX_TRACE_ANCHORS, trace_warnings};
+1 -1
View File
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move in Perspective",
[],
None,
"{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction; Transform Again repeats it",
"{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction, and moves every object on that plane, at any depth, by the same plane offset; Transform Again repeats it",
has_doc,
persp_move
),
@@ -285,7 +285,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
let constrain = switch_or(p, "constrain", false, C)?;
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
check_switches(p, &["snap"], C)?;
// Snap to Grid: a correction for the objects on the first one's plane and depth.
// Shift: the constrained end point, read on the first object's plane and depth.
let snap_to = if constrain && !perp {
let doc = &s.doc()?.doc;
let g = grid_of(doc);
@@ -300,11 +300,20 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
} else {
to
};
let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, snap_to)) };
let snapped = move |pl: Plane, depth: f64, raw: vectorcraft_geom::Vec2| {
// With Snap to Grid, every object on the first one's plane, at any depth, moves by the first
// one's plane-space offset and its correction, so the objects keep their spacing on the plane.
let fix = if perp {
None
} else {
let doc = &s.doc()?.doc;
crate::cmd::perspgrid::snap_fix(doc, &ids, p, (from, snap_to))
.and_then(|(plane, depth, correction)| Some((plane, grid_of(doc).plane_delta(plane, depth, from, to)?, correction)))
};
let snapped = move |pl: Plane, raw: vectorcraft_geom::Vec2| {
let raw = fix.filter(|f| f.0 == pl).map_or(raw, |f| f.1);
let (direction, dv) =
if constrain { vectorcraft_tools::distort::perspective::constrain_move_delta(raw) } else { (vectorcraft_geom::Vec2::ZERO, raw) };
dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| {
dv + fix.filter(|f| f.0 == pl).map_or(vectorcraft_geom::Vec2::ZERO, |f| {
if !constrain {
return f.2;
}
@@ -325,7 +334,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?;
Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth))
} else {
let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?);
let dv = snapped(pl, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?);
Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth))
}
};
@@ -336,7 +345,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
if perp {
g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9)
} else {
g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
}
};
let first_still = if copy { None } else { unmoved(s, &ids, still)? };
@@ -350,9 +359,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
let again = match perp {
_ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})),
true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})),
false => {
g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy}))
}
false => g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, dv))), "copy": copy})),
};
if let Some(a) = again {
record_again(s, a)?;
+224 -18
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Info",
["Window", "Document Info"],
None,
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}",
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}",
has_doc,
info
),
@@ -130,13 +130,15 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S
}
/// The Document Info categories of `d` (only `category` when given) from `info` (what
/// `document.info` reports), its fonts, its image objects and the rows of its Repeat objects.
/// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the
/// rows of its objects filled or stroked with a pattern.
fn sections(
d: &Document,
info: &Value,
fonts: &BTreeSet<super::fonts::UsedFont>,
images: &[ImageRow],
repeats: &[(String, String)],
pattern_objects: &[(String, String)],
category: Option<&str>,
) -> Vec<Section> {
let names = |k: &str| -> Vec<(String, String)> {
@@ -180,7 +182,7 @@ fn sections(
"graphicStyles" => names("graphicStyleNames"),
"brushes" => names("brushNames"),
"spotColors" => names("spotColors"),
"patterns" => names("patternNames"),
"patterns" => pattern_objects.to_vec(),
"gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(),
"symbols" => names("symbols"),
"fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(),
@@ -252,12 +254,67 @@ pub fn report(s: &mut Session, selection_only: bool) -> Result<String> {
Ok(v["text"].as_str().unwrap_or_default().to_string())
}
fn paint_kind(p: &Paint) -> Option<&'static str> {
match p {
Paint::Gradient(_) => Some("gradients"),
Paint::Pattern { .. } => Some("patterns"),
_ => None,
/// The type object `n` holds and the runs its layout draws, when it uses a pattern: the runs with a
/// glyph outline (list markers and ruby included) or an underline or strikethrough bar. Spaces and
/// line breaks draw no outline. A type object's characters carry their own fill and stroke, and the
/// type object's own fills and strokes paint on its characters, so only those runs paint. The type
/// is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the renderer lays it out.
fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet<usize>)> {
let NodeKind::Text(t) = &n.kind else { return None };
let is_pattern = |p: &Paint| matches!(p, Paint::Pattern { .. });
let own = n.appearance.items.iter().any(|i| match i {
vectorcraft_doc::AppearanceItem::Fill(f) => is_pattern(&f.paint),
vectorcraft_doc::AppearanceItem::Stroke(s) => is_pattern(&s.paint),
});
if !own && !t.runs.iter().any(|r| is_pattern(&r.style.fill) || is_pattern(&r.style.stroke)) {
return None;
}
let db = vectorcraft_text::FontDb::global();
let resolved = d.inline_resolved(t);
let lay = vectorcraft_text::layout(db, &resolved);
let mut painted: BTreeSet<usize> = lay.glyphs.iter().chain(&lay.ruby).filter(|g| !g.outline.elements().is_empty()).map(|g| g.run).collect();
painted.extend(vectorcraft_text::decorations(&lay, db, &resolved).into_iter().map(|(run, _)| run));
Some((t, painted))
}
/// The patterns `n`'s own fills and strokes paint, each once. A hidden fill or stroke and a stroke
/// with no width paint nothing, and neither does any fill or stroke of a guide (drawn in the guide
/// color), of a compound path's member (`members`: the compound path's own fills and strokes paint
/// it) or of type whose layout draws nothing (`painted_runs`). An image's fills and strokes paint
/// only where an effect gives them a shape.
fn own_patterns<'a>(
n: &'a Node,
members: &HashSet<*const Node>,
painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet<usize>)>,
) -> Vec<&'a str> {
if matches!(n.kind, NodeKind::Path { guide: true, .. })
|| members.contains(&std::ptr::from_ref(n))
|| painted_runs.is_some_and(|(_, r)| r.is_empty())
{
return vec![];
}
// The patterns an image's shaped fills and strokes paint (their effects can transform the
// pattern, so they are matched by name).
let shaped: Option<Vec<String>> = matches!(n.kind, NodeKind::Image(_)).then(|| {
vectorcraft_render::effects::image_paint(n)
.into_iter()
.filter_map(|(_, _, item)| match item.paint() {
Paint::Pattern { pattern, .. } => Some(pattern.clone()),
_ => None,
})
.collect()
});
let mut out: Vec<&str> = vec![];
for item in &n.appearance.items {
let Paint::Pattern { pattern, .. } = item.paint() else { continue };
let paints = item.visible()
&& !matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0)
&& shaped.as_ref().is_none_or(|s| s.contains(pattern));
if paints && !out.contains(&pattern.as_str()) {
out.push(pattern);
}
}
out
}
fn info(s: &mut Session, p: &Value) -> Result<Value> {
@@ -293,9 +350,19 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
// `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order.
let mut repeats: Vec<Value> = vec![];
let mut repeat_rows: Vec<(String, String)> = vec![];
// `pattern_objects` and `pattern_rows` hold the `patternObjects` entries and the rows of the objects filled or stroked with a pattern, in walk order.
let mut pattern_objects: Vec<Value> = vec![];
let mut pattern_rows: Vec<(String, String)> = vec![];
// The objects the walk has visited: a selection can hold a group and objects inside it, and each object counts once.
// The set holds each object's address, so two objects that share an id (as a damaged file can hold) count as two.
let mut seen: HashSet<*const Node> = HashSet::new();
// The members of the document's compound paths: the compound path's own fills and strokes paint them.
let mut members: HashSet<*const Node> = HashSet::new();
d.walk(|n| {
if let NodeKind::Compound { children, .. } = &n.kind {
members.extend(children.iter().map(|c| std::ptr::from_ref(&**c)));
}
});
for r in roots {
r.walk(&mut |n| {
if !seen.insert(std::ptr::from_ref(n)) {
@@ -345,16 +412,18 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
}
// The paint kinds this object's fills and strokes use, each once.
let mut kinds = BTreeSet::new();
// The patterns this object's fills and strokes use, each once.
let mut used_patterns: Vec<&str> = vec![];
let painted_runs = painted_runs(d, n);
for pattern in own_patterns(n, &members, painted_runs.as_ref()) {
patterns.insert(pattern.to_string());
used_patterns.push(pattern);
}
for item in &n.appearance.items {
let paint = match item {
vectorcraft_doc::AppearanceItem::Fill(f) => &f.paint,
vectorcraft_doc::AppearanceItem::Stroke(s) => &s.paint,
};
if let Some(k) = paint_kind(paint) {
kinds.insert(k);
}
if let Paint::Pattern { pattern, .. } = paint {
patterns.insert(pattern.clone());
let paint = item.paint();
// Objects counts an object with a pattern when Pattern Objects lists it, below.
if let Paint::Gradient(_) = paint {
kinds.insert("gradients");
}
if let Paint::Solid { swatch: Some(name), .. } = paint
&& d.swatch(name).is_some_and(|s| s.spot)
@@ -362,6 +431,28 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
spot.insert(name.clone());
}
}
// The characters' own paints: an inline graphic draws no outline and does not take its
// run's paint (a list marker set in its run does), and a stroke with no width paints
// nothing.
if let Some((t, painted)) = &painted_runs {
for run in painted.iter().filter_map(|i| t.runs.get(*i)) {
let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke);
for paint in std::iter::once(&run.style.fill).chain(stroke) {
if let Paint::Pattern { pattern, .. } = paint {
patterns.insert(pattern.clone());
if !used_patterns.contains(&pattern.as_str()) {
used_patterns.push(pattern);
}
}
}
}
}
if !used_patterns.is_empty() {
let name = n.display_name();
pattern_objects.push(json!({ "id": n.id.0, "name": name, "patterns": used_patterns }));
pattern_rows.push((name, used_patterns.join(", ")));
kinds.insert("patterns");
}
for k in kinds {
*counts.entry(k).or_default() += 1;
}
@@ -412,10 +503,11 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
"paragraphStyles": d.para_styles.len(),
"patterns": d.patterns.len(),
"patternNames": patterns,
"patternObjects": pattern_objects,
});
// The document's name: its file's, else its title.
out["document"]["name"] = json!(st.title());
let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, category);
let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, category);
if text {
return Ok(json!({ "text": report_text(&st.title(), selection_only, &sections) }));
}
@@ -634,6 +726,120 @@ mod tests {
assert_eq!(i["sections"][0]["rows"][0], json!(["<Grid Repeat>", "Grid Repeat, 500 rows × 500 columns, 125000 instances"]));
}
/// Document Info › Pattern Objects lists each object filled or stroked with a pattern once,
/// with the patterns it uses, one in a group and a type object whose characters use a pattern
/// included; a pattern no art uses adds no row, and Objects counts the listed objects. With
/// Selection Only it lists the patterned objects in the selection, and the report writes the
/// same rows.
#[test]
fn info_lists_the_pattern_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let square =
|s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
let tile = square(&mut s, 0.0);
s.execute("object.pattern.make", &json!({"ids": [tile], "name": "Dots", "edit": false})).unwrap();
let spare = square(&mut s, 20.0);
s.execute("object.pattern.make", &json!({"ids": [spare], "name": "Unused", "edit": false})).unwrap();
let (a, b, c) = (square(&mut s, 100.0), square(&mut s, 200.0), square(&mut s, 300.0));
s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Dots"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [a], "swatch": "Dots"})).unwrap();
s.execute("layer.setProps", &json!({"id": a, "name": "Both"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [b], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [b, c]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
// A type object whose characters are filled with the pattern.
let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [t], "swatch": "Dots"})).unwrap();
s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap();
// A hidden pattern fill and a pattern stroke with no width paint nothing, so neither object is listed.
let (d, e) = (square(&mut s, 100.0), square(&mut s, 200.0));
s.execute("paint.setStroke", &json!({"ids": [d], "none": true})).unwrap();
s.execute("paint.setFill", &json!({"ids": [d], "swatch": "Dots"})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [d], "index": 0, "visible": false})).unwrap();
s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [e], "swatch": "Dots"})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap();
// A type object with no characters paints nothing either.
let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Dots"})).unwrap();
// Its own fill (a copy of its characters' pattern fill) paints on its characters, so it paints nothing.
s.execute("appearance.addFill", &json!({"ids": [empty]})).unwrap();
// Nor does type of only spaces and a line break, or a guide made from a patterned path.
let blank = s.execute("text.create", &json!({"x": 0, "y": 320, "text": " \n"})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [blank], "swatch": "Dots"})).unwrap();
let guide = square(&mut s, 300.0);
s.execute("paint.setFill", &json!({"ids": [guide], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [guide]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
// Nor does a compound path's member's own pattern fill: the compound path's own fill paints it.
let (m1, m2) = (square(&mut s, 320.0), square(&mut s, 340.0));
s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap();
let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [compound], "color": "#000000"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [compound], "none": true})).unwrap();
let node = |s: &Session, id: &Value| s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().clone();
let (member, paint) = (node(&s, &compound).children().unwrap()[0].id, node(&s, &a).appearance);
// Nor does an image's pattern fill without an effect that gives it a shape.
let pic = square(&mut s, 360.0);
s.execute("select.set", &json!({"ids": [pic]})).unwrap();
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
let image = s.doc().unwrap().selection.objects[0];
s.edit("own paint", |d, _| {
d.node_mut(member).unwrap().appearance = paint.clone();
d.node_mut(image).unwrap().appearance = paint;
Ok(())
})
.unwrap();
// Underlined spaces draw a bar in the pattern, so that type object is listed.
let bar = s.execute("text.create", &json!({"x": 0, "y": 340, "text": " "})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [bar], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [bar]})).unwrap();
s.execute("text.setFormat", &json!({"underline": true})).unwrap();
s.execute("layer.setProps", &json!({"id": bar, "name": "Bar"})).unwrap();
// A list marker set in an inline graphic's run draws in that run's pattern, so that type object is listed.
s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 10, "height": 10})).unwrap();
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let listed = s.execute("text.create", &json!({"x": 0, "y": 360, "text": ""})).unwrap()["id"].clone();
s.execute("text.insertInline", &json!({"id": listed, "at": 0, "symbol": "Mark"})).unwrap();
s.execute("text.setFormat", &json!({"ids": [listed], "list": "bullet"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [listed], "swatch": "Dots"})).unwrap();
s.execute("layer.setProps", &json!({"id": listed, "name": "Listed"})).unwrap();
let i = s.execute("document.info", &json!({})).unwrap();
assert_eq!(
i["patternObjects"],
json!([
{"id": a, "name": "Both", "patterns": ["Dots"]},
{"id": b, "name": "<Rectangle>", "patterns": ["Dots"]},
{"id": t, "name": "Type", "patterns": ["Dots"]},
{"id": bar, "name": "Bar", "patterns": ["Dots"]},
{"id": listed, "name": "Listed", "patterns": ["Dots"]},
])
);
assert_eq!(i["patternNames"], json!(["Dots"]));
// Objects counts the objects that Pattern Objects lists.
assert_eq!(i["objects"]["patterns"].as_u64(), Some(5), "{}", i["objects"]);
s.execute("select.set", &json!({"ids": [d, e, empty, blank, guide, compound, image.0]})).unwrap();
let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap();
assert!(i["objects"]["patterns"].is_null() && i["patternObjects"] == json!([]), "{}", i["objects"]);
let i = s.execute("document.info", &json!({"category": "patterns"})).unwrap();
assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("patterns"), Some("Pattern Objects")));
assert_eq!(
i["sections"][0]["rows"],
json!([["Both", "Dots"], ["<Rectangle>", "Dots"], ["Type", "Dots"], ["Bar", "Dots"], ["Listed", "Dots"]])
);
s.execute("select.set", &json!({"ids": [b]})).unwrap();
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "patterns"})).unwrap();
assert_eq!(i["sections"][0]["rows"], json!([["<Rectangle>", "Dots"]]));
s.execute("select.set", &json!({"ids": [t]})).unwrap();
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "patterns"})).unwrap();
assert_eq!((i["sections"][0]["rows"].clone(), i["patternNames"].clone()), (json!([["Type", "Dots"]]), json!(["Dots"])));
let text = s.execute("document.info", &json!({"category": "patterns", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
assert!(text.contains("\nPATTERN OBJECTS\nBoth: Dots\n<Rectangle>: Dots\nType: Dots\nBar: Dots\nListed: Dots\n"), "{text}");
assert!(!text.contains("Unused"), "{text}");
}
/// Document Info › Objects counts an object with a gradient or a pattern on both its fill and
/// its stroke once, and with Selection Only a selection that holds a group and an object inside
/// it counts that object once.
+4
View File
@@ -36,6 +36,7 @@ mod importer;
mod legacy_ai;
mod load;
pub(crate) use load::{MAX_RASTER_ALLOC, size_note};
mod heifread;
mod jp2read;
mod metafile;
mod naming;
@@ -57,6 +58,7 @@ pub mod zip;
use serde_json::{Value, json};
pub use action_batch::batch_overwrites_one_file;
pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
pub(crate) use encode::{anti_alias, background, with_single_artboard};
@@ -206,6 +208,8 @@ mod tests_psd;
#[cfg(test)]
mod tests_psdread;
#[cfg(test)]
mod tests_heifread;
#[cfg(test)]
mod tests_jp2read;
#[cfg(test)]
+302 -1
View File
@@ -95,11 +95,93 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result<Value> {
};
match source {
"folder" => batch_folder(s, C, p, override_open, &run),
"datasets" => batch_datasets(s, C, &run),
"datasets" => {
let one_file = batch_overwrites_one_file(s, p);
let mut r = batch_datasets(s, C, &run)?;
if one_file {
r["warning"] = json!(ONE_FILE_WARNING);
}
Ok(r)
}
_ => Err(bad(C, "`source` must be `folder` or `datasets`")),
}
}
/// The `warning` of a Data Sets batch for which [`batch_overwrites_one_file`] holds.
#[cfg(not(target_arch = "wasm32"))]
const ONE_FILE_WARNING: &str = "each data set saves over the same file, which keeps only the last data set";
/// Whether an `action.batch` with params `p` plays on data sets of the open document in `s` and
/// writes each data set to the same file. Save and Close saves the open document over its own file
/// after every data set, unless a Save step that Override Save points into `destinationFolder`
/// first makes each data set's file the document's own. A step that the batch does not point into `destinationFolder` writes the
/// same file every time when it writes one: a Save, Save As, Save a Copy, Save as Template, Export,
/// Export Selection, PDF, EPS, DXF, Save for Web or Save for Office Documents step that gives a
/// `path`; a Save step without a `path` when the document has a file of its own (saved, and not
/// converted from an older version or restored by Data Recovery) and the step gives no `format` or
/// the document's own; an Export for Screens step whose `folder`, given or from its
/// `exportPreset`, names a folder, whatever the overrides.
/// Other steps return their data instead of writing a file.
pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool {
if p.get("source").and_then(Value::as_str) != Some("datasets") {
return false;
}
let folder = p.get("destinationFolder").and_then(Value::as_str).is_some();
let redirected = |key: &str| folder && p.get(key).and_then(Value::as_bool).unwrap_or(true);
let (save, export) = (redirected("overrideSave"), redirected("overrideExport"));
let steps = p.get("commands").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
if p.get("destination").and_then(Value::as_str) == Some("saveAndClose") && !saves_move_to_each_file(steps, save) {
return true;
}
// The format of the open document's own file, which a Save step without a `path` writes.
let own_format = s.active().filter(|st| st.path.is_some() && !st.converted && !st.recovered).map(|st| st.format);
let names = |v: Option<&Value>| v.and_then(Value::as_str).is_some_and(|s| !s.is_empty());
steps.iter().any(|c| {
let params = c.get("params").cloned().unwrap_or_else(|| Value::Object(Default::default()));
let path = names(params.get("path"));
let own_file = || match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
None => own_format.is_some(),
Some(f) => own_format.is_some_and(|own| super::format(f).is_some_and(|f| f.id == own)),
};
match c.get("command").and_then(Value::as_str).unwrap_or("") {
"document.save" | "file.save" => !save && (path || own_file()),
"file.saveAs" => !save && path,
"file.saveCopy" | "file.saveAsTemplate" => path,
"document.export" | "document.exportSelection" => !export && path,
// The batch does not point these exports into `destinationFolder`.
"document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path,
// Override Export sets `path`, which Export for Screens does not read: its `folder` stays.
"document.exportForScreens" => super::screens::with_preset(s, &params).is_ok_and(|p| names(p.get("folder"))),
_ => false,
}
})
}
/// Whether `steps` hold a Save, Save As or `document.save` step that Override Save points at each
/// data set's own file in the destination folder (`redirected`). Such a step makes that file the
/// document's own, so Save and Close then saves each data set to its own file.
fn saves_move_to_each_file(steps: &[Value], redirected: bool) -> bool {
redirected && steps.iter().any(|c| retargeting_save(c, true))
}
/// Whether step `c` is a Save, Save As or `document.save` that writes a file (its own `path`, or one
/// in the destination folder when `redirected`) and makes that file the document's own: a save in
/// the native template format writes a copy and leaves the document's file as it was.
fn retargeting_save(c: &Value, redirected: bool) -> bool {
if !matches!(c.get("command").and_then(Value::as_str), Some("document.save" | "file.save" | "file.saveAs")) {
return false;
}
let params = c.get("params");
let path = params.and_then(|p| p.get("path")).and_then(Value::as_str).filter(|p| !p.is_empty());
let format_ok = match params.and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty()) {
Some(f) => super::format(f).is_some_and(|f| f.id != "template"),
// Override Save gives the step a native file name; else its own path names the format.
None if redirected => true,
None => path.and_then(super::format_for_name).is_none_or(|f| f.id != "template"),
};
format_ok && (redirected || path.is_some())
}
#[cfg(not(target_arch = "wasm32"))]
fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result<Value> {
use super::{absolute_path, may_read};
@@ -145,6 +227,18 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
if names.len() > MAX_BATCH_FILES {
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets")));
}
let redirected = run.override_save && run.dest_folder.is_some();
// A recorded save with a file to write gives the document a file of its own before Save and Close.
if run.destination == "saveAndClose" && !run.steps.iter().any(|c| retargeting_save(c, redirected)) {
if st.path.is_none() {
return Err(bad(cmd, "the document has never been saved"));
}
// `save_plan` writes no file for a document converted from an older version or restored by
// Data Recovery.
if st.converted || st.recovered {
return Err(bad(cmd, "save the document with Save As before Save and Close"));
}
}
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
let stems = dataset_stems(&names, &doc_stem, run.dataset_file_naming, run, &st.doc)?;
let steps = filter_steps(run.steps, true, false);
@@ -1292,6 +1386,213 @@ mod tests {
assert_eq!(file_names(&dest), ["Untitled_A-B-3.vectorcraft", "Untitled_a-b-2.vectorcraft", "Untitled_a-b.vectorcraft"], "{r}");
}
/// A saved document with a text object bound to `Name` and two data sets, `A` and `B`.
fn saved_with_two_data_sets(path: &std::path::Path) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "A"})).unwrap()["id"].as_u64().unwrap();
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [id]})).unwrap();
s.execute("dataset.capture", &json!({"name": "A"})).unwrap();
s.execute("text.setText", &json!({"ids": [id], "text": "B"})).unwrap();
s.execute("dataset.capture", &json!({"name": "B"})).unwrap();
s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap();
s
}
fn save_and_close_over_data_sets(s: &mut Session) -> Result<Value> {
s.execute(
"action.batch",
&json!({"source": "datasets", "destination": "saveAndClose", "commands": [{"command": "object.unlockAll", "params": {}}]}),
)
}
#[test]
fn batch_save_and_close_over_data_sets_saves_each_over_the_template_and_warns() {
let path = tmp("save-close-sets").join("template.vectorcraft");
let mut s = saved_with_two_data_sets(&path);
std::fs::write(&path, b"stale").unwrap();
let r = save_and_close_over_data_sets(&mut s).unwrap();
assert_eq!((r["count"].as_u64(), r["failed"].as_u64()), (Some(2), Some(0)), "{r}");
assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}");
assert_ne!(std::fs::read(&path).unwrap(), b"stale", "the template's file is saved over");
assert_eq!(file_names(path.parent().unwrap()), ["template.vectorcraft"], "{r}");
let st = s.doc().unwrap();
assert_eq!(st.doc.variables.active_dataset.as_deref(), Some("B"), "the last data set is applied");
assert_eq!(st.path.as_deref(), Some(path.to_string_lossy().as_ref()), "the template stays open");
assert!(!st.is_dirty(), "the last data set is saved");
}
#[test]
fn batch_warns_when_a_recorded_save_writes_one_file_for_every_data_set() {
let path = tmp("recorded-save-sets").join("template.vectorcraft");
let mut s = saved_with_two_data_sets(&path);
let save = json!([{"command": "document.save", "params": {}}]);
let r = s.execute("action.batch", &json!({"source": "datasets", "commands": save})).unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}");
let dest = tmp("recorded-save-dest");
let p = json!({"source": "datasets", "destination": "folder", "destinationFolder": dest.to_string_lossy(), "commands": save});
let r = s.execute("action.batch", &p).unwrap();
assert!(r.get("warning").is_none(), "Override Save points each save into the folder: {r}");
let unlock = json!({"source": "datasets", "commands": [{"command": "object.unlockAll", "params": {}}]});
let r = s.execute("action.batch", &unlock).unwrap();
assert!(r.get("warning").is_none(), "{r}");
}
#[test]
fn batch_save_and_close_after_a_redirected_save_writes_each_data_set_to_its_own_file() {
let save_close = |dest: &std::path::Path| {
json!({
"source": "datasets",
"destination": "saveAndClose",
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.save", "params": {}}]
})
};
let path = tmp("redirected-save-close").join("template.vectorcraft");
let mut s = saved_with_two_data_sets(&path);
let bytes = std::fs::read(&path).unwrap();
let dest = tmp("redirected-save-close-dest");
let p = save_close(&dest);
let mut kept = p.clone();
kept["overrideSave"] = json!(false);
assert!(batch_overwrites_one_file(&s, &kept), "without Override Save the save stays on the template");
let r = s.execute("action.batch", &p).unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert!(r.get("warning").is_none(), "{r}");
assert_eq!(file_names(&dest), ["template_A.vectorcraft", "template_B.vectorcraft"], "{r}");
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template's file stays as it was");
// A document never saved gets its files from the redirected save too.
let mut unsaved = with_data_sets(&["a", "b"]);
let dest = tmp("redirected-save-close-unsaved");
let r = unsaved.execute("action.batch", &save_close(&dest)).unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert!(r.get("warning").is_none(), "{r}");
assert_eq!(file_names(&dest), ["Untitled_a.vectorcraft", "Untitled_b.vectorcraft"], "{r}");
// A template save writes a copy: Save and Close still saves over the document's own file.
let mut template = save_close(&dest);
template["commands"] = json!([{"command": "document.save", "params": {"format": "template"}}]);
assert!(batch_overwrites_one_file(&s, &template), "{template}");
let mut never = with_data_sets(&["a", "b"]);
let msg = never.execute("action.batch", &template).unwrap_err().to_string();
assert!(msg.ends_with("the document has never been saved"), "{msg}");
// A recorded Save As with its own path gives a never-saved document its file, the same one
// for every data set.
let one = tmp("save-as-then-close").join("one.vectorcraft");
let p = json!({
"source": "datasets",
"destination": "saveAndClose",
"commands": [{"command": "file.saveAs", "params": {"path": one.to_string_lossy()}}]
});
let r = never.execute("action.batch", &p).unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}");
assert!(one.exists(), "{r}");
}
#[test]
fn one_file_rule_covers_save_and_close_and_steps_left_in_place() {
let saved = saved_with_two_data_sets(&tmp("one-file-rule").join("template.vectorcraft"));
let unsaved = with_data_sets(&["a"]);
let with = |extra: Value| {
let mut p = json!({"source": "datasets", "commands": [{"command": "document.export", "params": {"path": "/x/a.png"}}]});
for (k, v) in extra.as_object().unwrap() {
p[k] = v.clone();
}
batch_overwrites_one_file(&saved, &p)
};
assert!(with(json!({})), "an export with its own path");
assert!(!with(json!({"destination": "folder", "destinationFolder": "/out"})));
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "overrideExport": false})));
assert!(!with(json!({"destination": "folder", "destinationFolder": "/out", "overrideSave": false})));
assert!(!with(json!({"source": "folder"})), "a folder source opens a file per pass");
assert!(with(json!({"destination": "saveAndClose", "commands": [{"command": "object.unlockAll"}]})));
let saved_as = json!({"commands": [{"command": "file.saveAs", "params": {"path": "/x/a.svg"}}]});
assert!(with(saved_as.clone()));
assert!(!with(json!({"destination": "folder", "destinationFolder": "/out", "commands": saved_as["commands"]})));
assert!(!with(json!({"commands": [{"command": "file.saveAs", "params": {}}]})), "no path: the data comes back");
assert!(!with(json!({"commands": [{"command": "document.export", "params": {"format": "png"}}]})), "no path: the data comes back");
let copy = json!([{"command": "file.saveCopy", "params": {"path": "/x/a.vectorcraft"}}]);
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": copy})), "Override Save leaves copies alone");
let save = |format: Option<&str>| {
let params = format.map_or(json!({}), |f| json!({"format": f}));
json!({"source": "datasets", "commands": [{"command": "document.save", "params": params}]})
};
assert!(batch_overwrites_one_file(&saved, &save(None)), "the document's own file");
assert!(batch_overwrites_one_file(&saved, &save(Some("vectorcraft"))), "the document's own format");
assert!(!batch_overwrites_one_file(&unsaved, &save(None)), "a document never saved has no file to write");
assert!(!batch_overwrites_one_file(&saved, &save(Some("svg"))), "another format without a path: the data comes back");
assert!(!with(json!({"commands": [{"command": "file.saveCopy", "params": {"folder": "/x"}}]})), "a folder alone: the data comes back");
let screens = json!([{"command": "document.exportForScreens", "params": {"folder": "/x"}}]);
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), "Override Export leaves its folder");
assert!(!with(json!({"commands": [{"command": "document.exportForScreens", "params": {}}]})));
let preset = json!([{"command": "document.exportForScreens", "params": {"exportPreset": "Logo Package"}}]);
assert!(!with(json!({"commands": preset})), "a preset without a folder: the data comes back");
for id in ["document.exportPdf", "document.exportEps", "document.exportDxf", "document.exportForWeb", "document.exportForOffice"] {
let step = json!([{"command": id, "params": {"path": "/x/a.out"}}]);
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": step})), "{id} keeps its path");
assert!(!with(json!({"commands": [{"command": id, "params": {}}]})), "{id} without a path: the data comes back");
}
}
#[test]
fn batch_refuses_save_and_close_over_data_sets_of_a_converted_or_recovered_document() {
for converted in [true, false] {
let path = tmp(if converted { "converted-sets" } else { "recovered-sets" }).join("template.vectorcraft");
let mut s = saved_with_two_data_sets(&path);
let bytes = std::fs::read(&path).unwrap();
let (active, revision) = {
let st = s.doc_mut().unwrap();
if converted {
st.converted = true;
} else {
st.recovered = true;
}
(st.doc.variables.active_dataset.clone(), st.revision)
};
let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string();
assert!(msg.ends_with("save the document with Save As before Save and Close"), "{msg}");
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the file is not written");
let st = s.doc().unwrap();
assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected");
assert_eq!(st.revision, revision, "no step runs");
}
}
#[test]
fn batch_refuses_save_and_close_over_data_sets_of_an_unsaved_document() {
let mut s = with_data_sets(&["a", "b"]);
let (active, revision) = {
let st = s.doc().unwrap();
(st.doc.variables.active_dataset.clone(), st.revision)
};
let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string();
assert!(msg.ends_with("the document has never been saved"), "{msg}");
let st = s.doc().unwrap();
assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected");
assert_eq!(st.revision, revision, "no step runs");
assert!(st.path.is_none());
}
#[test]
fn batch_save_and_close_saves_and_closes_each_source_file() {
let src = tmp("save-close-src");
let a = write_svg(&src, "a.svg", "#f00");
let before = std::fs::read(&a).unwrap();
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({"folder": src.to_string_lossy(), "destination": "saveAndClose", "commands": [{"command": "object.unlockAll", "params": {}}]}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_ne!(std::fs::read(&a).unwrap(), before, "the source file is saved over");
assert_eq!(file_names(&src), ["a.svg"], "{r}");
assert!(s.doc().is_err(), "the source file is closed");
}
#[test]
fn batch_refuses_nesting_and_empty_commands() {
let mut s = Session::new();
+1 -1
View File
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
[],
None,
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)",
always,
action_batch::action_batch
),
+11 -3
View File
@@ -503,6 +503,8 @@ pub const FORMATS: &[Format] = &[
reader("pxr", "Pixar", &["pxr"], "image/x-pixar", true),
// JP2 and raw codestreams are decoded to PNG; JPX composition is not supported.
reader("jpeg2000", "JPEG 2000", &["jpf", "jpx", "jp2", "j2k", "j2c", "jpc"], "image/jp2", true),
// HEVC-coded HEIF still images, decoded to PNG; image sequences are not read.
reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true),
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@@ -537,6 +539,8 @@ pub const OPEN_EXTS: &[&str] = &[
"j2k",
"j2c",
"jpc",
"heic",
"heif",
"psd",
"pdd",
"psb",
@@ -554,11 +558,13 @@ pub const OPEN_EXTS: &[&str] = &[
/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to
/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no
/// `.epsf`, `.ps`, Targa, PCX, Pixar or JPEG 2000 type, nor one for the `.pns`, `.dib` and `.rle` names of
/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000 or HEIC/HEIF type, nor one for the `.pns`, `.dib` and `.rle` names of
/// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS
/// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it.
pub const UNASSOCIATED_EXTS: &[&str] =
&["psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc"];
pub const UNASSOCIATED_EXTS: &[&str] = &[
"psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc",
"heic", "heif",
];
/// 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.
@@ -621,6 +627,8 @@ pub const PLACE_EXTS: &[&str] = &[
"j2k",
"j2c",
"jpc",
"heic",
"heif",
"psd",
"pdd",
"psb",
+627
View File
@@ -0,0 +1,627 @@
//! HEIC and HEIF images for File › Open and File › Place, decoded with heic-rs when the engine's
//! `heif` feature is on (the default). The image is bounded before decoding and converted to RGBA
//! for the common PNG storage path. A build without the feature still recognizes the files and
//! refuses them with a message.
#[cfg(all(test, feature = "heif"))]
pub(super) use decoder::{active_sps, picture_bytes, sps_signal, sps_size};
#[cfg(feature = "heif")]
pub(super) use decoder::{decode, quarter_turned, with_exif};
/// The brands heic-rs 0.1.4 reads: HEVC-coded images and image sequences (ISO/IEC 23008-12 annex
/// B) and the general image and sequence brands.
const BRANDS: [&[u8; 4]; 7] = [b"heic", b"heix", b"heim", b"heis", b"hevc", b"mif1", b"msf1"];
/// Recognize a HEIF file by its `ftyp` box: one of [`BRANDS`] as the major brand or a compatible
/// one, and no AVIF brand (`avif`, `avis`), as heic-rs refuses a file that lists one. The box is
/// read as JP2 boxes are ([`super::jp2read::boxes`]): ISO BMFF sizes, the 64-bit one included.
pub(super) fn is_heif(bytes: &[u8]) -> bool {
let Some((b"ftyp", ftyp)) = super::jp2read::boxes(bytes).next() else { return false };
let Some(major) = ftyp.get(..4).and_then(|b| <&[u8; 4]>::try_from(b).ok()) else { return false };
if ftyp.len() < 8 {
return false;
}
let compatible = ftyp.get(8..).unwrap_or_default().as_chunks::<4>().0;
let brands = || std::iter::once(major).chain(compatible);
!brands().any(|brand| matches!(brand, b"avif" | b"avis")) && brands().any(|brand| BRANDS.contains(&brand))
}
/// Without the `heif` feature every HEIF file is refused.
#[cfg(not(feature = "heif"))]
pub(super) fn decode(_bytes: &[u8], _max_alloc: u64) -> Result<image::RgbaImage, String> {
Err("HEIC and HEIF images can't be opened in this build".into())
}
/// Without the `heif` feature no image is decoded, so none is turned.
#[cfg(not(feature = "heif"))]
pub(super) fn quarter_turned(_bytes: &[u8]) -> bool {
false
}
/// Without the `heif` feature no resolution is read.
#[cfg(not(feature = "heif"))]
pub(super) fn with_exif<T>(_bytes: &[u8], _read: impl FnOnce(&[u8]) -> Option<T>) -> Option<T> {
None
}
#[cfg(feature = "heif")]
mod decoder {
use heic_rs::context::Context;
use heic_rs::{DecodeOptions, Error, MatrixCoefficients, Nclx, PixelLayout, Range};
/// The message for a file that isn't a readable HEIF image.
const UNREADABLE: &str = "the HEIC or HEIF file can't be read";
/// The message for an image sequence, an overlay, an image that isn't HEVC-coded, a grid whose
/// tiles are cropped, turned or mirrored, or an HEVC coding tool the decoder doesn't implement.
const UNSUPPORTED: &str = "the HEIC or HEIF file holds a kind of image that can't be read";
/// Decode the primary image within `max_alloc` bytes, counting the decoder's planes, the RGBA
/// output and the copy that rotating or cropping makes, with the container's rotation, mirror
/// and crop applied. An embedded RGB ICC profile, or `nclx` Display P3 primaries with the sRGB
/// curve, is converted to sRGB; other `nclx` values are read as sRGB. A panic inside the
/// decoder is reported as an unreadable file.
pub(in super::super) fn decode(bytes: &[u8], max_alloc: u64) -> Result<image::RgbaImage, String> {
crate::guard::catch_panic(|| decode_unguarded(bytes, max_alloc)).unwrap_or_else(|_| Err(UNREADABLE.into()))
}
/// Whether the container's rotations of the primary image add up to an odd number of quarter
/// turns, so its width and height swap. The decoder applies every `irot` the item lists.
pub(in super::super) fn quarter_turned(bytes: &[u8]) -> bool {
let turned = || {
let ctx = Context::open(bytes).ok()?;
let props = ctx.props(ctx.meta.primary).ok()?;
let quarters = props.transforms.iter().filter(|t| matches!(t, heic_rs::props::Transform::Rotate(r) if r.swaps_axes())).count();
Some(quarters % 2 == 1)
};
crate::guard::catch_panic(turned).ok().flatten().unwrap_or(false)
}
/// `read` of the primary image's EXIF block, a TIFF structure inside `bytes`, for its
/// resolution tags.
pub(in super::super) fn with_exif<T>(bytes: &[u8], read: impl FnOnce(&[u8]) -> Option<T>) -> Option<T> {
let find = || {
let ctx = Context::open(bytes).ok()?;
ctx.exif(ctx.meta.primary).ok().flatten()
};
crate::guard::catch_panic(find).ok().flatten().and_then(read)
}
fn decode_unguarded(bytes: &[u8], max_alloc: u64) -> Result<image::RgbaImage, String> {
let refused = |e: Error| match e {
Error::Unsupported(_) | Error::MissingBox("hvcC" | "meta") => UNSUPPORTED.to_string(),
_ => UNREADABLE.to_string(),
};
let info = heic_rs::probe(bytes).map_err(refused)?;
let ctx = Context::open(bytes).map_err(refused)?;
if unsupported_items(&ctx).ok_or(UNREADABLE)? {
return Err(UNSUPPORTED.into());
}
// The stored size, before a crop: the size the decoder holds.
let too_large = || format!("an image of {} × {} pixels is too large to draw", info.coded_width, info.coded_height);
let declared = u64::from(info.coded_width).checked_mul(u64::from(info.coded_height)).ok_or_else(too_large)?;
// A grid is composed at the size its own data gives, which need not match `ispe`.
let grid = grid_pixels(&ctx).ok_or(UNREADABLE)?;
// A single picture is converted at the size its stream gives, which need not match `ispe`
// either.
let coded = declared.max(grid).max(stream_pixels(&ctx).ok_or(UNREADABLE)?);
let shown = u64::from(info.width).checked_mul(u64::from(info.height)).ok_or_else(too_large)?;
// The RGBA output, and a second RGBA image when the decoder rotates, mirrors or crops it,
// which is no larger than the first.
let output = coded.checked_mul(8).ok_or_else(too_large)?;
let pictures = picture_bytes_in(&ctx).ok_or(UNREADABLE)?;
// An alpha grid is composed into a frame of 16-bit planes (at most three samples a pixel),
// and so is the image's grid when it is decoded with a color description of its tiles or
// its stream. A single picture is converted in place.
let composed = if grid > 0 { coded.checked_mul(if info.has_alpha { 12 } else { 6 }).ok_or_else(too_large)? } else { 0 };
if coded == 0 || shown == 0 || output.checked_add(pictures).and_then(|n| n.checked_add(composed)).is_none_or(|n| n > max_alloc) {
return Err(too_large());
}
let max_pixels = max_alloc / 4;
let decoded = match tile_nclx(&ctx).or_else(|| stream_nclx(&ctx)) {
Some(nclx) => decode_with_nclx(&ctx, nclx, max_pixels),
None => heic_rs::decode(bytes, &DecodeOptions::default().with_layout(PixelLayout::Rgba8).with_max_pixels(Some(max_pixels))),
}
.map_err(|e| match e {
Error::PixelLimit { .. } | Error::BoxTooLarge { .. } => too_large(),
e => refused(e),
})?;
let mut img = image::RgbaImage::from_raw(decoded.width, decoded.height, decoded.data).ok_or(UNREADABLE)?;
if let Some(profile) = source_profile(&ctx) {
for pixel in img.pixels_mut() {
let [r, g, b, a] = pixel.0;
let device = [r, g, b].map(|v| f32::from(v) / 255.0);
if let Some(rgb) = profile.to_srgb_into(&device, vectorcraft_color::cms::Intent::RelativeColorimetric) {
let [r, g, b] = rgb.map(|v| (v * 255.0).round() as u8);
pixel.0 = [r, g, b, a];
}
}
}
Ok(img)
}
/// The primary image and its alpha image, when it has one.
fn primary_and_alpha(ctx: &Context<'_>) -> Option<impl Iterator<Item = u32>> {
let primary = ctx.meta.primary;
Some(std::iter::once(primary).chain(ctx.alpha_item(primary).ok()?))
}
/// Whether the primary image or its alpha image is neither an HEVC-coded item (`hvc1`, `hev1`)
/// nor a grid of them, or is a grid with a tile that carries a crop, rotation or mirror. heic-rs
/// 0.1.4 decodes any item with an `hvcC` whatever its type, and composes a grid from its tiles
/// as coded, without their transforms: an encoder that pads tiles smaller than its coding block
/// (64 × 64 for x265) and crops them back with `clap` gets the wrong pixels.
fn unsupported_items(ctx: &Context<'_>) -> Option<bool> {
let hevc = |id: u32| ctx.meta.item(id).is_some_and(|info| info.is_hevc());
for item in primary_and_alpha(ctx)? {
let Some((_, tiles)) = ctx.grid(item).ok()? else {
if !hevc(item) {
return Some(true);
}
continue;
};
for tile in tiles {
if !hevc(tile) || !ctx.props(tile).ok()?.transforms.is_empty() {
return Some(true);
}
}
}
Some(false)
}
/// The pixels of the larger of the primary image's grid and its alpha image's grid, at the
/// output size each grid gives; 0 when neither is a grid.
fn grid_pixels(ctx: &Context<'_>) -> Option<u64> {
let mut largest = 0u64;
for item in primary_and_alpha(ctx)? {
if let Some((grid, _)) = ctx.grid(item).ok()? {
largest = largest.max(u64::from(grid.output_width) * u64::from(grid.output_height));
}
}
Some(largest)
}
/// The pixels of the larger of the primary image and its alpha image when either is a single
/// picture, at the size of its largest sequence parameter set (before its conformance window);
/// 0 when both are grids. `None` when a size can't be read.
fn stream_pixels(ctx: &Context<'_>) -> Option<u64> {
let mut largest = 0u64;
for item in primary_and_alpha(ctx)? {
if ctx.grid(item).ok()?.is_some() {
continue;
}
// An item without `hvcC` isn't decoded: heic-rs refuses the file.
let Some(hvcc) = ctx.props(item).ok()?.hvcc else { continue };
for nal in hvcc.parameter_sets().into_iter().filter(|nal| nal_type(nal) == Some(SPS)) {
let (width, height, _) = sps_size(nal)?;
largest = largest.max(width.checked_mul(height)?);
}
}
Some(largest)
}
/// The first tile's `nclx` when the primary image is a grid without an `nclx` of its own and
/// its tiles carry one. heic-rs 0.1.4 converts a grid's samples to RGB with the grid item's
/// `nclx` alone, and with its BT.709 limited-range default when the grid has none
/// (tbraun96/heic-rs#6).
fn tile_nclx(ctx: &Context<'_>) -> Option<Nclx> {
let primary = ctx.meta.primary;
if ctx.props(primary).ok()?.nclx.is_some() {
return None;
}
let (_, tiles) = ctx.grid(primary).ok()??;
ctx.props(*tiles.first()?).ok()?.nclx
}
/// The color description in the HEVC stream's video usability information (ITU-T H.265
/// E.2.1) of the primary image, or of its grid's first tile, when neither carries an `nclx`
/// (a file whose only color box is an ICC profile, or one without a color box). heic-rs 0.1.4
/// reads only the container's `nclx` and otherwise converts with BT.709 limited range.
fn stream_nclx(ctx: &Context<'_>) -> Option<Nclx> {
let primary = ctx.meta.primary;
if ctx.props(primary).ok()?.nclx.is_some() {
return None;
}
let coded = match ctx.grid(primary).ok()? {
Some((_, tiles)) => *tiles.first()?,
None => primary,
};
let props = ctx.props(coded).ok()?;
if props.nclx.is_some() {
return None;
}
let hvcc = props.hvcc?;
let data = ctx.item_data(coded).ok()?;
let slices = hvcc.split_nals(&data).ok()?;
active_sps(&hvcc.parameter_sets(), &slices).and_then(sps_signal)
}
/// The SPS the first slice of `slices` uses: the one its PPS names (ITU-T H.265 7.4.3.3.1).
pub(in super::super) fn active_sps<'a>(parameter_sets: &[&'a [u8]], slices: &[&[u8]]) -> Option<&'a [u8]> {
let slice = slices.iter().find(|nal| nal_type(nal).is_some_and(|t| t < 32))?;
let bits = &mut Bits { data: &payload(slice), at: 0 };
// first_slice_segment_in_pic_flag, no_output_of_prior_pics_flag in an IRAP picture (types 16
// to 23), slice_pic_parameter_set_id.
bits.skip(1)?;
if nal_type(slice).is_some_and(|t| (16..=23).contains(&t)) {
bits.skip(1)?;
}
let pps_id = bits.exp_golomb()?;
// pps_pic_parameter_set_id, pps_seq_parameter_set_id.
let pps_ids = |nal: &[u8]| {
let bits = &mut Bits { data: &payload(nal), at: 0 };
Some((bits.exp_golomb()?, bits.exp_golomb()?))
};
let sps_id = parameter_sets.iter().filter(|nal| nal_type(nal) == Some(PPS)).find_map(|nal| pps_ids(nal).filter(|(id, _)| *id == pps_id))?.1;
let sps_ids = |nal: &[u8]| {
let rbsp = sps_payload(nal)?;
sps_head(&mut Bits { data: &rbsp, at: 0 }).map(|(_, id, ..)| id)
};
parameter_sets.iter().copied().find(|nal| sps_ids(nal) == Some(sps_id))
}
/// The primary image decoded as heic-rs 0.1.4 decodes it (a grid's tiles one after another),
/// with its samples converted to RGBA with `nclx`, the alpha image applied and the image's
/// rotation, mirror and crop applied.
fn decode_with_nclx(ctx: &Context<'_>, nclx: Nclx, max_pixels: u64) -> heic_rs::Result<heic_rs::Image> {
use heic_rs::hevc::Frame;
fn coded(ctx: &Context<'_>, id: u32) -> heic_rs::Result<Frame> {
let props = ctx.props(id)?;
let hvcc = props.hvcc.as_ref().ok_or(Error::MissingBox("hvcC"))?;
let data = ctx.item_data(id)?;
let frame = heic_rs::hevc::decode_still(&hvcc.parameter_sets(), &hvcc.split_nals(&data)?)?;
frame.validate()?;
Ok(frame)
}
fn item(ctx: &Context<'_>, id: u32, max_pixels: u64) -> heic_rs::Result<Frame> {
match ctx.grid(id)? {
Some((grid, tiles)) => {
// The tiles decode in parallel on native, as heic-rs's own decode does.
#[cfg(not(target_arch = "wasm32"))]
let frames = {
use rayon::prelude::*;
tiles.par_iter().map(|&t| coded(ctx, t)).collect::<heic_rs::Result<Vec<_>>>()?
};
#[cfg(target_arch = "wasm32")]
let frames = tiles.iter().map(|&t| coded(ctx, t)).collect::<heic_rs::Result<Vec<_>>>()?;
heic_rs::grid::compose(&grid, &frames, max_pixels)
}
None => coded(ctx, id),
}
}
let primary = ctx.meta.primary;
let props = ctx.props(primary)?;
let frame = item(ctx, primary, max_pixels)?;
let alpha = ctx.alpha_item(primary)?.map(|a| item(ctx, a, max_pixels)).transpose()?;
// `None`: rayon's global pool on native, where heic-rs is built with `parallel`.
let image = heic_rs::color::convert(&frame, alpha.as_ref(), nclx, PixelLayout::Rgba8, max_pixels, None)?;
heic_rs::transform::apply_all(image, &props.transforms)
}
/// The bytes heic-rs 0.1.4 allocates for the coded pictures of `bytes` it decodes (see
/// [`picture_bytes_in`]); `None` when they can't be read.
#[cfg(test)]
pub(in super::super) fn picture_bytes(bytes: &[u8]) -> Option<u64> {
picture_bytes_in(&Context::open(bytes).ok()?)
}
/// The bytes heic-rs 0.1.4 allocates for the coded pictures it decodes: the primary image's,
/// or each tile of its grid, and its alpha image's the same way. Each picture is sized by the
/// largest of its sequence parameter sets (the size the decoder allocates, rounded up to whole
/// 64-sample coding blocks), not by the container, and takes three sets of 16-bit planes: the
/// picture, a filtered copy and the frame returned. `None` when an item's sizes can't be read.
fn picture_bytes_in(ctx: &Context<'_>) -> Option<u64> {
let mut total = 0u64;
for item in primary_and_alpha(ctx)? {
let coded = match ctx.grid(item).ok()? {
Some((_, tiles)) => tiles,
None => vec![item],
};
for id in coded {
let props = ctx.props(id).ok()?;
// An item without `hvcC` isn't decoded: heic-rs refuses the file.
let Some(hvcc) = props.hvcc else { continue };
// The slices pick their SPS through the PPS, so the largest one is counted. Every
// SPS must be read.
let mut largest = None;
for nal in hvcc.parameter_sets().into_iter().filter(|nal| nal_type(nal) == Some(SPS)) {
let (width, height, chroma) = sps_size(nal)?;
let blocks = |n: u64| n.div_ceil(64) * 64;
// Bytes a sample position takes in one set of planes: luma and the two chroma
// planes.
let planes = match chroma {
0 => 2,
1 => 3,
2 => 4,
_ => 6,
};
let picture = blocks(width).checked_mul(blocks(height))?.checked_mul(planes * 3)?;
largest = largest.max(Some(picture));
}
total = total.checked_add(largest?)?;
}
}
Some(total)
}
/// The NAL unit type of an HEVC sequence parameter set.
const SPS: u8 = 33;
/// The type of an HEVC NAL unit, from its header.
fn nal_type(nal: &[u8]) -> Option<u8> {
nal.first().map(|b| (b >> 1) & 0x3f)
}
/// The NAL unit type of an HEVC picture parameter set.
const PPS: u8 = 34;
/// A NAL unit's payload, without its two-byte header and the emulation prevention bytes (the 03
/// of 00 00 03), up to its first 4,096 bytes.
fn payload(nal: &[u8]) -> Vec<u8> {
let mut rbsp = Vec::with_capacity(256);
let mut zeros = 0;
for &b in nal.get(2..).unwrap_or_default().iter().take(4096) {
if b == 3 && zeros >= 2 {
zeros = 0;
continue;
}
zeros = if b == 0 { zeros + 1 } else { 0 };
rbsp.push(b);
}
rbsp
}
/// An SPS NAL unit's [`payload`]; `None` for another NAL unit.
fn sps_payload(nal: &[u8]) -> Option<Vec<u8>> {
(nal_type(nal) == Some(SPS)).then(|| payload(nal))
}
/// The fields of an SPS (ITU-T H.265 7.3.2.2) up to the picture height:
/// `sps_max_sub_layers_minus1`, `sps_seq_parameter_set_id`, the width, the height and
/// `chroma_format_idc`.
fn sps_head(bits: &mut Bits<'_>) -> Option<(u64, u64, u64, u64, u64)> {
// sps_video_parameter_set_id, sps_max_sub_layers_minus1, sps_temporal_id_nesting_flag.
bits.skip(4)?;
let sub_layers = bits.read(3)?;
bits.skip(1)?;
// profile_tier_level (7.3.3): the general profile, tier and level, then the sub-layers'.
bits.skip(96)?;
let mut present = Vec::new();
for _ in 0..sub_layers {
present.push((bits.read(1)?, bits.read(1)?));
}
if sub_layers > 0 {
bits.skip(2 * (8 - sub_layers as usize))?;
}
for (profile, level) in present {
bits.skip(88 * profile as usize + 8 * level as usize)?;
}
let id = bits.exp_golomb()?;
let chroma = bits.exp_golomb()?;
if chroma == 3 {
bits.skip(1)?;
}
let width = bits.exp_golomb()?;
let height = bits.exp_golomb()?;
Some((sub_layers, id, width, height, chroma))
}
/// The picture width, height and `chroma_format_idc` of an HEVC sequence parameter set NAL
/// unit, before its conformance window; `None` for another NAL unit.
pub(in super::super) fn sps_size(nal: &[u8]) -> Option<(u64, u64, u64)> {
let rbsp = sps_payload(nal)?;
let (_, _, width, height, chroma) = sps_head(&mut Bits { data: &rbsp, at: 0 })?;
Some((width, height, chroma))
}
/// The video signal type of an HEVC sequence parameter set NAL unit's VUI (ITU-T H.265 E.2.1)
/// as an `nclx`: the full-range flag and, when the VUI describes them, the color primaries,
/// transfer characteristics and matrix coefficients (otherwise 2, unspecified). `None` when the
/// SPS has no VUI, the VUI has no video signal type, or the SPS can't be read that far.
pub(in super::super) fn sps_signal(nal: &[u8]) -> Option<Nclx> {
let rbsp = sps_payload(nal)?;
let bits = &mut Bits { data: &rbsp, at: 0 };
let (sub_layers, ..) = sps_head(bits)?;
skip_to_vui(bits, sub_layers)?;
// vui_parameters_present_flag, then aspect_ratio_info_present_flag (an Extended_SAR
// aspect_ratio_idc of 255 adds its 32-bit ratio) and overscan_info_present_flag.
if bits.read(1)? == 0 {
return None;
}
if bits.read(1)? == 1 && bits.read(8)? == 255 {
bits.skip(32)?;
}
if bits.read(1)? == 1 {
bits.skip(1)?;
}
// video_signal_type_present_flag: video_format, video_full_range_flag and the color
// description.
if bits.read(1)? == 0 {
return None;
}
bits.skip(3)?;
let range = if bits.read(1)? == 1 { Range::Full } else { Range::Limited };
let (primaries, transfer, matrix) = if bits.read(1)? == 1 { (bits.read(8)?, bits.read(8)?, bits.read(8)?) } else { (2, 2, 2) };
let code = |v: u64| u16::try_from(v).ok();
let matrix_code = code(matrix)?;
Some(Nclx { primaries: code(primaries)?, transfer: code(transfer)?, matrix: MatrixCoefficients::from_code(matrix_code), matrix_code, range })
}
/// Skips the fields of an SPS after [`sps_head`], up to `vui_parameters_present_flag`.
fn skip_to_vui(bits: &mut Bits<'_>, sub_layers: u64) -> Option<()> {
// conformance_window_flag and its four offsets.
if bits.read(1)? == 1 {
for _ in 0..4 {
bits.exp_golomb()?;
}
}
// bit_depth_luma_minus8, bit_depth_chroma_minus8.
bits.exp_golomb()?;
bits.exp_golomb()?;
let poc_bits = bits.exp_golomb()?.checked_add(4).filter(|&n| n <= 16)?;
// sps_sub_layer_ordering_info_present_flag, then three values for each sub-layer given.
let ordered = if bits.read(1)? == 1 { sub_layers + 1 } else { 1 };
for _ in 0..ordered * 3 {
bits.exp_golomb()?;
}
// The coding and transform block sizes and the transform hierarchy depths.
for _ in 0..6 {
bits.exp_golomb()?;
}
// scaling_list_enabled_flag, sps_scaling_list_data_present_flag.
if bits.read(1)? == 1 && bits.read(1)? == 1 {
skip_scaling_lists(bits)?;
}
// amp_enabled_flag, sample_adaptive_offset_enabled_flag.
bits.skip(2)?;
// pcm_enabled_flag: the PCM sample depths, block sizes and loop filter flag.
if bits.read(1)? == 1 {
bits.skip(8)?;
bits.exp_golomb()?;
bits.exp_golomb()?;
bits.skip(1)?;
}
skip_reference_picture_sets(bits)?;
// long_term_ref_pics_present_flag: each picture's POC bits and used flag.
if bits.read(1)? == 1 {
let count = bits.exp_golomb()?;
if count > 32 {
return None;
}
for _ in 0..count {
bits.skip(poc_bits as usize + 1)?;
}
}
// sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag.
bits.skip(2)
}
/// Skips `scaling_list_data()` (ITU-T H.265 7.3.4).
fn skip_scaling_lists(bits: &mut Bits<'_>) -> Option<()> {
for size in 0..4 {
// The 32 × 32 lists are given for matrices 0 and 3 only.
for _ in 0..if size == 3 { 2 } else { 6 } {
if bits.read(1)? == 0 {
// scaling_list_pred_matrix_id_delta.
bits.exp_golomb()?;
} else {
// scaling_list_dc_coef_minus8, then each scaling_list_delta_coef (se(v) codes,
// as long as ue(v) codes).
if size > 1 {
bits.exp_golomb()?;
}
for _ in 0..if size == 0 { 16 } else { 64 } {
bits.exp_golomb()?;
}
}
}
}
Some(())
}
/// Skips `num_short_term_ref_pic_sets` and each `st_ref_pic_set()` (ITU-T H.265 7.3.7) of an
/// SPS. A set predicted from the one before it has one or two flags for each of that set's
/// pictures and one more.
fn skip_reference_picture_sets(bits: &mut Bits<'_>) -> Option<()> {
let sets = bits.exp_golomb()?;
if sets > 64 {
return None;
}
// NumDeltaPocs of each set read so far.
let mut pictures: Vec<u64> = Vec::new();
for index in 0..sets {
let predicted = index != 0 && bits.read(1)? == 1;
let count = if predicted {
// delta_rps_sign, abs_delta_rps_minus1.
bits.skip(1)?;
bits.exp_golomb()?;
let mut count = 0;
for _ in 0..=*pictures.last()? {
// used_by_curr_pic_flag, and use_delta_flag when it is 0.
if bits.read(1)? == 1 || bits.read(1)? == 1 {
count += 1;
}
}
count
} else {
let (negative, positive) = (bits.exp_golomb()?, bits.exp_golomb()?);
if negative > 16 || positive > 16 {
return None;
}
// Each picture's delta_poc_s0_minus1 or delta_poc_s1_minus1 and its used flag.
for _ in 0..negative + positive {
bits.exp_golomb()?;
bits.skip(1)?;
}
negative + positive
};
pictures.push(count);
}
Some(())
}
/// A big-endian bit reader over an RBSP.
struct Bits<'a> {
data: &'a [u8],
at: usize,
}
impl Bits<'_> {
fn bit(&mut self) -> Option<u64> {
let byte = self.data.get(self.at / 8)?;
let bit = (byte >> (7 - self.at % 8)) & 1;
self.at += 1;
Some(u64::from(bit))
}
fn read(&mut self, n: u32) -> Option<u64> {
(0..n).try_fold(0u64, |v, _| Some(v << 1 | self.bit()?))
}
fn skip(&mut self, n: usize) -> Option<()> {
self.at = self.at.checked_add(n)?;
(self.at <= self.data.len() * 8).then_some(())
}
/// An unsigned Exp-Golomb code, ue(v), of at most 32 leading zeros.
fn exp_golomb(&mut self) -> Option<u64> {
let mut zeros = 0;
while self.bit()? == 0 {
zeros += 1;
if zeros > 32 {
return None;
}
}
Some((1u64 << zeros) - 1 + self.read(zeros)?)
}
}
/// `nclx` color primaries for Display P3 (SMPTE EG 432-1) and transfer characteristics for
/// the sRGB curve (IEC 61966-2-1), ITU-T H.273.
const P3_PRIMARIES: u16 = 12;
const SRGB_TRANSFER: u16 = 13;
/// The profile the decoded RGB values are in when it isn't sRGB: the primary item's RGB ICC
/// profile, else Display P3 when its `nclx` names P3 primaries with the sRGB curve. A grid
/// without its own `colr` takes its first tile's, as cameras write it on the tiles. A profile
/// that can't be read, or isn't RGB, is left out and the values are read as sRGB. Without any
/// `colr`, the HEVC stream's color description ([`stream_nclx`]) is read the same way.
fn source_profile(ctx: &Context<'_>) -> Option<vectorcraft_color::cms::IccProfile> {
use vectorcraft_color::cms::{self, IccProfile, ProfileKind};
let id = ctx.meta.primary;
let own = ctx.props(id).ok()?;
let described = |p: &heic_rs::props::ItemProps<'_>| p.icc.is_some() || p.nclx.is_some();
let tile = || ctx.grid(id).ok().flatten().and_then(|(_, tiles)| tiles.first().and_then(|&t| ctx.props(t).ok())).filter(described);
let props = if described(&own) { Some(own) } else { tile() };
if let Some(icc) = props.as_ref().and_then(|p| p.icc) {
return IccProfile::from_bytes(None, icc).ok().filter(|p| p.kind == ProfileKind::Rgb);
}
let nclx = match props {
Some(props) => props.nclx?,
None => stream_nclx(ctx)?,
};
(nclx.primaries == P3_PRIMARIES && nclx.transfer == SRGB_TRANSFER).then(|| cms::icc::builtin_rgb(cms::DISPLAY_P3)).flatten()
}
}
+15 -1
View File
@@ -103,6 +103,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
if super::jp2read::is_jpeg2000(bytes) {
return format("jpeg2000");
}
if super::heifread::is_heif(bytes) {
return format("heif");
}
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
return Some(f);
}
@@ -475,11 +478,15 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
}
// Threaded type read from a file flows through its frames (the threaded type of a restored
// document keeps its text as saved), and every frame of a thread takes its first frame's
// Optical Margin Alignment setting.
// Optical Margin Alignment setting and writing direction. When that changes the direction of a
// frame of a restored document, the story flows through that frame's thread again. Threaded
// frames keep their size: Auto Size is off for them.
if !doc.text_threads.is_empty() && !restored {
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
} else if !doc.text_threads.is_empty() {
crate::cmd::threads::keep_frame_sizes(&mut doc);
crate::cmd::threads::share_optical_margin(&mut doc);
crate::cmd::threads::share_orientation(&mut doc);
// A frame whose first paragraph continues from the frame before draws it without the
// paragraph's first-line indent and list marker.
crate::cmd::threads::mark_continued(&mut doc);
@@ -731,6 +738,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option<String> {
/// [`super::pcxread::decode`], which refuses an image past [`MAX_RASTER_ALLOC`] before decoding.
/// A Pixar picture file is recognized by its magic number ([`super::pxrread::is_pxr`]) and read by
/// [`super::pxrread::decode`], which does the same.
/// A HEIC or HEIF file is recognized by its `ftyp` brands ([`super::heifread::is_heif`]) and read
/// by [`super::heifread::decode`], which does the same.
/// 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
@@ -749,6 +758,11 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
if super::jp2read::is_jpeg2000(bytes) {
return as_png(super::jp2read::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
}
if super::heifread::is_heif(bytes) {
// The EXIF resolution describes the stored pixels; a quarter turn swaps its axes.
let ppi = ppi.map(|(x, y)| if super::heifread::quarter_turned(bytes) { (y, x) } else { (x, y) });
return as_png(super::heifread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
}
if super::pxrread::is_pxr(bytes) {
return as_png(super::pxrread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
}
+4 -1
View File
@@ -1,6 +1,7 @@
//! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and
//! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX
//! header's HDpi and VDpi, or JP2 capture and display resolution boxes. Place
//! header's HDpi and VDpi, JP2 capture and display resolution boxes, or a HEIF image's EXIF
//! resolution tags. Place
//! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it
//! as a `pHYs` chunk.
@@ -26,6 +27,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
super::pcxread::resolution(bytes)
} else if super::jp2read::is_jpeg2000(bytes) {
jp2(bytes)
} else if super::heifread::is_heif(bytes) {
super::heifread::with_exif(bytes, tiff)
} else {
None
};
+1 -1
View File
@@ -670,7 +670,7 @@ fn find_preset(s: &Session, name: &str) -> Option<ScreenExportPreset> {
}
/// `p` over the settings of its `exportPreset`, when it names one (its own keys win).
fn with_preset(s: &Session, p: &Value) -> Result<Value> {
pub(super) fn with_preset(s: &Session, p: &Value) -> Result<Value> {
let Some(name) = str_param(p, "exportPreset") else { return Ok(p.clone()) };
let preset = find_preset(s, name).ok_or_else(|| bad(C, format!("no export preset `{name}` (see document.exportPresets.list)")))?;
let mut merged = preset.settings;
@@ -0,0 +1,620 @@
//! HEIC and HEIF Open, Place, pixel, color and resolution regressions using original synthetic
//! fixtures (quadrants of four colors, lossy HEVC, so colors are compared within a tolerance).
#[cfg(feature = "heif")]
use serde_json::json;
use super::heifread::is_heif;
use super::*;
const QUADRANTS: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants.heic");
const ALPHA: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-alpha.heic");
const TEN_BIT: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-10bit.heic");
const GRID: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-grid.heic");
const GRID16: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-grid16.heic");
const PPI300: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-300ppi.heic");
const ROTATED: &[u8] = include_bytes!("../../../tests/fixtures/heif/wide-rotated.heic");
const ROTATED_300X150: &[u8] = include_bytes!("../../../tests/fixtures/heif/wide-rotated-300x150.heic");
const TONES: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones.heic");
const TONES_P3_NCLX: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-nclx.heic");
const TONES_P3_ICC: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-icc.heic");
const TONES_P3_ICC_ONLY: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-icc-only.heic");
const EVERY_FIXTURE: [&[u8]; 12] =
[QUADRANTS, ALPHA, TEN_BIT, GRID, GRID16, PPI300, ROTATED, ROTATED_300X150, TONES, TONES_P3_NCLX, TONES_P3_ICC, TONES_P3_ICC_ONLY];
/// Red, green, blue and white: the top-left, top-right, bottom-left and bottom-right quadrants.
#[cfg(feature = "heif")]
const COLORS: [[u8; 4]; 4] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255; 4]];
/// The centers of the four quadrants of a square image `side` pixels wide.
#[cfg(feature = "heif")]
fn centers(side: u32) -> [(u32, u32); 4] {
let (a, b) = (side / 4, side * 3 / 4);
[(a, a), (b, a), (a, b), (b, b)]
}
#[cfg(feature = "heif")]
fn decoded(bytes: &[u8]) -> image::RgbaImage {
let raster = load::raster_image(bytes).unwrap();
image::load_from_memory(&raster.blob.bytes).unwrap().to_rgba8()
}
#[cfg(feature = "heif")]
fn close(got: [u8; 4], want: [u8; 4], tolerance: u8) -> bool {
got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= tolerance)
}
#[cfg(feature = "heif")]
fn assert_quadrants(img: &image::RgbaImage, side: u32, what: &str) {
assert_eq!(img.dimensions(), (side, side), "{what}");
for ((x, y), want) in centers(side).into_iter().zip(COLORS) {
let got = img.get_pixel(x, y).0;
assert!(close(got, want, 8), "{what} at ({x}, {y}): {got:?} != {want:?}");
}
}
#[cfg(feature = "heif")]
#[test]
fn quadrants_open_and_place_as_their_pixels() {
assert_quadrants(&decoded(QUADRANTS), 32, "quadrants.heic");
for name in ["photo.heic", "photo.heif", "unknown.bin"] {
let mut s = Session::new();
let encoded = vectorcraft_format::base64_encode(QUADRANTS);
let opened = s.execute("document.open", &json!({"name": name, "dataBase64": encoded})).unwrap();
assert_eq!(opened["format"], "heif", "{name}");
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(32.0, 32.0));
let blob = s.doc().unwrap().doc.images.values().next().unwrap();
assert_quadrants(&image::load_from_memory(&blob.bytes).unwrap().to_rgba8(), 32, name);
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let placed = s.execute("file.place", &json!({"name": name, "dataBase64": encoded})).unwrap();
assert_eq!(placed["format"], "heif", "{name}");
}
assert!(load::texture_image(QUADRANTS).is_ok());
// The general brand alone, without `heic`.
let mut general = QUADRANTS.to_vec();
assert_eq!(&QUADRANTS[8..28], b"heic\0\0\0\0mif1heicmiaf");
general[8..12].copy_from_slice(b"mif1");
general[20..24].copy_from_slice(b"miaf");
assert_quadrants(&decoded(&general), 32, "mif1 only");
}
#[test]
fn signatures_and_every_extension() {
for bytes in EVERY_FIXTURE {
assert!(is_heif(bytes));
assert_eq!(load::detect("unknown.bin", bytes).map(|f| f.id), Some("heif"));
}
for ext in ["heic", "heif"] {
assert_eq!(load::detect(&format!("a.{ext}"), b"invalid").map(|f| f.id), Some("heif"));
assert!(OPEN_EXTS.contains(&ext) && PLACE_EXTS.contains(&ext) && UNASSOCIATED_EXTS.contains(&ext), "{ext}");
}
// A brand only among the compatible ones counts, and so do the general brands alone.
assert!(is_heif(b"\0\0\0\x14ftypabcd\0\0\0\0heic"));
assert!(is_heif(b"\0\0\0\x18ftypmif1\0\0\0\0mif1heix"));
assert!(is_heif(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc"));
assert!(is_heif(b"\0\0\0\x18ftypmif1\0\0\0\0mif1miaf"));
// A 64-bit box size (ISO/IEC 14496-12 4.2).
assert!(is_heif(b"\0\0\0\x01ftyp\0\0\0\0\0\0\0\x18heic\0\0\0\0"));
let mut extended = b"\0\0\0\x01ftyp\0\0\0\0\0\0\0\x18heic\0\0\0\0".to_vec();
extended.extend_from_slice(&QUADRANTS[QUADRANTS.windows(4).position(|w| w == b"meta").unwrap() - 4..]);
assert_eq!(load::detect("unknown.bin", &extended).map(|f| f.id), Some("heif"));
// AVIF, with its brand major or compatible, and brands heic-rs doesn't read.
assert!(!is_heif(b"\0\0\0\x18ftypavif\0\0\0\0mif1miaf"));
assert!(!is_heif(b"\0\0\0\x1cftypmif1\0\0\0\0mif1avifmiaf"));
assert!(!is_heif(b"\0\0\0\x1cftypheic\0\0\0\0mif1heicavis"));
assert!(!is_heif(b"\0\0\0\x18ftyphevx\0\0\0\0hevxiso8"));
assert!(!is_heif(b"\0\0\0\x18ftypisom\0\0\0\0isomavc1"));
// A box shorter than its brands, longer than the file, or of another type.
assert!(!is_heif(b"\0\0\0\x0cftypheic"));
assert!(!is_heif(b"\0\0\0\x40ftypheic\0\0\0\0mif1"));
assert!(!is_heif(b"\0\0\0\x14styphei\0\0\0\0\0heic"));
assert!(!is_heif(b""));
}
#[cfg(feature = "heif")]
#[test]
fn alpha_10_bit_and_grid_images_keep_their_pixels() {
let alpha = decoded(ALPHA);
assert_eq!(alpha.dimensions(), (32, 32));
for ((x, y), want) in centers(32).into_iter().zip(COLORS) {
let got = alpha.get_pixel(x, y).0;
// The left half is opaque; the right half is transparent.
if x < 16 {
assert!(close(got, want, 8), "({x}, {y}): {got:?} != {want:?}");
} else {
assert!(got[3] <= 8, "({x}, {y}): {got:?}");
}
}
assert_quadrants(&decoded(TEN_BIT), 32, "10-bit");
// Four 64 × 64 tiles, one for each quadrant.
assert_quadrants(&decoded(GRID), 128, "2 × 2 grid");
}
#[cfg(feature = "heif")]
#[test]
fn a_grid_of_cropped_tiles_is_refused() {
// 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each coded 64 × 64 and cropped back
// with its own clap, which heic-rs 0.1.4 leaves out when it composes the grid.
let error = load::raster_image(GRID16).err().unwrap().to_string();
assert!(error.contains("kind of image that can't be read"), "{error}");
}
#[cfg(feature = "heif")]
#[test]
fn a_grid_without_its_own_color_box_uses_its_tiles() {
// The grid item's associations (ipma) list ispe (1), pixi (5) and colr (3); the tiles carry
// the same colr. Pointing the grid's third association at its ispe leaves the grid without one.
let mut tiles_only = GRID.to_vec();
assert_eq!(&GRID[565..571], &[0, 1, 3, 1, 0x85, 3], "item 1 with three associations, the last colr");
tiles_only[570] = 1;
assert_quadrants(&decoded(&tiles_only), 128, "colr on the tiles only");
}
#[cfg(feature = "heif")]
#[test]
fn the_containers_rotation_is_applied() {
// A 32 × 16 image, red then blue, stored with a quarter turn clockwise.
let img = decoded(ROTATED);
assert_eq!(img.dimensions(), (16, 32));
assert!(close(img.get_pixel(8, 4).0, [255, 0, 0, 255], 8), "{:?}", img.get_pixel(8, 4));
assert!(close(img.get_pixel(8, 27).0, [0, 0, 255, 255], 8), "{:?}", img.get_pixel(8, 27));
let mut s = Session::new();
s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(ROTATED)})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(16.0, 32.0));
}
#[cfg(feature = "heif")]
#[test]
fn the_containers_mirror_and_crop_are_applied() {
// The rotated image's irot (property 6) turned to no rotation, and to an imir of the same
// size: mirrored left to right (ISO/IEC 23008-12 6.5.12, axis 0).
assert_eq!(&ROTATED[384..393], b"\0\0\0\x09irot\x03");
let mut stored = ROTATED.to_vec();
stored[392] = 0;
let mut mirrored = ROTATED.to_vec();
mirrored[388..393].copy_from_slice(b"imir\0");
let (stored, mirrored) = (decoded(&stored), decoded(&mirrored));
assert_eq!((stored.dimensions(), mirrored.dimensions()), ((32, 16), (32, 16)));
let red = [255, 0, 0, 255];
assert!(close(stored.get_pixel(8, 8).0, red, 8) && !close(stored.get_pixel(24, 8).0, red, 8), "{:?}", stored.get_pixel(8, 8));
for (x, y) in [(4, 4), (8, 8), (20, 12), (27, 3)] {
assert!(close(mirrored.get_pixel(x, y).0, stored.get_pixel(31 - x, y).0, 8), "({x}, {y})");
}
// The quadrants' clap (property 4) takes the top-left 32 × 32 of the 64 × 64 coded picture.
// Rewritten to its top-left 16 × 16: a center offset of -24 (ISO/IEC 14496-12 12.1.4) leaves
// the red quadrant alone. Its edge next to green is left out, as lossy chroma blends there.
assert_eq!(&QUADRANTS[329..337], b"\0\0\0\x28clap");
let mut cropped = QUADRANTS.to_vec();
let clap: Vec<u8> = [16u32, 1, 16, 1, (-48i32).cast_unsigned(), 2, (-48i32).cast_unsigned(), 2].iter().flat_map(|v| v.to_be_bytes()).collect();
cropped[337..369].copy_from_slice(&clap);
let cropped = decoded(&cropped);
assert_eq!(cropped.dimensions(), (16, 16));
for (x, y) in [(0, 0), (8, 8), (12, 0), (0, 12), (12, 12)] {
assert!(close(cropped.get_pixel(x, y).0, red, 8), "({x}, {y}): {:?}", cropped.get_pixel(x, y));
}
}
#[cfg(feature = "heif")]
#[test]
fn exif_resolution_sizes_the_image_and_72_ppi_without_it() {
let got = ppi::resolution(PPI300).unwrap();
assert!((got.0 - 300.0).abs() < 0.01 && (got.1 - 300.0).abs() < 0.01, "{got:?}");
assert_eq!(ppi::resolution(QUADRANTS), None);
let mut s = Session::new();
s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(PPI300)})).unwrap();
let size = s.doc().unwrap().doc.artboards[0].rect.size();
assert!((size.width - 7.68).abs() < 0.001 && (size.height - 7.68).abs() < 0.001, "{size:?}");
let blob = s.doc().unwrap().doc.images.values().next().unwrap();
let stored = ppi::resolution(&blob.bytes).unwrap();
assert!((stored.0 - 300.0).abs() < 0.01 && (stored.1 - 300.0).abs() < 0.01);
// 32 × 16 pixels at 300 ppi across and 150 down, turned a quarter: 16 × 32 pixels at 150 ppi
// across and 300 down, the same 7.68 × 7.68 pt either way.
assert_eq!(ppi::resolution(ROTATED_300X150), Some((300.0, 150.0)), "as stored");
let img = load::raster_image(ROTATED_300X150).unwrap();
assert_eq!((img.width, img.height, img.ppi), (16, 32, Some((150.0, 300.0))));
let mut s = Session::new();
s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(ROTATED_300X150)})).unwrap();
let size = s.doc().unwrap().doc.artboards[0].rect.size();
assert!((size.width - 7.68).abs() < 0.001 && (size.height - 7.68).abs() < 0.001, "{size:?}");
}
#[cfg(feature = "heif")]
#[test]
fn display_p3_images_are_converted_to_srgb() {
use vectorcraft_color::cms::{DISPLAY_P3, Intent, icc::builtin_rgb};
let p3 = builtin_rgb(DISPLAY_P3).unwrap();
let plain = decoded(TONES);
// The ICC-only file's samples are full-range BT.601, which only its HEVC stream says.
for (what, bytes) in [("nclx", TONES_P3_NCLX), ("ICC", TONES_P3_ICC), ("ICC only", TONES_P3_ICC_ONLY)] {
let converted = decoded(bytes);
for (x, y) in centers(32) {
let [r, g, b, a] = plain.get_pixel(x, y).0;
let rgb = p3.to_srgb_into(&[r, g, b].map(|v| f32::from(v) / 255.0), Intent::RelativeColorimetric).unwrap();
let [r, g, b] = rgb.map(|v| (v * 255.0).round() as u8);
let got = converted.get_pixel(x, y).0;
assert!(close(got, [r, g, b, a], 2), "{what} at ({x}, {y}): {got:?} != {:?}", [r, g, b, a]);
}
}
// The orange quadrant changes, so the conversion ran.
assert!(!close(decoded(TONES_P3_NCLX).get_pixel(8, 8).0, plain.get_pixel(8, 8).0, 4));
}
#[cfg(feature = "heif")]
#[test]
fn display_p3_named_by_the_stream_alone_is_converted_to_srgb() {
// The nclx file's item associates hvcC, colr (property 2), ispe, pixi and clap. Pointing the
// colr association at the ispe leaves the item without a color box, and its HEVC stream's
// video signal type, which names Display P3 with the sRGB curve, describes the samples.
let ipma = TONES_P3_NCLX.windows(4).position(|w| w == b"ipma").unwrap();
let at = ipma + 4 + 4 + 4 + 2 + 1 + 1;
assert_eq!(&TONES_P3_NCLX[at - 4..at + 4], &[0, 1, 5, 0x81, 2, 3, 5, 0x84], "item 1 with five associations, the second colr");
let mut stream_only = TONES_P3_NCLX.to_vec();
stream_only[at] = 3;
let want = decoded(TONES_P3_NCLX);
let got = decoded(&stream_only);
for (x, y) in centers(32) {
assert!(close(got.get_pixel(x, y).0, want.get_pixel(x, y).0, 2), "at ({x}, {y}): {:?} != {:?}", got.get_pixel(x, y), want.get_pixel(x, y));
}
}
#[cfg(feature = "heif")]
#[test]
fn two_quarter_turns_keep_the_resolution_axes() {
// The rotated image's ipma lists its irot once; a second association with the same irot turns
// it a half turn, so the EXIF resolution keeps its axes.
let ipma = ROTATED_300X150.windows(4).position(|w| w == b"ipma").unwrap();
let count = ipma + 4 + 4 + 4 + 2;
let irot = ROTATED_300X150.windows(4).position(|w| w == b"irot").unwrap();
let ipco = ROTATED_300X150.windows(4).position(|w| w == b"ipco").unwrap();
// The irot's property index: its place among the boxes of ipco, counted from 1.
let mut index = 1u8;
let mut at = ipco + 4;
while at < irot - 4 {
index += 1;
at += u32::from_be_bytes(ROTATED_300X150[at..at + 4].try_into().unwrap()) as usize;
}
assert_eq!(at, irot - 4, "irot is a box of ipco");
let n = usize::from(ROTATED_300X150[count]);
assert!(ROTATED_300X150[count + 1..count + 1 + n].contains(&(0x80 | index)), "the item's irot association, essential");
let mut twice = ROTATED_300X150.to_vec();
twice[count] += 1;
twice.insert(count + 1 + n, 0x80 | index);
// The ipma, iprp and meta boxes each grow by one byte, and the iloc offsets move with them.
let grow = |bytes: &mut Vec<u8>, name: &[u8; 4]| {
let at = bytes.windows(4).position(|w| w == name).unwrap() - 4;
let size = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) + 1;
bytes[at..at + 4].copy_from_slice(&size.to_be_bytes());
};
for name in [b"ipma", b"iprp", b"meta"] {
grow(&mut twice, name);
}
let iloc = twice.windows(4).position(|w| w == b"iloc").unwrap();
assert_eq!(&twice[iloc + 4..iloc + 8], &[0, 0, 0, 0], "iloc version 0");
let sizes = twice[iloc + 8];
assert_eq!((sizes >> 4, sizes & 0xf), (4, 4), "4-byte offsets and lengths");
assert_eq!(twice[iloc + 9] >> 4, 4, "4-byte base offsets");
let items = u16::from_be_bytes([twice[iloc + 10], twice[iloc + 11]]);
// Each item: its id, data reference index and base offset, which moves, then its extents.
let mut at = iloc + 12;
for _ in 0..items {
at += 2 + 2;
let offset = u32::from_be_bytes(twice[at..at + 4].try_into().unwrap()) + 1;
twice[at..at + 4].copy_from_slice(&offset.to_be_bytes());
at += 4;
let extents = u16::from_be_bytes([twice[at], twice[at + 1]]);
at += 2 + 8 * usize::from(extents);
}
let img = load::raster_image(&twice).unwrap();
assert_eq!((img.width, img.height, img.ppi), (32, 16, Some((300.0, 150.0))), "a half turn keeps the stored axes");
}
#[cfg(feature = "heif")]
#[test]
fn damaged_unsupported_and_oversized_files_are_refused() {
let error = |bytes: &[u8]| load::raster_image(bytes).err().unwrap().to_string();
for cut in [16, 40, QUADRANTS.len() / 2, QUADRANTS.len() - 5] {
assert!(load::raster_image(&QUADRANTS[..cut]).is_err(), "{cut}");
}
// An ftyp and nothing else, as an image sequence starts (its `moov` would follow).
assert!(error(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc").contains("kind of image that can't be read"));
assert!(error(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic\0\0\0\x08free").contains("HEIC or HEIF file"));
// The primary item's type (infe) turned from HEVC to AV1, with its hvcC kept, and with its
// hvcC turned into an av1C.
let mut av1 = QUADRANTS.to_vec();
assert_eq!((&QUADRANTS[137..141], &QUADRANTS[176..180]), (&b"hvc1"[..], &b"hvcC"[..]));
av1[137..141].copy_from_slice(b"av01");
assert!(error(&av1).contains("kind of image that can't be read"), "{}", error(&av1));
av1[176..180].copy_from_slice(b"av1C");
assert!(error(&av1).contains("kind of image that can't be read"), "{}", error(&av1));
// The image spatial extents (`ispe`) claim 60,000 × 60,000 pixels.
let mut huge = QUADRANTS.to_vec();
let at = huge.windows(4).position(|w| w == b"ispe").unwrap() + 8;
huge[at..at + 4].copy_from_slice(&60_000u32.to_be_bytes());
huge[at + 4..at + 8].copy_from_slice(&60_000u32.to_be_bytes());
assert!(error(&huge).contains("60000 × 60000 pixels is too large to draw"), "{}", error(&huge));
// A single 64 × 64 coded picture (cropped to 32 × 32) takes its RGBA output and copy,
// 64 × 64 × 8, and its planes, 64 × 64 × 9: no composed frame is counted.
assert!(super::heifread::decode(QUADRANTS, 64 * 64 * 17).is_ok());
assert!(super::heifread::decode(QUADRANTS, 64 * 64 * 17 - 1).unwrap_err().contains("too large to draw"));
// The same picture with an ispe of 32 × 32 (its crop's size): its stream still decodes
// 64 × 64, and that size is counted.
let mut small = QUADRANTS.to_vec();
let at = small.windows(4).position(|w| w == b"ispe").unwrap() + 8;
assert_eq!(&small[at..at + 8], b"\0\0\0\x40\0\0\0\x40");
small[at..at + 4].copy_from_slice(&32u32.to_be_bytes());
small[at + 4..at + 8].copy_from_slice(&32u32.to_be_bytes());
let refused = super::heifread::decode(&small, 64 * 64 * 17 - 1).map(|img| img.dimensions());
assert!(refused.as_ref().is_err_and(|e| e.contains("too large to draw")), "{refused:?}");
// A 128 × 128 grid of four 64 × 64 tiles adds its composed frame, 128 × 128 × 6.
let grid = 128 * 128 * (8 + 6) + 4 * 64 * 64 * 9;
assert!(super::heifread::decode(GRID, grid).is_ok());
assert!(super::heifread::decode(GRID, grid - 1).unwrap_err().contains("too large to draw"));
// The grid's ispe (property 1) claims 16 × 16 pixels; its grid data still composes 128 × 128,
// and that size is counted.
let mut understated = GRID.to_vec();
assert_eq!(&GRID[358..370], b"ispe\0\0\0\0\0\0\0\x80", "property 1 is the grid's 128 × 128 ispe");
understated[366..370].copy_from_slice(&16u32.to_be_bytes());
understated[370..374].copy_from_slice(&16u32.to_be_bytes());
assert!(super::heifread::decode(&understated, grid - 1).unwrap_err().contains("too large to draw"));
}
/// Appends `value` as `n` bits, most significant first.
#[cfg(feature = "heif")]
fn put(bits: &mut Vec<bool>, value: u64, n: u32) {
bits.extend((0..n).rev().map(|i| i < 64 && value >> i & 1 == 1));
}
/// Appends `value` as an unsigned Exp-Golomb code.
#[cfg(feature = "heif")]
fn put_ue(bits: &mut Vec<bool>, value: u64) {
let len = 64 - (value + 1).leading_zeros();
put(bits, 0, len - 1);
put(bits, value + 1, len);
}
/// The fields of SPS 0 of `width` × `height` 4:2:0 samples with `sub_layers` sub-layers, each
/// with its profile and level, up to its height.
#[cfg(feature = "heif")]
fn sps_head(width: u64, height: u64, sub_layers: u64) -> Vec<bool> {
sps_head_with_id(width, height, sub_layers, 0)
}
/// [`sps_head`] for SPS `id`.
#[cfg(feature = "heif")]
fn sps_head_with_id(width: u64, height: u64, sub_layers: u64, id: u64) -> Vec<bool> {
let mut bits = Vec::new();
put(&mut bits, 0, 4);
put(&mut bits, sub_layers, 3);
put(&mut bits, 1, 1);
put(&mut bits, 0, 96);
for _ in 0..sub_layers {
put(&mut bits, 0b11, 2);
}
if sub_layers > 0 {
put(&mut bits, 0, 2 * (8 - sub_layers as u32));
}
for _ in 0..sub_layers {
put(&mut bits, 0, 88 + 8);
}
put_ue(&mut bits, id);
put_ue(&mut bits, 1);
put_ue(&mut bits, width);
put_ue(&mut bits, height);
bits
}
/// An SPS NAL unit of `bits` (see [`nal`]).
#[cfg(feature = "heif")]
fn sps_nal(bits: Vec<bool>) -> Vec<u8> {
nal(33, bits)
}
/// A NAL unit of type `kind` holding `bits`, with its stop bit, and emulation prevention bytes
/// inserted as a stream carries them.
#[cfg(feature = "heif")]
fn nal(kind: u8, mut bits: Vec<bool>) -> Vec<u8> {
put(&mut bits, 1, 1);
while !bits.len().is_multiple_of(8) {
bits.push(false);
}
let rbsp: Vec<u8> = bits.chunks(8).map(|b| b.iter().fold(0u8, |v, &bit| v << 1 | u8::from(bit))).collect();
let mut nal = vec![kind << 1, 1];
let mut zeros = 0;
for b in rbsp {
if zeros >= 2 && b <= 3 {
nal.push(3);
zeros = 0;
}
zeros = if b == 0 { zeros + 1 } else { 0 };
nal.push(b);
}
nal
}
#[cfg(feature = "heif")]
fn sps(width: u64, height: u64, sub_layers: u64) -> Vec<u8> {
sps_nal(sps_head(width, height, sub_layers))
}
/// A 64 × 64 SPS that uses every optional part before the VUI's video signal type: a conformance
/// window, sub-layer ordering for two sub-layers, explicit scaling lists, PCM, a reference picture
/// set predicted from another and a long-term picture, then an Extended_SAR aspect ratio and
/// overscan information. `signal` is the full-range flag and the color description, if any.
#[cfg(feature = "heif")]
fn sps_with_vui(signal: Option<(u64, Option<[u64; 3]>)>) -> Vec<u8> {
sps_with_vui_and_id(signal, 0)
}
/// [`sps_with_vui`] for SPS `id`.
#[cfg(feature = "heif")]
fn sps_with_vui_and_id(signal: Option<(u64, Option<[u64; 3]>)>, id: u64) -> Vec<u8> {
let mut bits = sps_head_with_id(64, 64, 1, id);
put(&mut bits, 1, 1);
for offset in [0, 2, 0, 2] {
put_ue(&mut bits, offset);
}
put_ue(&mut bits, 0);
put_ue(&mut bits, 0);
// 8 bits of picture order count.
put_ue(&mut bits, 4);
put(&mut bits, 1, 1);
for _ in 0..2 * 3 {
put_ue(&mut bits, 1);
}
for value in [0, 3, 0, 3, 1, 1] {
put_ue(&mut bits, value);
}
// Scaling lists: matrix 1 of each size given explicitly, the others copied.
put(&mut bits, 0b11, 2);
for size in 0..4 {
for matrix in 0..if size == 3 { 2 } else { 6 } {
if matrix == 1 {
put(&mut bits, 1, 1);
if size > 1 {
put_ue(&mut bits, 5);
}
for _ in 0..if size == 0 { 16 } else { 64 } {
put_ue(&mut bits, 3);
}
} else {
put(&mut bits, 0, 1);
put_ue(&mut bits, 0);
}
}
}
// AMP off, SAO on, PCM with its depths, block sizes and loop filter flag.
put(&mut bits, 0b01, 2);
put(&mut bits, 1, 1);
put(&mut bits, 0x77, 8);
put_ue(&mut bits, 0);
put_ue(&mut bits, 1);
put(&mut bits, 0, 1);
// Two short-term sets: one negative and one positive picture, then a set predicted from it
// with three entries (unused with use_delta_flag, used, unused).
put_ue(&mut bits, 2);
put_ue(&mut bits, 1);
put_ue(&mut bits, 1);
for _ in 0..2 {
put_ue(&mut bits, 0);
put(&mut bits, 1, 1);
}
put(&mut bits, 1, 1);
put(&mut bits, 0, 1);
put_ue(&mut bits, 0);
put(&mut bits, 0b01100, 5);
// One long-term picture: 8 bits of picture order count and its used flag.
put(&mut bits, 1, 1);
put_ue(&mut bits, 1);
put(&mut bits, 0xab, 8);
put(&mut bits, 1, 1);
put(&mut bits, 0b11, 2);
// VUI: Extended_SAR 1:1 and overscan information.
put(&mut bits, 1, 1);
put(&mut bits, 1, 1);
put(&mut bits, 255, 8);
put(&mut bits, 0x0001_0001, 32);
put(&mut bits, 1, 1);
put(&mut bits, 0, 1);
match signal {
Some((full, description)) => {
put(&mut bits, 1, 1);
put(&mut bits, 5, 3);
put(&mut bits, full, 1);
put(&mut bits, u64::from(description.is_some()), 1);
for code in description.into_iter().flatten() {
put(&mut bits, code, 8);
}
}
None => put(&mut bits, 0, 1),
}
// No chroma location, field or display window information, timing or restrictions.
put(&mut bits, 0, 8);
sps_nal(bits)
}
#[cfg(feature = "heif")]
#[test]
fn picture_sizes_come_from_the_sequence_parameter_sets() {
use super::heifread::{picture_bytes, sps_size};
for sub_layers in [0, 1, 6] {
for (w, h) in [(32, 32), (4032, 3024), (65535, 65535)] {
let nal = sps(w, h, sub_layers);
assert!(nal.windows(3).any(|w| w == [0, 0, 3]), "the profile's zeros need emulation prevention");
assert_eq!(sps_size(&nal), Some((w, h, 1)), "{w} × {h}, {sub_layers} sub-layers");
}
}
let mut vps = sps(32, 32, 0);
vps[0] = 32 << 1;
assert_eq!(sps_size(&vps), None);
assert_eq!(sps_size(&sps(32, 32, 0)[..8]), None);
// 4:2:0 is 3 bytes a sample position in each of three sets of planes, in 64-sample blocks.
assert_eq!(picture_bytes(QUADRANTS), Some(64 * 64 * 9));
assert_eq!(picture_bytes(GRID), Some(4 * 64 * 64 * 9));
assert!(picture_bytes(ALPHA).unwrap() > 64 * 64 * 9, "the alpha image is counted");
}
#[cfg(feature = "heif")]
#[test]
fn the_color_description_comes_from_the_streams_video_signal_type() {
use super::heifread::{sps_signal, sps_size};
use heic_rs::{MatrixCoefficients, Range};
let signal = |nal: &[u8]| sps_signal(nal).map(|n| (n.primaries, n.transfer, n.matrix, n.matrix_code, n.range));
let described = sps_with_vui(Some((1, Some([12, 13, 6]))));
assert_eq!(sps_size(&described), Some((64, 64, 1)));
assert_eq!(signal(&described), Some((12, 13, MatrixCoefficients::Bt601, 6, Range::Full)));
assert_eq!(signal(&sps_with_vui(Some((0, None)))), Some((2, 2, MatrixCoefficients::Unspecified, 2, Range::Limited)));
assert_eq!(signal(&sps_with_vui(None)), None);
assert_eq!(signal(&sps(64, 64, 0)), None, "cut short before its VUI");
// The ICC-only fixture's stream: heif-enc writes full-range BT.601.
let ctx = heic_rs::context::Context::open(TONES_P3_ICC_ONLY).unwrap();
let props = ctx.props(ctx.meta.primary).unwrap();
assert!(props.nclx.is_none() && props.icc.is_some());
let sets = props.hvcc.unwrap().parameter_sets();
let nal = sets.into_iter().find(|nal| nal[0] >> 1 & 0x3f == 33).unwrap();
assert_eq!(sps_signal(nal).map(|n| (n.matrix, n.range)), Some((MatrixCoefficients::Bt601, Range::Full)));
}
#[cfg(feature = "heif")]
#[test]
fn the_slices_picture_parameter_set_picks_the_sps() {
use super::heifread::{active_sps, sps_signal};
use heic_rs::Range;
let full = sps_with_vui_and_id(Some((1, Some([1, 13, 6]))), 0);
let limited = sps_with_vui_and_id(Some((0, Some([1, 13, 1]))), 1);
let pps = |id: u64, sps: u64| {
let mut bits = Vec::new();
put_ue(&mut bits, id);
put_ue(&mut bits, sps);
nal(34, bits)
};
// first_slice_segment_in_pic_flag, no_output_of_prior_pics_flag for an IDR picture (type 19),
// slice_pic_parameter_set_id.
let slice = |kind: u8, pps: u64| {
let mut bits = vec![true];
if kind == 19 {
bits.push(false);
}
put_ue(&mut bits, pps);
nal(kind, bits)
};
let (pps0, pps3) = (pps(0, 0), pps(3, 1));
let sets = [full.as_slice(), limited.as_slice(), pps0.as_slice(), pps3.as_slice()];
let (idr3, trail0, idr5) = (slice(19, 3), slice(1, 0), slice(19, 5));
assert_eq!(active_sps(&sets, &[&idr3]), Some(limited.as_slice()));
assert_eq!(active_sps(&sets, &[&trail0]), Some(full.as_slice()));
assert_eq!(active_sps(&sets, &[&idr5]), None, "no PPS 5");
assert_eq!(active_sps(&sets, &[&pps0]), None, "no slice");
assert_eq!(active_sps(&sets, &[&idr3]).and_then(sps_signal).map(|n| (n.matrix_code, n.range)), Some((1, Range::Limited)));
}
#[cfg(not(feature = "heif"))]
#[test]
fn a_build_without_the_decoder_refuses_heif_files_with_a_message() {
for bytes in EVERY_FIXTURE {
assert_eq!(load::detect("unknown.bin", bytes).map(|f| f.id), Some("heif"));
let error = load::raster_image(bytes).err().unwrap().to_string();
assert!(error.contains("can't be opened in this build"), "{error}");
}
assert_eq!(ppi::resolution(PPI300), None);
}
+1 -1
View File
@@ -428,7 +428,7 @@ fn rulers(s: &mut Session, p: &Value) -> Result<Value> {
/// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`):
/// the plane and depth of the first one and the plane-space correction that lands the edge of the
/// joint bounds of the objects there nearer a gridline on it.
/// joint bounds of the objects on that plane, each measured at its own depth, nearer a gridline on it.
pub(crate) fn snap_fix(doc: &Document, ids: &[NodeId], p: &Value, (from, to): (Point, Point)) -> Option<(Plane, f64, Vec2)> {
let g = grid_of(doc);
if !bool_or(p, "snap", g.snap) {
+9
View File
@@ -150,11 +150,19 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("scaleCorners", "General", "Options", "Scale Corners", bool),
p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool),
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
p!("ctrlWheelScrollsSideways", "General", "Options", "Scroll Sideways with Cmd/Ctrl + Wheel", bool),
p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool),
p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool),
p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool),
// Selection & Anchor Display
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
p!(
"marqueeSelection",
"Selection & Anchor Display",
"Selection",
"Marquee Selection",
choice(&[("touching", "Touching"), ("enclosed", "Fully Enclosed"), ("directional", "Drag Direction")])
),
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
p!("snapToPointTolerance", "Selection & Anchor Display", "Selection", "Snap to Point", num(1.0, 8.0, "px")),
p!("ctrlClickSelectsBehind", "Selection & Anchor Display", "Selection", "Command Click to Select Objects Behind", bool),
@@ -207,6 +215,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool),
p!("highlightSubstitutedFonts", "Type", "Options", "Highlight Substituted Fonts", bool),
p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool),
p!("typedLists", "Type", "Options", "Start Lists From Typed Markers", bool),
p!("fontsFolder", "Type", "Options", "Additional Fonts Folder", text),
// Units
p!("unitsGeneral", "Units", "", "General", choice(UNITS)),
+1 -1
View File
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Bullets and Numbering",
[],
None,
"{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), or \"* \", \"# \", \"> \" or \"+ \" (a bulleted list with the filled dot), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}",
"{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), \"* \" (a bulleted list with the filled dot), \"+ \" (the hollow circle), \"# \" (the filled square) or \"> \" (the dash), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}",
has_doc,
start_list
),
+61 -3
View File
@@ -168,7 +168,17 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
}
}
let optical = if live.len() >= 2 { optical_margin(before, doc, &live) } else { None };
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
let vertical = if live.len() >= 2 { thread_setting(before, doc, &live, |t| t.vertical) } else { None };
// The story is measured in each frame with the settings the frame takes.
let frames: Vec<TextObject> = live
.iter()
.filter_map(|id| text_of(doc, *id).cloned())
.map(|mut t| {
t.optical_margin = optical.unwrap_or(t.optical_margin);
t.vertical = vertical.unwrap_or(t.vertical);
t
})
.collect();
let refs: Vec<&TextObject> = frames.iter().collect();
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, &paras);
for (id, part) in live.iter().zip(parts) {
@@ -177,11 +187,13 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
next.runs = part.runs;
next.set_paragraph_styles(part.paras);
let optical = optical.unwrap_or(t.optical_margin);
let vertical = vertical.unwrap_or(t.vertical);
let continues = part.continues;
if next.runs != t.runs
|| next.para != t.para
|| next.paras != t.paras
|| optical != t.optical_margin
|| vertical != t.vertical
|| part.list_count != t.list_count
|| continues != t.first_para_continues
{
@@ -189,6 +201,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
t.para = next.para;
t.paras = next.paras;
t.optical_margin = optical;
t.vertical = vertical;
t.list_count = part.list_count;
t.first_para_continues = continues;
refresh_bounds(t);
@@ -235,11 +248,37 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
/// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes:
/// that of the first frame whose setting changed between `before` and `doc`, else the first frame's.
fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option<bool> {
let now = |id: &NodeId| text_of(doc, *id).map(|t| t.optical_margin);
let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.optical_margin) != now(id)));
thread_setting(before, doc, live, |t| t.optical_margin)
}
/// The value of the frame setting `get` that every frame of a thread with the frames `live` takes:
/// that of the first frame whose value changed between `before` and `doc`, or the first frame's value
/// when none changed.
fn thread_setting<T: PartialEq>(before: &Document, doc: &Document, live: &[NodeId], get: impl Fn(&TextObject) -> T) -> Option<T> {
let now = |id: &NodeId| text_of(doc, *id).map(&get);
let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(get(t)) != now(id)));
changed.or(live.first()).and_then(now)
}
/// Turn Auto Size off for the frames of each thread of two or more frames, as [`reflow`] does,
/// without re-flowing the story: for a document restored from a file as it was saved, so a later
/// change to a frame's settings keeps the frame's size.
pub(crate) fn keep_frame_sizes(doc: &mut Document) {
for thread in doc.text_threads.clone() {
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
if live.len() < 2 {
continue;
}
for id in live {
if text_of(doc, id).is_some_and(|t| t.area.fit == vectorcraft_doc::AreaFit::AutoHeight)
&& let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind)
{
t.area.fit = vectorcraft_doc::AreaFit::None;
}
}
}
}
/// Give every frame of each thread the first frame's Optical Margin Alignment setting, without
/// re-flowing the story: for a document restored from a file as it was saved.
pub(crate) fn share_optical_margin(doc: &mut Document) {
@@ -257,6 +296,25 @@ pub(crate) fn share_optical_margin(doc: &mut Document) {
}
}
/// Give every frame of each thread the first frame's writing direction, and re-flow each thread in
/// which that changes a frame: for a document restored from a file as it was saved.
pub(crate) fn share_orientation(doc: &mut Document) {
let before = doc.clone();
for thread in doc.text_threads.clone() {
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
let Some(vertical) = thread_setting(doc, doc, &live, |t| t.vertical) else { continue };
// Only the frames whose direction changes are copied, so only their threads re-flow.
let turn: Vec<NodeId> = live.into_iter().filter(|id| text_of(doc, *id).is_some_and(|t| t.vertical != vertical)).collect();
for id in turn {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.vertical = vertical;
refresh_bounds(t);
}
}
}
reflow(&before, doc);
}
/// Mark the frames of each thread whose first paragraph continues from the frame before
/// ([`TextObject::first_para_continues`]), without re-flowing the story: for a document restored
/// from a file as it was saved.
+171
View File
@@ -1090,6 +1090,177 @@ fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() {
}
}
/// The writing direction of each of the frames `ids` (true: vertical).
fn vertical<const N: usize>(s: &Session, ids: [u64; N]) -> [bool; N] {
ids.map(|id| {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
t.vertical
})
}
/// Whether each frame of the thread `ids` holds the part of the story that its layout, in its
/// writing direction, has room for: the parts that [`vectorcraft_text::thread::distribute`]
/// gives the frames as they are now.
fn flows_as_laid_out(s: &Session, ids: &[u64]) -> bool {
let d = &s.doc().unwrap().doc;
let frames: Vec<&TextObject> = ids.iter().map(|id| text_of(d, NodeId(*id)).unwrap()).collect();
let story = vectorcraft_text::thread::story(&frames);
let paras = vectorcraft_text::thread::story_paras(&frames, &[]);
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &frames, &story, &paras);
let texts = |runs: &[TextRun]| runs.iter().map(|r| r.text.as_str()).collect::<String>();
parts.iter().zip(&frames).all(|(part, f)| texts(&part.runs) == f.plain_text())
}
/// Type Orientation is a setting of the whole thread: Type › Type Orientation in any frame sets
/// it in every frame of the thread, in one undo step, and the story flows through the frames in
/// that direction. Typing keeps it, and frames taken out of the thread keep it.
#[test]
fn type_orientation_is_set_for_every_frame_of_a_thread() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let r = s.execute("type.orientation.vertical", &json!({})).unwrap();
assert_eq!(r, json!({"vertical": true, "ids": [a, b]}));
assert_eq!(vertical(&s, [a, b]), [true, true], "the second frame is vertical too");
assert!(flows_as_laid_out(&s, &[a, b]));
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4));
// Set back from the second frame, both are horizontal, and one undo makes both vertical.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
assert_eq!(s.execute("type.orientation.horizontal", &json!({})).unwrap()["ids"], json!([b, a]));
assert_eq!(vertical(&s, [a, b]), [false, false]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(vertical(&s, [a, b]), [true, true]);
// Typing in the first frame re-flows the story and keeps the direction.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap();
assert_eq!(vertical(&s, [a, b]), [true, true]);
assert!(flows_as_laid_out(&s, &[a, b]));
// Any edit that sets one frame sets the thread.
s.edit("Set", |d, _| {
let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") };
t.vertical = false;
refresh_bounds(t);
Ok(())
})
.unwrap();
assert_eq!(vertical(&s, [a, b]), [false, false]);
assert!(flows_as_laid_out(&s, &[a, b]));
s.execute("edit.undo", &json!({})).unwrap();
// Released or unthreaded, each frame keeps it.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert_eq!(vertical(&s, [a, b]), [true, true]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("text.thread.remove", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!(vertical(&s, [a, b]), [true, true]);
}
/// Threading type gives the joined thread the writing direction of its first frame, and the
/// story flows through every frame in that direction.
#[test]
fn a_thread_takes_its_first_frames_type_orientation() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let area = json!({"width": 150, "height": 60});
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": area, "vertical": true})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 60, "height": 200})).unwrap());
let c = new_id(s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert_eq!(vertical(&s, [a, b, c]), [true; 3]);
assert!(!text(&s, b).is_empty() && !text(&s, c).is_empty());
assert!(flows_as_laid_out(&s, &[a, b, c]), "the middle frame holds what fits it as vertical type");
// From `c`'s out port into horizontal area type `d`: `d` joins the thread and becomes vertical.
let d = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap());
s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap();
assert_eq!(threads(&s), [[a, b, c, d]]);
assert_eq!(vertical(&s, [a, b, c, d]), [true; 4]);
assert!(flows_as_laid_out(&s, &[a, b, c, d]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(vertical(&s, [d]), [false], "undo gives `d` its own direction back");
}
/// A file saved with the frames of one thread in different writing directions opens with the
/// first frame's direction in every frame, and the story flows through them in it: a native
/// file, and an EPS file restored from the document it carries.
#[test]
fn a_thread_saved_with_differing_orientations_opens_with_the_first_frames() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] {
for (id, on) in [(a, first), (b, !first)] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.vertical = on;
refresh_bounds(t);
}
let bytes = if name.ends_with(".eps") {
let r = s.execute("document.exportEps", &json!({})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
} else {
vectorcraft_format::save(&s.doc().unwrap().doc, false)
};
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], name.ends_with(".eps"), "{r}");
assert_eq!(threads(&s), [[a, b]], "{name}");
assert_eq!(vertical(&s, [a, b]), [first, first], "{name}, first frame vertical: {first}");
assert!(flows_as_laid_out(&s, &[a, b]), "{name}, first frame vertical: {first}");
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4), "{name}");
}
}
/// A thread restored from an EPS file whose frames share one writing direction keeps its text
/// in its frames as saved.
#[test]
fn a_restored_thread_in_one_direction_keeps_its_text_as_saved() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
let story = TEXT.repeat(4);
for (id, part) in [(a, &story[..10]), (b, &story[10..])] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.runs = vec![TextRun::new(part.to_string(), t.runs[0].style.clone())];
refresh_bounds(t);
}
let r = s.execute("document.exportEps", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let r = s.execute("document.open", &json!({"name": "saved.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], true, "{r}");
assert_eq!((text(&s, a), text(&s, b)), (story[..10].to_string(), story[10..].to_string()));
}
/// A threaded frame restored from a file with Auto Size on keeps its size when it turns from
/// vertical to horizontal: as the file opens, when the first frame is horizontal, and with Type
/// Orientation, when the whole thread is vertical.
#[test]
fn a_restored_threaded_frame_with_auto_size_keeps_its_size_when_it_turns_horizontal() {
for first_vertical in [false, true] {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
for (id, on) in [(a, first_vertical), (b, true)] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.vertical = on;
if id == b {
t.area.fit = vectorcraft_doc::AreaFit::AutoHeight;
}
refresh_bounds(t);
}
let saved = frame_box(&s, b);
let r = s.execute("document.exportEps", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let r = s.execute("document.open", &json!({"name": "saved.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], true, "{r}");
if first_vertical {
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("type.orientation.horizontal", &json!({})).unwrap();
}
assert_eq!(vertical(&s, [a, b]), [false, false], "first frame vertical: {first_vertical}");
assert_eq!(frame_box(&s, b), saved, "first frame vertical: {first_vertical}");
assert!(flows_as_laid_out(&s, &[a, b]), "first frame vertical: {first_vertical}");
}
}
/// One count runs through a thread: the second frame's numbered paragraphs go on from the
/// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a
/// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks
+13 -5
View File
@@ -16,15 +16,21 @@ use super::{
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("type.orientation.vertical", "Vertical", ["Type", "Orientation"], None, "{ids?|id?} set vertical writing", has_selection, |s, p| {
orientation(s, p, true)
}),
cmd!(
"type.orientation.vertical",
"Vertical",
["Type", "Orientation"],
None,
"{ids?|id?} set vertical writing for the selected type and every frame of its threads → {vertical, ids}",
has_selection,
|s, p| orientation(s, p, true)
),
cmd!(
"type.orientation.horizontal",
"Horizontal",
["Type", "Orientation"],
None,
"{ids?|id?} set horizontal writing",
"{ids?|id?} set horizontal writing for the selected type and every frame of its threads → {vertical, ids}",
has_selection,
|s, p| orientation(s, p, false)
),
@@ -139,8 +145,10 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Type › Type Orientation: set vertical or horizontal writing on every selected text object and
/// every frame of their threads, in one undo step.
fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
let ids = text_targets(s, p, "Text Orientation")?;
let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Text Orientation")?);
s.edit("Text Orientation", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
+10
View File
@@ -54,6 +54,8 @@ pub struct Prefs {
/// Scale Strokes & Effects.
pub scale_strokes: bool,
pub zoom_with_mouse_wheel: bool,
/// While Zoom with Mouse Wheel is off, Cmd/Ctrl-wheel scrolls sideways instead of zooming.
pub ctrl_wheel_scrolls_sideways: bool,
/// A horizontal drag on a numeric field or its label steps its value (#400).
pub scrub_numeric_fields: bool,
/// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°.
@@ -65,6 +67,8 @@ pub struct Prefs {
pub paste_offset: f64,
// Selection & Anchor Display
pub selection_tolerance: f64,
/// Selection-tool marquee: touching (default), enclosed, or screen drag direction.
pub marquee_selection: String,
pub object_selection_by_path_only: bool,
pub snap_to_point_tolerance: f64,
pub ctrl_click_selects_behind: bool,
@@ -95,6 +99,9 @@ pub struct Prefs {
/// font is highlighted, in every document (on by default).
pub highlight_substituted_fonts: bool,
pub placeholder_text: bool,
/// Type › Start Lists From Typed Markers: a list marker and a space typed at the start of a
/// paragraph start a list (on by default). Off, they stay as typed text.
pub typed_lists: bool,
// Units
pub units_general: String,
pub units_stroke: String,
@@ -367,11 +374,13 @@ impl Default for Prefs {
scale_corners: false,
scale_strokes: false,
zoom_with_mouse_wheel: false,
ctrl_wheel_scrolls_sideways: true,
scrub_numeric_fields: true,
trackpad_rotate_view: true,
hold_tool_keys: true,
paste_offset: 10.0,
selection_tolerance: 3.0,
marquee_selection: "touching".into(),
object_selection_by_path_only: false,
snap_to_point_tolerance: 2.0,
ctrl_click_selects_behind: true,
@@ -399,6 +408,7 @@ impl Default for Prefs {
highlight_alternate_glyphs: false,
highlight_substituted_fonts: true,
placeholder_text: true,
typed_lists: true,
units_general: s("points"),
units_stroke: s("points"),
units_type: s("points"),
+310
View File
@@ -3221,6 +3221,316 @@ fn image_trace_release_and_expand() {
assert!(s.execute("imageTrace.release", &json!({})).is_err());
}
/// Expand by color: each color's paths in a group of their own, named by the color, the groups
/// stacked by the lowest path in each and the art drawn as before; Release to Layers then gives a
/// sublayer for each color.
#[test]
#[allow(clippy::cognitive_complexity)] // Exercises several color and stacking cases in one trace fixture.
fn image_trace_expand_by_color_groups_each_colors_paths() {
// A light field with a black ring and a red square on it.
let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| {
let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 75.0);
if (34.0..=40.0).contains(&r) {
[0, 0, 0, 255]
} else if (130..170).contains(&x) && (55..95).contains(&y) {
[200, 40, 50, 255]
} else {
[240, 240, 220, 255]
}
});
let names = |n: &Node| n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect::<Vec<_>>();
let hex = |n: &Node| n.appearance.fill_paint().color().unwrap().to_hex();
let shot = |s: &Session| {
let img = render_doc(s, false);
(0..200).flat_map(|x| (0..150).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).collect::<Vec<_>>()
};
for method in ["abutting", "overlapping"] {
let params = json!({"mode": "color", "palette": "limited", "colors": 3, "method": method});
let expand = |by_color: bool| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params, "byColor": by_color})).unwrap();
let g = NodeId(r["id"].as_u64().unwrap());
(s, g)
};
let (flat, fg) = expand(false);
let (mut s, g) = expand(true);
let paths = node(&flat, fg).children().unwrap().clone();
// Without byColor (the default) the paths sit in the trace's group, with no group of their own.
assert!(paths.iter().all(|p| !matches!(p.kind, NodeKind::Group { .. })), "{method}");
let (default, dg) = {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap();
(s, NodeId(r["id"].as_u64().unwrap()))
};
assert!(*node(&default, dg).children().unwrap() == paths, "{method}: byColor is false when not given");
// The colors in the order they first come in, bottom to top.
let mut order: Vec<String> = vec![];
for p in &paths {
if !order.contains(&hex(p)) {
order.push(hex(p));
}
}
let mut sorted = order.clone();
sorted.sort();
assert_eq!(sorted, ["#000000", "#c82832", "#f0f0dc"], "{method}");
let n = node(&s, g);
assert_eq!(names(&n), order, "{method}");
// Each group holds its color's paths, and in turn they are the paths of the trace without
// byColor, in their order.
let groups = n.children().unwrap();
for c in groups {
assert!(matches!(c.kind, NodeKind::Group { .. }), "{method}");
assert!(c.children().unwrap().iter().all(|m| Some(hex(m)) == c.name), "{method}: {:?}", c.name);
}
let members: Vec<Arc<Node>> = groups.iter().flat_map(|c| c.children().unwrap().iter().cloned()).collect();
assert!(members == paths, "{method}");
assert!(shot(&s) == shot(&flat), "{method}: the art changed");
// Release to Layers: a sublayer for each color, in the same order.
let r = s.execute("layer.releaseToLayers", &json!({"id": g.0})).unwrap();
let subs = ids(&r);
assert_eq!(subs.len(), 3, "{method}");
for (sub, name) in subs.iter().zip(&order) {
let l = node(&s, *sub);
assert!(matches!(l.kind, NodeKind::Layer { .. }), "{method}");
assert_eq!(names(&l), std::slice::from_ref(name), "{method}");
}
}
// imageTrace.expand takes it too, as one undo step.
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let params = json!({"mode": "color", "palette": "limited", "colors": 3});
let g = NodeId(s.execute("imageTrace.make", &json!({"params": params})).unwrap()["id"].as_u64().unwrap());
let steps = s.doc().unwrap().history.undo.len();
s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
let mut got = names(&node(&s, g));
got.sort();
assert_eq!(got, ["#000000", "#c82832", "#f0f0dc"]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace"));
// A trace of one color gives one group holding every path.
let mut one = session();
let bar =
vectorcraft_trace::Raster::from_fn(80, 40, |x, y| if (10..70).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [255; 4] });
add_image(&mut one, &bar, Affine::IDENTITY);
let r = one.execute("imageTrace.makeAndExpand", &json!({"params": {"ignoreWhite": true}, "byColor": true})).unwrap();
let og = node(&one, NodeId(r["id"].as_u64().unwrap()));
assert_eq!(names(&og), ["#000000"]);
let only = &og.children().unwrap()[0];
assert!(!only.children().unwrap().is_empty() && only.children().unwrap().iter().all(|m| hex(m) == "#000000"));
// Auto Grouping's groups are not split: refused, and the trace stays as it was.
let mut grouped = params.clone();
grouped["autoGrouping"] = json!(true);
let e = s.execute("imageTrace.makeAndExpand", &json!({"id": g.0, "params": grouped, "byColor": true})).unwrap_err().to_string();
assert!(e.contains("Auto Grouping"), "{e}");
let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "params": grouped})).unwrap()["id"].as_u64().unwrap());
let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string();
assert!(e.contains("Auto Grouping"), "{e}");
assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace"));
let e = s.execute("imageTrace.expand", &json!({"byColor": "yes"})).unwrap_err().to_string();
assert!(e.contains("true or false"), "{e}");
// Refused too when Auto Grouping grouped nothing, as with side-by-side stripes.
let mut s = session();
let stripes = vectorcraft_trace::Raster::from_fn(60, 60, |x, _| match x / 20 {
0 => [200, 40, 40, 255],
1 => [40, 160, 60, 255],
_ => [40, 70, 200, 255],
});
add_image(&mut s, &stripes, Affine::IDENTITY);
let g = NodeId(s.execute("imageTrace.make", &json!({"params": grouped})).unwrap()["id"].as_u64().unwrap());
assert!(node(&s, g).children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Group { .. })));
let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string();
assert!(e.contains("Auto Grouping"), "{e}");
assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace"));
// A color linked to a global swatch goes by the swatch's name, a tint with its percentage.
let mut s = session();
s.execute("swatch.new", &json!({"name": "Ink", "color": "#c82828", "global": true})).unwrap();
s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap();
let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) {
(true, _) => [200, 40, 40, 255],
(false, true) => [40, 160, 60, 255],
(false, false) => [40, 70, 200, 255],
});
add_image(&mut s, &r, Affine::IDENTITY);
let limited = json!({"mode": "color", "palette": "limited", "colors": 3});
let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap());
let paths: Vec<(u64, String)> =
node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| (c.id.0, hex(c))).collect();
let mut colors: Vec<&str> = paths.iter().map(|(_, h)| h.as_str()).collect();
colors.sort();
colors.dedup();
assert_eq!(colors, ["#2846c8", "#28a03c", "#c82828"]);
// The red paths link to Ink and the blue ones to Sky at 50%; green stays a plain color.
let pick = |want: &str| paths.iter().filter(|(_, h)| h == want).map(|(id, _)| *id).collect::<Vec<_>>();
s.execute("paint.setFill", &json!({"ids": pick("#c82828"), "swatch": "Ink"})).unwrap();
s.execute("paint.setFill", &json!({"ids": pick("#2846c8"), "swatch": "Sky", "tint": 50})).unwrap();
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
let mut got = names(&node(&s, g));
got.sort();
assert_eq!(got, ["#28a03c", "Ink", "Sky 50%"]);
// A swatch named as another color's #rrggbb still gets a group of its own.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("swatch.edit", &json!({"name": "Ink", "newName": "#28a03c"})).unwrap();
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
let mut got = names(&node(&s, g));
got.sort();
assert_eq!(got, ["#28a03c", "#28a03c", "Sky 50%"]);
// Gradient fills share one group, and a stroked line goes by its stroke.
let ramp = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| {
let t = (f64::from(x) + 0.5) / 100.0;
[(20.0 + 200.0 * t) as u8, 40, (220.0 - 180.0 * t) as u8, 255]
});
let line =
vectorcraft_trace::Raster::from_fn(100, 100, |x, y| if (10..90).contains(&x) && (49..52).contains(&y) { [0, 0, 0, 255] } else { [255; 4] });
for (art, params, want) in [
(ramp, json!({"preset": "6 Colors", "params": {"gradients": true}, "byColor": true}), "Gradients"),
(line, json!({"params": {"ignoreWhite": true, "strokes": true, "strokeWidth": 5}, "byColor": true}), "#000000"),
] {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let res = s.execute("imageTrace.makeAndExpand", &params).unwrap();
assert_eq!(names(&node(&s, NodeId(res["id"].as_u64().unwrap()))), [want]);
}
// Flat Logo stacks its shapes biggest first without cutting holes under white: a black ring's
// outline, a white disc on it and a black dot on that. Black keeps a group below white and one
// above it, and the art draws as before, through Make and Expand and through Expand.
let target = vectorcraft_trace::Raster::from_fn(120, 120, |x, y| {
let r = (f64::from(x) + 0.5 - 60.0).hypot(f64::from(y) + 0.5 - 60.0);
if r < 15.0 || (30.0..50.0).contains(&r) {
[0, 0, 0, 255]
} else if r < 30.0 {
[255, 255, 255, 255]
} else {
[0, 0, 0, 0]
}
});
let logo = json!({"mode": "logo", "logoColors": ["#000000"]});
let traced = |by_color: bool, expand_after: bool| {
let mut s = session();
add_image(&mut s, &target, Affine::IDENTITY);
let g = if expand_after {
let g = s.execute("imageTrace.make", &json!({"params": logo})).unwrap()["id"].as_u64().unwrap();
s.execute("imageTrace.expand", &json!({"byColor": by_color})).unwrap();
g
} else {
s.execute("imageTrace.makeAndExpand", &json!({"params": logo, "byColor": by_color})).unwrap()["id"].as_u64().unwrap()
};
(s, NodeId(g))
};
for expand_after in [false, true] {
let (flat, fg) = traced(false, expand_after);
let (s, g) = traced(true, expand_after);
let paths = node(&flat, fg).children().unwrap().clone();
assert_eq!(paths.iter().map(|p| hex(p)).collect::<Vec<_>>(), ["#000000", "#ffffff", "#000000"]);
assert_eq!(names(&node(&s, g)), ["#000000", "#ffffff", "#000000"], "expand after: {expand_after}");
let members: Vec<Arc<Node>> = node(&s, g).children().unwrap().iter().flat_map(|c| c.children().unwrap().iter().cloned()).collect();
assert!(members == paths, "expand after: {expand_after}");
assert!(shot(&s) == shot(&flat), "expand after: {expand_after}: the art changed");
}
// An Abutting trace's top path, given the bottom path's color and moved onto the middle one (by
// a move or by a Transform effect), moved clear of it with a drop shadow cast onto it, or moved
// to share a pixel at its edge, before Expand, still draws on top of it.
let stripes = vectorcraft_trace::Raster::from_fn(90, 30, |x, _| match x / 30 {
0 => [200, 40, 40, 255],
1 => [40, 160, 60, 255],
_ => [40, 70, 200, 255],
});
let edited = |by_color: bool, how: &str| {
let mut s = session();
add_image(&mut s, &stripes, Affine::IDENTITY);
let limited = json!({"mode": "color", "palette": "limited", "colors": 3, "method": "abutting"});
let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap());
let paths: Vec<Arc<Node>> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect();
assert_eq!(paths.len(), 3);
let (first, middle, last) = (&paths[0], &paths[1], &paths[2]);
s.execute("paint.setFill", &json!({"ids": [last.id.0], "color": hex(first)})).unwrap();
let (to, from) = (middle.visual_bounds().unwrap(), last.visual_bounds().unwrap());
let (dx, dy) = (to.x0 - from.x0, to.y0 - from.y0);
match how {
"beside" => {
// The middle path and the moved top path end and start inside one pixel.
s.execute("object.move", &json!({"ids": [middle.id.0], "dx": -0.5, "dy": 0})).unwrap();
s.execute("object.move", &json!({"ids": [last.id.0], "dx": -0.3, "dy": 0})).unwrap()
}
"move" => s.execute("object.move", &json!({"ids": [last.id.0], "dx": dx, "dy": dy})).unwrap(),
"transform" => {
s.execute("effect.apply", &json!({"ids": [last.id.0], "effect": "distort.transform", "params": {"moveH": dx, "moveV": dy}})).unwrap()
}
_ => {
s.execute("object.move", &json!({"ids": [last.id.0], "dx": 40, "dy": 0})).unwrap();
s.execute("effect.apply", &json!({"ids": [last.id.0], "effect": "stylize.dropShadow", "params": {"x": -70, "y": 0, "blur": 0}}))
.unwrap()
}
};
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": by_color})).unwrap();
(s, g)
};
for how in ["move", "transform", "shadow", "beside"] {
let (flat, _) = edited(false, how);
let (s, g) = edited(true, how);
assert_eq!(names(&node(&s, g)).len(), 3, "{how}");
assert!(shot(&s) == shot(&flat), "{how}: the edited art changed");
}
// Two tints of one swatch that round to the same percentage are still two colors.
let mut s = session();
s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap();
add_image(&mut s, &stripes, Affine::IDENTITY);
let limited = json!({"mode": "color", "palette": "limited", "colors": 3});
let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap());
let paths: Vec<u64> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| c.id.0).collect();
assert_eq!(paths.len(), 3);
s.execute("paint.setFill", &json!({"ids": [paths[0]], "swatch": "Sky", "tint": 50.1})).unwrap();
s.execute("paint.setFill", &json!({"ids": [paths[1], paths[2]], "swatch": "Sky", "tint": 50.4})).unwrap();
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
let groups = node(&s, g).children().unwrap().clone();
assert_eq!(names(&node(&s, g)), ["Sky 50%", "Sky 50%"]);
assert_eq!(groups.iter().map(|c| c.children().unwrap().len()).collect::<Vec<_>>(), [1, 2]);
}
#[test]
fn image_trace_expand_by_color_preserves_live_pathfinder_art() {
let mut s = session();
let art = vectorcraft_trace::Raster::from_fn(100, 60, |x, y| {
if (10..40).contains(&y) && ((10..35).contains(&x) || (65..90).contains(&x)) { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
});
add_image(&mut s, &art, Affine::IDENTITY);
let g = NodeId(s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap()["id"].as_u64().unwrap());
let paths: Vec<NodeId> = node(&s, g).children().unwrap().iter().filter(|c| c.path_data().is_some()).map(|c| c.id).collect();
assert_eq!(paths.len(), 2);
s.execute("select.set", &json!({"ids": [paths[1].0]})).unwrap();
s.execute("object.move", &json!({"dx": -45, "dy": 0})).unwrap();
s.execute("effect.apply", &json!({"ids": [g.0], "effect": "pathfinder.subtract"})).unwrap();
let before = render_doc(&s, false);
let before_pixels: Vec<_> = (0..100).flat_map(|x| (0..60).map(move |y| (x, y))).map(|(x, y)| before.pixel(x, y)).collect();
let original = node(&s, g);
let steps = s.doc().unwrap().history.undo.len();
let result = s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true}));
if let Err(e) = &result {
assert!(e.to_string().contains("Pathfinder"), "{e}");
assert_eq!(node(&s, g), original);
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
}
let after = render_doc(&s, false);
let after_pixels: Vec<_> = (0..100).flat_map(|x| (0..60).map(move |y| (x, y))).map(|(x, y)| after.pixel(x, y)).collect();
let changed = after_pixels.iter().zip(&before_pixels).filter(|(a, b)| a != b).count();
assert_eq!(changed, 0, "byColor changed {changed} live Pathfinder pixels: {result:?}");
assert!(result.is_err(), "byColor must refuse a live Pathfinder operand group");
let error = s.execute("imageTrace.makeAndExpand", &json!({"id": g.0, "params": {"ignoreWhite": true}, "byColor": true})).unwrap_err();
assert!(error.to_string().contains("Pathfinder"), "{error}");
assert_eq!(node(&s, g), original);
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
}
#[test]
fn image_trace_make_and_expand_and_color_presets() {
let mut s = session();
+34
View File
@@ -228,6 +228,40 @@ fn diagonal_shift_snap_lands_an_edge_on_a_gridline() {
assert!(edge.iter().any(|y| ((y / g.cell).round() * g.cell - y).abs() < 1e-6), "{edge:?}, correction {correction:?}");
}
#[test]
fn snap_moves_objects_at_different_depths_by_one_plane_offset() {
let (mut s, _) = persp_session();
let rect = |s: &mut Session, x: f64| {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 380, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap())
};
let near = rect(&mut s, 240.0);
s.execute("perspective.attach", &json!({"ids": [near.0], "plane": "left"})).unwrap();
s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 50})).unwrap();
let far = rect(&mut s, 150.0);
s.execute("perspective.attach", &json!({"ids": [far.0], "plane": "left"})).unwrap();
s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 0})).unwrap();
assert_eq!(attached(&s, near), Some((Plane::Left, 0.0)));
assert_eq!(attached(&s, far), Some((Plane::Left, 50.0)));
s.execute("perspective.grid.snap", &json!({"on": true})).unwrap();
let g = grid(&s);
let (near_before, far_before) = (plane_pts(&s, near, Plane::Left, 0.0), plane_pts(&s, far, Plane::Left, 50.0));
let b = bounds_of(&near_before).union(bounds_of(&far_before));
// A move that ends 0.3 pt past a gridline crossing on the near object's plane.
let past = |v: f64| (v / g.cell).ceil() * g.cell + 0.3 - v;
let start = bounds_of(&near_before).center();
let h = g.homography_at(Plane::Left, 0.0).unwrap();
let from = h.apply(start).unwrap();
let to = h.apply(start + Vec2::new(past(b.x0), past(b.y0))).unwrap();
s.execute("perspective.move", &json!({"ids": [near.0, far.0], "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap();
let near_delta = plane_pts(&s, near, Plane::Left, 0.0)[0] - near_before[0];
let far_delta = plane_pts(&s, far, Plane::Left, 50.0)[0] - far_before[0];
assert!((near_delta - far_delta).hypot() < 1e-6, "near {near_delta:?}, far {far_delta:?}");
let on_line = |v: f64| ((v / g.cell).round() * g.cell - v).abs() < 1e-6;
let moved = b + near_delta;
assert!(on_line(moved.x0) || on_line(moved.x1), "{moved:?}");
assert!(on_line(moved.y0) || on_line(moved.y1), "{moved:?}");
}
#[test]
fn constrained_move_uses_each_objects_plane() {
let (mut s, right) = persp_session();
+40
View File
@@ -7,6 +7,32 @@ use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate};
use crate::*;
/// The saved preference reaches the real tool; older preferences keep Touching and invalid
/// values fail without changing the current setting.
#[test]
fn marquee_selection_preference_drives_the_selection_tool() {
assert_eq!(serde_json::from_value::<Prefs>(json!({})).unwrap().marquee_selection, "touching");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
for (mode, selected) in [("touching", true), ("enclosed", false), ("directional", false)] {
set_pref(&mut s, "marqueeSelection", json!(mode));
s.execute("select.none", &json!({})).unwrap();
for (k, x, y) in [(PointerKind::Down, 50.0, 50.0), (PointerKind::Drag, 150.0, 150.0), (PointerKind::Up, 150.0, 150.0)] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
assert_eq!(!s.doc().unwrap().selection.objects.is_empty(), selected, "{mode}");
let persisted = s.prefs.to_json();
assert_eq!(serde_json::from_value::<Prefs>(persisted).unwrap().marquee_selection, mode);
}
assert!(s.execute("prefs.set", &json!({"key": "marqueeSelection", "value": "unknown"})).is_err());
assert_eq!(s.prefs.marquee_selection, "directional");
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.prefs.marquee_selection, "touching");
}
#[test]
fn every_spec_matches_a_prefs_field_and_back() {
let all = Prefs::default().to_json();
@@ -2511,6 +2537,20 @@ fn a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide() {
}
}
/// General › Scroll Sideways with Cmd/Ctrl + Wheel starts on: in a new session, in a preferences
/// file saved before the option existed and after a reset.
#[test]
fn scroll_sideways_with_cmd_wheel_is_on_by_default() {
assert!(Prefs::default().ctrl_wheel_scrolls_sideways);
let old: Prefs = serde_json::from_value(json!({"zoomWithMouseWheel": false})).unwrap();
assert!(old.ctrl_wheel_scrolls_sideways);
let mut s = Session::new();
s.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": false})).unwrap();
assert!(!s.prefs.ctrl_wheel_scrolls_sideways);
s.execute("prefs.reset", &json!({})).unwrap();
assert!(s.prefs.ctrl_wheel_scrolls_sideways);
}
/// The Transform panel menu's choice starts as `default` (the panels follow General › Transform
/// Pattern Tiles): in a new session, in a preferences file saved before the choice existed and
/// after a reset; `prefs.set` takes it and the three menu choices, and nothing else.
+92 -16
View File
@@ -1047,18 +1047,20 @@ fn list_markers(s: &Session, id: NodeId) -> Vec<String> {
}
/// Typed list detection: at the start of a paragraph, "1.", "1)", "A." or "A)" and a space start
/// a numbered list, and "*", "#", ">" or "+" and a space start a bulleted list with the filled dot.
/// The typed characters go, the paragraph takes the hanging indent, and the text typed next is the
/// list item's. One Undo puts the typed characters back with no list, and typing goes on after
/// them, also after a Backspace that deleted nothing.
/// a numbered list, and "*", "+", "#" or ">" and a space start a bulleted list with the filled dot,
/// the hollow circle, the filled square or the dash. The typed characters go, the paragraph takes
/// the hanging indent, and the text typed next is the list item's. One Undo puts the typed
/// characters back with no list, and typing goes on after them, also after a Backspace that
/// deleted nothing.
#[test]
fn typing_a_list_start_and_a_space_starts_a_list() {
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
let v = ViewInfo::default();
let (arabic, letters, dot) =
(ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot));
let (arabic, letters) = (ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters));
let cases = [("1.", arabic, "1."), ("1)", arabic, "1."), ("A.", letters, "A."), ("A)", letters, "A.")];
let bullets = ["*", "#", ">", "+"].map(|b| (b, dot, "\u{2022}"));
let bullets =
[("*", Bullet::Dot, "\u{2022}"), ("+", Bullet::Circle, "\u{25E6}"), ("#", Bullet::Square, "\u{25AA}"), (">", Bullet::Dash, "\u{2013}")]
.map(|(b, bullet, marker)| (b, ParaList::bulleted(bullet), marker));
for (typed, list, marker) in cases.into_iter().chain(bullets) {
let mut s = session();
s.select_tool("type", v).unwrap();
@@ -1132,11 +1134,65 @@ fn the_caret_follows_undo_and_redo_of_a_typed_list() {
}
}
/// Type › Start Lists From Typed Markers off: a list marker and a space typed at the start of a
/// paragraph stay as text and start no list; `text.startList` still starts one.
#[test]
fn typed_lists_off_leaves_typed_markers_as_text() {
let v = ViewInfo::default();
for typed in ["1.", "1)", "A.", "A)", "*", "#", ">", "+"] {
let mut s = session();
s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
for c in typed.chars().chain(" a".chars()) {
s.tool_text(&c.to_string(), v).unwrap();
}
let t = obj(&s, id);
assert_eq!((t.plain_text(), t.para_at(0).list), (format!("{typed} a"), None), "{typed}");
assert!(!undo_labels(&s).iter().any(|l| l == "Bullets and Numbering"), "{typed}");
// Turned back on, the same typing in a new paragraph starts a list.
s.execute("prefs.set", &json!({"key": "typedLists", "value": true})).unwrap();
key(&mut s, ToolKey::Enter, Mods::default());
for c in typed.chars().chain(" ".chars()) {
s.tool_text(&c.to_string(), v).unwrap();
}
let t = obj(&s, id);
assert_eq!((t.plain_text(), t.para_at(0).list, t.para_at(1).list.is_some()), (format!("{typed} a\n"), None, true), "on: {typed}");
assert_eq!(undo_labels(&s).last().map(String::as_str), Some("Bullets and Numbering"), "on: {typed}");
}
let mut s = session();
s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap();
let id = text(&mut s, "1. a");
let r = s.execute("text.startList", &json!({"id": id.0, "at": 3})).unwrap();
assert_eq!(r["started"], true);
assert_eq!(obj(&s, id).plain_text(), "a");
assert!(obj(&s, id).para_at(0).list.is_some());
}
/// `typedLists` is a Type preference, on by default: `prefs.list` lists it as a bool, and
/// `prefs.reset` of the Type category turns it back on.
#[test]
fn typed_lists_is_a_type_preference_on_by_default() {
let mut s = Session::new();
assert!(s.prefs.typed_lists);
let l = s.execute("prefs.list", &json!({})).unwrap();
let row = l.as_array().unwrap().iter().find(|e| e["key"] == "typedLists").unwrap().clone();
assert_eq!(
(&row["category"], &row["kind"], &row["label"], &row["value"]),
(&json!("Type"), &json!("bool"), &json!("Start Lists From Typed Markers"), &json!(true))
);
s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap();
assert!(!s.prefs.typed_lists);
s.execute("prefs.reset", &json!({"category": "Type"})).unwrap();
assert!(s.prefs.typed_lists);
}
/// The same characters later in a paragraph, without the space, typed in one piece with the space
/// (a paste), or at the start of a paragraph that is already in a list start nothing.
#[test]
fn list_starts_typed_elsewhere_or_pasted_start_no_list() {
use vectorcraft_doc::text::{NumberStyle, ParaList};
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
let v = ViewInfo::default();
let mut s = session();
s.select_tool("type", v).unwrap();
@@ -1151,12 +1207,26 @@ fn list_starts_typed_elsewhere_or_pasted_start_no_list() {
for c in "1.Pears".chars() {
s.tool_text(&c.to_string(), v).unwrap();
}
key(&mut s, ToolKey::Enter, Mods::default());
s.tool_text("* ", v).unwrap();
for pasted in ["* ", "+ ", "# ", "> "] {
key(&mut s, ToolKey::Enter, Mods::default());
s.tool_text(pasted, v).unwrap();
}
let t = obj(&s, id);
assert_eq!(t.plain_text(), "Fruit 1. * A) x\n1. Apples\n1.Pears\n* ");
assert_eq!((0..4).map(|i| t.para_at(i).list).collect::<Vec<_>>(), [None, None, None, None]);
assert_eq!(t.plain_text(), "Fruit 1. * A) x\n1. Apples\n1.Pears\n* \n+ \n# \n> ");
assert!((0..7).all(|i| t.para_at(i).list.is_none()));
assert!(!undo_labels(&s).iter().any(|l| l == "Bullets and Numbering"));
// A paragraph in a bulleted list keeps its bullet when another list start is typed before it.
let id = text(&mut s, "Item");
s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet", "bullet": "dot"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
click(&mut s, 101.0, 95.0);
s.set_tool_option("select", &json!({"start": 0, "end": 0}));
for c in "+ ".chars() {
s.tool_text(&c.to_string(), v).unwrap();
}
let t = obj(&s, id);
assert_eq!((t.plain_text().as_str(), t.para_at(0).list), ("+ Item", Some(ParaList::bulleted(Bullet::Dot))));
assert_eq!(list_markers(&s, id), ["\u{2022}"]);
// A paragraph in a list keeps its list, and the typed characters stay as text.
let id = text(&mut s, "Item");
s.execute("text.setFormat", &json!({"ids": [id.0], "list": "number"})).unwrap();
@@ -1188,10 +1258,16 @@ fn start_list_command_removes_the_typed_characters_and_starts_the_list() {
assert_eq!(list_markers(&s, id), ["1."]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((obj(&s, id).plain_text().as_str(), obj(&s, id).para_at(1).list), ("Fruit\n1) Apples", None));
let id = text(&mut s, "x\n# ");
let r = s.execute("text.startList", &json!({"id": id.0})).unwrap();
assert_eq!((r["started"].clone(), obj(&s, id).plain_text()), (json!(true), "x\n".to_string()));
assert_eq!(obj(&s, id).para_at(1).list, Some(ParaList::bulleted(Bullet::Dot)));
for (typed, bullet, marker) in
[("* ", Bullet::Dot, "\u{2022}"), ("+ ", Bullet::Circle, "\u{25E6}"), ("# ", Bullet::Square, "\u{25AA}"), ("> ", Bullet::Dash, "\u{2013}")]
{
let id = text(&mut s, &format!("x\n{typed}"));
let r = s.execute("text.startList", &json!({"id": id.0})).unwrap();
assert_eq!((r["started"].clone(), obj(&s, id).plain_text()), (json!(true), "x\n".to_string()), "{typed:?}");
assert_eq!(obj(&s, id).para_at(1).list, Some(ParaList::bulleted(bullet)), "{typed:?}");
s.execute("text.editRange", &json!({"id": id.0, "start": 2, "end": 2, "insert": "Item"})).unwrap();
assert_eq!(list_markers(&s, id), [marker], "{typed:?}");
}
let steps = undo_labels(&s).len();
let id = text(&mut s, "1.x");
assert_eq!(s.execute("text.startList", &json!({"id": id.0, "at": 3})).unwrap()["started"], json!(false));
+19
View File
@@ -91,6 +91,25 @@ fn a_gesture_keeps_what_it_changed_and_a_document_switch_keeps_options() {
assert_eq!(s.tool_options()["segments"], json!(14));
}
/// Escape on a grid drag puts back the dividers its arrows changed, in the saved options too,
/// while an option set during the drag stays.
#[test]
fn escape_on_a_drag_saves_the_options_it_put_back() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let mut s = session();
select(&mut s, "rectangularGrid");
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap();
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
// Saves every option, the rows the arrow changed included.
s.set_tool_option("columns", &json!(8));
assert_eq!(s.prefs.tool_settings["rectangularGrid"]["rows"], json!(6));
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
let saved = &s.prefs.tool_settings["rectangularGrid"];
assert_eq!((saved["rows"].clone(), saved["columns"].clone()), (json!(5), json!(8)));
}
/// A dropped press (a long touch, a canceled touch, a second finger) keeps nothing it began: the
/// tool ends at rest with the options it had before the press, and the options the drag changed
/// are not stored. A release stores them.
+11
View File
@@ -319,6 +319,11 @@ impl Session {
slices_locked: self.menu.slices_locked,
auto_add_delete: !self.prefs.disable_auto_add_delete,
selection_tolerance: self.prefs.selection_tolerance,
marquee_mode: match self.prefs.marquee_selection.as_str() {
"enclosed" => vectorcraft_tools::MarqueeMode::Enclosed,
"directional" => vectorcraft_tools::MarqueeMode::Directional,
_ => vectorcraft_tools::MarqueeMode::Touching,
},
anchor_size: self.prefs.anchor_size,
path_only: self.prefs.object_selection_by_path_only,
type_path_only: self.prefs.type_selection_by_path_only,
@@ -332,6 +337,7 @@ impl Session {
pen_rubber_band: self.prefs.pen_rubber_band,
curvature_rubber_band: self.prefs.curvature_rubber_band,
placeholder_text: self.prefs.placeholder_text,
typed_lists: self.prefs.typed_lists,
smart_guide_color: rgb(&self.prefs.smart_guide_color, vectorcraft_tools::guides::MAGENTA),
alignment_guides: self.prefs.alignment_guides,
anchor_path_labels: self.prefs.anchor_path_labels,
@@ -577,6 +583,11 @@ impl Session {
}
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
self.take_tool_panic()?;
// A key that cancels a gesture may put back options the gesture changed (Escape on an
// arc, spiral or grid drag): keep them.
if acts.contains(&Action::Cancel) {
self.keep_tool_settings();
}
self.apply_actions(acts)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
View File
@@ -1934,6 +1934,33 @@ proptest! {
}
}
proptest! {
#![proptest_config(config())]
/// Mutated HEIF boxes and HEVC data must return errors rather than unwind.
#[test]
fn mutated_heif_files_do_not_panic(which in 0usize..4, cut in 0usize..1300, edits in prop::collection::vec((0usize..1300, any::<u8>()), 0..8)) {
let mut bytes = match which {
0 => include_bytes!("fixtures/heif/quadrants.heic").to_vec(),
1 => include_bytes!("fixtures/heif/quadrants-alpha.heic").to_vec(),
2 => include_bytes!("fixtures/heif/quadrants-grid.heic").to_vec(),
// Its stream's VUI gives the color description.
_ => include_bytes!("fixtures/heif/tones-p3-icc-only.heic").to_vec(),
};
for (at, byte) in edits {
if let Some(slot) = bytes.get_mut(at) { *slot = byte; }
}
bytes.truncate(cut);
let result = catch_quiet(|| {
let mut s = vectorcraft_engine::Session::new();
let params = json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(&bytes)});
let _ = no_panic(s.execute("document.open", &params));
let _ = no_panic(s.execute("file.place", &params));
});
prop_assert!(result.is_ok(), "a.heic: panicked: {:?}", result.err());
}
}
/// The rich document with a hidden layer, saved as SVG with its editing data.
fn saved_svg() -> String {
let mut d = rich_doc();
+254 -13
View File
@@ -21,8 +21,9 @@ use vello_cpu::color::PremulRgba8;
use crate::ink::Ink;
/// Largest default budget of the shared image store, in bytes; less on machines with under 8 GiB
/// of memory (an eighth of it). [`set_image_cache_budget`] changes it.
/// Default budget of the shared image store, in bytes, where the installed memory can't be read.
/// Elsewhere the default is a quarter of the installed memory, and on the web it is 256 MiB.
/// [`set_image_cache_budget`] changes it.
pub const IMAGE_CACHE_MAX: usize = 1 << 30;
/// The default budget on the web (a tab's memory is tight).
@@ -33,14 +34,21 @@ fn default_budget() -> usize {
#[cfg(not(target_arch = "wasm32"))]
fn default_budget() -> usize {
physical_memory().map_or(IMAGE_CACHE_MAX, budget_for)
budget_for_reading(physical_memory())
}
/// The default budget on a machine with `memory` bytes installed: an eighth of it, at most
/// [`IMAGE_CACHE_MAX`].
/// The default budget for a reading of the installed memory: [`IMAGE_CACHE_MAX`] when `memory` is
/// `None` or 0, otherwise [`budget_for`] it.
#[cfg(not(target_arch = "wasm32"))]
fn budget_for_reading(memory: Option<u64>) -> usize {
memory.filter(|&m| m > 0).map_or(IMAGE_CACHE_MAX, budget_for)
}
/// The default budget on a machine with `memory` bytes installed: a quarter of it, and at most a
/// quarter of the address space (on 32-bit builds).
#[cfg(not(target_arch = "wasm32"))]
fn budget_for(memory: u64) -> usize {
usize::try_from(memory / 8).unwrap_or(usize::MAX).min(IMAGE_CACHE_MAX)
usize::try_from(memory / 4).unwrap_or(usize::MAX).min(usize::MAX / 4)
}
/// Installed memory in bytes, `None` where it isn't known (the web, other systems). Other crates
@@ -52,13 +60,116 @@ pub fn installed_memory() -> Option<u64> {
physical_memory()
}
/// Installed memory in bytes (`MemTotal` in `/proc/meminfo`).
/// Installed memory in bytes (`MemTotal` in `/proc/meminfo`), or the memory limit of the process's
/// cgroups where that is lower.
#[cfg(target_os = "linux")]
fn physical_memory() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let line = info.lines().find(|l| l.starts_with("MemTotal:"))?;
let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
kib.checked_mul(1024)
let total = kib.checked_mul(1024)?;
let cgroups = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
let mounts = std::fs::read_to_string("/proc/self/mountinfo").unwrap_or_default();
Some(cgroup_limit(&cgroups, &mounts, |path| std::fs::read_to_string(path).ok()).map_or(total, |limit| limit.min(total)))
}
/// The lowest memory limit of the cgroups listed in `/proc/self/cgroup` (`cgroups`) and of their
/// ancestors within the mounted cgroup hierarchy: `memory.max` under cgroup v2 and
/// `memory.limit_in_bytes` under cgroup v1. Each cgroup is looked up under the mount in
/// `/proc/self/mountinfo` (`mounts`) whose root holds it, or under `/sys/fs/cgroup` (v2) or
/// `/sys/fs/cgroup/memory` (v1) when `mounts` lists no mount of its hierarchy; a cgroup that no
/// listed mount holds sets no limit. A mount whose root lies above the cgroup namespace's root
/// (`/..`) gives the limit of its mount point alone. `read` reads a file; a file that can't be read, or that holds
/// `max`, sets no limit.
#[cfg(any(target_os = "linux", test))]
fn cgroup_limit(cgroups: &str, mounts: &str, read: impl Fn(&str) -> Option<String>) -> Option<u64> {
let mut lowest: Option<u64> = None;
for line in cgroups.lines() {
let mut fields = line.splitn(3, ':');
let (Some(_), Some(controllers), Some(path)) = (fields.next(), fields.next(), fields.next()) else {
continue;
};
let v2 = controllers.is_empty();
if !v2 && !controllers.split(',').any(|c| c == "memory") {
continue;
}
let file = if v2 { "memory.max" } else { "memory.limit_in_bytes" };
let mounted = cgroup_mounts(mounts, v2);
// Every mount that holds the cgroup shows the same hierarchy; each may show more ancestors.
let holding: Vec<(String, String)> = if mounted.is_empty() {
vec![(path.to_owned(), (if v2 { "/sys/fs/cgroup" } else { "/sys/fs/cgroup/memory" }).to_owned())]
} else {
mounted
.into_iter()
.filter_map(|(root, point)| {
let root = root.trim_end_matches('/');
// A root of `..` steps is an ancestor of the cgroup namespace's root, so of every
// cgroup listed: only the mount point's own limit can be read.
if !root.is_empty() && root.split('/').skip(1).all(|step| step == "..") {
return Some((String::new(), point));
}
let rest = path.strip_prefix(root).filter(|rest| rest.is_empty() || rest.starts_with('/'))?;
Some((rest.to_owned(), point))
})
.collect()
};
for (relative, mount_point) in &holding {
let root = mount_point.trim_end_matches('/');
let mut dir = relative.trim_end_matches('/');
loop {
if let Some(limit) = read(&format!("{root}{dir}/{file}")).and_then(|s| s.trim().parse::<u64>().ok()) {
lowest = Some(lowest.map_or(limit, |l| l.min(limit)));
}
match dir.rsplit_once('/') {
Some((parent, _)) => dir = parent,
None => break,
}
}
}
}
lowest
}
/// The root and the mount point of each cgroup v2 mount (`v2`) or cgroup v1 mount with the
/// `memory` controller in `/proc/self/mountinfo` (`mounts`), with their octal escapes decoded.
#[cfg(any(target_os = "linux", test))]
fn cgroup_mounts(mounts: &str, v2: bool) -> Vec<(String, String)> {
mounts
.lines()
.filter_map(|line| {
let (before, after) = line.split_once(" - ")?;
let mut fields = before.split_whitespace().skip(3);
let (root, point) = (fields.next()?, fields.next()?);
let mut after = after.split_whitespace();
let (fs, _, options) = (after.next()?, after.next()?, after.next().unwrap_or(""));
let found = if v2 { fs == "cgroup2" } else { fs == "cgroup" && options.split(',').any(|o| o == "memory") };
found.then(|| (mountinfo_unescape(root), mountinfo_unescape(point)))
})
.collect()
}
/// A path field of `/proc/self/mountinfo` with its octal escapes (`\040` for a space) decoded.
#[cfg(any(target_os = "linux", test))]
fn mountinfo_unescape(field: &str) -> String {
let mut bytes = Vec::with_capacity(field.len());
let mut rest = field.as_bytes();
while let Some((&b, tail)) = rest.split_first() {
let code = match tail {
[a @ b'0'..=b'3', c @ b'0'..=b'7', d @ b'0'..=b'7', ..] if b == b'\\' => Some(((a - b'0') << 6) | ((c - b'0') << 3) | (d - b'0')),
_ => None,
};
match code {
Some(code) => {
bytes.push(code);
rest = tail.get(3..).unwrap_or_default();
}
None => {
bytes.push(b);
rest = tail;
}
}
}
String::from_utf8_lossy(&bytes).into_owned()
}
/// Installed memory in bytes (`GlobalMemoryStatusEx`).
@@ -524,18 +635,148 @@ mod tests {
drop(held);
}
#[test]
#[cfg(target_pointer_width = "64")]
fn default_budget_is_a_quarter_of_installed_memory() {
assert_eq!(budget_for(16 << 30), 4 << 30);
assert_eq!(budget_for(64 << 30), 16 << 30, "no fixed ceiling");
assert_eq!(budget_for(2 << 30), 512 << 20);
}
#[test]
#[cfg(target_pointer_width = "64")]
fn budget_follows_the_memory_reading() {
assert_eq!(budget_for_reading(Some(2 << 30)), 512 << 20);
assert_eq!(budget_for_reading(Some(8 << 30)), 2 << 30);
assert_eq!(budget_for_reading(Some(64 << 30)), 16 << 30);
assert_eq!(budget_for_reading(None), IMAGE_CACHE_MAX, "memory that can't be read");
assert_eq!(budget_for_reading(Some(0)), IMAGE_CACHE_MAX, "memory that reads as 0");
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn default_budget_is_an_eighth_of_installed_memory_at_most_1_gib() {
assert_eq!(budget_for(4 << 30), 512 << 20);
assert_eq!(budget_for(8 << 30), IMAGE_CACHE_MAX);
assert_eq!(budget_for(64 << 30), IMAGE_CACHE_MAX);
fn images_past_the_budget_drop_the_least_recently_used() {
// 16 MiB installed (16 GiB scaled down): a 4 MiB budget, room for four 512 × 512 images.
let mut s = ImageStore::new(budget_for_reading(Some(16 << 20)));
for k in ["a", "b", "c", "d"] {
drop(s.insert(key(k), Arc::new(Pixmap::new(512, 512))));
}
assert_eq!(s.bytes(), 4 << 20, "four images fill the budget exactly");
assert!(["a", "b", "c", "d"].iter().all(|k| s.peek(&key(k)).is_some()));
drop(s.insert(key("e"), Arc::new(Pixmap::new(512, 512))));
assert_eq!(s.bytes(), 4 << 20);
assert!(s.peek(&key("a")).is_none(), "the oldest image is dropped");
assert!(["b", "c", "d", "e"].iter().all(|k| s.peek(&key(k)).is_some()));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn an_image_larger_than_the_budget_is_kept_only_while_drawn() {
// 16 MiB installed: a 4 MiB budget. A 1,100 × 1,000 image takes 4.2 MiB.
let budget = budget_for_reading(Some(16 << 20));
let mut s = ImageStore::new(budget);
assert!(!s.keep(key("big"), Arc::new(Pixmap::new(1100, 1000))), "not kept for a later frame");
assert_eq!(s.bytes(), 0);
drop(s.insert(key("small"), Arc::new(Pixmap::new(512, 512))));
let drawn = s.insert(key("big"), Arc::new(Pixmap::new(1100, 1000)));
assert!(s.peek(&key("big")).is_some(), "kept while drawn");
assert!(s.peek(&key("small")).is_none(), "the other image is dropped to make room");
drop(drawn);
s.set_budget(budget);
assert!(s.peek(&key("big")).is_none(), "dropped once nothing draws it");
assert_eq!(s.bytes(), 0);
}
#[test]
#[cfg(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd"))]
fn installed_memory_is_read() {
assert!(physical_memory().is_some_and(|m| m >= 256 << 20), "{:?}", physical_memory());
// A cgroup's limit can be far below the installed memory.
assert!(physical_memory().is_some_and(|m| m > 0), "{:?}", physical_memory());
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn two_images_of_a_tenth_of_memory_each_stay_cached() {
// 16 MiB installed (16 GiB scaled down): two images of 1,600 KiB (640 × 640 pixels) take
// about a fifth of it.
let mut s = ImageStore::new(budget_for(16 << 20));
drop(s.insert(key("a"), Arc::new(Pixmap::new(640, 640))));
drop(s.insert(key("b"), Arc::new(Pixmap::new(640, 640))));
assert!(s.peek(&key("a")).is_some() && s.peek(&key("b")).is_some(), "neither image is decoded again");
assert_eq!(s.bytes(), 2 * 640 * 640 * 4);
}
#[test]
fn cgroup_limit_is_the_lowest_limit_of_the_cgroups_and_their_ancestors() {
let files = |list: &'static [(&'static str, &'static str)]| {
move |path: &str| list.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned())
};
let v2 = files(&[("/sys/fs/cgroup/user.slice/memory.max", "2147483648\n"), ("/sys/fs/cgroup/user.slice/app.scope/memory.max", "max\n")]);
assert_eq!(cgroup_limit("0::/user.slice/app.scope\n", "", v2), Some(2 << 30), "the parent's limit applies");
let v1 = files(&[("/sys/fs/cgroup/memory/memory.limit_in_bytes", "1073741824\n")]);
let v1_mounts = "30 25 0:26 / /sys/fs/cgroup/cpu rw - cgroup cgroup rw,cpu,cpuacct\n\
31 25 0:27 /docker/1 /sys/fs/cgroup/memory rw,nosuid master:9 - cgroup cgroup rw,memory\n";
let cgroups = "4:cpu,cpuacct:/docker/1\n5:memory:/docker/1\n";
assert_eq!(cgroup_limit(cgroups, v1_mounts, v1), Some(1 << 30), "a container's own mount");
assert_eq!(cgroup_limit("0::/\n", "", files(&[("/sys/fs/cgroup/memory.max", "max\n")])), None, "no limit");
assert_eq!(cgroup_limit("", "", files(&[])), None);
}
#[test]
fn cgroup_limit_reads_a_subtree_mount_relative_to_its_root() {
let mounts = "25 1 0:22 / /proc rw - proc proc rw\n\
40 25 0:30 /docker/id /sys/fs/cgroup rw,nosuid - cgroup2 cgroup2 rw,nsdelegate\n";
let files = [("/sys/fs/cgroup/worker/memory.max", "536870912\n"), ("/sys/fs/cgroup/memory.max", "4294967296\n")];
let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned());
assert_eq!(cgroup_limit("0::/docker/id/worker\n", mounts, read), Some(512 << 20), "the worker's own limit");
assert_eq!(cgroup_limit("0::/elsewhere\n", mounts, read), None, "no mount holds the cgroup");
}
#[test]
fn cgroup_limit_reads_the_mount_that_holds_the_cgroup() {
let mounts = "40 25 0:30 /other /mnt/other rw - cgroup2 cgroup2 rw\n\
41 25 0:30 /docker/id /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n";
let files = [("/mnt/other/memory.max", "1048576\n"), ("/sys/fs/cgroup/memory.max", "268435456\n")];
let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned());
assert_eq!(cgroup_limit("0::/docker/id\n", mounts, read), Some(256 << 20), "the unrelated subtree's limit is not read");
let both = "40 25 0:30 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n\
41 25 0:30 /parent/child /mnt/child rw - cgroup2 cgroup2 rw\n";
let files = [("/mnt/child/memory.max", "max\n"), ("/sys/fs/cgroup/parent/memory.max", "1073741824\n")];
let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned());
assert_eq!(cgroup_limit("0::/parent/child\n", both, read), Some(1 << 30), "the ancestor that only the full mount shows");
}
#[test]
fn cgroup_limit_reads_a_mount_above_the_namespace_root() {
let mounts = "40 25 0:30 /.. /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n";
let files = [("/sys/fs/cgroup/memory.max", "1073741824\n"), ("/sys/fs/cgroup/worker/memory.max", "4096\n")];
let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned());
assert_eq!(cgroup_limit("0::/\n", mounts, read), Some(1 << 30), "the namespace root's ancestor");
assert_eq!(cgroup_limit("0::/worker\n", mounts, read), Some(1 << 30), "the cgroup's directory is hidden");
let v1 = "31 25 0:27 /../.. /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n";
let files = [("/sys/fs/cgroup/memory/memory.limit_in_bytes", "536870912\n")];
let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned());
assert_eq!(cgroup_limit("5:memory:/\n", v1, read), Some(512 << 20));
let sibling = "40 25 0:30 /../other /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n";
let files = [("/sys/fs/cgroup/memory.max", "1048576\n")];
let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned());
assert_eq!(cgroup_limit("0::/\n", sibling, read), None, "a sibling of the namespace root is not an ancestor");
}
#[test]
fn cgroup_mounts_lists_the_hierarchy_of_the_memory_limit() {
let mounts = "30 25 0:26 / /sys/fs/cgroup/cpu rw - cgroup cgroup rw,cpu\n\
31 25 0:27 /a\\040b /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n\
32 25 0:28 / /sys/fs/cgroup/unified rw - cgroup2 cgroup2 rw\n";
assert_eq!(cgroup_mounts(mounts, false), [("/a b".to_owned(), "/sys/fs/cgroup/memory".to_owned())]);
assert_eq!(cgroup_mounts(mounts, true), [("/".to_owned(), "/sys/fs/cgroup/unified".to_owned())]);
assert!(cgroup_mounts("", true).is_empty());
}
#[test]
fn mountinfo_paths_have_their_escapes_decoded() {
assert_eq!(mountinfo_unescape(r"/a\040b\011c\012d\134e"), "/a b\tc\nd\\e");
assert_eq!(mountinfo_unescape(r"/plain\9x\04"), r"/plain\9x\04", "not an escape");
}
#[test]
+44 -16
View File
@@ -57,8 +57,9 @@ fn continues_cluster(prev: Option<&SGlyph>, g: Option<&SGlyph>) -> bool {
/// The caret stops inside glyph `g` when it alone stands for several characters (grapheme clusters)
/// of `text`, a ligature ([`PositionedGlyph::carets`]); `rtl` when its logical leading edge is its
/// right edge; `upright` when it stands upright in vertical type, its carets then down its cell
/// ([`upright_extent`]). Empty for a glyph of one character.
fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: bool) -> Vec<(usize, f64)> {
/// ([`upright_extent`]) as a share of `upright`, the length in font units its own width runs down.
/// Empty for a glyph of one character.
fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: Option<f64>) -> Vec<(usize, f64)> {
use unicode_segmentation::UnicodeSegmentation;
let Some(s) = text.get(g.byte..g.byte + g.len).filter(|_| g.len > 1) else { return Vec::new() };
let starts: Vec<usize> = s.grapheme_indices(true).skip(1).map(|(i, _)| g.byte + i).collect();
@@ -66,12 +67,13 @@ fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: bool) -> Vec<(usi
return Vec::new();
}
let parts = starts.len() + 1;
let from_font = if upright {
let from_font = if let Some(length) = upright {
let (cell, top) = upright_extent(g);
g.face
.vertical_ligature_carets(g.gid, starts.len(), top, cell)
.filter(|c| c.len() == starts.len() && cell > 0.0)
.map(|c| c.into_iter().map(|d| d / cell).collect::<Vec<f64>>())
.filter(|c| c.len() == starts.len() && length > 0.0)
.map(|c| c.into_iter().map(|d| d / length).collect::<Vec<f64>>())
.filter(|c| c.iter().all(|f| *f > 0.0 && *f < 1.0))
} else {
let advance = g.face.advance(g.gid);
g.face
@@ -332,9 +334,31 @@ impl Ctx<'_> {
Affine::IDENTITY
};
let mut m = pre * local;
// Vertical type on a path: an upright glyph's cell in font units, as (its length with the
// Proportional Metrics change to it, the height of its top above the baseline).
let path_cell = (self.vertical && self.on_path && g.inline.is_none() && g.tcy.is_none() && takes_cell(g) && stands_upright(g) && g.sy != 0.0)
.then(|| {
let (cell, top) = upright_extent(g);
let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / g.sy;
(cell + proportional, top)
});
if self.vertical && self.on_path {
// Vertical path type keeps the baseline path and turns each glyph across it.
m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m;
if let Some((cell, top)) = path_cell {
// An upright glyph whose top then faces the path's end turns half a turn about the
// center of its cell (without its Character Rotation), so that its top faces the
// path's start and the characters of an upright ligature read along the path in
// order. The effects that keep the glyph's up direction fixed already turn it so on
// a path that runs to the left.
let turned = m * unturned;
let tangent = Vec2::new(angle.cos(), angle.sin());
let along = |y: f64| (turned * Point::new(0.0, y) - origin).dot(tangent);
if along(-top) > along(cell - top) {
let centre = turned * Point::new(g.face.advance(g.gid) * 0.5, cell * 0.5 - top);
m = Affine::translate(centre.to_vec2() * 2.0) * Affine::scale(-1.0) * m;
}
}
} else if let (true, Some(t)) = (self.vertical, g.tcy) {
// Tate-chu-yoko: the block set across the column, centred on its em of it, then moved up
// by its Up/Down and right by its Left/Right (line x runs down the column and line y
@@ -408,20 +432,17 @@ impl Ctx<'_> {
} else {
0.0
};
// On a path, an upright glyph turns about its pen: its cell runs along the path between
// where the glyph's transform sets the cell's foot and its top, moved by the shaper's
// placement and a baseline shift, and slanted by the path's effect.
let (width, width_start) = match (upright && self.on_path, g.sy) {
(true, sy) if sy != 0.0 => {
// In font units, with the Proportional Metrics change to its length.
let (cell, top) = upright_extent(g);
let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / sy;
// On a path, an upright glyph's cell runs along the path from where the glyph's transform
// sets the cell's top to its foot, moved by the shaper's placement and a baseline shift, and
// slanted by the path's effect.
let (width, width_start) = match path_cell {
Some((cell, top)) => {
let tangent = Vec2::new(angle.cos(), angle.sin());
let along = |y: f64| (m * Point::new(0.0, y) - origin).dot(tangent);
let (start, end) = (along(cell + proportional - top), along(-top));
let (start, end) = (along(-top), along(cell - top));
if start.is_finite() && end > start { (end - start, start) } else { (width, width_start) }
}
_ => (width, width_start),
None => (width, width_start),
};
if let Some(art) = g.inline.as_ref().and_then(|ib| ib.art) {
self.out.inlines.push(InlineGlyph { run: g.run, byte: g.byte, glyph: self.out.glyphs.len(), xf: m, bounds: m.transform_rect_bbox(art) });
@@ -446,7 +467,14 @@ impl Ctx<'_> {
bend: None,
unturned,
pivot,
carets: if g.inline.is_some() { Vec::new() } else { ligature_carets(self.text, g, g.level.is_rtl() && !self.vertical, upright) },
// On a path its carets divide the cell its width runs along, with the Proportional
// Metrics change to it.
carets: if g.inline.is_some() {
Vec::new()
} else {
let length = upright.then(|| path_cell.map_or_else(|| upright_extent(g).0, |(cell, _)| cell));
ligature_carets(self.text, g, g.level.is_rtl() && !self.vertical, length)
},
width,
width_start,
});
+1 -1
View File
@@ -170,7 +170,7 @@ pub struct PositionedGlyph {
/// taken off, plus the manual kerning of right-to-left text, which moves the glyph right so that
/// the space it adds lies on the glyph's left. 0 for a glyph standing upright or set across the
/// column in vertical type, and for an inline graphic. An upright glyph in vertical type on a
/// path turns about its pen, and its cell starts one cell length before the cell's top: 0.12 em
/// path stands with its top toward the path's start, and its cell starts at its top: 0.12 em
/// before the pen without the font's vertical metrics.
pub width_start: f64,
}
+38
View File
@@ -855,6 +855,44 @@ pub fn vertical_ligature_caret_font(caret: i16) -> Option<Vec<u8>> {
Some(builder.build())
}
/// [`vertical_ligature_caret_font`] with its one caret at y = `caret` font units, f + i joined by
/// `ccmp` (which shaping applies top to bottom too, unlike `liga`), and its `GPOS` replaced by a
/// `vpal` single adjustment that changes the ligature glyph's advance down the column by
/// `y_advance` font units. `None` when it can't be built.
pub fn proportional_vertical_ligature_caret_font(caret: i16, y_advance: i16) -> Option<Vec<u8>> {
use write_fonts::tables::gpos::{Gpos, PositionLookup, PositionLookupList, SinglePos, ValueRecord};
use write_fonts::tables::gsub::{Gsub, Ligature, LigatureSet, LigatureSubstFormat1, SubstitutionLookup, SubstitutionLookupList};
use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord};
use write_fonts::types::GlyphId16;
let base = vertical_ligature_caret_font(caret)?;
let font = skrifa::FontRef::new(&base).ok()?;
let gid = |c| Some(GlyphId16::new(source_sans_gid(c)?));
let ligature = gid(LIGATURE_CARET_GLYPH)?;
let langsys = || Script::new(Some(LangSys::new(vec![0])), vec![]);
let scripts = || ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), langsys()), ScriptRecord::new(Tag::new(b"latn"), langsys())]);
let subst = LigatureSubstFormat1::new(
CoverageFormat1::new(vec![gid('f')?]).into(),
vec![LigatureSet::new(vec![Ligature::new(ligature, vec![gid('i')?])])],
);
let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"ccmp"), Feature::new(None, vec![0]))]);
let lookups = SubstitutionLookupList::new(vec![SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![subst]))]);
let gsub = Gsub::new(scripts(), features, lookups);
let pos = SinglePos::format_1(CoverageFormat1::new(vec![ligature]).into(), ValueRecord::new().with_y_advance(y_advance));
let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"vpal"), Feature::new(None, vec![0]))]);
let lookups = PositionLookupList::new(vec![PositionLookup::Single(Lookup::new(Default::default(), vec![pos]))]);
let mut builder = FontBuilder::new();
builder.add_table(&gsub).ok()?;
builder.add_table(&Gpos::new(scripts(), features, lookups)).ok()?;
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The family of [`variable_ligature_caret_font`].
pub const VARIABLE_LIGATURE_CARET_FAMILY: &str = "Varicaret Sans";
+100 -4
View File
@@ -8,7 +8,8 @@ use vectorcraft_geom::PathData;
use crate::test_fonts::{
LIGATURE_CARET_FAMILY, LIGATURE_CARET_GLYPH, VARIABLE_LIGATURE_CARET_FAMILY, VERTICAL_LIGATURE_CARET_FAMILY, lam_alef_family, lam_alef_font,
ligature_caret_font, placed_ligature_caret_font, source_sans_gid, variable_ligature_caret_font, vertical_ligature_caret_font,
ligature_caret_font, placed_ligature_caret_font, proportional_vertical_ligature_caret_font, source_sans_gid, variable_ligature_caret_font,
vertical_ligature_caret_font,
};
use crate::*;
@@ -628,9 +629,10 @@ fn an_upright_ligature_takes_its_caret_down_its_cell() {
assert!((at(900) - 0.5).abs() < 1e-9, "{}", at(900));
}
/// On a path, an upright ligature in vertical type turns about its pen, so its cell runs from
/// 0.12 em before the pen to 0.88 em after it, with or without the font's vertical metrics. The
/// inner stop lies at the middle of that cell, and the first character's selection ends there.
/// On a path, an upright ligature in vertical type stands with its top toward the path's start, and
/// its cell runs from 0.12 em before the pen to 0.88 em after it, with or without the font's vertical
/// metrics. The inner stop lies at the middle of that cell, and the first character's selection ends
/// there.
#[test]
fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() {
for (font, family) in [(ligature_caret_font(None), LIGATURE_CARET_FAMILY), (vertical_ligature_caret_font(900), VERTICAL_LIGATURE_CARET_FAMILY)] {
@@ -688,6 +690,100 @@ fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() {
}
}
/// On a path, an upright ligature in vertical type reads along the path in order: the top of its
/// cell comes first, and its font caret (600 units down the cell) is drawn where the caret, a click
/// and the selection put the stop, on a path running either way with any path effect. A glyph lying
/// on its side keeps its top toward the path's end.
#[test]
fn an_upright_ligature_on_a_path_reads_along_it_in_order() {
use vectorcraft_doc::PathEffect;
let db = db_with(vertical_ligature_caret_font(280));
let mut t = point("fi", VERTICAL_LIGATURE_CARET_FAMILY);
t.vertical = true;
t.runs[0].style.standard_vertical_roman = true;
for (to, effect) in [
(1000.0, PathEffect::Rainbow),
(-1000.0, PathEffect::Rainbow),
(-1000.0, PathEffect::Skew),
(-1000.0, PathEffect::Ribbon3d),
(-1000.0, PathEffect::StairStep),
(-1000.0, PathEffect::Gravity),
] {
let mut line = BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((to, 0.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
t.path_effect = effect;
let l = layout(&db, &t);
let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) };
let &[(1, f)] = g.carets.as_slice() else { panic!("one inner stop: {:?}", g.carets) };
assert!((f - 0.6).abs() < 1e-9, "{f}");
let tangent = Vec2::new(g.angle.cos(), g.angle.sin());
let along = |p: Point| (p - g.origin).dot(tangent);
// Font units, y down: the cell's top is 880 units above the baseline and its foot 120 below.
let (top, foot) = (along(g.xf * Point::new(0.0, -880.0)), along(g.xf * Point::new(0.0, 120.0)));
assert!(top < foot, "{to} {effect:?}: the top comes first along the path: {top} {foot}");
let (start, width) = (g.width_start, g.width);
assert!((top - start).abs() < 1e-6 && (foot - top - width).abs() < 1e-6, "{to} {effect:?}: {top} {foot} {start} {width}");
let drawn = along(g.xf * Point::new(0.0, -280.0));
assert!((drawn - (top + (foot - top) * 0.6)).abs() < 1e-6, "{to} {effect:?}: {drawn} against {top} {foot}");
let (a, b) = caret_position(&l, 1);
let caret = along(a.midpoint(b));
assert!((caret - drawn).abs() < 1e-6, "{to} {effect:?}: the caret stands at the drawn stop: {caret} against {drawn}");
assert_eq!(hit_byte(&l, g.origin + tangent * (drawn + 0.3)), 1, "{to} {effect:?}");
assert_eq!(hit_byte(&l, g.origin + tangent * (drawn - 0.3)), 1, "{to} {effect:?}");
assert_eq!(hit_byte(&l, g.origin + tangent * 0.3), 0, "{to} {effect:?}");
let q = selection_quads(&l, 0, 1);
assert_eq!(q.len(), 1, "{q:?}");
let (q0, q1) = (along(q[0][0]), along(q[0][1]));
assert!((q0.min(q1) - drawn.min(0.0)).abs() < 1e-6 && (q0.max(q1) - drawn.max(0.0)).abs() < 1e-6, "{to} {effect:?}: {q:?} against {drawn}");
}
// Without Standard Vertical Roman Alignment the letter lies on its side: its top still faces
// the path's end and its advance runs across the path, to its right.
let mut line = BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((1000.0, 0.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
t.path_effect = PathEffect::Rainbow;
t.runs[0].style.standard_vertical_roman = false;
let l = layout(&db, &t);
let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) };
let tangent = Vec2::new(s.angle.cos(), s.angle.sin());
let right = Vec2::new(-tangent.y, tangent.x);
let (pen, up, ahead) = (s.xf * Point::ZERO, s.xf * Point::new(0.0, -500.0), s.xf * Point::new(500.0, 0.0));
assert!((up - pen).dot(tangent) > 1.0 && (ahead - pen).dot(right) > 1.0, "{pen:?} {up:?} {ahead:?}");
}
/// On a path, an upright ligature whose cell Proportional Metrics shortens (`vpal`, from 1000 units
/// to 800) keeps its font caret (600 units down the cell) where it is drawn: the inner stop lies at
/// 600 / 800 of its width, and the caret stands at the drawn stop.
#[test]
fn an_upright_ligature_on_a_path_keeps_its_font_caret_on_a_proportional_cell() {
use vectorcraft_doc::PathEffect;
let db = db_with(proportional_vertical_ligature_caret_font(280, -200));
let mut t = point("fi", VERTICAL_LIGATURE_CARET_FAMILY);
t.vertical = true;
t.runs[0].style.standard_vertical_roman = true;
t.runs[0].style.proportional_metrics = true;
let mut line = BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((1000.0, 0.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
t.path_effect = PathEffect::Rainbow;
let l = layout(&db, &t);
let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) };
assert!((g.width - 80.0).abs() < 1e-6, "the cell is 800 units at 100 pt: {}", g.width);
let &[(1, f)] = g.carets.as_slice() else { panic!("one inner stop: {:?}", g.carets) };
assert!((f - 0.75).abs() < 1e-9, "{f}");
let tangent = Vec2::new(g.angle.cos(), g.angle.sin());
let along = |p: Point| (p - g.origin).dot(tangent);
let (top, drawn) = (along(g.xf * Point::new(0.0, -880.0)), along(g.xf * Point::new(0.0, -280.0)));
assert!((top - g.width_start).abs() < 1e-6 && (drawn - top - 60.0).abs() < 1e-6, "{top} {drawn} {}", g.width_start);
let (a, b) = caret_position(&l, 1);
let caret = along(a.midpoint(b));
assert!((caret - drawn).abs() < 1e-6, "the caret stands at the drawn stop: {caret} against {drawn}");
}
/// Tracking that makes an upright ligature's advance negative leaves it upright: it keeps its
/// caret down its cell, and its cell starts where it does without tracking, with or without the
/// font's vertical metrics.
+15
View File
@@ -1,5 +1,15 @@
use super::{Document, NodeId, Paint, Point, Selection, Unit, Vec2, distort, select, xform};
/// Which objects a Selection-tool marquee reaches before E reverses its rule for this drag.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MarqueeMode {
#[default]
Touching,
Enclosed,
/// Screen-right takes enclosed objects; screen-left takes touched objects.
Directional,
}
/// Paint defaults for new art (the fill/stroke proxy) and the rest of the new-art template.
#[derive(Clone, Debug, PartialEq)]
pub struct PaintDefaults {
@@ -120,6 +130,8 @@ pub struct ToolContext<'a> {
pub selection: &'a Selection,
/// Screen pixels per document point.
pub zoom: f64,
/// Selection-tool marquee rule; other selection tools keep their own rules.
pub marquee_mode: MarqueeMode,
pub isolation: Option<NodeId>,
pub paint: &'a PaintDefaults,
pub outline: bool,
@@ -223,6 +235,9 @@ pub struct ToolContext<'a> {
/// Type → Fill New Type Objects With Placeholder Text: type the Type tools place starts with
/// placeholder text, selected.
pub placeholder_text: bool,
/// Type → Start Lists From Typed Markers: a list marker and a space typed at the start of a
/// paragraph start a list ([`crate::text::TypeTool`]).
pub typed_lists: bool,
/// Smart Guides → Color: the smart guides' lines and labels (RGB).
pub smart_guide_color: [u8; 3],
/// Smart Guides → Alignment Guides: the lines along the edges and centres the art lines up
+185 -2
View File
@@ -12,6 +12,7 @@
//! On a grid, F and V lower and raise
//! the skew of the horizontal (rectangular) or radial (polar) dividers, and X and C lower and raise
//! that of the vertical or concentric ones.
//! Escape cancels the drag and puts back the options its keys and modifiers changed.
use serde_json::{Value, json};
use vectorcraft_geom::Point;
@@ -63,6 +64,22 @@ pub struct FamilyTool {
/// its own). The Reference Point is `reference`.
pub grid_size: (f64, f64),
snap: DrawSnap,
/// The options a drag's keys and modifiers change, as they were when the drag began: Escape
/// puts them back.
before_drag: Option<DragOptions>,
}
/// The options that keys and modifiers change while a shape is dragged out.
#[derive(Clone, Copy)]
struct DragOptions {
slope: f64,
closed: bool,
base_along: &'static str,
decay: f64,
segments: u32,
rows: u32,
columns: u32,
skew: [f64; 2],
}
impl FamilyTool {
@@ -99,9 +116,35 @@ impl FamilyTool {
fill: false,
grid_size: (100.0, 100.0),
snap: DrawSnap::default(),
before_drag: None,
}
}
fn drag_options(&self) -> DragOptions {
DragOptions {
slope: self.slope,
closed: self.closed,
base_along: self.base_along,
decay: self.decay,
segments: self.segments,
rows: self.rows,
columns: self.columns,
skew: self.skew,
}
}
/// Put back the options a canceled drag changed.
fn restore_drag_options(&mut self, o: DragOptions) {
self.slope = o.slope;
self.closed = o.closed;
self.base_along = o.base_along;
self.decay = o.decay;
self.segments = o.segments;
self.rows = o.rows;
self.columns = o.columns;
self.skew = o.skew;
}
fn label(&self) -> &'static str {
match self.id {
"spiral" => "Spiral",
@@ -264,6 +307,7 @@ impl Tool for FamilyTool {
self.began = false;
self.spiral_end = None;
self.spiral_alt_base = None;
self.before_drag = None;
vec![]
}
PointerKind::Drag => {
@@ -279,6 +323,7 @@ impl Tool for FamilyTool {
return out;
}
self.began = true;
self.before_drag = Some(self.drag_options());
out.push(Action::Begin(self.label().into()));
}
if self.id == "spiral" {
@@ -293,6 +338,7 @@ impl Tool for FamilyTool {
self.snap.clear();
self.spiral_end = None;
self.spiral_alt_base = None;
self.before_drag = None;
if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![Action::Dialog(self.id.into(), json!({"x": s.x, "y": s.y}))] }
}
_ => vec![],
@@ -364,6 +410,9 @@ impl Tool for FamilyTool {
self.began = false;
self.spiral_end = None;
self.spiral_alt_base = None;
if let Some(o) = self.before_drag.take() {
self.restore_drag_options(o);
}
self.snap.clear();
vec![Action::Cancel]
} else {
@@ -442,6 +491,23 @@ impl Tool for FamilyTool {
"height" => self.grid_size.1 = grid_length(v, self.grid_size.1),
_ => {}
}
// An option set while a shape is dragged out stays when Escape cancels the drag; a value
// the match above ignores leaves the drag's starting value as it was.
if let Some(b) = self.before_drag.as_mut() {
let finite = v.as_f64().is_some_and(f64::is_finite);
match key {
"slope" if finite => b.slope = self.slope,
"closed" if v.is_boolean() => b.closed = self.closed,
"baseAlong" if v.is_string() => b.base_along = self.base_along,
"decay" if v.as_f64().is_some() => b.decay = self.decay,
"segments" => b.segments = self.segments,
"rows" | "concentric" => b.rows = self.rows,
"columns" | "radial" => b.columns = self.columns,
"rowSkew" | "concentricSkew" if finite => b.skew[0] = self.skew[0],
"columnSkew" | "radialSkew" if finite => b.skew[1] = self.skew[1],
_ => {}
}
}
}
}
@@ -672,7 +738,8 @@ mod tests {
}
/// Switching between Alt and Ctrl/Cmd keeps the held inner end and uses the current segment
/// count when Alt resumes. Escape clears the held end before the next drag.
/// count when Alt resumes. Escape clears the held end and puts back the segments and decay
/// from before the drag.
#[test]
fn spiral_drag_switches_between_alt_and_ctrl_and_escape_lets_go_of_the_inner_end() {
let (d, _) = doc_with_rect();
@@ -699,9 +766,125 @@ mod tests {
t.key(&cx, ToolKey::Escape, alt);
assert_eq!(t.spiral_end, None);
assert_eq!(t.spiral_alt_base, None);
assert_eq!((t.segments, t.decay), (10, 80.0), "the options from before the drag");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt));
assert_eq!(t.segments, 18, "a new drag holds a new inner end");
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt));
assert_eq!(t.segments, 14, "a new drag holds a new inner end");
}
/// Escape during a drag puts back every option the drag's keys changed, for the next drag and
/// the tool's options; a drag that is let go keeps them.
#[test]
fn escape_puts_back_the_options_a_drag_changed() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let keys = |id: &str| -> Vec<ToolKey> {
match id {
"arc" => vec![ToolKey::Up, ToolKey::Letter('c'), ToolKey::Letter('f')],
"spiral" => vec![ToolKey::Up],
_ => vec![ToolKey::Up, ToolKey::Right, ToolKey::Letter('v'), ToolKey::Letter('c')],
}
};
let arc_and_spiral = ["slope", "closed", "baseAlong", "segments", "decay"];
let grids = ["rows", "columns", "concentric", "radial", "rowSkew", "columnSkew", "concentricSkew", "radialSkew"];
let pick = |v: &Value| arc_and_spiral.iter().chain(&grids).filter_map(|f| v.get(*f).map(|x| (*f, x.clone()))).collect::<Vec<_>>();
for id in ["arc", "spiral", "rectangularGrid", "polarGrid"] {
let mut t = FamilyTool::new(id);
let preview = |a: Vec<Action>| match a.last() {
Some(Action::Preview(_, v)) => v.clone(),
_ => panic!("{id}: {a:?}"),
};
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
let before = pick(&preview(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))));
let options = t.options();
let mut changed = before.clone();
for k in keys(id) {
changed = pick(&preview(t.key(&cx, k, Mods::default())));
}
assert_ne!(changed, before, "{id}: the keys change the options");
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{id}");
assert_eq!(t.options(), options, "{id}: the tool's options");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
let next = pick(&preview(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))));
assert_eq!(next, before, "{id}: the next drag");
// A drag that is let go keeps what its keys changed.
for k in keys(id) {
t.key(&cx, k, Mods::default());
}
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
let kept = pick(&preview(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))));
assert_eq!(kept, changed, "{id}: a finished drag keeps them");
t.key(&cx, ToolKey::Escape, Mods::default());
assert_eq!(pick(&t.options()), changed, "{id}: Escape puts back only what this drag changed");
}
}
/// Alt and Ctrl/Cmd change a spiral's segments and decay as it is dragged out; Escape puts
/// both back.
#[test]
fn escape_puts_back_the_spiral_decay_and_segments_alt_and_ctrl_changed() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let alt = Mods { alt: true, ..Mods::default() };
let ctrl = Mods { cmd: true, ..Mods::default() };
let mut t = FamilyTool::new("spiral");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(ctrl));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl));
assert!(t.segments != 10 && t.decay != 80.0, "{} {}", t.segments, t.decay);
t.key(&cx, ToolKey::Escape, ctrl);
assert_eq!((t.segments, t.decay), (10, 80.0));
}
/// An option set while a shape is dragged out (`tool.setOption`) stays when Escape cancels
/// the drag; the options only the drag's keys changed go back.
#[test]
fn escape_keeps_an_option_set_during_the_drag() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let mut t = FamilyTool::new("rectangularGrid");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0));
t.key(&cx, ToolKey::Up, Mods::default());
t.key(&cx, ToolKey::Right, Mods::default());
t.set_option("columns", &json!(9));
t.set_option("rowSkew", &json!(30.0));
t.key(&cx, ToolKey::Escape, Mods::default());
let o = t.options();
assert_eq!((o["rows"].clone(), o["columns"].clone(), o["rowSkew"].clone()), (json!(5), json!(9), json!(30.0)));
let mut t = FamilyTool::new("arc");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0));
t.key(&cx, ToolKey::Letter('c'), Mods::default());
t.set_option("slope", &json!(-20.0));
t.key(&cx, ToolKey::Escape, Mods::default());
let o = t.options();
assert_eq!((o["closed"].clone(), o["slope"].clone()), (json!(false), json!(-20.0)));
// A value the option ignores leaves the starting value for Escape.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0));
t.key(&cx, ToolKey::Up, Mods::default());
t.key(&cx, ToolKey::Letter('c'), Mods::default());
t.key(&cx, ToolKey::Letter('f'), Mods::default());
for (k, v) in [("slope", json!("steep")), ("closed", json!(1)), ("baseAlong", json!(null))] {
t.set_option(k, &v);
}
t.key(&cx, ToolKey::Escape, Mods::default());
let o = t.options();
assert_eq!((o["slope"].clone(), o["closed"].clone(), o["baseAlong"].clone()), (json!(-20.0), json!(false), json!("x")));
}
/// Alt adjusts the segment count in whole winds while keeping it within 2 to 1000.
+5 -1
View File
@@ -57,7 +57,7 @@ mod context;
mod cursor;
mod input;
pub use context::{GlyphSnap, PaintDefaults, ScreenFrame, ToolContext};
pub use context::{GlyphSnap, MarqueeMode, PaintDefaults, ScreenFrame, ToolContext};
pub use cursor::Cursor;
pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey};
@@ -66,6 +66,8 @@ pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey};
pub enum Overlay {
/// Marquee / rubber band rectangle.
Marquee(Rect),
/// Selection-tool marquee, with its effective rule and pointer position for feedback.
SelectionMarquee { rect: Rect, enclosed: bool, pointer: Point },
/// A path preview (e.g. pen rubber band) in a colour (RGB).
Path { path: BezPath, color: [u8; 3], width: f32, dashed: bool },
/// A line segment (smart guide, handle line).
@@ -482,6 +484,7 @@ pub(crate) mod testutil {
revision: (0, 0),
selection: s,
zoom: 1.0,
marquee_mode: MarqueeMode::Touching,
isolation: None,
paint: p,
outline: false,
@@ -525,6 +528,7 @@ pub(crate) mod testutil {
pen_rubber_band: true,
curvature_rubber_band: true,
placeholder_text: false,
typed_lists: true,
smart_guide_color: guides::MAGENTA,
alignment_guides: true,
anchor_path_labels: true,
+39 -9
View File
@@ -16,8 +16,8 @@
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
//! scaling its type: the text reflows at its size.
//! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and
//! E pressed again makes it take the objects it touches.
//! Marquee Selection defaults to touched objects, with fully enclosed and screen drag-direction
//! alternatives. E reverses the rule for the current drag. The overlay reports the effective rule.
//! Escape during a drag (a move or an Alt-drag copy, a scale, a rotation, a marquee, a widget or
//! bracket drag) cancels it: the art returns to its state at the press.
//! Selected area type and type on a path show an in port and an out port. A click on the out port
@@ -55,6 +55,29 @@ use crate::shaperwidget;
use crate::typewidget;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids};
/// Direction is read on screen, even in a turned view. Within three pixels of the press's
/// horizontal position, retain the previous rule so hand jitter does not flip it.
fn marquee_direction(cx: &ToolContext, start: Point, cur: Point, previous: bool) -> bool {
let dx = cx.screen.and_then(|f| Some(f.to_screen(cur)?.0 - f.to_screen(start)?.0)).unwrap_or((cur.x - start.x) * cx.zoom);
if dx > 3.0 {
true
} else if dx < -3.0 {
false
} else {
previous
}
}
/// The same effective rule drives artwork, ruler guides and the visible feedback.
fn marquee_enclosed(cx: &ToolContext, inverted: bool, directional_inside: bool) -> bool {
let inside = match cx.marquee_mode {
crate::MarqueeMode::Touching => false,
crate::MarqueeMode::Enclosed => true,
crate::MarqueeMode::Directional => directional_inside,
};
inside != inverted
}
#[derive(Clone, Debug, Default)]
enum State {
#[default]
@@ -97,9 +120,10 @@ enum State {
cur: Point,
/// Shift: the objects inside leave the selection if selected, else join it.
toggle: bool,
/// E pressed an odd number of times during the drag: the marquee takes only the objects
/// that lie entirely inside it.
/// E pressed an odd number of times during the drag: reverse the chosen rule.
enclosed: bool,
/// Last horizontal direction outside the 3 screen-pixel dead zone at the press.
directional_inside: bool,
},
/// Dragging a Live Corners widget.
Corner(CornerDrag),
@@ -952,8 +976,9 @@ impl Tool for SelectionTool {
}
vec![Action::Preview("object.transform".into(), params)]
}
(PointerKind::Drag, State::Marquee { start, toggle, enclosed, .. }) => {
self.state = State::Marquee { start, cur: p, toggle, enclosed };
(PointerKind::Drag, State::Marquee { start, toggle, enclosed, directional_inside, .. }) => {
self.state =
State::Marquee { start, cur: p, toggle, enclosed, directional_inside: marquee_direction(cx, start, p, directional_inside) };
vec![]
}
(PointerKind::Drag, State::Corner(mut c)) => {
@@ -1019,13 +1044,14 @@ impl Tool for SelectionTool {
out.push(Action::Commit);
out
}
(PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => {
(PointerKind::Up, State::Marquee { start, toggle, enclosed, directional_inside, .. }) => {
self.state = State::Idle;
let r = Rect::from_points(start, p);
if r.width() < Self::drag_threshold(cx) && r.height() < Self::drag_threshold(cx) {
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
}
let touched = marquee(cx.doc, r, cx.isolation, false);
let enclosed = marquee_enclosed(cx, enclosed, marquee_direction(cx, start, p, directional_inside));
let ids = if enclosed { inside(cx, r, touched) } else { touched };
let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) }));
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, enclosed)).collect()
@@ -1046,7 +1072,11 @@ impl Tool for SelectionTool {
}
}
match &self.state {
State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
State::Marquee { start, cur, enclosed, directional_inside, .. } => o.push(Overlay::SelectionMarquee {
rect: Rect::from_points(*start, *cur),
enclosed: marquee_enclosed(cx, *enclosed, *directional_inside),
pointer: *cur,
}),
State::Corner(c) => o.extend(c.overlays(cx)),
State::Pie(d) => o.extend(d.overlays(cx)),
State::RotateAll(d) => o.extend(d.overlays(cx)),
@@ -1544,7 +1574,7 @@ impl SelectionTool {
self.state = if !m.shift && selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects).is_some() {
State::Moving { start: grab, press: p, hold: grab, began: false, deselect: None, key: None, exclude: None }
} else {
State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false }
State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false, directional_inside: false }
};
}
+58 -3
View File
@@ -1307,7 +1307,7 @@ fn escape_cancels_a_marquee() {
t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0));
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the marquee");
assert!(!t.busy());
assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_))), "the marquee is removed");
assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_) | Overlay::SelectionMarquee { .. })), "the marquee is removed");
assert!(t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)).is_empty(), "the release selects nothing");
}
@@ -1380,6 +1380,58 @@ fn e_during_a_marquee_takes_only_the_objects_inside() {
assert!(t.busy() && !t.claims_key(&cx, e));
}
/// Each rule selects what its feedback promises, with E reversing it for just this gesture.
#[test]
fn marquee_rules_and_feedback_agree() {
use crate::MarqueeMode::{Directional, Enclosed, Touching};
let (d, id) = doc_with_rect();
let (s, paint) = (Selection::default(), paint());
for (mode, right, expected) in
[(Touching, true, true), (Touching, false, true), (Enclosed, true, false), (Directional, true, false), (Directional, false, true)]
{
let c = ToolContext { marquee_mode: mode, ..cx(&d, &s, &paint) };
for inverted in [false, true] {
let mut tool = SelectionTool::default();
let (a, b) = if right { ((50.0, 50.0), (150.0, 150.0)) } else { ((150.0, 50.0), (50.0, 150.0)) };
tool.pointer(&c, &ev(PointerKind::Down, a.0, a.1));
tool.pointer(&c, &ev(PointerKind::Drag, b.0, b.1));
if inverted {
tool.key(&c, ToolKey::Letter('e'), Mods::default());
}
let selected = expected != inverted;
assert!(
tool.overlays(&c).iter().any(|o| matches!(o, Overlay::SelectionMarquee { enclosed, .. } if *enclosed != selected)),
"{mode:?} right={right} E={inverted}"
);
let out = tool.pointer(&c, &ev(PointerKind::Up, b.0, b.1));
assert_eq!(out, vec![Action::Exec("select.set".into(), json!({"ids": if selected { vec![id.0] } else { vec![] }}))]);
assert!(!tool.overlays(&c).iter().any(|o| matches!(o, Overlay::SelectionMarquee { .. })));
}
}
}
/// Screen direction survives view rotation; near the press's x position, the last mode is kept.
#[test]
fn directional_marquee_uses_screen_direction_and_a_dead_zone() {
let (d, _) = doc_with_rect();
let (s, paint) = (Selection::default(), paint());
for angle in [0.0, 45.0, 90.0, 180.0, 270.0] {
for zoom in [0.25, 1.0, 4.0] {
let mut screen = turned_view(angle).unwrap();
screen.right /= zoom;
screen.down /= zoom;
let c = ToolContext { marquee_mode: crate::MarqueeMode::Directional, screen: Some(screen), zoom, ..cx(&d, &s, &paint) };
let start = screen.at(20.0, 20.0);
assert!(marquee_direction(&c, start, screen.at(24.0, 60.0), false));
assert!(!marquee_direction(&c, start, screen.at(16.0, 60.0), true));
for x in [18.0, 20.0, 22.0] {
assert!(marquee_direction(&c, start, screen.at(x, 60.0), true));
assert!(!marquee_direction(&c, start, screen.at(x, 60.0), false));
}
}
}
}
/// With E pressed during the drag, a marquee takes only the artboard guides that lie entirely
/// inside it, and no canvas guide, which runs across the whole canvas.
#[test]
@@ -2025,7 +2077,10 @@ fn a_dropped_press_keeps_the_text_loaded() {
t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0));
assert!(t.drop_press(&cx).is_empty());
assert!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)).is_empty(), "the release threads nothing");
assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Marquee(_))), "the new frame's outline is removed");
assert!(
!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Marquee(_) | Overlay::SelectionMarquee { .. })),
"the new frame's outline is removed"
);
assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText);
// A press on the port that loaded the text puts it down; dropped, it leaves the text loaded.
t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y));
@@ -2243,7 +2298,7 @@ fn a_press_on_the_centre_point_moves_an_unfilled_shape() {
t.pointer(&c, &ev(PointerKind::Down, 400.0, 400.0));
let a = t.pointer(&c, &ev(PointerKind::Drag, 450.0, 450.0));
assert!(a.is_empty(), "{a:?}");
assert!(matches!(t.overlays(&c).first(), Some(Overlay::Marquee(_))), "a marquee");
assert!(matches!(t.overlays(&c).first(), Some(Overlay::SelectionMarquee { .. })), "a marquee");
}
/// A Shift-press on a selected object's centre point moves the selection too, held to 45° steps,
+4
View File
@@ -521,7 +521,11 @@ impl TypeTool {
/// from the start of the caret's paragraph up to it start a list ([`TextObject::typed_list_at`]),
/// the typing session ends as one undo step with those characters, and `text.startList`
/// removes them and makes the paragraph a list as the next one, so one Undo puts them back.
/// With Type › Start Lists From Typed Markers off, the characters stay as typed.
fn start_typed_list(&mut self, cx: &ToolContext) -> Vec<Action> {
if !cx.typed_lists {
return vec![];
}
let (Some(id), Some(ty)) = (self.editing, &self.typing) else { return vec![] };
let Some(mut t) = Self::text(cx, id).cloned() else { return vec![] };
// The document does not have the space yet (its preview is applied after this input).
+12 -2
View File
@@ -123,6 +123,7 @@ impl VectorcraftApp {
canvas_rect: None,
hover_doc: None,
custom_titlebar: false,
desktop_frame: false,
last_window_title: String::new(),
fullscreen_for_mode: false,
graphics_adapter: None,
@@ -453,7 +454,8 @@ impl VectorcraftApp {
/// in a floating window, the tab strip's document draws as a pane.
pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui) {
let t = theme::Tokens::get(ui.ctx());
ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard);
// Tools may already occupy the left side of this panel.
ui.painter().rect_filled(ui.available_rect_before_wrap(), 0.0, t.pasteboard);
self.canvas.windows.strip = None;
if self.ui.screen_mode < 3 {
chrome::doc_tabs(self, ui);
@@ -541,8 +543,16 @@ impl VectorcraftApp {
widgets::set_menu_floor(&ctx, ctx.content_rect().bottom());
}
if self.ui.toolbar && self.ui.screen_mode < 3 {
tools_float::set_window_dock_zone(&ctx, ui.available_rect_before_wrap());
}
if !self.ui.tools_beside_canvas {
// A canvas-edge target left from before the setting was turned off docks nothing.
tools_float::clear_canvas_dock_zone(&ctx);
}
// Beside the canvas, docked Tools draws inside the canvas panel (`toolbar::show_beside_canvas`).
if self.ui.toolbar && self.ui.screen_mode < 3 && (!self.ui.tools_beside_canvas || self.ui.toolbar_pos.is_some()) {
toolbar::show(self, ui);
} else if self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() {
} else if !self.ui.toolbar && self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() {
// Window › Tools hides the Tools panel alone; Tab hides the toolbars made with New
// Toolbar too.
toolbar::show_custom(self, ui.ctx());
+8 -2
View File
@@ -206,8 +206,14 @@ fn wheel_view(
let turn_id = egui::Id::unique("canvas-wheel-turn");
let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default();
let mut turn = turn_before;
let (pointer, m, space, (factor, scroll)) =
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, animate, line, rect.height(), &mut turn)));
let (pointer, m, space, (factor, scroll)) = ui.input(|i| {
(
i.pointer.clone(),
i.modifiers,
i.key_down(egui::Key::Space),
wheel(i, &app.session.prefs, wheel_zooms, animate, line, rect.height(), &mut turn),
)
});
if turn != turn_before {
ui.data_mut(|d| d.insert_temp(turn_id, turn));
}
+23
View File
@@ -275,6 +275,29 @@ pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t:
match o {
// Where the tools select: the document rectangle, turned with the view.
Overlay::Marquee(r) => marquee_through(p, &xf.quad(*r)),
Overlay::SelectionMarquee { rect, enclosed, pointer } => {
let quad = xf.quad(*rect);
if *enclosed {
let points: Vec<Pos2> = quad.iter().chain(quad.first()).copied().collect();
p.add(Shape::line(points.clone(), Stroke::new(3.0, Color32::from_white_alpha(230))));
p.add(Shape::line(points, Stroke::new(1.0, Color32::from_gray(40))));
} else {
marquee_through(p, &quad);
}
let (current, other) = if *enclosed { (tl!("Fully Enclosed"), tl!("Touching")) } else { (tl!("Touching"), tl!("Fully Enclosed")) };
let label = format!("{current} · E: {other}");
let galley = p.layout(label, egui::FontId::proportional(11.0), t.text, (p.clip_rect().width() - 12.0).max(1.0));
let size = galley.size() + vec2(12.0, 8.0);
let wanted = xf.to_screen(*pointer) + vec2(14.0, 14.0);
let clip = p.clip_rect();
let min = egui::pos2(
wanted.x.clamp(clip.min.x, (clip.max.x - size.x).max(clip.min.x)),
wanted.y.clamp(clip.min.y, (clip.max.y - size.y).max(clip.min.y)),
);
let badge = egui::Rect::from_min_size(min, size);
p.rect_filled(badge, CornerRadius::same(3), t.panel);
p.galley(badge.min + vec2(6.0, 4.0), galley, t.text);
}
Overlay::Path { path, color, width, dashed } => {
let s = Stroke::new(*width, c32(*color));
if *dashed {
+347 -2
View File
@@ -2,6 +2,65 @@ use super::*;
use vectorcraft_engine::Session;
use vectorcraft_geom::Shape as _;
/// The marquee explains E without depending on color. At the canvas edge the hint stays in
/// view, in every brightness, and the two rules have visibly different outlines.
#[test]
fn marquee_feedback_explains_the_rule_and_stays_on_canvas() {
let draw = |ui: &mut egui::Ui| {
let t = Tokens::get(ui.ctx());
let xf = Xf { rect: ui.max_rect(), zoom: 1.0, center: Point::new(160.0, 100.0), rot: 0.0 };
let prefs = vectorcraft_engine::Prefs::default();
for (enclosed, y) in [(false, 80.0), (true, 180.0)] {
overlays::draw_overlays(
ui.painter(),
&xf,
&[Overlay::SelectionMarquee { rect: Rect::new(20.0, y - 60.0, 300.0, y), enclosed, pointer: Point::new(315.0, y) }],
&t,
overlays::HandleLook::of(&prefs),
);
}
};
for brightness in crate::theme::Brightness::ALL {
for width in [160.0, 320.0] {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, brightness);
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(width, 200.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| draw(ui));
out.textures_delta.clear();
let text = crate::tests_labels::shapes_text(&out);
assert!(text.contains("Fully Enclosed · E: Touching") && text.contains("Touching · E: Fully Enclosed"), "{text}");
for shape in &out.shapes {
if let egui::Shape::Text(label) = &shape.shape {
assert!(
label.pos.x >= shape.clip_rect.min.x && label.pos.x + label.galley.size().x <= shape.clip_rect.max.x,
"{brightness:?}: hint clipped"
);
assert!(
label.pos.y >= shape.clip_rect.min.y && label.pos.y + label.galley.size().y <= shape.clip_rect.max.y,
"{brightness:?}: hint clipped vertically"
);
}
}
}
}
// Optional visual QA of our own UI. Captures never enter the repository.
if let Some(path) = std::env::var_os("VECTORCRAFT_MARQUEE_CAPTURE") {
let mut ready = false;
let mut harness = egui_kittest::Harness::builder().with_size(vec2(320.0, 200.0)).wgpu().build_ui(move |ui| {
if !ready {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark);
ready = true;
return;
}
draw(ui);
});
harness.run_steps(4);
harness.render().unwrap().save(path).unwrap();
}
}
#[test]
fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() {
let mut observed = Vec::new();
@@ -4306,7 +4365,8 @@ fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() {
}
/// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off,
/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel
/// the wheel scrolls and Cmd-wheel scrolls sideways (Scroll Sideways with Cmd/Ctrl + Wheel is on
/// by default); on, the wheel zooms about the pointer, Shift-wheel
/// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a
/// trackpad pinch zoom about the pointer either way.
#[test]
@@ -4348,7 +4408,9 @@ fn zoom_with_mouse_wheel_preference() {
let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6;
let (zoom, scroll, _) = turn(&mut app, "");
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "off: the wheel scrolls up ({zoom}, {scroll:?})");
assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer");
// General › Scroll Sideways with Cmd/Ctrl + Wheel is on by default.
let (zoom, scroll, _) = turn(&mut app, "cmd");
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "off: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})");
assert!(zooms_in(turn(&mut app, "alt")), "off: Alt-wheel zooms in about the pointer");
assert!(zooms_in(turn(&mut app, "pinch")), "off: a pinch zooms in about the pointer");
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
@@ -4361,6 +4423,289 @@ fn zoom_with_mouse_wheel_preference() {
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})");
}
/// General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default), through the control channel's
/// `ui.wheel`: with Zoom with Mouse Wheel off, Cmd/Ctrl-wheel zooms about the pointer while the
/// option is off and scrolls sideways while it is on, and a trackpad pinch, two fingers spread on a touch
/// screen, Alt + Cmd/Ctrl-wheel and Alt-wheel still zoom. A Cmd/Ctrl-wheel notch with a plain
/// one right after it scrolls two notches in all, and a trackpad gesture during which Cmd/Ctrl
/// is pressed and released scrolls sideways until the gesture ends, while an Alt gesture that
/// pauses past egui's scroll timeout no longer holds Alt for the next move. With Zoom with
/// Mouse Wheel also on, Cmd/Ctrl-wheel scrolls sideways and the wheel zooms.
#[test]
fn cmd_wheel_scrolls_sideways_preference() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
// A canvas frame through the host's input hook (it feeds the control channel's input).
let run = |app: &mut VectorcraftApp| {
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
app.raw_input_hook(&ctx, &mut raw);
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
};
run(&mut app);
let rect = app.canvas_rect.unwrap();
let at = rect.center() + vec2(60.0, 40.0);
// One notch up at `at` with `mods` held, or a pinch: → (zoom ratio, scroll in points, how
// far the point under `at` moved).
let turn = |app: &mut VectorcraftApp, mods: &str| {
let before = *app.view().unwrap();
if mods == "pinch" {
app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]);
} else {
let mut p = json!({"x": at.x, "y": at.y, "dy": 1});
for m in mods.split('+').filter(|m| !m.is_empty()) {
p[m] = json!(true);
}
crate::tests_synthetic::control(app, &ctx, "ui.wheel", p);
}
// egui spreads a notch over a few frames.
for _ in 0..40 {
run(app);
}
let after = *app.view().unwrap();
let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at));
(after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved)
};
let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6;
assert!(app.session.prefs.ctrl_wheel_scrolls_sideways, "the option is on by default");
app.session.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": false})).unwrap();
assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer");
let (zoom, scroll, _) = turn(&mut app, "shift");
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "off: Shift-wheel scrolls sideways ({zoom}, {scroll:?})");
app.session.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": true})).unwrap();
// A notch scrolls one wheel line.
let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed));
let (zoom, scroll, _) = turn(&mut app, "cmd");
assert!(
(zoom - 1.0).abs() < 1e-9 && (scroll.x + line).abs() < 0.5 && scroll.y == 0.0,
"on: Cmd-wheel scrolls sideways by a notch ({zoom}, {scroll:?})"
);
assert!(zooms_in(turn(&mut app, "pinch")), "on: a pinch right after it zooms in about the pointer");
// Two fingers spread on a touch screen, with no wheel turn since the Cmd-wheel one.
let before = app.view().unwrap().zoom;
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
let fingers = |phase, d: f32| vec![touch(1, phase, at - vec2(40.0 + d, 0.0)), touch(2, phase, at + vec2(40.0 + d, 0.0))];
frame(&mut app, &ctx, fingers(egui::TouchPhase::Start, 0.0));
for k in 1..=3 {
frame(&mut app, &ctx, fingers(egui::TouchPhase::Move, 10.0 * k as f32));
}
frame(&mut app, &ctx, fingers(egui::TouchPhase::End, 30.0));
let zoom = app.view().unwrap().zoom / before;
assert!(zoom > 1.01, "on: fingers spread on a touch screen zoom in ({zoom})");
assert!(zooms_in(turn(&mut app, "alt+cmd")), "on: Alt + Cmd-wheel zooms in about the pointer");
assert!(zooms_in(turn(&mut app, "alt")), "on: so does Alt-wheel");
let (zoom, scroll, _) = turn(&mut app, "");
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "on: the wheel scrolls up ({zoom}, {scroll:?})");
let (zoom, scroll, _) = turn(&mut app, "shift");
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Shift-wheel scrolls sideways ({zoom}, {scroll:?})");
// A Cmd-wheel notch with a plain one right after it, before egui has spread the first: they
// scroll two notches in all, sideways and up.
let before = *app.view().unwrap();
crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true}));
crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1}));
for _ in 0..40 {
run(&mut app);
}
let after = *app.view().unwrap();
let scroll = (after.center - before.center) * after.zoom;
assert!(
scroll.x < -1.0 && scroll.y < -1.0 && (scroll.x + scroll.y + 2.0 * line).abs() < 0.5,
"on: a Cmd-wheel notch and a plain one right after it scroll two notches in all ({scroll:?})"
);
// A pinch while egui still spreads a Cmd-wheel notch zooms by the pinch alone, and the
// canvas scrolls sideways by one notch; the pinch magnifies the part scrolled before it.
let before = *app.view().unwrap();
let p = Xf::new(rect, &before).to_doc(at);
crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true}));
app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]);
for _ in 0..40 {
run(&mut app);
}
let after = *app.view().unwrap();
let (zoom, moved) = (after.zoom / before.zoom, Xf::new(rect, &after).to_screen(p) - at);
let notch = line as f32;
assert!(
(zoom - 1.25).abs() < 1e-6 && moved.x > notch - 0.5 && moved.x < 1.25 * notch + 0.5 && moved.y.abs() < 1e-3,
"on: a pinch during a Cmd-wheel notch zooms by the pinch, and the notch scrolls sideways ({zoom}, {moved:?})"
);
// A trackpad gesture starts with no modifier, and Cmd is pressed and released during it:
// egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps scrolling
// sideways until then.
let before = *app.view().unwrap();
let swipe = |phase, modifiers| egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 6.0),
phase,
modifiers,
source: egui::MouseWheelSource::Unknown,
};
app.synthetic.extend([
egui::Event::PointerMoved(at),
swipe(egui::TouchPhase::Start, egui::Modifiers::NONE),
swipe(egui::TouchPhase::Move, egui::Modifiers::COMMAND),
swipe(egui::TouchPhase::Move, egui::Modifiers::NONE),
swipe(egui::TouchPhase::End, egui::Modifiers::NONE),
]);
for _ in 0..40 {
run(&mut app);
}
let after = *app.view().unwrap();
let scroll = (after.center - before.center) * after.zoom;
assert!(
(after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 12.0).abs() < 0.01 && scroll.y == 0.0,
"on: a trackpad gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})",
after.zoom / before.zoom
);
// An Alt-wheel gesture pauses past egui's scroll timeout with no frame in between: egui
// ends it before the next event, so a move with Cmd alone scrolls sideways.
// Frames at the given times, with the events in the frame's own input (the host's input
// hook hands synthetic events out over several frames).
let run_at = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
time: Some(time),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
};
// The Alt part rests the fingers without moving, so only the Cmd move below acts.
let rest = |phase| egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: Vec2::ZERO,
phase,
modifiers: egui::Modifiers::ALT,
source: egui::MouseWheelSource::Unknown,
};
let t = ctx.input(|i| i.time) + 1.0;
run_at(&mut app, t, vec![egui::Event::PointerMoved(at), rest(egui::TouchPhase::Start), rest(egui::TouchPhase::Move)]);
let before = *app.view().unwrap();
run_at(&mut app, t + 2.0, vec![swipe(egui::TouchPhase::Move, egui::Modifiers::COMMAND)]);
for k in 1..=40 {
run_at(&mut app, t + 2.0 + f64::from(k) / 60.0, vec![]);
}
let after = *app.view().unwrap();
let scroll = (after.center - before.center) * after.zoom;
assert!(
(after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 6.0).abs() < 0.01 && scroll.y == 0.0,
"on: after egui's timeout, a Cmd move scrolls sideways ({}, {scroll:?})",
after.zoom / before.zoom
);
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
let (zoom, scroll, _) = turn(&mut app, "cmd");
assert!(
(zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0,
"with Zoom with Mouse Wheel: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"
);
assert!(zooms_in(turn(&mut app, "")), "with Zoom with Mouse Wheel: the wheel zooms in about the pointer");
}
/// With Scroll Sideways with Cmd/Ctrl + Wheel on and Animated Zoom off, Alt + Cmd-wheel zooms
/// about the pointer by egui's Cmd-wheel zoom, as Cmd-wheel does with the option off, and
/// Cmd-wheel alone scrolls sideways.
#[test]
fn cmd_wheel_sideways_alt_cmd_zooms_without_animated_zoom() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
for (key, value) in [("ctrlWheelScrollsSideways", true), ("zoomWithMouseWheel", false), ("animatedZoom", false)] {
app.session.execute("prefs.set", &json!({"key": key, "value": value})).unwrap();
}
let ctx = egui::Context::default();
let run = |app: &mut VectorcraftApp| {
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
app.raw_input_hook(&ctx, &mut raw);
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
};
run(&mut app);
let rect = app.canvas_rect.unwrap();
let at = rect.center() + vec2(60.0, 40.0);
let before = *app.view().unwrap();
crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "alt": true, "cmd": true}));
for _ in 0..40 {
run(&mut app);
}
let after = *app.view().unwrap();
let expected = ctx.options(|o| f64::from(o.input_options.line_scroll_speed * o.input_options.scroll_zoom_speed).exp());
let zoom = after.zoom / before.zoom;
assert!((zoom - expected).abs() < 1e-3, "Alt + Cmd-wheel zooms in by one notch ({zoom}, expected {expected})");
assert!(Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)) < 1e-6, "Alt + Cmd-wheel zooms about the pointer");
let before = after;
crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true}));
for _ in 0..40 {
run(&mut app);
}
let after = *app.view().unwrap();
let scroll = (after.center - before.center) * after.zoom;
let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed));
assert!((after.zoom / before.zoom - 1.0).abs() < 1e-9, "Cmd-wheel does not zoom");
assert!((scroll.x + line).abs() < 0.5 && scroll.y == 0.0, "Cmd-wheel scrolls sideways ({scroll:?})");
}
/// With Zoom with Mouse Wheel off, a trackpad gesture that starts with Alt keeps zooming about the
/// pointer until it ends, also after Alt is let go, as egui keeps a modifier held during a gesture,
/// with Animated Zoom on and off. A gesture that gains Alt partway zooms from then on.
#[test]
fn alt_wheel_gesture_keeps_zooming_after_alt_is_let_go() {
for animate in [true, false] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
for (key, value) in [("zoomWithMouseWheel", false), ("animatedZoom", animate)] {
app.session.execute("prefs.set", &json!({"key": key, "value": value})).unwrap();
}
let ctx = egui::Context::default();
let run = |app: &mut VectorcraftApp| {
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
app.raw_input_hook(&ctx, &mut raw);
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
};
run(&mut app);
let rect = app.canvas_rect.unwrap();
let at = rect.center() + vec2(60.0, 40.0);
let swipe = |phase, modifiers| egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 6.0),
phase,
modifiers,
source: egui::MouseWheelSource::Unknown,
};
// One gesture with the given modifiers on its Start and its two Moves → (zoom ratio, how far
// the point under `at` moved).
let gesture = |app: &mut VectorcraftApp, mods: [egui::Modifiers; 3]| {
let before = *app.view().unwrap();
app.synthetic.extend([
egui::Event::PointerMoved(at),
swipe(egui::TouchPhase::Start, mods[0]),
swipe(egui::TouchPhase::Move, mods[1]),
swipe(egui::TouchPhase::Move, mods[2]),
swipe(egui::TouchPhase::End, egui::Modifiers::NONE),
]);
for _ in 0..40 {
run(app);
}
let after = *app.view().unwrap();
(after.zoom / before.zoom, Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)))
};
let alt = egui::Modifiers::ALT;
let (held, _) = gesture(&mut app, [alt, alt, alt]);
assert!(held > 1.01, "animate {animate}: an Alt gesture zooms in ({held})");
let (zoom, moved) = gesture(&mut app, [alt, alt, egui::Modifiers::NONE]);
assert!(
(zoom - held).abs() < 1e-9 && moved < 1e-6,
"animate {animate}: a move without Alt keeps the gesture zooming about the pointer ({zoom}, expected {held}; moved {moved})"
);
let (zoom, moved) = gesture(&mut app, [egui::Modifiers::NONE, alt, egui::Modifiers::NONE]);
assert!(
(zoom - held).abs() < 1e-9 && moved < 1e-6,
"animate {animate}: Alt pressed partway zooms the rest of the gesture about the pointer ({zoom}, expected {held}; moved {moved})"
);
}
}
/// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a
/// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom.
#[test]
+84 -9
View File
@@ -52,8 +52,19 @@ const WHEEL_GLIDE: f64 = 0.05;
/// What the wheel did across frames ([`wheel`]).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct WheelTurn {
/// The last wheel turn was an Alt-wheel one.
/// The last wheel turn was an Alt-wheel one: `held` had Alt without Cmd/Ctrl at its last Start
/// or Move.
alt: bool,
/// The modifiers egui's wheel state holds, followed while Zoom with Mouse Wheel is off: each
/// wheel event's own, or, through a trackpad gesture, all those held since it began; none
/// while the wheel is idle.
held: egui::Modifiers,
/// A trackpad gesture is under way: egui has seen its Start phase and has not yet seen its End
/// or Cancel phase.
gesture: bool,
/// When the last wheel event came (`InputState::time`), and whether a trackpad sent it.
last: f64,
trackpad: bool,
/// The zoom (natural log) wheel notches still have to glide in.
pub(super) glide: f64,
}
@@ -342,13 +353,22 @@ struct ThumbDrag {
/// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame:
/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel
/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
/// on the Mac) zoom; with General › Scroll Sideways with Cmd/Ctrl + Wheel, Cmd/Ctrl-wheel scrolls
/// sideways instead, and Alt + Cmd/Ctrl-wheel still zooms. With General › Zoom with Mouse Wheel the
/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
/// per wheel line and page. `turn`: kept by the caller across frames. With Performance › Animated
/// Zoom (`animate`) a mouse wheel's notches zoom in a glide over a few frames, as scrolling does
/// (#888); off, each notch zooms on the frame it arrives (#4283). A trackpad's fine steps zoom at
/// once either way.
pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, animate: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) {
pub(super) fn wheel(
i: &egui::InputState,
prefs: &vectorcraft_engine::Prefs,
wheel_zooms: bool,
animate: bool,
line: f32,
page: f32,
turn: &mut WheelTurn,
) -> (f64, egui::Vec2) {
// Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead).
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta);
// A wheel event's motion in points.
@@ -360,17 +380,72 @@ pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, animate: bool, line
if !wheel_zooms {
// egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few
// frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go.
if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) {
turn.alt = m.alt && !m.command;
// Each wheel event changes `turn.held` and `turn.gesture` as `WheelState::on_wheel_event`
// changes egui's wheel modifiers and status. Both are cleared while egui's wheel is idle.
// `turn.alt` follows `turn.held` after each Start or Move, so a trackpad gesture that held
// Alt keeps zooming until it ends, also after Alt is let go.
for e in &i.events {
if let egui::Event::MouseWheel { phase, modifiers, source, .. } = e {
// A wheel event that comes after egui's scroll timeout ends the turn first, as
// `on_wheel_event` does (1 s for fingers resting on a trackpad mid-gesture, else
// 0.15 s), also when no frame ran in between.
let timeout = if turn.gesture && turn.trackpad { 1.0 } else { 0.150 };
if i.time - turn.last > timeout {
turn.gesture = false;
turn.held = egui::Modifiers::NONE;
}
turn.last = i.time;
turn.trackpad = *source == egui::MouseWheelSource::Trackpad;
match phase {
egui::TouchPhase::Start => {
turn.gesture = true;
turn.held = *modifiers;
turn.alt = turn.held.alt && !turn.held.command;
}
egui::TouchPhase::Move => {
if turn.gesture {
turn.held |= *modifiers;
} else {
turn.held = *modifiers;
}
turn.alt = turn.held.alt && !turn.held.command;
}
egui::TouchPhase::End | egui::TouchPhase::Cancel => {
turn.gesture = false;
turn.held = egui::Modifiers::NONE;
}
}
}
}
if !i.is_scrolling() {
turn.gesture = false;
turn.held = egui::Modifiers::NONE;
}
let (mut zoom, mut scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
// Scroll Sideways with Cmd/Ctrl + Wheel: while egui's wheel state holds Cmd/Ctrl without
// Alt, egui's zoom, less the pinches', is `exp(points × scroll_zoom_speed)`, at egui's
// default speed, which the app keeps. The canvas scrolls sideways by those points instead.
// A trackpad or web pinch still zooms. With fingers on a touch screen, egui's zoom is the
// fingers' alone, and the canvas leaves it as it is.
if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && i.multi_touch().is_none() {
let pinch: f64 = i.events.iter().filter_map(|e| if let egui::Event::Zoom(f) = e { Some(f64::from(*f)) } else { None }).product();
let wheel_zoom = zoom / pinch;
if wheel_zoom.is_finite() && wheel_zoom > 0.0 {
scroll.x += (wheel_zoom.ln() / f64::from(egui::InputOptions::default().scroll_zoom_speed)) as f32;
zoom = pinch;
}
}
let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
if turn.alt && !animate {
// Without Animated Zoom, Alt-wheel zooms by the wheel events themselves, on the frame
// they arrive; the rest of egui's spread scroll is dropped.
// they arrive; the rest of egui's spread scroll is dropped. Only Move events count, as
// `on_wheel_event` moves egui's scroll by Move events alone.
let d: f32 = i
.events
.iter()
.filter_map(|e| if let egui::Event::MouseWheel { unit, delta, .. } = e { Some(points(unit, *delta)) } else { None })
.filter_map(|e| match e {
egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, .. } => Some(points(unit, *delta)),
_ => None,
})
.map(|d| d.x + d.y)
.sum();
return (zoom * (f64::from(d) * WHEEL_ZOOM).exp(), pan);
+8 -6
View File
@@ -129,13 +129,15 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// Whether the window shows the system's title bar instead of the app drawing its own: Windows
/// and Linux with Preferences › User Interface › System Title Bar. macOS always has system
/// decorations, but keeps the in-app brand mark, so it never counts as system mode here; nor
/// does the web build (the browser tab has no document title), nor an embedder that never
/// turned the preference on.
/// Whether the window shows the system's title bar instead of the app drawing its own
/// ([`VectorcraftApp::desktop_frame`]): Windows with Preferences › User Interface › System Title
/// Bar, and Linux whenever the desktop draws the frame, with or without the preference. The
/// preference is read when the app starts, so changing it later does not change this. macOS
/// always has system decorations, but keeps the in-app brand mark, so it never counts as system
/// mode here; nor does the web build (the browser tab has no document title), nor an embedder
/// without a window of its own.
pub fn system_title_bar(app: &VectorcraftApp) -> bool {
!app.custom_titlebar && app.session.prefs.system_title_bar && !cfg!(any(target_os = "macos", target_arch = "wasm32"))
app.desktop_frame && !app.custom_titlebar && !cfg!(any(target_os = "macos", target_arch = "wasm32"))
}
/// The OS window title: the active document's name suffixed with the app name (a `*` prefix
+35
View File
@@ -1230,16 +1230,51 @@ fn the_zoom_hint_follows_animated_zoom() {
fn title_test_app(custom_titlebar: bool) -> crate::VectorcraftApp {
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.custom_titlebar = custom_titlebar;
app.desktop_frame = !custom_titlebar;
// The preference that makes the shell launch without its own title bar.
app.run("prefs.set", json!({"key": "systemTitleBar", "value": !custom_titlebar})).unwrap();
app
}
/// How many brand marks one frame of the app bar paints.
fn brand_marks(app: &mut crate::VectorcraftApp) -> usize {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::app_bar(app, ui));
out.textures_delta.clear();
out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Mesh(m) if m.texture_id == crate::brand::texture_id(&ctx))).count()
}
#[test]
fn without_the_preference_the_mark_stays() {
// The default (e.g. the web build): no custom bar, no preference.
let app = crate::VectorcraftApp::new(Session::new(), Default::default());
assert!(!super::system_title_bar(&app));
// An embedder without a window of its own keeps the mark with the preference on too.
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("prefs.set", json!({"key": "systemTitleBar", "value": true})).unwrap();
assert!(!super::system_title_bar(&app));
assert_eq!(brand_marks(&mut app), 1);
}
#[test]
fn a_desktop_frame_hides_the_mark_whatever_the_preference_says() {
// Linux on a desktop that draws the frame (KDE, X11): the window has the desktop's title bar
// with System Title Bar off.
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.desktop_frame = true;
app.run("prefs.set", json!({"key": "systemTitleBar", "value": false})).unwrap();
let hidden = !cfg!(target_os = "macos");
assert_eq!(super::system_title_bar(&app), hidden);
assert_eq!(brand_marks(&mut app), if hidden { 0 } else { 1 });
// The preference applies at the next start: changing it now leaves the window's frame, and the mark, as they are.
let mut app = title_test_app(false);
app.run("prefs.set", json!({"key": "systemTitleBar", "value": false})).unwrap();
assert_eq!(super::system_title_bar(&app), hidden);
let mut app = title_test_app(true);
app.run("prefs.set", json!({"key": "systemTitleBar", "value": true})).unwrap();
assert!(!super::system_title_bar(&app));
assert_eq!(brand_marks(&mut app), 1);
}
#[test]
+8 -3
View File
@@ -523,12 +523,17 @@ pub fn sync(ui: &UiState) {
}
}
/// Window › Toolbars: Advanced and Double Column, the toolbars made with New Toolbar (each checked
/// while it shows), New Toolbar… and Manage Toolbars….
/// Window › Toolbars: Advanced, Double Column and Keep Tools Beside the Canvas, the toolbars made
/// with New Toolbar (each checked while it shows), New Toolbar… and Manage Toolbars….
pub fn menu_items() -> Vec<crate::menus::Item> {
use crate::menus::Item;
let c = |label: &'static str, id: &'static str| Item::Cmd(label, id, Value::Null);
let mut v = vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns"), Item::Sep];
let mut v = vec![
c("Advanced", "window.toolbarAdvanced"),
c("Double Column", "window.toolbarColumns"),
c("Keep Tools Beside the Canvas", "window.toolsBesideCanvas"),
Item::Sep,
];
let names = names_store().read().map(|v| v.clone()).unwrap_or_default();
if !names.is_empty() {
v.extend(names.into_iter().map(|n| Item::Cmd(n, "window.toolbar.custom", json!({ "name": n }))));
+60 -4
View File
@@ -92,9 +92,23 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::check(ui, d, "overrideSave", "Override Action \"Save\" Commands");
form::check(ui, d, "overrideExport", "Override Action \"Export\" Commands");
form::check(ui, d, "stopOnError", "Stop on Errors");
if overwrites_one_file(app, d) {
ui.add_space(6.0);
ui.label(egui::RichText::new(tl!("Each data set saves over the same file, which keeps only the last data set.")).size(11.5).color(t.warning));
}
false
}
/// Whether the batch the dialog would run saves every data set over the same file
/// ([`vectorcraft_engine::cmd::fileio::batch_overwrites_one_file`]).
fn overwrites_one_file(app: &VectorcraftApp, d: &Dialog) -> bool {
let set = d.fields.get("set").and_then(Value::as_u64).unwrap_or(0) as usize;
let action = d.fields.get("action").and_then(Value::as_u64).unwrap_or(0) as usize;
let steps = app.ui.action_sets.get(set).and_then(|s| s.actions.get(action)).map_or(&[][..], |a| &a.steps[..]);
let commands = steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect();
vectorcraft_engine::cmd::fileio::batch_overwrites_one_file(&app.session, &params(d, "", commands))
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let set = d.fields.get("set").and_then(Value::as_u64).unwrap_or(0) as usize;
let action = d.fields.get("action").and_then(Value::as_u64).unwrap_or(0) as usize;
@@ -161,6 +175,18 @@ mod tests {
use vectorcraft_engine::Session;
fn shown(source: &str) -> Vec<String> {
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test"));
d.fields["source"] = json!(source);
render(&mut d)
}
/// Draws `d` once and returns the texts it shows.
fn render(d: &mut Dialog) -> Vec<String> {
render_in(&mut VectorcraftApp::new(Session::new(), Default::default()), d)
}
/// Draws `d` once over `app` and returns the texts it shows.
fn render_in(app: &mut VectorcraftApp, d: &mut Dialog) -> Vec<String> {
fn collect(shape: &egui::Shape, out: &mut Vec<String>) {
match shape {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
@@ -168,13 +194,10 @@ mod tests {
_ => {}
}
}
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test"));
d.fields["source"] = json!(source);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut frame = ctx.run_ui(Default::default(), |ui| {
body(&mut app, ui, &mut d);
body(app, ui, d);
});
frame.textures_delta.clear();
let mut labels = vec![];
@@ -220,4 +243,37 @@ mod tests {
d.fields["source"] = json!("folder");
assert!(params(&d, "Test", vec![]).get("datasetFileNaming").is_none());
}
const ONE_FILE: &str = "Each data set saves over the same file, which keeps only the last data set.";
#[test]
fn data_sets_offer_save_and_close_with_a_warning() {
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test"));
d.fields["destination"] = json!("saveAndClose");
let labels = render(&mut d);
assert!(labels.iter().any(|label| label == "Save and Close"), "{labels:?}");
assert!(!labels.iter().any(|label| label == ONE_FILE), "a folder source opens a file per pass: {labels:?}");
d.fields["source"] = json!("datasets");
let labels = render(&mut d);
assert!(labels.iter().any(|label| label == "Save and Close"), "{labels:?}");
assert!(labels.iter().any(|label| label == ONE_FILE), "{labels:?}");
assert_eq!(params(&d, "Test", vec![])["destination"], "saveAndClose");
d.fields["destination"] = json!("folder");
assert!(!render(&mut d).iter().any(|label| label == ONE_FILE));
}
#[test]
fn data_sets_warn_about_a_recorded_save_left_in_place() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.ui.action_sets[0].actions[0].steps.push(("document.save".into(), json!({"path": "/x/a.vectorcraft"})));
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test"));
d.fields["source"] = json!("datasets");
d.fields["destinationFolder"] = json!("/out");
assert!(!render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE), "Override Save points the save into the folder");
d.fields["overrideSave"] = json!(false);
assert!(render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE));
d.fields["overrideSave"] = json!(true);
d.fields["destination"] = json!("none");
assert!(render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE));
}
}
@@ -508,7 +508,7 @@ pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
shortcut_editor::open(app);
}
if let Some(k) = app.ui.dialog.as_mut() {
k.fields.insert("overrides".into(), crate::keymap::stored::to_value(km.overrides()));
k.fields.insert("overrides".into(), crate::keymap::stored::to_value(&shortcut_editor::saved_overrides(km.overrides(), &ov)));
k.fields.insert("set".into(), json!(set));
k.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set.")));
}
@@ -500,6 +500,25 @@ fn apply_rows(app: &mut VectorcraftApp, start: &str, rows: &[(&str, &str, &str,
(out, dialog(app).fields["overrides"].clone())
}
#[test]
fn resetting_an_imported_function_key_restores_its_edit_default() {
if crate::keymap::platform().is_mac() {
return;
}
for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] {
let mut app = app();
let (out, value) = apply_rows(&mut app, "defaults", &[("Acme Duplicate", "Menus", "edit.duplicate", alias)]);
assert_eq!(out["applied"], 1);
let mut saved = crate::keymap::stored::from_value(value);
assert!(!crate::keymap::has_override(&saved, edit), "{alias} saved edit override");
shortcut_editor::reset_one(&mut saved, "edit.duplicate");
assert_eq!(
crate::keymap::lookup(&crate::keymap::keymap_with(&saved), &crate::keymap::vc_chord(alias).unwrap()),
Some(crate::keymap::command_of(edit)),
);
}
}
/// Cmd and Ctrl are one key off the Mac: the review lets both rows through, and the later one takes
/// the shortcut.
#[test]
+10
View File
@@ -1130,6 +1130,7 @@ font-technology Variable Променливи
Live curve fitting Напасване на кривата в реално време
Fill Color Цвят на запълване
Fill New Type Objects With Placeholder Text Запълване на нови текстови обекти с примесен текст
Start Lists From Typed Markers Започване на списъци от въведени маркери
Fill Rule Правило за запълване
Fill with Placeholder Text Запълване с примесен текст
Fill: Запълване:
@@ -2979,6 +2980,11 @@ digits Malayalam Малаялам
Selected Swatches Избрани образци
Selected Swatches Only Само избрани образци
Selection Селекция
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Избор с рамка
Touching Докоснати обекти
Fully Enclosed Изцяло вътре
Drag Direction Посока на плъзгане
Selection & Anchor Display Показване на селекция и опорни точки
Selection Only Само селекция
Selection Tool Инструмент „Селекция“
@@ -3114,6 +3120,7 @@ digits Malayalam Малаялам
Simulate Colored Paper Симулиране на цветна хартия
Simulate Paper Симулиране на хартия
Double Column Две колони
Keep Tools Beside the Canvas Инструментите до платното
Single Left Quotation Mark Лява единична кавичка
Single Quotes: Единични кавички:
Single Right Quotation Mark Дясна единична кавичка
@@ -3710,6 +3717,7 @@ digits Malayalam Малаялам
Zoom to Selection Приближаване до селекцията
Zoom Tool Инструмент „Мащаб“
Zoom with Mouse Wheel Мащабиране с колелцето на мишката
Scroll Sideways with Cmd/Ctrl + Wheel Хоризонтално превъртане с Cmd/Ctrl + колелцето
Scrub Numeric Fields by Dragging Плъзгане на числени полета чрез влачене
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} файл в {folder} {note}. Показване на папката на пакета?|{n} файла в {folder} {note}. Показване на папката на пакета?
@plural {n} linked file not found|{n} linked files not found {n} свързан файл не е намерен|{n} свързани файла не са намерени
@@ -6099,6 +6107,8 @@ breakText Glyphs Глифи
File + Data Set Name Файл + име на набора от данни
Data Set Name Име на набора от данни
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` трябва да е `fileNumber`, `fileDataSetName` или `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Всеки набор от данни се записва върху същия файл, в който остава само последният набор от данни.
@msg save the document with Save As before Save and Close запазете документа със „Запази като“ преди „Запази и затвори“
@msg no free data set file name in destination folder Няма свободно име за файл на набор от данни в целевата папка
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -179,6 +179,11 @@
Ungroup Razgrupiši
Lock Zaključaj
Selection Odabir
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Pravougaoni odabir
Touching Dodirnuti objekti
Fully Enclosed Potpuno obuhvaćeni
Drag Direction Smjer povlačenja
All Artwork Above Sav crtež iznad
Other Layers Ostali slojevi
Unlock All Otključaj sve
@@ -665,6 +670,7 @@
Toolbars Trake s alatima
Advanced Napredno
Double Column Dvostruka kolona
Keep Tools Beside the Canvas Alati pored platna
Actions Akcije
Asset Export Izvoz resursa
Attributes Atributi
@@ -3577,6 +3583,7 @@ theme Appearance Izgled
Feet Stope
Files Datoteke
Fill New Type Objects With Placeholder Text Ispuni nove tekstualne objekte tekstom čuvara mjesta
Start Lists From Typed Markers Započni liste iz otkucanih oznaka
Folder Folder
Font Preview Size Veličina pregleda fonta
Get Preferences Preuzmi postavke
@@ -3699,6 +3706,7 @@ theme Appearance Izgled
Yards Jardi
Zoom to Selection Zumiraj na odabir
Zoom with Mouse Wheel Zumiraj točkićem miša
Scroll Sideways with Cmd/Ctrl + Wheel Pomjeraj vodoravno uz Cmd/Ctrl + točkić miša
Scrub Numeric Fields by Dragging Mijenjaj numerička polja prevlačenjem
Linux Display Server Linux server prikaza
Print Preview Pregled štampanja
@@ -5021,6 +5029,9 @@ imageTrace Outline Obris
@msg the Pixar file's image data is damaged slikovni podaci Pixar datoteke su oštećeni
@msg the JPEG 2000 file can't be read datoteku JPEG 2000 nije moguće pročitati
@msg the JPEG 2000 file has an unsupported color space or JPX content datoteka JPEG 2000 ima nepodržan prostor boja ili JPX sadržaj
@msg the HEIC or HEIF file can't be read datoteku HEIC ili HEIF nije moguće pročitati
@msg the HEIC or HEIF file holds a kind of image that can't be read datoteka HEIC ili HEIF sadrži vrstu slike koju nije moguće pročitati
@msg HEIC and HEIF images can't be opened in this build HEIC i HEIF slike nije moguće otvoriti u ovoj verziji programa
@msg the PSD image is too large to read ({_1} × {_2} pixels) PSD slika je prevelika za čitanje ({_1} × {_2} piksela)
@msg zipped 32-bit PSD image data with prediction can't be read zipovani 32-bitni PSD podaci slike s predviđanjem ne mogu se pročitati
@msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitne PSD datoteke u ovom načinu boja ne mogu se pročitati
@@ -6111,6 +6122,8 @@ shape Open Otvoren
File + Data Set Name Datoteka + naziv skupa podataka
Data Set Name Naziv skupa podataka
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` mora biti `fileNumber`, `fileDataSetName` ili `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Svaki skup podataka se sačuva preko iste datoteke, u kojoj ostaje samo posljednji skup podataka.
@msg save the document with Save As before Save and Close sačuvajte dokument pomoću „Sačuvaj kao“ prije „Sačuvaj i zatvori“
@msg no free data set file name in destination folder Nema slobodnog naziva datoteke skupa podataka u odredišnoj mapi
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -1206,6 +1206,7 @@
Double Bar Barra doble
Double Click To Isolate Doble clic per aïllar
Double Column Columna doble
Keep Tools Beside the Canvas Eines al costat del llenç
Double Left Quotation Marks Cometes dobles esquerres
Double Quotes: Cometes dobles:
Double Right Quotation Marks Cometes dobles dretes
@@ -1555,6 +1556,7 @@ digits Malayalam Malaiàlam
Fill Line Línia de farciment
Fill new strokes Omple els traços nous
Fill New Type Objects With Placeholder Text Omple els objectes de text nous amb text de mostra
Start Lists From Typed Markers Comença llistes a partir dels marcadors escrits
Fill Rule Regla de farciment
Fill with Placeholder Text Omple amb text de mostra
Fill: Farciment:
@@ -3728,6 +3730,7 @@ digits Malayalam Malaiàlam
Scribble… Garabateig…
Script / Brush Cal·ligràfica / Pinzell
Scripts Scripts
Scroll Sideways with Cmd/Ctrl + Wheel Desplaça horitzontalment amb Cmd/Ctrl + roda del ratolí
Scrub Numeric Fields by Dragging Canvia els camps numèrics arrossegant
Search Cerca
Search All Cerca a tot
@@ -3796,6 +3799,11 @@ digits Malayalam Malaiàlam
Selected Swatches Only (0) Només les mostres seleccionades (0)
Selected Symbols Only Només els símbols seleccionats
Selection Selecció
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Selecció rectangular
Touching Objectes tocats
Fully Enclosed Totalment inclosos
Drag Direction Direcció de l’arrossegament
Selection & Anchor Display Selecció i visualització d’ancoratges
Selection Only Només la selecció
Selection Tool Eina de selecció
@@ -6127,6 +6135,9 @@ anchor Points: Punts:
CMYK Images Imatges CMYK
Grayscale Images Imatges en escala de grisos
@msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` ha de ser spotColors, colorEffects, rgbImages, cmykImages o grayImages
@msg byColor must be true or false byColor ha de ser true o false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor no pot separar els grups de l'agrupació automàtica: traça sense l'agrupació automàtica
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor no pot tornar a agrupar un traçat amb un efecte Pathfinder actiu: expandeix-lo sense byColor
# Color Effects add-on: Vector Halftone (Effect › Color Effects).
Dot Color from Art Color dels punts de la il·lustració
@@ -6252,6 +6263,8 @@ breakText Glyphs Glifs
File + Data Set Name Fitxer + nom del conjunt de dades
Data Set Name Nom del conjunt de dades
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` ha de ser `fileNumber`, `fileDataSetName` o `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Cada conjunt de dades es desa sobre el mateix fitxer, que només conserva l'últim conjunt de dades.
@msg save the document with Save As before Save and Close deseu el document amb «Desa com a» abans de «Desa i tanca»
@msg no free data set file name in destination folder No hi ha cap nom de fitxer de conjunt de dades lliure a la carpeta de destinació
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
@@ -6300,6 +6313,9 @@ breakText Glyphs Glifs
@msg the JPEG 2000 file can't be read no es pot llegir el fitxer JPEG 2000
@msg the JPEG 2000 file has an unsupported color space or JPX content el fitxer JPEG 2000 té un espai de color o contingut JPX no compatible
@msg the HEIC or HEIF file can't be read no es pot llegir el fitxer HEIC o HEIF
@msg the HEIC or HEIF file holds a kind of image that can't be read el fitxer HEIC o HEIF conté un tipus d'imatge que no es pot llegir
@msg HEIC and HEIF images can't be opened in this build les imatges HEIC i HEIF no es poden obrir en aquesta compilació
@msg You must choose a side pattern. Heu de triar un patró lateral.
@msg invalid pattern tile choice opció de mosaic de patró no vàlida
@msg no pattern named `{choice}` no hi ha cap patró anomenat `{choice}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -187,6 +187,11 @@
Ungroup Oddělit
Lock Zamknout
Selection Výběr
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Obdélníkový výběr
Touching Dotčené objekty
Fully Enclosed Zcela uvnitř
Drag Direction Směr tažení
All Artwork Above Veškerou kresbu výše
Other Layers Ostatní vrstvy
Unlock All Odemknout vše
@@ -673,6 +678,7 @@
Toolbars Panely nástrojů
Advanced Rozšířené
Double Column Dva sloupce
Keep Tools Beside the Canvas Nástroje vedle plátna
Actions Akce
Asset Export Export prvků
Attributes Atributy
@@ -3620,6 +3626,7 @@ theme Appearance Motiv
Feet Stopy
Files Soubory
Fill New Type Objects With Placeholder Text Vyplňovat nové textové objekty zástupným textem
Start Lists From Typed Markers Začínat seznamy z napsaných značek
Folder Složka
Font Preview Size Velikost náhledu písma
Get Preferences Získat předvolby
@@ -3744,6 +3751,7 @@ theme Appearance Motiv
Yards Yardy
Zoom to Selection Zvětšit na výběr
Zoom with Mouse Wheel Zvětšovat kolečkem myši
Scroll Sideways with Cmd/Ctrl + Wheel Posouvat vodorovně kolečkem s Cmd/Ctrl
Scrub Numeric Fields by Dragging Měnit číselné hodnoty tažením
Linux Display Server Zobrazovací server Linuxu
Print Preview Náhled tisku
@@ -5083,6 +5091,9 @@ imageTrace Outline Obrys
@msg the Pixar file's image data is damaged obrazová data souboru Pixar jsou poškozená
@msg the JPEG 2000 file can't be read soubor JPEG 2000 nelze přečíst
@msg the JPEG 2000 file has an unsupported color space or JPX content soubor JPEG 2000 obsahuje nepodporovaný barevný prostor nebo obsah JPX
@msg the HEIC or HEIF file can't be read soubor HEIC nebo HEIF nelze přečíst
@msg the HEIC or HEIF file holds a kind of image that can't be read soubor HEIC nebo HEIF obsahuje druh obrázku, který nelze přečíst
@msg HEIC and HEIF images can't be opened in this build obrázky HEIC a HEIF nelze v tomto sestavení otevřít
@msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD je příliš velký na přečtení ({_1} × {_2} obrazových bodů)
@msg zipped 32-bit PSD image data with prediction can't be read zazipovaná 32bitová obrazová data PSD s predikcí nelze přečíst
@msg {depth}-bit PSD files in this colour mode can't be read {depth}bitové soubory PSD v tomto barevném režimu nelze přečíst
@@ -5711,6 +5722,9 @@ mark-style Japanese Japonské
@msg too many library panels were opened in this session v této relaci bylo otevřeno příliš mnoho panelů knihoven
@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. Obraz má {_1} × {_2} pixelů, na jedné straně více než {LARGE_TRACE_SIDE}: vektorizuje se, ale pomaleji. Rychleji se vektorizuje s předvolbou nižší věrnosti nebo menší kopie obrazu.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz byl vektorizován v rozlišení {_1} × {_2} pixelů, zmenšený z {_3} × {_4} (nejvýše {_5} megapixelů).
@msg byColor must be true or false byColor musí být true nebo false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor nerozdělí skupiny z Automatického seskupení: vektorizujte bez Automatického seskupení
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor nemůže přeskupit trasování s živým efektem Pathfinder: rozbalte bez byColor
Snapping Přichytávání
Snap to Tangent Přichytávat k tečně
Snap to Perpendicular Přichytávat ke kolmici
@@ -6271,6 +6285,8 @@ breakText Glyphs Glyfy
File + Data Set Name Soubor + název sady dat
Data Set Name Název sady dat
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` musí být `fileNumber`, `fileDataSetName` nebo `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Každá sada dat se uloží přes stejný soubor, ve kterém zůstane jen poslední sada dat.
@msg save the document with Save As before Save and Close před „Uložit a zavřít“ uložte dokument pomocí „Uložit jako“
@msg no free data set file name in destination folder V cílové složce není volný název souboru sady dat
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -172,6 +172,11 @@
Ungroup Afgruppér
Lock Lås
Selection Markering
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Rektangelmarkering
Touching Berørte objekter
Fully Enclosed Helt omsluttet
Drag Direction Trækretning
All Artwork Above Al grafik ovenfor
Other Layers Andre lag
Unlock All Lås alle op
@@ -622,6 +627,7 @@
Toolbars Værktøjslinjer
Advanced Avanceret
Double Column Dobbelt kolonne
Keep Tools Beside the Canvas Værktøjer ved siden af canvaset
Actions Handlinger
Asset Export Aktiveksport
Attributes Attributter
@@ -3362,6 +3368,7 @@ theme Appearance Udseende
Feet Fod
Files Filer
Fill New Type Objects With Placeholder Text Udfyld nye tekstobjekter med pladsholdertekst
Start Lists From Typed Markers Start lister fra indtastede markører
Folder Mappe
Font Preview Size Størrelse på skrifttypeforhåndsvisning
Get Preferences Hent indstillinger
@@ -3472,6 +3479,7 @@ theme Appearance Udseende
Yards Yards
Zoom to Selection Zoom til markering
Zoom with Mouse Wheel Zoom med musehjul
Scroll Sideways with Cmd/Ctrl + Wheel Rul vandret med Cmd/Ctrl + musehjul
Scrub Numeric Fields by Dragging Juster numeriske felter ved at trække
Linux Display Server Linux-visningsserver
Print Preview Udskriftsvisning
@@ -6114,6 +6122,8 @@ breakText Glyphs Glyffer
File + Data Set Name Fil + datasætnavn
Data Set Name Datasætnavn
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` skal være `fileNumber`, `fileDataSetName` eller `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Hvert datasæt gemmes oven i den samme fil, som kun beholder det sidste datasæt.
@msg save the document with Save As before Save and Close gem dokumentet med Gem som før Gem og luk
@msg no free data set file name in destination folder Intet ledigt filnavn til datasættet i destinationsmappen
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -1145,6 +1145,7 @@ font-technology Variable Variabel
Stroke When Editable Kontur, wenn bearbeitbar
Fill Color Flächenfarbe
Fill New Type Objects With Placeholder Text Neue Textobjekte mit Platzhaltertext füllen
Start Lists From Typed Markers Listen aus getippten Aufzählungszeichen beginnen
Fill Rule Füllregel
Fill with Placeholder Text Mit Platzhaltertext füllen
Fill: Fläche:
@@ -3021,6 +3022,11 @@ digits Malayalam Malayalam
Selected Swatches Ausgewählte Farbfelder
Selected Swatches Only Nur ausgewählte Farbfelder
Selection Auswahl
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Rechteckauswahl
Touching Berührt
Fully Enclosed Vollständig eingeschlossen
Drag Direction Ziehrichtung
Selection & Anchor Display Auswahl und Ankerpunktanzeige
Selection Only Nur Auswahl
Selection Tool Auswahl-Werkzeug
@@ -3158,6 +3164,7 @@ digits Malayalam Malayalam
Simulate Colored Paper Farbiges Papier simulieren
Simulate Paper Papier simulieren
Double Column Zweispaltig
Keep Tools Beside the Canvas Werkzeuge neben der Arbeitsfläche
Single Left Quotation Mark Einfaches öffnendes Anführungszeichen
Single Quotes: Einfache Anführungszeichen:
Single Right Quotation Mark Einfaches schließendes Anführungszeichen
@@ -3703,6 +3710,7 @@ effect Points Punkte
Zoom to Selection Auf Auswahl zoomen
Zoom Tool Zoom-Werkzeug
Zoom with Mouse Wheel Mit Mausrad zoomen
Scroll Sideways with Cmd/Ctrl + Wheel Mit Cmd/Ctrl + Mausrad horizontal scrollen
Scrub Numeric Fields by Dragging Zahlenfelder durch Ziehen ändern
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} Datei in {folder} {note}. Paketordner anzeigen?|{n} Dateien in {folder} {note}. Paketordner anzeigen?
@plural {n} linked file not found|{n} linked files not found {n} verknüpfte Datei nicht gefunden|{n} verknüpfte Dateien nicht gefunden
@@ -4824,6 +4832,9 @@ theme Appearance Erscheinungsbild
@msg the Pixar file's image data is damaged die Bilddaten der Pixar-Datei sind beschädigt
@msg the JPEG 2000 file can't be read die JPEG-2000-Datei kann nicht gelesen werden
@msg the JPEG 2000 file has an unsupported color space or JPX content die JPEG-2000-Datei enthält einen nicht unterstützten Farbraum oder JPX-Inhalt
@msg the HEIC or HEIF file can't be read die HEIC- oder HEIF-Datei kann nicht gelesen werden
@msg the HEIC or HEIF file holds a kind of image that can't be read die HEIC- oder HEIF-Datei enthält eine Bildart, die nicht gelesen werden kann
@msg HEIC and HEIF images can't be opened in this build HEIC- und HEIF-Bilder können in diesem Build nicht geöffnet werden
@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
@msg {depth}-bit PSD files in this colour mode can't be read {depth}-Bit-PSD-Dateien in diesem Farbmodus können nicht gelesen werden
@@ -5290,6 +5301,9 @@ effect Soft Weich
@msg too many library panels were opened in this session in dieser Sitzung wurden zu viele Bibliotheksbedienfelder geöffnet
@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. Das Bild hat {_1} × {_2} Pixel, mehr als {LARGE_TRACE_SIDE} auf einer Seite: Es wird nachgezeichnet, aber langsamer. Eine Vorgabe mit geringerer Detailtreue oder eine kleinere Kopie des Bildes geht schneller.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Das Bild wurde mit {_1} × {_2} Pixeln nachgezeichnet, verkleinert von {_3} × {_4} (höchstens {_5} Megapixel).
@msg byColor must be true or false byColor muss true oder false sein
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor kann Gruppen aus Automatisch gruppieren nicht aufteilen: ohne Automatisch gruppieren nachzeichnen
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor kann eine Nachzeichnung mit einem aktiven Pathfinder-Effekt nicht neu gruppieren: ohne byColor umwandeln
Snapping Einrasten
Snap to Tangent An Tangente ausrichten
Snap to Perpendicular An Senkrechter ausrichten
@@ -5856,6 +5870,8 @@ breakText Glyphs Glyphen
File + Data Set Name Datei + Datensatzname
Data Set Name Datensatzname
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` muss `fileNumber`, `fileDataSetName` oder `dataSetName` sein
Each data set saves over the same file, which keeps only the last data set. Jeder Datensatz wird über dieselbe Datei gespeichert, die nur den letzten Datensatz behält.
@msg save the document with Save As before Save and Close speichern Sie das Dokument mit „Speichern unter“, bevor Sie „Speichern und schließen“ wählen
@msg no free data set file name in destination folder Kein freier Dateiname für den Datensatz im Zielordner
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -1103,6 +1103,7 @@ font-technology Variable Μεταβλητές
Live curve fitting Προσαρμογή καμπύλης σε πραγματικό χρόνο
Fill Color Χρώμα γεμίσματος
Fill New Type Objects With Placeholder Text Συμπλήρωση νέων αντικειμένων κειμένου με κείμενο υπόδειξης
Start Lists From Typed Markers Έναρξη λιστών από πληκτρολογημένα σύμβολα
Fill Rule Κανόνας γεμίσματος
Fill with Placeholder Text Γέμισμα με κείμενο υπόδειξης
Fill: Γέμισμα:
@@ -1934,6 +1935,11 @@ font-technology Variable Μεταβλητές
Select All Επιλογή όλων
Select: Επιλογή:
Selection Επιλογή
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Ορθογώνια επιλογή
Touching Αντικείμενα που τέμνονται
Fully Enclosed Πλήρως εντός
Drag Direction Κατεύθυνση σύρσης
Selection Tool Επιλογή (Selection Tool)
Send Backward Μεταφορά ένα βήμα πίσω
Send to Back Μεταφορά στο παρασκήνιο
@@ -3612,6 +3618,7 @@ digits Malayalam Μαλαγιάλαμ
Simulate Colored Paper Προσομοίωση χρωματιστού χαρτιού
Simulate Paper Προσομοίωση χαρτιού
Double Column Διπλή στήλη
Keep Tools Beside the Canvas Εργαλεία δίπλα στον καμβά
Single Left Quotation Mark Μονό αριστερό εισαγωγικό
Single Quotes: Μονά εισαγωγικά:
Single Right Quotation Mark Μονό δεξί εισαγωγικό
@@ -4036,6 +4043,7 @@ digits Malayalam Μαλαγιάλαμ
Zig Zag… Ζιγκ-ζαγκ…
Zoom to Selection Ζουμ στην επιλογή
Zoom with Mouse Wheel Ζουμ με τον τροχό του ποντικιού
Scroll Sideways with Cmd/Ctrl + Wheel Οριζόντια κύλιση με Cmd/Ctrl + τροχό του ποντικιού
Scrub Numeric Fields by Dragging Ρύθμιση αριθμητικών πεδίων με σύρσιμο
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} αρχείο στον φάκελο {folder} {note}. Να εμφανιστεί ο φάκελος συσκευασίας;|{n} αρχεία στον φάκελο {folder} {note}. Να εμφανιστεί ο φάκελος συσκευασίας;
@plural {n} linked file not found|{n} linked files not found Δεν βρέθηκε {n} συνδεδεμένο αρχείο|Δεν βρέθηκαν {n} συνδεδεμένα αρχεία
@@ -4764,6 +4772,9 @@ digits Malayalam Μαλαγιάλαμ
@msg the Pixar file's image data is damaged τα δεδομένα εικόνας του αρχείου Pixar είναι κατεστραμμένα
@msg the JPEG 2000 file can't be read δεν είναι δυνατή η ανάγνωση του αρχείου JPEG 2000
@msg the JPEG 2000 file has an unsupported color space or JPX content το αρχείο JPEG 2000 έχει μη υποστηριζόμενο χρωματικό χώρο ή περιεχόμενο JPX
@msg the HEIC or HEIF file can't be read δεν είναι δυνατή η ανάγνωση του αρχείου HEIC ή HEIF
@msg the HEIC or HEIF file holds a kind of image that can't be read το αρχείο HEIC ή HEIF περιέχει είδος εικόνας που δεν είναι δυνατό να διαβαστεί
@msg HEIC and HEIF images can't be opened in this build οι εικόνες HEIC και HEIF δεν μπορούν να ανοίξουν σε αυτή την έκδοση
@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 τα συμπιεσμένα δεδομένα εικόνας PSD 32 bit με πρόβλεψη δεν διαβάζονται
@msg {depth}-bit PSD files in this colour mode can't be read τα αρχεία PSD {depth} bit δεν διαβάζονται σε αυτήν τη λειτουργία χρώματος
@@ -5662,6 +5673,9 @@ effect Points Σημεία
@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} megapixel).
@msg byColor must be true or false το byColor πρέπει να είναι true ή false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping το byColor δεν μπορεί να χωρίσει τις ομάδες της Αυτόματης ομαδοποίησης: ανιχνεύστε χωρίς Αυτόματη ομαδοποίηση
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor το byColor δεν μπορεί να αναομαδοποιήσει μια ανίχνευση με ενεργό εφέ Pathfinder: επεκτείνετε χωρίς byColor
Snapping Προσκόλληση
Snap to Tangent Προσκόλληση σε εφαπτομένη
Snap to Perpendicular Προσκόλληση σε κάθετο
@@ -6222,6 +6236,8 @@ breakText Glyphs Γλύφοι
File + Data Set Name Αρχείο + όνομα συνόλου δεδομένων
Data Set Name Όνομα συνόλου δεδομένων
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` Το `datasetFileNaming` πρέπει να είναι `fileNumber`, `fileDataSetName` ή `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Κάθε σύνολο δεδομένων αποθηκεύεται πάνω στο ίδιο αρχείο, το οποίο κρατά μόνο το τελευταίο σύνολο δεδομένων.
@msg save the document with Save As before Save and Close αποθηκεύστε το έγγραφο με «Αποθήκευση ως» πριν από «Αποθήκευση και κλείσιμο»
@msg no free data set file name in destination folder Δεν υπάρχει διαθέσιμο όνομα αρχείου συνόλου δεδομένων στον φάκελο προορισμού
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 168 and column 5.
+16
View File
@@ -1173,6 +1173,7 @@ font-technology Variable Variables
Stroke When Editable Trazo si es editable
Fill Color Color de relleno
Fill New Type Objects With Placeholder Text Rellenar nuevos objetos de texto con texto de muestra
Start Lists From Typed Markers Iniciar listas a partir de marcadores escritos
Fill Rule Regla de relleno
Fill with Placeholder Text Rellenar con texto de muestra
Insert Inline Symbol Insertar símbolo en el texto
@@ -3061,6 +3062,11 @@ digits Malayalam Malayálam
Selected Swatches Muestras seleccionadas
Selected Swatches Only Solo muestras seleccionadas
Selection Selección
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Selección rectangular
Touching Objetos tocados
Fully Enclosed Totalmente incluidos
Drag Direction Dirección de arrastre
Selection & Anchor Display Visualización de selección y anclas
Selection Only Solo la selección
Selection Tool Herramienta Selección
@@ -3198,6 +3204,7 @@ digits Malayalam Malayálam
Simulate Colored Paper Simular papel de color
Simulate Paper Simular papel
Double Column Columna doble
Keep Tools Beside the Canvas Herramientas junto al lienzo
Single Left Quotation Mark Comilla simple de apertura
Single Quotes: Comillas simples:
Single Right Quotation Mark Comilla simple de cierre
@@ -3793,6 +3800,7 @@ digits Malayalam Malayálam
Zoom to Selection Zoom a la selección
Zoom Tool Herramienta Zoom
Zoom with Mouse Wheel Zoom con la rueda del mouse
Scroll Sideways with Cmd/Ctrl + Wheel Desplazar horizontalmente con Cmd/Ctrl + rueda del mouse
Scrub Numeric Fields by Dragging Ajustar campos numéricos arrastrando
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} archivo en {folder} {note}. ¿Mostrar la carpeta del paquete?|{n} archivos en {folder} {note}. ¿Mostrar la carpeta del paquete?
@plural {n} linked file not found|{n} linked files not found No se encontró {n} archivo vinculado|No se encontraron {n} archivos vinculados
@@ -4803,6 +4811,9 @@ axis Both Ambos
@msg the Pixar file's image data is damaged los datos de imagen del archivo Pixar están dañados
@msg the JPEG 2000 file can't be read no se puede leer el archivo JPEG 2000
@msg the JPEG 2000 file has an unsupported color space or JPX content el archivo JPEG 2000 tiene un espacio de color o contenido JPX no compatible
@msg the HEIC or HEIF file can't be read no se puede leer el archivo HEIC o HEIF
@msg the HEIC or HEIF file holds a kind of image that can't be read el archivo HEIC o HEIF contiene un tipo de imagen que no se puede leer
@msg HEIC and HEIF images can't be opened in this build las imágenes HEIC y HEIF no se pueden abrir en esta compilación
@msg the PSD image is too large to read ({_1} × {_2} pixels) la imagen PSD es demasiado grande para leerla ({_1} × {_2} píxeles)
@msg zipped 32-bit PSD image data with prediction can't be read no se pueden leer datos de imagen PSD de 32 bits comprimidos en ZIP con predicción
@msg {depth}-bit PSD files in this colour mode can't be read los archivos PSD de {depth} bits en este modo de color no se pueden leer
@@ -5652,6 +5663,9 @@ mark-style Japanese Japonesas
@msg too many library panels were opened in this session se abrieron demasiados paneles de biblioteca en esta sesión
@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. La imagen tiene {_1} × {_2} píxeles, más de {LARGE_TRACE_SIDE} en un lado: se calcará, pero más despacio. Un ajuste preestablecido de menor fidelidad o una copia más pequeña de la imagen se calcan más rápido.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). La imagen se calcó a {_1} × {_2} píxeles, reducida desde {_3} × {_4} ({_5} megapíxeles como máximo).
@msg byColor must be true or false byColor debe ser true o false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor no puede dividir los grupos de Agrupación automática: calque sin Agrupación automática
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor no puede reagrupar un calco con un efecto Pathfinder activo: expanda sin byColor
Snapping Ajuste
Snap to Tangent Ajustar a tangente
Snap to Perpendicular Ajustar a perpendicular
@@ -6213,6 +6227,8 @@ breakText Glyphs Glifos
File + Data Set Name Archivo + nombre del conjunto de datos
Data Set Name Nombre del conjunto de datos
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` debe ser `fileNumber`, `fileDataSetName` o `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Cada conjunto de datos se guarda sobre el mismo archivo, que solo conserva el último conjunto de datos.
@msg save the document with Save As before Save and Close guarde el documento con «Guardar como» antes de «Guardar y cerrar»
@msg no free data set file name in destination folder No hay un nombre de archivo libre para el conjunto de datos en la carpeta de destino
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -194,6 +194,11 @@
Ungroup Pura ryhmitys
Lock Lukitse
Selection Valinta
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Suorakulmiovalinta
Touching Kosketetut
Fully Enclosed Kokonaan sisällä
Drag Direction Vetosuunta
All Artwork Above Kaikki yllä oleva kuvitus
Other Layers Muut tasot
Unlock All Avaa kaikki lukitukset
@@ -664,6 +669,7 @@
Toolbars Työkalurivit
Advanced Lisäasetukset
Double Column Kaksi saraketta
Keep Tools Beside the Canvas Työkalut kankaan vieressä
Actions Toiminnot
Asset Export Resurssien vienti
Attributes Määritteet
@@ -3558,6 +3564,7 @@ theme Appearance Ulkoasu
Feet Jalat
Files Tiedostot
Fill New Type Objects With Placeholder Text Täytä uudet tekstiobjektit paikkamerkkitekstillä
Start Lists From Typed Markers Aloita luettelot kirjoitetuista merkeistä
Folder Kansio
Font Preview Size Fontin esikatselukoko
Get Preferences Hae asetukset
@@ -3668,6 +3675,7 @@ theme Appearance Ulkoasu
Yards Jaardit
Zoom to Selection Zoomaa valintaan
Zoom with Mouse Wheel Zoomaa hiiren rullalla
Scroll Sideways with Cmd/Ctrl + Wheel Vieritä vaakasuunnassa Cmd/Ctrl + hiiren rullalla
Scrub Numeric Fields by Dragging Muuta numerokenttiä vetämällä
Linux Display Server Linuxin näyttöpalvelin
Print Preview Tulostuksen esikatselu
@@ -4985,6 +4993,9 @@ imageTrace Outline Ääriviiva
@msg the Pixar file's image data is damaged Pixar-tiedoston kuvadata on vahingoittunut
@msg the JPEG 2000 file can't be read tätä JPEG 2000 -tiedostoa ei voi lukea
@msg the JPEG 2000 file has an unsupported color space or JPX content tässä JPEG 2000 -tiedostossa on tukematon väriavaruus tai JPX-sisältö
@msg the HEIC or HEIF file can't be read tätä HEIC- tai HEIF-tiedostoa ei voi lukea
@msg the HEIC or HEIF file holds a kind of image that can't be read tässä HEIC- tai HEIF-tiedostossa on kuvatyyppi, jota ei voi lukea
@msg HEIC and HEIF images can't be opened in this build HEIC- ja HEIF-kuvia ei voi avata tässä versiossa
@msg the PSD image is too large to read ({_1} × {_2} pixels) tämä PSD-kuva on liian suuri luettavaksi ({_1} × {_2} pikseliä)
@msg zipped 32-bit PSD image data with prediction can't be read ennustusta käyttävää zip-pakattua 32-bittistä PSD-kuvadataa ei voi lukea
@msg {depth}-bit PSD files in this colour mode can't be read tässä väritilassa olevia {depth}-bittisiä PSD-tiedostoja ei voi lukea
@@ -5674,6 +5685,9 @@ mark-style Japanese Japanilainen
@msg too many library panels were opened in this session tässä istunnossa on avattu liian monta kirjastopaneelia
@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. Kuva on {_1} × {_2} kuvapistettä, yhdeltä sivultaan yli {LARGE_TRACE_SIDE}: se jäljitetään, mutta hitaammin. Pienemmän tarkkuuden esiasetus tai kuvan pienempi kopio jäljittyy nopeammin.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Kuva jäljitettiin kokoon {_1} × {_2} kuvapistettä pienennettynä koosta {_3} × {_4} (enintään {_5} megapikseliä).
@msg byColor must be true or false byColor-arvon on oltava true tai false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor ei voi jakaa Automaattisen ryhmittelyn ryhmiä: jäljitä ilman Automaattista ryhmittelyä
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ei voi ryhmitellä uudelleen jäljitystä, jossa on aktiivinen Pathfinder-tehoste: laajenna ilman byColor-asetusta
Snapping Tartunta
Snap to Tangent Kohdista tangenttiin
Snap to Perpendicular Kohdista kohtisuoraan
@@ -6301,6 +6315,8 @@ breakText Glyphs Merkit
File + Data Set Name Tiedosto + tietojoukon nimi
Data Set Name Tietojoukon nimi
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming`-arvon on oltava `fileNumber`, `fileDataSetName` tai `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Jokainen tietojoukko tallennetaan saman tiedoston päälle, johon jää vain viimeinen tietojoukko.
@msg save the document with Save As before Save and Close tallenna asiakirja komennolla Tallenna nimellä ennen kuin valitset Tallenna ja sulje
@msg no free data set file name in destination folder Kohdekansiossa ei ole vapaata tietojoukon tiedostonimeä
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -1168,6 +1168,7 @@ font-technology Variable Variables
Stroke When Editable Contour quand il est modifiable
Fill Color Couleur de fond
Fill New Type Objects With Placeholder Text Remplir les nouveaux objets texte avec un texte de substitution
Start Lists From Typed Markers Commencer des listes à partir des marqueurs saisis
Fill Rule Règle de remplissage
Fill with Placeholder Text Remplir avec un texte de substitution
Fill: Fond :
@@ -3053,6 +3054,11 @@ digits Malayalam Malayalam
Selected Swatches Nuances sélectionnées
Selected Swatches Only Nuances sélectionnées uniquement
Selection Sélection
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Sélection rectangulaire
Touching Objets touchés
Fully Enclosed Entièrement inclus
Drag Direction Sens du glissement
Selection & Anchor Display Affichage de la sélection et des points d’ancrage
Selection Only Sélection uniquement
Selection Tool Outil Sélection
@@ -3190,6 +3196,7 @@ digits Malayalam Malayalam
Simulate Colored Paper Simuler un papier de couleur
Simulate Paper Simuler le papier
Double Column Deux colonnes
Keep Tools Beside the Canvas Outils à côté de la zone de travail
Single Left Quotation Mark Guillemet simple ouvrant
Single Quotes: Guillemets simples :
Single Right Quotation Mark Guillemet simple fermant
@@ -3785,6 +3792,7 @@ digits Malayalam Malayalam
Zoom to Selection Zoom sur la sélection
Zoom Tool Outil Zoom
Zoom with Mouse Wheel Zoom avec la molette de la souris
Scroll Sideways with Cmd/Ctrl + Wheel Défilement horizontal avec Cmd/Ctrl + molette
Scrub Numeric Fields by Dragging Modifier les champs numériques en faisant glisser
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} fichier dans {folder} {note}. Afficher le dossier de l’assemblage ?|{n} fichiers dans {folder} {note}. Afficher le dossier de l’assemblage ?
@plural {n} linked file not found|{n} linked files not found {n} fichier lié introuvable|{n} fichiers liés introuvables
@@ -4811,6 +4819,9 @@ axis Both Les deux
@msg the Pixar file's image data is damaged les données d'image du fichier Pixar sont endommagées
@msg the JPEG 2000 file can't be read le fichier JPEG 2000 ne peut pas être lu
@msg the JPEG 2000 file has an unsupported color space or JPX content le fichier JPEG 2000 contient un espace colorimétrique ou un contenu JPX non pris en charge
@msg the HEIC or HEIF file can't be read le fichier HEIC ou HEIF ne peut pas être lu
@msg the HEIC or HEIF file holds a kind of image that can't be read le fichier HEIC ou HEIF contient un type d'image qui ne peut pas être lu
@msg HEIC and HEIF images can't be opened in this build les images HEIC et HEIF ne peuvent pas être ouvertes dans cette version
@msg the PSD image is too large to read ({_1} × {_2} pixels) l'image PSD est trop grande pour être lue ({_1} × {_2} pixels)
@msg zipped 32-bit PSD image data with prediction can't be read les données d'image PSD 32 bits compressées en ZIP avec prédiction ne peuvent pas être lues
@msg {depth}-bit PSD files in this colour mode can't be read les fichiers PSD {depth} bits dans ce mode colorimétrique ne peuvent pas être lus
@@ -5706,6 +5717,9 @@ theme Appearance Apparence
@msg too many library panels were opened in this session trop de panneaux de bibliothèque ont été ouverts dans cette 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. L’image fait {_1} × {_2} pixels, plus de {LARGE_TRACE_SIDE} sur un côté : elle est vectorisée, mais plus lentement. Un paramètre prédéfini moins fidèle ou une copie plus petite de l’image se vectorise plus vite.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’image a été vectorisée à {_1} × {_2} pixels, réduite de {_3} × {_4} ({_5} mégapixels au plus).
@msg byColor must be true or false byColor doit être true ou false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor ne peut pas scinder les groupes du Groupement automatique : vectorisez sans Groupement automatique
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ne peut pas regrouper un tracé avec un effet Pathfinder actif : développez sans byColor
Snapping Magnétisme
Snap to Tangent Magnétisme de la tangente
Snap to Perpendicular Magnétisme de la perpendiculaire
@@ -6266,6 +6280,8 @@ breakText Glyphs Glyphes
File + Data Set Name Fichier + nom du jeu de données
Data Set Name Nom du jeu de données
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` doit être `fileNumber`, `fileDataSetName` ou `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Chaque jeu de données est enregistré sur le même fichier, qui ne conserve que le dernier jeu de données.
@msg save the document with Save As before Save and Close enregistrez le document avec Enregistrer sous avant Enregistrer et fermer
@msg no free data set file name in destination folder Aucun nom de fichier libre pour le jeu de données dans le dossier de destination
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -7960,6 +7960,11 @@ swatch White, Black सफ़ेद, काला
swatch Yellow पीला
swatch Yellow Green पीला हरा
theme Appearance अपीयरेंस
Start Lists From Typed Markers टाइप किए गए चिह्नों से सूचियाँ शुरू करें
Marquee Selection मार्की चयन
Touching स्पर्श करते हुए
Fully Enclosed पूरी तरह घिरे हुए
Drag Direction खींचने की दिशा
# Importer plug-ins (File › Open With, document.open)
Open With इसके साथ खोलें
Can't render this file because it is too large.
+16
View File
@@ -1111,6 +1111,7 @@ font-technology Variable Változó
Double Bar Kettős vonal
Double Click To Isolate Elkülönítés dupla kattintással
Double Column Két oszlop
Keep Tools Beside the Canvas Eszközök a vászon mellett
Double Left Quotation Marks Bal oldali dupla idézőjel
Double Quotes: Dupla idézőjelek:
Double Right Quotation Marks Jobb oldali dupla idézőjel
@@ -1467,6 +1468,7 @@ digits Malayalam Malajálam
Highlight: Kiemelés:
Stroke When Editable Körvonal, ha szerkeszthető
Fill New Type Objects With Placeholder Text Új szövegobjektumok kitöltése helykitöltő szöveggel
Start Lists From Typed Markers Listák indítása begépelt jelölőkből
Fill Rule Kitöltési szabály
Fill with Placeholder Text Kitöltés helykitöltő szöveggel
Fill: Kitöltés:
@@ -3666,6 +3668,11 @@ mark-style Japanese Japán
Selected Swatches Only Csak a kijelölt színminták
Selected Swatches Only (0) Csak a kijelölt színminták (0)
Selection Kijelölés
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Téglalapos kijelölés
Touching Érintett objektumok
Fully Enclosed Teljesen befoglalt
Drag Direction Húzás iránya
Selection & Anchor Display Kijelölés és horgonypontok megjelenítése
Selection Only Csak a kijelölés
Selection Tool Kijelölés
@@ -4526,6 +4533,7 @@ mark-style Japanese Japán
Zoom to Selection Nagyítás a kijelölésre
Zoom Tool Nagyító
Zoom with Mouse Wheel Nagyítás egérgörgővel
Scroll Sideways with Cmd/Ctrl + Wheel Vízszintes görgetés Cmd/Ctrl + görgővel
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? Fájlok: {n}, mappa: {folder} {note}. Megnyitod a csomag mappáját?
@plural {n} folder or file couldn't be read|{n} folders or files couldn't be read Nem olvasható mappák vagy fájlok: {n}
@plural {n} folder skipped, such as another app's or the system's folder or a hidden one|{n} folders skipped, such as other apps' and the system's folders and hidden ones Kihagyott mappák: {n} (például más alkalmazások, a rendszer és a rejtett mappák)
@@ -5327,6 +5335,9 @@ imageTrace Outline Körvonal
@msg the Pixar file's image data is damaged a Pixar-fájl képadatai sérültek
@msg the JPEG 2000 file can't be read a JPEG 2000 fájl nem olvasható
@msg the JPEG 2000 file has an unsupported color space or JPX content a JPEG 2000 fájl nem támogatott színteret vagy JPX-tartalmat tartalmaz
@msg the HEIC or HEIF file can't be read a HEIC- vagy HEIF-fájl nem olvasható
@msg the HEIC or HEIF file holds a kind of image that can't be read a HEIC- vagy HEIF-fájl olyan képtípust tartalmaz, amely nem olvasható
@msg HEIC and HEIF images can't be opened in this build a HEIC- és HEIF-képek ebben a buildben nem nyithatók meg
@msg the PSD image is too large to read ({_1} × {_2} pixels) a PSD-kép túl nagy a beolvasáshoz ({_1} × {_2} pixel)
@msg the Save PDF dialog didn't open a PDF mentése párbeszédablak nem nyílt meg
@msg the scale is not a usable number a méretarány nem használható szám
@@ -5694,6 +5705,9 @@ imageTrace Outline Körvonal
@msg too many library panels were opened in this session ebben a munkamenetben túl sok könyvtárpanel nyílt meg
@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. A kép {_1} × {_2} képpont, egyik oldalán több mint {LARGE_TRACE_SIDE}: a vektorizálás megtörténik, de lassabban. Kisebb hűségű előbeállítással vagy a kép kisebb másolatával gyorsabb.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A kép vektorizálása {_1} × {_2} képponton történt, {_3} × {_4} méretről kicsinyítve (legfeljebb {_5} megapixel).
@msg byColor must be true or false a byColor értéke true vagy false lehet
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping a byColor nem bontja fel az Automatikus csoportosítás csoportjait: vektorizáljon Automatikus csoportosítás nélkül
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor a byColor nem csoportosíthatja át az élő Pathfinder-effektussal rendelkező vektorizálást: bővítse ki byColor nélkül
Snapping Illesztés
Snap to Tangent Illesztés érintőre
Snap to Perpendicular Illesztés merőlegesre
@@ -6255,6 +6269,8 @@ breakText Glyphs Glifák
File + Data Set Name Fájl + adatkészlet neve
Data Set Name Adatkészlet neve
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` A `datasetFileNaming` értéke `fileNumber`, `fileDataSetName` vagy `dataSetName` lehet
Each data set saves over the same file, which keeps only the last data set. Minden adatkészlet ugyanabba a fájlba mentődik, amely csak az utolsó adatkészletet őrzi meg.
@msg save the document with Save As before Save and Close mentse a dokumentumot a „Mentés másként” paranccsal a „Mentés és bezárás” előtt
@msg no free data set file name in destination folder Nincs szabad adatkészlet-fájlnév a célmappában
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -172,6 +172,11 @@
Ungroup Buka grup
Lock Kunci
Selection Seleksi
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Seleksi persegi panjang
Touching Objek tersentuh
Fully Enclosed Sepenuhnya di dalam
Drag Direction Arah seret
All Artwork Above Semua karya seni di atas
Other Layers Lapisan lain
Unlock All Buka kunci semua
@@ -622,6 +627,7 @@
Toolbars Bilah alat
Advanced Lanjutan
Double Column Kolom ganda
Keep Tools Beside the Canvas Alat di samping kanvas
Actions Tindakan
Asset Export Ekspor aset
Attributes Atribut
@@ -3362,6 +3368,7 @@ theme Appearance Tampilan
Feet Kaki
Files File
Fill New Type Objects With Placeholder Text Isi objek teks baru dengan teks placeholder
Start Lists From Typed Markers Mulai daftar dari penanda yang diketik
Folder Folder
Font Preview Size Ukuran pratinjau font
Get Preferences Dapatkan preferensi
@@ -3472,6 +3479,7 @@ theme Appearance Tampilan
Yards Yard
Zoom to Selection Zum ke seleksi
Zoom with Mouse Wheel Zum dengan roda mouse
Scroll Sideways with Cmd/Ctrl + Wheel Gulir horizontal dengan Cmd/Ctrl + roda mouse
Scrub Numeric Fields by Dragging Atur nilai kolom numerik dengan menyeret
Linux Display Server Server tampilan Linux
Print Preview Pratinjau cetak
@@ -6114,6 +6122,8 @@ breakText Glyphs Glif
File + Data Set Name File + nama set data
Data Set Name Nama set data
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` harus `fileNumber`, `fileDataSetName`, atau `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Setiap set data disimpan menimpa file yang sama, sehingga hanya set data terakhir yang tersisa.
@msg save the document with Save As before Save and Close simpan dokumen dengan Simpan sebagai sebelum Simpan dan tutup
@msg no free data set file name in destination folder Tidak ada nama file set data yang tersedia di folder tujuan
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2266 and column 5.
+16
View File
@@ -1175,6 +1175,7 @@ font-technology Variable Variabili
Stroke When Editable Traccia se modificabile
Fill Color Colore riempimento
Fill New Type Objects With Placeholder Text Riempi i nuovi oggetti di testo con testo segnaposto
Start Lists From Typed Markers Avvia elenchi dai marcatori digitati
Fill Rule Regola di riempimento
Fill with Placeholder Text Riempi con testo segnaposto
Fill: Riempimento:
@@ -3097,6 +3098,11 @@ digits Malayalam Malayalam
Selected Swatches Campioni selezionati
Selected Swatches Only Solo campioni selezionati
Selection Selezione
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Selezione rettangolare
Touching Oggetti toccati
Fully Enclosed Interamente inclusi
Drag Direction Direzione di trascinamento
Selection & Anchor Display Visualizzazione selezione e punti di ancoraggio
Selection Only Solo selezione
Selection Tool Strumento Selezione
@@ -3234,6 +3240,7 @@ digits Malayalam Malayalam
Simulate Colored Paper Simula carta colorata
Simulate Paper Simula carta
Double Column Colonna doppia
Keep Tools Beside the Canvas Strumenti accanto all'area di disegno
Single Left Quotation Mark Virgoletta singola di apertura
Single Quotes: Virgolette singole:
Single Right Quotation Mark Virgoletta singola di chiusura
@@ -3830,6 +3837,7 @@ digits Malayalam Malayalam
Zoom to Selection Zoom sulla selezione
Zoom Tool Strumento Zoom
Zoom with Mouse Wheel Zoom con la rotellina del mouse
Scroll Sideways with Cmd/Ctrl + Wheel Scorrimento orizzontale con Cmd/Ctrl + rotellina
Scrub Numeric Fields by Dragging Regola i campi numerici trascinando
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} file in {folder} {note}. Vuoi mostrare la cartella del pacchetto?|{n} file in {folder} {note}. Vuoi mostrare la cartella del pacchetto?
@plural {n} linked file not found|{n} linked files not found File collegati non trovati: {n}|File collegati non trovati: {n}
@@ -4845,6 +4853,9 @@ axis Both Entrambi
@msg the Pixar file's image data is damaged i dati immagine del file Pixar sono danneggiati
@msg the JPEG 2000 file can't be read impossibile leggere il file JPEG 2000
@msg the JPEG 2000 file has an unsupported color space or JPX content il file JPEG 2000 contiene uno spazio colore o un contenuto JPX non supportato
@msg the HEIC or HEIF file can't be read impossibile leggere il file HEIC o HEIF
@msg the HEIC or HEIF file holds a kind of image that can't be read il file HEIC o HEIF contiene un tipo di immagine che non può essere letto
@msg HEIC and HEIF images can't be opened in this build le immagini HEIC e HEIF non possono essere aperte in questa build
@msg the PSD image is too large to read ({_1} × {_2} pixels) l'immagine PSD è troppo grande per essere letta ({_1} × {_2} pixel)
@msg zipped 32-bit PSD image data with prediction can't be read i dati immagine PSD a 32 bit compressi ZIP con previsione non possono essere letti
@msg {depth}-bit PSD files in this colour mode can't be read i file PSD a {depth} bit in questo metodo colore non possono essere letti
@@ -5324,6 +5335,9 @@ lighting Light Luce
@msg too many library panels were opened in this session in questa sessione sono stati aperti troppi pannelli libreria
@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. L’immagine è di {_1} × {_2} pixel, più di {LARGE_TRACE_SIDE} su un lato: viene vettorializzata, ma più lentamente. Un predefinito a fedeltà più bassa o una copia più piccola dell’immagine è più veloce.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’immagine è stata vettorializzata a {_1} × {_2} pixel, ridotta da {_3} × {_4} (al massimo {_5} megapixel).
@msg byColor must be true or false byColor deve essere true o false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor non può dividere i gruppi di Raggruppamento automatico: vettorializza senza Raggruppamento automatico
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor non può raggruppare di nuovo una traccia con un effetto Pathfinder attivo: espandi senza byColor
Snapping Aggancio
Snap to Tangent Aggancia alla tangente
Snap to Perpendicular Aggancia alla perpendicolare
@@ -5890,6 +5904,8 @@ breakText Glyphs Glifi
File + Data Set Name File + nome set di dati
Data Set Name Nome set di dati
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` deve essere `fileNumber`, `fileDataSetName` o `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Ogni set di dati viene salvato sullo stesso file, che conserva solo l'ultimo set di dati.
@msg save the document with Save As before Save and Close salva il documento con «Salva con nome» prima di «Salva e chiudi»
@msg no free data set file name in destination folder Nessun nome file libero per il set di dati nella cartella di destinazione
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -213,6 +213,11 @@ digits Malayalam マラヤーラム数字
Ungroup グループ解除
Lock ロック
Selection 選択
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection 矩形選択
Touching 接触するオブジェクト
Fully Enclosed 完全に囲まれたオブジェクト
Drag Direction ドラッグ方向
All Artwork Above 前面のすべてのアートワーク
Other Layers その他のレイヤー
Unlock All すべてをロック解除
@@ -679,6 +684,7 @@ digits Malayalam マラヤーラム数字
Toolbars ツールバー
Advanced 詳細
Double Column 2 列
Keep Tools Beside the Canvas ツールをキャンバスの横に配置
Actions アクション
Asset Export アセットの書き出し
Attributes 属性
@@ -1635,6 +1641,7 @@ digits Malayalam マラヤーラム数字
Files of this standard tag their colours with ICC profiles. この規格のファイルでは、カラーに ICC プロファイルが付けられます。
Fill 塗り
Fill New Type Objects With Placeholder Text 新規テキストオブジェクトをダミーテキストで埋める
Start Lists From Typed Markers 入力した記号からリストを開始
Fill Rule 塗りのルール
Fill and Stroke 塗りと線
Fill new strokes 新しいストロークに塗りを適用
@@ -3408,6 +3415,7 @@ mark-style Japanese 日本式
Zoom Tool ズームツール
Zoom to Selection 選択範囲にズーム
Zoom with Mouse Wheel マウスホイールでズーム
Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + ホイールで横スクロール
Scrub Numeric Fields by Dragging ドラッグで数値フィールドを調整
[Default] is protected: New… starts an editable copy. [Default] は変更できません。「新規…」で編集できる複製を作ります。
[Unnamed] [名称未設定]
@@ -4886,6 +4894,9 @@ swatch Bright Violet ビビッドバイオレット
@msg the Pixar file's image data is damaged Pixar ファイルの画像データが壊れています
@msg the JPEG 2000 file can't be read JPEG 2000 ファイルを読み込めません
@msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 ファイルには未対応の色空間または JPX コンテンツがあります
@msg the HEIC or HEIF file can't be read HEIC または HEIF ファイルを読み込めません
@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC または HEIF ファイルには読み込めない種類の画像が含まれています
@msg HEIC and HEIF images can't be opened in this build このビルドでは HEIC と HEIF の画像を開けません
@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 bit PSD 画像データは読み込めません
@msg {depth}-bit PSD files in this colour mode can't be read このカラーモードの {depth} bit PSD ファイルは読み込めません
@@ -5367,6 +5378,9 @@ lighting Light ライト
@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). 画像は {_3} × {_4} から縮小した {_1} × {_2} ピクセルでトレースされました (最大 {_5} メガピクセル)。
@msg byColor must be true or false byColor は true または false にしてください
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor は自動グループ化のグループを分割できません: 自動グループ化なしでトレースしてください
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor はライブ Pathfinder 効果のあるトレースを再グループ化できません: byColor なしで拡張してください
Snapping スナップ
Snap to Tangent 接線にスナップ
Snap to Perpendicular 垂線にスナップ
@@ -5955,6 +5969,8 @@ breakText Glyphs 字形
File + Data Set Name ファイル名 + データセット名
Data Set Name データセット名
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` は `fileNumber`、`fileDataSetName`、`dataSetName` のいずれかである必要があります
Each data set saves over the same file, which keeps only the last data set. 各データセットが同じファイルに上書き保存されるため、最後のデータセットだけが残ります。
@msg save the document with Save As before Save and Close 「保存して閉じる」の前に「別名で保存」でドキュメントを保存してください
@msg no free data set file name in destination folder 保存先フォルダーに使用可能なデータセットのファイル名がありません
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -146,6 +146,11 @@
Ungroup დაჯგუფების მოხსნა
Lock ჩაკეტვა
Selection მონიშნული
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection მართკუთხა მონიშვნა
Touching შეხებული ობიექტები
Fully Enclosed სრულად მოქცეული
Drag Direction გადათრევის მიმართულება
All Artwork Above ზემოთ მდებარე მთელი ნახატი
Other Layers სხვა შრეები
Unlock All ყველაფრის განბლოკვა
@@ -575,6 +580,7 @@
Toolbars ხელსაწყოთა ზოლები
Advanced დამატებითი
Double Column ორი სვეტი
Keep Tools Beside the Canvas ხელსაწყოები ტილოს გვერდით
Actions მოქმედებები
Asset Export რესურსების ექსპორტირება
Attributes ატრიბუტები
@@ -3178,6 +3184,7 @@ theme Appearance იერსახე
Feet ფუტები
Files ფაილები
Fill New Type Objects With Placeholder Text ახალი ტექსტური ობიექტების ჩანაცვლების ტექსტით შევსება
Start Lists From Typed Markers სიების დაწყება აკრეფილი მარკერებიდან
Folder საქაღალდე
Font Preview Size შრიფტის ნიმუშის ზომა
Get Preferences პარამეტრების მიღება
@@ -3288,6 +3295,7 @@ theme Appearance იერსახე
Yards იარდები
Zoom to Selection მონიშნულის მასშტაბირება
Zoom with Mouse Wheel მაუსის ბორბლით მასშტაბირება
Scroll Sideways with Cmd/Ctrl + Wheel ჰორიზონტალური გადაადგილება Cmd/Ctrl + ბორბლით
Scrub Numeric Fields by Dragging რიცხვითი ველების შეცვლა გადათრევით
Linux Display Server Linux-ის ეკრანის სერვერი
Print Preview ნახვა ამობეჭდვამდე
@@ -4502,6 +4510,9 @@ font-technology Variable ცვლადი
@msg the Pixar file's image data is damaged Pixar ფაილის გამოსახულების მონაცემები დაზიანებულია
@msg the JPEG 2000 file can't be read JPEG 2000 ფაილის წაკითხვა შეუძლებელია
@msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 ფაილს აქვს მხარდაუჭერელი ფერთა სივრცე ან JPX შიგთავსი
@msg the HEIC or HEIF file can't be read HEIC ან HEIF ფაილის წაკითხვა შეუძლებელია
@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC ან HEIF ფაილი შეიცავს გამოსახულების ტიპს, რომლის წაკითხვაც შეუძლებელია
@msg HEIC and HEIF images can't be opened in this build ამ ვერსიაში HEIC და HEIF გამოსახულებების გახსნა შეუძლებელია
@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 ფაილების წაკითხვა ვერ ხერხდება
@@ -5557,6 +5568,9 @@ imageTrace Outline კონტური
@msg `colorGroup` needs `mode: "color"` `colorGroup`-ს სჭირდება `mode: "color"`
@msg no color group `{name}` in the document (see swatch.list) დოკუმენტში ფერების ჯგუფი `{name}` არ არის (იხ. swatch.list)
@msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. ბრტყელი ლოგოს რეჟიმში გამოსახულება ვექტორიზდა {_1} × {_2} პიქსელზე, საშუალოს გამოთვლით შემცირდა {_3} × {_4}-დან.
@msg byColor must be true or false byColor უნდა იყოს true ან false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor ვერ დაყოფს ავტომატური დაჯგუფების ჯგუფებს: ვექტორიზება გააკეთეთ ავტომატური დაჯგუფების გარეშე
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ვერ გადააჯგუფებს ტრასირებას აქტიური Pathfinder ეფექტით: გააფართოვეთ byColor-ის გარეშე
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ბიბლიოთეკის პანელი არ არის (იხ. window.swatchLibrary)
@msg `{key}` must be a #rrggbb color `{key}` უნდა იყოს #rrggbb ფერი
@msg a key object sits on that point ამ წერტილზე საკვანძო ობიექტი მდებარეობს
@@ -6268,6 +6282,8 @@ breakText Glyphs გლიფები
File + Data Set Name ფაილი + მონაცემთა ნაკრების სახელი
Data Set Name მონაცემთა ნაკრების სახელი
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` უნდა იყოს `fileNumber`, `fileDataSetName` ან `dataSetName`
Each data set saves over the same file, which keeps only the last data set. თითოეული მონაცემთა ნაკრები ერთსა და იმავე ფაილზე ინახება, რომელშიც მხოლოდ ბოლო მონაცემთა ნაკრები რჩება.
@msg save the document with Save As before Save and Close „შენახვა და დახურვამდე“ შეინახეთ დოკუმენტი „შენახვა როგორც“ ბრძანებით
@msg no free data set file name in destination folder დანიშნულების საქაღალდეში მონაცემთა ნაკრებისთვის თავისუფალი ფაილის სახელი არ არის
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it is too large.
+16
View File
@@ -206,6 +206,11 @@ digits Malayalam 말라얄람어
Ungroup 그룹 해제
Lock 잠그기
Selection 선택 영역
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection 사각형 선택
Touching 닿는 개체
Fully Enclosed 완전히 포함된 개체
Drag Direction 드래그 방향
All Artwork Above 위의 모든 아트워크
Other Layers 다른 레이어
Unlock All 모두 잠금 해제
@@ -672,6 +677,7 @@ digits Malayalam 말라얄람어
Toolbars 도구 모음
Advanced 고급
Double Column 2열
Keep Tools Beside the Canvas 캔버스 옆에 도구 배치
Actions 액션
Asset Export 에셋 내보내기
Attributes 속성
@@ -1625,6 +1631,7 @@ digits Malayalam 말라얄람어
Files of this standard tag their colours with ICC profiles. 이 표준의 파일은 ICC 프로파일로 색상을 태그합니다.
Fill 칠
Fill New Type Objects With Placeholder Text 새 문자 개체를 대체 텍스트로 채우기
Start Lists From Typed Markers 입력한 기호로 목록 시작
Fill Rule 칠 규칙
Fill and Stroke 칠과 선
Fill new strokes 새 선 칠하기
@@ -3397,6 +3404,7 @@ mark-style Japanese 일본식
Zoom Tool 확대/축소 도구
Zoom to Selection 선택 항목에 맞춰 확대/축소
Zoom with Mouse Wheel 마우스 휠로 확대/축소
Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + 휠로 가로 스크롤
Scrub Numeric Fields by Dragging 드래그로 숫자 필드 조절
[Default] is protected: New… starts an editable copy. [Default]는 보호되어 있습니다. 새로 만들기…를 누르면 편집 가능한 복사본이 만들어집니다.
[Unnamed] [이름 없음]
@@ -4813,6 +4821,9 @@ theme Appearance 외관
@msg the Pixar file's image data is damaged Pixar 파일의 이미지 데이터가 손상되었습니다
@msg the JPEG 2000 file can't be read JPEG 2000 파일을 읽을 수 없습니다
@msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 파일에 지원되지 않는 색 공간 또는 JPX 콘텐츠가 있습니다
@msg the HEIC or HEIF file can't be read HEIC 또는 HEIF 파일을 읽을 수 없습니다
@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 또는 HEIF 파일에 읽을 수 없는 종류의 이미지가 있습니다
@msg HEIC and HEIF images can't be opened in this build 이 빌드에서는 HEIC 및 HEIF 이미지를 열 수 없습니다
@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 파일은 읽을 수 없습니다
@@ -5285,6 +5296,9 @@ lighting Light 광원
@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). 이미지를 {_3} × {_4}에서 축소한 {_1} × {_2}픽셀로 추적했습니다(최대 {_5}메가픽셀).
@msg byColor must be true or false byColor는 true 또는 false여야 합니다
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor는 자동 그룹화 그룹을 나눌 수 없습니다. 자동 그룹화 없이 추적하세요
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor는 라이브 Pathfinder 효과가 있는 추적을 다시 그룹화할 수 없습니다. byColor 없이 확장하세요
Snapping 스냅
Snap to Tangent 접선에 스냅
Snap to Perpendicular 수직선에 스냅
@@ -5851,6 +5865,8 @@ breakText Glyphs 글리프
File + Data Set Name 파일 + 데이터 세트 이름
Data Set Name 데이터 세트 이름
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming`은 `fileNumber`, `fileDataSetName` 또는 `dataSetName`이어야 합니다
Each data set saves over the same file, which keeps only the last data set. 각 데이터 세트가 같은 파일에 덮어써지므로 마지막 데이터 세트만 남습니다.
@msg save the document with Save As before Save and Close '저장 후 닫기' 전에 '다른 이름으로 저장'으로 문서를 저장하십시오
@msg no free data set file name in destination folder 대상 폴더에 사용할 수 있는 데이터 세트 파일 이름이 없습니다
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -186,6 +186,11 @@
Ungroup Groepering opheffen
Lock Vergrendelen
Selection Selectie
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Rechthoekselectie
Touching Geraakt
Fully Enclosed Volledig omsloten
Drag Direction Sleeprichting
All Artwork Above Alle artwork erboven
Other Layers Andere lagen
Unlock All Alles ontgrendelen
@@ -636,6 +641,7 @@
Toolbars Werkbalken
Advanced Geavanceerd
Double Column Dubbele kolom
Keep Tools Beside the Canvas Gereedschappen naast het canvas
Actions Acties
Asset Export Assetexport
Attributes Kenmerken
@@ -3376,6 +3382,7 @@ theme Appearance Vormgeving
Feet Voet
Files Bestanden
Fill New Type Objects With Placeholder Text Nieuwe tekstobjecten met plaatshoudertekst vullen
Start Lists From Typed Markers Lijsten starten met getypte opsommingstekens
Folder Map
Font Preview Size Grootte van lettertypevoorbeelden
Get Preferences Voorkeuren ophalen
@@ -3486,6 +3493,7 @@ theme Appearance Vormgeving
Yards Yards
Zoom to Selection Inzoomen op selectie
Zoom with Mouse Wheel Zoomen met muiswiel
Scroll Sideways with Cmd/Ctrl + Wheel Horizontaal scrollen met Cmd/Ctrl + muiswiel
Scrub Numeric Fields by Dragging Numerieke velden aanpassen door te slepen
Linux Display Server Linux-weergaveserver
Print Preview Afdrukvoorbeeld
@@ -6160,6 +6168,8 @@ breakText Glyphs Glyphs
File + Data Set Name Bestand + naam gegevensset
Data Set Name Naam gegevensset
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` moet `fileNumber`, `fileDataSetName` of `dataSetName` zijn
Each data set saves over the same file, which keeps only the last data set. Elke gegevensset wordt over hetzelfde bestand opgeslagen, dat alleen de laatste gegevensset bewaart.
@msg save the document with Save As before Save and Close sla het document op met Opslaan als voordat u Opslaan en sluiten kiest
@msg no free data set file name in destination folder Geen vrije bestandsnaam voor de gegevensset in de doelmap
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -152,6 +152,11 @@
Ungroup Rozgrupuj
Lock Zablokuj
Selection Zaznaczenie
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Zaznaczanie prostokątne
Touching Dotknięte
Fully Enclosed Całkowicie wewnątrz
Drag Direction Kierunek przeciągania
All Artwork Above Całą kompozycję powyżej
Other Layers Pozostałe warstwy
Unlock All Odblokuj wszystko
@@ -623,6 +628,7 @@
Toolbars Paski narzędzi
Advanced Zaawansowany
Double Column Dwie kolumny
Keep Tools Beside the Canvas Narzędzia obok płótna
Actions Operacje
Asset Export Eksport zasobów
Attributes Atrybuty
@@ -3497,6 +3503,7 @@ theme Appearance Wygląd
Feet Stopy
Files Pliki
Fill New Type Objects With Placeholder Text Wypełniaj nowe obiekty tekstowe tekstem zastępczym
Start Lists From Typed Markers Rozpoczynaj listy od wpisanych znaczników
Folder Folder
Font Preview Size Rozmiar podglądu czcionki
Get Preferences Pobierz preferencje
@@ -3607,6 +3614,7 @@ theme Appearance Wygląd
Yards Jardy
Zoom to Selection Powiększaj do zaznaczenia
Zoom with Mouse Wheel Powiększaj kółkiem myszy
Scroll Sideways with Cmd/Ctrl + Wheel Przewijaj w poziomie kółkiem z Cmd/Ctrl
Scrub Numeric Fields by Dragging Zmieniaj pola liczbowe przeciąganiem
Linux Display Server Serwer wyświetlania Linux
Print Preview Podgląd wydruku
@@ -4909,6 +4917,9 @@ imageTrace Outline Kontur
@msg the Pixar file's image data is damaged dane obrazu pliku Pixar są uszkodzone
@msg the JPEG 2000 file can't be read nie można odczytać pliku JPEG 2000
@msg the JPEG 2000 file has an unsupported color space or JPX content plik JPEG 2000 zawiera nieobsługiwaną przestrzeń barw lub zawartość JPX
@msg the HEIC or HEIF file can't be read nie można odczytać pliku HEIC lub HEIF
@msg the HEIC or HEIF file holds a kind of image that can't be read plik HEIC lub HEIF zawiera rodzaj obrazu, którego nie można odczytać
@msg HEIC and HEIF images can't be opened in this build obrazów HEIC i HEIF nie można otworzyć w tej wersji
@msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD jest zbyt duży, aby go odczytać ({_1} × {_2} pikseli)
@msg zipped 32-bit PSD image data with prediction can't be read nie można odczytać spakowanych 32-bitowych danych obrazu PSD z predykcją
@msg {depth}-bit PSD files in this colour mode can't be read nie można odczytać {depth}-bitowych plików PSD w tym trybie koloru
@@ -5564,6 +5575,9 @@ mark-style Japanese Japońskie
@msg too many library panels were opened in this session w tej sesji otwarto zbyt wiele paneli bibliotek
@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. Obraz ma {_1} × {_2} pikseli, ponad {LARGE_TRACE_SIDE} na jednym boku: zostanie obrysowany, ale wolniej. Ustawienie wstępne o niższej wierności lub mniejsza kopia obrazu obrysuje się szybciej.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz obrysowano w rozdzielczości {_1} × {_2} pikseli, po zmniejszeniu z {_3} × {_4} (najwyżej {_5} megapikseli).
@msg byColor must be true or false byColor musi mieć wartość true lub false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor nie może podzielić grup Automatycznego grupowania: obrysuj bez Automatycznego grupowania
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor nie może ponownie zgrupować obrysu z aktywnym efektem Pathfinder: rozwiń bez byColor
Snapping Przyciąganie
Snap to Tangent Przyciągaj do stycznych
Snap to Perpendicular Przyciągaj do prostopadłych
@@ -6125,6 +6139,8 @@ breakText Glyphs Glify
File + Data Set Name Plik + nazwa zestawu danych
Data Set Name Nazwa zestawu danych
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` musi mieć wartość `fileNumber`, `fileDataSetName` lub `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Każdy zestaw danych jest zapisywany w tym samym pliku, który zachowuje tylko ostatni zestaw danych.
@msg save the document with Save As before Save and Close zapisz dokument poleceniem „Zapisz jako” przed „Zapisz i zamknij”
@msg no free data set file name in destination folder Brak wolnej nazwy pliku zestawu danych w folderze docelowym
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -1045,6 +1045,7 @@
Fill & Stroke Preenchimento e Traçado
Fill Color Cor de Preenchimento
Fill New Type Objects With Placeholder Text Preencher Novos Objetos de Texto com Texto de Placeholder
Start Lists From Typed Markers Iniciar Listas a Partir de Marcadores Digitados
Fill Rule Regra de Preenchimento
Fill and Stroke Preenchimento e Traçado
Fill new strokes Preencher novos traçados
@@ -2563,6 +2564,11 @@ digits Malayalam Malaiala
Selected Swatches Amostras Selecionadas
Selected Swatches Only Somente Amostras Selecionadas
Selection Seleção
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Seleção retangular
Touching Objetos tocados
Fully Enclosed Totalmente contidos
Drag Direction Direção do arrasto
Selection & Anchor Display Exibição de Seleção e Âncora
Selection Only Somente Seleção
Selection Tool Ferramenta Seleção
@@ -2681,6 +2687,7 @@ digits Malayalam Malaiala
Simulate Colored Paper Simular Papel Colorido
Simulate Paper Simular Papel
Double Column Coluna Dupla
Keep Tools Beside the Canvas Ferramentas ao Lado da Tela
Single Left Quotation Mark Aspa Esquerda Única
Single Quotes: Aspas Simples:
Single Right Quotation Mark Aspa Direita Única
@@ -3115,6 +3122,7 @@ digits Malayalam Malaiala
Zoom Tool Ferramenta Zoom
Zoom to Selection Ampliar para a Seleção
Zoom with Mouse Wheel Zoom com a Roda do Mouse
Scroll Sideways with Cmd/Ctrl + Wheel Rolar na Horizontal com Cmd/Ctrl + Roda
Scrub Numeric Fields by Dragging Ajustar Campos Numéricos Arrastando
[Default] is protected: New… starts an editable copy. [Padrão] é protegido: Novo… inicia uma cópia editável.
[Unnamed] [Sem Nome]
@@ -4347,6 +4355,9 @@ axis Both Ambos
@msg the Pixar file's image data is damaged os dados de imagem do arquivo Pixar estão danificados
@msg the JPEG 2000 file can't be read não é possível ler o arquivo JPEG 2000
@msg the JPEG 2000 file has an unsupported color space or JPX content o arquivo JPEG 2000 contém um espaço de cores ou conteúdo JPX sem suporte
@msg the HEIC or HEIF file can't be read não é possível ler o arquivo HEIC ou HEIF
@msg the HEIC or HEIF file holds a kind of image that can't be read o arquivo HEIC ou HEIF contém um tipo de imagem que não pode ser lido
@msg HEIC and HEIF images can't be opened in this build imagens HEIC e HEIF não podem ser abertas nesta versão
@msg the PSD image is too large to read ({_1} × {_2} pixels) a imagem PSD é grande demais para ser lida ({_1} × {_2} pixels)
@msg zipped 32-bit PSD image data with prediction can't be read dados de imagem PSD de 32 bits compactados em zip com previsão não podem ser lidos
@msg {depth}-bit PSD files in this colour mode can't be read arquivos PSD de {depth} bits neste modo de cor não podem ser lidos
@@ -5626,6 +5637,9 @@ mark-style Japanese Japonesas
@msg too many library panels were opened in this session foram abertos painéis de biblioteca demais nesta sessão
@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. A imagem tem {_1} × {_2} pixels, mais de {LARGE_TRACE_SIDE} em um lado: ela é traçada, mas mais devagar. Uma predefinição de menor fidelidade ou uma cópia menor da imagem traça mais rápido.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A imagem foi traçada em {_1} × {_2} pixels, reduzida de {_3} × {_4} (no máximo {_5} megapixels).
@msg byColor must be true or false byColor deve ser true ou false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor não pode dividir os grupos de Agrupamento Automático: trace sem Agrupamento Automático
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor não pode reagrupar um traçado com um efeito Pathfinder ativo: expanda sem byColor
Snapping Ajuste
Snap to Tangent Ajustar à Tangente
Snap to Perpendicular Ajustar à Perpendicular
@@ -6187,6 +6201,8 @@ breakText Glyphs Glifos
File + Data Set Name Arquivo + nome do conjunto de dados
Data Set Name Nome do conjunto de dados
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` deve ser `fileNumber`, `fileDataSetName` ou `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Cada conjunto de dados é salvo sobre o mesmo arquivo, que mantém apenas o último conjunto de dados.
@msg save the document with Save As before Save and Close salve o documento com Salvar Como antes de Salvar e fechar
@msg no free data set file name in destination folder Não há nome de arquivo livre para o conjunto de dados na pasta de destino
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 6 and column 47.
+16
View File
@@ -1172,6 +1172,7 @@ font-technology Variable Вариативные
Stroke When Editable Редактируемая обводка
Fill Color Цвет заливки
Fill New Type Objects With Placeholder Text Заполнять новые текстовые объекты текстом-заполнителем
Start Lists From Typed Markers Начинать списки с введённых маркеров
Fill Rule Правило заливки
Fill with Placeholder Text Заполнить текстом-заполнителем
Fill: Заливка:
@@ -3092,6 +3093,11 @@ digits Malayalam Малаялам
Selected Swatches Выбранные образцы
Selected Swatches Only Только выбранные образцы
Selection Выделение
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Выделение рамкой
Touching Пересекаемые
Fully Enclosed Полностью внутри
Drag Direction Направление перетаскивания
Selection & Anchor Display Отображение выделения и опорных точек
Selection Only Только выделение
Selection Tool Инструмент «Выделение»
@@ -3229,6 +3235,7 @@ digits Malayalam Малаялам
Simulate Colored Paper Имитировать цветную бумагу
Simulate Paper Имитировать бумагу
Double Column Двойной столбец
Keep Tools Beside the Canvas Инструменты рядом с холстом
Single Left Quotation Mark Одинарная открывающая кавычка
Single Quotes: Одинарные кавычки:
Single Right Quotation Mark Одинарная закрывающая кавычка
@@ -3825,6 +3832,7 @@ digits Malayalam Малаялам
Zoom to Selection Масштаб по выделению
Zoom Tool Инструмент «Масштаб»
Zoom with Mouse Wheel Масштабировать колесом мыши
Scroll Sideways with Cmd/Ctrl + Wheel Прокручивать по горизонтали колесом с Cmd/Ctrl
Scrub Numeric Fields by Dragging Изменять числовые поля перетаскиванием
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? Файлов: {n} в {folder} {note}. Показать папку пакета?|Файлов: {n} в {folder} {note}. Показать папку пакета?|Файлов: {n} в {folder} {note}. Показать папку пакета?
@plural {n} linked file not found|{n} linked files not found Не найдено связанных файлов: {n}|Не найдено связанных файлов: {n}|Не найдено связанных файлов: {n}
@@ -4842,6 +4850,9 @@ axis Both Оба
@msg the Pixar file's image data is damaged данные изображения в файле Pixar повреждены
@msg the JPEG 2000 file can't be read не удается прочитать файл JPEG 2000
@msg the JPEG 2000 file has an unsupported color space or JPX content файл JPEG 2000 содержит неподдерживаемое цветовое пространство или содержимое JPX
@msg the HEIC or HEIF file can't be read не удается прочитать файл HEIC или HEIF
@msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC или HEIF содержит изображение такого типа, которое не удается прочитать
@msg HEIC and HEIF images can't be opened in this build изображения HEIC и HEIF нельзя открыть в этой сборке
@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 32-битные данные изображения PSD со сжатием ZIP с предсказанием не читаются
@msg {depth}-bit PSD files in this colour mode can't be read {depth}-битные файлы PSD в этом цветовом режиме не читаются
@@ -5319,6 +5330,9 @@ lighting Light Свет
@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} мегапикселей).
@msg byColor must be true or false byColor должен иметь значение true или false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor не может разделить группы автогруппировки: трассируйте без автогруппировки
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor не может перегруппировать трассировку с действующим эффектом Pathfinder: разверните без byColor
Snapping Привязка
Snap to Tangent Привязать к касательной
Snap to Perpendicular Привязать к перпендикуляру
@@ -6248,6 +6262,8 @@ breakText Glyphs Глифы
File + Data Set Name Файл + имя набора данных
Data Set Name Имя набора данных
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` должно быть `fileNumber`, `fileDataSetName` или `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Каждый набор данных сохраняется поверх одного и того же файла, в котором остаётся только последний набор данных.
@msg save the document with Save As before Save and Close сохраните документ командой «Сохранить как» перед «Сохранить и закрыть»
@msg no free data set file name in destination folder В папке назначения нет свободного имени файла для набора данных
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -1138,6 +1138,7 @@ font-technology Variable Variabla
Live curve fitting Kurvanpassning medan du ritar
Fill Color Fyllnadsfärg
Fill New Type Objects With Placeholder Text Fyll nya textobjekt med platshållartext
Start Lists From Typed Markers Starta listor från inskrivna punkttecken
Fill Rule Fyllningsregel
Fill with Placeholder Text Fyll med platshållartext
Fill: Fyllning:
@@ -2985,6 +2986,11 @@ digits Malayalam Malayalam
Selected Swatches Markerade färgrutor
Selected Swatches Only Endast markerade färgrutor
Selection Markering
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Rektangelmarkering
Touching Berörda objekt
Fully Enclosed Helt omslutna
Drag Direction Dragriktning
Selection & Anchor Display Visning av markering och ankarpunkter
Selection Only Endast markering
Selection Tool Markeringsverktyg
@@ -3120,6 +3126,7 @@ digits Malayalam Malayalam
Simulate Colored Paper Simulera färgat papper
Simulate Paper Simulera papper
Double Column Dubbel kolumn
Keep Tools Beside the Canvas Verktyg bredvid arbetsytan
Single Left Quotation Mark Enkelt vänster citattecken
Single Quotes: Enkla citattecken:
Single Right Quotation Mark Enkelt höger citattecken
@@ -3715,6 +3722,7 @@ digits Malayalam Malayalam
Zoom to Selection Zooma till markering
Zoom Tool Zoomverktyg
Zoom with Mouse Wheel Zooma med mushjulet
Scroll Sideways with Cmd/Ctrl + Wheel Rulla horisontellt med Cmd/Ctrl + mushjulet
Scrub Numeric Fields by Dragging Ändra numeriska fält genom att dra
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} fil i {folder} {note}. Visa paketmappen?|{n} filer i {folder} {note}. Visa paketmappen?
@plural {n} linked file not found|{n} linked files not found {n} länkad fil hittades inte|{n} länkade filer hittades inte
@@ -4720,6 +4728,9 @@ axis Both Båda
@msg the Pixar file's image data is damaged Pixar-filens bilddata är skadade
@msg the JPEG 2000 file can't be read JPEG 2000-filen kan inte läsas
@msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000-filen har en färgrymd eller ett JPX-innehåll som inte stöds
@msg the HEIC or HEIF file can't be read HEIC- eller HEIF-filen kan inte läsas
@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC- eller HEIF-filen innehåller en typ av bild som inte kan läsas
@msg HEIC and HEIF images can't be opened in this build HEIC- och HEIF-bilder kan inte öppnas i den här versionen
@msg the PSD image is too large to read ({_1} × {_2} pixels) PSD-bilden är för stor för att läsas ({_1} × {_2} pixlar)
@msg zipped 32-bit PSD image data with prediction can't be read zippad 32-bitars PSD-bilddata med prediktion kan inte läsas
@msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitars PSD-filer i det här färgläget kan inte läsas
@@ -5733,6 +5744,9 @@ swatch Yellow Green Gulgrön
@msg “{name}” is not a file ”{name}” är inte en fil
@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. Bilden är {_1} × {_2} pixlar, mer än {LARGE_TRACE_SIDE} på en sida: den vektoriseras, men långsammare. En förinställning med lägre återgivning eller en mindre kopia av bilden går snabbare.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Bilden vektoriserades vid {_1} × {_2} pixlar, nedskalad från {_3} × {_4} (högst {_5} megapixlar).
@msg byColor must be true or false byColor måste vara true eller false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor kan inte dela upp grupper från Automatisk gruppering: vektorisera utan Automatisk gruppering
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor kan inte gruppera om en kalkering med en aktiv Pathfinder-effekt: expandera utan byColor
Snapping Fäst
Snap to Tangent Fäst mot tangent
Snap to Perpendicular Fäst mot vinkelrät
@@ -6323,6 +6337,8 @@ breakText Glyphs Glyfer
File + Data Set Name Fil + datauppsättningens namn
Data Set Name Datauppsättningens namn
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` måste vara `fileNumber`, `fileDataSetName` eller `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Varje datauppsättning sparas över samma fil, som bara behåller den sista datauppsättningen.
@msg save the document with Save As before Save and Close spara dokumentet med Spara som före Spara och stäng
@msg no free data set file name in destination folder Inget ledigt filnamn för datauppsättningen i målmappen
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 5 and column 25.
+10
View File
@@ -184,6 +184,16 @@ fn font_height_reference_labels_are_in_complete_catalogs() {
}
}
/// Preference choices and the marquee's E hint share the same translated labels.
#[test]
fn marquee_selection_labels_are_in_complete_catalogs() {
for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) {
for label in ["Marquee Selection", "Touching", "Fully Enclosed", "Drag Direction"] {
assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code);
}
}
}
/// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then
/// `other`/`many`).
#[test]
+16
View File
@@ -1127,6 +1127,7 @@ font-technology Variable Değişken
Merge only with selection Yalnızca seçimle birleştir
Fill Color Dolgu Rengi
Fill New Type Objects With Placeholder Text Yeni Yazım Nesnelerini Yer Tutucu Metinle Doldurun
Start Lists From Typed Markers Yazılan İşaretlerden Liste Başlat
Fill Rule Doldurma Kuralı
Fill with Placeholder Text Yer Tutucu Metinle Doldur
Fill: Doldurun:
@@ -2889,6 +2890,11 @@ digits Malayalam Malayalam
Selected Swatches Seçilen Renk Örnekleri
Selected Swatches Only Yalnızca Seçilen Renk Örnekleri
Selection Seçim
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Dikdörtgen seçim
Touching Dokunulanlar
Fully Enclosed Tamamen içeride
Drag Direction Sürükleme yönü
Selection & Anchor Display Seçim ve Bağlantı Ekranı
Selection Only Yalnızca Seçim
Selection Tool Seçim Aracı
@@ -3023,6 +3029,7 @@ digits Malayalam Malayalam
Simulate Colored Paper Renkli Kağıdı Simüle Et
Simulate Paper Kağıdı Simüle Et
Double Column Çift Sütun
Keep Tools Beside the Canvas Araçları Tuvalin Yanında Tut
Single Left Quotation Mark Tek Sol Tırnak İşareti
Single Quotes: Tek Alıntılar:
Single Right Quotation Mark Tek Sağ Tırnak İşareti
@@ -3602,6 +3609,7 @@ digits Malayalam Malayalam
Zoom to Selection Seçimi Yakınlaştır
Zoom Tool Yakınlaştırma Aracı
Zoom with Mouse Wheel Fare Tekerleği ile Yakınlaştırma
Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + Tekerlek ile Yatay Kaydırma
Scrub Numeric Fields by Dragging Sayısal Alanları Sürükleyerek Fırçalayın
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} içinde {n} dosya {note}. Paket klasörü gösterilsin mi?
@plural {n} linked file not found|{n} linked files not found {n} bağlantılı dosya bulunamadı
@@ -4553,6 +4561,9 @@ axis Both Her İkisi
@msg the Pixar file's image data is damaged Pixar dosyasının görüntü verileri bozuk
@msg the JPEG 2000 file can't be read JPEG 2000 dosyası okunamıyor
@msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 dosyasında desteklenmeyen bir renk alanı veya JPX içeriği var
@msg the HEIC or HEIF file can't be read HEIC veya HEIF dosyası okunamıyor
@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC veya HEIF dosyası okunamayan türde bir görüntü içeriyor
@msg HEIC and HEIF images can't be opened in this build HEIC ve HEIF görüntüleri bu derlemede açılamıyor
@msg the PSD image is too large to read ({_1} × {_2} pixels) PSD görüntüsü okunamayacak kadar büyük ({_1} × {_2} piksel)
@msg zipped 32-bit PSD image data with prediction can't be read Tahmin içeren sıkıştırılmış 32 bit PSD görüntü verileri okunamıyor
@msg {depth}-bit PSD files in this colour mode can't be read Bu renk modunda {depth}-bit PSD dosyaları okunamaz
@@ -5712,6 +5723,9 @@ theme Appearance Görünüm
@msg library panel `{given}` isn't missing its library `{given}` kitaplık panelinin kitaplığı eksik değil
@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. Görüntü {_1} × {_2} piksel, bir kenarı {LARGE_TRACE_SIDE} değerinden büyük: izlenir, ancak daha yavaş. Daha düşük doğruluklu bir ön ayar veya görüntünün daha küçük bir kopyası daha hızlı izlenir.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Görüntü {_3} × {_4} boyutundan küçültülerek {_1} × {_2} pikselde izlendi (en fazla {_5} megapiksel).
@msg byColor must be true or false byColor true veya false olmalıdır
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor, Otomatik Gruplama gruplarını bölemez: Otomatik Gruplama olmadan izleyin
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor, canlı Pathfinder efekti olan bir izlemeyi yeniden gruplayamaz: byColor olmadan genişletin
Gap Length: Boşluk Uzunluğu:
@msg `colorGroup` needs `library: "document"` `colorGroup` için `library: "document"` gerekir
@msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` için `palette: "documentLibrary"` gerekir
@@ -6228,6 +6242,8 @@ breakText Glyphs Glifler
File + Data Set Name Dosya + veri kümesi adı
Data Set Name Veri kümesi adı
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` değeri `fileNumber`, `fileDataSetName` veya `dataSetName` olmalıdır
Each data set saves over the same file, which keeps only the last data set. Her veri kümesi aynı dosyanın üzerine kaydedilir ve dosyada yalnızca son veri kümesi kalır.
@msg save the document with Save As before Save and Close Kaydet ve Kapat'tan önce belgeyi Farklı Kaydet ile kaydedin
@msg no free data set file name in destination folder Hedef klasörde veri kümesi için boş bir dosya adı yok
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 5 and column 25.
+16
View File
@@ -1169,6 +1169,7 @@ font-technology Variable Варіативні
Stroke When Editable Обведення, яке можна редагувати
Fill Color Колір заливки
Fill New Type Objects With Placeholder Text Заповнювати нові текстові об’єкти текстом-заповнювачем
Start Lists From Typed Markers Починати списки з введених маркерів
Fill Rule Правило заливки
Fill with Placeholder Text Заповнити текстом-заповнювачем
Fill: Заливка:
@@ -3089,6 +3090,11 @@ digits Malayalam Малаялам
Selected Swatches Вибрані зразки
Selected Swatches Only Тільки вибрані зразки
Selection Виділення
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Виділення рамкою
Touching Перетнуті
Fully Enclosed Повністю всередині
Drag Direction Напрямок перетягування
Selection & Anchor Display Відображення виділення та опорних точок
Selection Only Тільки виділення
Selection Tool Інструмент «Виділення»
@@ -3226,6 +3232,7 @@ digits Malayalam Малаялам
Simulate Colored Paper Імітувати кольоровий папір
Simulate Paper Імітувати папір
Double Column Подвійний стовпець
Keep Tools Beside the Canvas Інструменти поруч із полотном
Single Left Quotation Mark Одинарна лапка, що відкриває
Single Quotes: Одинарні лапки:
Single Right Quotation Mark Одинарна лапка, що закриває
@@ -3823,6 +3830,7 @@ effect Points Точки
Zoom to Selection Масштаб виділення
Zoom Tool Інструмент «Масштаб»
Zoom with Mouse Wheel Масштабувати колесом миші
Scroll Sideways with Cmd/Ctrl + Wheel Прокручувати по горизонталі колесом із Cmd/Ctrl
Scrub Numeric Fields by Dragging Змінювати числові поля перетягуванням
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? файлів: {n} {folder} {note}. Показати папку пакета?|файлів: {n} {folder} {note}. Показати папку пакета?|файлів: {n} {folder} {note}. Показати папку пакета?
@plural {n} linked file not found|{n} linked files not found не знайдено пов’язаних файлів: {n}|не знайдено пов’язаних файлів: {n}|не знайдено пов’язаних файлів: {n}
@@ -4829,6 +4837,9 @@ axis Both Обидві
@msg the Pixar file's image data is damaged дані зображення у файлі Pixar пошкоджені
@msg the JPEG 2000 file can't be read не вдається прочитати файл JPEG 2000
@msg the JPEG 2000 file has an unsupported color space or JPX content файл JPEG 2000 містить непідтримуваний колірний простір або вміст JPX
@msg the HEIC or HEIF file can't be read не вдається прочитати файл HEIC або HEIF
@msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC або HEIF містить зображення такого типу, яке не вдається прочитати
@msg HEIC and HEIF images can't be opened in this build зображення HEIC і HEIF не можна відкрити в цій збірці
@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 32-бітні дані зображення PSD зі стисненням ZIP із передбаченням не читаються
@msg {depth}-bit PSD files in this colour mode can't be read {depth}-бітні файли PSD у цьому колірному режимі не читаються
@@ -5304,6 +5315,9 @@ lighting Light Світло
@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} мегапікселів).
@msg byColor must be true or false byColor має мати значення true або false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor не може розділити групи автогрупування: трасуйте без автогрупування
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor не може перегрупувати трасування з активним ефектом Pathfinder: розгорніть без byColor
Snapping Прив’язка
Snap to Tangent Прив’язати до дотичної
Snap to Perpendicular Прив’язати до перпендикуляра
@@ -5870,6 +5884,8 @@ breakText Glyphs Гліфи
File + Data Set Name Файл + назва набору даних
Data Set Name Назва набору даних
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` має бути `fileNumber`, `fileDataSetName` або `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Кожен набір даних зберігається поверх того самого файлу, у якому лишається тільки останній набір даних.
@msg save the document with Save As before Save and Close збережіть документ командою «Зберегти як» перед «Зберегти й закрити»
@msg no free data set file name in destination folder У папці призначення немає вільного імені файлу набору даних
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 182 and column 5.
+16
View File
@@ -997,6 +997,7 @@ font-technology Variable Biến thiên
Dots Chấm
Double Click To Isolate Bấm đúp để cô lập
Double Column Hai cột
Keep Tools Beside the Canvas Công cụ cạnh vùng vẽ
Double Left Quotation Marks Dấu ngoặc kép trái đôi
Double Quotes: Dấu ngoặc kép:
Double Right Quotation Marks Dấu ngoặc kép phải đôi
@@ -1345,6 +1346,7 @@ digits Malayalam Malayalam
Highlight: Làm nổi bật:
Stroke When Editable Nét khi có thể chỉnh sửa
Fill New Type Objects With Placeholder Text Tô đối tượng chữ mới bằng văn bản giữ chỗ
Start Lists From Typed Markers Bắt đầu danh sách từ ký hiệu đã gõ
Fill Rule Quy tắc tô
Fill with Placeholder Text Tô bằng văn bản giữ chỗ
Fill: Tô:
@@ -3417,6 +3419,11 @@ mark-style Japanese Kiểu Nhật
Selected Swatches Mẫu màu đã chọn
Selected Swatches Only Chỉ mẫu màu đã chọn
Selection Vùng chọn
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection Chọn bằng khung
Touching Đối tượng chạm khung
Fully Enclosed Nằm hoàn toàn trong khung
Drag Direction Hướng kéo
Selection & Anchor Display Hiển thị vùng chọn & điểm neo
Selection Only Chỉ vùng chọn
Selection Tool Công cụ Chọn
@@ -4189,6 +4196,7 @@ mark-style Japanese Kiểu Nhật
Zoom to Selection Thu phóng đến vùng chọn
Zoom Tool Công cụ thu phóng
Zoom with Mouse Wheel Thu phóng bằng con lăn chuột
Scroll Sideways with Cmd/Ctrl + Wheel Cuộn ngang bằng Cmd/Ctrl + con lăn
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} tệp trong {folder} {note}. Hiện thư mục gói?
@plural {n} folder or file couldn't be read|{n} folders or files couldn't be read {n} thư mục hoặc tệp không thể đọc được
@plural {n} folder skipped, such as another app's or the system's folder or a hidden one|{n} folders skipped, such as other apps' and the system's folders and hidden ones Đã bỏ qua {n} thư mục, chẳng hạn thư mục của ứng dụng khác, thư mục hệ thống và thư mục ẩn
@@ -4986,6 +4994,9 @@ theme Appearance Giao diện
@msg the Pixar file's image data is damaged dữ liệu ảnh của tệp Pixar bị hỏng
@msg the JPEG 2000 file can't be read không thể đọc tệp JPEG 2000
@msg the JPEG 2000 file has an unsupported color space or JPX content tệp JPEG 2000 có không gian màu hoặc nội dung JPX không được hỗ trợ
@msg the HEIC or HEIF file can't be read không thể đọc tệp HEIC hoặc HEIF
@msg the HEIC or HEIF file holds a kind of image that can't be read tệp HEIC hoặc HEIF chứa loại ảnh không thể đọc được
@msg HEIC and HEIF images can't be opened in this build không thể mở ảnh HEIC và HEIF trong bản dựng này
@msg the PSD image is too large to read ({_1} × {_2} pixels) ảnh PSD quá lớn để đọc ({_1} × {_2} pixel)
@msg the Save PDF dialog didn't open hộp thoại Lưu PDF không mở
@msg the scale is not a usable number tỷ lệ không phải là số dùng được
@@ -5305,6 +5316,9 @@ theme Appearance Giao diện
@msg too many library panels were opened in this session đã mở quá nhiều bảng thư viện trong phiên này
@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. Ảnh có kích thước {_1} × {_2} điểm ảnh, hơn {LARGE_TRACE_SIDE} ở một cạnh: ảnh vẫn được dò, nhưng chậm hơn. Một thiết lập sẵn có độ trung thực thấp hơn hoặc một bản sao nhỏ hơn của ảnh sẽ dò nhanh hơn.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Ảnh đã được dò ở {_1} × {_2} điểm ảnh, thu nhỏ từ {_3} × {_4} (tối đa {_5} megapixel).
@msg byColor must be true or false byColor phải là true hoặc false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor không thể tách các nhóm của Tự động nhóm: hãy dò mà không bật Tự động nhóm
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor không thể nhóm lại bản dò có hiệu ứng Pathfinder đang hoạt động: mở rộng mà không dùng byColor
Snapping Hút
Snap to Tangent Hút vào tiếp tuyến
Snap to Perpendicular Hút vào đường vuông góc
@@ -5871,6 +5885,8 @@ breakText Glyphs Glyph
File + Data Set Name Tệp + tên tập dữ liệu
Data Set Name Tên tập dữ liệu
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` phải là `fileNumber`, `fileDataSetName` hoặc `dataSetName`
Each data set saves over the same file, which keeps only the last data set. Mỗi tập dữ liệu được lưu đè lên cùng một tệp, nên tệp chỉ giữ lại tập dữ liệu cuối cùng.
@msg save the document with Save As before Save and Close hãy lưu tài liệu bằng Lưu thành trước khi dùng Lưu và đóng
@msg no free data set file name in destination folder Không có tên tệp tập dữ liệu còn trống trong thư mục đích
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 204 and column 5.
+16
View File
@@ -1114,6 +1114,7 @@
Stroke When Editable 可编辑时的描边
Fill Color 填色
Fill New Type Objects With Placeholder Text 用占位文本填充新文字对象
Start Lists From Typed Markers 从键入的标记开始列表
Fill Rule 填充规则
Fill with Placeholder Text 用占位文本填充
Insert Inline Symbol 插入行内符号
@@ -2933,6 +2934,11 @@ digits Malayalam 马拉雅拉姆数字
Selected Swatches 所选色板
Selected Swatches Only 仅限选择的色板
Selection 所选对象
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection 框选
Touching 接触对象
Fully Enclosed 完全包含
Drag Direction 拖动方向
Selection & Anchor Display 选择和锚点显示
Selection Only 仅所选内容
Selection Tool 选择工具
@@ -3067,6 +3073,7 @@ digits Malayalam 马拉雅拉姆数字
Simulate Colored Paper 模拟有色纸张
Simulate Paper 模拟纸张
Double Column 双列
Keep Tools Beside the Canvas 工具置于画布旁
Single Left Quotation Mark 左单引号
Single Quotes: 单引号:
Single Right Quotation Mark 右单引号
@@ -3680,6 +3687,7 @@ digits Malayalam 马拉雅拉姆数字
Zoom to Selection 缩放到所选对象
Zoom Tool 缩放工具
Zoom with Mouse Wheel 使用鼠标滚轮缩放
Scroll Sideways with Cmd/Ctrl + Wheel 使用 Cmd/Ctrl + 滚轮水平滚动
Scrub Numeric Fields by Dragging 拖动以调整数值字段
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} 中有 {n} 个文件{note}。要显示打包文件夹吗?
@plural {n} linked file not found|{n} linked files not found 找不到 {n} 个链接文件
@@ -4596,6 +4604,9 @@ imageTrace Outline 轮廓
@msg the Pixar file's image data is damaged Pixar 文件的图像数据已损坏
@msg the JPEG 2000 file can't be read 无法读取 JPEG 2000 文件
@msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 文件包含不受支持的色彩空间或 JPX 内容
@msg the HEIC or HEIF file can't be read 无法读取 HEIC 或 HEIF 文件
@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 文件包含无法读取的图像类型
@msg HEIC and HEIF images can't be opened in this build 此版本无法打开 HEIC 和 HEIF 图像
@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 文件
@@ -5542,6 +5553,9 @@ lighting Light 光源
@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} 百万像素)。
@msg byColor must be true or false byColor 必须是 true 或 false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor 无法拆分自动编组生成的编组:请在不使用自动编组的情况下描摹
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor 无法重新编组带有实时 Pathfinder 效果的描摹:请不使用 byColor 进行扩展
Snapping 对齐
Snap to Tangent 对齐切线
Snap to Perpendicular 对齐垂线
@@ -6112,6 +6126,8 @@ breakText Glyphs 字形
File + Data Set Name 文件 + 数据集名称
Data Set Name 数据集名称
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` 必须是 `fileNumber`、`fileDataSetName` 或 `dataSetName`
Each data set saves over the same file, which keeps only the last data set. 每个数据集都会存储到同一个文件并覆盖它,该文件只保留最后一个数据集。
@msg save the document with Save As before Save and Close 请先用“另存为”保存文档,再使用“保存并关闭”
@msg no free data set file name in destination folder 目标文件夹中没有可用的数据集文件名
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -1139,6 +1139,7 @@ font-technology Variable 可變
Highlight: 強調顯示:
Stroke When Editable 可編輯時的筆畫
Fill New Type Objects With Placeholder Text 以預留位置文字填滿新文字物件
Start Lists From Typed Markers 從輸入的標記開始清單
Fill Rule 填色規則
Fill with Placeholder Text 以預留位置文字填滿
Insert Inline Symbol 插入行內符號
@@ -2453,6 +2454,9 @@ file-filter Templates 範本
@msg the Pixar file's image data is damaged Pixar 檔案的影像資料已損毀
@msg the JPEG 2000 file can't be read 無法讀取 JPEG 2000 檔案
@msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 檔案包含不支援的色彩空間或 JPX 內容
@msg the HEIC or HEIF file can't be read 無法讀取 HEIC 或 HEIF 檔案
@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 檔案包含無法讀取的影像類型
@msg HEIC and HEIF images can't be opened in this build 此版本無法開啟 HEIC 和 HEIF 影像
@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 檔案
@@ -2553,6 +2557,9 @@ file-filter Templates 範本
@msg `colorGroup` needs `mode: "color"` `colorGroup` 需要 `mode: "color"`
@msg no color group `{name}` in the document (see swatch.list) 文件中沒有顏色群組 `{name}` (請參閱 swatch.list)
@msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. 「扁平標誌」模式已以 {_1} × {_2} 像素描圖,由 {_3} × {_4} 平均縮小而來。
@msg byColor must be true or false byColor 必須是 true 或 false
@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor 無法拆分自動群組產生的群組:請不使用自動群組來描圖
@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor 無法重新編組帶有即時 Pathfinder 效果的描圖:請不使用 byColor 進行擴充
# 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}」不會
@@ -3960,6 +3967,11 @@ digits Malayalam 馬來亞拉姆數字
Selected Swatches 選取的色票
Selected Swatches Only 僅限選取的色票
Selection 選取範圍
# Marquee selection modes (draft translations; native review welcome).
Marquee Selection 框選
Touching 接觸物件
Fully Enclosed 完全包含
Drag Direction 拖曳方向
Selection & Anchor Display 選取和錨點顯示
Selection Only 僅限選取範圍
Selection Tool 選取工具
@@ -4097,6 +4109,7 @@ digits Malayalam 馬來亞拉姆數字
Simulate Colored Paper 模擬有色紙張
Simulate Paper 模擬紙張
Double Column 雙欄
Keep Tools Beside the Canvas 工具置於畫布旁
Single Left Quotation Mark 左單引號
Single Quotes: 單引號:
Single Right Quotation Mark 右單引號
@@ -4713,6 +4726,7 @@ digits Malayalam 馬來亞拉姆數字
Zoom to Selection 縮放至選取範圍
Zoom Tool 縮放工具
Zoom with Mouse Wheel 使用滑鼠滾輪縮放
Scroll Sideways with Cmd/Ctrl + Wheel 使用 Cmd/Ctrl + 滾輪水平捲動
Scrub Numeric Fields by Dragging 拖曳以調整數值欄位
@plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} 中有 {n} 個檔案{note}。要顯示封裝資料夾嗎?
@plural {n} linked file not found|{n} linked files not found 找不到 {n} 個連結檔案
@@ -5908,6 +5922,8 @@ breakText Glyphs 字符
File + Data Set Name 檔案 + 資料集名稱
Data Set Name 資料集名稱
@msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` 必須是 `fileNumber`、`fileDataSetName` 或 `dataSetName`
Each data set saves over the same file, which keeps only the last data set. 每個資料集都會儲存到同一個檔案並覆寫它,該檔案只保留最後一個資料集。
@msg save the document with Save As before Save and Close 請先用「另存新檔」儲存文件,再使用「儲存並關閉」
@msg no free data set file name in destination folder 目的地資料夾中沒有可用的資料集檔案名稱
# Path Precision Tools add-on: Path Effects (Effect › Path Effects).
Can't render this file because it contains an unexpected character in line 6 and column 47.

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