mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-21 22:54:17 -04:00
Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56b7dc300d | ||
|
|
d792e7b2be | ||
|
|
e87534342b | ||
|
|
db42a68e3f | ||
|
|
2282122272 | ||
|
|
7c39ce026f | ||
|
|
a98dd3e552 | ||
|
|
bf3e6adc50 | ||
|
|
c43ead202f | ||
|
|
152fc18d9b | ||
|
|
bfc3f5251a | ||
|
|
f639f449d7 | ||
|
|
aef00190ed | ||
|
|
3dd8c3ec28 | ||
|
|
f3863ef50f | ||
|
|
6a4a739178 | ||
|
|
1fbec667ec | ||
|
|
1388b8ec1a | ||
|
|
fe9ceb3dfa | ||
|
|
e33094e11d | ||
|
|
633ad1d749 | ||
|
|
f832625e0b | ||
|
|
2dfe6613ad | ||
|
|
9219e41440 | ||
|
|
490c8da696 | ||
|
|
9bd9f570ae | ||
|
|
66655c67c2 | ||
|
|
17fbf6ad6a | ||
|
|
757eaf9afa | ||
|
|
d275fbf725 | ||
|
|
e72735bcf9 | ||
|
|
dd9c285252 | ||
|
|
ae3a8fe4c3 | ||
|
|
60f4c4f145 | ||
|
|
79dca4c8f9 | ||
|
|
afd66ad695 | ||
|
|
2f893566ee | ||
|
|
caa6962508 | ||
|
|
f64db69b53 | ||
|
|
2b8a11876a | ||
|
|
dc9d751015 | ||
|
|
6066b4b8c7 | ||
|
|
718c65c855 | ||
|
|
a4f5ae3b16 | ||
|
|
de917a138a | ||
|
|
e66bca39b2 | ||
|
|
4e4723699d | ||
|
|
3bb0f07a4b | ||
|
|
92a8a45d32 | ||
|
|
5f6c7b8da9 | ||
|
|
14a70449f7 | ||
|
|
c8082facb1 | ||
|
|
430741f074 | ||
|
|
bd9a508d49 | ||
|
|
b15230bfb5 | ||
|
|
15434b6db4 | ||
|
|
84e76c1d70 | ||
|
|
ede08590a0 | ||
|
|
92f295d2a2 | ||
|
|
cc42a333b7 | ||
|
|
95f503367f | ||
|
|
a339433c4b | ||
|
|
34cde7c3db | ||
|
|
0ac1f133b0 | ||
|
|
b3268a829d |
No files matched your search
@@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
|
||||
|
||||
Generated
+12
@@ -712,6 +712,15 @@ dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -4569,6 +4578,7 @@ version = "0.14.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
"chrono",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"craft-window",
|
||||
@@ -4678,6 +4688,7 @@ dependencies = [
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4846,6 +4857,7 @@ name = "vectorcraft-pdf"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
"flate2",
|
||||
"image",
|
||||
|
||||
@@ -56,6 +56,8 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
proptest = "1"
|
||||
crc32fast = "1.5"
|
||||
# tests: a TCP port bound without listening (connections to it are refused)
|
||||
|
||||
@@ -27,6 +27,7 @@ eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
|
||||
@@ -93,28 +93,36 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
|
||||
/// logging never panics and never silently resets to the epoch for a convertible instant.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
|
||||
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
|
||||
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
|
||||
let (y, t) = utc_parts(secs, since.subsec_nanos());
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
||||
/// value can overflow it.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
|
||||
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
|
||||
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
|
||||
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -387,6 +395,20 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
|
||||
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
|
||||
// SystemTime can't hold this instant, so it is only checked where it exists.
|
||||
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,6 +33,9 @@ const WIDE_LINES: &str =
|
||||
const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)";
|
||||
const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole";
|
||||
const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)";
|
||||
/// The warning for an image left out because it can't be decoded: the note raster exports give
|
||||
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
|
||||
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
|
||||
/// One segment of a subpath, cubics for every curve.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -973,8 +976,8 @@ impl<'a> Scene<'a> {
|
||||
|
||||
/// Image `key` as a file in the chosen format, and its size in pixels.
|
||||
fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> {
|
||||
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| format!("image '{key}' has no pixels and was left out"))?;
|
||||
let img = image::load_from_memory(&blob.bytes).map_err(|_| format!("image '{key}' could not be decoded and was left out"))?;
|
||||
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| "an image has no pixels and was left out".to_string())?;
|
||||
let img = image::load_from_memory(&blob.bytes).map_err(|_| UNDECODED.to_string())?;
|
||||
let size = (img.width(), img.height());
|
||||
let mut bytes = vec![];
|
||||
let written = match self.o.raster {
|
||||
@@ -992,7 +995,7 @@ impl<'a> Scene<'a> {
|
||||
rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90))
|
||||
}
|
||||
};
|
||||
written.map_err(|e| format!("image '{key}' could not be written: {e}"))?;
|
||||
written.map_err(|e| format!("an image could not be written: {e}"))?;
|
||||
Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +526,24 @@ fn images_link_png_or_jpeg_files() {
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("no images")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_without_pixels_are_left_out_with_one_note() {
|
||||
// One image has no data in the document, and the other image's data is empty.
|
||||
let mut d = doc_with(|d| {
|
||||
["raster-1", "raster-2"]
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let im = ImageObject { key: key.into(), width: 4, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
d.images.insert("raster-2".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(vec![]), proxy: None });
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
assert!(entities(&out).is_empty() && out.images.is_empty());
|
||||
assert_eq!(out.warnings, ["an image has no pixels and was left out"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_leaves_out_art_outside_the_region() {
|
||||
let d = shapes_doc();
|
||||
|
||||
@@ -20,6 +20,7 @@ zune-jpeg = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
jpeg-encoder = { workspace = true }
|
||||
|
||||
+68
-10
@@ -579,11 +579,18 @@ impl Lerp {
|
||||
|
||||
/// The object at `t` (0 = `a`, 1 = `b`), with id 0.
|
||||
pub fn at(&self, t: f64) -> Node {
|
||||
self.at_eased(t, t)
|
||||
}
|
||||
|
||||
/// The object whose shape is at `t` and whose colours (fills, strokes, opacity, mesh and text
|
||||
/// colours) are at `tc`: a blend with its colours eased apart from its spacing.
|
||||
pub fn at_eased(&self, t: f64, tc: f64) -> Node {
|
||||
let (a, b) = (&self.a, &self.b);
|
||||
let base = if t < 0.5 { a } else { b };
|
||||
let mut n = match &self.shape {
|
||||
Shape::Group { children, clip } => {
|
||||
let mut g = Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip });
|
||||
let mut g =
|
||||
Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at_eased(t, tc))).collect(), clip: *clip });
|
||||
g.isolate = base.isolate;
|
||||
g.knockout = base.knockout;
|
||||
g
|
||||
@@ -593,8 +600,8 @@ impl Lerp {
|
||||
if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) {
|
||||
for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) {
|
||||
p.p = x.p.lerp(y.p, t);
|
||||
p.color = lerp_color(&x.color, &y.color, t as f32);
|
||||
p.opacity = lerp_f32(x.opacity, y.opacity, t as f32);
|
||||
p.color = lerp_color(&x.color, &y.color, tc as f32);
|
||||
p.opacity = lerp_f32(x.opacity, y.opacity, tc as f32);
|
||||
for h in 0..4 {
|
||||
p.handles[h] = x.handles[h].lerp(y.handles[h], t);
|
||||
}
|
||||
@@ -620,7 +627,7 @@ impl Lerp {
|
||||
Shape::Placed => {
|
||||
let mut n = base.clone();
|
||||
match (&mut n.kind, &a.kind, &b.kind) {
|
||||
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t),
|
||||
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t, tc),
|
||||
(NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => {
|
||||
*o = lerp_affine(*x, *y, t)
|
||||
}
|
||||
@@ -657,8 +664,8 @@ impl Lerp {
|
||||
}
|
||||
};
|
||||
n.id = NodeId(0);
|
||||
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into();
|
||||
n.opacity = lerp_f32(a.opacity, b.opacity, t as f32);
|
||||
n.appearance = lerp_appearance(&a.appearance, &b.appearance, tc).into();
|
||||
n.opacity = lerp_f32(a.opacity, b.opacity, tc as f32);
|
||||
n.blend = base.blend;
|
||||
n.visible = true;
|
||||
n.locked = false;
|
||||
@@ -688,7 +695,7 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N
|
||||
/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an
|
||||
/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at
|
||||
/// the same place (by character) of both.
|
||||
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
|
||||
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) {
|
||||
o.xf = lerp_affine(x.xf, y.xf, t);
|
||||
if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) =
|
||||
(&mut o.kind, &x.kind, &y.kind)
|
||||
@@ -710,7 +717,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
|
||||
}
|
||||
let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
|
||||
let mut start = 0usize;
|
||||
let tf = t as f32;
|
||||
let tf = tc as f32;
|
||||
let mut resized = false;
|
||||
for r in &mut o.runs {
|
||||
let len = r.text.chars().count();
|
||||
@@ -1071,8 +1078,10 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
let n = blend_step_count(a, b, spec.spacing, len);
|
||||
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
|
||||
for j in 1..=n {
|
||||
let t = j as f64 / (n + 1) as f64;
|
||||
let mut s = lerp.at(t);
|
||||
// Even position of the step, then where the easing puts its shape and its colours.
|
||||
let u = j as f64 / (n + 1) as f64;
|
||||
let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u));
|
||||
let mut s = lerp.at_eased(t, tc);
|
||||
if let Some(r) = &rail {
|
||||
r.place(&mut s, i, t);
|
||||
}
|
||||
@@ -1134,6 +1143,55 @@ mod tests {
|
||||
assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn easing_keeps_the_ends_and_stays_monotone() {
|
||||
use crate::live::{BlendEase, BlendEaseType as K};
|
||||
for kind in [K::In, K::Out, K::InOut, K::OutIn] {
|
||||
for ramp in [0.0, 0.5, 1.0, f64::NAN, f64::INFINITY] {
|
||||
let e = BlendEase { kind, ramp };
|
||||
assert_eq!((e.apply(0.0), e.apply(1.0)), (0.0, 1.0));
|
||||
let ys: Vec<f64> = (0..=100).map(|i| e.apply(i as f64 / 100.0)).collect();
|
||||
assert!(ys.windows(2).all(|w| w[0] <= w[1]));
|
||||
assert_eq!(e.apply(f64::NAN), 0.0);
|
||||
if ramp == 0.0 {
|
||||
assert_eq!(e.apply(0.25), 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
let e = |kind| BlendEase { kind, ramp: 1.0 };
|
||||
assert!(e(K::In).apply(0.25) < 0.25);
|
||||
assert!(e(K::Out).apply(0.25) > 0.25);
|
||||
assert!(e(K::InOut).apply(0.25) < 0.25 && e(K::InOut).apply(0.75) > 0.75);
|
||||
assert!(e(K::OutIn).apply(0.25) > 0.25 && e(K::OutIn).apply(0.75) < 0.75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_easing_moves_colours_apart_from_positions() {
|
||||
let rect = |x: f64, c: Color| {
|
||||
let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default());
|
||||
n.appearance = Appearance::basic(Paint::solid(c), Paint::None, 0.0).into();
|
||||
Arc::new(n)
|
||||
};
|
||||
let keys = vec![rect(0.0, Color::BLACK), rect(100.0, Color::WHITE)];
|
||||
let mid = |spec: &BlendSpec| {
|
||||
let out = blend_expand(&keys, spec);
|
||||
let n = &out[1];
|
||||
(n.geometric_bounds().unwrap().x0, n.appearance.fill_paint())
|
||||
};
|
||||
// Position easing runs along a spine: Align to Path gives the blend one.
|
||||
let base = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() };
|
||||
let (x0, p0) = mid(&base);
|
||||
// Colour easing alone leaves the position and moves the colour.
|
||||
let colour = BlendSpec { color_shift: 50.0, ..base.clone() };
|
||||
let (x1, p1) = mid(&colour);
|
||||
assert!((x0 - x1).abs() < 1e-9 && p0 != p1);
|
||||
// Position easing alone leaves the colour and moves the position.
|
||||
let pos = BlendSpec { ease: crate::live::BlendEase { kind: crate::live::BlendEaseType::In, ramp: 1.0 }, ..base.clone() };
|
||||
let (x2, p2) = mid(&pos);
|
||||
assert!((x0 - x2).abs() > 1e-3 && p0 == p2);
|
||||
assert_eq!(mid(&pos).0, x2, "deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_lerp_turns_the_short_way() {
|
||||
let x = Affine::rotate(170f64.to_radians());
|
||||
|
||||
+131
-1
@@ -99,7 +99,7 @@ pub use graph::{
|
||||
};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use live_paint::{GapOptions, GapSize};
|
||||
pub use metadata::{CopyrightStatus, DocMetadata};
|
||||
pub use node::Knockout;
|
||||
@@ -935,6 +935,136 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
/// Is everything but the layers the same in `other`, as the next version of the document after
|
||||
/// an edit of its art? The id counter and the id index are left out, and art outside the layers
|
||||
/// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it
|
||||
/// doesn't change: quick for every frame of an animation.
|
||||
pub fn same_outside_layers(&self, other: &Self) -> bool {
|
||||
let same_art = |a: &[Arc<Node>], b: &[Arc<Node>]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b));
|
||||
let same_pattern = |a: &PatternDef, b: &PatternDef| {
|
||||
same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() }
|
||||
};
|
||||
let Self {
|
||||
version,
|
||||
title,
|
||||
template,
|
||||
units,
|
||||
ruler_origin,
|
||||
rulers,
|
||||
artboard_ruler_origins,
|
||||
color_mode,
|
||||
artboards,
|
||||
layers: _,
|
||||
swatches,
|
||||
swatch_groups,
|
||||
graphic_styles,
|
||||
char_styles,
|
||||
para_styles,
|
||||
text_threads,
|
||||
symbols,
|
||||
graph_designs,
|
||||
guides,
|
||||
views,
|
||||
saved_selections,
|
||||
variables,
|
||||
grid,
|
||||
raster_effects_ppi,
|
||||
images,
|
||||
patterns,
|
||||
pattern_edit,
|
||||
mask_edit,
|
||||
next_id: _,
|
||||
unknown,
|
||||
page_isolate,
|
||||
page_knockout,
|
||||
spot_use_lab,
|
||||
setup,
|
||||
paste_remembers_layers,
|
||||
metadata,
|
||||
raster_effects,
|
||||
last_view,
|
||||
transparency_grid,
|
||||
layers_open,
|
||||
color_profiles,
|
||||
export_settings,
|
||||
extra,
|
||||
slices,
|
||||
slices_clip_to_artboard,
|
||||
print_setup,
|
||||
assets,
|
||||
svg_scripts,
|
||||
puppet,
|
||||
new_art,
|
||||
dimensions,
|
||||
index: _,
|
||||
} = self;
|
||||
*version == other.version
|
||||
&& *title == other.title
|
||||
&& *template == other.template
|
||||
&& *units == other.units
|
||||
&& *ruler_origin == other.ruler_origin
|
||||
&& *rulers == other.rulers
|
||||
&& *artboard_ruler_origins == other.artboard_ruler_origins
|
||||
&& *color_mode == other.color_mode
|
||||
&& *artboards == other.artboards
|
||||
&& *swatches == other.swatches
|
||||
&& *swatch_groups == other.swatch_groups
|
||||
&& *graphic_styles == other.graphic_styles
|
||||
&& *char_styles == other.char_styles
|
||||
&& *para_styles == other.para_styles
|
||||
&& *text_threads == other.text_threads
|
||||
&& symbols.len() == other.symbols.len()
|
||||
&& symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
|
||||
&& graph_designs.len() == other.graph_designs.len()
|
||||
&& graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
|
||||
&& *guides == other.guides
|
||||
&& *views == other.views
|
||||
&& *saved_selections == other.saved_selections
|
||||
&& *variables == other.variables
|
||||
&& *grid == other.grid
|
||||
&& *raster_effects_ppi == other.raster_effects_ppi
|
||||
&& *images == other.images
|
||||
&& patterns.len() == other.patterns.len()
|
||||
&& patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b))
|
||||
&& match (pattern_edit, &other.pattern_edit) {
|
||||
(Some(a), Some(b)) => {
|
||||
a.pattern == b.pattern
|
||||
&& a.layer == b.layer
|
||||
&& match (&a.original, &b.original) {
|
||||
(Some(a), Some(b)) => same_pattern(a, b),
|
||||
(a, b) => a.is_none() && b.is_none(),
|
||||
}
|
||||
}
|
||||
(a, b) => a.is_none() && b.is_none(),
|
||||
}
|
||||
&& *mask_edit == other.mask_edit
|
||||
&& *unknown == other.unknown
|
||||
&& *page_isolate == other.page_isolate
|
||||
&& *page_knockout == other.page_knockout
|
||||
&& *spot_use_lab == other.spot_use_lab
|
||||
&& *setup == other.setup
|
||||
&& *paste_remembers_layers == other.paste_remembers_layers
|
||||
&& *metadata == other.metadata
|
||||
&& *raster_effects == other.raster_effects
|
||||
&& *last_view == other.last_view
|
||||
&& *transparency_grid == other.transparency_grid
|
||||
&& *layers_open == other.layers_open
|
||||
&& *color_profiles == other.color_profiles
|
||||
&& *export_settings == other.export_settings
|
||||
&& *extra == other.extra
|
||||
&& *slices == other.slices
|
||||
&& *slices_clip_to_artboard == other.slices_clip_to_artboard
|
||||
&& *print_setup == other.print_setup
|
||||
&& *assets == other.assets
|
||||
&& *svg_scripts == other.svg_scripts
|
||||
&& match (puppet, &other.puppet) {
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
(a, b) => a.is_none() && b.is_none(),
|
||||
}
|
||||
&& *new_art == other.new_art
|
||||
&& *dimensions == other.dimensions
|
||||
}
|
||||
|
||||
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
|
||||
/// isn't a number).
|
||||
pub fn raster_ppi(&self) -> f64 {
|
||||
|
||||
@@ -60,12 +60,75 @@ pub enum BlendOrientation {
|
||||
AlignToPath,
|
||||
}
|
||||
|
||||
/// Distribution of blend steps, as offered by the Blend panel's Type menu.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BlendEaseType {
|
||||
#[default]
|
||||
In,
|
||||
Out,
|
||||
InOut,
|
||||
OutIn,
|
||||
}
|
||||
|
||||
impl BlendEaseType {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::In => "easeIn",
|
||||
Self::Out => "easeOut",
|
||||
Self::InOut => "easeInOut",
|
||||
Self::OutIn => "easeOutIn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlendEase {
|
||||
#[serde(default)]
|
||||
pub kind: BlendEaseType,
|
||||
#[serde(default, deserialize_with = "blend_ramp")]
|
||||
pub ramp: f64,
|
||||
}
|
||||
|
||||
impl BlendEase {
|
||||
pub fn is_linear(&self) -> bool {
|
||||
self.ramp <= 0.0
|
||||
}
|
||||
|
||||
/// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0.
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.kind == BlendEaseType::In && self.ramp == 0.0
|
||||
}
|
||||
|
||||
/// A monotone polynomial distribution, blended with even spacing by Ramp. Public help
|
||||
/// describes the four distributions, not their numeric curves; these curves are original.
|
||||
pub fn apply(&self, u: f64) -> f64 {
|
||||
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let eased = match self.kind {
|
||||
BlendEaseType::In => u * u,
|
||||
BlendEaseType::Out => u * (2.0 - u),
|
||||
BlendEaseType::InOut => u * u * (3.0 - 2.0 * u),
|
||||
BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u),
|
||||
BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5),
|
||||
};
|
||||
u + r * (eased - u)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlendSpec {
|
||||
#[serde(default)]
|
||||
pub spacing: BlendSpacing,
|
||||
#[serde(default)]
|
||||
pub orientation: BlendOrientation,
|
||||
/// Acceleration of the steps' shapes and positions along the spine between two keys.
|
||||
#[serde(default, skip_serializing_if = "BlendEase::is_default")]
|
||||
pub ease: BlendEase,
|
||||
/// Independent color acceleration. -100..100, with 0 preserving the even color ramp.
|
||||
#[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")]
|
||||
pub color_shift: f64,
|
||||
/// Replaced spine. `None` = the straight lines between the key objects' centres.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub spine: Option<PathData>,
|
||||
@@ -1757,10 +1820,58 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxe
|
||||
o
|
||||
}
|
||||
|
||||
/// Reading a native file never fails over one bad blend number: out-of-range values clamp,
|
||||
/// and anything that is not a finite number (NaN, null, text) becomes the default. The
|
||||
/// commands still refuse such input (`ease_param`).
|
||||
fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result<f64, D::Error> {
|
||||
let v = serde_json::Value::deserialize(d)?;
|
||||
Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max)))
|
||||
}
|
||||
|
||||
fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||
blend_number(d, 0.0, 1.0)
|
||||
}
|
||||
|
||||
fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||
blend_number(d, -100.0, 100.0)
|
||||
}
|
||||
|
||||
/// Whether an optional stored color shift has its unchanged default.
|
||||
fn is_zero(v: &f64) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
impl BlendSpec {
|
||||
/// Shift the color transition independently of shape placement. The exponential curve is
|
||||
/// original; the documented behavior specifies the direction, not numeric sample values.
|
||||
pub fn color_position(&self, u: f64) -> f64 {
|
||||
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 };
|
||||
if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A bad Ramp or Shift in a file clamps (non-finite values take the default) instead of
|
||||
/// failing the whole document.
|
||||
#[test]
|
||||
fn blend_easing_numbers_clamp_when_read() {
|
||||
use serde::de::value::{Error, F64Deserializer};
|
||||
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"kind":"out","ramp":7.5},"colorShift":-900}"#).unwrap();
|
||||
assert_eq!((spec.ease.ramp, spec.color_shift), (1.0, -100.0));
|
||||
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":-3},"colorShift":250}"#).unwrap();
|
||||
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 100.0));
|
||||
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":null},"colorShift":"NaN"}"#).unwrap();
|
||||
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 0.0));
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
assert_eq!(blend_ramp(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
|
||||
assert_eq!(blend_color_shift(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
|
||||
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
|
||||
/// real distance from the centre line; an inner edge that would pass the centre stops there.
|
||||
|
||||
+26
-15
@@ -3,6 +3,7 @@
|
||||
//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
|
||||
//! before File Info existed load unchanged.
|
||||
|
||||
use chrono::{Datelike, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// File Info → Copyright Status.
|
||||
@@ -118,23 +119,26 @@ pub fn now_unix() -> Option<i64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (civil-from-days, proleptic
|
||||
/// Gregorian; years clamped to 0–9999).
|
||||
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (proleptic Gregorian; years
|
||||
/// clamped to 0–9999).
|
||||
pub fn civil(t: i64) -> [i64; 6] {
|
||||
// Clamp first so every step stays in range for any input.
|
||||
// Clamp the input first so every input maps to a representable date (0000-01-01 .. 9999-12-31),
|
||||
// exactly as the old code did. The clamped instant is always inside chrono's range, so the
|
||||
// conversion below never falls back; the trailing `clamp(0, 9999)` is the old year clamp.
|
||||
let t = t.clamp(-62_167_219_200, 253_402_300_799);
|
||||
let days = t.div_euclid(86_400);
|
||||
let secs = t.rem_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
[year.clamp(0, 9999), month, day, secs / 3600, secs / 60 % 60, secs % 60]
|
||||
let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400));
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
|
||||
match (date, time) {
|
||||
(Some(d), Some(tm)) => {
|
||||
let year = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX });
|
||||
[year.clamp(0, 9999), i64::from(d.month()), i64::from(d.day()), i64::from(tm.hour()), i64::from(tm.minute()), i64::from(tm.second())]
|
||||
}
|
||||
_ => [1970, 1, 1, 0, 0, 0], // unreachable: the clamped `t` is always a valid date
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix seconds → `2026-10-05T14:03:00Z`.
|
||||
@@ -151,8 +155,15 @@ mod tests {
|
||||
fn dates_and_statuses() {
|
||||
assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z");
|
||||
// The input is clamped to 0000-01-01..9999-12-31, then the year is clamped to 0..=9999,
|
||||
// exactly as the old code did: inputs far past chrono's range land on the clamp bounds,
|
||||
// never on the epoch.
|
||||
assert_eq!(civil(i64::MIN)[0], 0);
|
||||
assert_eq!(civil(i64::MAX)[0], 9999);
|
||||
assert_eq!(iso8601(i64::MIN), "0000-01-01T00:00:00Z");
|
||||
assert_eq!(iso8601(i64::MAX), "9999-12-31T23:59:59Z");
|
||||
assert_eq!(iso8601(10_000_000_000_000), "9999-12-31T23:59:59Z");
|
||||
assert_eq!(iso8601(-10_000_000_000_000), "0000-01-01T00:00:00Z");
|
||||
assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain));
|
||||
assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain));
|
||||
assert_eq!(CopyrightStatus::parse("maybe"), None);
|
||||
|
||||
@@ -1301,10 +1301,8 @@ impl Node {
|
||||
self.projected(Some(b)).map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
// The characters' strokes paint around the glyphs, scaled with the type.
|
||||
let k = Scaling::factor(t.xf).unwrap_or(1.0);
|
||||
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
|
||||
let o = self.appearance.outset().max(chars);
|
||||
// The characters' strokes paint around the glyphs.
|
||||
let o = self.appearance.outset().max(t.char_stroke_outset());
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
// An image is measured by its frame, without its own fills and strokes.
|
||||
|
||||
@@ -279,7 +279,7 @@ impl PatternDef {
|
||||
out
|
||||
}
|
||||
/// Offsets of the pattern-editing preview copies (`copies`×`copies` around the tile, the
|
||||
/// centre `(0, 0)` excluded).
|
||||
/// centre `(0, 0)` excluded), in row-major order (not paint order).
|
||||
pub fn preview_offsets(&self) -> Vec<Vec2> {
|
||||
let k = (self.copies.clamp(1, 15) / 2) as i64;
|
||||
let mut out = vec![];
|
||||
@@ -292,6 +292,38 @@ impl PatternDef {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Offsets of every pattern-editing preview copy, including the centre tile, in the same paint
|
||||
/// order [`offsets_covering`] uses for [`Overlap`].
|
||||
pub fn preview_offsets_in_paint_order(&self) -> Vec<Vec2> {
|
||||
let k = (self.copies.clamp(1, 15) / 2) as i64;
|
||||
let (i0, i1, j0, j1) = (-k, k, -k, k);
|
||||
let mut out = vec![];
|
||||
let rows: Vec<i64> = if self.overlap.bottom_in_front { (j0..=j1).collect() } else { (j0..=j1).rev().collect() };
|
||||
for &j in &rows {
|
||||
let cols: Vec<i64> = if self.overlap.right_in_front { (i0..=i1).collect() } else { (i0..=i1).rev().collect() };
|
||||
for i in cols {
|
||||
out.push(self.offset(i, j));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Preview copies drawn before and after the centre tile in [`Self::preview_offsets_in_paint_order`],
|
||||
/// for one dimmed composite each (the centre tile is drawn at full strength between them).
|
||||
pub fn preview_offsets_dimmed_sides(&self) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||
let mut before = vec![];
|
||||
let mut after = vec![];
|
||||
let mut past_center = false;
|
||||
for o in self.preview_offsets_in_paint_order() {
|
||||
if o.x == 0.0 && o.y == 0.0 {
|
||||
past_center = true;
|
||||
} else if past_center {
|
||||
after.push(o);
|
||||
} else {
|
||||
before.push(o);
|
||||
}
|
||||
}
|
||||
(before, after)
|
||||
}
|
||||
/// Instances covering `region` (document space) as plain nodes for a pattern painted with
|
||||
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Distort
|
||||
/// Pattern Fills; Expand uses [`Self::instances_in_with`].
|
||||
@@ -744,6 +776,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_paint_order_respects_overlap() {
|
||||
let mut def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
||||
def.copies = 3;
|
||||
assert!(close(*def.preview_offsets_in_paint_order().last().unwrap(), Vec2::new(-10.0, -10.0)));
|
||||
let mut d2 = def.clone();
|
||||
d2.overlap = Overlap { right_in_front: true, bottom_in_front: true };
|
||||
assert!(close(*d2.preview_offsets_in_paint_order().last().unwrap(), Vec2::new(10.0, 10.0)));
|
||||
assert_eq!(def.preview_offsets_in_paint_order().len(), 9);
|
||||
let (before, after) = def.preview_offsets_dimmed_sides();
|
||||
assert_eq!(before.len() + after.len(), 8);
|
||||
assert!(before.iter().all(|o| *o != Vec2::ZERO) && after.iter().all(|o| *o != Vec2::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covering_offsets_and_overlap_order() {
|
||||
let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
||||
|
||||
@@ -154,6 +154,7 @@ pub const LANGUAGES: &[(&str, Quotes)] = &[
|
||||
("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
|
||||
("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
|
||||
("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }),
|
||||
("Turkish", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }),
|
||||
("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
];
|
||||
|
||||
+41
-1
@@ -104,6 +104,12 @@ pub struct CharStyle {
|
||||
/// their line line up with it.
|
||||
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub char_align: CharAlign,
|
||||
/// Character Direction (Character panel menu, Middle Eastern options): these characters are
|
||||
/// set left to right or right to left inside their paragraph, each stretch of them a Unicode
|
||||
/// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives
|
||||
/// them.
|
||||
#[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")]
|
||||
pub char_direction: Option<ParaDirection>,
|
||||
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
|
||||
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
|
||||
/// its proportional heights, `vpal`.
|
||||
@@ -297,6 +303,7 @@ impl Default for CharStyle {
|
||||
stroke_align: StrokeAlign::Center,
|
||||
position: CharPosition::Normal,
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
char_direction: None,
|
||||
proportional_metrics: false,
|
||||
tate_chu_yoko: false,
|
||||
small_caps: None,
|
||||
@@ -862,7 +869,8 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph's base direction ([`ParaStyle::direction`]).
|
||||
/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set
|
||||
/// in ([`CharStyle::char_direction`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ParaDirection {
|
||||
@@ -1702,6 +1710,22 @@ impl TextObject {
|
||||
TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))),
|
||||
}
|
||||
}
|
||||
/// How far the characters' strokes paint past their glyphs, in document space: they scale
|
||||
/// with the type.
|
||||
pub fn char_stroke_outset(&self) -> f64 {
|
||||
self.char_strokes(CharStyle::stroke_outset)
|
||||
}
|
||||
/// How far the characters' strokes can paint past their glyphs, their miter spikes and square
|
||||
/// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must
|
||||
/// redraw. The visual bounds take [`Self::char_stroke_outset`].
|
||||
pub fn char_stroke_reach(&self) -> f64 {
|
||||
self.char_strokes(|style| if style.has_stroke() { style.stroke_layer().reach() } else { 0.0 })
|
||||
}
|
||||
/// The farthest `outset` of the characters' styles, scaled with the type.
|
||||
fn char_strokes(&self, outset: impl Fn(&CharStyle) -> f64) -> f64 {
|
||||
let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0);
|
||||
self.runs.iter().map(|r| outset(&r.style)).fold(0.0, f64::max) * k
|
||||
}
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.xf = a * self.xf;
|
||||
}
|
||||
@@ -1906,6 +1930,22 @@ impl crate::Document {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Character Direction is saved only when set, and styles written before it existed read as
|
||||
/// Default.
|
||||
#[test]
|
||||
fn character_direction_is_saved_only_when_set() {
|
||||
let st = CharStyle::default();
|
||||
assert_eq!(st.char_direction, None);
|
||||
assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none());
|
||||
for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] {
|
||||
let set = CharStyle { char_direction: Some(d), ..CharStyle::default() };
|
||||
assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key);
|
||||
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
|
||||
}
|
||||
let old = serde_json::to_value(&st).unwrap();
|
||||
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().char_direction, None);
|
||||
}
|
||||
|
||||
/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none.
|
||||
#[test]
|
||||
fn ruby_is_saved_and_missing_ruby_reads_as_none() {
|
||||
|
||||
@@ -372,8 +372,10 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_
|
||||
}
|
||||
|
||||
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
|
||||
/// targeted item (on the first target object), else `default`.
|
||||
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool) -> Result<bool> {
|
||||
/// targeted item (on the first target object), else `default`. A `stroke` other than true, false or
|
||||
/// null is an error of command `cmd`.
|
||||
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result<bool> {
|
||||
check_switches(p, &["stroke"], cmd)?;
|
||||
if let Some(b) = p.get("stroke").and_then(Value::as_bool) {
|
||||
return Ok(b);
|
||||
}
|
||||
@@ -486,7 +488,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if source == target {
|
||||
return Err(bad(C, "`source` and `target` are the same object"));
|
||||
}
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let copy = switch_or(p, "copy", false, C)?;
|
||||
// When both objects come out as they were, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| {
|
||||
@@ -549,6 +551,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let idx = index_param(p, "index", "appearance.setItem")?;
|
||||
super::paint::check_keep_model(p, "appearance.setItem")?;
|
||||
check_numbers(p, &["weight", "opacity"], "appearance.setItem")?;
|
||||
check_switches(p, &["visible"], "appearance.setItem")?;
|
||||
let paint = paint_from(s, p)?;
|
||||
let ids = appearance_targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
@@ -762,7 +766,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "appearance.copyFrom";
|
||||
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
|
||||
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
|
||||
let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?);
|
||||
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
|
||||
let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None };
|
||||
let selected = match ids_param(p, "ids") {
|
||||
|
||||
@@ -970,7 +970,7 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let name = resolve_symbol(d, p, "symbol.place")?;
|
||||
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
|
||||
let at = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
|
||||
let at = Point::new(number_or(p, "x", centre.x, "symbol.place")?, number_or(p, "y", centre.y, "symbol.place")?);
|
||||
let size = natural_size(d, &name);
|
||||
let reg = d.symbol_registration(&name);
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
@@ -1099,7 +1099,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) {
|
||||
return Err(bad(C, format!("no symbol named `{name}`")));
|
||||
}
|
||||
let expand = bool_or(p, "expandInstances", true);
|
||||
let expand = switch_or(p, "expandInstances", true, C)?;
|
||||
s.edit("Delete Symbol", |d, _| {
|
||||
let inst = instances_of(d, Some(&name));
|
||||
if expand {
|
||||
@@ -1216,10 +1216,10 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = points(p, C)?;
|
||||
let st = s.doc()?;
|
||||
let name = resolve_symbol(&st.doc, p, C)?;
|
||||
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
|
||||
let density = f64_or(p, "density", 5.0).clamp(1.0, 10.0);
|
||||
let scale = f64_or(p, "scale", 1.0).clamp(0.01, 100.0);
|
||||
let alt = bool_or(p, "alt", false);
|
||||
let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0);
|
||||
let density = number_or(p, "density", 5.0, C)?.clamp(1.0, 10.0);
|
||||
let scale = number_or(p, "scale", 1.0, C)?.clamp(0.01, 100.0);
|
||||
let alt = switch_or(p, "alt", false, C)?;
|
||||
let size = natural_size(&st.doc, &name);
|
||||
let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set));
|
||||
// Only a new Symbol Set needs a layer that takes new art.
|
||||
@@ -1303,9 +1303,9 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
let pts = points(p, C)?;
|
||||
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
|
||||
let intensity = f64_or(p, "intensity", 5.0).clamp(1.0, 10.0) / 10.0;
|
||||
let alt = bool_or(p, "alt", false);
|
||||
let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0);
|
||||
let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0;
|
||||
let alt = switch_or(p, "alt", false, C)?;
|
||||
let st = s.doc()?;
|
||||
let color = match p.get("color").and_then(color_value) {
|
||||
Some(c) => super::paint::new_color(s, p, c),
|
||||
|
||||
@@ -248,10 +248,11 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if pts.is_empty() || !pts.iter().all(|q| finite(*q)) {
|
||||
return Err(bad(C, "points must be a non-empty list of finite [x,y]"));
|
||||
}
|
||||
let erase = bool_or(p, "erase", false);
|
||||
let erase = switch_or(p, "erase", false, C)?;
|
||||
check_numbers(p, &["tolerance", "gap"], C)?;
|
||||
// Line pieces and edges are only deleted (erase mode), never merged.
|
||||
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
|
||||
let open_filled_as_closed = bool_or(p, "openFilledAsClosed", true);
|
||||
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, C)?;
|
||||
let swatches = match p.get("pickColorFrom") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => match v.as_str() {
|
||||
@@ -382,9 +383,11 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
|
||||
}
|
||||
|
||||
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, "shapeBuilder.regions")?;
|
||||
check_numbers(p, &["gap"], "shapeBuilder.regions")?;
|
||||
let roots = root_ids(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let (map, leaves) = BuilderMap::with_gap(d, &roots, true, bool_or(p, "openFilledAsClosed", true), gap_param(p));
|
||||
let (map, leaves) = BuilderMap::with_gap(d, &roots, true, open_filled_as_closed, gap_param(p));
|
||||
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
|
||||
let regions: Vec<Value> = map
|
||||
.regions
|
||||
@@ -714,7 +717,7 @@ fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()),
|
||||
None => None,
|
||||
};
|
||||
let close = bool_or(p, "closeGaps", false);
|
||||
let close = switch_or(p, "closeGaps", false, C)?;
|
||||
let targets = live_targets(s, p)?;
|
||||
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
|
||||
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
|
||||
@@ -801,11 +804,11 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
|
||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or(s.paint.stroke_width.max(0.0));
|
||||
let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?;
|
||||
if !(0.0..=1000.0).contains(&width) {
|
||||
return Err(bad(C, "width out of range"));
|
||||
}
|
||||
let tol = f64_or(p, "tolerance", 4.0).clamp(0.0, 1000.0);
|
||||
let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0);
|
||||
let edge = s.edit("Live Paint Bucket", |d, _| {
|
||||
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
|
||||
let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||
@@ -863,7 +866,7 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
|
||||
|
||||
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
|
||||
let name = str_param(p, "preset").unwrap_or("Default");
|
||||
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false);
|
||||
let enhanced = switch_or(p, "enhanced", false, "imageTrace.make")?;
|
||||
// (A preset without an enhanced version is traced as it is.)
|
||||
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
|
||||
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
|
||||
@@ -207,6 +207,7 @@ fn rect_transform(r: Rect, p: &Value, cmd: &str) -> Result<Affine> {
|
||||
}
|
||||
|
||||
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["radius"], "shape.rectangle")?;
|
||||
let r = rect_of(p, "shape.rectangle")?;
|
||||
let radius = f64_or(p, "radius", 0.0).max(0.0);
|
||||
let live = LiveShape::Rectangle {
|
||||
@@ -230,6 +231,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = Point::new(f64_req(p, "cx", "shape.polygon")?, f64_req(p, "cy", "shape.polygon")?);
|
||||
let r = f64_req(p, "radius", "shape.polygon")?.abs();
|
||||
check_numbers(p, &["sides", "rotation"], "shape.polygon")?;
|
||||
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32;
|
||||
let rot = f64_or(p, "rotation", 0.0);
|
||||
let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians());
|
||||
@@ -240,6 +242,7 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn star(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = Point::new(f64_req(p, "cx", "shape.star")?, f64_req(p, "cy", "shape.star")?);
|
||||
let r1 = f64_req(p, "radius1", "shape.star")?.abs();
|
||||
check_numbers(p, &["radius2", "points", "rotation"], "shape.star")?;
|
||||
let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
|
||||
let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32;
|
||||
let path = shapes::star(c, r1, r2, n, f64_or(p, "rotation", 0.0));
|
||||
@@ -251,6 +254,24 @@ fn star(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn flare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use vectorcraft_color::{BlendMode, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
let c = Point::new(f64_req(p, "cx", "shape.flare")?, f64_req(p, "cy", "shape.flare")?);
|
||||
let numbers = [
|
||||
"diameter",
|
||||
"opacity",
|
||||
"brightness",
|
||||
"growth",
|
||||
"fuzziness",
|
||||
"rays",
|
||||
"longest",
|
||||
"rayFuzziness",
|
||||
"rings",
|
||||
"largest",
|
||||
"x2",
|
||||
"y2",
|
||||
"direction",
|
||||
"pathLength",
|
||||
"seed",
|
||||
];
|
||||
check_numbers(p, &numbers, "shape.flare")?;
|
||||
let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0);
|
||||
let r = (f64_or(p, "diameter", 100.0) / 2.0).max(0.5);
|
||||
let opacity = pct("opacity", 50.0).min(1.0) as f32;
|
||||
@@ -360,12 +381,13 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = Point::new(f64_req(p, "cx", "shape.spiral")?, f64_req(p, "cy", "shape.spiral")?);
|
||||
check_numbers(p, &["segments"], "shape.spiral")?;
|
||||
let path = shapes::spiral(
|
||||
c,
|
||||
f64_req(p, "radius", "shape.spiral")?,
|
||||
f64_or(p, "decay", 80.0),
|
||||
number_or(p, "decay", 80.0, "shape.spiral")?,
|
||||
p.get("segments").and_then(Value::as_u64).unwrap_or(10).clamp(2, 1000) as u32,
|
||||
bool_or(p, "clockwise", true),
|
||||
switch_or(p, "clockwise", true, "shape.spiral")?,
|
||||
);
|
||||
add_art(s, "Spiral", path_kind(path, None), None)
|
||||
}
|
||||
@@ -373,7 +395,7 @@ fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?);
|
||||
let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?);
|
||||
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
|
||||
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, switch_or(p, "closed", false, "shape.arc")?), None), None)
|
||||
}
|
||||
|
||||
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
|
||||
@@ -406,9 +428,11 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) ->
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// A grid's divider count `count` with its skew `key` in percent (0 when absent, clamped to
|
||||
/// ±[`shapes::GRID_SKEW_MAX`]).
|
||||
/// A grid's divider count `count` (an error unless it is a number or null; 5 unless it is a JSON
|
||||
/// integer of 0 or more, so 4.0 and 2.5 give 5; at most 999) with its skew `key` in percent (0 when
|
||||
/// absent, clamped to ±[`shapes::GRID_SKEW_MAX`]).
|
||||
fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> {
|
||||
check_numbers(p, &[count], cmd)?;
|
||||
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let skew = match p.get(key) {
|
||||
None | Some(Value::Null) => 0.0,
|
||||
@@ -466,6 +490,8 @@ pub(crate) fn anchor_from_json(v: &Value, cmd: &str) -> Result<Anchor> {
|
||||
}
|
||||
|
||||
fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Checked with `d` too, where the path data alone decides whether the path is closed.
|
||||
let closed = switch_or(p, "closed", false, "path.create")?;
|
||||
let path = if let Some(d) = str_param(p, "d") {
|
||||
let bp = vectorcraft_geom::BezPath::from_svg(d).map_err(|e| bad("path.create", format!("bad path data: {e}")))?;
|
||||
PathData::from_bezpath(&bp)
|
||||
@@ -480,7 +506,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if anchors.is_empty() {
|
||||
return Err(bad("path.create", "need at least one anchor"));
|
||||
}
|
||||
PathData::single(SubPath::new(anchors, bool_or(p, "closed", false)))
|
||||
PathData::single(SubPath::new(anchors, closed))
|
||||
};
|
||||
let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
// Open paths drawn with a stroke of None would be invisible: mimic Illustrator and keep what the user set.
|
||||
@@ -495,6 +521,11 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let x = f64_req(p, "x", "text.create")?;
|
||||
let y = f64_req(p, "y", "text.create")?;
|
||||
super::paint::check_keep_model(p, "text.create")?;
|
||||
check_numbers(p, &["size"], "text.create")?;
|
||||
check_switches(p, &["vertical", "placeholder"], "text.create")?;
|
||||
if let Some(a) = p.get("area") {
|
||||
check_numbers(a, &["width", "height"], "text.create")?;
|
||||
}
|
||||
let text = str_param(p, "text").unwrap_or("");
|
||||
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
|
||||
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
|
||||
|
||||
@@ -665,9 +665,10 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pressure = stroke_pressure(&samples);
|
||||
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
|
||||
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0);
|
||||
let closed = bool_or(p, "closed", false);
|
||||
let closed = switch_or(p, "closed", false, C)?;
|
||||
let brush = str_param(p, "style") == Some("brush");
|
||||
let keep_selected = bool_or(p, "keepSelected", true);
|
||||
let keep_selected = switch_or(p, "keepSelected", true, C)?;
|
||||
let filled = switch_or(p, "fill", false, C)?;
|
||||
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
|
||||
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
|
||||
let at_start = str_param(ext, "end") == Some("start");
|
||||
@@ -764,7 +765,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "need at least two points"));
|
||||
}
|
||||
let sp = fit_freehand(&pts, tol, closed);
|
||||
let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None };
|
||||
let fill = if filled { s.paint.fill.clone() } else { Paint::None };
|
||||
// A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing
|
||||
// gets black. A brush is its stroke.
|
||||
let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() };
|
||||
@@ -780,12 +781,11 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.curvature";
|
||||
let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?;
|
||||
let pts: Vec<(Point, bool)> =
|
||||
arr.iter().map(|v| Ok((point_entry(v, C)?, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))).collect::<Result<_>>()?;
|
||||
let pts: Vec<(Point, bool)> = arr.iter().map(|v| Ok((point_entry(v, C)?, switch_or(v, "corner", false, C)?))).collect::<Result<_>>()?;
|
||||
if pts.is_empty() {
|
||||
return Err(bad(C, "need at least one point"));
|
||||
}
|
||||
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, bool_or(p, "closed", false));
|
||||
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?);
|
||||
if let Some(id) = id_param(p, "id") {
|
||||
s.edit("Curvature", |d, sel| {
|
||||
*path_mut(d, id)? = PathData::single(sp);
|
||||
@@ -831,7 +831,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => EndCurve::Keep,
|
||||
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
|
||||
};
|
||||
let corner = bool_or(p, "corner", false);
|
||||
let corner = switch_or(p, "corner", false, C)?;
|
||||
let op = match str_param(p, "op") {
|
||||
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
|
||||
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
|
||||
@@ -1030,7 +1030,7 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
|
||||
return Err(bad(C, "invalid delta"));
|
||||
}
|
||||
let perpendicular = bool_or(p, "perpendicular", false);
|
||||
let perpendicular = switch_or(p, "perpendicular", false, C)?;
|
||||
s.edit("Reshape", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
@@ -1490,11 +1490,12 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
Paint::solid(Color::BLACK)
|
||||
};
|
||||
let keep_selected = bool_or(p, "keepSelected", false);
|
||||
let only_selection = bool_or(p, "mergeOnlySelection", false);
|
||||
let keep_selected = switch_or(p, "keepSelected", false, C)?;
|
||||
let only_selection = switch_or(p, "mergeOnlySelection", false, C)?;
|
||||
let merge = switch_or(p, "merge", true, C)?;
|
||||
let st = s.doc()?;
|
||||
let mut merge_ids = vec![];
|
||||
if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) {
|
||||
if merge && !(only_selection && st.selection.is_empty()) {
|
||||
let all = Selection::default();
|
||||
let from = if only_selection { &st.selection } else { &all };
|
||||
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
|
||||
|
||||
@@ -11,6 +11,7 @@ use super::clipboard::SwatchChoices;
|
||||
use super::*;
|
||||
use crate::{Clipboard, EngineError, inspect};
|
||||
|
||||
/// Register editing commands, including document-to-document artwork drops and their parameter contracts.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -73,6 +74,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
global_rulers
|
||||
),
|
||||
cmd!(
|
||||
"edit.dropSelection",
|
||||
"Drop Artwork",
|
||||
[],
|
||||
None,
|
||||
"{sourceDocument: uid, ids: [id…], grab: [x,y], at: [x,y]} copy 1–10000 editable source objects from an open document to the active document, preserving the grab point and clipboard, as one undo step",
|
||||
has_doc,
|
||||
drop_selection
|
||||
),
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!(
|
||||
@@ -571,6 +581,40 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Document ids scope node ids: two tabs may contain objects with the same node id.
|
||||
fn drop_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "edit.dropSelection";
|
||||
let key = "sourceDocument";
|
||||
let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?;
|
||||
let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates")));
|
||||
let grab = point("grab")?;
|
||||
let at = point("at")?;
|
||||
let source = s.documents().iter().find(|d| d.uid == uid).ok_or_else(|| bad(CMD, "that document is no longer open"))?;
|
||||
let key = "ids";
|
||||
let values = p[key].as_array().ok_or_else(|| bad(CMD, format!("{key} must be an array of non-negative integer object ids")))?;
|
||||
if values.is_empty() || values.len() > 10000 {
|
||||
return Err(bad(CMD, "ids must contain 1–10000 object ids"));
|
||||
}
|
||||
let ids = values
|
||||
.iter()
|
||||
.map(|value| value.as_u64().map(NodeId).ok_or_else(|| bad(CMD, format!("invalid object id {value}: expected a non-negative integer"))))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
// Validate the entire batch in one walk; node ids are local to the source document.
|
||||
let paths = source.doc.index_paths(ids.iter().copied());
|
||||
if let Some(id) = ids.iter().find(|id| !paths.contains_key(id)) {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
}
|
||||
let clip = Clipboard::copy(source, &roots_of(&source.doc, ids));
|
||||
let bounds = clip.bounds().ok_or_else(|| bad(CMD, "the art has no bounds"))?;
|
||||
let center = bounds.center() + Vec2::new(at[0] - grab[0], at[1] - grab[1]);
|
||||
if !center.x.is_finite() || !center.y.is_finite() {
|
||||
return Err(bad(CMD, "result would exceed the canvas (coordinates out of range)"));
|
||||
}
|
||||
// A drop preserves the source colours even when the flyer has same-named swatches.
|
||||
let choices = SwatchChoices::parse(PasteMode::Drag.label(), &json!({"swatchConflict": "add"}))?;
|
||||
paste_clip(s, &json!({"center": [center.x, center.y]}), PasteMode::Drag, &clip, &choices)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum PasteMode {
|
||||
Offset,
|
||||
@@ -580,9 +624,11 @@ enum PasteMode {
|
||||
AllArtboards,
|
||||
/// A graphic from the Libraries panel: centred where it is placed, as Paste centres.
|
||||
Library,
|
||||
Drag,
|
||||
}
|
||||
|
||||
impl PasteMode {
|
||||
/// Return the user-visible action label for this insertion mode.
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
PasteMode::Front => "Paste in Front",
|
||||
@@ -591,6 +637,7 @@ impl PasteMode {
|
||||
PasteMode::AllArtboards => "Paste on All Artboards",
|
||||
PasteMode::Offset => "Paste",
|
||||
PasteMode::Library => "Place from Library",
|
||||
PasteMode::Drag => "Drop Artwork",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -623,6 +670,7 @@ fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert clipboard resources atomically in the active editable context using the requested placement mode.
|
||||
fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> {
|
||||
let off = s.prefs.paste_offset;
|
||||
let st = s.doc()?;
|
||||
@@ -640,7 +688,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
.map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO }));
|
||||
// Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art
|
||||
// always goes back into its layers.
|
||||
let remember = (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
// A drop goes into the current editable insertion context, not a same-named source layer.
|
||||
let remember = mode != PasteMode::Drag
|
||||
&& (st.doc.paste_remembers_layers || boards.is_some())
|
||||
&& parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
// Front/back: relative to the selection (top-most / bottom-most selected object); with
|
||||
// nothing selected, the top / bottom of the current layer.
|
||||
let anchor = match mode {
|
||||
@@ -653,7 +704,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
};
|
||||
let placements: Vec<Affine> = match (mode, boards) {
|
||||
(_, Some((_, dv))) => vec![Affine::translate(dv)],
|
||||
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
|
||||
(PasteMode::Offset | PasteMode::Library | PasteMode::Drag, None) => vec![match point_param(p, "center") {
|
||||
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
||||
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
|
||||
}],
|
||||
@@ -680,7 +731,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
let mut c = d.reid(n);
|
||||
imported.apply(&mut c);
|
||||
if xf != Affine::IDENTITY {
|
||||
c.transform(xf, false);
|
||||
// A cross-document drag carries the graphic's appearance with it,
|
||||
// including the pattern phase, independently of transform-tool settings.
|
||||
c.transform(xf, vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() });
|
||||
}
|
||||
let layer = match clip.source_layers.get(k) {
|
||||
Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))),
|
||||
|
||||
@@ -793,6 +793,17 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
|
||||
/// Open-dialog filters for Swatch Libraries and Graphic Style Libraries Other Library… and Locate
|
||||
/// Library…. The library formats come first so Linux file portals (which grey out files that don't
|
||||
/// match the default filter) still list `.ase` and the other library extensions; then the same
|
||||
/// filters as [`open_filters`] so a document can be picked for its swatches or graphic styles.
|
||||
pub fn library_open_filters(
|
||||
library_label: &'static str,
|
||||
library_exts: &'static [&'static str],
|
||||
) -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once((library_label, library_exts)).chain(open_filters())
|
||||
}
|
||||
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
|
||||
/// RTF and Word.
|
||||
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
|
||||
@@ -384,6 +384,14 @@ fn open_exts_cover_every_readable_format() {
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_open_filters_list_library_formats_first() {
|
||||
let filters: Vec<_> = library_open_filters("Swatch libraries", super::swatchlib::LIBRARY_EXTS).collect();
|
||||
assert_eq!(filters[0], ("Swatch libraries", super::swatchlib::LIBRARY_EXTS));
|
||||
assert!(filters[0].1.contains(&"ase"));
|
||||
assert_eq!(filters[1], ("All readable files", OPEN_EXTS));
|
||||
}
|
||||
|
||||
/// The extensions usage texts list for export: one per writable format, each picking that format
|
||||
/// (`.png` picks PNG; PNG-8 is asked for by `format`).
|
||||
#[test]
|
||||
|
||||
@@ -125,6 +125,24 @@ fn placed_images_come_back_as_linked_files() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of the drawing and get no
|
||||
/// linked file, and `warnings` holds the note raster exports give, once, without naming either
|
||||
/// image's key.
|
||||
#[test]
|
||||
fn images_that_cant_be_decoded_give_the_raster_note_once() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
|
||||
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||
assert_eq!(keys.len(), 2);
|
||||
let r = s.execute("document.export", &json!({"format": "dxf"})).unwrap();
|
||||
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{w:?}");
|
||||
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}");
|
||||
assert!(entities(&text(&r)).is_empty() && r.get("linked").is_none(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_dxf_options_are_refused() {
|
||||
let mut s = two_boards();
|
||||
|
||||
@@ -156,6 +156,23 @@ fn placed_images_are_written_and_previews_are_black_and_white_on_request() {
|
||||
assert!(l3.contains("/FlateDecode filter >> image"));
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings`
|
||||
/// holds the note raster exports give, once, without naming either image's key.
|
||||
#[test]
|
||||
fn images_that_cant_be_decoded_give_the_raster_note_once() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
|
||||
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||
assert_eq!(keys.len(), 2);
|
||||
let r = s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap();
|
||||
let w = warnings(&r);
|
||||
assert_eq!(w.iter().filter(|w| *w == NOTE).count(), 1, "{w:?}");
|
||||
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}");
|
||||
assert!(!postscript(&b64(&r)).contains("filter >> image"), "no image is written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_eps_options_are_refused_and_formats_list_eps() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
|
||||
@@ -95,6 +95,24 @@ fn raster_effects_are_written_as_images() {
|
||||
assert!(kinds.contains(&114), "the shadow is an image with transparency");
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of an EMF or WMF, and
|
||||
/// `warnings` holds the note raster exports give, once, without naming either image's key.
|
||||
#[test]
|
||||
fn images_that_cant_be_decoded_give_the_raster_note_once() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
|
||||
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||
assert_eq!(keys.len(), 2);
|
||||
for format in ["emf", "wmf"] {
|
||||
let r = s.execute("document.export", &json!({"format": format})).unwrap();
|
||||
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{format}: {w:?}");
|
||||
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{format}: {w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafiles_open_and_place() {
|
||||
let mut s = two_boards();
|
||||
|
||||
@@ -131,6 +131,32 @@ fn options_not_applied_yet_and_document_features_warn() {
|
||||
assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
/// A placed 16-bit PNG is written at 8 bits a sample in PDF 1.3 and 1.4 files (PDF/X-3 and the
|
||||
/// pages of a .ai file too), and keeps 16 bits from PDF 1.5.
|
||||
#[test]
|
||||
fn a_16_bit_png_is_written_at_8_bits_before_pdf_1_5() {
|
||||
let mut s = session(1);
|
||||
let deep = image::ImageBuffer::from_fn(8, 8, |x, y| image::Rgb([x as u16 * 8000, y as u16 * 8000, 40000]));
|
||||
let mut png = vec![];
|
||||
image::DynamicImage::ImageRgb16(deep).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.execute("file.place", &json!({"name": "deep.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [50, 40]})).unwrap();
|
||||
let readable = json!({"compressText": false});
|
||||
for (cmd, p, depth) in [
|
||||
("document.exportPdf", json!({"compatibility": "1.3", "compression": readable}), 8),
|
||||
("document.exportPdf", json!({"compatibility": "1.4", "compression": readable}), 8),
|
||||
("document.exportPdf", json!({"standard": "pdfX3", "compression": readable}), 8),
|
||||
("file.saveCopy", json!({"format": "ai", "compatibility": "1.4", "compress": false}), 8),
|
||||
("document.exportPdf", json!({"compatibility": "1.5", "compression": readable}), 16),
|
||||
("document.exportPdf", json!({"compression": readable}), 16),
|
||||
] {
|
||||
let v = s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
let text = String::from_utf8_lossy(&b64(&v)).into_owned();
|
||||
let (eight, sixteen) = (text.contains("/BitsPerComponent 8"), text.contains("/BitsPerComponent 16"));
|
||||
assert_eq!((eight, sixteen), (depth == 8, depth == 16), "{cmd} {p}");
|
||||
assert!(warnings(&v).iter().all(|w| !w.contains("decoded")), "{cmd} {p}: {}", v["warnings"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes,
|
||||
/// and the rest of the page is written. Each reply that carries the PDF's warnings has the note
|
||||
/// raster exports give, once.
|
||||
|
||||
@@ -176,7 +176,7 @@ pub(crate) fn parse_mode(m: &str) -> std::result::Result<FreeformMode, String> {
|
||||
/// automatic points it shows first, and the stops follow the points' colours.
|
||||
fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freeform, Space) -> std::result::Result<R, String>) -> Result<R> {
|
||||
let item = item_target(s, p, cmd)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active, cmd)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let ids = item.targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
@@ -308,7 +308,7 @@ fn split_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.splitLine";
|
||||
let line = index_param(p, "line", C)?;
|
||||
let segment = index_param(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5);
|
||||
let t = number_or(p, "t", 0.5, C)?;
|
||||
let index = edit(s, p, C, |f, _| f.split_line(line, segment, t).ok_or_else(|| format!("no segment {segment} on line {line}")))?;
|
||||
s.select_freeform_point(Some(index));
|
||||
Ok(json!({ "index": index }))
|
||||
@@ -332,7 +332,7 @@ fn select_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.get";
|
||||
let stroke = bool_or(p, "stroke", !s.fill_active);
|
||||
let stroke = switch_or(p, "stroke", !s.fill_active, C)?;
|
||||
let (f, _) = s.proxy_freeform(stroke).ok_or_else(|| bad(C, NOT_FREEFORM))?;
|
||||
let selected = if stroke != s.fill_active { s.selected_freeform_point().filter(|i| *i < f.points.len()) } else { None };
|
||||
let mut v = vectorcraft_tools::params::freeform_json(&f);
|
||||
|
||||
@@ -117,7 +117,12 @@ pub(crate) fn link_stops(d: &Document, v: &Value) -> Parsed<Value> {
|
||||
if d.swatch(&name).is_none() && st.get("color").is_some() {
|
||||
continue;
|
||||
}
|
||||
let tint = st.get("tint").and_then(Value::as_f64).map(|t| (t / 100.0).clamp(0.0, 1.0) as f32);
|
||||
let tint = st
|
||||
.get("tint")
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| v.as_f64().ok_or_else(|| format!("stop {i}: `{k}` must be a number", k = "tint")))
|
||||
.transpose()?;
|
||||
let tint = tint.map(|t| (t / 100.0).clamp(0.0, 1.0) as f32);
|
||||
let Paint::Solid { color, swatch, tint } = swatch_solid(d, &name, tint).map_err(|e| format!("stop {i}: {e}"))? else {
|
||||
continue;
|
||||
};
|
||||
@@ -372,7 +377,9 @@ pub(crate) fn apply_gradient_edit_in(
|
||||
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.editGradient";
|
||||
let item = item_target(s, p, C)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
|
||||
check_switches(p, &["reverse"], C)?;
|
||||
check_numbers(p, &["angle"], C)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let ids = item.targets(s, p)?;
|
||||
// How the gradient lies on a stroke (the panel's Stroke buttons).
|
||||
@@ -554,7 +561,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let p = &p;
|
||||
let item = item_target(s, p, C)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let targets = match item {
|
||||
ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?,
|
||||
|
||||
@@ -870,6 +870,11 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => vec![p],
|
||||
};
|
||||
check_switches(p, &["moveArt", "scaleArt", "strokes", "corners", "patterns", "lockedAndHidden"], "artboard.setProps")?;
|
||||
for q in &entries {
|
||||
check_switches(q, &["locked"], "artboard.setProps")?;
|
||||
check_numbers(q, &["x", "y", "width", "height"], "artboard.setProps")?;
|
||||
}
|
||||
// Scale Artwork with Artboard: resized, an artboard takes the art fully inside it (as Move
|
||||
// Artwork with Artboard gathers it) and its guides from the old rectangle onto the new one.
|
||||
let scale_art = bool_or(p, "scaleArt", false);
|
||||
|
||||
@@ -10,8 +10,8 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::live::{
|
||||
self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, GradientMesh, MeshAppearance,
|
||||
PreserveShape, Spine,
|
||||
self, BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions,
|
||||
GradientMesh, MeshAppearance, PreserveShape, Spine,
|
||||
};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Blend"],
|
||||
Some("Cmd+Alt+B"),
|
||||
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
|
||||
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
|
||||
has_doc,
|
||||
blend_make
|
||||
),
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Options…",
|
||||
["Object", "Blend"],
|
||||
None,
|
||||
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}",
|
||||
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; a tool setting, not an undo step) → {defaults?: true}",
|
||||
always,
|
||||
blend_options
|
||||
),
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Info",
|
||||
[],
|
||||
None,
|
||||
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
|
||||
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
|
||||
always,
|
||||
blend_info
|
||||
),
|
||||
@@ -451,6 +451,32 @@ fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend panel Type and Ramp, validated before any document mutation.
|
||||
fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result<BlendEase> {
|
||||
let kind = match p.get("easingType").filter(|v| !v.is_null()) {
|
||||
None => current.kind,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("easeIn") => BlendEaseType::In,
|
||||
Some("easeOut") => BlendEaseType::Out,
|
||||
Some("easeInOut") => BlendEaseType::InOut,
|
||||
Some("easeOutIn") => BlendEaseType::OutIn,
|
||||
_ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")),
|
||||
},
|
||||
};
|
||||
Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 })
|
||||
}
|
||||
|
||||
/// Bounded percentage input; missing or null preserves the current value.
|
||||
fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result<f64> {
|
||||
match p.get(key).filter(|v| !v.is_null()) {
|
||||
None => Ok(current),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && (min..=100.0).contains(n))
|
||||
.ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from.
|
||||
fn starts_param(p: &Value, roots: &[NodeId]) -> Result<Vec<(NodeId, u32)>> {
|
||||
const C: &str = "object.blend.make";
|
||||
@@ -533,6 +559,12 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let spacing = spacing_param(p, C, cur.spacing)?;
|
||||
let orientation = orientation_param(p, cur.orientation);
|
||||
let (ease0, color0) = match base.as_ref().map(|n| &n.kind) {
|
||||
Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift),
|
||||
_ => Default::default(),
|
||||
};
|
||||
let ease = ease_param(p, C, ease0)?;
|
||||
let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?;
|
||||
let id = s.edit("Make Blend", |d, sel| {
|
||||
wrap(d, sel, &roots, |id, nodes| {
|
||||
let (children, starts) = merge_keys(nodes, &starts);
|
||||
@@ -543,7 +575,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.knockout = vectorcraft_doc::Knockout::On;
|
||||
n
|
||||
});
|
||||
let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() };
|
||||
let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() };
|
||||
if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind {
|
||||
extend_spine(&mut spec, old, old_spec, &children);
|
||||
}
|
||||
@@ -772,7 +804,10 @@ fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec<NodeId>)> {
|
||||
fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.options";
|
||||
let Some((_, cur, _)) = selected_spec(s) else {
|
||||
// Nothing to set them on: the options new blends start with.
|
||||
if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad(C, "select a blend to set easing or color shift"));
|
||||
}
|
||||
// Nothing to set them on: the options new blends start with (easing is per blend).
|
||||
let d = s.prefs.blend_options.unwrap_or_default();
|
||||
let spacing = spacing_param(p, C, d.spacing)?;
|
||||
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) });
|
||||
@@ -780,9 +815,13 @@ fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let spacing = spacing_param(p, C, cur.spacing)?;
|
||||
let orientation = orientation_param(p, cur.orientation);
|
||||
let ease = ease_param(p, C, cur.ease)?;
|
||||
let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?;
|
||||
edit_blends(s, "Blend Options", |_, spec| {
|
||||
spec.spacing = spacing;
|
||||
spec.orientation = orientation;
|
||||
spec.ease = ease;
|
||||
spec.color_shift = color_shift;
|
||||
})
|
||||
}
|
||||
|
||||
@@ -804,6 +843,9 @@ fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut v = blend_options_json(spec.spacing, spec.orientation);
|
||||
v["target"] = json!("blend");
|
||||
v["id"] = json!(id.0);
|
||||
v["easingType"] = json!(spec.ease.kind.id());
|
||||
v["ramp"] = json!(spec.ease.ramp * 100.0);
|
||||
v["colorShift"] = json!(spec.color_shift);
|
||||
v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>());
|
||||
v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>());
|
||||
if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind)
|
||||
|
||||
@@ -353,6 +353,7 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
let random = switch_or(p, "random", false, "object.transformEach")?;
|
||||
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
|
||||
let copy = switch_or(p, "copy", false, "object.transformEach")?;
|
||||
check_switches(p, &["strokes", "corners", "objects", "patterns"], "object.transformEach")?;
|
||||
let objects = super::object::transforms_objects(p);
|
||||
if sh == 0.0 || sv == 0.0 {
|
||||
return Err(bad("object.transformEach", "scale must be non-zero"));
|
||||
@@ -1018,6 +1019,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["spacing"], CMD)?;
|
||||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||||
let move_art = switch_or(p, "moveArtwork", true, CMD)?;
|
||||
check_switches(p, &["patterns"], CMD)?;
|
||||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||||
let d = &s.doc()?.doc;
|
||||
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
|
||||
@@ -252,8 +252,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
|
||||
has_selection,
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
|
||||
has_selection_or_guides,
|
||||
align
|
||||
),
|
||||
cmd!(
|
||||
@@ -1155,6 +1155,7 @@ fn data_param(m: &serde_json::Map<String, Value>) -> Result<Vec<(String, Option<
|
||||
|
||||
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["visible", "locked", "isolate", "knockoutShape"], "object.setProps")?;
|
||||
check_numbers(p, &["opacity"], "object.setProps")?;
|
||||
let ids = targets(s, p)?;
|
||||
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
|
||||
let blend = match str_param(p, "blend") {
|
||||
@@ -1361,11 +1362,22 @@ fn align_reference(s: &Session, p: &Value, ids: &[NodeId], preview: bool, own: O
|
||||
Ok((r, true, true))
|
||||
}
|
||||
|
||||
/// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to
|
||||
/// Selection when no objects are selected.
|
||||
fn selected_guides_bounds(st: &crate::DocState) -> Option<Rect> {
|
||||
st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option<Rect>, g| {
|
||||
let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) };
|
||||
Some(acc.map(|a| a.union(r)).unwrap_or(r))
|
||||
})
|
||||
}
|
||||
|
||||
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
|
||||
let st = s.doc()?;
|
||||
match align_to(s, p) {
|
||||
Some("artboard") => {
|
||||
let b = effects::preview::bounds_of(&st.doc, ids, preview).unwrap_or_default();
|
||||
let b = effects::preview::bounds_of(&st.doc, ids, preview)
|
||||
.or_else(|| ids.is_empty().then(|| selected_guides_bounds(st)).flatten())
|
||||
.unwrap_or_default();
|
||||
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
|
||||
// it, or with an index the document doesn't have, it uses the artboard under the center
|
||||
// of the selection, or the first one when no artboard is there.
|
||||
@@ -1377,7 +1389,13 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
|
||||
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
|
||||
effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k))
|
||||
}
|
||||
_ => effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())),
|
||||
_ => {
|
||||
if ids.is_empty() {
|
||||
selected_guides_bounds(st).ok_or_else(|| EngineError::Other("nothing to align".into()))
|
||||
} else {
|
||||
effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1407,6 +1425,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (h, v) = direction(p, "object.align")?;
|
||||
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
|
||||
check_switches(p, &["patterns"], "object.align")?;
|
||||
check_numbers(p, &["artboard"], "object.align")?;
|
||||
if h.is_none() && v.is_none() {
|
||||
return Err(bad("object.align", "give horizontal or vertical"));
|
||||
}
|
||||
@@ -1443,15 +1462,22 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
apply_moves(s, "Align", p, &ids, &moves, &guides)
|
||||
}
|
||||
|
||||
/// The selected ruler guides that Align to the key object (or key guide) moves, and where: a
|
||||
/// vertical guide goes to the left edge, centre or right edge of `r` that `h` names, a horizontal
|
||||
/// one to the top, centre or bottom that `v` names. The key guide stays put, and so do guides
|
||||
/// when aligning to the selection or the artboard.
|
||||
/// The selected ruler guides that Align moves, and where: a vertical guide goes to the left edge,
|
||||
/// centre or right edge of `r` that `h` names, a horizontal one to the top, centre or bottom that
|
||||
/// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put.
|
||||
/// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are
|
||||
/// selected; with objects in the selection they stay put.
|
||||
fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(usize, f64)>> {
|
||||
if align_to(s, p) != Some("key") {
|
||||
let st = s.doc()?;
|
||||
let to = align_to(s, p);
|
||||
let move_guides = match to {
|
||||
Some("key" | "artboard") => true,
|
||||
Some("selection") => st.selection.is_empty(),
|
||||
_ => false,
|
||||
};
|
||||
if !move_guides {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let at = |vertical: bool| match (vertical, h, v) {
|
||||
(true, Some("left"), _) => Some(r.x0),
|
||||
(true, Some("center"), _) => Some(r.center().x),
|
||||
@@ -1461,11 +1487,12 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>
|
||||
(false, _, Some("bottom")) => Some(r.y1),
|
||||
_ => None,
|
||||
};
|
||||
let skip_key = to == Some("key");
|
||||
Ok(st
|
||||
.selection
|
||||
.guides
|
||||
.iter()
|
||||
.filter(|&&i| Some(i) != st.selection.key_guide)
|
||||
.filter(|&&i| !skip_key || Some(i) != st.selection.key_guide)
|
||||
.filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?)))
|
||||
.collect())
|
||||
}
|
||||
@@ -1626,6 +1653,7 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64
|
||||
|
||||
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["patterns"], "object.distribute")?;
|
||||
check_numbers(p, &["artboard"], "object.distribute")?;
|
||||
let ids = selected_roots(s)?;
|
||||
let preview = preview_bounds(s, p, "object.distribute")?;
|
||||
let board = distribute_to(s, p, &ids, preview, "object.distribute")?;
|
||||
@@ -1700,6 +1728,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["patterns"], "object.distributeSpacing")?;
|
||||
check_numbers(p, &["artboard"], "object.distributeSpacing")?;
|
||||
let ids = selected_roots(s)?;
|
||||
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
|
||||
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
|
||||
@@ -1786,7 +1815,7 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(
|
||||
/// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the
|
||||
/// art with a width or height below `least` of the one it has keeps `least` of it.
|
||||
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> {
|
||||
check_numbers(p, &["x", "y", "width", "height"], "object.setBounds")?;
|
||||
check_numbers(p, &["x", "y", "width", "height", "reference"], "object.setBounds")?;
|
||||
let st = s.doc()?;
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?;
|
||||
check_switches(p, &["proportional"], "object.setBounds")?;
|
||||
@@ -2321,7 +2350,8 @@ fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
|
||||
}
|
||||
|
||||
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["invertPie", "makeSidesEqual"], "object.setLiveShape")?;
|
||||
check_switches(p, &["invertPie", "makeSidesEqual", "relativeRadii"], "object.setLiveShape")?;
|
||||
check_numbers(p, &["radius", "sides"], "object.setLiveShape")?;
|
||||
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
|
||||
let items = match p.get("items").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
|
||||
@@ -351,6 +351,7 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
||||
};
|
||||
}
|
||||
if let Some(g) = p.get("gradient") {
|
||||
check_numbers(g, &["angle"], "paint")?;
|
||||
// A stop naming a process swatch keeps the swatch's model.
|
||||
let stored = super::gradient::stop_colors(s, p, g);
|
||||
let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?;
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -72,6 +72,7 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// copy commands: by default the artboards the Artboard tool has selected (its active one when none
|
||||
/// of them is in the document) and its Move/Copy Artwork with Artboard option.
|
||||
fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<usize>, bool)> {
|
||||
check_switches(p, &["art"], cmd)?;
|
||||
let t = s.tool_options_of("artboard");
|
||||
let d = &s.doc()?.doc;
|
||||
let indices = if p.get("index").is_some() || p.get("indices").is_some() {
|
||||
@@ -159,6 +160,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
|
||||
/// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep
|
||||
/// their places relative to each other, together right of the last artboard.
|
||||
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["patterns"], "artboard.duplicate")?;
|
||||
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
|
||||
let arts = artboards_art(s, &indices, art, false)?;
|
||||
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
|
||||
@@ -327,6 +329,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"direction",
|
||||
"leadingModel",
|
||||
"charAlign",
|
||||
"charDirection",
|
||||
"proportionalMetrics",
|
||||
"tateChuYoko",
|
||||
"noBreak",
|
||||
@@ -350,6 +353,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
|
||||
let char_align = super::textedit::char_align_param(p, C)?;
|
||||
let char_direction = super::textedit::char_direction_param(p, C)?;
|
||||
let digits = super::textedit::digits_param(p, C)?;
|
||||
let font_axes = super::textedit::font_axes_param(p, C)?;
|
||||
let mojikumi = match p.get("mojikumi") {
|
||||
@@ -460,6 +464,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = char_align {
|
||||
st.char_align = v;
|
||||
}
|
||||
if let Some(v) = char_direction {
|
||||
st.char_direction = v;
|
||||
}
|
||||
if let Some(v) = digits {
|
||||
st.digits = v;
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => None,
|
||||
Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?),
|
||||
};
|
||||
let independent = bool_or(p, "independent", false);
|
||||
let independent = switch_or(p, "independent", false, "path.close")?;
|
||||
s.edit("Close Path", |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
|
||||
@@ -182,6 +182,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?);
|
||||
check_switches(p, &["patterns"], "path.moveAnchors")?;
|
||||
// Objects selected whole move as objects, and their pattern tiles move with them when
|
||||
// `patterns` (default: General › Transform Pattern Tiles) is on.
|
||||
let whole = s.doc()?.selection.whole();
|
||||
@@ -232,7 +233,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
|
||||
let out = str_param(p, "which") != Some("in");
|
||||
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
|
||||
let independent = bool_or(p, "independent", false);
|
||||
let independent = switch_or(p, "independent", false, "path.setHandle")?;
|
||||
s.edit("Reshape", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||
|
||||
@@ -138,6 +138,7 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.new";
|
||||
let mode = str_param(p, "mode");
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
check_switches(p, &["spot", "global"], C)?;
|
||||
let paints = match (p.get("colors").and_then(Value::as_array), str_param(p, "pattern")) {
|
||||
(Some(cs), _) => {
|
||||
let stored = |c: Color| if mode.is_some() { c } else { super::paint::new_color(s, p, c) };
|
||||
@@ -229,6 +230,7 @@ pub(super) fn names_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.delete";
|
||||
let names = names_param(p, C)?;
|
||||
check_switches(p, &["unlink"], C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
// Global swatches going away (named ones and those in named groups): their links are dropped.
|
||||
let mut global: Vec<String> = vec![];
|
||||
@@ -304,6 +306,7 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.newGroup";
|
||||
let mut names = str_list(p, "swatches");
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
check_switches(p, &["fromArtwork", "toGlobal", "includeTints"], C)?;
|
||||
let colors: Vec<Color> = p
|
||||
.get("colors")
|
||||
.and_then(Value::as_array)
|
||||
@@ -365,6 +368,7 @@ fn insert_before<T>(list: &mut Vec<T>, items: Vec<T>, anchor: Option<&str>, name
|
||||
|
||||
fn swatch_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.move";
|
||||
check_numbers(p, &["to"], C)?;
|
||||
let names = names_param(p, C)?;
|
||||
let to = p.get("to").and_then(Value::as_u64).map(index);
|
||||
let group = str_param(p, "group").map(str::to_string);
|
||||
@@ -530,6 +534,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if sw.is_reserved() {
|
||||
return Err(bad(C, format!("`{name}` can't be edited")));
|
||||
}
|
||||
check_switches(p, &["spot", "global"], C)?;
|
||||
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
|
||||
let solid = sw.paint.color();
|
||||
if solid.is_none() && (given("color") || given("mode") || bool_or(p, "spot", false)) {
|
||||
@@ -649,8 +654,8 @@ fn swatch_sort(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn swatch_add_used(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.addUsedColors";
|
||||
let global = bool_or(p, "global", false);
|
||||
let only_selection = bool_or(p, "selection", false);
|
||||
let global = switch_or(p, "global", false, C)?;
|
||||
let only_selection = switch_or(p, "selection", false, C)?;
|
||||
let (added, linked) = s.edit("Add Used Colors", |d, sel| {
|
||||
let ids: Vec<NodeId> = if only_selection { sel.objects.clone() } else { d.layers.iter().map(|l| l.id).collect() };
|
||||
if only_selection && ids.is_empty() {
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some("Cmd+Shift+."),
|
||||
"{by?: steps (1)} type.step {attribute: size}",
|
||||
has_selection,
|
||||
|s, p| type_step(s, &json!({"attribute": "size", "by": f64_or(p, "by", 1.0)}))
|
||||
|s, p| type_step(s, &json!({"attribute": "size", "by": number_or(p, "by", 1.0, "type.size.increase")?}))
|
||||
),
|
||||
cmd!(
|
||||
"type.size.decrease",
|
||||
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some("Cmd+Shift+,"),
|
||||
"{by?: steps (1)} type.step {attribute: size, by: -by}",
|
||||
has_selection,
|
||||
|s, p| type_step(s, &json!({"attribute": "size", "by": -f64_or(p, "by", 1.0)}))
|
||||
|s, p| type_step(s, &json!({"attribute": "size", "by": -number_or(p, "by", 1.0, "type.size.decrease")?}))
|
||||
),
|
||||
cmd!(
|
||||
"type.size.increaseByFive",
|
||||
@@ -179,10 +179,13 @@ fn byte_param(p: &Value, k: &str) -> Option<usize> {
|
||||
p.get(k).and_then(Value::as_u64).map(index)
|
||||
}
|
||||
|
||||
fn range_of(p: &Value, len: usize) -> (usize, usize) {
|
||||
/// The bytes `start..end` of `p` in text of `len` bytes, clamped and ordered (without them, all of
|
||||
/// it). A `start` or `end` that is not a number or null, such as `"2"`, is an error.
|
||||
fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> {
|
||||
check_numbers(p, &["start", "end"], cmd)?;
|
||||
let a = byte_param(p, "start").unwrap_or(0).min(len);
|
||||
let b = byte_param(p, "end").unwrap_or(len).min(len);
|
||||
(a.min(b), a.max(b))
|
||||
Ok((a.min(b), a.max(b)))
|
||||
}
|
||||
|
||||
fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -190,7 +193,7 @@ fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let t = text_ref(s, id)?;
|
||||
let len = edit::runs_len(&t.runs);
|
||||
let (a, b) = range_of(p, len);
|
||||
let (a, b) = range_of(p, len, C)?;
|
||||
let styled: Option<Vec<TextRun>> = match p.get("runs") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(C, format!("bad `runs`: {e}")))?),
|
||||
@@ -221,9 +224,9 @@ fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?;
|
||||
let t = text_ref(s, id)?;
|
||||
let len = edit::runs_len(&t.runs);
|
||||
let (a, b) = range_of(p, len);
|
||||
let (a, b) = range_of(p, len, "text.getRange")?;
|
||||
let mut runs = edit::slice_runs(&t.runs, a, b);
|
||||
if p.get("effective").and_then(Value::as_bool).unwrap_or(false) {
|
||||
if switch_or(p, "effective", false, "text.getRange")? {
|
||||
for run in &mut runs {
|
||||
run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?;
|
||||
}
|
||||
@@ -256,6 +259,7 @@ pub(crate) struct CharChange {
|
||||
position: Option<vectorcraft_doc::CharPosition>,
|
||||
small_caps: Option<Option<f64>>,
|
||||
char_align: Option<vectorcraft_doc::CharAlign>,
|
||||
char_direction: Option<Option<vectorcraft_doc::ParaDirection>>,
|
||||
proportional_metrics: Option<bool>,
|
||||
tate_chu_yoko: Option<bool>,
|
||||
no_break: Option<bool>,
|
||||
@@ -263,6 +267,19 @@ pub(crate) struct CharChange {
|
||||
font_axes: Option<AxisChange>,
|
||||
}
|
||||
|
||||
/// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None`
|
||||
/// inside: Default).
|
||||
pub(crate) fn char_direction_param(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_doc::ParaDirection>>> {
|
||||
use vectorcraft_doc::ParaDirection;
|
||||
let Some(v) = p.get("charDirection").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
Ok(Some(match v.as_str() {
|
||||
Some("default") => None,
|
||||
Some("leftToRight") => Some(ParaDirection::LeftToRight),
|
||||
Some("rightToLeft") => Some(ParaDirection::RightToLeft),
|
||||
_ => return Err(bad(cmd, "`charDirection` must be \"default\", \"leftToRight\" or \"rightToLeft\"")),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in.
|
||||
pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::Digits>> {
|
||||
use vectorcraft_doc::Digits;
|
||||
@@ -384,6 +401,8 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> {
|
||||
impl CharChange {
|
||||
/// `setup` gives the superscript, subscript and small caps proportions.
|
||||
pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> {
|
||||
check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
|
||||
check_switches(p, &["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "noBreak"], cmd)?;
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let (position, small_caps) = super::docsetup::script_params(p, setup, cmd)?;
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
@@ -416,6 +435,7 @@ impl CharChange {
|
||||
position,
|
||||
small_caps,
|
||||
char_align: char_align_param(p, cmd)?,
|
||||
char_direction: char_direction_param(p, cmd)?,
|
||||
proportional_metrics: flag("proportionalMetrics"),
|
||||
tate_chu_yoko: flag("tateChuYoko"),
|
||||
no_break: flag("noBreak"),
|
||||
@@ -459,6 +479,7 @@ impl CharChange {
|
||||
&& self.position.is_none()
|
||||
&& self.small_caps.is_none()
|
||||
&& self.char_align.is_none()
|
||||
&& self.char_direction.is_none()
|
||||
&& self.proportional_metrics.is_none()
|
||||
&& self.tate_chu_yoko.is_none()
|
||||
&& self.no_break.is_none()
|
||||
@@ -554,6 +575,9 @@ impl CharChange {
|
||||
if let Some(v) = self.char_align {
|
||||
st.char_align = v;
|
||||
}
|
||||
if let Some(v) = self.char_direction {
|
||||
st.char_direction = v;
|
||||
}
|
||||
if let Some(v) = self.proportional_metrics {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
@@ -633,7 +657,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some());
|
||||
let t = text_ref(s, id)?;
|
||||
change.localize(&t, C)?;
|
||||
let (a, b) = range_of(p, edit::runs_len(&t.runs));
|
||||
let (a, b) = range_of(p, edit::runs_len(&t.runs), C)?;
|
||||
let n = s.edit("Character", |d, _| {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
if a == b && !t.plain_text().is_empty() {
|
||||
@@ -663,6 +687,7 @@ fn glyph_em(t: &TextObject, g: &vectorcraft_text::PositionedGlyph) -> Option<f64
|
||||
fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.touchType";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
check_numbers(p, &["start"], C)?;
|
||||
let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?;
|
||||
if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad(C, "nothing to change: give dx, dy, hScale, vScale or rotation"));
|
||||
@@ -847,6 +872,8 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "area type needs a path that encloses an area"));
|
||||
}
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
check_numbers(p, &["size"], C)?;
|
||||
check_switches(p, &["vertical", "placeholder"], C)?;
|
||||
let style = super::create::new_type_style(s, p);
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
let start = match point_param(p, "at") {
|
||||
@@ -933,7 +960,7 @@ fn type_ranges(
|
||||
) -> Result<Vec<(NodeId, usize, usize, bool)>> {
|
||||
// (text, its range; None: all of it).
|
||||
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match id_param(p, "id") {
|
||||
Some(id) => vec![(id, Some(range_of(p, edit::runs_len(&text_ref(s, id)?.runs))))],
|
||||
Some(id) => vec![(id, Some(range_of(p, edit::runs_len(&text_ref(s, id)?.runs), c)?))],
|
||||
None => {
|
||||
let editing = editing_range(s)?.filter(|_| p.get("ids").filter(|v| !v.is_null()).is_none());
|
||||
match editing {
|
||||
@@ -971,7 +998,7 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("baselineShift") => Step::BaselineShift,
|
||||
_ => return Err(bad(C, "attribute must be size|leading|tracking|kerning|baselineShift")),
|
||||
};
|
||||
let by = f64_or(p, "by", 1.0);
|
||||
let by = number_or(p, "by", 1.0, C)?;
|
||||
if !by.is_finite() {
|
||||
return Err(bad(C, "by must be a number"));
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
("position", json!(v.position)),
|
||||
("small_caps", json!(v.small_caps)),
|
||||
("charAlign", json!(v.char_align)),
|
||||
("charDirection", json!(v.char_direction)),
|
||||
("proportionalMetrics", json!(v.proportional_metrics)),
|
||||
("tateChuYoko", json!(v.tate_chu_yoko)),
|
||||
("noBreak", json!(v.no_break)),
|
||||
|
||||
@@ -150,7 +150,7 @@ fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(NodeKind::Text(t)) => Some(t.optical_margin),
|
||||
_ => None,
|
||||
});
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!first.unwrap_or(false));
|
||||
let on = switch_or(p, "on", !first.unwrap_or(false), "type.opticalMarginAlignment")?;
|
||||
s.edit("Optical Margin Alignment", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
|
||||
@@ -168,7 +168,7 @@ fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// setting) on the document and every text object, in one undo step. Off is the default.
|
||||
fn system_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let current = s.doc()?.doc.setup.system_layout;
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!current);
|
||||
let on = switch_or(p, "on", !current, "type.systemLayout")?;
|
||||
s.edit("System Layout", |d, _| {
|
||||
d.setup.system_layout = on;
|
||||
let mut ids = vec![];
|
||||
@@ -198,7 +198,7 @@ fn fractional_widths(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(NodeKind::Text(t)) => Some(t.fractional_widths),
|
||||
_ => None,
|
||||
});
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!first.unwrap_or(true));
|
||||
let on = switch_or(p, "on", !first.unwrap_or(true), "type.fractionalWidths")?;
|
||||
s.edit("Fractional Widths", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
|
||||
@@ -219,7 +219,7 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.ruby";
|
||||
let ids = text_targets(s, p, C)?;
|
||||
let range = TextRange::parse(p, C)?;
|
||||
let remove = p.get("remove").and_then(Value::as_bool).unwrap_or(false);
|
||||
let remove = switch_or(p, "remove", false, C)?;
|
||||
let reading = str_param(p, "text").map(str::trim).filter(|t| !t.is_empty()).map(str::to_string);
|
||||
if !remove && reading.is_none() {
|
||||
return Err(bad(C, "give `text` (the reading) or `remove`"));
|
||||
@@ -235,11 +235,11 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("end") | Some("right") | Some("bottom") => RubyAlign::End,
|
||||
Some(_) => return Err(bad(C, "`align` is start, center or end")),
|
||||
};
|
||||
let size = p.get("size").and_then(Value::as_f64).unwrap_or(50.0);
|
||||
let size = number_or(p, "size", 50.0, C)?;
|
||||
if !size.is_finite() || !(1.0..=100.0).contains(&size) {
|
||||
return Err(bad(C, "`size` is a percentage of the base text's size, 1 to 100"));
|
||||
}
|
||||
let overhang = p.get("overhang").and_then(Value::as_bool).unwrap_or(true);
|
||||
let overhang = switch_or(p, "overhang", true, C)?;
|
||||
let mut first: Option<Value> = None;
|
||||
s.edit("Ruby", |d, _| {
|
||||
for id in &ids {
|
||||
@@ -890,6 +890,7 @@ fn size_area(t: &mut TextObject, w: Option<f64>, h: Option<f64>) -> bool {
|
||||
/// `fitMinPercent`), starting from `cur`. None when `p` sets neither (or only `fitMinPercent`
|
||||
/// while the fit isn't Shrink Text).
|
||||
pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result<Option<AreaFit>> {
|
||||
check_numbers(p, &["fitMinPercent"], c)?;
|
||||
let min = p.get("fitMinPercent").and_then(Value::as_f64);
|
||||
let cur_min = match cur {
|
||||
AreaFit::ShrinkText { min_percent } => Some(min_percent),
|
||||
@@ -942,6 +943,7 @@ const AREA_KEYS: [&str; 7] = ["rows", "columns", "gutter", "inset", "firstBaseli
|
||||
|
||||
fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.areaOptions";
|
||||
check_numbers(p, &["width", "height"], C)?;
|
||||
let ids: Vec<NodeId> = {
|
||||
let d = &s.doc()?.doc;
|
||||
text_targets(s, p, C)?.into_iter().filter(|i| area_text(d.node(*i)).is_some()).collect()
|
||||
@@ -1236,8 +1238,10 @@ mod area_tests {
|
||||
// New type inherits the document's setting.
|
||||
let made = s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Z"})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(text(&s, made).system_layout);
|
||||
// A boolean given as text is ignored, so the setting toggles off.
|
||||
assert_eq!(s.execute("type.systemLayout", &json!({"on": "no"})).unwrap()["on"], false);
|
||||
// A boolean given as text is an error, and the setting stays on.
|
||||
assert!(s.execute("type.systemLayout", &json!({"on": "no"})).is_err());
|
||||
assert!(setup(&s).system_layout && text(&s, id).system_layout);
|
||||
assert_eq!(s.execute("type.systemLayout", &json!({"on": false})).unwrap()["on"], false);
|
||||
assert!(!setup(&s).system_layout && !text(&s, id).system_layout && !text(&s, made).system_layout);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(setup(&s).system_layout && text(&s, id).system_layout);
|
||||
@@ -1282,8 +1286,10 @@ mod area_tests {
|
||||
assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}");
|
||||
assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}");
|
||||
assert_eq!(serde_json::to_value(text(&s)).unwrap()["fractionalWidths"], false);
|
||||
// A boolean given as text is ignored, so the flag toggles back on.
|
||||
assert_eq!(s.execute("type.fractionalWidths", &json!({"on": "no"})).unwrap()["on"], true);
|
||||
// A boolean given as text is an error, and the flag stays off.
|
||||
assert!(s.execute("type.fractionalWidths", &json!({"on": "no"})).is_err());
|
||||
assert!(!text(&s).fractional_widths);
|
||||
assert_eq!(s.execute("type.fractionalWidths", &json!({})).unwrap()["on"], true);
|
||||
assert!(text(&s).fractional_widths);
|
||||
assert_eq!(s.execute("type.fractionalWidths", &json!({"on": false})).unwrap()["on"], false);
|
||||
assert!(!text(&s).fractional_widths);
|
||||
@@ -1599,6 +1605,47 @@ mod area_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Character Direction (Character panel menu, Middle Eastern options): a character attribute
|
||||
/// that `text.setFormat` and `text.setRangeStyle` set in one undo step each, saved only when
|
||||
/// set and kept by a native save.
|
||||
#[test]
|
||||
fn character_direction_is_a_character_attribute_saved_only_when_set() {
|
||||
use vectorcraft_doc::ParaDirection;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap();
|
||||
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.runs.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let set = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::<Vec<_>>();
|
||||
assert_eq!(runs(&s)[0].style.char_direction, None);
|
||||
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charDirection").is_none());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
for (key, want) in [("rightToLeft", Some(ParaDirection::RightToLeft)), ("leftToRight", Some(ParaDirection::LeftToRight)), ("default", None)] {
|
||||
s.execute("text.setFormat", &json!({"charDirection": key})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.char_direction == want));
|
||||
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
|
||||
assert_eq!(saved.get("charDirection").and_then(|v| v.as_str()), want.is_some().then_some(key));
|
||||
}
|
||||
let err = s.execute("text.setFormat", &json!({"charDirection": "up"})).unwrap_err().to_string();
|
||||
assert!(err.contains("`charDirection` must be"), "{err}");
|
||||
// Characters selected with the Type tool: only the range takes it (שלום and the space are 9 bytes).
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap();
|
||||
assert_eq!(set(&s), [("שלום ".to_string(), None), ("21 10 2026".to_string(), Some(ParaDirection::LeftToRight))]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.char_direction.is_none()));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
// Saved and reopened as it was.
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect();
|
||||
assert_eq!(reopened, set(&s));
|
||||
// A character style captured from the characters carries it, Default included.
|
||||
assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["charDirection"], "leftToRight");
|
||||
assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style).get("charDirection"), Some(&serde_json::Value::Null));
|
||||
}
|
||||
|
||||
/// Proportional Metrics (#966): a character attribute, saved only when on.
|
||||
#[test]
|
||||
fn proportional_metrics_is_a_character_attribute_saved_only_when_on() {
|
||||
|
||||
@@ -368,6 +368,8 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.move")?;
|
||||
check_numbers(p, &["dx", "dy"], "artboard.move")?;
|
||||
check_switches(p, &["moveArt", "copy", "patterns"], "artboard.move")?;
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let move_art = bool_or(p, "moveArt", false);
|
||||
let copy = bool_or(p, "copy", false);
|
||||
|
||||
@@ -252,6 +252,7 @@ fn document_base(s: &Session) -> Value {
|
||||
let Some(st) = s.active() else { return Value::Null };
|
||||
let d = &st.doc;
|
||||
json!({
|
||||
"uid": st.uid,
|
||||
"title": st.title(),
|
||||
"path": st.path,
|
||||
"dirty": st.is_dirty(),
|
||||
|
||||
@@ -531,6 +531,29 @@ fn align_and_distribute_refuse_a_boolean_given_as_text() {
|
||||
assert_eq!(state(&s).1, [0.0, 0.0]);
|
||||
}
|
||||
|
||||
/// Align, Distribute and Distribute Spacing to an artboard refuse an `artboard` given as text or as
|
||||
/// another value that is not a number, naming the key: nothing moves and no undo step is recorded.
|
||||
/// A null is read as the key left out.
|
||||
#[test]
|
||||
fn align_and_distribute_refuse_an_artboard_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0);
|
||||
let c = rect(&mut s, 70.0, 60.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
|
||||
let calls = [
|
||||
("object.align", json!({"horizontal": "right", "to": "artboard", "artboard": "0"})),
|
||||
("object.distribute", json!({"horizontal": "left", "to": "artboard", "artboard": [0]})),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal", "to": "artboard", "artboard": true})),
|
||||
]
|
||||
.map(|(cmd, p)| not_a_number(cmd, p, "artboard"));
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
s.execute("object.align", &json!({"horizontal": "right", "to": "artboard", "artboard": null})).unwrap();
|
||||
let right = |id: NodeId| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x1;
|
||||
assert_eq!([right(a), right(b), right(c)], [s.doc().unwrap().doc.artboards[0].rect.x1; 3]);
|
||||
}
|
||||
|
||||
/// Align, Distribute and Distribute Spacing return an error that lists the choices of a param
|
||||
/// given a value outside them: nothing moves and no undo step is recorded. A null value counts as
|
||||
/// not given, and Distribute Spacing without an `axis` spaces the objects horizontally.
|
||||
@@ -808,6 +831,32 @@ pub(crate) fn history(s: &Session) -> (usize, usize, bool) {
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
}
|
||||
|
||||
/// A call to `cmd` with `p` that must fail with "`key` must be true or false".
|
||||
pub(crate) fn not_a_switch(cmd: &str, p: serde_json::Value, key: &str) -> (String, serde_json::Value, String) {
|
||||
(cmd.to_string(), p, format!("`{key}` must be true or false"))
|
||||
}
|
||||
|
||||
/// A call to `cmd` with `p` that must fail with "`key` must be a number, not <value>", the value
|
||||
/// `p` gives for `key`.
|
||||
pub(crate) fn not_a_number(cmd: &str, p: serde_json::Value, key: &str) -> (String, serde_json::Value, String) {
|
||||
let msg = format!("`{key}` must be a number, not {}", p[key]);
|
||||
(cmd.to_string(), p, msg)
|
||||
}
|
||||
|
||||
/// The calls among `calls` (a command, its params and the error it must give) that do not fail
|
||||
/// with that error, or that change the document or its history, as "command params".
|
||||
pub(crate) fn not_refused(s: &mut Session, calls: &[(String, serde_json::Value, String)]) -> Vec<String> {
|
||||
calls
|
||||
.iter()
|
||||
.filter(|(cmd, p, want)| {
|
||||
let (before, h) = (s.doc().unwrap().doc.clone(), history(s));
|
||||
let r = s.execute(cmd, p);
|
||||
!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want) && s.doc().unwrap().doc == before && history(s) == h)
|
||||
})
|
||||
.map(|(cmd, p, _)| format!("{cmd} {p}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Select `ids` with a move of them left to redo, and mark the document saved.
|
||||
pub(crate) fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
@@ -1132,6 +1181,99 @@ fn a_boolean_given_as_text_for_a_transform_is_an_error() {
|
||||
assert_eq!((b.width(), w), (100.0, before.2));
|
||||
}
|
||||
|
||||
/// Object Properties' opacity, Live Shape Properties' corner radius and polygon sides, and the
|
||||
/// Transform panel's reference point given as text or as another value that is not a number are an
|
||||
/// error naming the key, and so is Live Shape Properties' relative rounding given as anything but
|
||||
/// true or false; the objects and the undo history stay as they were. Numbers and null still work.
|
||||
#[test]
|
||||
fn object_properties_live_shapes_and_bounds_refuse_a_number_or_boolean_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
let poly = NodeId(s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 20})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let mut calls = [
|
||||
("object.setProps", json!({"id": a.0, "opacity": "50"}), "opacity"),
|
||||
("object.setLiveShape", json!({"ids": [a.0], "radius": "5"}), "radius"),
|
||||
("object.setLiveShape", json!({"ids": [poly.0], "sides": "8"}), "sides"),
|
||||
("object.setBounds", json!({"width": 40, "reference": "0"}), "reference"),
|
||||
]
|
||||
.map(|(cmd, p, key)| not_a_number(cmd, p, key))
|
||||
.to_vec();
|
||||
calls.push(not_a_switch("object.setLiveShape", json!({"ids": [a.0], "radius": 5, "relativeRadii": "true"}), "relativeRadii"));
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
s.execute("object.setProps", &json!({"id": a.0, "opacity": 50})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"width": 40, "reference": 0, "x": null})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
|
||||
assert_eq!((n.opacity, n.geometric_bounds().unwrap()), (0.5, vectorcraft_geom::Rect::new(0.0, 0.0, 40.0, 20.0)));
|
||||
}
|
||||
|
||||
/// The shape tools' commands and the Pen's `path.create` refuse a number or an on/off option given
|
||||
/// as text or as another value of the wrong kind, naming the key, and draw nothing; null is read as
|
||||
/// the key left out.
|
||||
#[test]
|
||||
fn shape_commands_refuse_a_number_or_boolean_given_as_text() {
|
||||
let mut s = session();
|
||||
let star = json!({"cx": 100, "cy": 100, "radius1": 20});
|
||||
let flare = json!({"cx": 100, "cy": 100});
|
||||
let spiral = json!({"cx": 100, "cy": 100, "radius": 40});
|
||||
let with = |base: &serde_json::Value, key: &str, v: serde_json::Value| {
|
||||
let mut p = base.clone();
|
||||
p[key] = v;
|
||||
p
|
||||
};
|
||||
let mut calls: Vec<_> = [
|
||||
("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "radius": "2"}), "radius"),
|
||||
("shape.polygon", json!({"cx": 100, "cy": 100, "radius": 20, "rotation": "45"}), "rotation"),
|
||||
("shape.polygon", json!({"cx": 100, "cy": 100, "radius": 20, "sides": "8"}), "sides"),
|
||||
("shape.star", with(&star, "radius2", json!("10")), "radius2"),
|
||||
("shape.star", with(&star, "rotation", json!([45])), "rotation"),
|
||||
("shape.star", with(&star, "points", json!("7")), "points"),
|
||||
("shape.flare", with(&flare, "diameter", json!("100")), "diameter"),
|
||||
("shape.flare", with(&flare, "opacity", json!("50")), "opacity"),
|
||||
("shape.flare", with(&flare, "largest", json!(true)), "largest"),
|
||||
("shape.flare", with(&flare, "rays", json!("15")), "rays"),
|
||||
("shape.flare", json!({"cx": 100, "cy": 100, "x2": "300", "y2": 300}), "x2"),
|
||||
("shape.flare", with(&flare, "direction", json!("45")), "direction"),
|
||||
("shape.flare", with(&flare, "seed", json!("7")), "seed"),
|
||||
("shape.spiral", with(&spiral, "decay", json!("80")), "decay"),
|
||||
("shape.spiral", with(&spiral, "segments", json!("10")), "segments"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(cmd, p, key)| not_a_number(cmd, p, key))
|
||||
.collect();
|
||||
calls.extend(
|
||||
[
|
||||
("shape.spiral", with(&spiral, "clockwise", json!("false")), "clockwise"),
|
||||
("shape.arc", json!({"x1": 0, "y1": 0, "x2": 50, "y2": 50, "closed": "true"}), "closed"),
|
||||
("path.create", json!({"anchors": [{"x": 0, "y": 0}, {"x": 50, "y": 0}], "closed": 1}), "closed"),
|
||||
]
|
||||
.map(|(cmd, p, key)| not_a_switch(cmd, p, key)),
|
||||
);
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
s.execute("shape.star", &json!({"cx": 100, "cy": 100, "radius1": 20, "radius2": null, "points": null, "rotation": null})).unwrap();
|
||||
s.execute("shape.flare", &json!({"cx": 100, "cy": 100, "diameter": 50, "x2": null, "seed": 7})).unwrap();
|
||||
s.execute("shape.arc", &json!({"x1": 0, "y1": 0, "x2": 50, "y2": 50, "closed": false})).unwrap();
|
||||
}
|
||||
|
||||
/// `path.create` given path data (`d`) refuses a `closed` other than true, false or null, as it does
|
||||
/// with `anchors`, and draws nothing; with true, false or null it draws the path data, which alone
|
||||
/// decides whether the path is closed.
|
||||
#[test]
|
||||
fn path_create_with_path_data_refuses_closed_given_as_text() {
|
||||
let mut s = session();
|
||||
let d = "M0 0 L10 0 L10 10";
|
||||
let calls = [json!("true"), json!(1), json!([true])].map(|v| not_a_switch("path.create", json!({"d": d, "closed": v}), "closed"));
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
for closed in [json!(true), json!(false), json!(null)] {
|
||||
let id = NodeId(s.execute("path.create", &json!({"d": d, "closed": closed})).unwrap()["id"].as_u64().unwrap());
|
||||
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a path") };
|
||||
assert_eq!((path.subpaths.len(), path.anchor_count(), path.is_closed()), (1, 3, false), "closed: {closed}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1824: with anchors selected with Direct Selection, the Transform panel's X/Y/W/H
|
||||
/// (`object.setBounds`) are the box around those anchors, and only they move or scale.
|
||||
#[test]
|
||||
|
||||
@@ -420,6 +420,32 @@ fn appearance_commands_that_change_nothing_record_no_undo_step() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Appearance panel's commands refuse a weight or opacity that is not a number and an on/off
|
||||
/// option that is not true or false, given as text or as another value of the wrong kind; the
|
||||
/// objects and the undo history stay as they were. Numbers, true, false and null still work.
|
||||
#[test]
|
||||
fn appearance_commands_refuse_a_number_or_boolean_given_as_text() {
|
||||
use super::tests::{not_a_number, not_a_switch, not_refused};
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 50.0);
|
||||
let b = rect(&mut s, 200.0);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let calls = [
|
||||
not_a_number("appearance.setItem", json!({"index": 1, "weight": "3"}), "weight"),
|
||||
not_a_number("appearance.setItem", json!({"index": 0, "opacity": "50"}), "opacity"),
|
||||
not_a_switch("appearance.setItem", json!({"index": 0, "visible": "false"}), "visible"),
|
||||
not_a_switch("appearance.transfer", json!({"source": b.0, "target": a.0, "copy": "true"}), "copy"),
|
||||
not_a_switch("appearance.copyFrom", json!({"source": b.0, "ids": [a.0], "reverse": "true"}), "reverse"),
|
||||
not_a_switch("appearance.copyFrom", json!({"source": b.0, "ids": [a.0], "append": 1}), "append"),
|
||||
];
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
run(&mut s, "appearance.setItem", json!({"index": 1, "weight": 3, "visible": null}));
|
||||
run(&mut s, "appearance.transfer", json!({"source": b.0, "target": a.0, "copy": true}));
|
||||
assert_eq!((stroke(&node(&s, a), 1).width, fill_paint(&node(&s, a), 0)), (stroke(&node(&s, b), 1).width, fill_paint(&node(&s, b), 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_style_apply_replaces_the_stack() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -153,3 +153,79 @@ fn start_points_round_trip_and_old_blends_load() {
|
||||
assert!(old.starts.is_empty() && old.start(0).is_none());
|
||||
assert!(serde_json::to_value(&old).unwrap().get("starts").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn easing_is_set_validated_saved_and_old_blends_are_even() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("object.blend.options", &json!({"ramp": 50})).is_err());
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
|
||||
sel(&mut s, &[a, b]);
|
||||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "easingType": "easeIn", "ramp": 40})).unwrap());
|
||||
assert_eq!(spec(&s, g).1.ease.ramp, 0.4);
|
||||
sel(&mut s, &[g]);
|
||||
for kind in ["easeIn", "easeOut", "easeInOut", "easeOutIn"] {
|
||||
s.execute("object.blend.options", &json!({"easingType": kind, "colorShift": -50})).unwrap();
|
||||
let info = s.execute("object.blend.info", &json!({})).unwrap();
|
||||
assert_eq!((&info["easingType"], &info["ramp"], &info["colorShift"]), (&json!(kind), &json!(40.0), &json!(-50.0)));
|
||||
}
|
||||
let before = spec(&s, g).1;
|
||||
let depth = s.doc().unwrap().history.undo.len();
|
||||
for bad in [json!({"ramp": 101}), json!({"ramp": -1}), json!({"colorShift": "x"}), json!({"colorShift": 1e308}), json!({"easingType": "unknown"})]
|
||||
{
|
||||
assert!(s.execute("object.blend.options", &bad).is_err(), "{bad}");
|
||||
assert_eq!(spec(&s, g).1, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), depth);
|
||||
}
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
|
||||
assert_eq!(&back.layers, &d.layers);
|
||||
s.execute("object.blend.options", &json!({"ramp": 0})).unwrap();
|
||||
let zero = spec(&s, g).1;
|
||||
let back: BlendSpec = serde_json::from_value(serde_json::to_value(&zero).unwrap()).unwrap();
|
||||
assert_eq!(back.ease, zero.ease, "a chosen Type survives at Ramp 0");
|
||||
let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}})).unwrap();
|
||||
assert!(old.ease.is_linear() && old.color_shift == 0.0);
|
||||
let v = serde_json::to_value(&old).unwrap();
|
||||
assert!(v.get("ease").is_none() && v.get("colorShift").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_with_bad_blend_easing_numbers_opens_clamped_while_commands_refuse() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
|
||||
sel(&mut s, &[a, b]);
|
||||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "easingType": "easeOut", "ramp": 40, "colorShift": -50})).unwrap());
|
||||
let text = String::from_utf8(vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let blend_of = |doc: &vectorcraft_doc::Document| match doc.node(g).unwrap().kind.clone() {
|
||||
NodeKind::Blend { spec, .. } => *spec,
|
||||
_ => panic!("not a blend"),
|
||||
};
|
||||
for (ramp, shift, want) in [("12.5", "-9000.0", (1.0, -100.0)), ("-3.0", "250.0", (0.0, 100.0)), ("null", "\"NaN\"", (0.0, 0.0))] {
|
||||
let t = text.replacen("\"ramp\":0.4", &format!("\"ramp\":{ramp}"), 1).replacen("\"colorShift\":-50.0", &format!("\"colorShift\":{shift}"), 1);
|
||||
assert!(t != text && t.contains("\"ramp\":") && t.contains(shift), "the saved blend carries ramp and colorShift");
|
||||
let doc = vectorcraft_format::load(t.as_bytes()).expect("one bad blend number must not fail the document");
|
||||
let spec = blend_of(&doc);
|
||||
assert_eq!((spec.ease.ramp, spec.color_shift), want, "{ramp} / {shift}");
|
||||
}
|
||||
// The commands still refuse the same values and leave the blend alone.
|
||||
sel(&mut s, &[g]);
|
||||
let before = spec(&s, g).1;
|
||||
for bad in [json!({"ramp": 1250}), json!({"ramp": -3}), json!({"colorShift": 250}), json!({"colorShift": "NaN"}), json!({"colorShift": 1e308})] {
|
||||
assert!(s.execute("object.blend.options", &bad).is_err(), "{bad}");
|
||||
assert_eq!(spec(&s, g).1, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_blend_easing_values_clamp_and_non_finite_become_default() {
|
||||
for (bad, ramp, shift) in [
|
||||
(json!({"ease": {"ramp": 1e308}}), 1.0, 0.0),
|
||||
(json!({"ease": {"ramp": -0.1}}), 0.0, 0.0),
|
||||
(json!({"colorShift": 101}), 0.0, 100.0),
|
||||
(json!({"colorShift": -101}), 0.0, -100.0),
|
||||
(json!({"ease": {"ramp": null}, "colorShift": "NaN"}), 0.0, 0.0),
|
||||
] {
|
||||
let spec: BlendSpec = serde_json::from_value(bad.clone()).unwrap_or_else(|e| panic!("{bad}: {e}"));
|
||||
assert_eq!((spec.ease.ramp, spec.color_shift), (ramp, shift), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -1069,6 +1069,43 @@ fn symbol_stainer_refuses_a_keep_model_given_as_text() {
|
||||
assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red)));
|
||||
}
|
||||
|
||||
/// Place Symbol Instance, Delete Symbol, the Symbol Sprayer and the symbolism tools refuse a number
|
||||
/// or an on/off option given as text or as another value of the wrong kind, naming the key; the
|
||||
/// document and the undo history stay as they were. Numbers, true, false and null still work.
|
||||
#[test]
|
||||
fn symbol_commands_refuse_a_number_or_boolean_given_as_text() {
|
||||
use super::tests::{not_a_number, not_a_switch, not_refused};
|
||||
let mut s = session();
|
||||
let (_, name, b) = make_symbol(&mut s);
|
||||
let at = json!([[b.center().x, b.center().y]]);
|
||||
let mut calls: Vec<_> = [
|
||||
("symbol.place", json!({"name": name, "x": "200"}), "x"),
|
||||
("symbol.place", json!({"name": name, "x": 200, "y": [100]}), "y"),
|
||||
("symbol.spray", json!({"name": name, "points": at, "radius": "40"}), "radius"),
|
||||
("symbol.spray", json!({"name": name, "points": at, "density": "5"}), "density"),
|
||||
("symbol.spray", json!({"name": name, "points": at, "scale": true}), "scale"),
|
||||
("symbol.adjust", json!({"tool": "size", "points": at, "radius": "40"}), "radius"),
|
||||
("symbol.adjust", json!({"tool": "size", "points": at, "intensity": "10"}), "intensity"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(cmd, p, key)| not_a_number(cmd, p, key))
|
||||
.collect();
|
||||
calls.extend(
|
||||
[
|
||||
("symbol.spray", json!({"name": name, "points": at, "alt": "true"}), "alt"),
|
||||
("symbol.adjust", json!({"tool": "size", "points": at, "alt": 1}), "alt"),
|
||||
("symbol.delete", json!({"name": name, "expandInstances": "false"}), "expandInstances"),
|
||||
]
|
||||
.map(|(cmd, p, key)| not_a_switch(cmd, p, key)),
|
||||
);
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
s.execute("symbol.place", &json!({"name": name, "x": 200, "y": null})).unwrap();
|
||||
s.execute("symbol.spray", &json!({"name": name, "points": at, "radius": 40, "alt": false})).unwrap();
|
||||
s.execute("symbol.delete", &json!({"name": name, "expandInstances": true})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.symbols.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_sprayer_tool_gesture_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -716,6 +716,52 @@ fn live_paint_stroke_edge() {
|
||||
assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [300, 300]})).is_err());
|
||||
}
|
||||
|
||||
/// Shape Builder refuses an on/off option that is not true or false and a tolerance or gap that is
|
||||
/// not a number, given as text or as another value of the wrong kind, naming the key; the document
|
||||
/// and the undo history stay as they were. True, false and null still work.
|
||||
#[test]
|
||||
fn shape_builder_refuses_a_number_or_boolean_given_as_text() {
|
||||
use super::tests::{not_a_number, not_a_switch, not_refused};
|
||||
let mut s = session();
|
||||
two(&mut s);
|
||||
let merge = |extra: Value| {
|
||||
let mut p = json!({"points": [[75, 50]]});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
let calls = [
|
||||
not_a_switch("shapeBuilder.merge", merge(json!({"erase": "true"})), "erase"),
|
||||
not_a_switch("shapeBuilder.merge", merge(json!({"openFilledAsClosed": "false"})), "openFilledAsClosed"),
|
||||
not_a_number("shapeBuilder.merge", merge(json!({"erase": true, "tolerance": "2"})), "tolerance"),
|
||||
not_a_number("shapeBuilder.merge", merge(json!({"gap": "5"})), "gap"),
|
||||
not_a_switch("shapeBuilder.regions", json!({"openFilledAsClosed": 1}), "openFilledAsClosed"),
|
||||
not_a_number("shapeBuilder.regions", json!({"gap": [5]}), "gap"),
|
||||
];
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
s.execute("shapeBuilder.merge", &merge(json!({"erase": false, "openFilledAsClosed": null}))).unwrap();
|
||||
}
|
||||
|
||||
/// Live Paint and Image Trace refuse an on/off option that is not true or false and a width or
|
||||
/// tolerance that is not a number, given as text or as another value of the wrong kind, naming the
|
||||
/// key; the document and the undo history stay as they were. Numbers and null still work.
|
||||
#[test]
|
||||
fn live_paint_and_image_trace_refuse_a_number_or_boolean_given_as_text() {
|
||||
use super::tests::{not_a_number, not_a_switch, not_refused};
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let calls = [
|
||||
not_a_switch("livePaint.gapOptions", json!({"closeGaps": "true"}), "closeGaps"),
|
||||
not_a_number("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "width": "3"}), "width"),
|
||||
not_a_number("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "tolerance": [4]}), "tolerance"),
|
||||
not_a_switch("imageTrace.make", json!({"enhanced": "true"}), "enhanced"),
|
||||
];
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "width": 3, "tolerance": null})).unwrap();
|
||||
assert_eq!(node(&s, NodeId(r["edge"].as_u64().unwrap())).appearance.stroke_width(), 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_stroke_edge_skips_hidden_edges() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -83,8 +83,20 @@ fn renamed(v: &Value) -> Vec<(String, String, String)> {
|
||||
|
||||
// ---------- M4.22: resources ----------
|
||||
|
||||
/// Verify ordinary paste retains image, symbol and pattern resources and their rendered appearance.
|
||||
#[test]
|
||||
fn an_image_symbol_and_pattern_rect_pasted_into_a_new_document_render_the_same() {
|
||||
resource_transfer(false);
|
||||
}
|
||||
|
||||
/// Verify a translated artwork drop preserves editable resources, pattern phase, opacity and clipboard contents.
|
||||
#[test]
|
||||
fn dropped_artwork_preserves_editable_images_symbols_patterns_and_clipboard() {
|
||||
resource_transfer(true);
|
||||
}
|
||||
|
||||
/// Exercise paste or tab-drop resource transfer and compare source and destination rendering.
|
||||
fn resource_transfer(drag: bool) {
|
||||
let mut s = session();
|
||||
// Pattern "Dots" painting a rectangle.
|
||||
let dot = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
@@ -107,21 +119,37 @@ fn an_image_symbol_and_pattern_rect_pasted_into_a_new_document_render_the_same()
|
||||
sel(&mut s, &[e]);
|
||||
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("transparency.set", &json!({"opacity":45})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(s.clipboard.nodes.len(), 3);
|
||||
assert_eq!((s.clipboard.symbols.len(), s.clipboard.patterns.len(), s.clipboard.images.len()), (1, 1, 1));
|
||||
let before = render(&s);
|
||||
|
||||
let source = s.doc().unwrap().clone();
|
||||
let ids = source.selection.objects.clone();
|
||||
new_doc(&mut s);
|
||||
assert!(before != render(&s));
|
||||
let r = s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
||||
let r = if drag {
|
||||
s.clipboard = Clipboard::default();
|
||||
s.execute("edit.dropSelection", &json!({"sourceDocument": source.uid, "ids": ids, "grab": [0,0], "at": [13,17]})).unwrap()
|
||||
} else {
|
||||
s.execute("edit.pasteInPlace", &json!({})).unwrap()
|
||||
};
|
||||
if drag {
|
||||
assert_eq!(s.clipboard, Clipboard::default());
|
||||
}
|
||||
assert_eq!(s.documents()[0].doc, source.doc);
|
||||
// The pattern (its swatch comes along), the symbol and the image blob.
|
||||
assert_eq!((r["added"].as_u64(), r["merged"].as_u64()), (Some(3), Some(0)), "{r}");
|
||||
let d = doc(&s);
|
||||
assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some());
|
||||
assert!(d.symbols.iter().any(|x| x.name == "Badge"));
|
||||
assert_eq!(d.images.len(), 1);
|
||||
assert!(before == render(&s), "the pasted art renders as it did in its own document");
|
||||
let after = if drag {
|
||||
vectorcraft_render::Renderer::new().render_region(doc(&s), Rect::new(13.0, 17.0, 413.0, 317.0), 1.0, true).pixels
|
||||
} else {
|
||||
render(&s)
|
||||
};
|
||||
assert!(before == after, "the transferred art, its transparency and pattern alignment render as in the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -649,3 +677,107 @@ fn paste_remembers_layers_survives_save_and_old_files_load_without_it() {
|
||||
assert!(v.get("paste_remembers_layers").is_none(), "off is not written");
|
||||
assert!(!vectorcraft_format::load(v.to_string().as_bytes()).unwrap().paste_remembers_layers);
|
||||
}
|
||||
|
||||
/// Verify document-scoped object IDs, grab-point placement and one destination Undo/Redo step.
|
||||
#[test]
|
||||
fn artwork_drop_uses_source_ids_hotspot_and_one_target_undo_step() {
|
||||
let mut s = session();
|
||||
let r = swatched_rect(&mut s, "Brand", "#ff0000");
|
||||
let source = s.doc().unwrap().clone();
|
||||
new_doc(&mut s);
|
||||
let other = swatched_rect(&mut s, "Brand", "#0000ff");
|
||||
assert_eq!(r, other, "node ids collide across documents");
|
||||
copy(&mut s, &[other]);
|
||||
let clip = s.clipboard.clone();
|
||||
let target = s.doc().unwrap().clone();
|
||||
let result = s.execute("edit.dropSelection", &json!({"sourceDocument": source.uid, "ids": [r, r], "grab": [25,30], "at": [175,130]})).unwrap();
|
||||
let ids = ids_of(&result);
|
||||
assert_eq!(ids.len(), 1);
|
||||
assert_eq!(fill(&s, ids[0]), linked(hex("#ff0000"), "Brand 2", 1.0));
|
||||
assert_eq!(fill(&s, other), linked(hex("#0000ff"), "Brand", 1.0));
|
||||
assert_eq!(node(&s, ids[0]).geometric_bounds().unwrap(), Rect::new(170.0, 120.0, 210.0, 160.0));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), target.history.undo.len() + 1);
|
||||
assert_eq!(s.clipboard, clip);
|
||||
assert_eq!(s.documents()[0].doc, source.doc);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, target.doc);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, ids);
|
||||
}
|
||||
|
||||
/// Verify malformed, oversized, stale and overflowing drop requests leave document and clipboard state intact.
|
||||
#[test]
|
||||
fn invalid_artwork_drop_is_atomic() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 10.0, 10.0, 40.0, 40.0);
|
||||
let uid = s.execute("document.inspect", &json!({})).unwrap()["uid"].as_u64().unwrap();
|
||||
new_doc(&mut s);
|
||||
let target = s.doc().unwrap().clone();
|
||||
let clipboard = s.clipboard.clone();
|
||||
for patch in [
|
||||
json!({"sourceDocument": u64::MAX}),
|
||||
json!({"sourceDocument": -1}),
|
||||
json!({"sourceDocument": 1.5}),
|
||||
json!({"sourceDocument": null}),
|
||||
json!({"ids": vec![r;10001]}),
|
||||
json!({"ids": null}),
|
||||
json!({"ids": [r,-1]}),
|
||||
json!({"ids": [r,1.5]}),
|
||||
json!({"grab": [0,0,0]}),
|
||||
json!({"grab": ["0",0]}),
|
||||
json!({"ids": []}),
|
||||
json!({"ids": [r, 9999999]}),
|
||||
json!({"ids": ["1"]}),
|
||||
json!({"grab": [0]}),
|
||||
json!({"at": [null, 0]}),
|
||||
json!({"at": [1e308, 0]}),
|
||||
] {
|
||||
let mut p = json!({"sourceDocument": uid, "ids": [r], "grab": [0,0], "at": [50,60]});
|
||||
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
|
||||
assert!(s.execute("edit.dropSelection", &p).is_err(), "{p}");
|
||||
assert_eq!(s.doc().unwrap().doc, target.doc);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), target.history.undo.len());
|
||||
assert_eq!(s.doc().unwrap().selection, target.selection);
|
||||
assert_eq!(s.clipboard, clipboard);
|
||||
}
|
||||
s.execute("file.close", &json!({"index":0})).unwrap();
|
||||
assert!(s.execute("edit.dropSelection", &json!({"sourceDocument": uid,"ids":[r],"grab":[0,0],"at":[50,60]})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, target.doc);
|
||||
}
|
||||
|
||||
/// Verify refusing a locked insertion context preserves the target document and its undo history.
|
||||
#[test]
|
||||
fn artwork_drop_into_a_locked_destination_is_atomic() {
|
||||
let mut s = session();
|
||||
let r = swatched_rect(&mut s, "Brand", "#ff0000");
|
||||
let uid = s.doc().unwrap().uid;
|
||||
new_doc(&mut s);
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
s.execute("layer.setProps", &json!({"id":layer,"locked":true})).unwrap();
|
||||
let target = s.doc().unwrap().clone();
|
||||
assert!(s.execute("edit.dropSelection", &json!({"sourceDocument":uid,"ids":[r],"grab":[0,0],"at":[100,100]})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, target.doc);
|
||||
assert_eq!(s.doc().unwrap().selection, target.selection);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), target.history.undo.len());
|
||||
}
|
||||
|
||||
/// Verify tab drops use the editable current layer despite Paste Remembers Layers and source-layer name collisions.
|
||||
#[test]
|
||||
fn artwork_drop_uses_the_current_layer_even_when_paste_remembers_hidden_or_locked_source_layers() {
|
||||
for hidden in [true, false] {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 10.0, 10.0, 40.0, 40.0);
|
||||
let uid = s.doc().unwrap().uid;
|
||||
new_doc(&mut s);
|
||||
let blocked = doc(&s).layers[0].id;
|
||||
s.execute("layer.setProps", &json!({"id":blocked,"visible":!hidden,"locked":!hidden})).unwrap();
|
||||
let current = id_of(&s.execute("layer.new", &json!({"name":"Flyer"})).unwrap());
|
||||
s.execute("layer.pasteRemembersLayers", &json!({"on":true})).unwrap();
|
||||
let blocked_before = node(&s, blocked);
|
||||
let placed = ids_of(&s.execute("edit.dropSelection", &json!({"sourceDocument":uid,"ids":[r],"grab":[0,0],"at":[100,100]})).unwrap());
|
||||
assert_eq!(doc(&s).parent_of(placed[0]), Some(current));
|
||||
assert_eq!(node(&s, blocked), blocked_before);
|
||||
assert_eq!(doc(&s).layers.len(), 2);
|
||||
assert!(doc(&s).paste_remembers_layers, "the preference still applies to ordinary Paste");
|
||||
}
|
||||
}
|
||||
@@ -818,13 +818,17 @@ fn polar_grid_options_compound_fill_and_skew() {
|
||||
assert!(tips(&inward)[1..].iter().all(|t| t.x < 50.0), "counterclockwise of the top: {:?}", tips(&inward));
|
||||
}
|
||||
|
||||
/// The grid options are checked: a skew is a number and a switch true or false, or the command
|
||||
/// fails without an undo step.
|
||||
/// The grid options are checked: a divider count or a skew is a number and a switch true or false,
|
||||
/// or the command fails without an undo step.
|
||||
#[test]
|
||||
fn grid_commands_reject_options_of_the_wrong_type() {
|
||||
let mut s = session();
|
||||
let size = json!({"x": 0, "y": 0, "width": 10, "height": 10});
|
||||
for (cmd, key, value) in [
|
||||
("shape.rectangularGrid", "rows", json!("3")),
|
||||
("shape.rectangularGrid", "columns", json!([4])),
|
||||
("shape.polarGrid", "concentric", json!(true)),
|
||||
("shape.polarGrid", "radial", json!("6")),
|
||||
("shape.rectangularGrid", "rowSkew", json!("50")),
|
||||
("shape.rectangularGrid", "columnSkew", json!(true)),
|
||||
("shape.rectangularGrid", "frame", json!(1)),
|
||||
@@ -1230,6 +1234,40 @@ fn move_anchors_reshape_and_close_refuse_a_number_given_as_text() {
|
||||
assert!(after.subpaths[0].closed && moved == Some(before.subpaths[0].anchors[1].p.x + 10.0), "{after:?}");
|
||||
}
|
||||
|
||||
/// Close Path, Set Handle, Move Anchors and the Pencil, Curvature, Reshape Segment and Blob Brush
|
||||
/// commands refuse an on/off option given as text or as another value that is not true or false,
|
||||
/// naming the key, and change nothing; true, false and null still work.
|
||||
#[test]
|
||||
fn path_commands_refuse_a_boolean_given_as_text() {
|
||||
let mut s = session();
|
||||
let id = curve(&mut s);
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
|
||||
let stroke = json!([[100, 300], [150, 340], [200, 300]]);
|
||||
let calls = [
|
||||
("path.close", json!({"id": id.0, "independent": "true"}), "independent"),
|
||||
("path.setHandle", json!({"id": id.0, "anchor": 0, "x": 160, "y": 90, "independent": 1}), "independent"),
|
||||
("path.moveAnchors", json!({"dx": 10, "patterns": "true"}), "patterns"),
|
||||
("path.freehand", json!({"points": stroke, "closed": "true"}), "closed"),
|
||||
("path.freehand", json!({"points": stroke, "keepSelected": "false"}), "keepSelected"),
|
||||
("path.freehand", json!({"points": stroke, "fill": [true]}), "fill"),
|
||||
("path.curvature", json!({"points": stroke, "closed": "true"}), "closed"),
|
||||
("path.curvature", json!({"points": [{"x": 100, "y": 300, "corner": "true"}, [150, 340], [200, 300]]}), "corner"),
|
||||
("path.curvatureEdit", json!({"id": id.0, "op": "move", "anchor": 1, "x": 300, "y": 150, "corner": "true"}), "corner"),
|
||||
("path.reshapeSegment", json!({"id": id.0, "segment": 0, "dy": 20, "perpendicular": "true"}), "perpendicular"),
|
||||
("path.blob", json!({"points": stroke, "keepSelected": "true"}), "keepSelected"),
|
||||
("path.blob", json!({"points": stroke, "mergeOnlySelection": 1}), "mergeOnlySelection"),
|
||||
("path.blob", json!({"points": stroke, "merge": "false"}), "merge"),
|
||||
]
|
||||
.map(|(cmd, p, key)| super::tests::not_a_switch(cmd, p, key));
|
||||
let wrong = super::tests::not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
s.execute("path.setHandle", &json!({"id": id.0, "anchor": 0, "x": 160, "y": 90, "independent": true})).unwrap();
|
||||
s.execute("path.close", &json!({"id": id.0, "independent": null})).unwrap();
|
||||
s.execute("path.freehand", &json!({"points": stroke, "closed": false, "keepSelected": null, "fill": true})).unwrap();
|
||||
s.execute("path.blob", &json!({"points": stroke, "merge": false, "keepSelected": null, "mergeOnlySelection": false})).unwrap();
|
||||
assert!(path(&s, id).subpaths[0].closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
|
||||
// `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and
|
||||
|
||||
@@ -159,9 +159,10 @@ fn expanded_art_keeps_the_random_transforms_the_canvas_drew() {
|
||||
assert_eq!(differ, RANDOM_CASES.map(|case| (case, 0)));
|
||||
}
|
||||
|
||||
/// The reply lists each object whose appearance expanded once, by the id it has in the document.
|
||||
/// In "group copies" the two copies of the square share an id until Expand Appearance gives them
|
||||
/// ids of their own, and the reply names the group, then each copy by its new id.
|
||||
/// The reply lists each object whose appearance expanded once, by the id it has in the document,
|
||||
/// starting with the selected object. In "group copies" the two copies of the square share an id
|
||||
/// until Expand Appearance gives them ids of their own, and the reply names the group, then each
|
||||
/// copy by its new id.
|
||||
#[test]
|
||||
fn the_reply_names_each_expanded_copy_by_its_own_id() {
|
||||
for case in RANDOM_CASES {
|
||||
@@ -169,6 +170,7 @@ fn the_reply_names_each_expanded_copy_by_its_own_id() {
|
||||
let id = random_transforms(&mut s, case);
|
||||
let r = run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let ids: Vec<u64> = r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||||
assert_eq!(ids.first(), Some(&id.0), "{case}: {ids:?}");
|
||||
let unique: std::collections::HashSet<_> = ids.iter().collect();
|
||||
assert_eq!(unique.len(), ids.len(), "{case}: {ids:?}");
|
||||
assert!(ids.iter().all(|&i| s.doc().unwrap().doc.node(NodeId(i)).is_some()), "{case}: {ids:?}");
|
||||
|
||||
@@ -238,6 +238,27 @@ fn lines_join_split_and_follow_the_selection() {
|
||||
assert_eq!(g["points"][i]["at"], json!([190.0, 190.0]));
|
||||
}
|
||||
|
||||
/// Split Line refuses a `t` that is not a number, and the point commands and the query a `stroke`
|
||||
/// that is not true or false, given as text or as another value of the wrong kind; the gradient and
|
||||
/// the undo history stay as they were. Numbers, false and null still work.
|
||||
#[test]
|
||||
fn freeform_commands_refuse_a_number_or_boolean_given_as_text() {
|
||||
use super::tests::{not_a_number, not_a_switch, not_refused};
|
||||
let (mut s, id) = freeform_rect();
|
||||
run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1]}));
|
||||
let calls = [
|
||||
not_a_number("paint.freeform.splitLine", json!({"line": 0, "segment": 0, "t": "0.25"}), "t"),
|
||||
not_a_switch("paint.freeform.addPoint", json!({"at": [150, 150], "stroke": "false"}), "stroke"),
|
||||
not_a_switch("paint.freeform.get", json!({"stroke": [false]}), "stroke"),
|
||||
];
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
run(&mut s, "paint.freeform.splitLine", json!({"line": 0, "segment": 0, "t": null}));
|
||||
let g = run(&mut s, "paint.freeform.get", json!({"stroke": false}));
|
||||
assert_eq!(g["lines"][0].as_array().map(Vec::len), Some(3));
|
||||
assert_eq!(points(&s, id).lines[0].len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_commands_need_a_freeform_gradient() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -96,6 +96,33 @@ fn bad_gradients_are_rejected_without_editing() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
/// The gradient commands refuse a `reverse` or `stroke` that is not true or false, and an `angle`
|
||||
/// or a stop's `tint` that is not a number, given as text or as another value of the wrong kind:
|
||||
/// the object and the undo history stay as they were. True, false, numbers and null still work.
|
||||
#[test]
|
||||
fn gradient_commands_refuse_a_number_or_boolean_given_as_text() {
|
||||
use super::tests::{not_a_number, not_a_switch, not_refused};
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||||
let red = s.execute("swatch.new", &json!({"color": "#ff0000", "global": true})).unwrap()["name"].clone();
|
||||
let stops = |tint: Value| json!([{"offset": 0, "color": "#ff0000", "swatch": red, "tint": tint}, {"offset": 1, "color": "#000000"}]);
|
||||
let calls = [
|
||||
not_a_switch("paint.editGradient", json!({"reverse": "true"}), "reverse"),
|
||||
not_a_switch("paint.editGradient", json!({"angle": 45, "stroke": "false"}), "stroke"),
|
||||
not_a_switch("paint.setGradientGeom", json!({"aspect": 50, "stroke": 0}), "stroke"),
|
||||
not_a_number("paint.editGradient", json!({"angle": "45"}), "angle"),
|
||||
("paint.setFill".to_string(), json!({"gradient": {"angle": "45"}}), "`angle` must be a number, not \"45\"".to_string()),
|
||||
("paint.editGradient".to_string(), json!({"stops": stops(json!("50"))}), "stop 0: `tint` must be a number".to_string()),
|
||||
("paint.setFill".to_string(), json!({"gradient": {"stops": stops(json!([50]))}}), "stop 0: `tint` must be a number".to_string()),
|
||||
];
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
s.execute("paint.editGradient", &json!({"angle": 30, "reverse": false, "stroke": null, "stops": stops(json!(50))})).unwrap();
|
||||
let g = fill_gradient(&s, id);
|
||||
assert_eq!((g.angle, g.gradient.stops[0].tint), (30.0, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_opacity_and_offsets_are_normalised() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -153,9 +153,9 @@ fn selected_guides_align_to_a_clicked_key_object() {
|
||||
assert_eq!(guides(&s), [140.0, 110.0], "horizontal alignment moves only the vertical guide");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(guides(&s), [120.0, 110.0], "one undo step");
|
||||
// Aligning to the artboard leaves the guides alone.
|
||||
// Aligning to the artboard moves the vertical guide to the artboard's left edge.
|
||||
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap();
|
||||
assert_eq!(guides(&s), [120.0, 110.0]);
|
||||
assert_eq!(guides(&s), [0.0, 110.0]);
|
||||
// Deselecting the guides lets the lone key go.
|
||||
s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
@@ -310,6 +310,18 @@ fn positions(s: &Session) -> Vec<f64> {
|
||||
s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_selected_alone_align_to_artboard() {
|
||||
let mut s = guides_alone(&[100.0, 400.0], &[50.0, 300.0]);
|
||||
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard", "artboard": 0})).unwrap();
|
||||
assert_eq!(positions(&s), [0.0, 0.0, 50.0, 300.0]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.align", &json!({"horizontal": "right", "to": "artboard", "artboard": 0})).unwrap();
|
||||
assert_eq!(&positions(&s)[..2], [800.0, 800.0]);
|
||||
s.execute("object.align", &json!({"vertical": "top", "to": "artboard", "artboard": 0})).unwrap();
|
||||
assert_eq!(positions(&s), [800.0, 800.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribute_spaces_guides_selected_without_objects() {
|
||||
let mut s = guides_alone(&[100.0, 150.0, 500.0], &[50.0, 60.0, 400.0]);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::tests_menucmds::place_undecodable_png;
|
||||
use super::tests_menucmds::{place_undecodable_png, place_undecodable_png_sized};
|
||||
use super::*;
|
||||
use crate::tests_links::Folder;
|
||||
|
||||
@@ -362,6 +362,29 @@ fn a_flattened_image_that_cant_be_decoded_is_noted_once() {
|
||||
assert_eq!(undecoded_notes(&r), 1, "{r}");
|
||||
}
|
||||
|
||||
/// Images that can't be decoded and are written as they are get the same note as one that is
|
||||
/// flattened. All of them give one note, and the note names no image's key.
|
||||
#[test]
|
||||
fn images_written_or_flattened_that_cant_be_decoded_are_noted_once() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
place_undecodable_png(&mut s);
|
||||
place_undecodable_png_sized(&mut s, 48, 24);
|
||||
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||
assert_eq!(keys.len(), 2);
|
||||
let decode_notes = |r: &Value| warnings(r).iter().filter(|w| w.contains("decoded")).count();
|
||||
let named = |r: &Value| warnings(r).iter().any(|w| keys.iter().any(|k| w.contains(k.as_str())));
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||||
assert_eq!((undecoded_notes(&r), decode_notes(&r), named(&r)), (1, 1, false), "{r}");
|
||||
// A third image, at 50% opacity, is flattened. The engine's note and the writer's note come out
|
||||
// as one warning.
|
||||
let cut = place_undecodable_png_sized(&mut s, 32, 16);
|
||||
s.execute("transparency.set", &json!({"ids": [cut.0], "opacity": 50})).unwrap();
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||||
assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}");
|
||||
assert_eq!((undecoded_notes(&r), decode_notes(&r), named(&r)), (1, 1, false), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_that_cant_be_decoded_with_an_expanded_effect_is_noted() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -41,8 +41,14 @@ fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
/// A placed 64 × 32 px PNG whose header reads (so it places at its size) but whose pixel data is
|
||||
/// cut short: the renderer can't decode it and draws nothing.
|
||||
pub(crate) fn place_undecodable_png(s: &mut Session) -> NodeId {
|
||||
place_undecodable_png_sized(s, 64, 32)
|
||||
}
|
||||
|
||||
/// Places a PNG like [`place_undecodable_png`] does, at `w` × `h` px. PNGs of different sizes are
|
||||
/// different images, each stored under its own key.
|
||||
pub(crate) fn place_undecodable_png_sized(s: &mut Session, w: u32, h: u32) -> NodeId {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
@@ -436,9 +442,9 @@ fn transform_each_refuses_a_number_given_as_text() {
|
||||
assert_eq!(bounds(&s, a), Rect::new(35.0, 25.0, 85.0, 75.0));
|
||||
}
|
||||
|
||||
/// Transform Each refuses Reflect X, Reflect Y, Random or Copy given as text or as another value
|
||||
/// that is not true or false, and the object stays as it was and is not copied; true, false and null
|
||||
/// still work.
|
||||
/// Transform Each refuses Reflect X, Reflect Y, Random, Copy, Scale Strokes & Effects, Scale
|
||||
/// Corners, Transform Objects or Transform Patterns given as text or as another value that is not
|
||||
/// true or false, and the object stays as it was and is not copied; true, false and null still work.
|
||||
#[test]
|
||||
fn transform_each_refuses_a_boolean_given_as_text() {
|
||||
let mut s = session();
|
||||
@@ -452,14 +458,51 @@ fn transform_each_refuses_a_boolean_given_as_text() {
|
||||
("random", json!({"moveH": 10, "random": "false"})),
|
||||
("copy", json!({"copy": "true"})),
|
||||
("copy", json!({"moveH": 10, "copy": [true]})),
|
||||
("strokes", json!({"scaleH": 50, "scaleV": 50, "strokes": "false"})),
|
||||
("corners", json!({"scaleH": 50, "corners": 1})),
|
||||
("objects", json!({"moveH": 10, "objects": "false"})),
|
||||
("patterns", json!({"moveH": 10, "patterns": [true]})),
|
||||
] {
|
||||
refuses_non_boolean(&mut s, "object.transformEach", &p, key);
|
||||
assert_eq!((undo_len(&s), bounds(&s, a), count(&s)), before, "{p}");
|
||||
}
|
||||
s.execute("object.transformEach", &json!({"moveH": 10, "reflectX": true, "random": false, "copy": null})).unwrap();
|
||||
let p = json!({"moveH": 10, "reflectX": true, "random": false, "copy": null, "objects": null, "patterns": false});
|
||||
s.execute("object.transformEach", &p).unwrap();
|
||||
assert_eq!((bounds(&s, a), count(&s)), (Rect::new(10.0, 0.0, 110.0, 100.0), before.2));
|
||||
}
|
||||
|
||||
/// With Scale Strokes & Effects on, Transform Each asked to scale a stroked rectangle with
|
||||
/// `strokes` given as text, such as "false", fails: the saved document, the undo history and what
|
||||
/// Transform Again repeats stay as they were, and Transform Again still repeats the Transform Each
|
||||
/// before it. `strokes: false` scales the rectangle and keeps its stroke weight; `strokes: true`
|
||||
/// and a null (the preference) scale both.
|
||||
#[test]
|
||||
fn transform_each_refuses_strokes_given_as_text_and_keeps_transform_again() {
|
||||
let mut s = session();
|
||||
s.prefs.scale_strokes = true;
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
|
||||
s.execute("object.transformEach", &json!({"moveH": 10})).unwrap();
|
||||
let saved = |s: &Session| vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||||
let repeat = |s: &Session| s.doc().unwrap().last_transform.clone();
|
||||
let before = (saved(&s), history(&s), repeat(&s));
|
||||
for strokes in [json!("false"), json!("true"), json!(0)] {
|
||||
let p = json!({"scaleH": 200, "scaleV": 200, "strokes": strokes});
|
||||
refuses_non_boolean(&mut s, "object.transformEach", &p, "strokes");
|
||||
assert!((saved(&s), history(&s), repeat(&s)) == before, "{p}");
|
||||
}
|
||||
let weight = |s: &Session| node(s, a).appearance.stroke_width();
|
||||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||||
assert_eq!((bounds(&s, a), weight(&s)), (Rect::new(20.0, 0.0, 120.0, 100.0), 4.0), "the move before it repeats");
|
||||
for (strokes, w) in [(json!(false), 4.0), (json!(true), 8.0), (Value::Null, 8.0)] {
|
||||
let p = json!({"scaleH": 200, "scaleV": 200, "strokes": strokes});
|
||||
s.execute("object.transformEach", &p).unwrap();
|
||||
assert_eq!((bounds(&s, a), weight(&s)), (Rect::new(-30.0, -50.0, 170.0, 150.0), w), "{p}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// #1910: Transform Each's Reflect X flips left-right; Reflect Y flips top-bottom (same as
|
||||
/// Effect › Distort › Transform and Illustrator's Transform Each checkboxes).
|
||||
#[test]
|
||||
@@ -971,9 +1014,9 @@ fn rearrange_refuses_a_spacing_given_as_text() {
|
||||
assert_eq!(boards(&s)[1], Rect::new(0.0, 650.0, 400.0, 950.0));
|
||||
}
|
||||
|
||||
/// Rearrange All Artboards refuses `byColumn` or `moveArtwork` given as text or as another value
|
||||
/// that is not true or false, and the artboards and the art stay where they were; `moveArtwork:
|
||||
/// false` leaves the art in place.
|
||||
/// Rearrange All Artboards refuses `byColumn`, `moveArtwork` or `patterns` given as text or as
|
||||
/// another value that is not true or false, and the artboards and the art stay where they were;
|
||||
/// `moveArtwork: false` leaves the art in place.
|
||||
#[test]
|
||||
fn rearrange_refuses_a_boolean_given_as_text() {
|
||||
let mut s = session();
|
||||
@@ -986,6 +1029,7 @@ fn rearrange_refuses_a_boolean_given_as_text() {
|
||||
("byColumn", json!({"layout": "row", "byColumn": 1})),
|
||||
("moveArtwork", json!({"moveArtwork": "false"})),
|
||||
("moveArtwork", json!({"columns": 1, "moveArtwork": 0})),
|
||||
("patterns", json!({"patterns": "true"})),
|
||||
] {
|
||||
refuses_non_boolean(&mut s, "artboard.rearrange", &p, key);
|
||||
assert_eq!((boards(&s), undo_len(&s), bounds(&s, b)), before, "{p}");
|
||||
|
||||
@@ -265,6 +265,52 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() {
|
||||
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
|
||||
}
|
||||
|
||||
/// Artboard Options, Move Artboard, Duplicate Artboards, Copy Artboard and Cut Artboard refuse an
|
||||
/// option given as text or as another value that is not true or false, such as "true", and
|
||||
/// Artboard Options and Move Artboard a position, size or move given as text, such as "10". The
|
||||
/// artboards, the art, the undo history and the clipboard stay as they were; a null reads as the
|
||||
/// option left out.
|
||||
#[test]
|
||||
fn artboard_commands_refuse_a_boolean_or_number_given_as_text() {
|
||||
use super::tests::{not_a_number, not_a_switch, not_refused};
|
||||
let mut s = session();
|
||||
let r = rect(&mut s);
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
let mut calls: Vec<_> = [
|
||||
("artboard.setProps", "scaleArt", json!({"index": 0, "width": 400, "scaleArt": "true"})),
|
||||
("artboard.setProps", "strokes", json!({"index": 0, "width": 400, "scaleArt": true, "strokes": "false"})),
|
||||
("artboard.setProps", "corners", json!({"index": 0, "width": 400, "scaleArt": true, "corners": 1})),
|
||||
("artboard.setProps", "patterns", json!({"index": 0, "x": 50, "moveArt": true, "patterns": "true"})),
|
||||
("artboard.setProps", "moveArt", json!({"index": 0, "x": 50, "moveArt": "true"})),
|
||||
("artboard.setProps", "lockedAndHidden", json!({"index": 0, "x": 50, "moveArt": true, "lockedAndHidden": [true]})),
|
||||
("artboard.setProps", "locked", json!({"artboards": [{"index": 1, "locked": "true"}]})),
|
||||
("artboard.move", "moveArt", json!({"index": 0, "dx": 10, "moveArt": "true"})),
|
||||
("artboard.move", "copy", json!({"index": 0, "dx": 10, "copy": 1})),
|
||||
("artboard.move", "patterns", json!({"index": 0, "dx": 10, "moveArt": true, "patterns": "false"})),
|
||||
("artboard.duplicate", "patterns", json!({"index": 0, "patterns": "true"})),
|
||||
("artboard.duplicate", "art", json!({"index": 0, "art": "false"})),
|
||||
("artboard.copy", "art", json!({"index": 0, "art": "true"})),
|
||||
("artboard.cut", "art", json!({"index": 1, "art": 0})),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(cmd, key, p)| not_a_switch(cmd, p, key))
|
||||
.collect();
|
||||
for (cmd, key, p) in [
|
||||
("artboard.setProps", "x", json!({"index": 0, "x": "50"})),
|
||||
("artboard.setProps", "height", json!({"index": 1, "height": [300]})),
|
||||
("artboard.move", "dx", json!({"index": 0, "dx": "10"})),
|
||||
("artboard.move", "dy", json!({"index": 0, "dx": 10, "dy": "5"})),
|
||||
] {
|
||||
calls.push(not_a_number(cmd, p, key));
|
||||
}
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
|
||||
let p = json!({"index": 0, "dx": 10, "dy": null, "moveArt": true, "copy": null, "patterns": false});
|
||||
s.execute("artboard.move", &p).unwrap();
|
||||
assert_eq!(node(&s, r).geometric_bounds().map(|b| b.x0), Some(20.0));
|
||||
}
|
||||
|
||||
/// Artboards 1 and 2, each with a square on it, and an artboard 3 below artboard 1 with a square on
|
||||
/// it, with the Artboard tool selecting artboards 1 and 2 → the squares' ids, artboard by artboard.
|
||||
fn two_selected_artboards(s: &mut Session) -> [u64; 3] {
|
||||
|
||||
@@ -139,6 +139,46 @@ fn effect_params_are_the_effects_own() {
|
||||
assert!(s.check_params("effect.setParams", &json!({"index": 7, "params": {"blurr": 3}})).is_ok());
|
||||
}
|
||||
|
||||
/// The Transform effect (`distort.transform`) is held to its params description: `effect.apply`
|
||||
/// and `effect.setParams` refuse each of its on/off options given as text, such as `reflectX:
|
||||
/// "false"`, and each of its numbers given as text, such as `copies: "0"`, and leave the effect
|
||||
/// as it was; true, false and numbers pass. The loop takes the keys from the effect's defaults, so
|
||||
/// an option the effect gains is checked too.
|
||||
#[test]
|
||||
fn transform_effect_refuses_a_boolean_or_number_given_as_text() {
|
||||
let info = vectorcraft_render::effects::effect_info("distort.transform").unwrap();
|
||||
let one = |key: &str, v: serde_json::Value| serde_json::Value::Object([(key.to_string(), v)].into_iter().collect());
|
||||
let apply = |params| json!({"effect": "distort.transform", "params": params});
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
s.execute("effect.apply", &apply(json!({"moveH": 20}))).unwrap();
|
||||
let state = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.doc.layers[0].children().unwrap()[0].appearance.effects[0].params.clone())
|
||||
};
|
||||
let before = state(&s);
|
||||
let mut switches = vec![];
|
||||
for (key, default) in info.defaults.as_object().unwrap() {
|
||||
let why = match default {
|
||||
serde_json::Value::Bool(_) => format!("`{key}` must be true or false"),
|
||||
serde_json::Value::Number(_) => format!("`{key}` must be a number"),
|
||||
_ => continue,
|
||||
};
|
||||
if default.is_boolean() {
|
||||
switches.push(key.as_str());
|
||||
}
|
||||
let text = json!(default.to_string());
|
||||
assert_eq!(refusal("effect.apply", apply(one(key, text.clone()))), why);
|
||||
let e = s.execute("effect.setParams", &json!({"index": 0, "params": one(key, text)})).unwrap_err().to_string();
|
||||
assert!(e.contains(&why), "{e}");
|
||||
assert!(check_params("effect.apply", &apply(one(key, default.clone()))).is_ok(), "{key}");
|
||||
}
|
||||
assert!(["reflectX", "reflectY", "random"].iter().all(|k| switches.contains(k)), "{switches:?}");
|
||||
assert_eq!(state(&s), before);
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"reflectX": true, "random": false, "copies": 2}})).unwrap();
|
||||
}
|
||||
|
||||
/// What a command notes for its journal entry is a key its description names, so the entry
|
||||
/// passes the check a caller's params get.
|
||||
#[test]
|
||||
|
||||
@@ -310,6 +310,39 @@ fn the_swatch_commands_refuse_a_keep_model_given_as_text() {
|
||||
assert_eq!(doc(&s).swatch(r["name"].as_str().unwrap()).and_then(|w| w.paint.color()), Some(managed(Color::rgb(1.0, 0.0, 0.0))));
|
||||
}
|
||||
|
||||
/// The swatch commands refuse an on/off option given as text or as another value that is not true
|
||||
/// or false, and `swatch.move` a `to` that is not a number, naming the key; the swatches and the
|
||||
/// undo history stay as they were. True, false, numbers and null still work.
|
||||
#[test]
|
||||
fn the_swatch_commands_refuse_a_boolean_or_number_given_as_text() {
|
||||
use super::tests::{not_a_number, not_a_switch, not_refused};
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#3366cc"}));
|
||||
let name = run(&mut s, "swatch.new", json!({"color": "#000000"}))["name"].clone();
|
||||
let mut calls: Vec<_> = [
|
||||
("swatch.new", json!({"color": "#ff0000", "spot": "true"}), "spot"),
|
||||
("swatch.new", json!({"color": "#ff0000", "global": 1}), "global"),
|
||||
("swatch.delete", json!({"name": name, "unlink": "false"}), "unlink"),
|
||||
("swatch.newGroup", json!({"name": "K", "colors": ["#00ff00"], "includeTints": "true"}), "includeTints"),
|
||||
("swatch.newGroup", json!({"name": "K", "fromArtwork": "true"}), "fromArtwork"),
|
||||
("swatch.newGroup", json!({"name": "K", "fromArtwork": true, "toGlobal": "false"}), "toGlobal"),
|
||||
("swatch.edit", json!({"name": name, "spot": "true"}), "spot"),
|
||||
("swatch.edit", json!({"name": name, "global": [true]}), "global"),
|
||||
("swatch.addUsedColors", json!({"global": "true"}), "global"),
|
||||
("swatch.addUsedColors", json!({"selection": 1}), "selection"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(cmd, p, key)| not_a_switch(cmd, p, key))
|
||||
.collect();
|
||||
calls.push(not_a_number("swatch.move", json!({"name": name, "to": "0"}), "to"));
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
run(&mut s, "swatch.edit", json!({"name": name, "global": true, "spot": null}));
|
||||
run(&mut s, "swatch.move", json!({"name": name, "to": 0}));
|
||||
assert!(doc(&s).swatch(name.as_str().unwrap()).is_some_and(|w| w.global && !w.spot));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_documents_keep_colours_as_given_and_edits_keep_the_source_model() {
|
||||
use vectorcraft_color::Color;
|
||||
|
||||
@@ -112,6 +112,79 @@ fn set_range_style_defaults_to_all_text_and_get_range() {
|
||||
assert_eq!(r["length"], json!(3));
|
||||
}
|
||||
|
||||
/// The type commands refuse a number or an on/off option given as text or as another value of the
|
||||
/// wrong kind, naming the key, and leave the document and its undo history as they were; true,
|
||||
/// false, numbers and null still work.
|
||||
#[test]
|
||||
fn type_commands_refuse_a_number_or_boolean_given_as_text() {
|
||||
use super::tests::{not_a_number, not_a_switch, not_refused};
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "abc");
|
||||
let frame = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 100, "height": 50})).unwrap()["id"].clone();
|
||||
let boxed = s.execute("text.create", &json!({"x": 0, "y": 300, "text": "a", "area": {"width": 100, "height": 50}})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let area = json!({"x": 0, "y": 0, "text": "a", "area": {"width": "200", "height": 100}});
|
||||
let mut calls = vec![("text.create".to_string(), area, "`width` must be a number, not \"200\"".to_string())];
|
||||
let area = json!({"x": 0, "y": 0, "text": "a", "area": {"width": 200, "height": 100, "fit": "shrinkText", "fitMinPercent": "40"}});
|
||||
calls.push(("text.create".to_string(), area, "`fitMinPercent` must be a number, not \"40\"".to_string()));
|
||||
calls.extend(
|
||||
[
|
||||
("text.create", json!({"x": 0, "y": 0, "text": "a", "size": "12"}), "size"),
|
||||
("text.createInPath", json!({"path": frame, "text": "a", "size": [12]}), "size"),
|
||||
("text.createInPath", json!({"path": frame, "mode": "area", "fitMinPercent": "40"}), "fitMinPercent"),
|
||||
("text.setRangeStyle", json!({"id": id.0, "size": "30"}), "size"),
|
||||
("text.setRangeStyle", json!({"id": id.0, "tracking": "50"}), "tracking"),
|
||||
("text.setRangeStyle", json!({"id": id.0, "hScale": true}), "hScale"),
|
||||
("text.setRangeStyle", json!({"id": id.0, "strokeWidth": "1"}), "strokeWidth"),
|
||||
("text.setRangeStyle", json!({"id": id.0, "start": "1", "size": 30}), "start"),
|
||||
("text.setRangeStyle", json!({"id": id.0, "end": [1], "size": 30}), "end"),
|
||||
("text.editRange", json!({"id": id.0, "start": 0, "end": "1", "insert": "x"}), "end"),
|
||||
("text.getRange", json!({"id": id.0, "start": "1"}), "start"),
|
||||
("text.touchType", json!({"id": id.0, "start": 0, "rotation": "10"}), "rotation"),
|
||||
("text.touchType", json!({"id": id.0, "start": "0", "rotation": 10}), "start"),
|
||||
("type.step", json!({"attribute": "size", "by": "2"}), "by"),
|
||||
("type.step", json!({"attribute": "size", "id": id.0, "start": "1"}), "start"),
|
||||
("type.size.increase", json!({"by": "2"}), "by"),
|
||||
("type.size.decrease", json!({"by": [2]}), "by"),
|
||||
("type.ruby", json!({"text": "a", "size": "60"}), "size"),
|
||||
("text.areaOptions", json!({"ids": [boxed], "width": "200"}), "width"),
|
||||
("text.areaOptions", json!({"ids": [boxed], "height": [80]}), "height"),
|
||||
("text.areaOptions", json!({"ids": [boxed], "fit": "shrinkText", "fitMinPercent": "40"}), "fitMinPercent"),
|
||||
]
|
||||
.map(|(cmd, p, key)| not_a_number(cmd, p, key)),
|
||||
);
|
||||
calls.extend(
|
||||
[
|
||||
("text.create", json!({"x": 0, "y": 0, "text": "a", "vertical": "true"}), "vertical"),
|
||||
("text.create", json!({"x": 0, "y": 0, "placeholder": 1}), "placeholder"),
|
||||
("text.createInPath", json!({"path": frame, "vertical": "true"}), "vertical"),
|
||||
("text.createInPath", json!({"path": frame, "placeholder": "true"}), "placeholder"),
|
||||
("text.setRangeStyle", json!({"id": id.0, "underline": "true"}), "underline"),
|
||||
("text.setRangeStyle", json!({"id": id.0, "allCaps": 1}), "allCaps"),
|
||||
("text.setRangeStyle", json!({"id": id.0, "noBreak": "true"}), "noBreak"),
|
||||
("text.setRangeStyle", json!({"id": id.0, "size": 30, "tateChuYoko": 1}), "tateChuYoko"),
|
||||
("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"),
|
||||
("type.opticalMarginAlignment", json!({"on": "true"}), "on"),
|
||||
("type.systemLayout", json!({"on": "true"}), "on"),
|
||||
("type.fractionalWidths", json!({"on": "false"}), "on"),
|
||||
("type.ruby", json!({"remove": "true"}), "remove"),
|
||||
("type.ruby", json!({"text": "a", "overhang": "false"}), "overhang"),
|
||||
]
|
||||
.map(|(cmd, p, key)| not_a_switch(cmd, p, key)),
|
||||
);
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
let style = json!({"id": id.0, "size": 30, "underline": true, "tracking": null, "start": null, "noBreak": true});
|
||||
s.execute("text.setRangeStyle", &style).unwrap();
|
||||
s.execute("type.size.increase", &json!({"by": null})).unwrap();
|
||||
s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap();
|
||||
s.execute("type.ruby", &json!({"text": "a", "size": null, "overhang": false})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
let st = &t.runs[0].style;
|
||||
assert!(st.underline && st.no_break && t.optical_margin && st.size > 30.0, "{st:?}");
|
||||
assert!(!t.ruby.is_empty() && t.ruby.iter().all(|r| !r.overhang && r.size_percent == 50.0), "{:?}", t.ruby);
|
||||
}
|
||||
|
||||
/// Where each glyph of type object `id` is drawn from (its origin in font units), document space.
|
||||
fn glyph_pens(s: &Session, id: NodeId) -> Vec<(usize, Point)> {
|
||||
let t = obj(s, id);
|
||||
|
||||
@@ -472,6 +472,8 @@ fn structured_junk() {
|
||||
("object.blend.make", json!({"ids": [1, 2, 3], "starts": [u64::MAX, -1, "x", null, 1e308]})),
|
||||
("object.blend.make", json!({"ids": [2, 2], "starts": {"a": 1}, "steps": 1e308})),
|
||||
("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})),
|
||||
("object.blend.options", json!({"ramp": 1e308, "colorShift": -1e308, "easingType": [1]})),
|
||||
("object.blend.make", json!({"ids": [1, 2], "ramp": f64::NAN, "colorShift": [1]})),
|
||||
("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})),
|
||||
("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})),
|
||||
("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})),
|
||||
|
||||
@@ -98,7 +98,10 @@ const STROKES: &str =
|
||||
"strokes a plain line can't draw (inside or outside the path, variable width, gradients, brushes, arrowheads) are written as filled outlines";
|
||||
const IMAGE_ALPHA: &str = "transparent image pixels are written over white";
|
||||
const LIVE: &str = "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art";
|
||||
const UNREADABLE_IMAGE: &str = "an image that can't be decoded or is too large was left out";
|
||||
/// The warning for an image left out because it can't be decoded: the note raster exports give
|
||||
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
|
||||
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
const LARGE_IMAGE: &str = "an image that is empty or too large for legacy .ai was left out";
|
||||
const ELLIPTICAL: &str = "elliptical radial gradients are written circular";
|
||||
|
||||
/// What to write in the header, and how to write what the format can't hold.
|
||||
@@ -1302,13 +1305,18 @@ impl<'a> Writer<'a> {
|
||||
if !invertible(m) {
|
||||
return;
|
||||
}
|
||||
let Some(img) = self.doc.images.get(&im.key).and_then(|b| image::load_from_memory(&b.bytes).ok()) else {
|
||||
self.warn(UNREADABLE_IMAGE);
|
||||
let blob = self.doc.images.get(&im.key);
|
||||
if blob.is_some_and(|b| b.bytes.is_empty()) {
|
||||
self.warn(LARGE_IMAGE);
|
||||
return;
|
||||
}
|
||||
let Some(img) = blob.and_then(|b| image::load_from_memory(&b.bytes).ok()) else {
|
||||
self.warn(UNDECODED);
|
||||
return;
|
||||
};
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS {
|
||||
self.warn(UNREADABLE_IMAGE);
|
||||
self.warn(LARGE_IMAGE);
|
||||
return;
|
||||
}
|
||||
// The object places the image's own pixel grid (its width × height) wherever the file's is.
|
||||
|
||||
@@ -33,6 +33,10 @@ const TRANSPARENCY: &str =
|
||||
const RASTER_FX: &str = "raster effects (shadows, glows, blurs, feathering) are left out: EPS has no transparency, flatten them first";
|
||||
const IMAGE_ALPHA_L2: &str = "transparent image pixels are written white at PostScript Level 2 (Level 3 masks them out)";
|
||||
const IMAGE_PARTLY: &str = "partly transparent image pixels are written over white";
|
||||
/// The warning for an image left out because it can't be decoded: the note raster exports give
|
||||
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
|
||||
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
const LARGE_IMAGE: &str = "an image that is too large for EPS was left out";
|
||||
const SPOT_GRADIENTS: &str = "gradients of spot colours are written in process colours";
|
||||
const MISSING_PATTERN: &str = "missing patterns are written as mid-grey";
|
||||
|
||||
@@ -859,11 +863,11 @@ impl<'a> Scene<'a> {
|
||||
/// Image `key` as RGBA pixels; `None` (with a warning) when it can't be written.
|
||||
fn decode_image(&mut self, key: &str) -> Option<image::RgbaImage> {
|
||||
let Some(img) = self.doc.images.get(key).and_then(|blob| image::load_from_memory(&blob.bytes).ok()) else {
|
||||
self.warn(&format!("image '{key}' could not be decoded and was left out"));
|
||||
self.warn(UNDECODED);
|
||||
return None;
|
||||
};
|
||||
if u64::from(img.width()) * u64::from(img.height()) > MAX_IMAGE_PIXELS {
|
||||
self.warn(&format!("image '{key}' is too large for EPS and was left out"));
|
||||
self.warn(LARGE_IMAGE);
|
||||
return None;
|
||||
}
|
||||
Some(img.to_rgba8())
|
||||
|
||||
@@ -382,6 +382,28 @@ fn images_are_written_in_the_form_the_specification_shows() {
|
||||
assert!(rgb.iter().zip([247.0, 148.0, 29.0]).all(|(c, want)| (c * 255.0 - want).abs() < 0.1), "{rgb:?}");
|
||||
}
|
||||
|
||||
/// An image whose data is empty is left out with the note for empty or too large images, and an
|
||||
/// image whose data can't be decoded is left out with the decode note.
|
||||
#[test]
|
||||
fn empty_images_get_the_size_note_and_undecodable_ones_the_decode_note() {
|
||||
let notes = |bytes: Vec<u8>| {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
doc.layers.clear();
|
||||
let layer = doc.add_layer(Some("Images"));
|
||||
let blob = ImageBlob::new("image/png", bytes);
|
||||
let key = blob.content_key();
|
||||
doc.images.insert(key.clone(), blob);
|
||||
let im = ImageObject { key, width: 4, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||||
let node = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
doc.insert(Some(layer), usize::MAX, node).unwrap();
|
||||
let out = export_legacy_ai(&doc, &LegacyAiOptions::default()).unwrap();
|
||||
assert!(rasters(&String::from_utf8(out.bytes).unwrap()).is_empty());
|
||||
out.warnings
|
||||
};
|
||||
assert_eq!(notes(vec![]), [LARGE_IMAGE]);
|
||||
assert_eq!(notes(b"not an image".to_vec()), [UNDECODED]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_fill_and_its_stroke_are_one_object() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -33,6 +33,9 @@ const CLIPPING: &str = "clipping masks are not applied: the art they clip is wri
|
||||
const FLAT_GRADIENTS: &str = "gradients are written as one colour, the average of their stops";
|
||||
const FLAT_PATTERNS: &str = "pattern fills are written as one colour";
|
||||
const TOO_MANY_TILES: &str = "pattern fills past 250,000 tiles in all are written as one colour";
|
||||
/// The warning for an image left out because it can't be decoded: the note raster exports give
|
||||
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
|
||||
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
|
||||
/// A stroke as a pen draws it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -449,7 +452,7 @@ impl<'a> Scene<'a> {
|
||||
.filter(|i| i.width > 0 && i.height > 0)
|
||||
.map(Arc::new);
|
||||
if img.is_none() {
|
||||
self.warn(&format!("image '{key}' could not be decoded and was left out"));
|
||||
self.warn(UNDECODED);
|
||||
}
|
||||
self.images.insert(key.to_string(), img.clone());
|
||||
img
|
||||
|
||||
@@ -24,6 +24,7 @@ image = { workspace = true }
|
||||
jpeg-encoder = { workspace = true }
|
||||
craft-pdf = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
smallvec = "1"
|
||||
weezl = "0.1"
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -569,6 +569,9 @@ pub(crate) struct Exporter<'a> {
|
||||
lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>,
|
||||
/// Images may ask viewers to smooth them (not in PDF/A).
|
||||
interpolate: bool,
|
||||
/// Images may have 16 bits a sample (PDF 1.5 and later); in earlier files they are written at
|
||||
/// 8 bits ([`embed`]).
|
||||
sixteen_bit: bool,
|
||||
/// How images are resampled and compressed.
|
||||
compression: &'a crate::CompressionSettings,
|
||||
/// Layers whose Print option is off are written too.
|
||||
@@ -629,6 +632,8 @@ impl<'a> Exporter<'a> {
|
||||
knockout: doc.page_knockout,
|
||||
lab_spots: vec![],
|
||||
interpolate: set.standard != Standard::PdfA2b,
|
||||
// PDF 1.3 and 1.4 files are written with the PDF 1.4 settings ([`Writer::new`]).
|
||||
sixteen_bit: !matches!(set.compatibility, Compatibility::Pdf13 | Compatibility::Pdf14),
|
||||
compression: &set.compression,
|
||||
non_printing: false,
|
||||
intent: vectorcraft_color::cms::active().settings().intent,
|
||||
@@ -1791,7 +1796,7 @@ impl Exporter<'_> {
|
||||
if let Some(w) = r.warning {
|
||||
self.warn(w);
|
||||
}
|
||||
r.image.or_else(|| embed(blob, interpolate))
|
||||
r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit))
|
||||
});
|
||||
if img.is_none() {
|
||||
self.warn(UNDECODED);
|
||||
@@ -1851,11 +1856,41 @@ fn placed_size(im: &vectorcraft_doc::ImageObject) -> (f64, f64) {
|
||||
(im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d))
|
||||
}
|
||||
|
||||
/// Does PNG `bytes` have 16 bits a sample: the bit depth of its IHDR chunk, which follows the
|
||||
/// signature?
|
||||
fn png_16_bit(bytes: &[u8]) -> bool {
|
||||
bytes.get(12..16) == Some(b"IHDR".as_slice()) && bytes.get(24) == Some(&16)
|
||||
}
|
||||
|
||||
/// PNG `bytes` at 8 bits a sample: decoded, and encoded again as gray or RGB, with alpha when the
|
||||
/// source has it, and with the source's ICC profile. `None` when it can't be decoded.
|
||||
fn png_8_bit(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
use image::{ColorType, DynamicImage, ImageDecoder, ImageEncoder};
|
||||
let mut decoder = image::codecs::png::PngDecoder::new(std::io::Cursor::new(bytes)).ok()?;
|
||||
let icc = decoder.icc_profile().ok().flatten();
|
||||
let img = DynamicImage::from_decoder(decoder).ok()?;
|
||||
let img = match img.color() {
|
||||
ColorType::L8 | ColorType::L16 => DynamicImage::ImageLuma8(img.into_luma8()),
|
||||
ColorType::La8 | ColorType::La16 => DynamicImage::ImageLumaA8(img.into_luma_alpha8()),
|
||||
ColorType::Rgb8 | ColorType::Rgb16 => DynamicImage::ImageRgb8(img.into_rgb8()),
|
||||
_ => DynamicImage::ImageRgba8(img.into_rgba8()),
|
||||
};
|
||||
let mut png = vec![];
|
||||
let mut encoder = image::codecs::png::PngEncoder::new(&mut png);
|
||||
if let Some(icc) = icc {
|
||||
encoder.set_icc_profile(icc).ok()?;
|
||||
}
|
||||
img.write_with_encoder(encoder).ok()?;
|
||||
Some(png)
|
||||
}
|
||||
|
||||
/// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged),
|
||||
/// other formats decoded.
|
||||
fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool) -> Option<Image> {
|
||||
/// other formats decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG of 16 bits a
|
||||
/// sample is written at 8 bits ([`png_8_bit`]).
|
||||
fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option<Image> {
|
||||
let bytes = blob.bytes.as_ref().clone();
|
||||
let direct = match blob.mime.as_str() {
|
||||
"image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()),
|
||||
"image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(),
|
||||
"image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(),
|
||||
"image/gif" => Image::from_gif(bytes.clone().into(), interpolate).ok(),
|
||||
@@ -1893,4 +1928,22 @@ mod tests {
|
||||
s.finish();
|
||||
page.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_16_bit_png_at_8_bits_keeps_its_color_type_and_icc_profile() {
|
||||
use image::{ImageDecoder, ImageEncoder};
|
||||
let icc = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::GRAY).unwrap().to_vec();
|
||||
let gray = image::DynamicImage::ImageLuma16(image::ImageBuffer::from_fn(4, 2, |x, y| image::Luma([x as u16 * 20000 + y as u16])));
|
||||
let mut png = vec![];
|
||||
let mut encoder = image::codecs::png::PngEncoder::new(&mut png);
|
||||
encoder.set_icc_profile(icc.clone()).unwrap();
|
||||
gray.write_with_encoder(encoder).unwrap();
|
||||
assert!(png_16_bit(&png));
|
||||
let out = png_8_bit(&png).unwrap();
|
||||
assert!(!png_16_bit(&out));
|
||||
let mut decoder = image::codecs::png::PngDecoder::new(std::io::Cursor::new(out.as_slice())).unwrap();
|
||||
assert_eq!(decoder.icc_profile().unwrap(), Some(icc));
|
||||
assert_eq!(decoder.color_type(), image::ColorType::L8);
|
||||
assert_eq!(image::DynamicImage::from_decoder(decoder).unwrap().to_luma8(), gray.to_luma8());
|
||||
}
|
||||
}
|
||||
+12
-15
@@ -234,14 +234,8 @@ fn pdf_date(d: DateTime) -> Option<i64> {
|
||||
|
||||
/// Whether `day` is a day of `month` (1 to 12) in `year` (proleptic Gregorian).
|
||||
fn in_month(year: i64, month: i64, day: i64) -> bool {
|
||||
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
|
||||
let last = match month {
|
||||
2 if leap => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
_ => 31,
|
||||
};
|
||||
(1..=last).contains(&day)
|
||||
let ymd = i32::try_from(year).ok().zip(u32::try_from(month).ok()).zip(u32::try_from(day).ok());
|
||||
ymd.is_some_and(|((y, m), d)| chrono::NaiveDate::from_ymd_opt(y, m, d).is_some())
|
||||
}
|
||||
|
||||
/// An XMP date (ISO 16684-1: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, then optionally `Thh:mm`, `:ss` with
|
||||
@@ -282,13 +276,16 @@ fn xmp_date(s: &str) -> Option<i64> {
|
||||
}
|
||||
|
||||
/// `[year, month, day, hour, minute, second]` at a UTC offset of `offset` minutes → Unix seconds
|
||||
/// (days-from-civil, proleptic Gregorian: the inverse of [`vectorcraft_doc::metadata::civil`]).
|
||||
/// (the inverse of [`vectorcraft_doc::metadata::civil`]). The date has already passed [`in_month`],
|
||||
/// so it is a real day; an impossible one reads as the epoch rather than panicking.
|
||||
fn unix([year, month, day, hour, minute, second]: [i64; 6], offset: i64) -> i64 {
|
||||
let y = if month <= 2 { year - 1 } else { year };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let doy = (153 * ((month + 9) % 12) + 2) / 5 + day - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146_097 + doe - 719_468;
|
||||
let days = i32::try_from(year)
|
||||
.ok()
|
||||
.zip(u32::try_from(month).ok())
|
||||
.zip(u32::try_from(day).ok())
|
||||
.and_then(|((y, m), d)| chrono::NaiveDate::from_ymd_opt(y, m, d))
|
||||
.zip(chrono::NaiveDate::from_ymd_opt(1970, 1, 1))
|
||||
.map(|(date, epoch)| (date - epoch).num_days())
|
||||
.unwrap_or(0);
|
||||
days * 86_400 + hour * 3600 + minute * 60 + second - offset * 60
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Image compression and downsampling: images above the threshold resolution are resampled to the
|
||||
//! target, compressed with JPEG or ZIP as their kind (colour, greyscale, monochrome) asks, and the
|
||||
//! codecs the writer lacks are written as ZIP with a warning.
|
||||
//! codecs the writer lacks are written as ZIP with a warning. In files before PDF 1.5, 16-bit
|
||||
//! images are written at 8 bits.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
@@ -81,6 +82,59 @@ fn jpeg_zip_and_automatic_compression() {
|
||||
assert!(!text.contains("/DCTDecode") && text.contains("/SMask"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_images_are_written_at_8_bits_before_pdf_1_5() {
|
||||
let mut clear = colour().to_rgba8();
|
||||
clear.put_pixel(0, 0, image::Rgba([0, 0, 0, 0]));
|
||||
let clear = DynamicImage::from(clear);
|
||||
// 16-bit PNGs of RGB, gray and RGBA pixels, and the 8-bit pixels they hold.
|
||||
let images = [
|
||||
("RGB", DynamicImage::ImageRgb16(colour().to_rgb16()), colour()),
|
||||
("gray", DynamicImage::ImageLuma16(colour().grayscale().to_luma16()), colour().grayscale()),
|
||||
("RGBA", DynamicImage::ImageRgba16(clear.to_rgba16()), clear),
|
||||
];
|
||||
for (kind, deep, pixels) in images {
|
||||
let d = doc(encoded(deep, ImageFormat::Png), "image/png");
|
||||
// PDF/X-1a and PDF/X-3 files are PDF 1.3. A PDF 1.3 file has no soft masks: the app
|
||||
// flattens an image with transparency before it is written.
|
||||
let mut cases = vec![(json!({"compatibility": "1.4"}), 8), (json!({"compatibility": "1.5"}), 16), (json!({"compatibility": "1.7"}), 16)];
|
||||
if kind != "RGBA" {
|
||||
cases.extend([(json!({"compatibility": "1.3"}), 8), (json!({"standard": "pdfX3", "compatibility": "1.3"}), 8)]);
|
||||
}
|
||||
if kind == "gray" {
|
||||
cases.push((json!({"standard": "pdfX1a", "compatibility": "1.3"}), 8));
|
||||
}
|
||||
for (case, depth) in cases {
|
||||
let mut settings: PdfSettings = serde_json::from_value(case.clone()).unwrap();
|
||||
settings.compression.compress_text = false;
|
||||
let r = export_with_report(&d, &PdfOptions { settings, created: Some(0), ..Default::default() })
|
||||
.unwrap_or_else(|e| panic!("{kind}, {case}: {e}"));
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
let other = if depth == 8 { 16 } else { 8 };
|
||||
assert!(
|
||||
text.contains(&format!("/BitsPerComponent {depth}")) && !text.contains(&format!("/BitsPerComponent {other}")),
|
||||
"{kind}, {case}: {depth} bits"
|
||||
);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("decoded")), "{kind}, {case}: {:?}", r.warnings);
|
||||
if depth == 8 {
|
||||
// The image reads back with the 8-bit pixels (within 1 for the PDF/X-3 file's RGB
|
||||
// image, which is tagged with the sRGB profile).
|
||||
let back = import(&r.bytes).unwrap();
|
||||
let got = image::load_from_memory(&back.images.values().next().unwrap().bytes).unwrap().to_rgba8();
|
||||
let close = got.dimensions() == (600, 600) && got.as_raw().iter().zip(pixels.to_rgba8().as_raw()).all(|(a, b)| a.abs_diff(*b) <= 1);
|
||||
assert!(back.images.len() == 1 && close, "{kind}, {case}: the pixels");
|
||||
}
|
||||
}
|
||||
}
|
||||
// A 16-bit PNG whose pixel data can't be decoded is left out with the note.
|
||||
let mut cut = encoded(DynamicImage::ImageRgb16(colour().to_rgb16()), ImageFormat::Png);
|
||||
let at = cut.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
cut.truncate(at + 10);
|
||||
let settings = serde_json::from_value(json!({"compatibility": "1.4"})).unwrap();
|
||||
let r = export_with_report(&doc(cut, "image/png"), &PdfOptions { settings, ..Default::default() }).unwrap();
|
||||
assert_eq!(r.warnings, ["an image couldn't be decoded and is missing from the picture"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codecs_the_writer_lacks_are_zip_with_a_warning() {
|
||||
let png = doc(encoded(colour(), ImageFormat::Png), "image/png");
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! What draws differently after an edit, so a view can draw again only there.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
/// What may draw differently in a document than in its version before an edit ([`changed`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Changed {
|
||||
Nothing,
|
||||
/// This area of the document, outside of which every pixel draws as before.
|
||||
Area(Rect),
|
||||
Everything,
|
||||
}
|
||||
|
||||
/// What may draw differently in `new` than in `old`, the version before an edit: the bounds of
|
||||
/// everything the objects that changed paint, before and after the edit ([`crate::painted_bounds`]),
|
||||
/// or everything when more than the art in the layers changed, or while a pattern is edited (its
|
||||
/// art also draws in the copies round it). Objects that an edit doesn't touch
|
||||
/// keep their identity, so this costs what changed, not the size of the document.
|
||||
pub fn changed(old: &Document, new: &Document) -> Changed {
|
||||
if new.pattern_edit.is_some() || !old.same_outside_layers(new) {
|
||||
return Changed::Everything;
|
||||
}
|
||||
let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None };
|
||||
diff.children(&old.layers, &new.layers);
|
||||
diff.area.map_or(Changed::Nothing, Changed::Area)
|
||||
}
|
||||
|
||||
/// The pixels of a `w`×`h` render through `view` that drawing `area` again covers, as x0, y0, x1,
|
||||
/// y1: its box in the view, grown by a pixel for the anti-aliased edges. None when it covers none.
|
||||
pub fn view_pixels(area: Rect, view: Affine, w: u32, h: u32) -> Option<[u32; 4]> {
|
||||
let r = view.transform_rect_bbox(area).inflate(1.0, 1.0);
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|c| c.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (x0, y0) = (r.x0.floor().max(0.0), r.y0.floor().max(0.0));
|
||||
let (x1, y1) = (r.x1.ceil().min(f64::from(w)), r.y1.ceil().min(f64::from(h)));
|
||||
(x1 > x0 && y1 > y0).then_some([x0 as u32, y0 as u32, x1 as u32, y1 as u32])
|
||||
}
|
||||
|
||||
/// [`changed`] in progress: the symbols' boxes of both versions, and the area so far.
|
||||
struct Diff<'a> {
|
||||
old: SymbolBoxes<'a>,
|
||||
new: SymbolBoxes<'a>,
|
||||
area: Option<Rect>,
|
||||
}
|
||||
|
||||
impl Diff<'_> {
|
||||
/// Members in the same order compare one by one. Members added, removed or restacked count
|
||||
/// before and after, except those that kept their place unchanged.
|
||||
fn children(&mut self, old: &[Arc<Node>], new: &[Arc<Node>]) {
|
||||
if old.len() == new.len() && old.iter().zip(new).all(|(a, b)| a.id == b.id) {
|
||||
for (a, b) in old.iter().zip(new) {
|
||||
self.node(a, b);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for i in 0..old.len().max(new.len()) {
|
||||
match (old.get(i), new.get(i)) {
|
||||
(Some(a), Some(b)) if Arc::ptr_eq(a, b) => {}
|
||||
(a, b) => {
|
||||
self.add_old(a.map(|a| &**a));
|
||||
self.add_new(b.map(|b| &**b));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group or layer that only its members changed in, without a clipping mask or effects of
|
||||
/// its own that draw from all of them, changed where they did. Any other object that changed
|
||||
/// counts whole, before and after.
|
||||
fn node(&mut self, old: &Arc<Node>, new: &Arc<Node>) {
|
||||
if Arc::ptr_eq(old, new) {
|
||||
return;
|
||||
}
|
||||
if let (Some(a), Some(b)) = (members(old), members(new))
|
||||
&& same_but_members(old, new)
|
||||
{
|
||||
self.children(a, b);
|
||||
} else {
|
||||
self.add_old(Some(old));
|
||||
self.add_new(Some(new));
|
||||
}
|
||||
}
|
||||
|
||||
fn add_old(&mut self, n: Option<&Node>) {
|
||||
self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.old, n)));
|
||||
}
|
||||
|
||||
fn add_new(&mut self, n: Option<&Node>) {
|
||||
self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.new, n)));
|
||||
}
|
||||
|
||||
fn add(&mut self, r: Option<Rect>) {
|
||||
self.area = vectorcraft_geom::union_opt(self.area, r);
|
||||
}
|
||||
}
|
||||
|
||||
/// The members of a group or layer whose members draw each on their own: no clipping mask, and no
|
||||
/// effects of its own.
|
||||
fn members(n: &Node) -> Option<&[Arc<Node>]> {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !crate::fx::has_object_fx(n) => Some(children),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Are `a` and `b` the same but for their members?
|
||||
fn same_but_members(a: &Node, b: &Node) -> bool {
|
||||
let strip = |n: &Node| {
|
||||
let mut n = n.clone();
|
||||
if let Some(children) = n.children_mut() {
|
||||
children.clear();
|
||||
}
|
||||
n
|
||||
};
|
||||
strip(a) == strip(b)
|
||||
}
|
||||
+46
-12
@@ -478,12 +478,26 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
|
||||
}
|
||||
_ => {
|
||||
let b = n.visual_bounds_with(symbols)?;
|
||||
let b = match &n.kind {
|
||||
NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b),
|
||||
_ => b,
|
||||
};
|
||||
let a = live_content_reach(symbols, n);
|
||||
Some(b.inflate(a, a))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The box of type `t`'s glyphs as laid out (its layout bounds, else laid out now), grown by its
|
||||
/// characters' strokes, miter spikes included, and by `outset`: on a path they stand above it and
|
||||
/// run past its ends, in an area they can hang past its frame, where its own box
|
||||
/// ([`Node::visual_bounds_with`]) doesn't.
|
||||
fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option<Rect> {
|
||||
let b = t.cached_bounds.unwrap_or_else(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds);
|
||||
let o = outset.max(t.char_stroke_reach());
|
||||
(b.width() > 0.0 || b.height() > 0.0).then(|| t.xf.transform_rect_bbox(b).inflate(o, o))
|
||||
}
|
||||
|
||||
/// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual
|
||||
/// bounds: for each object in it, how far the effects in a symbol instance's art paint past that
|
||||
/// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the
|
||||
@@ -643,7 +657,7 @@ pub(crate) struct PixelEntry {
|
||||
/// For one cropped to the view, or made at a lower resolution, the view and the rect it was
|
||||
/// made for: it draws only there, so dragging other art over the object keeps it (a wide blur,
|
||||
/// or any blur zoomed in, reaches past the pixel budget).
|
||||
view: Option<ViewKey>,
|
||||
view: Option<ViewCrop>,
|
||||
made: Made,
|
||||
/// What the content read when the raster was made, with the channels the effects ran on and,
|
||||
/// under De-Interlace, the raster resolution.
|
||||
@@ -672,13 +686,29 @@ fn linear_key(view: Affine) -> [u64; 4] {
|
||||
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
|
||||
}
|
||||
|
||||
/// A view's six coefficients (its pan included) and the device-pixel rect a frame draws in, as a
|
||||
/// cache key: a raster cropped to that rect, or made at a lower resolution than the view's, draws
|
||||
/// only in that view of that rect ([`PixelEntry::view`], [`ShadowEntry::view`]).
|
||||
type ViewKey = ([u64; 6], [u64; 4]);
|
||||
/// The pan of the view a raster was cropped for, or made at a lower resolution than the view's, and
|
||||
/// the device-pixel rect of the frame it was made in ([`PixelEntry::view`], [`ShadowEntry::view`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ViewCrop {
|
||||
pan: Vec2,
|
||||
screen: Rect,
|
||||
}
|
||||
|
||||
fn view_key(view: Affine, screen: Rect) -> ViewKey {
|
||||
(view.as_coeffs().map(f64::to_bits), [screen.x0, screen.y0, screen.x1, screen.y1].map(f64::to_bits))
|
||||
impl ViewCrop {
|
||||
fn of(view: Affine, screen: Rect) -> Self {
|
||||
Self { pan: view.translation(), screen }
|
||||
}
|
||||
|
||||
/// Does the raster draw in a frame through `view` (of the same zoom and rotation) onto
|
||||
/// `screen`? When the view is panned by whole pixels and the frame's rect, panned back, lies
|
||||
/// within the one the raster was made in: the same pixels in the same places, as when a frame
|
||||
/// is drawn again only in a part of it.
|
||||
fn serves(&self, view: Affine, screen: Rect) -> bool {
|
||||
let d = view.translation() - self.pan;
|
||||
let whole = Vec2::new(d.x.round(), d.y.round());
|
||||
let s = screen - whole;
|
||||
(d - whole).hypot() < 1e-6 && s.x0 >= self.screen.x0 && s.y0 >= self.screen.y0 && s.x1 <= self.screen.x1 && s.y1 <= self.screen.y1
|
||||
}
|
||||
}
|
||||
|
||||
/// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while
|
||||
@@ -768,7 +798,7 @@ pub(crate) struct ShadowEntry {
|
||||
/// `None` for a raster of the whole silhouette's reach: it draws after a pan too. For one
|
||||
/// cropped to the view, or made over such a raster, the view and the rect it was made for: it
|
||||
/// draws only there ([`PixelEntry::view`]).
|
||||
view: Option<ViewKey>,
|
||||
view: Option<ViewCrop>,
|
||||
made: Made,
|
||||
/// What the silhouette read when the raster was made.
|
||||
inputs: Inputs,
|
||||
@@ -1084,9 +1114,9 @@ impl Renderer {
|
||||
// What the silhouette reads: as the raster kept for `n` found it, else found now.
|
||||
let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads);
|
||||
let inputs = self.raster_inputs(f, n, reads);
|
||||
let in_view = view_key(f.view, screen);
|
||||
let in_view = ViewCrop::of(f.view, screen);
|
||||
let exact = |e: &ShadowEntry| {
|
||||
e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v == in_view)
|
||||
e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v.serves(f.view, screen))
|
||||
};
|
||||
if interacting && !self.shadows.get(&key).is_some_and(exact) {
|
||||
self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint);
|
||||
@@ -1784,7 +1814,7 @@ impl Renderer {
|
||||
// The cached raster: of the whole content at full size at this zoom, else of this view.
|
||||
let linear = linear_key(f.view);
|
||||
let whole = fits && k >= 1.0;
|
||||
let in_view = (!whole).then(|| view_key(f.view, screen));
|
||||
let in_view = (!whole).then(|| ViewCrop::of(f.view, screen));
|
||||
let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink));
|
||||
let stamp = self.stamp;
|
||||
let channels = match f.ink {
|
||||
@@ -1803,8 +1833,12 @@ impl Renderer {
|
||||
self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits())))
|
||||
});
|
||||
let interacting = f.opts.interacting;
|
||||
let in_place = |e: &PixelEntry| match e.view {
|
||||
None => whole,
|
||||
Some(v) => !whole && v.serves(f.view, screen),
|
||||
};
|
||||
let exact = |e: &PixelEntry| {
|
||||
e.linear == linear && e.view == in_view && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved)
|
||||
e.linear == linear && in_place(e) && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved)
|
||||
};
|
||||
if interacting
|
||||
&& whole
|
||||
|
||||
+17
-14
@@ -7,6 +7,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod brush_fx;
|
||||
mod changed;
|
||||
pub mod encode;
|
||||
mod freeform;
|
||||
mod fx;
|
||||
@@ -35,6 +36,7 @@ use ink::Ink;
|
||||
use lru::FrameCache;
|
||||
|
||||
pub use brush_fx::instance_art;
|
||||
pub use changed::{Changed, changed, view_pixels};
|
||||
pub use effects::stroke::width_outline;
|
||||
pub use images::{IMAGE_CACHE_MAX, image_cache_bytes, set_image_cache_budget};
|
||||
pub use live::expand_live;
|
||||
@@ -101,7 +103,7 @@ pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> {
|
||||
}
|
||||
|
||||
/// Rendering options.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RenderOptions {
|
||||
/// Outline (wireframe) view: 1 px black paths, no paint.
|
||||
pub outline: bool,
|
||||
@@ -444,8 +446,6 @@ pub struct Renderer {
|
||||
text_fonts: u64,
|
||||
/// Clip regions per clipping path (see [`Self::clip_of`]).
|
||||
clips: FrameCache<usize, ClipEntry>,
|
||||
/// Context reused when rendering single-threaded (`threads == 0`).
|
||||
ctx_st: Option<RenderContext>,
|
||||
/// Worker threads for the multithreaded rasterizer (0 = single-threaded).
|
||||
pub threads: u16,
|
||||
geom: PtrMap<usize, GeomEntry>,
|
||||
@@ -476,7 +476,10 @@ pub struct Renderer {
|
||||
stamp: u64,
|
||||
/// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer).
|
||||
alpha: f32,
|
||||
ctx: Option<RenderContext>,
|
||||
/// The contexts kept for the next frames, with the threads they draw on, the last used last: a
|
||||
/// frame of the size of one reuses it instead of making a context and its threads. Two are
|
||||
/// kept, so a view's frames and the parts of them drawn again ([`changed`]) each keep theirs.
|
||||
contexts: Vec<(u16, RenderContext)>,
|
||||
resources: Resources,
|
||||
/// Statistics of the last frame.
|
||||
pub stats: FrameStats,
|
||||
@@ -613,7 +616,6 @@ impl Renderer {
|
||||
texts: FrameCache::new(lru::TEXT_BUDGET, 1024),
|
||||
text_fonts: 0,
|
||||
clips: FrameCache::new(lru::CLIP_BUDGET, 256),
|
||||
ctx_st: None,
|
||||
threads: default_threads(),
|
||||
geom: PtrMap::default(),
|
||||
symbols: vec![],
|
||||
@@ -625,7 +627,7 @@ impl Renderer {
|
||||
fx_geom: PtrMap::default(),
|
||||
stamp: 0,
|
||||
alpha: 1.0,
|
||||
ctx: None,
|
||||
contexts: Vec::new(),
|
||||
resources: Resources::new(),
|
||||
stats: FrameStats::default(),
|
||||
brushes: Default::default(),
|
||||
@@ -761,13 +763,13 @@ impl Renderer {
|
||||
/// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art.
|
||||
fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec<u8> {
|
||||
let (threads, opts) = (self.threads, f.opts);
|
||||
let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() };
|
||||
let mut ctx = match slot {
|
||||
Some(mut c) if c.width() == w && c.height() == h => {
|
||||
let kept = self.contexts.iter().position(|(t, c)| *t == threads && c.width() == w && c.height() == h);
|
||||
let mut ctx = match kept.map(|i| self.contexts.remove(i)) {
|
||||
Some((_, mut c)) => {
|
||||
c.reset();
|
||||
c
|
||||
}
|
||||
_ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }),
|
||||
None => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }),
|
||||
};
|
||||
// `reset` keeps the threshold of the previous render: set it every time.
|
||||
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
|
||||
@@ -800,10 +802,9 @@ impl Renderer {
|
||||
self.draw_page(&mut ctx, f);
|
||||
}
|
||||
let pm = self.finish(&mut ctx, self.raster);
|
||||
if threads == 0 {
|
||||
self.ctx_st = Some(ctx);
|
||||
} else {
|
||||
self.ctx = Some(ctx);
|
||||
self.contexts.push((threads, ctx));
|
||||
if self.contexts.len() > 2 {
|
||||
self.contexts.remove(0);
|
||||
}
|
||||
pm.data_as_u8_slice().to_vec()
|
||||
}
|
||||
@@ -2560,6 +2561,8 @@ mod tests_alternate;
|
||||
#[cfg(test)]
|
||||
mod tests_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_changed;
|
||||
#[cfg(test)]
|
||||
mod tests_charstroke;
|
||||
#[cfg(test)]
|
||||
mod tests_clip;
|
||||
|
||||
@@ -22,7 +22,7 @@ use std::sync::{Arc, Weak};
|
||||
use vectorcraft_color::cms::Cms;
|
||||
use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType};
|
||||
use vectorcraft_doc::{Document, Node};
|
||||
use vectorcraft_geom::{Affine, Rect, Shape};
|
||||
use vectorcraft_geom::{Affine, Rect, Shape, Vec2};
|
||||
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
|
||||
use vello_cpu::{Pixmap, RenderContext};
|
||||
|
||||
@@ -299,6 +299,22 @@ pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option<Re
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Dimmed pattern-editing preview copies at `offsets`.
|
||||
fn draw_dimmed_pattern_copies(&mut self, ctx: &mut RenderContext, f: &Frame, dim: f32, offsets: &[Vec2], children: &[Arc<Node>]) {
|
||||
if offsets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let comp = crate::group::Composite { opacity: dim, ..Default::default() };
|
||||
self.group(ctx, f, comp, &mut |r, c, fr| {
|
||||
for o in offsets {
|
||||
let frame = Frame { view: fr.view * Affine::translate(*o), visible: fr.visible - *o, ..*fr };
|
||||
for a in children {
|
||||
r.draw_arc(c, &frame, a);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Pattern editing mode: draw the tile layer, its dimmed copies and the tile edge instead of
|
||||
/// the document. Returns false when not in pattern editing mode.
|
||||
pub(crate) fn draw_pattern_edit(&mut self, ctx: &mut RenderContext, f: &Frame) -> bool {
|
||||
@@ -308,17 +324,13 @@ impl Renderer {
|
||||
let children: Vec<Arc<Node>> = layer.children().cloned().unwrap_or_default();
|
||||
if layer.visible && !children.is_empty() {
|
||||
let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0);
|
||||
let comp = crate::group::Composite { opacity: dim, ..Default::default() };
|
||||
self.group(ctx, f, comp, &mut |r, c, fr| {
|
||||
for o in def.preview_offsets() {
|
||||
let frame = Frame { view: fr.view * Affine::translate(o), visible: fr.visible - o, ..*fr };
|
||||
for a in &children {
|
||||
r.draw_arc(c, &frame, a);
|
||||
}
|
||||
}
|
||||
});
|
||||
let (before, after) = def.preview_offsets_dimmed_sides();
|
||||
self.draw_dimmed_pattern_copies(ctx, f, dim, &before, &children);
|
||||
self.draw_arc(ctx, f, layer);
|
||||
self.draw_dimmed_pattern_copies(ctx, f, dim, &after, &children);
|
||||
} else {
|
||||
self.draw_arc(ctx, f, layer);
|
||||
}
|
||||
self.draw_arc(ctx, f, layer);
|
||||
// The tile edge and swatch bounds are editing aids: exports and thumbnails leave them out,
|
||||
// as they do guides (#3261).
|
||||
if f.opts.skip_templates {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Drawing again only what an edit changed gives the pixels of drawing it all again.
|
||||
|
||||
use super::*;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::Appearance;
|
||||
use vectorcraft_geom::{Point, shapes};
|
||||
|
||||
const W: u32 = 160;
|
||||
const H: u32 = 120;
|
||||
|
||||
/// A view that doesn't line the art up with the pixels, so the edges are anti-aliased.
|
||||
fn view() -> Affine {
|
||||
Affine::translate((3.3, 2.7)) * Affine::scale(1.37)
|
||||
}
|
||||
|
||||
fn rect(d: &mut Document, r: Rect, fill: Color) -> Node {
|
||||
let id = d.alloc_id();
|
||||
Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::solid(Color::BLACK), 2.0))
|
||||
}
|
||||
|
||||
fn shadow() -> vectorcraft_doc::Effect {
|
||||
vectorcraft_doc::Effect {
|
||||
id: "stylize.dropShadow".into(),
|
||||
params: serde_json::json!({"x": 6.0, "y": 6.0, "blur": 4.0, "opacity": 75.0}),
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer with a rectangle, a group at half opacity holding two, a clip group, and one with a
|
||||
/// drop shadow; the ids of the loose rectangle, a member of each group, and the shadowed one.
|
||||
fn document() -> (Document, [NodeId; 5]) {
|
||||
let mut d = Document::new(110.0, 80.0);
|
||||
let l = d.layers[0].id;
|
||||
let loose = rect(&mut d, Rect::new(5.0, 5.0, 25.0, 20.0), Color::rgb(1.0, 0.0, 0.0));
|
||||
let loose = d.insert(Some(l), 0, loose).unwrap();
|
||||
let (a, b) = (
|
||||
rect(&mut d, Rect::new(30.0, 5.0, 50.0, 25.0), Color::rgb(0.0, 0.6, 0.0)),
|
||||
rect(&mut d, Rect::new(40.0, 15.0, 60.0, 35.0), Color::rgb(0.0, 0.0, 1.0)),
|
||||
);
|
||||
let mut group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(a), Arc::new(b)], clip: false });
|
||||
group.opacity = 0.5;
|
||||
let member = group.children().unwrap()[1].id;
|
||||
d.insert(Some(l), 1, group).unwrap();
|
||||
let (mask, art) =
|
||||
(rect(&mut d, Rect::new(5.0, 40.0, 30.0, 60.0), Color::WHITE), rect(&mut d, Rect::new(0.0, 45.0, 40.0, 55.0), Color::rgb(0.8, 0.4, 0.0)));
|
||||
let clipped = art.id;
|
||||
let clip = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(art)], clip: true });
|
||||
d.insert(Some(l), 2, clip).unwrap();
|
||||
let mut shadowed = rect(&mut d, Rect::new(60.0, 45.0, 80.0, 60.0), Color::rgb(0.9, 0.9, 0.2));
|
||||
shadowed.appearance.effects.push(shadow());
|
||||
let shadowed = d.insert(Some(l), 3, shadowed).unwrap();
|
||||
let other = rect(&mut d, Rect::new(85.0, 10.0, 105.0, 30.0), Color::rgb(0.5, 0.0, 0.5));
|
||||
let other = d.insert(Some(l), 4, other).unwrap();
|
||||
(d, [loose, member, clipped, shadowed, other])
|
||||
}
|
||||
|
||||
/// Draws `new` again only where it changed from `old`, over `old` drawn whole, and checks that
|
||||
/// this is `new` drawn whole; returns what changed.
|
||||
fn redraw(old: &Document, new: &Document) -> Changed {
|
||||
let mut r = Renderer::new();
|
||||
let opts = RenderOptions::default();
|
||||
let before = r.render(old, W, H, view(), &opts);
|
||||
let after = r.render(new, W, H, view(), &opts);
|
||||
let change = changed(old, new);
|
||||
let mut patched = Rendered { width: W, height: H, pixels: before.pixels.clone() };
|
||||
match change {
|
||||
Changed::Everything => patched.pixels = after.pixels.clone(),
|
||||
Changed::Nothing => {}
|
||||
Changed::Area(area) => {
|
||||
if let Some([x0, y0, x1, y1]) = view_pixels(area, view(), W, H) {
|
||||
let part = r.render(new, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view(), &opts);
|
||||
for y in 0..y1 - y0 {
|
||||
let (from, to) = ((y * (x1 - x0) * 4) as usize, (((y0 + y) * W + x0) * 4) as usize);
|
||||
let n = ((x1 - x0) * 4) as usize;
|
||||
patched.pixels[to..to + n].copy_from_slice(&part.pixels[from..from + n]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(patched.pixels == after.pixels, "{change:?}: drawn again only there, it differs from drawing it all");
|
||||
change
|
||||
}
|
||||
|
||||
fn area(c: Changed) -> Rect {
|
||||
match c {
|
||||
Changed::Area(a) => a,
|
||||
other => panic!("an area: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_an_object_changes_where_it_was_and_is() {
|
||||
let (old, [loose, ..]) = document();
|
||||
let mut new = old.clone();
|
||||
new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false);
|
||||
let a = area(redraw(&old, &new));
|
||||
assert!(a.contains(Point::new(6.0, 6.0)) && a.contains(Point::new(31.0, 22.0)), "before and after: {a:?}");
|
||||
assert!(a.x1 < 35.0, "not the objects beside it: {a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recolouring_a_member_of_a_translucent_group_changes_only_the_member() {
|
||||
let (old, [_, member, ..]) = document();
|
||||
let mut new = old.clone();
|
||||
new.node_mut(member).unwrap().appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(1.0, 0.5, 0.0));
|
||||
let a = area(redraw(&old, &new));
|
||||
assert!(a.x0 > 35.0, "not the group's other member: {a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_clipped_art_changes_its_whole_clip_group() {
|
||||
let (old, [_, _, clipped, ..]) = document();
|
||||
let mut new = old.clone();
|
||||
new.node_mut(clipped).unwrap().transform(Affine::translate((0.0, 4.0)), false);
|
||||
redraw(&old, &new);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_shadowed_object_changes_where_its_shadow_falls() {
|
||||
let (old, [.., shadowed, _]) = document();
|
||||
let mut new = old.clone();
|
||||
new.node_mut(shadowed).unwrap().transform(Affine::translate((-5.0, 5.0)), false);
|
||||
let a = area(redraw(&old, &new));
|
||||
assert!(a.x1 > 86.0 && a.y1 > 70.0, "the shadow, before and after: {a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_removing_and_restacking_change_those_objects() {
|
||||
let (old, [loose, _, _, _, other]) = document();
|
||||
let mut added = old.clone();
|
||||
let n = rect(&mut added, Rect::new(70.0, 5.0, 90.0, 15.0), Color::BLACK);
|
||||
let l = added.layers[0].id;
|
||||
added.insert(Some(l), 5, n).unwrap();
|
||||
redraw(&old, &added);
|
||||
let mut removed = old.clone();
|
||||
removed.remove(loose).unwrap();
|
||||
redraw(&old, &removed);
|
||||
let mut restacked = old.clone();
|
||||
let n = restacked.remove(other).unwrap();
|
||||
restacked.insert(Some(l), 0, (*n).clone()).unwrap();
|
||||
redraw(&old, &restacked);
|
||||
}
|
||||
|
||||
/// A blur too wide for a raster of all of it is cropped to the frame. A part of the frame drawn
|
||||
/// again, during a drag too, draws it from that raster: the same pixels, and no preview at the
|
||||
/// drag's lower resolution.
|
||||
#[test]
|
||||
fn a_part_of_a_frame_draws_a_cropped_blur_from_the_frames_raster() {
|
||||
let (mut d, _) = document();
|
||||
let mut back = rect(&mut d, Rect::new(0.0, 0.0, 110.0, 80.0), Color::rgb(0.1, 0.3, 0.9));
|
||||
back.appearance.effects.push(vectorcraft_doc::Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 500.0}), visible: true });
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, back).unwrap();
|
||||
let view = Affine::scale(2.0) * Affine::translate((-20.0, -15.0));
|
||||
let mut r = Renderer::new();
|
||||
let whole = r.render(&d, W, H, view, &RenderOptions::default());
|
||||
let [x0, y0, x1, y1] = [40, 30, 100, 80];
|
||||
let dragging = RenderOptions { interacting: true, dragging: true, ..Default::default() };
|
||||
let part = r.render(&d, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &dragging);
|
||||
assert_eq!(r.stats.previewed, 0, "drawn from the frame's raster");
|
||||
for y in 0..y1 - y0 {
|
||||
for x in 0..x1 - x0 {
|
||||
assert_eq!(part.pixel(x, y), whole.pixel(x0 + x, y0 + y), "at {x}, {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on a path whose path a script reshapes: its letters stand above the path and run past
|
||||
/// where it ends, and they change where they were and where they are, outside the path's box.
|
||||
#[test]
|
||||
fn reshaping_the_path_of_type_on_a_path_changes_where_its_letters_were_and_are() {
|
||||
let mut old = Document::new(110.0, 80.0);
|
||||
let l = old.layers[0].id;
|
||||
let on = |y: f64| {
|
||||
let mut t = vectorcraft_doc::text::TextObject::point(
|
||||
Point::ZERO,
|
||||
"Text along a path",
|
||||
vectorcraft_doc::text::CharStyle { size: 14.0, ..Default::default() },
|
||||
);
|
||||
let line = kurbo::Line::new((10.0, 60.0), (90.0, y)).to_path(0.1);
|
||||
t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
// The engine lays out type that changed ([`vectorcraft_doc::TextObject::cached_bounds`]).
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds);
|
||||
t
|
||||
};
|
||||
let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(on(55.0))))).unwrap();
|
||||
let mut new = old.clone();
|
||||
if let Some(NodeKind::Text(t)) = new.node_mut(text).map(|n| &mut n.kind) {
|
||||
**t = on(25.0);
|
||||
}
|
||||
redraw(&old, &new);
|
||||
}
|
||||
|
||||
/// Stroked type with sharp corners: the miter spikes of its characters' strokes reach past half
|
||||
/// the weight, and change where they were and where they are.
|
||||
#[test]
|
||||
fn moving_type_with_mitred_strokes_changes_where_its_spikes_reach() {
|
||||
let mut old = Document::new(110.0, 80.0);
|
||||
let l = old.layers[0].id;
|
||||
let style = vectorcraft_doc::text::CharStyle {
|
||||
size: 30.0,
|
||||
stroke: Paint::solid(Color::BLACK),
|
||||
stroke_width: 10.0,
|
||||
stroke_join: vectorcraft_doc::LineJoin::Miter,
|
||||
stroke_miter_limit: 10.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut t = vectorcraft_doc::text::TextObject::point(Point::new(10.0, 50.0), "VAWV", style);
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds);
|
||||
let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(t)))).unwrap();
|
||||
let mut new = old.clone();
|
||||
new.node_mut(text).unwrap().transform(Affine::translate((9.0, 7.0)), false);
|
||||
redraw(&old, &new);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_document_changes_nothing_and_a_new_symbol_everything() {
|
||||
let (old, _) = document();
|
||||
assert_eq!(redraw(&old, &old.clone()), Changed::Nothing);
|
||||
let mut new = old.clone();
|
||||
let art = Arc::new(rect(&mut new, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK));
|
||||
new.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art });
|
||||
assert_eq!(redraw(&old, &new), Changed::Everything);
|
||||
}
|
||||
|
||||
/// While a pattern is edited its art also draws in the copies round it: an edit of it changes
|
||||
/// everything.
|
||||
#[test]
|
||||
fn an_edit_while_a_pattern_is_edited_changes_everything() {
|
||||
let (mut old, [loose, ..]) = document();
|
||||
let l = old.layers[0].id;
|
||||
old.pattern_edit = Some(vectorcraft_doc::PatternEdit { pattern: "Tile".into(), layer: l, original: None });
|
||||
let mut new = old.clone();
|
||||
new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false);
|
||||
assert_eq!(changed(&old, &new), Changed::Everything);
|
||||
}
|
||||
@@ -1885,9 +1885,11 @@ impl Writer<'_> {
|
||||
fn text(&mut self, n: &Node, t: &TextObject) {
|
||||
// Live SVG text is written in logical order from left-aligned pieces: bidirectional text
|
||||
// (and right-to-left paragraphs) keep their look as outlines until it is written with
|
||||
// `direction`/`unicode-bidi`.
|
||||
// `direction`/`unicode-bidi`. So do runs set Right to Left (Character Direction), whose
|
||||
// `direction="rtl"` would anchor their pieces at the right end.
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) {
|
||||
let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft));
|
||||
if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs {
|
||||
self.warn("bidirectional text is outlined to preserve shaping and visual order");
|
||||
return self.text_outlines(n, t);
|
||||
}
|
||||
@@ -2035,7 +2037,8 @@ impl Writer<'_> {
|
||||
if st.rotation != 0.0 {
|
||||
attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation)));
|
||||
}
|
||||
let diff = run_diff(&base.props, self.char_props(st, base.space));
|
||||
let mut diff = run_diff(&base.props, self.char_props(st, base.space));
|
||||
diff.extend(direction_props(st));
|
||||
attrs.push_str(&self.attrs(&diff));
|
||||
let text = xml_text(&seg.text);
|
||||
if attrs.is_empty() && fewer {
|
||||
@@ -2165,7 +2168,8 @@ impl Writer<'_> {
|
||||
let offset = fmt_num(start * 100.0, 3);
|
||||
let mut s = format!("<text{id} xml:space=\"preserve\"{a}><textPath xlink:href=\"#{pid}\" startOffset=\"{offset}%\">");
|
||||
for run in &t.runs {
|
||||
let diff = run_diff(&base, self.char_props(&run.style, space));
|
||||
let mut diff = run_diff(&base, self.char_props(&run.style, space));
|
||||
diff.extend(direction_props(&run.style));
|
||||
let a = self.attrs(&diff);
|
||||
s.push_str(&format!("<tspan{a}>{}</tspan>", xml_text(&run.text.replace('\n', " "))));
|
||||
}
|
||||
@@ -2198,6 +2202,16 @@ fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool {
|
||||
lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin)
|
||||
}
|
||||
|
||||
/// The `direction` and `unicode-bidi` of a `<tspan>` of characters in style `st`: an embedding in
|
||||
/// its Character Direction, and nothing without one. Each `<tspan>` of characters with a direction
|
||||
/// carries them, and the `<text>` does not. Type with a run set Right to Left is written as
|
||||
/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only.
|
||||
fn direction_props(st: &CharStyle) -> Props {
|
||||
let Some(d) = st.char_direction else { return Props::new() };
|
||||
let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" };
|
||||
vec![("direction", direction.into()), ("unicode-bidi", "embed".into())]
|
||||
}
|
||||
|
||||
/// What the `<tspan>` of a run whose properties are `run` sets over its `<text>`'s `base`: the
|
||||
/// properties that differ, and the initial value of each one `base` sets and the run doesn't.
|
||||
fn run_diff(base: &Props, run: Props) -> Props {
|
||||
|
||||
@@ -125,6 +125,43 @@ fn proportional_metrics_write_palt() {
|
||||
assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]);
|
||||
}
|
||||
|
||||
/// Character Direction: live text writes each run set Left to Right as a bidi embedding
|
||||
/// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines.
|
||||
#[test]
|
||||
fn character_direction_is_written_on_its_runs() {
|
||||
use vectorcraft_doc::ParaDirection;
|
||||
let ltr = CharStyle { char_direction: Some(ParaDirection::LeftToRight), ..style(12.0) };
|
||||
let d = area_doc(vec![run("Due ", style(12.0)), run("21 10 2026", ltr.clone()), run(" ok", style(12.0))], 280.0, Justify::Left);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let tags = tspans(&svg);
|
||||
assert_eq!(tags.len(), 3, "{svg}");
|
||||
assert!(!tags[0].contains("direction") && !tags[2].contains("direction"), "{svg}");
|
||||
assert!(tags[1].contains(" direction=\"ltr\" unicode-bidi=\"embed\""), "{}", tags[1]);
|
||||
// Type on a path: on the run's `<tspan>` in the `<textPath>` too.
|
||||
let mut on_path = TextObject::point(Point::ZERO, "", style(12.0));
|
||||
on_path.runs = vec![run("Due ", style(12.0)), run("21 10 2026", ltr)];
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((20.0, 100.0));
|
||||
line.line_to((280.0, 100.0));
|
||||
on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(on_path)));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let tags = tspans(&svg);
|
||||
assert!(svg.contains("<textPath") && tags.len() == 2, "{svg}");
|
||||
assert!(!tags[0].contains("direction") && tags[1].contains(" direction=\"ltr\" unicode-bidi=\"embed\""), "{svg}");
|
||||
// A run set Right to Left is written as outlines, also when its characters are drawn left to
|
||||
// right (digits inside an embedding stay left to right).
|
||||
let rtl = CharStyle { char_direction: Some(ParaDirection::RightToLeft), ..style(12.0) };
|
||||
for date in ["21 10 2026", "2026"] {
|
||||
let d = area_doc(vec![run("Due ", style(12.0)), run(date, rtl.clone())], 280.0, Justify::Left);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(!svg.contains("<text"), "{date}: {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Digits are written as drawn: the style's numeral system, not the digits that were typed (as
|
||||
/// All Caps writes capitals).
|
||||
#[test]
|
||||
|
||||
@@ -50,6 +50,23 @@ impl Ctx<'_> {
|
||||
find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default)
|
||||
}
|
||||
|
||||
/// The stretches of paragraph `r` whose characters have a Character Direction, as byte ranges
|
||||
/// from `r.start` with their direction, in order; neighboring runs of one direction make one
|
||||
/// stretch.
|
||||
fn char_directions(&self, r: &Range<usize>) -> Vec<(Range<usize>, ParaDirection)> {
|
||||
let mut out: Vec<(Range<usize>, ParaDirection)> = Vec::new();
|
||||
for (rr, st) in &self.runs {
|
||||
let (a, b) = (rr.start.max(r.start), rr.end.min(r.end));
|
||||
let Some(d) = st.char_direction.filter(|_| a < b) else { continue };
|
||||
let (a, b) = (a - r.start, b - r.start);
|
||||
match out.last_mut() {
|
||||
Some((prev, pd)) if *pd == d && prev.end == a => prev.end = b,
|
||||
_ => out.push((a..b, d)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Shape paragraph `r` (its bidi resolution `bidi`).
|
||||
fn shape_para(&self, r: Range<usize>, bidi: Option<&BidiInfo<'_>>) -> Vec<SGlyph> {
|
||||
let mut v = Vec::with_capacity(r.len());
|
||||
@@ -1908,7 +1925,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
// its first line) follow its own.
|
||||
pen.model = para.leading_model;
|
||||
let text = cx.text;
|
||||
let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction);
|
||||
let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr));
|
||||
let rtl = is_rtl(bidi.as_ref());
|
||||
let mut sg = cx.shape_para(pr.clone(), bidi.as_ref());
|
||||
// Thai, Lao, Khmer and Myanmar break lines between words, not at spaces.
|
||||
@@ -2310,7 +2327,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
let mut shaped = Vec::new();
|
||||
for pr in paras {
|
||||
let text = cx.text;
|
||||
let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction);
|
||||
let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr));
|
||||
rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical);
|
||||
let glyphs = cx.shape_para(pr.clone(), bidi.as_ref());
|
||||
shaped.push((pr.clone(), bidi.filter(|b| b.has_rtl() && !cx.vertical), glyphs));
|
||||
@@ -2554,6 +2571,52 @@ pub(crate) fn para_bidi(text: &str, direction: Option<ParaDirection>) -> Option<
|
||||
Some(BidiInfo::new(text, level))
|
||||
}
|
||||
|
||||
/// [`para_bidi`] for a paragraph whose characters in `set` (byte ranges of `text`, in order and not
|
||||
/// overlapping, from [`Ctx::char_directions`]) have a Character Direction: each range is resolved
|
||||
/// as a Unicode bidi embedding, as if an LRE or RLE stood before it and a PDF after it (UAX #9 X2,
|
||||
/// X3, X7). The levels, classes and paragraphs come back for the bytes of `text`. The paragraph's own
|
||||
/// direction is unchanged: the first strong character is found inside embeddings too (P2).
|
||||
pub(crate) fn para_bidi_with<'a>(text: &'a str, direction: Option<ParaDirection>, set: &[(Range<usize>, ParaDirection)]) -> Option<BidiInfo<'a>> {
|
||||
let plain = para_bidi(text, direction);
|
||||
if set.is_empty() || (plain.is_none() && set.iter().all(|(_, d)| *d == ParaDirection::LeftToRight)) {
|
||||
// A left-to-right embedding in left-to-right text takes an even level: its order is the same.
|
||||
return plain;
|
||||
}
|
||||
// The text with the embedding controls, and where each byte of `text` is in it.
|
||||
let mut marked = String::with_capacity(text.len() + set.len() * 6);
|
||||
let mut at_byte: Vec<usize> = Vec::with_capacity(text.len());
|
||||
let mut copy = |marked: &mut String, from: usize, to: usize| {
|
||||
if let Some(s) = text.get(from..to) {
|
||||
at_byte.extend(marked.len()..marked.len() + s.len());
|
||||
marked.push_str(s);
|
||||
}
|
||||
};
|
||||
let mut done = 0;
|
||||
for (r, d) in set {
|
||||
let (a, b) = (r.start.max(done).min(text.len()), r.end.min(text.len()));
|
||||
if a >= b {
|
||||
continue;
|
||||
}
|
||||
copy(&mut marked, done, a);
|
||||
marked.push(if *d == ParaDirection::RightToLeft { '\u{202B}' } else { '\u{202A}' });
|
||||
copy(&mut marked, a, b);
|
||||
marked.push('\u{202C}');
|
||||
done = b;
|
||||
}
|
||||
copy(&mut marked, done, text.len());
|
||||
let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() });
|
||||
let info = BidiInfo::new(&marked, level);
|
||||
let back = |i: usize| at_byte.partition_point(|&m| m < i);
|
||||
let paragraphs =
|
||||
info.paragraphs.iter().map(|p| unicode_bidi::ParagraphInfo { range: back(p.range.start)..back(p.range.end), level: p.level }).collect();
|
||||
Some(BidiInfo {
|
||||
text,
|
||||
original_classes: at_byte.iter().filter_map(|&i| info.original_classes.get(i).copied()).collect(),
|
||||
levels: at_byte.iter().filter_map(|&i| info.levels.get(i).copied()).collect(),
|
||||
paragraphs,
|
||||
})
|
||||
}
|
||||
|
||||
/// Does the paragraph run right to left ([`para_bidi`])?
|
||||
pub(crate) fn is_rtl(bidi: Option<&BidiInfo<'_>>) -> bool {
|
||||
bidi.and_then(|b| b.paragraphs.first()).is_some_and(|p| p.level.is_rtl())
|
||||
|
||||
@@ -433,9 +433,10 @@ fn source_run_at(runs: &[(Range<usize>, &CharStyle)], span: &Range<usize>, byte:
|
||||
|
||||
/// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text?
|
||||
/// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the
|
||||
/// character style's name, leading, baseline shift, rotation, Character Alignment, the kerning
|
||||
/// (each glyph takes its run's, see [`shape_segment`]) and the tracking, as long as the tracking
|
||||
/// keeps or drops standard ligatures alike ([`crate::features::ligatures_suppressed_by`]).
|
||||
/// character style's name, leading, baseline shift, rotation, Character Alignment, Character
|
||||
/// Direction (text is shaped apart where its bidi level changes), the kerning (each glyph takes its
|
||||
/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard
|
||||
/// ligatures alike ([`crate::features::ligatures_suppressed_by`]).
|
||||
fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
// Every field is named, so that a new attribute has to be sorted into one group or the other.
|
||||
let CharStyle {
|
||||
@@ -469,6 +470,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
position,
|
||||
small_caps,
|
||||
char_align: _,
|
||||
char_direction: _,
|
||||
proportional_metrics,
|
||||
tate_chu_yoko,
|
||||
digits,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Direction, source-cluster and editing regressions; no RTL font assets required.
|
||||
use super::*;
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Shape};
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::{CharStyle, TextKind, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextRun};
|
||||
|
||||
fn text_layout(s: &str) -> TextLayout {
|
||||
layout(FontDb::global(), &TextObject::point(Point::ZERO, s, CharStyle::default()))
|
||||
@@ -88,6 +89,40 @@ fn mixed_text_preserves_latin_and_number_order() {
|
||||
assert_eq!(s, "שלום Rust 123", "layout must not rewrite source text");
|
||||
}
|
||||
|
||||
/// The characters of `s` in the order a layout of it draws them, left to right, with the bytes
|
||||
/// `set` given Character Direction `d`.
|
||||
fn drawn_with_direction(s: &str, set: Range<usize>, d: Option<ParaDirection>) -> String {
|
||||
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
|
||||
edit::style_range(&mut t.runs, set.start, set.end, |st| st.char_direction = d);
|
||||
let l = layout(FontDb::global(), &t);
|
||||
let mut glyphs: Vec<_> = l.glyphs.iter().collect();
|
||||
glyphs.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x));
|
||||
glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect()
|
||||
}
|
||||
|
||||
/// Character Direction Left to Right keeps a date written with spaces in its own order inside a
|
||||
/// right-to-left paragraph, where the bidi algorithm alone sets its numbers right to left.
|
||||
#[test]
|
||||
fn left_to_right_characters_keep_a_date_in_order_in_right_to_left_text() {
|
||||
let s = "שלום 21 10 2026";
|
||||
let date = s.find('2').unwrap_or_default()..s.len();
|
||||
assert_eq!(drawn_with_direction(s, date.clone(), None), "2026 10 21 םולש");
|
||||
assert_eq!(drawn_with_direction(s, date, Some(ParaDirection::LeftToRight)), "21 10 2026 םולש");
|
||||
}
|
||||
|
||||
/// Character Direction Right to Left sets a Hebrew word and the exclamation mark after it right to
|
||||
/// left inside a left-to-right paragraph, where the bidi algorithm alone leaves the mark after the
|
||||
/// word in the paragraph's direction.
|
||||
#[test]
|
||||
fn right_to_left_characters_set_a_word_and_its_mark_right_to_left_in_left_to_right_text() {
|
||||
let s = "abc שלום! def";
|
||||
let word = s.find('ש').unwrap_or_default()..s.find('!').map_or(0, |i| i + 1);
|
||||
assert_eq!(drawn_with_direction(s, word.clone(), None), "abc םולש! def");
|
||||
assert_eq!(drawn_with_direction(s, word.clone(), Some(ParaDirection::RightToLeft)), "abc !םולש def");
|
||||
// Left to Right on the same characters of a left-to-right paragraph draws them as without it.
|
||||
assert_eq!(drawn_with_direction(s, word, Some(ParaDirection::LeftToRight)), "abc םולש! def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_hit_testing_arrows_and_selection_follow_visual_positions() {
|
||||
let s = "שלום";
|
||||
|
||||
@@ -142,12 +142,18 @@ fn vertical_ruby_is_upright_to_the_right_of_the_column() {
|
||||
let ruby = ruby_ink(&lay);
|
||||
assert!(ruby.x0 > base.x0, "ruby right of the column: {ruby:?} vs {base:?}");
|
||||
assert!(ruby.x0 >= base.x1 - 1.0, "ruby outside the column: {ruby:?} vs {base:?}");
|
||||
// Upright kana are taller than wide (they would be lying on their side if not).
|
||||
// Upright: each reading glyph's ink has the width and height of its outline at 10 pt (half the
|
||||
// base size); a glyph on its side has them swapped.
|
||||
let mut not_square = false;
|
||||
for g in &lay.ruby {
|
||||
if let Some(b) = ink(g) {
|
||||
assert!(b.width() <= b.height() + 1.0, "upright ruby glyph {b:?}");
|
||||
}
|
||||
let Some(r) = ink(g) else { continue };
|
||||
let face = db().face_by_id(g.font_id).expect("the ruby glyph's face");
|
||||
let o = db().outline(&face, g.gid).bounding_box();
|
||||
let (w, h) = (o.width() * 10.0 / face.units_per_em(), o.height() * 10.0 / face.units_per_em());
|
||||
assert!((r.width() - w).abs() < 0.01 && (r.height() - h).abs() < 0.01, "upright ruby glyph {r:?}, its outline {w} x {h}");
|
||||
not_square |= (w - h).abs() > 0.5;
|
||||
}
|
||||
assert!(not_square, "one reading glyph's outline is not square");
|
||||
// Down the column: the reading's glyphs have distinct y positions.
|
||||
let ys: Vec<f64> = lay.ruby.iter().filter_map(|g| ink(g).map(|b| b.center().y)).collect();
|
||||
assert!(ys.windows(2).any(|w| w[0] != w[1]), "the reading runs down the column: {ys:?}");
|
||||
|
||||
@@ -101,6 +101,14 @@ enum Drag {
|
||||
Present,
|
||||
}
|
||||
|
||||
/// Clear canvas interaction state before a tab transfer takes ownership of the artwork drag.
|
||||
pub(crate) fn end_document_drag(ctx: &egui::Context) {
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<Drag>(drag_id());
|
||||
d.remove::<Pos2>(drag_pos_id());
|
||||
});
|
||||
}
|
||||
|
||||
fn drag_id() -> egui::Id {
|
||||
egui::Id::new("canvas-drag")
|
||||
}
|
||||
@@ -114,7 +122,7 @@ fn drag_layer_id() -> egui::Id {
|
||||
}
|
||||
/// Whether `p` is over the canvas itself, not a panel or a floating window over it.
|
||||
fn on_canvas(ui: &Ui, resp: &egui::Response, p: Pos2) -> bool {
|
||||
ui.clip_rect().contains(p) && ui.ctx().layer_id_at(p).is_none_or(|l| l == resp.layer_id)
|
||||
resp.rect.contains(p) && ui.clip_rect().contains(p) && ui.ctx().layer_id_at(p).is_none_or(|l| l == resp.layer_id)
|
||||
}
|
||||
/// The document point last sent to the tool as a drag during [`Drag::Tool`].
|
||||
fn tool_drag_doc_id() -> egui::Id {
|
||||
@@ -274,6 +282,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let v = *app.view().unwrap_or(&View::default());
|
||||
let xf = Xf::new(rect, &v);
|
||||
let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None };
|
||||
crate::document_drag::drop_on_canvas(app, &resp, &xf);
|
||||
panel_drop(app, ui, &resp, &xf);
|
||||
// In Presentation Mode a right click or a long touch opens no context menu.
|
||||
if app.ui.screen_mode < 3 {
|
||||
@@ -379,6 +388,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
{
|
||||
upload(app, ui.ctx(), &done.img, done.key.pixel.is_some());
|
||||
app.canvas.key = Some(done.key);
|
||||
app.canvas.shown = Some((done.doc, done.opts));
|
||||
app.perf.render_ms = done.ms;
|
||||
app.canvas.last_ms = done.ms;
|
||||
app.canvas.moved = done.moved > 0;
|
||||
@@ -453,13 +463,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let settling = app.canvas.moved && !key.interacting;
|
||||
let staying = key.interacting && app.canvas.from_worker;
|
||||
let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc;
|
||||
match (&mut app.canvas.worker, heavy) {
|
||||
(Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }),
|
||||
let changed = changed_pixels(app, &key, &doc, &opts, view, w, h);
|
||||
match (&mut app.canvas.worker, heavy, changed) {
|
||||
// Only the document changed since the frame shown, in a part of it: that part is
|
||||
// rendered again, here, into the texture. `last_ms` stays the whole frame's.
|
||||
(_, _, Some(region)) => {
|
||||
let t0 = now_ms();
|
||||
if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) {
|
||||
let part = app.canvas.renderer.render(&doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts);
|
||||
let color = color_image(&part);
|
||||
let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR };
|
||||
tex.set_partial([x0 as usize, y0 as usize], color, options);
|
||||
app.canvas.moved |= app.canvas.renderer.stats.moved > 0;
|
||||
}
|
||||
app.canvas.key = Some(key.clone());
|
||||
app.canvas.shown = Some((doc.clone(), opts.clone()));
|
||||
app.perf.render_ms = now_ms() - t0;
|
||||
}
|
||||
(Some(worker), true, None) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }),
|
||||
_ => {
|
||||
let t0 = now_ms();
|
||||
let img = app.canvas.renderer.render(&doc, w, h, view, &opts);
|
||||
upload(app, ui.ctx(), &img, pixel.is_some());
|
||||
app.canvas.key = Some(key.clone());
|
||||
app.canvas.shown = Some((doc.clone(), opts.clone()));
|
||||
app.perf.render_ms = now_ms() - t0;
|
||||
app.canvas.last_ms = app.perf.render_ms;
|
||||
app.canvas.moved = app.canvas.renderer.stats.moved > 0;
|
||||
@@ -795,6 +822,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch canvas pointer, keyboard and drop events through the active document view.
|
||||
fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
|
||||
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
|
||||
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
|
||||
@@ -911,6 +939,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
// Cmd with another tool drags with the selection tool used last (the session lends it).
|
||||
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) };
|
||||
dispatch(app, &ev, view);
|
||||
crate::document_drag::begin(app, ui.ctx(), ev.pos);
|
||||
Drag::Tool
|
||||
};
|
||||
ui.data_mut(|dd| {
|
||||
@@ -1476,7 +1505,7 @@ fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_engine::ViewI
|
||||
/// canvas, and a panel it is released on takes it (the Graphic Styles panel makes a style of it).
|
||||
/// Back on the canvas, [`Drag::Art`] takes the move up again where the pointer is (#2826).
|
||||
fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos2) -> bool {
|
||||
let off = !ui.clip_rect().contains(p) || ui.ctx().layer_id_at(p).is_some_and(|l| l != resp.layer_id);
|
||||
let off = !on_canvas(ui, resp, p);
|
||||
let Some(st) = app.session.active().filter(|_| off && app.session.tool_id() == "selection") else { return false };
|
||||
// The Selection tool's interaction while it moves (or Alt-copies) the selection.
|
||||
if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") {
|
||||
@@ -1491,6 +1520,7 @@ fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos2) -
|
||||
/// panel that takes the art ([`widgets::PanelDrag::Art`]) takes it where it was, and no move is
|
||||
/// left open (#2826). The art stays where it last moved while the pointer is over the panel.
|
||||
pub(crate) fn release_over_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
crate::document_drag::cleanup(app, ctx);
|
||||
if !ctx.input(|i| i.pointer.primary_released()) {
|
||||
return;
|
||||
}
|
||||
@@ -3127,6 +3157,52 @@ pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImag
|
||||
egui::ColorImage::new(size, pixels)
|
||||
}
|
||||
|
||||
/// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document
|
||||
/// changed since the frame shown (its key `key` but for the revision, and its render options
|
||||
/// `opts`), which this thread rendered, and no frame is on its way from the worker: those it
|
||||
/// changed in (`Some(None)`: none), grown to whole eighths of the frame, as long as they are at
|
||||
/// most half of it. None: render the whole frame. Frames the worker renders stay
|
||||
/// whole: its renderer keeps the rasters of the art's effects, this thread's doesn't.
|
||||
fn changed_pixels(
|
||||
app: &VectorcraftApp,
|
||||
key: &CacheKey,
|
||||
doc: &vectorcraft_doc::Document,
|
||||
opts: &vectorcraft_render::RenderOptions,
|
||||
view: Affine,
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Option<Option<[u32; 4]>> {
|
||||
let (shown, _) = app.canvas.shown.as_ref().filter(|(_, o)| o == opts)?;
|
||||
// A viewed opacity mask shows its coverage wherever its art reaches, past its object's bounds.
|
||||
if opts.mask_view.is_some() {
|
||||
return None;
|
||||
}
|
||||
let same_view = app.canvas.key.as_ref().is_some_and(|k| *k == CacheKey { revision: k.revision, ..key.clone() });
|
||||
if !same_view || app.canvas.texture.is_none() || app.canvas.from_worker || app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) {
|
||||
return None;
|
||||
}
|
||||
match vectorcraft_render::changed(shown, doc) {
|
||||
vectorcraft_render::Changed::Nothing => Some(None),
|
||||
vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) {
|
||||
None => Some(None),
|
||||
Some([x0, y0, x1, y1]) => {
|
||||
let ((x0, x1), (y0, y1)) = (in_steps(x0, x1, w), in_steps(y0, y1, h));
|
||||
(u64::from(x1 - x0) * u64::from(y1 - y0) * 2 <= u64::from(w) * u64::from(h)).then_some(Some([x0, y0, x1, y1]))
|
||||
}
|
||||
},
|
||||
vectorcraft_render::Changed::Everything => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `a..b` of `0..n`, grown to whole eighths of `n` and moved back into `0..n`: the parts of a
|
||||
/// frame rendered again come in few sizes, which keep the contexts the renderer keeps.
|
||||
fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) {
|
||||
let step = n.div_ceil(8).max(1);
|
||||
let len = (b - a).max(1).div_ceil(step).saturating_mul(step).min(n);
|
||||
let a = a.min(n - len);
|
||||
(a, a + len)
|
||||
}
|
||||
|
||||
/// Upload the art's raster; Pixel Preview's document pixels keep hard edges when magnified.
|
||||
fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered, pixel: bool) {
|
||||
let color = color_image(img);
|
||||
@@ -4691,6 +4767,69 @@ mod tests {
|
||||
(v, out.textures_delta)
|
||||
}
|
||||
|
||||
/// An edit of one object renders the canvas again only where it changed, into its texture; a
|
||||
/// change of the view renders all of it.
|
||||
#[test]
|
||||
fn an_edit_renders_only_its_part_of_the_canvas() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":400,"height":300})).unwrap();
|
||||
app.ui.task_bar = false;
|
||||
app.ui.view.rulers = false;
|
||||
app.canvas.worker_started = true; // every frame on this thread, however long it takes
|
||||
let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":40,"height":30})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap();
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
// Where the canvas texture changed in a frame, and how much of it.
|
||||
let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| {
|
||||
let tex = app.canvas.texture.as_ref().map(|t| t.id());
|
||||
let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size()));
|
||||
delta.clear();
|
||||
set
|
||||
};
|
||||
let (_, delta) = frame_output(&mut app, &ctx);
|
||||
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it");
|
||||
app.run("paint.setFill", json!({"color": "#ff0000", "ids": [id]})).unwrap();
|
||||
let (_, delta) = frame_output(&mut app, &ctx);
|
||||
let (pos, size) = canvas(&app, delta).expect("the edit shows");
|
||||
let whole = app.canvas.key.as_ref().map(|k| (k.w as usize, k.h as usize)).unwrap();
|
||||
assert!(pos.is_some() && size[0] * size[1] * 10 < whole.0 * whole.1, "a small part of it: {pos:?} {size:?} of {whole:?}");
|
||||
// An edit with other render options than the frame shown's renders all of it.
|
||||
app.session.prefs.pattern_tile_edge_color = "#ff00ff".into();
|
||||
app.run("paint.setFill", json!({"color": "#00ff00", "ids": [id]})).unwrap();
|
||||
let (_, delta) = frame_output(&mut app, &ctx);
|
||||
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "other options render all of it");
|
||||
app.run("view.setZoom", json!({"zoom": 2})).unwrap();
|
||||
let (_, delta) = frame_output(&mut app, &ctx);
|
||||
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it");
|
||||
}
|
||||
|
||||
/// While an opacity mask is viewed, an edit renders all of the canvas: the mask view shows the
|
||||
/// mask's coverage wherever its art reaches, past its object's bounds.
|
||||
#[test]
|
||||
fn an_edit_while_a_mask_is_viewed_renders_all_of_the_canvas() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":400,"height":300})).unwrap();
|
||||
app.ui.task_bar = false;
|
||||
app.ui.view.rulers = false;
|
||||
app.canvas.worker_started = true; // every frame on this thread, however long it takes
|
||||
let rect =
|
||||
|app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 50, "width": 60, "height": 40})).unwrap()["id"].clone();
|
||||
let (obj, mask) = (rect(&mut app, 50.0), rect(&mut app, 70.0));
|
||||
app.run("select.set", json!({"ids": [obj, mask]})).unwrap();
|
||||
app.run("transparency.makeOpacityMask", json!({})).unwrap();
|
||||
app.run("transparency.viewOpacityMask", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let (_, mut delta) = frame_output(&mut app, &ctx);
|
||||
delta.clear();
|
||||
app.run("paint.setFill", json!({"color": "#ff0000", "ids": [obj]})).unwrap();
|
||||
let (_, mut delta) = frame_output(&mut app, &ctx);
|
||||
let tex = app.canvas.texture.as_ref().map(|t| t.id());
|
||||
let pos = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| d.pos);
|
||||
delta.clear();
|
||||
assert_eq!(pos, Some(None), "all of it");
|
||||
}
|
||||
|
||||
/// #1278: a segment click marks the edge, not its endpoint anchors or neighboring handles.
|
||||
#[test]
|
||||
fn a_segment_pick_has_hollow_anchors_and_only_its_own_handles() {
|
||||
@@ -5298,7 +5437,7 @@ mod tests {
|
||||
// Deliver the actual replacement and verify that it replaces the cached key.
|
||||
let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||||
let expected = job.key.clone();
|
||||
completed.send(crate::render_worker::Done { key: job.key, img, ms: 1.0, moved: 0 }).unwrap();
|
||||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0 }).unwrap();
|
||||
frame(&mut app, size, new_ppp);
|
||||
assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived");
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let mut x = strip.left();
|
||||
let mut activate = None;
|
||||
let mut close = None;
|
||||
let mut hovering_art = false;
|
||||
for (i, ((_, galley), w)) in tabs.iter().zip(&widths).enumerate().skip(first) {
|
||||
if x + w > strip.left() + room && Some(i) != active {
|
||||
break;
|
||||
@@ -468,6 +469,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let xresp = ui.interact(xr.expand(3.0), ui.id().with(("tabx", i)), Sense::click());
|
||||
icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text });
|
||||
ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text);
|
||||
hovering_art |= crate::document_drag::hover_tab(app, &resp, i, xr.expand(3.0));
|
||||
if xresp.clicked() {
|
||||
close = Some(i);
|
||||
} else if resp.clicked() {
|
||||
@@ -475,6 +477,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
x += w;
|
||||
}
|
||||
crate::document_drag::finish_tabs(ui.ctx(), hovering_art);
|
||||
// The » button: every open document, the active one checked.
|
||||
if overflow {
|
||||
let more = egui::Rect::from_min_max(egui::pos2(strip.right() - MORE, strip.top()), egui::pos2(strip.right(), strip.bottom() - 1.0));
|
||||
|
||||
@@ -78,6 +78,7 @@ fn unit(d: &Dialog) -> Unit {
|
||||
Unit::named(&d.str("units")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The General tab of Document Setup (units, bleed, artboard editing, options).
|
||||
fn general(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let units: Vec<(&str, &str)> = Unit::ALL.iter().map(|u| (u.label(), u.label())).collect();
|
||||
choice(ui, d, "units", tl!("Units:"), &units);
|
||||
@@ -88,7 +89,7 @@ fn general(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool
|
||||
let mut close = false;
|
||||
widgets::label_row(ui, "", LABEL, |ui| {
|
||||
// Applies the settings, then edits the artboards with the Artboard tool.
|
||||
if widgets::flat_button(ui, tl!("Edit Artboards"), 120.0).clicked() && confirm(app, d).is_ok() {
|
||||
if widgets::flat_button_fit(ui, tl!("Edit Artboards"), 120.0).clicked() && confirm(app, d).is_ok() {
|
||||
app.select_tool("artboard");
|
||||
// The shared frame restores its working copy after drawing the body. Tell it to
|
||||
// close too, otherwise it reopens the dialog that confirm just closed.
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
//! Effect → Effect Gallery: browse Photoshop-style raster filters by category, build a stack, and
|
||||
//! apply it as separate appearance effects in one undo step.
|
||||
|
||||
use egui;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_effects::{EffectInfo, PIXEL_EFFECTS};
|
||||
|
||||
use super::{DialogSpec, effect, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// Dialog kind for the Effect Gallery.
|
||||
pub const KIND: &str = "effectGallery";
|
||||
|
||||
/// Raster effect submenus in the gallery (the six Photoshop-style folders it holds).
|
||||
const CATEGORIES: [(&str, &[&str]); 6] = [
|
||||
("Artistic", &["Effect", "Artistic"]),
|
||||
("Brush Strokes", &["Effect", "Brush Strokes"]),
|
||||
("Distort", &["Effect", "Distort"]),
|
||||
("Sketch", &["Effect", "Sketch"]),
|
||||
("Stylize", &["Effect", "Stylize"]),
|
||||
("Texture", &["Effect", "Texture"]),
|
||||
];
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Effect Gallery").into(),
|
||||
body,
|
||||
confirm,
|
||||
preview: true,
|
||||
min_width: 720.0,
|
||||
max_width: Some(820.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct StackEntry {
|
||||
effect: String,
|
||||
visible: bool,
|
||||
params: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// Open the gallery on the current selection.
|
||||
pub fn open(app: &mut VectorcraftApp, _p: &Value) -> Result<Value, String> {
|
||||
if app.session.active().is_none_or(|s| s.selection.is_empty()) {
|
||||
return Err(tl!("Select artwork to apply effects").into());
|
||||
}
|
||||
let selected = effects_in_category(0).first().map(|e| e.id).unwrap_or("");
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
KIND,
|
||||
json!({ "category": 0, "selected": selected, "stack": [], "stackSelected": 0, "stackFocus": false, "preview": true }),
|
||||
));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn effects_in_category(cat: usize) -> Vec<EffectInfo> {
|
||||
let path = CATEGORIES.get(cat).map(|(_, p)| *p).unwrap_or(CATEGORIES[0].1);
|
||||
let mut out: Vec<EffectInfo> =
|
||||
vectorcraft_effects::effect_catalog().into_iter().filter(|e| e.menu == path && PIXEL_EFFECTS.contains(&e.id)).collect();
|
||||
out.sort_by(|a, b| a.label.cmp(b.label));
|
||||
out
|
||||
}
|
||||
|
||||
fn default_params(id: &str) -> Map<String, Value> {
|
||||
match vectorcraft_effects::effect_info(id).map(|e| e.defaults) {
|
||||
Some(Value::Object(m)) => m,
|
||||
_ => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn merged_params(id: &str, stored: &Map<String, Value>) -> Map<String, Value> {
|
||||
match vectorcraft_effects::merged_params(id, &Value::Object(stored.clone())) {
|
||||
Value::Object(m) => m,
|
||||
_ => default_params(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn stack_of(d: &Dialog) -> Vec<StackEntry> {
|
||||
d.fields
|
||||
.get("stack")
|
||||
.and_then(Value::as_array)
|
||||
.map(|rows| {
|
||||
rows.iter()
|
||||
.filter_map(|row| {
|
||||
let effect = row.get("effect").and_then(Value::as_str)?;
|
||||
let visible = row.get("visible").and_then(Value::as_bool).unwrap_or(true);
|
||||
let params = row
|
||||
.get("params")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.map(|m| merged_params(effect, &m))
|
||||
.unwrap_or_else(|| default_params(effect));
|
||||
Some(StackEntry { effect: effect.to_string(), visible, params })
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_stack(d: &mut Dialog, stack: &[StackEntry]) {
|
||||
let rows: Vec<Value> = stack.iter().map(|e| json!({ "effect": e.effect, "visible": e.visible, "params": e.params })).collect();
|
||||
d.fields.insert("stack".into(), Value::Array(rows));
|
||||
}
|
||||
|
||||
fn category_ix(d: &Dialog) -> usize {
|
||||
d.fields.get("category").and_then(Value::as_u64).unwrap_or(0) as usize
|
||||
}
|
||||
|
||||
fn selected_id(d: &Dialog) -> String {
|
||||
d.str("selected")
|
||||
}
|
||||
|
||||
fn set_selected(d: &mut Dialog, id: &str) {
|
||||
d.fields.insert("selected".into(), json!(id));
|
||||
}
|
||||
|
||||
fn stack_focus(d: &Dialog) -> bool {
|
||||
d.fields.get("stackFocus").and_then(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn set_stack_focus(d: &mut Dialog, on: bool) {
|
||||
d.fields.insert("stackFocus".into(), json!(on));
|
||||
}
|
||||
|
||||
fn browse_params(d: &Dialog) -> Map<String, Value> {
|
||||
let effect = d.fields.get("browseEffect").and_then(Value::as_str).unwrap_or("");
|
||||
let stored = d.fields.get("browseParams").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
if effect.is_empty() { stored } else { merged_params(effect, &stored) }
|
||||
}
|
||||
|
||||
fn set_browse_params(d: &mut Dialog, effect: &str, params: Map<String, Value>) {
|
||||
d.fields.insert("browseEffect".into(), json!(effect));
|
||||
d.fields.insert("browseParams".into(), Value::Object(params));
|
||||
}
|
||||
|
||||
/// The effect whose parameters the panel edits, and its values.
|
||||
fn param_target(d: &Dialog, stack: &[StackEntry], stack_sel: usize, browse: &str) -> (String, Map<String, Value>) {
|
||||
if stack_focus(d) {
|
||||
let entry = stack.get(stack_sel);
|
||||
if let Some(e) = entry {
|
||||
return (e.effect.clone(), e.params.clone());
|
||||
}
|
||||
}
|
||||
let id = browse.to_string();
|
||||
let params = if d.fields.get("browseEffect").and_then(Value::as_str) == Some(browse) { browse_params(d) } else { default_params(browse) };
|
||||
(id, params)
|
||||
}
|
||||
|
||||
fn write_param_target(d: &mut Dialog, stack: &mut [StackEntry], stack_sel: usize, browse: &str, effect: &str, params: Map<String, Value>) {
|
||||
if stack_focus(d) {
|
||||
if let Some(e) = stack.get_mut(stack_sel)
|
||||
&& e.effect == effect
|
||||
{
|
||||
e.params = params;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if browse == effect {
|
||||
set_browse_params(d, effect, params);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reuse the live effect dialog's fields for `effect_id` and `params`.
|
||||
fn effect_param_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, effect_id: &str, params: &mut Map<String, Value>) {
|
||||
if effect_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let label =
|
||||
vectorcraft_effects::effect_info(effect_id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_else(|| effect_id.to_string());
|
||||
let mut fields = params.clone();
|
||||
fields.insert("__effect".into(), json!(effect_id));
|
||||
fields.insert("__label".into(), json!(label));
|
||||
fields.insert("preview".into(), json!(false));
|
||||
let mut temp = Dialog::new("effect", Value::Object(fields));
|
||||
(effect::SPEC.body)(app, ui, &mut temp);
|
||||
if let Value::Object(m) = form::params(&temp) {
|
||||
*params = m;
|
||||
}
|
||||
if let Value::Object(m) = vectorcraft_effects::upgrade_params(effect_id, &Value::Object(params.clone())) {
|
||||
*params = m;
|
||||
}
|
||||
}
|
||||
|
||||
fn stack_batch_commands(stack: &[StackEntry]) -> Vec<Value> {
|
||||
let mut commands = Vec::with_capacity(stack.len() * 2);
|
||||
for (i, entry) in stack.iter().enumerate() {
|
||||
let params = Value::Object(entry.params.clone());
|
||||
commands.push(json!({ "command": "effect.apply", "params": { "effect": entry.effect, "params": params } }));
|
||||
if !entry.visible {
|
||||
commands.push(json!({ "command": "effect.setParams", "params": { "index": i, "visible": false } }));
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
fn stack_batch_params(stack: &[StackEntry]) -> Value {
|
||||
json!({ "label": "Effect Gallery", "commands": stack_batch_commands(stack) })
|
||||
}
|
||||
|
||||
/// Live canvas preview of the stack (`command.batch` would settle the dialog interaction each frame).
|
||||
fn preview_stack_on_canvas(app: &mut VectorcraftApp, stack: &[StackEntry]) {
|
||||
if app.session.in_interaction() {
|
||||
let _ = app.session.cancel_interaction();
|
||||
}
|
||||
let _ = app.session.begin_interaction("Effect Gallery");
|
||||
for (i, entry) in stack.iter().enumerate() {
|
||||
let params = Value::Object(entry.params.clone());
|
||||
if app.run("effect.apply", json!({ "effect": entry.effect, "params": params })).is_err() {
|
||||
let _ = app.session.cancel_interaction();
|
||||
return;
|
||||
}
|
||||
if !entry.visible && app.run("effect.setParams", json!({ "index": i, "visible": false })).is_err() {
|
||||
let _ = app.session.cancel_interaction();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stack_preview_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, stack: &[StackEntry]) {
|
||||
const LAST: &str = super::form::PREVIEWED;
|
||||
ui.add_space(8.0);
|
||||
let mut on = d.bool("preview");
|
||||
if widgets::check(ui, tl!("Preview"), on, true) {
|
||||
on = !on;
|
||||
d.fields.insert("preview".into(), json!(on));
|
||||
}
|
||||
if !on {
|
||||
if d.fields.remove(LAST).is_some() {
|
||||
let _ = app.session.cancel_interaction();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if stack.is_empty() {
|
||||
if d.fields.remove(LAST).is_some() {
|
||||
let _ = app.session.cancel_interaction();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let batch = stack_batch_params(stack);
|
||||
if d.fields.get(LAST) != Some(&batch) || !app.session.in_interaction() {
|
||||
preview_stack_on_canvas(app, stack);
|
||||
d.fields.insert(LAST.into(), batch);
|
||||
}
|
||||
}
|
||||
|
||||
fn effect_label(id: &str) -> String {
|
||||
vectorcraft_effects::effect_info(id).map(|e| tl!(e.label).trim_end_matches('…').to_string()).unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let cat = category_ix(d);
|
||||
let effects = effects_in_category(cat);
|
||||
let mut sel = selected_id(d);
|
||||
if !effects.iter().any(|e| e.id == sel) {
|
||||
sel = effects.first().map(|e| e.id.to_string()).unwrap_or_default();
|
||||
set_selected(d, &sel);
|
||||
}
|
||||
|
||||
let mut stack = stack_of(d);
|
||||
let mut stack_sel = d.fields.get("stackSelected").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
if stack_sel >= stack.len() && !stack.is_empty() {
|
||||
stack_sel = stack.len() - 1;
|
||||
}
|
||||
let (param_effect, mut param_values) = param_target(d, &stack, stack_sel, &sel);
|
||||
if !param_effect.is_empty() {
|
||||
widgets::subheader(ui, tl!("Settings"));
|
||||
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
|
||||
effect_param_fields(app, ui, ¶m_effect, &mut param_values);
|
||||
});
|
||||
write_param_target(d, &mut stack, stack_sel, &sel, ¶m_effect, param_values);
|
||||
}
|
||||
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
widgets::subheader(ui, tl!("Categories"));
|
||||
ui.set_width(120.0);
|
||||
for (i, (name, _)) in CATEGORIES.iter().enumerate() {
|
||||
let count = effects_in_category(i).len();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let label = tl!(name);
|
||||
if ui.add(egui::Button::selectable(cat == i, label)).clicked() {
|
||||
d.fields.insert("category".into(), json!(i));
|
||||
let first = effects_in_category(i).first().map(|e| e.id).unwrap_or("");
|
||||
set_selected(d, first);
|
||||
set_stack_focus(d, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
widgets::subheader(ui, tl!("Filters"));
|
||||
ui.set_width(220.0);
|
||||
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
|
||||
for e in &effects {
|
||||
let picked = e.id == sel;
|
||||
let name = tl!(e.label).trim_end_matches('…').to_string();
|
||||
let resp = ui.add(egui::Button::selectable(picked, name));
|
||||
if resp.clicked() {
|
||||
set_selected(d, e.id);
|
||||
set_stack_focus(d, false);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
set_selected(d, e.id);
|
||||
set_stack_focus(d, false);
|
||||
add_selected(d, e.id);
|
||||
stack = stack_of(d);
|
||||
}
|
||||
}
|
||||
});
|
||||
if widgets::flat_button(ui, tl!("Add to Stack"), 120.0).clicked() && !sel.is_empty() {
|
||||
add_selected(d, &sel);
|
||||
stack = stack_of(d);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, tl!("Applied Filters"));
|
||||
let mut stack_sel = d.fields.get("stackSelected").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
if stack_sel >= stack.len() && !stack.is_empty() {
|
||||
stack_sel = stack.len() - 1;
|
||||
}
|
||||
enum StackOp {
|
||||
Toggle(usize),
|
||||
Up(usize),
|
||||
Down(usize),
|
||||
Remove(usize),
|
||||
Select(usize),
|
||||
}
|
||||
let mut op = None;
|
||||
let stack_len = stack.len();
|
||||
egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
|
||||
if stack.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("Add filters from the list above")).color(t.text_dim));
|
||||
return;
|
||||
}
|
||||
for (i, entry) in stack.iter().enumerate().take(stack_len) {
|
||||
ui.horizontal(|ui| {
|
||||
let eye = if entry.visible { "eye" } else { "eye-off" };
|
||||
if widgets::icon_button(ui, eye, tl!("Click to toggle visibility"), false, 22.0).clicked() {
|
||||
op = Some(StackOp::Toggle(i));
|
||||
}
|
||||
let name = effect_label(&entry.effect);
|
||||
if ui.add(egui::Button::selectable(stack_sel == i, name)).clicked() {
|
||||
op = Some(StackOp::Select(i));
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, "dc-arrow-up", tl!("Move Up"), false, i > 0, 22.0).clicked() {
|
||||
op = Some(StackOp::Up(i));
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, "dc-arrow-down", tl!("Move Down"), false, i + 1 < stack_len, 22.0).clicked() {
|
||||
op = Some(StackOp::Down(i));
|
||||
}
|
||||
if widgets::flat_button(ui, tl!("Remove"), 64.0).clicked() {
|
||||
op = Some(StackOp::Remove(i));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
match op {
|
||||
Some(StackOp::Toggle(i)) if stack.get(i).is_some() => stack[i].visible = !stack[i].visible,
|
||||
Some(StackOp::Select(i)) => {
|
||||
stack_sel = i;
|
||||
set_stack_focus(d, true);
|
||||
}
|
||||
Some(StackOp::Up(i)) if i > 0 => {
|
||||
stack.swap(i, i - 1);
|
||||
stack_sel = i - 1;
|
||||
}
|
||||
Some(StackOp::Down(i)) if i + 1 < stack.len() => {
|
||||
stack.swap(i, i + 1);
|
||||
stack_sel = i + 1;
|
||||
}
|
||||
Some(StackOp::Remove(i)) if i < stack.len() => {
|
||||
stack.remove(i);
|
||||
if stack_sel >= stack.len() && !stack.is_empty() {
|
||||
stack_sel = stack.len() - 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
write_stack(d, &stack);
|
||||
d.fields.insert("stackSelected".into(), json!(stack_sel));
|
||||
stack_preview_row(app, ui, d, &stack);
|
||||
false
|
||||
}
|
||||
|
||||
fn add_selected(d: &mut Dialog, id: &str) {
|
||||
let params =
|
||||
if !stack_focus(d) && d.fields.get("browseEffect").and_then(Value::as_str) == Some(id) { browse_params(d) } else { default_params(id) };
|
||||
let mut stack = stack_of(d);
|
||||
stack.push(StackEntry { effect: id.to_string(), visible: true, params });
|
||||
write_stack(d, &stack);
|
||||
d.fields.insert("stackSelected".into(), json!(stack.len().saturating_sub(1)));
|
||||
set_stack_focus(d, true);
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let stack = stack_of(d);
|
||||
if stack.is_empty() {
|
||||
if app.session.in_interaction() {
|
||||
let _ = app.session.cancel_interaction();
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let batch = stack_batch_params(&stack);
|
||||
if d.bool("preview") && app.session.in_interaction() {
|
||||
return super::form::commit_preview(app, "command.batch", batch);
|
||||
}
|
||||
let r = app.run("command.batch", batch);
|
||||
if r.is_ok() {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 900.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_preview_is_one_interaction() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("ui.effectGalleryDialog", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("stack".into(), json!([{ "effect": "artistic.cutout", "visible": true, "params": { "levels": 3 } }]));
|
||||
frame(&mut app);
|
||||
assert!(app.session.in_interaction(), "the stack previews on the canvas");
|
||||
crate::dialogs::cancel(&mut app);
|
||||
assert!(!app.session.in_interaction());
|
||||
assert!(app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_ok_is_one_undo_step() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("ui.effectGalleryDialog", json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(super::KIND));
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert(
|
||||
"stack".into(),
|
||||
json!([
|
||||
{ "effect": "artistic.cutout", "visible": true, "params": { "levels": 3 } },
|
||||
{ "effect": "stylize.glowingEdges", "visible": false, "params": { "edgeWidth": 2 } }
|
||||
]),
|
||||
);
|
||||
frame(&mut app);
|
||||
let steps = app.session.active().unwrap().history.undo.len();
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let st = app.session.active().unwrap();
|
||||
let n = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap();
|
||||
assert_eq!(n.appearance.effects.len(), 2);
|
||||
assert_eq!(n.appearance.effects[0].id, "artistic.cutout");
|
||||
assert_eq!(n.appearance.effects[0].params.get("levels").and_then(|v| v.as_i64()), Some(3));
|
||||
assert_eq!(n.appearance.effects[1].id, "stylize.glowingEdges");
|
||||
assert!(!n.appearance.effects[1].visible);
|
||||
assert_eq!(st.history.undo.len(), steps + 1);
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,23 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
app.ui.dialog = None;
|
||||
let path = io::target_path(app, None, f.extensions[0])?;
|
||||
let range = if all { "all".to_string() } else { range };
|
||||
let r = options_or_write(app, f, plugin, path, use_artboards, all, range);
|
||||
// The options follow the save panel: the Enter that closed it isn't theirs (as in Save As).
|
||||
super::opened_by_panel(app);
|
||||
r
|
||||
}
|
||||
|
||||
/// After the save panel picked `path`: the options dialog of format `f` (or of its exporter
|
||||
/// plug-in), else the file written.
|
||||
fn options_or_write(
|
||||
app: &mut VectorcraftApp,
|
||||
f: &'static Format,
|
||||
plugin: Option<std::sync::Arc<vectorcraft_plugins::Plugin>>,
|
||||
path: String,
|
||||
use_artboards: bool,
|
||||
all: bool,
|
||||
range: String,
|
||||
) -> Result<Value, String> {
|
||||
match f.id {
|
||||
"svg" | "svgz" => {
|
||||
svg_options::open(app, svg_options::Mode::Export, Some(&path));
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod dxf_import;
|
||||
pub mod dxf_options;
|
||||
pub mod edit_selection;
|
||||
mod effect;
|
||||
pub mod effect_gallery;
|
||||
pub mod envelope;
|
||||
pub mod eps_options;
|
||||
pub mod expand;
|
||||
@@ -301,6 +302,7 @@ registry! {
|
||||
SwatchConflict: [swatch_conflict::KIND] => swatch_conflict::SPEC,
|
||||
FileInfo: [file_info::KIND] => file_info::SPEC,
|
||||
RasterEffectsSettings: [raster_effects::KIND] => raster_effects::SPEC,
|
||||
EffectGallery: [effect_gallery::KIND] => effect_gallery::SPEC,
|
||||
MissingLinks: [missing_links::KIND] => missing_links::SPEC,
|
||||
MissingFonts: [missing_fonts::KIND] => missing_fonts::SPEC,
|
||||
FindLinks: [find_links::KIND] => find_links::SPEC,
|
||||
@@ -426,6 +428,14 @@ pub(crate) fn settle(app: &mut VectorcraftApp) {
|
||||
/// until Enter has been let go.
|
||||
pub(crate) const AFTER_PANEL: &str = "__afterPanel";
|
||||
|
||||
/// Marks the open dialog, if any, as one a save panel opened ([`AFTER_PANEL`]): the Enter that
|
||||
/// closed the panel, still held, doesn't confirm it.
|
||||
pub(crate) fn opened_by_panel(app: &mut VectorcraftApp) {
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert(AFTER_PANEL.into(), json!(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the active dialog and handles shared initial focus, keyboard shortcuts and actions.
|
||||
/// Inline actions and footer buttons use the same confirmation and preview rollback paths.
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
|
||||
@@ -16,10 +16,15 @@ pub(super) type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
/// An app with `artboards` 60×40 pt artboards whose save dialog answers `/out/<suggested>` and
|
||||
/// whose writer records what it is given.
|
||||
pub(super) fn app(artboards: usize) -> (VectorcraftApp, Written) {
|
||||
app_saving(artboards, |p| format!("/out/{}", p.name))
|
||||
}
|
||||
|
||||
/// [`app`], with a save dialog that answers `pick`.
|
||||
pub(super) fn app_saving(artboards: usize, pick: fn(&crate::FilePick) -> String) -> (VectorcraftApp, Written) {
|
||||
let written = Written::default();
|
||||
let w = written.clone();
|
||||
let services = Services {
|
||||
pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name)))),
|
||||
pick_save: Some(Box::new(move |p: &crate::FilePick| Some(pick(p)))),
|
||||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
@@ -266,3 +271,42 @@ fn export_as_writes_only_the_chosen_artboard_in_every_format() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Enter that closed the save panel, still held as the format's options open, doesn't write
|
||||
/// the file; Enter pressed again after it is let go does. So for every options dialog Export As
|
||||
/// opens after its panel, an exporter plug-in's included (as Save As's options).
|
||||
#[test]
|
||||
fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() {
|
||||
let params = r#"{"speed":{"type":"number","min":1,"max":100,"default":40}}"#;
|
||||
let module = wat::parse_str(vectorcraft_plugins::wat::returning(
|
||||
&vectorcraft_plugins::wat::exporter("test.ui.held", r#"["tuh"]"#, params),
|
||||
r#"{"dataBase64":"SGk="}"#,
|
||||
))
|
||||
.unwrap();
|
||||
let enter = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
// Each format whose options follow the panel (PDF's with Use Artboards: Save PDF).
|
||||
let formats = [("png", false), ("svg", false), ("pdf", true), ("txt", false), ("dxf", false), ("eps", false), ("plugin.test.ui.held", false)];
|
||||
for (format, artboards) in formats {
|
||||
let (mut app, written) = app(1);
|
||||
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&module)})).unwrap();
|
||||
app.run("file.exportAs", json!({"format": format})).unwrap();
|
||||
set(&mut app, "useArtboards", json!(artboards));
|
||||
confirm(&mut app).unwrap();
|
||||
let options = kind(&app).map(str::to_string);
|
||||
assert!(options.as_deref().is_some_and(|k| k != "exportAs"), "{format}: its options follow the panel, not {options:?}");
|
||||
frame(&mut app, vec![enter(true)]);
|
||||
assert_eq!(kind(&app).map(str::to_string), options, "{format}: the Enter still held isn't the options'");
|
||||
assert!(written.borrow().is_empty(), "{format}: nothing written");
|
||||
frame(&mut app, vec![enter(false)]);
|
||||
frame(&mut app, vec![enter(true)]);
|
||||
assert!(app.ui.dialog.is_none(), "{format}: Enter pressed again confirms");
|
||||
assert!(!written.borrow().is_empty(), "{format}: and writes");
|
||||
}
|
||||
vectorcraft_plugins::registry::remove("test.ui.held");
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Exporter plug-ins in File › Export › Export As: listed after the built-in formats, written
|
||||
//! whole (Use Artboards is not theirs to apply), and their parameters asked for once the file is
|
||||
//! picked. Each test installs exporters of its own: plug-ins are process-wide.
|
||||
//! picked. Each test installs exporters of its own and removes them: plug-ins are process-wide, and
|
||||
//! a format left installed would show in other tests' Export As.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_plugins::wat::{self as tpl, Echo};
|
||||
|
||||
use super::tests_export::{app, frame, kind, names, set};
|
||||
use super::tests_export::{app, app_saving, frame, kind, names, set};
|
||||
use super::*;
|
||||
|
||||
fn install(app: &mut VectorcraftApp, src: &str) {
|
||||
@@ -13,6 +14,8 @@ fn install(app: &mut VectorcraftApp, src: &str) {
|
||||
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap();
|
||||
}
|
||||
|
||||
/// Export As lists an exporter after the built-in formats and writes its file of the whole
|
||||
/// document (Use Artboards greyed out), with the exporter's warnings in the status bar.
|
||||
#[test]
|
||||
fn export_as_lists_exporters_and_writes_their_file_whole() {
|
||||
let (mut app, written) = app(2);
|
||||
@@ -37,8 +40,10 @@ fn export_as_lists_exporters_and_writes_their_file_whole() {
|
||||
assert_eq!(names(&written), ["/out/Untitled-1.tuw"]);
|
||||
assert_eq!(written.borrow()[0].1, b"Hi");
|
||||
assert!(app.ui.status.contains("a note"), "{}", app.ui.status);
|
||||
app.run("plugin.remove", json!({"id": "test.ui.whole"})).unwrap();
|
||||
}
|
||||
|
||||
/// An exporter with parameters asks for them once the file is picked, and writes with them.
|
||||
#[test]
|
||||
fn an_exporter_with_parameters_asks_for_them_once_the_file_is_picked() {
|
||||
let (mut app, written) = app(1);
|
||||
@@ -59,6 +64,7 @@ fn an_exporter_with_parameters_asks_for_them_once_the_file_is_picked() {
|
||||
assert_eq!(names(&written), ["/out/Untitled-1.tup"]);
|
||||
let sent: Value = serde_json::from_slice(&written.borrow()[0].1).unwrap();
|
||||
assert_eq!(sent, json!({"density": 7.0, "_context": {"mode": "export", "extension": "tup", "name": "Untitled-1"}}));
|
||||
app.run("plugin.remove", json!({"id": "test.ui.params"})).unwrap();
|
||||
}
|
||||
|
||||
/// `CRAFT_UI_PLUGIN_EXPORT_SHOT=<folder>` saves the pictures of the dialogs to that folder: Export As
|
||||
@@ -113,4 +119,18 @@ fn a_declining_exporter_writes_nothing() {
|
||||
assert!(written.borrow().is_empty());
|
||||
let said = r.err().unwrap_or_default() + &app.ui.status;
|
||||
assert!(said.contains("Nothing to plot."), "{said}");
|
||||
app.run("plugin.remove", json!({"id": "test.ui.declines"})).unwrap();
|
||||
}
|
||||
|
||||
/// Export options that aren't an object are none: the exporter is still told the file it writes,
|
||||
/// by its name and extension (here the second it lists), not the document's name and its first.
|
||||
#[test]
|
||||
fn an_exporter_is_told_its_file_whatever_the_options_are() {
|
||||
let (mut app, written) = app_saving(1, |_| "/out/plotter-job.tnb".into());
|
||||
install(&mut app, &tpl::echo_exporter(&tpl::exporter("test.ui.nulloptions", r#"["tna","tnb"]"#, "{}"), Echo::Params));
|
||||
crate::io::export(&mut app, Some("plugin.test.ui.nulloptions"), None, &Value::Null).unwrap();
|
||||
assert_eq!(names(&written), ["/out/plotter-job.tnb"]);
|
||||
let sent: Value = serde_json::from_slice(&written.borrow()[0].1).unwrap();
|
||||
assert_eq!(sent, json!({"_context": {"mode": "export", "extension": "tnb", "name": "plotter-job"}}));
|
||||
app.run("plugin.remove", json!({"id": "test.ui.nulloptions"})).unwrap();
|
||||
}
|
||||
@@ -127,6 +127,7 @@ fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &s
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
crate::panel_docking::prune_collapsed(&mut app.ui);
|
||||
if crate::panel_docking::show(app, ui) {
|
||||
return;
|
||||
}
|
||||
@@ -159,6 +160,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// It floats from this frame on.
|
||||
return;
|
||||
}
|
||||
// Collapsed to its tabs (a double-click on one).
|
||||
if app.ui.docking_collapsed.contains(app.ui.dock_tab.info().0) {
|
||||
return;
|
||||
}
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match active {
|
||||
DockTab::Properties => {
|
||||
egui::ScrollArea::vertical()
|
||||
@@ -230,9 +235,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The tabbed group's tab strip, showing `active`: a click on a tab shows it, a tab dragged out of
|
||||
/// the strip floats on its own, and the strip right of the tabs floats the whole group. True when
|
||||
/// the shown panel floats now.
|
||||
/// The tabbed group's tab strip, showing `active`: a click on a tab shows it, a double-click
|
||||
/// collapses the group to its tabs or expands it, a tab dragged out of the strip floats on its own,
|
||||
/// and the strip right of the tabs floats the whole group. True when the shown panel floats now.
|
||||
fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
|
||||
@@ -258,6 +263,10 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
|
||||
let id = tab.info().0;
|
||||
if let Some((from, to)) = tab_reorder_on_release(strip, pointer, resp, &out.responses) {
|
||||
apply_dock_tab_reorder(app, &tabs, from, to);
|
||||
} else if resp.double_clicked() {
|
||||
if let Err(error) = app.run("window.panel.collapse", json!({ "panel": id })) {
|
||||
app.ui.status = error;
|
||||
}
|
||||
} else if resp.clicked() {
|
||||
app.ui.dock_tab = tab;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! A selection dragged across document tabs remains editable and leaves its source intact.
|
||||
|
||||
use egui::{Context, Id, Response};
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::{VectorcraftApp, canvas, widgets};
|
||||
|
||||
const HOVER_SECONDS: f64 = 0.4;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Origin {
|
||||
uid: u64,
|
||||
grab: Point,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Artwork {
|
||||
origin: Origin,
|
||||
ids: Vec<NodeId>,
|
||||
revision: u64,
|
||||
target: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Hover {
|
||||
uid: u64,
|
||||
since: f64,
|
||||
}
|
||||
|
||||
/// Key for the source document and original canvas grab point.
|
||||
fn origin_id() -> Id {
|
||||
Id::new("document-drag-origin")
|
||||
}
|
||||
/// Key for the tab identity and dwell start time of the current hover.
|
||||
fn hover_id() -> Id {
|
||||
Id::new("document-drag-hover")
|
||||
}
|
||||
/// Key for the pointer position latched after tab activation to prevent cascading switches.
|
||||
fn activated_at_id() -> Id {
|
||||
Id::new("document-drag-activated-at")
|
||||
}
|
||||
|
||||
/// Record the Selection drag source and grab point, clearing any previous tab-hover timer.
|
||||
pub(crate) fn begin(app: &VectorcraftApp, ctx: &Context, grab: Point) {
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<Origin>(origin_id());
|
||||
d.remove::<Hover>(hover_id());
|
||||
d.remove::<egui::Pos2>(activated_at_id());
|
||||
if app.session.tool_id() == "selection"
|
||||
&& let Some(st) = app.session.active()
|
||||
{
|
||||
d.insert_temp(origin_id(), Origin { uid: st.uid, grab });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Called before panels. Cancelled or abandoned transfers must never reach a later mouse-up.
|
||||
pub(crate) fn cleanup(app: &VectorcraftApp, ctx: &Context) {
|
||||
// Activating a tab can scroll the strip or change title widths. A tab moving under a
|
||||
// stationary pointer must not start another hover; actual pointer movement rearms it.
|
||||
let at = ctx.input(|i| i.pointer.interact_pos());
|
||||
ctx.data_mut(|d| {
|
||||
if d.get_temp::<egui::Pos2>(activated_at_id()) != at {
|
||||
d.remove::<egui::Pos2>(activated_at_id());
|
||||
}
|
||||
});
|
||||
if let Some(art) = egui::DragAndDrop::payload::<Artwork>(ctx) {
|
||||
let valid = app.session.documents().iter().any(|d| d.uid == art.origin.uid && d.revision == art.revision)
|
||||
&& app.session.active().is_some_and(|d| d.uid == art.target);
|
||||
let cancel = ctx.input(|i| {
|
||||
!i.focused
|
||||
|| i.key_pressed(egui::Key::Escape)
|
||||
|| (!i.pointer.primary_down() && !i.pointer.primary_released())
|
||||
|| i.multi_touch().is_some()
|
||||
|| i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::Cancel, .. }))
|
||||
});
|
||||
let outside_release =
|
||||
ctx.input(|i| i.pointer.primary_released() && !app.canvas_rect.is_some_and(|r| i.pointer.interact_pos().is_some_and(|p| r.contains(p))));
|
||||
if !valid || cancel || outside_release || app.ui.dialog.is_some() {
|
||||
egui::DragAndDrop::clear_payload(ctx);
|
||||
}
|
||||
}
|
||||
if !ctx.input(|i| i.pointer.primary_down()) {
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<Origin>(origin_id());
|
||||
d.remove::<Hover>(hover_id());
|
||||
d.remove::<egui::Pos2>(activated_at_id());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Hover uses `contains_pointer`: the source canvas still owns egui's dragged widget.
|
||||
pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, index: usize, close: egui::Rect) -> bool {
|
||||
let ctx = &resp.ctx;
|
||||
let eligible = resp.contains_pointer()
|
||||
&& ctx
|
||||
.input(|i| i.pointer.primary_down() && !i.key_pressed(egui::Key::Escape) && i.pointer.interact_pos().is_some_and(|p| !close.contains(p)))
|
||||
&& app.ui.dialog.is_none();
|
||||
if !eligible || app.session.active_index() == Some(index) || ctx.data(|d| d.get_temp::<egui::Pos2>(activated_at_id()).is_some()) {
|
||||
return false;
|
||||
}
|
||||
let transfer = egui::DragAndDrop::has_payload_of_type::<Artwork>(ctx);
|
||||
let art = egui::DragAndDrop::payload::<widgets::PanelDrag>(ctx).is_some_and(|p| matches!(*p, widgets::PanelDrag::Art(_)));
|
||||
if !transfer && !art {
|
||||
return false;
|
||||
}
|
||||
let Some(uid) = app.session.documents().get(index).map(|d| d.uid) else {
|
||||
return false;
|
||||
};
|
||||
let now = ctx.input(|i| i.time);
|
||||
let hover = ctx.data(|d| d.get_temp::<Hover>(hover_id())).filter(|h| h.uid == uid).unwrap_or(Hover { uid, since: now });
|
||||
ctx.data_mut(|d| d.insert_temp(hover_id(), hover.clone()));
|
||||
let remaining = HOVER_SECONDS - (now - hover.since);
|
||||
if remaining > 0.0 {
|
||||
ctx.request_repaint_after(std::time::Duration::try_from_secs_f64(remaining).unwrap_or(std::time::Duration::from_millis(400)));
|
||||
return true;
|
||||
}
|
||||
if !transfer {
|
||||
let Some(origin) = ctx.data(|d| d.get_temp::<Origin>(origin_id())) else {
|
||||
return true;
|
||||
};
|
||||
if !app
|
||||
.session
|
||||
.active()
|
||||
.is_some_and(|d| d.uid == origin.uid && d.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Err(e) = app.session.cancel_interaction() {
|
||||
app.status(e.to_string());
|
||||
return true;
|
||||
}
|
||||
// Alt-drag previews use temporary duplicate ids; only the restored originals travel.
|
||||
let ids = app.session.active().map(|d| d.selection.objects.clone()).unwrap_or_default();
|
||||
if ids.is_empty() {
|
||||
return true;
|
||||
}
|
||||
canvas::end_document_drag(ctx);
|
||||
let revision = app.session.active().map(|d| d.revision).unwrap_or_default();
|
||||
egui::DragAndDrop::set_payload(ctx, Artwork { origin, ids, revision, target: uid });
|
||||
}
|
||||
if transfer && let Some(art) = egui::DragAndDrop::payload::<Artwork>(ctx) {
|
||||
let mut art = (*art).clone();
|
||||
art.target = uid;
|
||||
egui::DragAndDrop::set_payload(ctx, art);
|
||||
}
|
||||
app.ui.home = None;
|
||||
app.session.set_active(index);
|
||||
ctx.request_repaint();
|
||||
let at = ctx.input(|i| i.pointer.interact_pos());
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<Hover>(hover_id());
|
||||
if let Some(at) = at {
|
||||
d.insert_temp(activated_at_id(), at);
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Reset the dwell timer when no eligible inactive tab is under the artwork.
|
||||
pub(crate) fn finish_tabs(ctx: &Context, hovering: bool) {
|
||||
if !hovering {
|
||||
ctx.data_mut(|d| d.remove::<Hover>(hover_id()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Release artwork into the active canvas; returning to the source resumes Move or Alt-copy.
|
||||
pub(crate) fn drop_on_canvas(app: &mut VectorcraftApp, resp: &Response, xf: &canvas::Xf) {
|
||||
let Some(payload) = egui::DragAndDrop::payload::<Artwork>(&resp.ctx) else {
|
||||
return;
|
||||
};
|
||||
let same = app.session.active().is_some_and(|d| d.uid == payload.origin.uid);
|
||||
let copy = resp.ctx.input(|i| i.modifiers.alt);
|
||||
if resp.contains_pointer() {
|
||||
resp.ctx.set_cursor_icon(if same && !copy { egui::CursorIcon::Move } else { egui::CursorIcon::Copy });
|
||||
}
|
||||
if let Some(art) = resp.dnd_release_payload::<Artwork>()
|
||||
&& let Some(at) = resp.ctx.input(|i| i.pointer.interact_pos()).map(|p| xf.to_doc(p))
|
||||
{
|
||||
let result = if same {
|
||||
// Back in the source tab, resume the ordinary move (Alt copies).
|
||||
app.run("object.move", json!({"ids": art.ids, "dx": at.x-art.origin.grab.x, "dy": at.y-art.origin.grab.y, "copy": copy}))
|
||||
} else {
|
||||
app.run(
|
||||
"edit.dropSelection",
|
||||
json!({"sourceDocument": art.origin.uid, "ids": art.ids, "grab": [art.origin.grab.x, art.origin.grab.y], "at": [at.x, at.y]}),
|
||||
)
|
||||
};
|
||||
if let Err(e) = result {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -583,6 +583,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
app.ui.docking = m.original.docking;
|
||||
app.ui.docking_hidden = m.original.docking_hidden;
|
||||
app.ui.docking_icons = m.original.docking_icons;
|
||||
app.ui.docking_collapsed = m.original.docking_collapsed;
|
||||
app.ui.floating_panels = m.original.floating_panels;
|
||||
app.ui.open_panel = m.original.open_panel;
|
||||
app.ui.dock_tab = m.original.dock_tab;
|
||||
|
||||
@@ -1049,6 +1049,11 @@
|
||||
Fidelity: Точност:
|
||||
Figure Space Цифрово разстояние
|
||||
Figure: Фигура:
|
||||
# Artwork drag between document tabs.
|
||||
Drop Artwork Поставяне на графика
|
||||
@msg {key} must be [x, y] with finite coordinates {key} трябва да е [x, y] с крайни координати
|
||||
@msg ids must contain 1–10000 object ids ids трябва да съдържа от 1 до 10000 идентификатора на обекти
|
||||
|
||||
File Файл
|
||||
File Dimensions Размери на файла
|
||||
File Format: Файлов формат:
|
||||
@@ -2342,6 +2347,7 @@
|
||||
Proof Setup Настройка на пробен печат
|
||||
Properties Свойства
|
||||
Proportional Metrics Пропорционални метрики
|
||||
Character Direction Посока на знаците
|
||||
Digits Цифри
|
||||
Arabic Арабски
|
||||
Hindi Хинди
|
||||
@@ -3900,7 +3906,7 @@ axis Both И двете
|
||||
@msg give the path `id` and the width point `index` дайте пътя `id` и точката на ширина `index`
|
||||
@msg guides are locked водачите са заключени
|
||||
@msg handle must be in or out handle трябва да е in или out
|
||||
@msg image '{key}' has no pixels and was left out изображението '{key}' няма пиксели и беше пропуснато
|
||||
@msg an image has no pixels and was left out изображение без пиксели беше пропуснато
|
||||
@msg image data `{_1}` is missing данните за изображение `{_1}` липсват
|
||||
@msg image would be too large; lower the resolution изображението ще бъде твърде голямо; намалете разделителната способност
|
||||
@msg index must be a number or first, previous, next or last индексът трябва да е число или first, previous, next или last
|
||||
@@ -4474,6 +4480,7 @@ effect Soft Меко
|
||||
Activate Panel Активиране на панела
|
||||
Change Panel Layout Промяна на оформлението на панела
|
||||
Close Panel Затваряне на панела
|
||||
Collapse Panel Group Свиване на групата панели
|
||||
Move Panel Преместване на панела
|
||||
Group with Групиране с
|
||||
Resize panels Преоразмеряване на панели
|
||||
@@ -4484,6 +4491,9 @@ effect Soft Меко
|
||||
@msg before must name a panel параметърът „преди“ трябва да указва панел
|
||||
@msg docking action contains an unknown panel or an excessive split path действието по закачане съдържа непознат панел или прекомерен път на разделяне
|
||||
@msg group must be true or false групата трябва да е истина или лъжа
|
||||
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} панелът е плаващ; свийте групата му с window.panel.float {panel, collapsed}
|
||||
@msg panel is not in a dock group панелът не е в група на дока
|
||||
@msg only a dock group above or below others collapses, not one beside another свива се само група на дока над или под други, не и такава до друга
|
||||
@msg icon must be true or false icon трябва да е истина или лъжа
|
||||
@msg icon only docks a panel in the icon column, with no anchor icon закачва панел само в колоната с икони, без anchor
|
||||
@msg column docks a set as a dock column, with no anchor or icon column закачва набор като колона на дока, без anchor или icon
|
||||
@@ -5305,6 +5315,14 @@ theme Appearance Външен вид
|
||||
Full Justify Пълно двустранно
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Минималната стойност не може да надвишава желаната, нито желаната – максималната.
|
||||
Justification Подравняване
|
||||
Add filters from the list above Добавете филтри от списъка по-горе
|
||||
Add to Stack Добавяне към стека
|
||||
Applied Filters Приложени филтри
|
||||
Categories Категории
|
||||
Choose a filter Изберете филтър
|
||||
Effect Gallery Галерия с ефекти
|
||||
Filters Филтри
|
||||
Select artwork to apply effects Изберете графика, за да приложите ефекти
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them променливите и наборите от данни на файла не бяха прочетени (променливи: {variables}, набори от данни: {data_sets}): панелът „Променливи“ не ги показва
|
||||
# Select by Color (select.color)
|
||||
Select by Color Избиране по цвят
|
||||
@@ -5330,5 +5348,13 @@ theme Appearance Външен вид
|
||||
Water Paper Мокра хартия
|
||||
Ruby… Руби…
|
||||
Ruby Руби
|
||||
Ease In Плавно начало
|
||||
Ease Out Плавен край
|
||||
Ease In Out Плавно начало и край
|
||||
Ease Out In Плавен край и начало
|
||||
Ramp: Рампа:
|
||||
Shift: Изместване:
|
||||
Fresco… Фреска…
|
||||
Fresco Фреска
|
||||
Effect Gallery… Галерия с ефекти…
|
||||
Effect Gallery Dialog Диалог „Галерия с ефекти“
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -34,6 +34,11 @@
|
||||
Medium Light Středně světlý
|
||||
Light Světlý
|
||||
Quit VectorCraft Ukončit VectorCraft
|
||||
# Artwork drag between document tabs.
|
||||
Drop Artwork Umístit grafiku
|
||||
@msg {key} must be [x, y] with finite coordinates {key} musí být [x, y] s konečnými souřadnicemi
|
||||
@msg ids must contain 1–10000 object ids ids musí obsahovat 1–10000 identifikátorů objektů
|
||||
|
||||
File Soubor
|
||||
New… Nový…
|
||||
New Without Dialog Nový bez dialogového okna
|
||||
@@ -829,6 +834,7 @@
|
||||
Activate Panel Aktivovat panel
|
||||
Change Panel Layout Změnit rozložení panelů
|
||||
Close Panel Zavřít panel
|
||||
Collapse Panel Group Sbalit skupinu panelů
|
||||
Move Panel Přesunout panel
|
||||
Group with Seskupit s
|
||||
Resize panels Změnit velikost panelů
|
||||
@@ -1393,6 +1399,7 @@ lighting Light Světlo
|
||||
Leading Proklad
|
||||
No Break Nezalamovat
|
||||
Proportional Metrics Proporcionální metrika
|
||||
Character Direction Směr znaků
|
||||
Digits Číslice
|
||||
Arabic Arabské
|
||||
Hindi Hindské
|
||||
@@ -4290,7 +4297,7 @@ imageTrace Outline Obrys
|
||||
@msg give the path `id` and the width point `index` zadejte `id` cesty a `index` bodu šířky
|
||||
@msg guides are locked vodítka jsou zamčená
|
||||
@msg handle must be in or out handle musí být in nebo out
|
||||
@msg image '{key}' has no pixels and was left out obrázek '{key}' nemá žádné obrazové body a byl vynechán
|
||||
@msg an image has no pixels and was left out obrázek bez obrazových bodů byl vynechán
|
||||
@msg image data `{_1}` is missing chybí obrazová data `{_1}`
|
||||
@msg image would be too large; lower the resolution obraz by byl příliš velký; snižte rozlišení
|
||||
@msg index must be a number or first, previous, next or last index musí být číslo nebo first, previous, next či last
|
||||
@@ -4752,6 +4759,9 @@ imageTrace Outline Obrys
|
||||
@msg before must name a panel before musí určovat panel
|
||||
@msg docking action contains an unknown panel or an excessive split path akce ukotvení obsahuje neznámý panel nebo příliš dlouhou cestu rozdělení
|
||||
@msg group must be true or false group musí být true nebo false
|
||||
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panel je plovoucí; jeho skupinu sbalíte pomocí window.panel.float {panel, collapsed}
|
||||
@msg panel is not in a dock group panel není ve skupině doku
|
||||
@msg only a dock group above or below others collapses, not one beside another sbalit lze jen skupinu doku nad nebo pod jinými, ne vedle jiné
|
||||
@msg icon must be true or false icon musí být true nebo false
|
||||
@msg icon only docks a panel in the icon column, with no anchor icon jen ukotví panel ve sloupci ikon, bez anchor
|
||||
@msg column docks a set as a dock column, with no anchor or icon column ukotví sadu jako sloupec doku, bez anchor nebo icon
|
||||
@@ -5328,6 +5338,7 @@ mark-style Japanese Japonské
|
||||
@msg Persistent libraries not found: {files} Trvalé knihovny nebyly nalezeny: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` není panel knihovny (viz window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open panel knihovny `{given}` není otevřený
|
||||
@msg library panel `{given}` isn't missing its library knihovna panelu knihovny `{given}` nechybí
|
||||
@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ů).
|
||||
@@ -5348,6 +5359,14 @@ mark-style Japanese Japonské
|
||||
Full Justify Zarovnat do bloku
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum nesmí překročit požadovanou hodnotu ani požadovaná hodnota maximum.
|
||||
Justification Zarovnání do bloku
|
||||
Add filters from the list above Přidejte filtry ze seznamu výše
|
||||
Add to Stack Přidat na zásobník
|
||||
Applied Filters Použité filtry
|
||||
Categories Kategorie
|
||||
Choose a filter Vyberte filtr
|
||||
Effect Gallery Galerie efektů
|
||||
Filters Filtry
|
||||
Select artwork to apply effects Vyberte grafiku, na kterou chcete použít efekty
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them proměnné a sady dat souboru nebyly načteny (proměnné: {variables}, sady dat: {data_sets}): panel Proměnné je nezobrazuje
|
||||
# Select by Color (select.color)
|
||||
Select by Color Vybrat podle barvy
|
||||
@@ -5373,5 +5392,13 @@ mark-style Japanese Japonské
|
||||
Water Paper Mokrý papír
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Ease In Pozvolný začátek
|
||||
Ease Out Pozvolný konec
|
||||
Ease In Out Pozvolný začátek a konec
|
||||
Ease Out In Pozvolný konec a začátek
|
||||
Ramp: Síla:
|
||||
Shift: Posun:
|
||||
Fresco… Freska…
|
||||
Fresco Freska
|
||||
Effect Gallery… Galerie efektů…
|
||||
Effect Gallery Dialog Dialog Galerie efektů
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -20,6 +20,11 @@
|
||||
Medium Light Mellemlys
|
||||
Light Lys
|
||||
Quit VectorCraft Slut VectorCraft
|
||||
# Artwork drag between document tabs.
|
||||
Drop Artwork Slip illustration
|
||||
@msg {key} must be [x, y] with finite coordinates {key} skal være [x, y] med endelige koordinater
|
||||
@msg ids must contain 1–10000 object ids ids skal indeholde 1–10000 objekt-id’er
|
||||
|
||||
File Filer
|
||||
New… Ny…
|
||||
New Without Dialog Ny uden dialog
|
||||
@@ -761,6 +766,7 @@
|
||||
Activate Panel Aktivér panel
|
||||
Change Panel Layout Skift panelets layout
|
||||
Close Panel Luk panel
|
||||
Collapse Panel Group Skjul panelgruppe
|
||||
Move Panel Flyt panel
|
||||
Group with Gruppér med
|
||||
Resize panels Tilpas størrelse på paneler
|
||||
@@ -1311,6 +1317,7 @@ lighting Light Lys
|
||||
Leading Linjeafstand
|
||||
No Break Ingen ombrydning
|
||||
Proportional Metrics Proportionale metrikker
|
||||
Character Direction Tegnretning
|
||||
Digits Cifre
|
||||
Arabic Arabisk
|
||||
Hindi Hindi
|
||||
@@ -4012,7 +4019,7 @@ imageTrace Outline Kontur
|
||||
@msg give horizontal or vertical angiv vandret eller lodret
|
||||
@msg give the path `id` and the width point `index` angiv `id` for kurven og `index` for breddepunktet
|
||||
@msg guides are locked hjælpelinjerne er låst
|
||||
@msg image '{key}' has no pixels and was left out billedet '{key}' har ingen pixels og blev udeladt
|
||||
@msg an image has no pixels and was left out et billede uden pixels blev udeladt
|
||||
@msg image data `{_1}` is missing billeddata `{_1}` mangler
|
||||
@msg image would be too large; lower the resolution billedet ville blive for stort; sænk opløsningen
|
||||
@msg index must be a number or first, previous, next or last indeks skal være et tal eller first, previous, next eller last
|
||||
@@ -4450,6 +4457,9 @@ imageTrace Outline Kontur
|
||||
@msg before must name a panel before skal angive et panel
|
||||
@msg docking action contains an unknown panel or an excessive split path dokningshandlingen indeholder et ukendt panel eller en overdreven opdelingssti
|
||||
@msg group must be true or false group skal være true eller false
|
||||
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panelet flyder; skjul dets gruppe med window.panel.float {panel, collapsed}
|
||||
@msg panel is not in a dock group panelet er ikke i en dockgruppe
|
||||
@msg only a dock group above or below others collapses, not one beside another kun en dockgruppe over eller under andre kan skjules, ikke en ved siden af en anden
|
||||
@msg icon must be true or false icon skal være true eller false
|
||||
@msg icon only docks a panel in the icon column, with no anchor icon docker kun et panel i ikonkolonnen, uden anchor
|
||||
@msg column docks a set as a dock column, with no anchor or icon column docker et sæt som en dockkolonne, uden anchor eller icon
|
||||
@@ -5301,6 +5311,14 @@ swatch Bright Violet Kraftig violet
|
||||
Full Justify Lige margener
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum må ikke overstige Ønsket, og Ønsket ikke Maksimum.
|
||||
Justification Justering
|
||||
Add filters from the list above Tilføj filtre fra listen ovenfor
|
||||
Add to Stack Tilføj til stak
|
||||
Applied Filters Anvendte filtre
|
||||
Categories Kategorier
|
||||
Choose a filter Vælg et filter
|
||||
Effect Gallery Effektgalleri
|
||||
Filters Filtre
|
||||
Select artwork to apply effects Vælg grafik for at anvende effekter
|
||||
# Select by Color (select.color)
|
||||
Select by Color Markér efter farve
|
||||
Smudge Stick… Udtværingsstift…
|
||||
@@ -5346,5 +5364,13 @@ swatch Bright Violet Kraftig violet
|
||||
Water Paper Vådt papir
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Ease In Langsom start
|
||||
Ease Out Langsom slutning
|
||||
Ease In Out Langsom start og slutning
|
||||
Ease Out In Langsom slutning og start
|
||||
Ramp: Styrke:
|
||||
Shift: Forskydning:
|
||||
Fresco… Fresko…
|
||||
Fresco Fresko
|
||||
Effect Gallery… Effektgalleri…
|
||||
Effect Gallery Dialog Dialogboksen Effektgalleri
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1046,6 +1046,11 @@
|
||||
Fidelity: Genauigkeit:
|
||||
Figure Space Ziffernleerzeichen
|
||||
Figure: Ziffern:
|
||||
# Artwork drag between document tabs.
|
||||
Drop Artwork Grafik ablegen
|
||||
@msg {key} must be [x, y] with finite coordinates {key} muss [x, y] mit endlichen Koordinaten sein
|
||||
@msg ids must contain 1–10000 object ids ids muss 1–10000 Objekt-IDs enthalten
|
||||
|
||||
File Datei
|
||||
File Dimensions Dateiabmessungen
|
||||
File Format: Dateiformat:
|
||||
@@ -2360,6 +2365,7 @@ mark-style Japanese Japanisch
|
||||
Proof Setup Proof einrichten
|
||||
Properties Eigenschaften
|
||||
Proportional Metrics Proportionale Metrik
|
||||
Character Direction Zeichenrichtung
|
||||
Digits Ziffern
|
||||
Arabic Arabisch
|
||||
Hindi Hindi
|
||||
@@ -3912,7 +3918,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg give the path `id` and the width point `index` geben Sie die `id` des Pfads und den `index` des Breitenpunkts an
|
||||
@msg guides are locked die Hilfslinien sind gesperrt
|
||||
@msg handle must be in or out handle muss in oder out sein
|
||||
@msg image '{key}' has no pixels and was left out das Bild '{key}' hat keine Pixel und wurde ausgelassen
|
||||
@msg an image has no pixels and was left out ein Bild ohne Pixel wurde ausgelassen
|
||||
@msg image data `{_1}` is missing die Bilddaten `{_1}` fehlen
|
||||
@msg image would be too large; lower the resolution das Bild wäre zu groß; verringern Sie die Auflösung
|
||||
@msg index must be a number or first, previous, next or last index muss eine Zahl oder first, previous, next oder last sein
|
||||
@@ -4554,6 +4560,7 @@ theme Appearance Erscheinungsbild
|
||||
Activate Panel Bedienfeld aktivieren
|
||||
Change Panel Layout Bedienfeldanordnung ändern
|
||||
Close Panel Bedienfeld schließen
|
||||
Collapse Panel Group Bedienfeldgruppe minimieren
|
||||
Move Panel Bedienfeld verschieben
|
||||
Group with Gruppieren mit
|
||||
Resize panel window Bedienfeldfenster skalieren
|
||||
@@ -4564,6 +4571,9 @@ theme Appearance Erscheinungsbild
|
||||
@msg before must name a panel before muss ein Bedienfeld benennen
|
||||
@msg docking action contains an unknown panel or an excessive split path Die Docking-Aktion enthält ein unbekanntes Bedienfeld oder einen zu langen Teilungspfad
|
||||
@msg group must be true or false group muss true oder false sein
|
||||
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} Bedienfeld ist abgelöst; seine Gruppe mit window.panel.float {panel, collapsed} minimieren
|
||||
@msg panel is not in a dock group Bedienfeld ist in keiner Dock-Gruppe
|
||||
@msg only a dock group above or below others collapses, not one beside another nur eine Dock-Gruppe über oder unter anderen lässt sich minimieren, nicht eine daneben
|
||||
@msg icon must be true or false icon muss true oder false sein
|
||||
@msg icon only docks a panel in the icon column, with no anchor icon dockt ein Bedienfeld nur in der Symbolspalte an, ohne anchor
|
||||
@msg column docks a set as a dock column, with no anchor or icon column dockt einen Satz als Dock-Spalte an, ohne anchor oder icon
|
||||
@@ -4908,6 +4918,7 @@ effect Soft Weich
|
||||
@msg Persistent libraries not found: {files} Dauerhafte Bibliotheken nicht gefunden: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ist kein Bibliotheksbedienfeld (siehe window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open Bibliotheksbedienfeld `{given}` ist nicht geöffnet
|
||||
@msg library panel `{given}` isn't missing its library dem Bibliotheksbedienfeld `{given}` fehlt seine Bibliothek nicht
|
||||
@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).
|
||||
@@ -4929,6 +4940,14 @@ effect Soft Weich
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Das Minimum darf den optimalen Wert nicht übersteigen, der optimale Wert nicht das Maximum.
|
||||
Minimum Minimum
|
||||
Justification Abstände
|
||||
Add filters from the list above Filter aus der Liste oben hinzufügen
|
||||
Add to Stack Zum Stapel hinzufügen
|
||||
Applied Filters Angewendete Filter
|
||||
Categories Kategorien
|
||||
Choose a filter Filter wählen
|
||||
Effect Gallery Effekt-Galerie
|
||||
Filters Filter
|
||||
Select artwork to apply effects Wählen Sie Grafik aus, um Effekte anzuwenden
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them die Variablen und Datensätze der Datei wurden nicht gelesen (Variablen: {variables}, Datensätze: {data_sets}): Das Bedienfeld „Variablen“ listet sie nicht auf
|
||||
# Select by Color (select.color)
|
||||
Select by Color Nach Farbe auswählen
|
||||
@@ -4954,5 +4973,13 @@ effect Soft Weich
|
||||
Water Paper Feuchtes Papier
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Ease In Langsam starten
|
||||
Ease Out Langsam enden
|
||||
Ease In Out Langsam starten und enden
|
||||
Ease Out In Langsam enden und starten
|
||||
Ramp: Stärke:
|
||||
Shift: Verschiebung:
|
||||
Fresco… Fresko…
|
||||
Fresco Fresko
|
||||
Effect Gallery… Effekt-Galerie…
|
||||
Effect Gallery Dialog Dialogfeld „Effekt-Galerie“
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1015,6 +1015,11 @@
|
||||
Fidelity: Πιστότητα:
|
||||
Figure Space Κενό ψηφίου
|
||||
Figure: Σχήμα:
|
||||
# Artwork drag between document tabs.
|
||||
Drop Artwork Απόθεση γραφικών
|
||||
@msg {key} must be [x, y] with finite coordinates Το {key} πρέπει να είναι [x, y] με πεπερασμένες συντεταγμένες
|
||||
@msg ids must contain 1–10000 object ids Το ids πρέπει να περιέχει 1–10000 αναγνωριστικά αντικειμένων
|
||||
|
||||
File Αρχείο
|
||||
File Dimensions Διαστάσεις αρχείου
|
||||
File Format: Μορφή αρχείου:
|
||||
@@ -1738,6 +1743,7 @@
|
||||
Proof Colors Χρώματα δοκιμίου
|
||||
Proof Setup Ρύθμιση δοκιμίου
|
||||
Properties Ιδιότητες
|
||||
Character Direction Κατεύθυνση χαρακτήρων
|
||||
Digits Ψηφία
|
||||
Punctuation Στίξη
|
||||
Puppet Warp Παραμόρφωση με σημεία ελέγχου
|
||||
@@ -2229,6 +2235,7 @@ effect Soft Απαλό
|
||||
Activate Panel Ενεργοποίηση πάνελ
|
||||
Change Panel Layout Αλλαγή διάταξης πάνελ
|
||||
Close Panel Κλείσιμο πάνελ
|
||||
Collapse Panel Group Σύμπτυξη ομάδας πάνελ
|
||||
Move Panel Μετακίνηση πάνελ
|
||||
Group with Ομαδοποίηση με
|
||||
Resize panels Αλλαγή μεγέθους πάνελ
|
||||
@@ -3968,7 +3975,7 @@ theme Appearance Εμφάνιση
|
||||
@msg give the path `id` and the width point `index` δώστε το `id` διαδρομής και το `index` σημείου πλάτους
|
||||
@msg guides are locked οι οδηγοί είναι κλειδωμένοι
|
||||
@msg handle must be in or out το handle πρέπει να είναι in ή out
|
||||
@msg image '{key}' has no pixels and was left out η εικόνα '{key}' δεν περιέχει εικονοστοιχεία και παραλείφθηκε
|
||||
@msg an image has no pixels and was left out παραλείφθηκε εικόνα χωρίς εικονοστοιχεία
|
||||
@msg image data `{_1}` is missing λείπουν τα δεδομένα εικόνας `{_1}`
|
||||
@msg image would be too large; lower the resolution η εικόνα θα ήταν υπερβολικά μεγάλη· μειώστε την ανάλυση
|
||||
@msg index must be a number or first, previous, next or last ο δείκτης πρέπει να είναι αριθμός ή first, previous, next ή last
|
||||
@@ -4469,6 +4476,9 @@ theme Appearance Εμφάνιση
|
||||
@msg before must name a panel το before πρέπει να ορίζει πίνακα
|
||||
@msg docking action contains an unknown panel or an excessive split path η ενέργεια σύνδεσης περιέχει άγνωστο πίνακα ή υπερβολικά βαθιά διαδρομή διαίρεσης
|
||||
@msg group must be true or false το group πρέπει να είναι true ή false
|
||||
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} Το πάνελ αιωρείται· συμπτύξτε την ομάδα του με window.panel.float {panel, collapsed}
|
||||
@msg panel is not in a dock group Το πάνελ δεν βρίσκεται σε ομάδα αγκύρωσης
|
||||
@msg only a dock group above or below others collapses, not one beside another συμπτύσσεται μόνο ομάδα αγκύρωσης πάνω ή κάτω από άλλες, όχι δίπλα σε άλλη
|
||||
@msg icon must be true or false το icon πρέπει να είναι true ή false
|
||||
@msg icon only docks a panel in the icon column, with no anchor το icon αγκυρώνει πάνελ μόνο στη στήλη εικονιδίων, χωρίς anchor
|
||||
@msg column docks a set as a dock column, with no anchor or icon το column αγκυρώνει ένα σύνολο ως στήλη αγκύρωσης, χωρίς anchor ή icon
|
||||
@@ -5280,6 +5290,7 @@ effect Points Σημεία
|
||||
@msg Persistent libraries not found: {files} Δεν βρέθηκαν μόνιμες βιβλιοθήκες: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) Το `{given}` δεν είναι πίνακας βιβλιοθήκης (δείτε window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open ο πίνακας βιβλιοθήκης `{given}` δεν είναι ανοιχτός
|
||||
@msg library panel `{given}` isn't missing its library η βιβλιοθήκη του πίνακα βιβλιοθήκης `{given}` δεν λείπει
|
||||
@msg too many library panels were opened in this session άνοιξαν πάρα πολλοί πίνακες βιβλιοθηκών σε αυτή τη συνεδρία
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Η εικόνα είναι {_1} × {_2} εικονοστοιχεία, πάνω από {LARGE_TRACE_SIDE} σε μία πλευρά: ανιχνεύεται, αλλά πιο αργά. Μια προρύθμιση χαμηλότερης πιστότητας ή ένα μικρότερο αντίγραφο της εικόνας ανιχνεύεται γρηγορότερα.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Η εικόνα ανιχνεύτηκε σε {_1} × {_2} εικονοστοιχεία, σμικρυμένη από {_3} × {_4} (το πολύ {_5} megapixel).
|
||||
@@ -5300,6 +5311,14 @@ effect Points Σημεία
|
||||
Full Justify Στοίχιση και στα δύο άκρα
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Το ελάχιστο δεν πρέπει να υπερβαίνει το επιθυμητό, ούτε το επιθυμητό το μέγιστο.
|
||||
Justification Πλήρης στοίχιση
|
||||
Add filters from the list above Προσθέστε φίλτρα από τη λίστα παραπάνω
|
||||
Add to Stack Προσθήκη στη στοίβα
|
||||
Applied Filters Εφαρμοσμένα φίλτρα
|
||||
Categories Κατηγορίες
|
||||
Choose a filter Επιλέξτε φίλτρο
|
||||
Effect Gallery Συλλογή εφέ
|
||||
Filters Φίλτρα
|
||||
Select artwork to apply effects Επιλέξτε γραφικά για να εφαρμόσετε εφέ
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them οι μεταβλητές και τα σύνολα δεδομένων του αρχείου δεν διαβάστηκαν (μεταβλητές: {variables}, σύνολα δεδομένων: {data_sets}): ο πίνακας Μεταβλητές δεν τα εμφανίζει
|
||||
# Select by Color (select.color)
|
||||
Select by Color Επιλογή βάσει χρώματος
|
||||
@@ -5325,5 +5344,13 @@ effect Points Σημεία
|
||||
Water Paper Βρεγμένο χαρτί
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Ease In Ομαλή αρχή
|
||||
Ease Out Ομαλό τέλος
|
||||
Ease In Out Ομαλή αρχή και τέλος
|
||||
Ease Out In Ομαλό τέλος και αρχή
|
||||
Ramp: Ένταση:
|
||||
Shift: Μετατόπιση:
|
||||
Fresco… Νωπογραφία…
|
||||
Fresco Νωπογραφία
|
||||
Effect Gallery… Συλλογή εφέ…
|
||||
Effect Gallery Dialog Παράθυρο διαλόγου Συλλογή εφέ
|
||||
|
Can't render this file because it contains an unexpected character in line 2045 and column 19.
|
@@ -1069,6 +1069,11 @@
|
||||
Fidelity: Fidelidad:
|
||||
Figure Space Espacio de cifra
|
||||
Figure: Cifras:
|
||||
# Artwork drag between document tabs.
|
||||
Drop Artwork Soltar ilustración
|
||||
@msg {key} must be [x, y] with finite coordinates {key} debe ser [x, y] con coordenadas finitas
|
||||
@msg ids must contain 1–10000 object ids ids debe contener entre 1 y 10000 identificadores de objetos
|
||||
|
||||
File Archivo
|
||||
File Dimensions Dimensiones del archivo
|
||||
File Format: Formato de archivo:
|
||||
@@ -2394,6 +2399,7 @@
|
||||
Proof Setup Ajuste de prueba
|
||||
Properties Propiedades
|
||||
Proportional Metrics Métrica proporcional
|
||||
Character Direction Dirección de caracteres
|
||||
Digits Dígitos
|
||||
Arabic Árabes
|
||||
Hindi Hindi
|
||||
@@ -3988,7 +3994,7 @@ axis Both Ambos
|
||||
@msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura
|
||||
@msg guides are locked las guías están bloqueadas
|
||||
@msg handle must be in or out handle debe ser in u out
|
||||
@msg image '{key}' has no pixels and was left out la imagen '{key}' no tiene píxeles y se omitió
|
||||
@msg an image has no pixels and was left out se omitió una imagen sin píxeles
|
||||
@msg image data `{_1}` is missing faltan los datos de imagen `{_1}`
|
||||
@msg image would be too large; lower the resolution la imagen sería demasiado grande; baja la resolución
|
||||
@msg index must be a number or first, previous, next or last index debe ser un número o first, previous, next o last
|
||||
@@ -4557,6 +4563,7 @@ axis Both Ambos
|
||||
Activate Panel Activar panel
|
||||
Change Panel Layout Cambiar distribución de paneles
|
||||
Close Panel Cerrar panel
|
||||
Collapse Panel Group Contraer grupo de paneles
|
||||
Move Panel Mover panel
|
||||
Group with Agrupar con
|
||||
Resize panels Cambiar tamaño de los paneles
|
||||
@@ -4567,6 +4574,9 @@ axis Both Ambos
|
||||
@msg before must name a panel before debe indicar un panel
|
||||
@msg docking action contains an unknown panel or an excessive split path La acción de anclaje contiene un panel desconocido o una ruta de divisiones demasiado larga
|
||||
@msg group must be true or false group debe ser true o false
|
||||
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} el panel flota; contraiga su grupo con window.panel.float {panel, collapsed}
|
||||
@msg panel is not in a dock group el panel no está en un grupo del acoplamiento
|
||||
@msg only a dock group above or below others collapses, not one beside another solo se contrae un grupo del acoplamiento situado encima o debajo de otros, no al lado
|
||||
@msg icon must be true or false icon debe ser true o false
|
||||
@msg icon only docks a panel in the icon column, with no anchor icon solo acopla un panel en la columna de iconos, sin anchor
|
||||
@msg column docks a set as a dock column, with no anchor or icon column acopla un conjunto como columna del acoplamiento, sin anchor ni icon
|
||||
@@ -5270,6 +5280,7 @@ mark-style Japanese Japonesas
|
||||
@msg Persistent libraries not found: {files} No se encontraron bibliotecas persistentes: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` no es un panel de biblioteca (consulte window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open el panel de biblioteca `{given}` no está abierto
|
||||
@msg library panel `{given}` isn't missing its library la biblioteca del panel de biblioteca `{given}` no falta
|
||||
@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).
|
||||
@@ -5290,6 +5301,14 @@ mark-style Japanese Japonesas
|
||||
Full Justify Justificar completamente
|
||||
Minimum must not exceed Desired, nor Desired Maximum. El mínimo no puede superar el deseado, ni el deseado el máximo.
|
||||
Justification Justificación
|
||||
Add filters from the list above Añada filtros de la lista de arriba
|
||||
Add to Stack Añadir a la pila
|
||||
Applied Filters Filtros aplicados
|
||||
Categories Categorías
|
||||
Choose a filter Elija un filtro
|
||||
Effect Gallery Galería de efectos
|
||||
Filters Filtros
|
||||
Select artwork to apply effects Seleccione gráficos para aplicar efectos
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them no se leyeron las variables ni los conjuntos de datos del archivo (variables: {variables}, conjuntos de datos: {data_sets}): el panel Variables no los muestra
|
||||
# Select by Color (select.color)
|
||||
Select by Color Seleccionar por color
|
||||
@@ -5315,5 +5334,13 @@ mark-style Japanese Japonesas
|
||||
Water Paper Papel mojado
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Ease In Entrada suave
|
||||
Ease Out Salida suave
|
||||
Ease In Out Entrada y salida suaves
|
||||
Ease Out In Salida y entrada suaves
|
||||
Ramp: Intensidad:
|
||||
Shift: Desplazamiento:
|
||||
Fresco… Fresco…
|
||||
Fresco Fresco
|
||||
Effect Gallery… Galería de efectos…
|
||||
Effect Gallery Dialog Cuadro de diálogo Galería de efectos
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -42,6 +42,11 @@
|
||||
Medium Light Keskivaalea
|
||||
Light Vaalea
|
||||
Quit VectorCraft Lopeta VectorCraft
|
||||
# Artwork drag between document tabs.
|
||||
Drop Artwork Pudota kuvitus
|
||||
@msg {key} must be [x, y] with finite coordinates {key} on annettava muodossa [x, y] äärellisillä koordinaateilla
|
||||
@msg ids must contain 1–10000 object ids ids-arvon on sisällettävä 1–10000 objektitunnusta
|
||||
|
||||
File Tiedosto
|
||||
New… Uusi…
|
||||
New Without Dialog Uusi ilman valintaikkunaa
|
||||
@@ -812,6 +817,7 @@
|
||||
Activate Panel Aktivoi paneeli
|
||||
Change Panel Layout Muuta paneelien asettelua
|
||||
Close Panel Sulje paneeli
|
||||
Collapse Panel Group Kutista paneeliryhmä
|
||||
Move Panel Siirrä paneelia
|
||||
Group with Ryhmitä kohteen kanssa
|
||||
Resize panels Muuta paneelien kokoa
|
||||
@@ -1368,6 +1374,7 @@ lighting Light Valo
|
||||
Leading Riviväli
|
||||
No Break Ei katkoa
|
||||
Proportional Metrics Suhteelliset metriikat
|
||||
Character Direction Merkkien suunta
|
||||
Digits Numerot
|
||||
Arabic Arabia
|
||||
Hindi Hindi
|
||||
@@ -4205,7 +4212,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg give the path `id` and the width point `index` anna polun `id` ja leveyspisteen `index`
|
||||
@msg guides are locked apuviivat on lukittu
|
||||
@msg handle must be in or out handle-arvon on oltava in tai out
|
||||
@msg image '{key}' has no pixels and was left out kuvassa '{key}' ei ole pikseleitä, joten se jätettiin pois
|
||||
@msg an image has no pixels and was left out kuva, jossa ei ole pikseleitä, jätettiin pois
|
||||
@msg image data `{_1}` is missing kuvan data `{_1}` puuttuu
|
||||
@msg image would be too large; lower the resolution kuvasta tulisi liian suuri; pienennä tarkkuutta
|
||||
@msg index must be a number or first, previous, next or last indeksin on oltava luku tai first, previous, next tai last
|
||||
@@ -4661,6 +4668,9 @@ imageTrace Outline Ääriviiva
|
||||
@msg before must name a panel before-arvon on nimettävä paneeli
|
||||
@msg docking action contains an unknown panel or an excessive split path telakointitoiminto sisältää tuntemattoman paneelin tai liian pitkän jakopolun
|
||||
@msg group must be true or false group-arvon on oltava true tai false
|
||||
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} paneeli kelluu; kutista sen ryhmä komennolla window.panel.float {panel, collapsed}
|
||||
@msg panel is not in a dock group paneeli ei ole telakkaryhmässä
|
||||
@msg only a dock group above or below others collapses, not one beside another vain muiden ylä- tai alapuolella oleva telakkaryhmä kutistuu, ei toisen vieressä oleva
|
||||
@msg icon must be true or false icon-arvon on oltava true tai false
|
||||
@msg icon only docks a panel in the icon column, with no anchor icon telakoi paneelin vain kuvakesarakkeeseen, ilman anchor-arvoa
|
||||
@msg column docks a set as a dock column, with no anchor or icon column telakoi joukon telakkasarakkeeksi, ilman anchor- tai icon-arvoa
|
||||
@@ -5292,6 +5302,7 @@ mark-style Japanese Japanilainen
|
||||
@msg Persistent libraries not found: {files} Pysyviä kirjastoja ei löytynyt: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ei ole kirjastopaneeli (katso window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open kirjastopaneeli `{given}` ei ole auki
|
||||
@msg library panel `{given}` isn't missing its library kirjastopaneelin `{given}` kirjasto ei puutu
|
||||
@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ä).
|
||||
@@ -5312,6 +5323,14 @@ mark-style Japanese Japanilainen
|
||||
Full Justify Täysi tasaus
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimi ei saa ylittää haluttua arvoa eikä haluttu arvo maksimia.
|
||||
Justification Tasaus
|
||||
Add filters from the list above Lisää suodattimia yllä olevasta luettelosta
|
||||
Add to Stack Lisää pinoon
|
||||
Applied Filters Käytetyt suodattimet
|
||||
Categories Luokat
|
||||
Choose a filter Valitse suodatin
|
||||
Effect Gallery Tehostegalleria
|
||||
Filters Suodattimet
|
||||
Select artwork to apply effects Valitse grafiikka, johon tehosteet otetaan
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them tiedoston muuttujia ja tietojoukkoja ei luettu (muuttujia: {variables}, tietojoukkoja: {data_sets}): Muuttujat-paneeli ei näytä niitä
|
||||
# Select by Color (select.color)
|
||||
Select by Color Valitse värin mukaan
|
||||
@@ -5404,5 +5423,13 @@ swatch Bright Violet Kirkas violetti
|
||||
Water Paper Märkä paperi
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Ease In Hidas alku
|
||||
Ease Out Hidas loppu
|
||||
Ease In Out Hidas alku ja loppu
|
||||
Ease Out In Hidas loppu ja alku
|
||||
Ramp: Voimakkuus:
|
||||
Shift: Siirtymä:
|
||||
Fresco… Fresko…
|
||||
Fresco Fresko
|
||||
Effect Gallery… Tehostegalleria…
|
||||
Effect Gallery Dialog Tehostegalleria-valintaikkuna
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1069,6 +1069,11 @@
|
||||
Fidelity: Fidélité :
|
||||
Figure Space Espace numérique
|
||||
Figure: Chiffres :
|
||||
# Artwork drag between document tabs.
|
||||
Drop Artwork Déposer l’illustration
|
||||
@msg {key} must be [x, y] with finite coordinates {key} doit être [x, y] avec des coordonnées finies
|
||||
@msg ids must contain 1–10000 object ids ids doit contenir de 1 à 10000 identifiants d’objets
|
||||
|
||||
File Fichier
|
||||
File Dimensions Dimensions du fichier
|
||||
File Format: Format de fichier :
|
||||
@@ -2391,6 +2396,7 @@
|
||||
Proof Setup Format d’épreuve
|
||||
Properties Propriétés
|
||||
Proportional Metrics Métrique proportionnelle
|
||||
Character Direction Direction des caractères
|
||||
Digits Chiffres
|
||||
Arabic Arabes
|
||||
Hindi Hindi
|
||||
@@ -3985,7 +3991,7 @@ axis Both Les deux
|
||||
@msg give the path `id` and the width point `index` indiquez l’`id` du tracé et l’`index` du point de largeur
|
||||
@msg guides are locked les repères sont verrouillés
|
||||
@msg handle must be in or out handle doit valoir in ou out
|
||||
@msg image '{key}' has no pixels and was left out l’image « {key} » n’a aucun pixel et a été ignorée
|
||||
@msg an image has no pixels and was left out une image sans aucun pixel a été ignorée
|
||||
@msg image data `{_1}` is missing les données d’image `{_1}` sont manquantes
|
||||
@msg image would be too large; lower the resolution l’image serait trop grande ; réduisez la résolution
|
||||
@msg index must be a number or first, previous, next or last index doit être un nombre ou first, previous, next ou last
|
||||
@@ -4569,6 +4575,7 @@ effect Soft Doux
|
||||
Activate Panel Activer le panneau
|
||||
Change Panel Layout Modifier la disposition des panneaux
|
||||
Close Panel Fermer le panneau
|
||||
Collapse Panel Group Réduire le groupe de panneaux
|
||||
Move Panel Déplacer le panneau
|
||||
Group with Regrouper avec
|
||||
Resize panels Redimensionner les panneaux
|
||||
@@ -4579,6 +4586,9 @@ effect Soft Doux
|
||||
@msg before must name a panel before doit désigner un panneau
|
||||
@msg docking action contains an unknown panel or an excessive split path L’action d’ancrage contient un panneau inconnu ou un chemin de divisions trop long
|
||||
@msg group must be true or false group doit être true ou false
|
||||
@msg panel floats; collapse its group with window.panel.float {panel, collapsed} le panneau est flottant ; réduisez son groupe avec window.panel.float {panel, collapsed}
|
||||
@msg panel is not in a dock group le panneau n’est dans aucun groupe du dock
|
||||
@msg only a dock group above or below others collapses, not one beside another seul un groupe du dock situé au-dessus ou au-dessous d’autres se réduit, pas un groupe à côté
|
||||
@msg icon must be true or false icon doit être true ou false
|
||||
@msg icon only docks a panel in the icon column, with no anchor icon ne fait qu’ancrer un panneau dans la colonne d’icônes, sans anchor
|
||||
@msg column docks a set as a dock column, with no anchor or icon column ancre un ensemble comme colonne du dock, sans anchor ni icon
|
||||
@@ -5327,6 +5337,7 @@ theme Appearance Apparence
|
||||
@msg Persistent libraries not found: {files} Bibliothèques persistantes introuvables : {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` n’est pas un panneau de bibliothèque (voir window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open le panneau de bibliothèque `{given}` n’est pas ouvert
|
||||
@msg library panel `{given}` isn't missing its library la bibliothèque du panneau de bibliothèque `{given}` ne manque pas
|
||||
@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).
|
||||
@@ -5347,6 +5358,14 @@ theme Appearance Apparence
|
||||
Full Justify Justification complète
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Le minimum ne doit pas dépasser la valeur optimale, ni la valeur optimale le maximum.
|
||||
Justification Justification
|
||||
Add filters from the list above Ajoutez des filtres à partir de la liste ci-dessus
|
||||
Add to Stack Ajouter à la pile
|
||||
Applied Filters Filtres appliqués
|
||||
Categories Catégories
|
||||
Choose a filter Choisir un filtre
|
||||
Effect Gallery Galerie d'effets
|
||||
Filters Filtres
|
||||
Select artwork to apply effects Sélectionnez des éléments graphiques pour appliquer des effets
|
||||
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them les variables et jeux de données du fichier n’ont pas été lus (variables : {variables}, jeux de données : {data_sets}) : le panneau Variables ne les affiche pas
|
||||
# Select by Color (select.color)
|
||||
Select by Color Sélectionner par couleur
|
||||
@@ -5372,5 +5391,13 @@ theme Appearance Apparence
|
||||
Water Paper Papier humide
|
||||
Ruby… Ruby…
|
||||
Ruby Ruby
|
||||
Ease In Accélération progressive
|
||||
Ease Out Décélération progressive
|
||||
Ease In Out Accélération et décélération
|
||||
Ease Out In Décélération et accélération
|
||||
Ramp: Intensité :
|
||||
Shift: Décalage :
|
||||
Fresco… Fresque…
|
||||
Fresco Fresque
|
||||
Effect Gallery… Galerie d'effets…
|
||||
Effect Gallery Dialog Boîte de dialogue Galerie d'effets
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user