Merge main into feat/cjk-tsume

This commit is contained in:
dotCooCoo
2026-11-14 01:56:33 -08:00
130 changed files with 14638 additions and 935 deletions
Generated
+1
View File
@@ -6386,6 +6386,7 @@ dependencies = [
"pollster 1.0.1",
"raw-window-handle",
"rfd",
"rustix 1.1.6",
"serde_json",
"socket2",
"tempfile",
+6
View File
@@ -18,6 +18,8 @@ gate: all six core workflows (path drawing and editing, paint and style, type, l
production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without
blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
> **Gujarati status (2026-11-14, #6613):** 4,673 catalog rows: the original 4,286 remain unchanged; all 387 additions are contributor-reviewed and approved, with 11 wording corrections. Status/error messages remain partial, and the pinned CI/release fonts and web startup list contain no Gujarati face; web selection stays unavailable until a Gujarati interface face is bundled. Scores and estimates unchanged.
## Headline numbers
| | Value | Kind |
@@ -96,6 +98,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
| Simplified Chinese | `zh-hans` | partial (messages in English) | 85% |
| Spanish | `es` | full | 100% |
| Hindi | `hi` | catalog complete; machine-assisted draft, native review pending | complete under the scanner |
| Gujarati | `gu` | 4,673 contributor-reviewed catalog rows; 11 corrections applied to the 387 additions | menus and dialogs; status/error messages partial |
| Arabic | `ar` | none (no right-to-left interface) | 0% |
| French | `fr` | full | 100% |
| Portuguese (Brazil) | `pt-br` | full | 100% |
@@ -218,6 +221,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. |
| 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. |
| 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. |
| 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. |
@@ -226,6 +230,8 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| 2026-11-03 | minor | UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged. |
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
| 2026-11-12 | minor | Gujarati (`gu`): reviewed by a native speaker; zero/one singular plurals (CLDR); Gujarati craft-fonts faces join the UI fonts; strings added on main since branching translated (4,286 rows) |
| 2026-11-11 | minor | Gujarati (`gu`): 4,235-row machine-assisted interface catalog, one/other plurals, desktop Gujarati font fallback; messages not yet translated; native review pending |
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
| 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. |
| 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. |
+4
View File
@@ -110,6 +110,10 @@ wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
# through winit and rfd.
wayland-backend = { version = "0.3", features = ["client_system"] }
# The Wayland clipboard's pipes (src/clipboard.rs): `poll` and the nonblocking flag give up a read
# or send when the other app moves no data for a while. Apache-2.0 WITH LLVM-exception OR
# Apache-2.0 OR MIT, already in the build through the workspace.
rustix = { workspace = true, features = ["std", "event", "fs"] }
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
+185 -43
View File
@@ -1790,15 +1790,17 @@ mod portable {
/// starts a thread for each paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads
/// run on another worker and take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, SVG and
/// PNG as long as a paste's bitmap from arboard. A read that gets no answer in its wait is kept for
/// a paste to wait for off the UI thread, as arboard's are.
/// a paste to wait for off the UI thread, as arboard's are. A read or send whose pipe moves no data
/// for [`IDLE_WAIT`] fails and closes the pipe, which frees its worker or thread.
#[cfg(target_os = "linux")]
mod wayland {
use std::io::{Read, Write};
use std::os::fd::AsFd as _;
use std::io::{ErrorKind, Read, Write};
use std::os::fd::{AsFd, BorrowedFd};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, mpsc};
use std::time::{Duration, Instant};
use rustix::event::{PollFd, PollFlags, Timespec};
use vectorcraft_engine::cmd::clipboard::names_a_picture;
use vectorcraft_ui_egui::ClipboardRead;
use wayland_client::backend::ObjectData;
@@ -1827,6 +1829,11 @@ mod wayland {
const MAX_SENDS: usize = 8;
/// The most types of an offer that are kept (untrusted data).
const MAX_TYPES: usize = 256;
/// How long a read or send waits for the other app to move a byte through the pipe before it
/// fails, VectorCraft's own limit; the wait starts again after each byte moved.
const IDLE_WAIT: Duration = LOAD_WAIT;
/// The most bytes a read takes from the pipe at a time.
const CHUNK: usize = 64 << 10;
/// What a read gives: the bytes, or why there are none.
type Got = Result<Vec<u8>, String>;
@@ -1898,37 +1905,117 @@ mod wayland {
Some(first)
}
/// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read.
fn read_capped(from: impl Read, cap: usize) -> Got {
let mut data = vec![];
from.take(cap as u64 + 1).read_to_end(&mut data).map_err(|e| format!("couldn't read the clipboard's data: {e}"))?;
if data.len() > cap {
return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20));
/// The time `idle` from now; None when that is past the clock's range (no limit).
fn idle_until(idle: Duration) -> Option<Instant> {
Instant::now().checked_add(idle)
}
/// Makes `fd` nonblocking, its other flags kept.
fn nonblocking(fd: BorrowedFd<'_>) -> std::io::Result<()> {
let flags = rustix::fs::fcntl_getfl(fd)?;
rustix::fs::fcntl_setfl(fd, flags | rustix::fs::OFlags::NONBLOCK)?;
Ok(())
}
/// Waits until `fd` is ready for `events`, closed or failed (true), or `until` passes (false);
/// None waits without a limit. A wait a signal cuts short goes on until `until`.
fn ready(fd: BorrowedFd<'_>, events: PollFlags, until: Option<Instant>) -> std::io::Result<bool> {
loop {
let left = until.map(|t| t.saturating_duration_since(Instant::now()));
let timeout = left.and_then(|d| Timespec::try_from(d).ok());
let mut fds = [PollFd::new(&fd, events)];
match rustix::event::poll(&mut fds, timeout.as_ref()) {
Ok(n) => return Ok(n > 0),
Err(rustix::io::Errno::INTR) => {}
Err(e) => return Err(e.into()),
}
}
Ok(data)
}
/// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read and a
/// pipe that is left open with no byte coming for `idle`. A read that fails gives none of the
/// bytes it took, and closes `from`.
fn read_capped(mut from: impl Read + AsFd, cap: usize, idle: Duration) -> Got {
let failed = |e: std::io::Error| format!("couldn't read the clipboard's data: {e}");
nonblocking(from.as_fd()).map_err(failed)?;
let (mut data, mut chunk) = (vec![], vec![0u8; CHUNK]);
let mut until = idle_until(idle);
loop {
match from.read(&mut chunk) {
Ok(0) => return Ok(data),
Ok(n) => {
let got = chunk.get(..n).ok_or_else(|| failed(ErrorKind::InvalidData.into()))?;
if data.len() + got.len() > cap {
return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20));
}
data.extend_from_slice(got);
until = idle_until(idle);
}
Err(e) if e.kind() == ErrorKind::Interrupted => {}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
if !ready(from.as_fd(), PollFlags::IN, until).map_err(failed)? {
return Err("the app that copied stopped sending the clipboard's data".to_string());
}
}
Err(e) => return Err(failed(e)),
}
}
}
/// `data` written to `to`: an error when the app that pastes closes its end (the process
/// ignores `SIGPIPE`) or takes no byte for `idle`.
fn send(mut to: impl Write + AsFd, data: &[u8], idle: Duration) -> std::io::Result<()> {
nonblocking(to.as_fd())?;
let (mut sent, mut until) = (0, idle_until(idle));
while let Some(rest) = data.get(sent..).filter(|rest| !rest.is_empty()) {
match to.write(rest) {
Ok(0) => return Err(ErrorKind::WriteZero.into()),
Ok(n) => {
sent += n;
until = idle_until(idle);
}
Err(e) if e.kind() == ErrorKind::Interrupted => {}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
if !ready(to.as_fd(), PollFlags::OUT, until)? {
return Err(ErrorKind::TimedOut.into());
}
}
Err(e) => return Err(e),
}
}
Ok(())
}
/// The threads that send a Copy's data to the apps that paste it, counted.
#[derive(Clone, Default)]
struct Sends(Arc<AtomicUsize>);
#[derive(Clone)]
struct Sends {
running: Arc<AtomicUsize>,
/// How long a send waits for the app that pastes to take a byte.
idle: Duration,
}
impl Default for Sends {
fn default() -> Self {
Self { running: Arc::default(), idle: IDLE_WAIT }
}
}
impl Sends {
/// `data` written to `to` on a thread of its own, which then closes `to`; an app that
/// closes its end early ends the write (the process ignores `SIGPIPE`). While
/// [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false.
fn start(&self, mut to: impl Write + Send + 'static, data: Arc<[u8]>) -> bool {
if self.0.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS {
self.0.fetch_sub(1, Ordering::SeqCst);
/// `data` written to `to` on a thread of its own ([`send`]), which then closes `to`; an app
/// that closes its end early or takes no byte for the send's idle wait ends the write.
/// While [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false.
fn start(&self, to: impl Write + AsFd + Send + 'static, data: Arc<[u8]>) -> bool {
if self.running.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS {
self.running.fetch_sub(1, Ordering::SeqCst);
return false;
}
let count = self.0.clone();
let send = move || {
let _ = to.write_all(&data);
drop(to);
let (count, idle) = (self.running.clone(), self.idle);
let work = move || {
let _ = send(to, &data, idle);
count.fetch_sub(1, Ordering::SeqCst);
};
if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(send).is_err() {
self.0.fetch_sub(1, Ordering::SeqCst);
if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(work).is_err() {
self.running.fetch_sub(1, Ordering::SeqCst);
return false;
}
true
@@ -1937,7 +2024,7 @@ mod wayland {
/// How many are being written.
#[cfg(test)]
fn running(&self) -> usize {
self.0.load(Ordering::SeqCst)
self.running.load(Ordering::SeqCst)
}
}
@@ -2298,7 +2385,7 @@ mod wayland {
impl Receiver {
/// The clipboard's data of type `wire`, read until the app that copied closes the pipe,
/// [`MAX_BYTES`] at most.
/// [`MAX_BYTES`] at most; an error when that app sends no byte for [`IDLE_WAIT`].
fn receive(&self, wire: String) -> Got {
let offer = lock(&self.objects).selection.clone().ok_or("the clipboard is empty")?;
let (from, to) = std::io::pipe().map_err(|e| format!("couldn't open a pipe for the clipboard's data: {e}"))?;
@@ -2307,7 +2394,7 @@ mod wayland {
// holds the only one, and the read ends when that app closes it.
drop(to);
self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}"))?;
read_capped(from, MAX_BYTES)
read_capped(from, MAX_BYTES, IDLE_WAIT)
}
}
@@ -2557,31 +2644,86 @@ mod wayland {
assert_eq!(keyboard_serial(&modifiers(9), None), None);
}
/// A wait no test reaches: the other end of each pipe below either moves data or closes.
const LONG: Duration = Duration::from_secs(600);
/// A pipe holding `data`, its write end closed when `close`.
fn pipe_with(data: &[u8], close: bool) -> (std::io::PipeReader, Option<std::io::PipeWriter>) {
let (from, mut to) = std::io::pipe().unwrap();
to.write_all(data).unwrap();
(from, (!close).then_some(to))
}
#[test]
fn a_read_stops_at_its_limit() {
assert_eq!(MAX_BYTES, 64 * 1024 * 1024);
assert_eq!(read_capped(&b"12345678"[..], 8), Ok(b"12345678".to_vec()));
assert_eq!(read_capped(&b""[..], 8), Ok(vec![]));
assert_eq!(read_capped(&[0u8; 9][..], 8), Err("the clipboard's data is larger than 0 MiB".to_string()));
assert_eq!(read_capped(&vec![0u8; (2 << 20) + 1][..], 2 << 20), Err("the clipboard's data is larger than 2 MiB".to_string()));
/// A pipe that breaks.
struct Broken;
impl Read for Broken {
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
assert_eq!(IDLE_WAIT, LOAD_WAIT);
assert_eq!(read_capped(pipe_with(b"12345678", true).0, 8, Duration::ZERO), Ok(b"12345678".to_vec()));
assert_eq!(read_capped(pipe_with(b"", true).0, 8, Duration::ZERO), Ok(vec![]));
assert_eq!(read_capped(pipe_with(&[0u8; 9], true).0, 8, Duration::ZERO), Err("the clipboard's data is larger than 0 MiB".to_string()));
// More than a pipe holds, from an app that is still sending.
let (from, to) = std::io::pipe().unwrap();
assert!(Sends::default().start(to, vec![0u8; (2 << 20) + 1].into()));
assert_eq!(read_capped(from, 2 << 20, LONG), Err("the clipboard's data is larger than 2 MiB".to_string()));
// A pipe that can't be read: the write end.
let (_, to) = std::io::pipe().unwrap();
let unreadable = std::fs::File::from(std::os::fd::OwnedFd::from(to));
assert!(read_capped(unreadable, 8, LONG).is_err_and(|e| e.starts_with("couldn't read the clipboard's data")));
}
#[test]
fn a_read_from_an_app_that_stops_sending_fails_and_closes_the_pipe() {
let stopped = Err("the app that copied stopped sending the clipboard's data".to_string());
// Some bytes and then nothing, the pipe left open: none of them are pasted.
let (from, to) = pipe_with(b"part", false);
assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), stopped);
// The read end is closed, so that app's next write fails.
let mut to = to.unwrap();
assert_eq!(to.write(b"x").map_err(|e| e.kind()), Err(ErrorKind::BrokenPipe));
// Nothing at all, and data that fills the limit exactly with more never coming.
let (from, _held) = pipe_with(b"", false);
assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), stopped);
let (from, _held) = pipe_with(b"12345678", false);
assert_eq!(read_capped(from, 8, Duration::ZERO), stopped);
// The next read gets its data.
assert_eq!(read_capped(pipe_with(b"words", true).0, MAX_BYTES, Duration::ZERO), Ok(b"words".to_vec()));
}
#[test]
fn a_send_to_an_app_that_stops_reading_fails_and_frees_its_thread() {
let data: Arc<[u8]> = vec![3u8; 300_000].into();
// More than a pipe holds, to an app that never reads.
let (_held, to) = std::io::pipe().unwrap();
assert_eq!(send(to, &data, Duration::ZERO).map_err(|e| e.kind()), Err(ErrorKind::TimedOut));
// An app that closes its end before reading.
let (from, to) = std::io::pipe().unwrap();
drop(from);
assert_eq!(send(to, &data, LONG).map_err(|e| e.kind()), Err(ErrorKind::BrokenPipe));
// A send thread to an app that does not read until the send gives up, its end still open.
let sends = Sends { idle: Duration::ZERO, ..Sends::default() };
let (from, to) = std::io::pipe().unwrap();
assert!(sends.start(to, data.clone()));
while sends.running() > 0 {
std::thread::yield_now();
}
assert!(read_capped(Broken, 8).is_err_and(|e| e.starts_with("couldn't read the clipboard's data")));
// What the pipe held when the send gave up, then the end of the data: the send closed it.
let got = read_capped(from, MAX_BYTES, Duration::ZERO).map(|d| d.len());
assert!(got.as_ref().is_ok_and(|&n| n > 0 && n < data.len()), "{got:?}");
// The thread is free for the next paste.
let (from, to) = std::io::pipe().unwrap();
assert!(sends.start(to, b"words"[..].into()));
assert_eq!(read_capped(from, MAX_BYTES, LONG), Ok(b"words".to_vec()));
}
#[test]
fn a_copys_data_is_sent_through_the_pipe_and_closed() {
let sends = Sends::default();
assert_eq!(sends.idle, IDLE_WAIT);
let (from, to) = std::io::pipe().unwrap();
let data: Arc<[u8]> = vec![7u8; 300_000].into();
assert!(sends.start(to, data.clone()));
// More than a pipe holds: the write ends once all of it is read, then the pipe closes.
assert_eq!(read_capped(from, MAX_BYTES).as_deref(), Ok(&data[..]));
assert_eq!(read_capped(from, MAX_BYTES, LONG).as_deref(), Ok(&data[..]));
// An app that closes its end before reading ends the send.
let (from, to) = std::io::pipe().unwrap();
drop(from);
@@ -2593,9 +2735,9 @@ mod wayland {
#[test]
fn pastes_that_read_nothing_hold_a_few_sends_at_most() {
let sends = Sends::default();
let sends = Sends { idle: LONG, ..Sends::default() };
let data: Arc<[u8]> = vec![1u8; 300_000].into();
// Apps that ask for the data and never read it.
// Apps that ask for the data and never read it, within the sends' idle wait.
let stalled: Vec<_> = (0..MAX_SENDS)
.map(|_| {
let (from, to) = std::io::pipe().unwrap();
@@ -2606,7 +2748,7 @@ mod wayland {
// The next paste gets its pipe closed at once, with nothing written.
let (from, to) = std::io::pipe().unwrap();
assert!(!sends.start(to, data.clone()));
assert_eq!(read_capped(from, MAX_BYTES), Ok(vec![]));
assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), Ok(vec![]));
// Once those apps close their pipes, the sends end and others are sent again.
drop(stalled);
while sends.running() > 0 {
@@ -2614,7 +2756,7 @@ mod wayland {
}
let (from, to) = std::io::pipe().unwrap();
assert!(sends.start(to, data.clone()));
assert_eq!(read_capped(from, MAX_BYTES).map(|d| d.len()), Ok(data.len()));
assert_eq!(read_capped(from, MAX_BYTES, LONG).map(|d| d.len()), Ok(data.len()));
}
/// What a clipboard holds, by type.
+8
View File
@@ -363,6 +363,14 @@ pub struct SeriesPaint {
/// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend: Option<vectorcraft_color::BlendMode>,
/// A gradient in [`Self::fill`] is placed relative to the unit box (0, 0)–(1, 1), and each mark takes it on its own
/// geometric bounds. False: the gradient is in document coordinates, and every mark takes it unchanged.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub fill_unit_box: bool,
/// A gradient in [`Self::stroke`] is placed relative to the unit box, as [`Self::fill_unit_box`], and each mark takes
/// it on its stroke's box (the geometric bounds grown by half the weight).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub stroke_unit_box: bool,
}
/// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode
+7 -4
View File
@@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints};
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::{
GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
@@ -58,6 +58,7 @@ pub fn needs_bake(n: &Node) -> bool {
|| has_geometry(&n.appearance.effects)
|| n.projection().is_some()
|| n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))
|| has_instance_row_raster(n)
|| n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c)))
}
@@ -142,9 +143,11 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
}
// Type, images, symbol instances and live objects: reshaped through their outlines, and so are
// type whose own fills or strokes have geometry effects and images whose own fills or strokes
// paint a shape ([`has_item_geometry`]). Type that Transform effects only move and copy stays
// type, moved to each place.
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) {
// paint a shape ([`has_item_geometry`]), and symbol instances whose active fill or stroke has a
// raster effect, which the fills and strokes of their art then carry
// ([`has_instance_row_raster`]). Type that Transform effects only move and copy stays type,
// moved to each place.
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n) || has_instance_row_raster(n)) {
if let Some(m) = moved_type_group(n) {
return Some(bake_pieces(d, m));
}
+3 -2
View File
@@ -80,8 +80,9 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry};
pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects,
has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry,
has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text,
reshape,
};
pub use revolve::{
CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in,
+29 -16
View File
@@ -162,15 +162,26 @@ fn active_stroke(n: &Node) -> Option<&vectorcraft_doc::StrokeLayer> {
n.appearance.stroke().filter(|s| s.visible && s.opacity > 0.0)
}
/// The visible geometry effects in `fx`, in order.
fn geometry_of(fx: &[Effect]) -> Vec<Effect> {
fx.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect()
/// Is `n` a symbol instance whose active fill or stroke has a visible raster effect (a shadow, glow,
/// blur, feather or other painted effect, [`crate::is_raster`])? Its art takes that effect on each
/// fill or stroke in it that paints ([`add_instance_row_effects`]), and draws it there.
pub fn has_instance_row_raster(n: &Node) -> bool {
let raster = |fx: &[Effect]| fx.iter().any(|e| e.visible && crate::is_raster(&e.id));
matches!(n.kind, NodeKind::SymbolInstance { .. })
&& (active_fill(n).is_some_and(|f| raster(&f.effects)) || active_stroke(n).is_some_and(|s| raster(&s.effects)))
}
/// `art`, the art symbol instance `n` places, with the visible geometry effects of `n`'s active
/// fill added after the effects of every fill in it that paints, and those of its active stroke
/// after the effects of every stroke in it that paints ([`active_fill`], [`active_stroke`]). Each
/// fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are
/// The visible geometry and raster effects in `fx`, in order: those a symbol instance's active fill
/// or stroke passes on to its art.
fn row_effects_of(fx: &[Effect]) -> Vec<Effect> {
fx.iter().filter(|e| e.visible && (is_geometry(&e.id) || crate::is_raster(&e.id))).cloned().collect()
}
/// `art`, the art symbol instance `n` places, with the visible geometry and raster effects of
/// `n`'s active fill added after the effects of every fill in it that paints, and those of its
/// active stroke after the effects of every stroke in it that paints ([`active_fill`],
/// [`active_stroke`]): a Drop Shadow on the instance's fill shadows each filled shape in the art.
/// Each fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are
/// reached are evaluated first (with the symbols of `doc`), so the effects reshape what they draw.
/// Clip groups' clipping paths, guides and the art of opacity masks keep their shapes. A symbol
/// instance in the art takes the effects on its own active fill or stroke, or on a new fill or
@@ -180,8 +191,8 @@ fn geometry_of(fx: &[Effect]) -> Vec<Effect> {
/// instance in the art takes them.
fn add_instance_row_effects(doc: Option<&Document>, n: &Node, art: &mut Node) {
let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return };
let fill = active_fill(n).map_or(vec![], |f| geometry_of(&f.effects));
let stroke = active_stroke(n).map_or(vec![], |s| geometry_of(&s.effects));
let fill = active_fill(n).map_or(vec![], |f| row_effects_of(&f.effects));
let stroke = active_stroke(n).map_or(vec![], |s| row_effects_of(&s.effects));
if !fill.is_empty() || !stroke.is_empty() {
add_row_effects(doc, symbol, art, &fill, &stroke);
}
@@ -526,8 +537,8 @@ fn with_id(art: &Node, id: NodeId) -> Node {
/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by
/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), as
/// the art its members draw when the instance has geometry effects of its own
/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry effects of the
/// instance's active fill and stroke ([`add_instance_row_effects`]).
/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry and raster
/// effects of the instance's active fill and stroke ([`add_instance_row_effects`]).
/// `None` for other kinds (images, paths, containers), and for an instance whose art placed
/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of
/// `doc`'s symbols that its instances show toward its instance budget.
@@ -709,12 +720,14 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping
/// reshaped with it, and keep their effects from their first Convert to Shape or Outline Object
/// on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask
/// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
/// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is
/// always reshaped. The fills and strokes in the art whose own Transform effects reach their
/// appearance become objects of their own ([`transform_items`]).
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts
/// or a symbol instance whose active fill or stroke has a raster effect
/// ([`has_instance_row_raster`]), or has no art (a symbol instance without `symbol_art`). A path
/// with [`has_transform_art`] is always reshaped. The fills and strokes in the art whose own
/// Transform effects reach their appearance become objects of their own ([`transform_items`]).
pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if !has_transform_art(n) && ((geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n)) {
let own = !geometry_effects(n).is_empty() || n.projection().is_some() || has_item_geometry(n) || has_instance_row_raster(n);
if !has_transform_art(n) && (!own || !needs_outline(n)) {
return None;
}
as_art(doc, n, symbol_art)
+25 -21
View File
@@ -7,7 +7,7 @@ use vectorcraft_doc::color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask};
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2};
use vectorcraft_pathops::BoolOp;
use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View};
use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, MeshedSolid, PlanarFace, View};
#[cfg(test)]
use vectorcraft_doc::color::Color;
@@ -656,7 +656,7 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) {
#[cfg(test)]
thread_local! {
/// How many solids [`built_solids`] has built on this thread, for the tests.
/// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests.
static SOLIDS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
@@ -2039,31 +2039,35 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize)
return Ok(out);
}
let (parts, bounds) = solid_parts(shapes, b);
// The solids the art draws, past the limits of the object's solids together as it is
// ([`built_solids`]), each built and its faces counted as the loop reaches it, then dropped.
let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some()));
// Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel
// solid and its mesh are built as the loop reaches them.
// Inflate lays out every puff at once (they share one eye). Each Inflate and Extrude & Bevel
// solid is built once, as the loop reaches it: its facets decide whether the art draws it, as
// [`built_solids`] does, and its faces before projection make its mesh.
let mut inflated = (fx.id == INFLATE).then(|| {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view)
vectorcraft_three_d::inflate_meshed(&shapes, bounds, inflate_options(&p), view)
});
let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) };
for ((item, member, area, rule, _, _), drawn) in parts.iter().zip(drawn) {
let mut kept = 0usize;
for (item, member, area, rule, _, _) in &parts {
let item: &AppearanceItem = item;
if !drawn? {
// A stroke the art leaves out is left out of the meshes too; Inflate's next puff
// still belongs to the next part.
if let Some(built) = inflated.as_mut() {
built.next();
}
continue;
}
let mesh = match inflated.as_mut() {
Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())),
None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view),
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
let built = match inflated.as_mut() {
Some(each) => each.next().unwrap_or_else(|| Ok(MeshedSolid::default())),
None => vectorcraft_three_d::extrude_meshed(area, *rule, bounds, extrude, view),
};
match mesh {
// A stroke the art leaves out is left out of the meshes too. The faces of the solids kept
// count together against the object's limit.
let solid = match built {
Ok(solid) => solid,
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => continue,
Err(e) => return Err(e),
};
kept = kept.saturating_add(solid.facets.len());
if kept > MAX_OBJECT_FACES {
return Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string());
}
match solid.mesh() {
Ok(mesh) => {
let (paint, opacity, _) = paint_of(item, *member);
keep(&mut out, mesh, mesh_color(&paint, opacity))?;
+148
View File
@@ -208,3 +208,151 @@ fn a_gradient_takes_the_average_of_its_stops() {
let pattern = Paint::Pattern { pattern: "x".into(), xf: vectorcraft_geom::Affine::IDENTITY };
assert!(close(mesh_color(&pattern, 0.5), [0.5, 0.5, 0.5, 0.5]));
}
/// The meshes of Extrude & Bevel or Inflate `fx` on `shapes`, each built apart from the facets that
/// decide which solids are drawn ([`built_solids`], then [`vectorcraft_three_d::extrude_mesh`] or
/// [`vectorcraft_three_d::inflate_meshes`]), for comparing with [`meshes_for`].
fn meshes_built_apart(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result<Vec<SolidMesh>, String> {
let p = crate::merged_params(&fx.id, &fx.params);
let view = view_options(&p);
let (parts, bounds) = solid_parts(shapes, union_bounds(shapes).unwrap());
let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some()));
let mut inflated = (fx.id == INFLATE).then(|| {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view)
});
let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) };
let mut out = vec![];
for ((item, opacity, area, rule, _, _), drawn) in parts.iter().zip(drawn) {
let item: &AppearanceItem = item;
if !drawn? {
if let Some(built) = inflated.as_mut() {
built.next();
}
continue;
}
let mesh = match inflated.as_mut() {
Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())),
None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view),
};
match mesh {
Ok(mesh) => {
let (paint, opacity, _) = paint_of(item, *opacity);
keep_mesh(&mut out, total, SolidMesh { mesh, color: mesh_color(&paint, opacity) }, limit)?;
}
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
Err(e) => return Err(e),
}
}
Ok(out)
}
/// The bits of every number in `meshes`, for comparing two exports exactly.
type MeshBits = Vec<([u32; 4], Vec<[u64; 3]>, Vec<[u64; 3]>, Vec<[u32; 3]>)>;
fn mesh_bits(meshes: &[SolidMesh]) -> MeshBits {
meshes
.iter()
.map(|m| {
let (positions, normals) = (&m.mesh.positions, &m.mesh.normals);
(
m.color.map(f32::to_bits),
positions.iter().map(|p| p.map(f64::to_bits)).collect(),
normals.iter().map(|n| n.map(f64::to_bits)).collect(),
m.mesh.triangles.clone(),
)
})
.collect()
}
/// A five-pointed star about `(x, 150)` with curved edges, filled red and stroked black.
fn star_node(id: u64, x: f64) -> Node {
let mut b = BezPath::new();
for k in 0..10 {
let (s, c) = (k as f64 * std::f64::consts::PI / 5.0).sin_cos();
let r = if k % 2 == 0 { 60.0 } else { 25.0 };
let p = (x + r * c, 150.0 + r * s);
if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 4.0, p.1), (p.0, p.1 + 4.0), p) }
}
b.close_path();
Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 6.0))
}
/// The shapes `meshes_for` builds from: one node's, or each of several.
fn shapes_of(nodes: &[&Node]) -> Vec<Profile> {
nodes
.iter()
.map(|n| Profile { path: profile(n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(n), opacity: 1.0, scopes: vec![] })
.collect()
}
/// Building each Extrude & Bevel and Inflate solid once exports the same meshes, to the bit, as
/// building its facets and its mesh apart, within and past the triangle budget.
#[test]
fn one_build_exports_the_meshes_built_apart() {
let mut second_stroke = square_node(3, 400.0);
if let Some(stroke) = second_stroke.appearance.items.get(1).cloned() {
second_stroke.appearance.items.push(stroke);
}
let (star, square, other) = (star_node(1, 100.0), square_node(2, 250.0), star_node(4, 600.0));
let cases = [
(EXTRUDE, json!({})),
(EXTRUDE, json!({ "bevel": "rounded", "bevelWidth": 6.0, "bevelHeight": 5.0, "twist": 60.0, "taper": 70.0 })),
(EXTRUDE, json!({ "bevel": "cove", "bevelInside": true, "bevelBothSides": true, "cap": false, "perspectiveAngle": 50.0 })),
(EXTRUDE, json!({ "depth": 0.0 })),
(CLASSIC_EXTRUDE, json!({ "bevel": "classic", "bevelHeight": 8.0 })),
(INFLATE, json!({})),
(INFLATE, json!({ "depth": 30.0, "bothSides": true, "volume": 80.0 })),
(INFLATE, json!({ "depth": 40.0, "twist": 360.0, "taper": 70.0, "cap": false })),
(INFLATE, json!({ "depth": 0.1, "twist": 0.1, "bothSides": true })),
];
let sets: [Vec<&Node>; 3] = [vec![&star], vec![&second_stroke], vec![&star, &square, &other]];
for (id, params) in cases {
let effect = fx(id, params.clone());
for nodes in &sets {
let shapes = shapes_of(nodes);
let (mut once, mut apart) = (0, 0);
let built = meshes_for(&effect, &shapes, &mut once, MAX_MESH_TRIANGLES).unwrap_or_else(|e| panic!("{id} {params}: {e}"));
let reference = meshes_built_apart(&effect, &shapes, &mut apart, MAX_MESH_TRIANGLES).unwrap();
assert!(!reference.is_empty());
assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id} {params}");
assert_eq!(once, apart);
// Past the budget, at the same mesh, with the same count.
let limit = once - 1;
let (mut once, mut apart) = (0, 0);
let built = meshes_for(&effect, &shapes, &mut once, limit);
assert!(built.is_err(), "{id} {params}");
assert_eq!(built.err(), meshes_built_apart(&effect, &shapes, &mut apart, limit).err(), "{id} {params}");
assert_eq!(once, apart, "{id} {params}");
}
}
}
/// A stroke whose solid is past the limits is left out of the export and the fill's mesh exports,
/// as when the facets and the mesh are built apart.
#[test]
fn a_stroke_past_the_limits_is_left_out_of_the_export() {
// 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline.
let points: Vec<Point> = (0..40_000)
.map(|k| {
let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos();
let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 };
Point::new(20_000.0 + r * c, 20_000.0 + r * s)
})
.collect();
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
let n = Node::path(NodeId(1), path, Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 2.0));
for id in [EXTRUDE, INFLATE] {
// Without caps: a cap of 40,000 corners takes more work to cut into triangles than an
// export allows.
let effect = fx(id, json!({ "cap": false }));
let shapes = shapes_of(&[&n]);
// The fill's mesh alone has more triangles than an export takes.
let (mut once, mut apart) = (0, 0);
let built = meshes_for(&effect, &shapes, &mut once, usize::MAX).unwrap_or_else(|e| panic!("{id}: {e}"));
assert_eq!(built.len(), 1, "{id}: the fill's mesh alone");
assert!(close(built[0].color, [0.8, 0.2, 0.1, 1.0]), "{id}");
let reference = meshes_built_apart(&effect, &shapes, &mut apart, usize::MAX).unwrap();
assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id}");
}
}
+9
View File
@@ -559,6 +559,15 @@ pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result<Option<Vec<No
pub(crate) fn index(v: u64) -> usize {
usize::try_from(v).unwrap_or(usize::MAX)
}
/// The whole number of 0 or more that `p` gives for `key`, read with [`index`], or `None` when `p`
/// leaves it out or gives null. Any other value, such as `"3"`, `2.5`, `-1` or `true`, is an error
/// under `cmd`: "`{key}` must be a whole number".
pub(crate) fn index_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().map(|n| Some(index(n))).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))),
}
}
/// Validate all explicit targets before a command changes the document or selection.
pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result<Vec<NodeId>> {
checked_nodes_param(s, p, key, command, false)
+179 -79
View File
@@ -9,6 +9,8 @@
#[cfg(test)]
mod tests;
use std::ops::RangeInclusive;
use serde_json::{Value, json};
use vectorcraft_doc::{LiveShape, NodeId, NodeKind};
use vectorcraft_geom::{Affine, FillRule, Point, Rect, shapes};
@@ -16,9 +18,25 @@ use vectorcraft_tools::addons::box3d::CustomShape;
use super::super::create::insert_look;
use super::super::{
CommandSpec, Result, Session, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_selection, str_param, targets,
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_selection, number_or, str_param,
targets,
};
/// The sides of a polygon and the points of a star: whole numbers in this range.
const COUNTS: RangeInclusive<f64> = 3.0..=1000.0;
/// `rounded` and `randomized` of a polygon or star.
const ROUNDING: RangeInclusive<f64> = -10.0..=10.0;
/// A star's inner radius as a fraction of its outer radius.
const SPOKE_RATIO: RangeInclusive<f64> = 0.01..=1.0;
/// A spiral's `turns`.
const TURNS: RangeInclusive<f64> = 0.01..=1024.0;
/// A spiral's `divergence`.
const DIVERGENCE: RangeInclusive<f64> = 0.0..=1000.0;
/// A spiral's `innerRadius`, as a fraction of its radius.
const INNER_RADIUS: RangeInclusive<f64> = 0.0..=0.999;
/// The `arcMode` names; `slice` and `open` are also read as `pie` and `arc`.
const ARC_MODES: [&str; 4] = ["whole", "pie", "chord", "arc"];
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -26,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rectangle",
[],
None,
"{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
"{x, y, width, height: pt (0 or more), radius?: pt (0 or more), radii?: [tl,tr,br,bl] (each 0 or more), startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
has_doc,
rectangle
),
@@ -35,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Ellipse",
[],
None,
"{x, y, width, height, startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
"{x, y, width, height: pt (0 or more), startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
has_doc,
ellipse
),
@@ -44,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Polygon",
[],
None,
"{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}",
"{cx, cy, radius: pt (0 or more), sides=6: a whole number 3 to 1000, rounded=0, randomized=0: -10 to 10, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}",
has_doc,
polygon
),
@@ -53,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Star",
[],
None,
"{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}",
"{cx, cy, radius1: pt (0 or more), radius2?: pt (0.01 to 1 times radius1; default half of it), points=5: a whole number 3 to 1000, rounded=0, randomized=0: -10 to 10, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}",
has_doc,
star
),
@@ -62,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Spiral",
[],
None,
"{cx, cy, radius, turns=3, divergence=1, innerRadius=0, clockwise=true} → {id}",
"{cx, cy, radius: pt (0 or more), turns=3: 0.01 to 1024, divergence=1: 0 to 1000, innerRadius=0: 0 to 0.999 of radius, clockwise=true} → {id}",
has_doc,
spiral
),
@@ -71,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Box",
[],
None,
"{x, y, width, height, angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}",
"{x, y, width, height: pt (0 or more), angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}",
has_doc,
box3d
),
@@ -80,33 +98,96 @@ pub fn specs() -> Vec<CommandSpec> {
"Configure Shape",
[],
None,
"{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}",
"{ids?, radius?: pt (0 or more; fitted to half each rectangle's shorter side), radii?: [tl,tr,br,bl] (each 0 or more), startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?: a whole number 3 to 1000, isStar?, spokeRatio?: 0.01 to 1, rounded?, randomized?: -10 to 10, turns?: 0.01 to 1024, divergence?: 0 to 1000, innerRadius?: 0 to 0.999, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} an omitted or null value keeps each shape's setting → {updated}",
has_selection,
configure
),
]
}
fn parse_arc_params(p: &Value) -> ((f64, f64), shapes::EllipseMode) {
/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. A number
/// outside `range` is an error that names `key` and the range.
fn ranged(p: &Value, key: &str, default: f64, range: &RangeInclusive<f64>, cmd: &str) -> Result<f64> {
let x = number_or(p, key, default, cmd)?;
if !range.contains(&x) {
let (min, max) = (range.start(), range.end());
return Err(bad(cmd, format!("`{key}` must be between {min} and {max} (got {x})")));
}
Ok(x)
}
/// The number of sides or points `p` gives for `key`, or `default` when `p` leaves it out or gives
/// null. A fraction or a number outside [`COUNTS`] is an error.
fn count(p: &Value, key: &str, default: u32, cmd: &str) -> Result<u32> {
let x = number_or(p, key, f64::from(default), cmd)?;
if x.fract() != 0.0 || !COUNTS.contains(&x) {
let (min, max) = (COUNTS.start(), COUNTS.end());
return Err(bad(cmd, format!("`{key}` must be a whole number between {min} and {max} (got {x})")));
}
Ok(x as u32)
}
/// The size `p` gives for `key`: a number that is not negative.
fn size(p: &Value, key: &str, cmd: &str) -> Result<f64> {
not_negative(f64_req(p, key, cmd)?, key, cmd)
}
/// The size `p` gives for `key`, or `default` when `p` leaves it out or gives null.
fn size_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result<f64> {
not_negative(number_or(p, key, default, cmd)?, key, cmd)
}
fn not_negative(x: f64, key: &str, cmd: &str) -> Result<f64> {
if x < 0.0 {
return Err(bad(cmd, format!("`{key}` must be a number not below 0")));
}
Ok(x)
}
/// The four corner radii `p` gives as `radii` ([tl, tr, br, bl]), or `None` when `p` leaves it out
/// or gives null. Anything but four numbers that are not negative is an error.
fn radii_param(p: &Value, cmd: &str) -> Result<Option<[f64; 4]>> {
let key = "radii";
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let four = v.as_array().filter(|a| a.len() == 4).and_then(|a| {
let mut out = [0.0; 4];
for (slot, item) in out.iter_mut().zip(a) {
*slot = item.as_f64().filter(|r| *r >= 0.0)?;
}
Some(out)
});
four.map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be four numbers not below 0: [tl, tr, br, bl]")))
}
}
}
/// The mode `p` gives as `arcMode`, or `None` when `p` leaves it out or gives null. Any other
/// value than one of [`ARC_MODES`] (or `slice` or `open`) is an error.
fn arc_mode(p: &Value, cmd: &str) -> Result<Option<shapes::EllipseMode>> {
let key = "arcMode";
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(parse_mode).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be one of {}", ARC_MODES.join(", ")))),
}
}
fn parse_arc_params(p: &Value, cmd: &str) -> Result<((f64, f64), shapes::EllipseMode)> {
let start = f64_or(p, "startAngle", 0.0);
let end = f64_or(p, "endAngle", 360.0);
let mode = match str_param(p, "arcMode") {
Some("pie" | "slice") => shapes::EllipseMode::Slice,
Some("chord") => shapes::EllipseMode::Chord,
Some("arc" | "open") => shapes::EllipseMode::Arc,
_ => shapes::EllipseMode::Whole,
};
let mode = arc_mode(p, cmd)?.unwrap_or(shapes::EllipseMode::Whole);
let span = (end - start).rem_euclid(360.0);
let pie = if mode == shapes::EllipseMode::Whole && (span < 1e-6 || (span - 360.0).abs() < 1e-6) { (0.0, 360.0) } else { (start, end) };
(pie, mode)
Ok((pie, mode))
}
fn rect_of(p: &Value, c: &str) -> Result<Rect> {
let x = f64_req(p, "x", c)?;
let y = f64_req(p, "y", c)?;
let w = f64_req(p, "width", c)?;
let h = f64_req(p, "height", c)?;
Ok(Rect::new(x, y, x + w.abs(), y + h.abs()).abs())
let w = size(p, "width", c)?;
let h = size(p, "height", c)?;
Ok(Rect::new(x, y, x + w, y + h))
}
fn rect_xf(r: Rect, rot: f64) -> Affine {
@@ -141,18 +222,9 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.rectangle";
check_numbers(p, &["radius", "startAngle", "endAngle", "rotation"], c)?;
let r = rect_of(p, c)?;
let radius = f64_or(p, "radius", 0.0).max(0.0);
let mut radii = [radius; 4];
if let Some(arr) = p.get("radii").and_then(Value::as_array)
&& arr.len() == 4
{
for (i, item) in arr.iter().enumerate() {
if let Some(val) = item.as_f64().filter(|n| n.is_finite()) {
radii[i] = val.max(0.0);
}
}
}
let (pie, arc_mode) = parse_arc_params(p);
let radius = size_or(p, "radius", 0.0, c)?;
let radii = radii_param(p, c)?.unwrap_or([radius; 4]);
let (pie, arc_mode) = parse_arc_params(p, c)?;
let xf = rect_xf(r, f64_or(p, "rotation", 0.0));
let live = LiveShape::Rectangle { w: r.width(), h: r.height(), radii, kinds: Default::default(), radii_relative: false, xf };
let cs = CustomShape::Rectangle { w: r.width(), h: r.height(), radii, kinds: Default::default(), radii_relative: false, xf, pie, arc_mode };
@@ -164,7 +236,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.ellipse";
check_numbers(p, &["startAngle", "endAngle", "rotation"], c)?;
let r = rect_of(p, c)?;
let (pie, arc_mode) = parse_arc_params(p);
let (pie, arc_mode) = parse_arc_params(p, c)?;
let xf = rect_xf(r, f64_or(p, "rotation", 0.0));
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie, xf };
let cs = CustomShape::Ellipse { w: r.width(), h: r.height(), pie, arc_mode, xf };
@@ -174,13 +246,13 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.polygon";
let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?);
let r = f64_req(p, "radius", c)?.abs();
let r = size(p, "radius", c)?;
check_numbers(p, &["sides", "rotation", "rounded", "randomized", "startAngle", "endAngle"], c)?;
let sides = (f64_or(p, "sides", 6.0).round() as i64).clamp(3, 1000) as u32;
let sides = count(p, "sides", 6, c)?;
let rot = f64_or(p, "rotation", 0.0);
let rounded = f64_or(p, "rounded", 0.0).clamp(-10.0, 10.0);
let randomized = f64_or(p, "randomized", 0.0).clamp(-10.0, 10.0);
let (pie, arc_mode) = parse_arc_params(p);
let rounded = ranged(p, "rounded", 0.0, &ROUNDING, c)?;
let randomized = ranged(p, "randomized", 0.0, &ROUNDING, c)?;
let (pie, arc_mode) = parse_arc_params(p, c)?;
let xf = Affine::translate(center.to_vec2()) * vectorcraft_geom::rotate_deg(rot);
let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![], radii_relative: false };
let cs = CustomShape::Polygon {
@@ -202,16 +274,17 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
fn star(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.star";
let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?);
let r1 = f64_req(p, "radius1", c)?.abs();
let r1 = size(p, "radius1", c)?;
check_numbers(p, &["radius2", "points", "rotation", "rounded", "randomized", "startAngle", "endAngle"], c)?;
let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
let points = (f64_or(p, "points", 5.0).round() as i64).clamp(3, 1000) as u32;
// The inner radius is a fraction of the outer one, in `SPOKE_RATIO`.
let r2 = ranged(p, "radius2", r1 / 2.0, &(r1 * SPOKE_RATIO.start()..=r1 * SPOKE_RATIO.end()), c)?;
let points = count(p, "points", 5, c)?;
let rot = f64_or(p, "rotation", 0.0);
let rounded = f64_or(p, "rounded", 0.0).clamp(-10.0, 10.0);
let randomized = f64_or(p, "randomized", 0.0).clamp(-10.0, 10.0);
let (pie, arc_mode) = parse_arc_params(p);
let rounded = ranged(p, "rounded", 0.0, &ROUNDING, c)?;
let randomized = ranged(p, "randomized", 0.0, &ROUNDING, c)?;
let (pie, arc_mode) = parse_arc_params(p, c)?;
let xf = Affine::translate(center.to_vec2()) * vectorcraft_geom::rotate_deg(rot);
let ratio = if r1 > 1e-9 { (r2 / r1).clamp(0.01, 1.0) } else { 0.5 };
let ratio = if r1 > 1e-9 { (r2 / r1).clamp(*SPOKE_RATIO.start(), *SPOKE_RATIO.end()) } else { 0.5 };
let cs = CustomShape::Polygon {
radius: r1,
sides: points,
@@ -231,12 +304,12 @@ fn star(s: &mut Session, p: &Value) -> Result<Value> {
fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.spiral";
let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?);
let r = f64_req(p, "radius", c)?.abs();
let r = size(p, "radius", c)?;
check_numbers(p, &["turns", "divergence", "innerRadius"], c)?;
check_switches(p, &["clockwise"], c)?;
let turns = f64_or(p, "turns", 3.0).clamp(0.01, 1024.0);
let divergence = f64_or(p, "divergence", 1.0).clamp(0.0, 1000.0);
let inner_radius = f64_or(p, "innerRadius", 0.0).clamp(0.0, 0.999);
let turns = ranged(p, "turns", 3.0, &TURNS, c)?;
let divergence = ranged(p, "divergence", 1.0, &DIVERGENCE, c)?;
let inner_radius = ranged(p, "innerRadius", 0.0, &INNER_RADIUS, c)?;
let clockwise = bool_or(p, "clockwise", true);
let cs = CustomShape::Spiral { center, radius: r, turns, divergence, inner_radius, clockwise };
insert_custom(s, "Spiral", None, cs)
@@ -299,24 +372,54 @@ fn configure(s: &mut Session, p: &Value) -> Result<Value> {
c,
)?;
check_switches(p, &["isStar", "parallelX", "parallelY", "parallelZ", "reset"], c)?;
check_configure(p, c)?;
let ids = targets(s, p)?;
let reset = bool_or(p, "reset", false);
let spiral_hint = p.get("turns").is_some() || p.get("divergence").is_some() || p.get("innerRadius").is_some();
// The shapes the params change, worked out first so that a call that changes nothing records
// no undo step.
let changes: Vec<(NodeId, CustomShape)> = {
let doc = &s.doc()?.doc;
ids.into_iter()
.filter_map(|id| {
let n = doc.node(id)?;
let before = CustomShape::read_for_update(n, spiral_hint)?;
let mut cs = before.clone();
let stored = CustomShape::read(n).is_some();
(apply_shape_params(&mut cs, p, reset) && (cs != before || !stored)).then_some((id, cs))
})
.collect()
};
if changes.is_empty() {
return Ok(json!({ "updated": 0 }));
}
let updated = s.edit("Live Shape", |d, _| {
let mut count = 0usize;
for id in ids {
let Some(n) = d.node_mut(id) else { continue };
let Some(mut cs) = CustomShape::read_for_update(n, spiral_hint) else { continue };
if apply_shape_params(&mut cs, p, reset) {
cs.write_to_node(n);
count += 1;
}
for (id, cs) in &changes {
let Some(n) = d.node_mut(*id) else { continue };
cs.write_to_node(n);
count += 1;
}
Ok(count)
})?;
Ok(json!({ "updated": updated }))
}
/// Error unless every bounded value `p` gives is in its range, before any shape changes.
fn check_configure(p: &Value, c: &str) -> Result<()> {
size_or(p, "radius", 0.0, c)?;
radii_param(p, c)?;
arc_mode(p, c)?;
count(p, "sides", 6, c)?;
ranged(p, "spokeRatio", 0.5, &SPOKE_RATIO, c)?;
ranged(p, "rounded", 0.0, &ROUNDING, c)?;
ranged(p, "randomized", 0.0, &ROUNDING, c)?;
ranged(p, "turns", 3.0, &TURNS, c)?;
ranged(p, "divergence", 1.0, &DIVERGENCE, c)?;
ranged(p, "innerRadius", 0.0, &INNER_RADIUS, c)?;
Ok(())
}
fn num_param(p: &Value, k: &str) -> Option<f64> {
p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite())
}
@@ -331,8 +434,8 @@ fn apply_arc_params(p: &Value, pie: &mut (f64, f64), arc_mode: &mut shapes::Elli
pie.1 = a1.rem_euclid(360.0);
changed = true;
}
if let Some(m) = str_param(p, "arcMode") {
*arc_mode = parse_mode(m);
if let Some(m) = str_param(p, "arcMode").and_then(parse_mode) {
*arc_mode = m;
if *arc_mode == shapes::EllipseMode::Whole {
*pie = (0.0, 360.0);
} else if (pie.1 - pie.0).abs() < 1e-6 || ((pie.1 - pie.0).abs() - 360.0).abs() < 1e-6 {
@@ -351,19 +454,14 @@ fn apply_rect_params(p: &Value, reset: bool, max_r: f64, radii: &mut [f64; 4], p
*arc_mode = shapes::EllipseMode::Whole;
changed = true;
}
// A radius larger than half the rectangle's shorter side is fitted to that size.
if let Some(r) = num_param(p, "radius") {
*radii = [r.clamp(0.0, max_r); 4];
changed = true;
}
if let Some(arr) = p.get("radii").and_then(Value::as_array)
&& arr.len() == 4
{
for (i, v) in arr.iter().enumerate() {
if let Some(r) = v.as_f64().filter(|x| x.is_finite()) {
radii[i] = r.clamp(0.0, max_r);
changed = true;
}
}
if let Ok(Some(four)) = radii_param(p, "addon.shapes.configure") {
*radii = four.map(|r| r.clamp(0.0, max_r));
changed = true;
}
changed | apply_arc_params(p, pie, arc_mode)
}
@@ -378,8 +476,9 @@ fn apply_poly_params(p: &Value, reset: bool, sides: &mut u32, spoke_ratio: &mut
}
changed = true;
}
if let Some(n_sides) = p.get("sides").and_then(Value::as_u64) {
*sides = (n_sides as u32).clamp(3, 1000);
// `check_configure` has refused values outside their ranges.
if let Some(n_sides) = num_param(p, "sides") {
*sides = n_sides as u32;
changed = true;
}
if let Some(star) = p.get("isStar").and_then(Value::as_bool) {
@@ -387,15 +486,15 @@ fn apply_poly_params(p: &Value, reset: bool, sides: &mut u32, spoke_ratio: &mut
changed = true;
}
if let Some(sr) = num_param(p, "spokeRatio") {
*spoke_ratio = Some(sr.clamp(0.01, 1.0));
*spoke_ratio = Some(sr);
changed = true;
}
if let Some(rnd) = num_param(p, "rounded") {
*rounded = rnd.clamp(-10.0, 10.0);
*rounded = rnd;
changed = true;
}
if let Some(rand) = num_param(p, "randomized") {
*randomized = rand.clamp(-10.0, 10.0);
*randomized = rand;
changed = true;
}
changed
@@ -429,15 +528,15 @@ fn apply_shape_params(cs: &mut CustomShape, p: &Value, reset: bool) -> bool {
changed = true;
}
if let Some(t) = num_param(p, "turns") {
*turns = t.clamp(0.01, 1024.0);
*turns = t;
changed = true;
}
if let Some(div) = num_param(p, "divergence") {
*divergence = div.clamp(0.0, 1000.0);
*divergence = div;
changed = true;
}
if let Some(ir) = num_param(p, "innerRadius") {
*inner_radius = ir.clamp(0.0, 0.999);
*inner_radius = ir;
changed = true;
}
changed
@@ -470,11 +569,12 @@ fn apply_shape_params(cs: &mut CustomShape, p: &Value, reset: bool) -> bool {
}
}
fn parse_mode(m: &str) -> shapes::EllipseMode {
fn parse_mode(m: &str) -> Option<shapes::EllipseMode> {
match m {
"pie" | "slice" => shapes::EllipseMode::Slice,
"chord" => shapes::EllipseMode::Chord,
"arc" | "open" => shapes::EllipseMode::Arc,
_ => shapes::EllipseMode::Whole,
"whole" => Some(shapes::EllipseMode::Whole),
"pie" | "slice" => Some(shapes::EllipseMode::Slice),
"chord" => Some(shapes::EllipseMode::Chord),
"arc" | "open" => Some(shapes::EllipseMode::Arc),
_ => None,
}
}
@@ -135,3 +135,169 @@ fn addon_shapes_commands_and_format_roundtrip_preserve_paths() {
assert_eq!(CustomShape::read(a), CustomShape::read(b));
}
}
fn id_of(s: &mut Session, cmd: &str, p: serde_json::Value) -> NodeId {
NodeId(s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))["id"].as_u64().unwrap())
}
fn shape_of(s: &Session, id: NodeId) -> CustomShape {
CustomShape::read(s.doc().unwrap().doc.node(id).unwrap()).unwrap()
}
/// Runs `cmd` with `p`, which must fail with `msg` and leave the document and its history as they
/// were.
fn refused(s: &mut Session, cmd: &str, p: serde_json::Value, msg: &str) {
let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let undo = s.doc().unwrap().history.undo.len();
let e = s.execute(cmd, &p).map(|v| v.to_string()).unwrap_err().to_string();
assert!(e.contains(msg), "{cmd} {p}: {e}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {p}");
assert_eq!(vectorcraft_format::save(&s.doc().unwrap().doc, false), saved, "{cmd} {p}");
}
#[test]
fn polygon_and_star_refuse_counts_that_are_not_whole_numbers_from_3_to_1000() {
let mut s = new_session();
for n in [json!(2.5), json!(2), json!(0), json!(-6), json!(1000.5), json!(5000), json!(4_294_967_299u64)] {
let x = n.as_f64().unwrap();
let p = json!({"cx": 0.0, "cy": 0.0, "radius": 10.0, "sides": n});
refused(&mut s, "addon.shapes.polygon", p, &format!("`sides` must be a whole number between 3 and 1000 (got {x})"));
let p = json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0, "points": n});
refused(&mut s, "addon.shapes.star", p, &format!("`points` must be a whole number between 3 and 1000 (got {x})"));
}
refused(&mut s, "addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": 10.0, "sides": "6"}), "`sides` must be a number");
refused(&mut s, "addon.shapes.star", json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0, "points": [5]}), "`points` must be a number");
// The ends of the range are taken as given, and an omitted or null count is the default.
for (n, want) in [(json!(3), 3), (json!(1000), 1000), (json!(7.0), 7), (json!(null), 6)] {
let id = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": 10.0, "sides": n}));
assert!(matches!(shape_of(&s, id), CustomShape::Polygon { sides, spoke_ratio: None, .. } if sides == want), "{n}");
}
let id = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": 10.0}));
assert!(matches!(shape_of(&s, id), CustomShape::Polygon { sides: 6, .. }));
for (n, want) in [(json!(3), 3), (json!(1000), 1000), (json!(null), 5)] {
let id = id_of(&mut s, "addon.shapes.star", json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0, "points": n}));
assert!(matches!(shape_of(&s, id), CustomShape::Polygon { sides, spoke_ratio: Some(_), .. } if sides == want), "{n}");
}
}
#[test]
fn shapes_refuse_numbers_outside_their_ranges() {
let mut s = new_session();
let poly = json!({"cx": 0.0, "cy": 0.0, "radius": 10.0});
let star = json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0});
let spiral = json!({"cx": 0.0, "cy": 0.0, "radius": 10.0});
let with = |base: &serde_json::Value, key: &str, v: f64| {
let mut p = base.clone();
p[key] = json!(v);
p
};
for (cmd, base, key, bad, good) in [
("addon.shapes.polygon", &poly, "rounded", [-10.5, 10.5], [-10.0, 10.0]),
("addon.shapes.polygon", &poly, "randomized", [-11.0, 10.01], [-10.0, 10.0]),
("addon.shapes.star", &star, "rounded", [-10.5, 10.5], [-10.0, 10.0]),
("addon.shapes.star", &star, "randomized", [-11.0, 10.01], [-10.0, 10.0]),
("addon.shapes.star", &star, "radius2", [0.05, 11.0], [0.1, 10.0]),
("addon.shapes.spiral", &spiral, "turns", [0.0, 1024.5], [0.01, 1024.0]),
("addon.shapes.spiral", &spiral, "divergence", [-0.1, 1000.5], [0.0, 1000.0]),
("addon.shapes.spiral", &spiral, "innerRadius", [-0.1, 1.0], [0.0, 0.999]),
] {
let (min, max) = (good[0], good[1]);
for x in bad {
refused(&mut s, cmd, with(base, key, x), &format!("`{key}` must be between {min} and {max} (got {x})"));
}
for x in good {
s.execute(cmd, &with(base, key, x)).unwrap_or_else(|e| panic!("{cmd} {key} {x}: {e}"));
}
}
// Sizes are not negative.
for (cmd, p, key) in [
("addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": -10.0}), "radius"),
("addon.shapes.star", json!({"cx": 0.0, "cy": 0.0, "radius1": -10.0}), "radius1"),
("addon.shapes.spiral", json!({"cx": 0.0, "cy": 0.0, "radius": -10.0}), "radius"),
("addon.shapes.rectangle", json!({"x": 0.0, "y": 0.0, "width": -10.0, "height": 10.0}), "width"),
("addon.shapes.rectangle", json!({"x": 0.0, "y": 0.0, "width": 10.0, "height": 10.0, "radius": -1.0}), "radius"),
("addon.shapes.ellipse", json!({"x": 0.0, "y": 0.0, "width": 10.0, "height": -10.0}), "height"),
("addon.shapes.box3d", json!({"x": 0.0, "y": 0.0, "width": -10.0, "height": 10.0}), "width"),
] {
refused(&mut s, cmd, p, &format!("`{key}` must be a number not below 0"));
}
// Corner radii are four numbers that are not negative.
for radii in [json!([1.0, 2.0, 3.0]), json!([1.0, 2.0, 3.0, -4.0]), json!([1.0, 2.0, 3.0, "4"]), json!(4.0)] {
let p = json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "radii": radii});
refused(&mut s, "addon.shapes.rectangle", p, "`radii` must be four numbers not below 0: [tl, tr, br, bl]");
}
// An arc mode is one of the names the commands take.
for mode in [json!("slices"), json!(""), json!(3)] {
let p = json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "arcMode": mode});
refused(&mut s, "addon.shapes.rectangle", p.clone(), "`arcMode` must be one of whole, pie, chord, arc");
refused(&mut s, "addon.shapes.ellipse", p, "`arcMode` must be one of whole, pie, chord, arc");
}
for mode in ["whole", "pie", "slice", "chord", "arc", "open"] {
s.execute("addon.shapes.ellipse", &json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "arcMode": mode})).unwrap();
}
}
#[test]
fn configure_refuses_values_outside_their_ranges_before_changing_any_shape() {
let mut s = new_session();
let rect = id_of(&mut s, "addon.shapes.rectangle", json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "radius": 4.0}));
let poly = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 50.0, "cy": 50.0, "radius": 40.0, "sides": 5}));
let spiral = id_of(&mut s, "addon.shapes.spiral", json!({"cx": 60.0, "cy": 60.0, "radius": 45.0}));
let ids = json!([rect.0, poly.0, spiral.0]);
let cfg = |key: &str, v: serde_json::Value| json!({"ids": ids.clone(), key: v});
let c = "addon.shapes.configure";
for n in [json!(2.5), json!(2), json!(-1), json!(5000), json!(4_294_967_299u64)] {
let x = n.as_f64().unwrap();
refused(&mut s, c, cfg("sides", n), &format!("`sides` must be a whole number between 3 and 1000 (got {x})"));
}
for (key, bad, min, max) in [
("spokeRatio", [0.0, 1.5], 0.01, 1.0),
("rounded", [-10.5, 11.0], -10.0, 10.0),
("randomized", [-11.0, 10.5], -10.0, 10.0),
("turns", [0.0, 2000.0], 0.01, 1024.0),
("divergence", [-1.0, 1000.5], 0.0, 1000.0),
("innerRadius", [-0.5, 1.0], 0.0, 0.999),
] {
for x in bad {
refused(&mut s, c, cfg(key, json!(x)), &format!("`{key}` must be between {min} and {max} (got {x})"));
}
}
refused(&mut s, c, cfg("radius", json!(-1.0)), "`radius` must be a number not below 0");
for radii in [json!([1.0, 2.0]), json!([1.0, 2.0, 3.0, -1.0]), json!("4")] {
refused(&mut s, c, cfg("radii", radii), "`radii` must be four numbers not below 0: [tl, tr, br, bl]");
}
refused(&mut s, c, cfg("arcMode", json!("bogus")), "`arcMode` must be one of whole, pie, chord, arc");
// A valid value next to a refused one changes no shape either.
refused(&mut s, c, json!({"ids": ids.clone(), "sides": 8, "turns": 0.0}), "`turns` must be between 0.01 and 1024 (got 0)");
// In range, the values are taken as given; a radius larger than the rectangle takes is fitted to it.
s.execute(c, &json!({"ids": [poly.0], "sides": 1000, "rounded": -10.0, "randomized": 10.0})).unwrap();
assert!(matches!(shape_of(&s, poly), CustomShape::Polygon { sides: 1000, rounded, randomized, .. } if rounded == -10.0 && randomized == 10.0));
s.execute(c, &json!({"ids": [poly.0], "sides": 3})).unwrap();
assert!(matches!(shape_of(&s, poly), CustomShape::Polygon { sides: 3, .. }));
s.execute(c, &json!({"ids": [spiral.0], "turns": 1024.0, "divergence": 0.0, "innerRadius": 0.999})).unwrap();
assert!(
matches!(shape_of(&s, spiral), CustomShape::Spiral { turns, divergence, inner_radius, .. } if turns == 1024.0 && divergence == 0.0 && inner_radius == 0.999)
);
s.execute(c, &json!({"ids": [rect.0], "radius": 1000.0})).unwrap();
assert!(matches!(shape_of(&s, rect), CustomShape::Rectangle { radii, .. } if radii == [40.0; 4]));
}
#[test]
fn configure_records_no_undo_step_when_nothing_changes() {
let mut s = new_session();
let poly = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 50.0, "cy": 50.0, "radius": 40.0, "sides": 5, "rounded": 0.5}));
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("addon.shapes.configure", &json!({"ids": [poly.0], "sides": 5, "rounded": 0.5})).unwrap();
assert_eq!(r["updated"], 0);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
let r = s.execute("addon.shapes.configure", &json!({"ids": [poly.0], "sides": 6})).unwrap();
assert_eq!(r["updated"], 1);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert!(matches!(shape_of(&s, poly), CustomShape::Polygon { sides: 6, .. }));
}
@@ -127,6 +127,17 @@ fn node_refs(n: &Node, f: Visit) {
if let Some(cm) = Document::color_mask_of(m) {
f(Ref::Name(Res::Image, &cm.key));
}
// A graph keeps paints and style names for its series and labels, which a redraw gives them.
if let Some(g) = m.extras().graph.as_deref() {
vectorcraft_doc::swatches::graph_paints(m).for_each(|p| paint_refs(p, f));
let l = &g.label_styles;
for s in [&l.values, &l.categories, &l.legend].into_iter().flatten().filter_map(|st| st.style_name.as_ref()) {
f(Ref::Name(Res::CharStyle, s));
}
for s in [&l.values_para, &l.categories_para, &l.legend_para].into_iter().flatten().filter_map(|pa| pa.style_name.as_ref()) {
f(Ref::Name(Res::ParaStyle, s));
}
}
match &m.kind {
NodeKind::Text(t) => {
for r in &t.runs {
@@ -671,6 +682,27 @@ impl Relink {
self.name(Res::Image, &mut cm.key);
self.name(Res::Image, &mut cm.from);
}
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
for s in &mut g.series_paints {
s.fill.iter_mut().chain(s.stroke.iter_mut()).for_each(|p| self.paint(p));
}
let l = &mut g.label_styles;
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
self.paint(&mut st.fill);
self.paint(&mut st.stroke);
if let Some(s) = &mut st.style_name {
self.name(Res::CharStyle, s);
}
}
for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() {
if let Some(s) = &mut pa.style_name {
self.name(Res::ParaStyle, s);
}
}
for r in [&mut l.values_rows, &mut l.categories_rows, &mut l.legend_rows].into_iter().flatten() {
r.paint.fill.iter_mut().chain(r.paint.stroke.iter_mut()).for_each(|p| self.paint(p));
}
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
+7 -7
View File
@@ -568,12 +568,11 @@ fn switch_units(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "units": next.label() }))
}
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given.
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when left out or null. A
/// value that is not a whole number is "`artboard` must be a whole number"; one past the artboards
/// is "index out of range".
fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
let index = match p.get("artboard") {
None => 0,
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?,
};
let index = super::index_opt(p, "artboard", c)?.unwrap_or(0);
if index >= s.doc()?.doc.artboards.len() {
return Err(bad(c, "index out of range"));
}
@@ -583,14 +582,15 @@ fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
fn ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
// A document without artboards measures from its global zero point.
let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? };
let index =
if d.artboards.is_empty() && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, "document.rulerOrigin")? };
Ok(json!(s.doc()?.doc.ruler_origin_for(index)))
}
fn set_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.setRulerOrigin";
let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? };
let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, C)? };
change_ruler_origin(s, index, Some(origin), "Ruler Origin")
}
+5 -3
View File
@@ -865,8 +865,8 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
/// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several
/// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with
/// fills, strokes or effects of its own or such members, other objects with effects, type with
/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry
/// effect ([`effects::has_item_geometry`])?
/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry or
/// raster effect ([`effects::has_item_geometry`], [`effects::has_instance_row_raster`])?
pub(crate) fn expandable(n: &Node) -> bool {
expandable_except(n, &[])
}
@@ -883,7 +883,9 @@ pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool {
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip))
}
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
effects::has_item_geometry(n) || effects::has_instance_row_raster(n) || !n.appearance.effects.is_empty()
}
_ => !n.appearance.effects.is_empty(),
}
}
+23 -1
View File
@@ -304,11 +304,22 @@ fn options_not_applied_yet_and_document_features_warn() {
let frame = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setStroke", &json!({"ids": [frame], "swatch": "Dots"})).unwrap();
assert_eq!(warnings(&export(&mut s, json!({}))).len(), 0);
// A knockout group is approximated: its warning comes back from every PDF path.
// A knockout group is written as a PDF knockout group: nothing to report.
s.execute("select.set", &json!({"ids": [frame]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
let knockout = |w: Vec<String>| w.iter().any(|w| w.contains("knockout groups"));
assert!(!knockout(warnings(&export(&mut s, json!({})))));
// One whose soft masks would draw a brushed stroke is approximated: its warning comes back
// from every PDF path.
let rect =
|s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 45, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap();
let (lower, upper) = (rect(&mut s, 20), rect(&mut s, 40));
s.execute("brush.apply", &json!({"ids": [upper], "name": "3 pt. Round"})).unwrap();
s.execute("object.setProps", &json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap();
s.execute("select.set", &json!({"ids": [lower, upper]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
assert!(knockout(warnings(&export(&mut s, json!({})))));
assert!(knockout(warnings(&s.execute("document.export", &as_export(&json!({}))).unwrap())));
// Other formats have none.
@@ -432,6 +443,17 @@ fn pdf_settings_summarise_changes_and_warnings() {
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [g]})).unwrap();
assert_eq!(s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap()["warnings"], json!([]));
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
// A knockout group is written as a PDF knockout group: nothing to report.
assert_eq!(s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap()["warnings"], json!([]));
// One whose soft masks would draw a brushed stroke is approximated.
let rect =
|s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 45, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap();
let (lower, upper) = (rect(&mut s, 20), rect(&mut s, 40));
s.execute("brush.apply", &json!({"ids": [upper], "name": "3 pt. Round"})).unwrap();
s.execute("object.setProps", &json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap();
s.execute("select.set", &json!({"ids": [lower, upper]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["warnings"], json!([]));
let full = s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap();
assert!(warnings(&full).iter().any(|w| w.contains("knockout groups")), "{}", full["warnings"]);
+1 -1
View File
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient Vector",
[],
None,
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label",
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label",
has_doc,
set_gradient_geom
),
+2 -2
View File
@@ -6,7 +6,7 @@ use super::labels::{
recolour_text, runs, shadow_of,
};
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
use super::series::{series_marks, with_series_opacity};
use super::series::{series_marks, with_series_gradients, with_series_opacity};
use super::{
Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes,
};
@@ -1006,7 +1006,7 @@ impl<'a> Plot<'a> {
}
}
let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1));
let grp = self.b.series(&name, s, with_series_opacity(g, s, items));
let grp = self.b.series(&name, s, with_series_opacity(g, s, with_series_gradients(g, s, items)));
self.b.out.push(grp);
}
self.b.out.append(&mut self.on_top);
+133 -7
View File
@@ -2,7 +2,7 @@ use vectorcraft_color::BlendMode;
use super::axis::radar_axis;
use super::labels::{Role, filled_lines_on, label_para, label_style};
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad};
// ---------- generation ----------
@@ -229,7 +229,8 @@ pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, b
continue;
}
let (paint, width) = shown_paint(node, *fill);
captures.push((r.graph, r.index, *fill, paint, (!*fill).then_some(width)));
let width = (!*fill).then_some(width);
captures.push((r.graph, r.index, *fill, paint, width, paint_box(node, width)));
}
store_series_paints(doc, captures)
}
@@ -281,11 +282,129 @@ fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool
changed
}
/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a
/// The box a series keeps a gradient relative to: (0, 0)–(1, 1), as graphic styles keep theirs.
const UNIT_BOX: Rect = vectorcraft_doc::GraphicStyle::UNIT_BOX;
/// The gradient `slot` keeps for its fill (`fill`) or stroke as the graph draws it on box `on`: one kept relative to the
/// unit box placed on `on`, also when `on` has no width or no height, and one kept in document coordinates as it is kept.
/// None when the slot keeps no gradient, or keeps one relative to the unit box and there is no box.
fn kept_on(slot: &vectorcraft_doc::SeriesPaint, fill: bool, on: Option<Rect>) -> Option<Paint> {
let (kept, unit) = if fill { (&slot.fill, slot.fill_unit_box) } else { (&slot.stroke, slot.stroke_unit_box) };
let Some(Paint::Gradient(g)) = kept else { return None };
if !unit {
return Some(Paint::Gradient(g.clone()));
}
let on = on?;
let mut g = g.clone();
g.rebase(UNIT_BOX, on);
Some(Paint::Gradient(g))
}
/// Whether `a` and `b` are the same paint, a gradient's vector, focal point and freeform points each within 1e-6 pt (the
/// rounding a move or a placement on another box leaves).
fn same_paint_placed(a: &Paint, b: &Paint) -> bool {
let (Paint::Gradient(a), Paint::Gradient(b)) = (a, b) else { return a == b };
let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| (p - q).hypot() <= 1e-6;
let mut a = (**a).clone();
match (&mut a.geom, &b.geom) {
(Some(ag), Some(bg)) => {
let focal = match (ag.focal, bg.focal) {
(Some(p), Some(q)) => near(p, q),
(p, q) => p.is_none() && q.is_none(),
};
if !(near(ag.start, bg.start) && near(ag.end, bg.end) && focal) {
return false;
}
(ag.start, ag.end, ag.focal) = (bg.start, bg.end, bg.focal);
}
(None, None) => {}
_ => return false,
}
match (&mut a.freeform, &b.freeform) {
(Some(af), Some(bf)) => {
if af.points.len() != bf.points.len() || !af.points.iter().zip(&bf.points).all(|(p, q)| near(p.at, q.at)) {
return false;
}
for (p, q) in af.points.iter_mut().zip(&bf.points) {
p.at = q.at;
}
}
(None, None) => {}
_ => return false,
}
a == **b
}
/// The box a gradient on `node`'s fill (`width` None) or on its stroke of `width` is placed on: the geometric bounds, grown
/// by half the weight for a stroke. The box can have no width or no height.
fn paint_box(node: &Node, width: Option<f64>) -> Option<Rect> {
let b = node.geometric_bounds()?;
Some(width.map_or(b, |w| vectorcraft_doc::appearance::stroke_paint_bounds(b, w)))
}
/// `paint` as a series keeps it: a gradient placed on `from` (the edited mark's [`paint_box`]) is moved to the same place
/// relative to [`UNIT_BOX`], so each mark takes it on its own box. A box with no width or no height, which a gradient's
/// place cannot be kept relative to, leaves the paint in document coordinates. Returns the paint and whether it was moved.
fn unit_box_paint(paint: Paint, from: Option<Rect>) -> (Paint, bool) {
match (paint, from.filter(|b| b.width() > 1e-9 && b.height() > 1e-9)) {
(Paint::Gradient(mut g), Some(from)) => {
g.rebase(from, UNIT_BOX);
(Paint::Gradient(g), true)
}
(paint, _) => (paint, false),
}
}
/// The paths directly inside series `i`'s group (`items`) with the series' kept gradients, which are kept relative to the
/// unit box, placed on each path's own box: a fill on its geometric bounds, a stroke on its stroke's box. Paints kept in
/// document coordinates (from files saved before the unit box) are left as they are.
pub(super) fn with_series_gradients(g: &GraphSpec, i: usize, mut items: Vec<Arc<Node>>) -> Vec<Arc<Node>> {
let Some(slot) = g.series_paints.get(i) else { return items };
fn kept(p: &Option<Paint>, unit: bool) -> Option<&Paint> {
p.as_ref().filter(|p| unit && matches!(p, Paint::Gradient(_)))
}
let (fill, stroke) = (kept(&slot.fill, slot.fill_unit_box), kept(&slot.stroke, slot.stroke_unit_box));
if fill.is_none() && stroke.is_none() {
return items;
}
// A Filled Lines band takes the kept stroke as its fill when no fill is kept.
let band_fill = if slot.fill.is_none() { stroke } else { None };
let fill_from_store = |p: &Paint| fill == Some(p) || band_fill == Some(p);
let stroke_from_store = |p: &Paint| stroke == Some(p);
for item in &mut items {
if !matches!(item.kind, NodeKind::Path { .. }) {
continue;
}
let Some(b) = item.geometric_bounds() else { continue };
let ap = &item.appearance;
if !ap.fill().is_some_and(|f| fill_from_store(&f.paint)) && !ap.stroke().is_some_and(|st| stroke_from_store(&st.paint)) {
continue;
}
let ap = &mut Arc::make_mut(item).appearance;
if let Some(f) = ap.fill_mut().filter(|f| fill_from_store(&f.paint))
&& let Paint::Gradient(gp) = &mut f.paint
{
gp.rebase(UNIT_BOX, b);
}
if let Some(st) = ap.stroke_mut().filter(|st| stroke_from_store(&st.paint)) {
let to = st.paint_bounds(b);
if let Paint::Gradient(gp) = &mut st.paint {
gp.rebase(UNIT_BOX, to);
}
}
}
items
}
/// A paint taken from a series mark: graph, series index, fill or stroke, paint, stroke weight and the mark's
/// [`paint_box`].
type Capture = (NodeId, usize, bool, Paint, Option<f64>, Option<Rect>);
/// Store each capture in its series; a gradient is kept relative to the unit box ([`unit_box_paint`]). Returns whether a
/// series' captured fill or stroke differs from what the graph stored before the first capture.
fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option<f64>)>) -> bool {
fn store_series_paints(doc: &mut Document, captures: Vec<Capture>) -> bool {
let mut before = SeriesPaintsBefore::new();
for (graph, index, fill, paint, width) in captures {
for (graph, index, fill, paint, width, from) in captures {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
before.entry(graph).or_insert_with(|| spec.series_paints.clone());
// `index` is below the graph's series count (`series_members`).
@@ -293,10 +412,17 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
}
let Some(slot) = spec.series_paints.get_mut(index) else { continue };
// A mark that shows the kept gradient as it was drawn leaves the kept gradient as it is.
let drawn = kept_on(slot, fill, from).is_some_and(|kept| same_paint_placed(&kept, &paint));
let (paint, unit) = unit_box_paint(paint, from);
if fill {
slot.fill = Some(paint);
if !drawn {
(slot.fill, slot.fill_unit_box) = (Some(paint), unit);
}
} else {
slot.stroke = Some(paint);
if !drawn {
(slot.stroke, slot.stroke_unit_box) = (Some(paint), unit);
}
slot.stroke_width = width;
}
}
+273 -12
View File
@@ -2754,10 +2754,39 @@ fn a_gradient_edit_on_a_series_mark_is_kept() {
series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was);
}
/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored on the graph as it is placed, and
/// every mark of the series has that fill, placement included, after the graph is drawn again.
/// Where gradient `paint` sits on box `b`: its vector's start and end, then each freeform point, as fractions of the box.
fn gradient_place(paint: &Paint, b: vectorcraft_geom::Rect) -> Vec<f64> {
let Paint::Gradient(g) = paint else { panic!("not a gradient: {paint:?}") };
let points = g.geom.iter().flat_map(|geom| [geom.start, geom.end]).chain(g.freeform.iter().flat_map(|f| f.points.iter().map(|p| p.at)));
points.flat_map(|p| [(p.x - b.x0) / b.width(), (p.y - b.y0) / b.height()]).collect()
}
/// The box a gradient on `mark`'s fill (`stroke` false) or stroke is placed on.
fn paint_box_of(mark: &vectorcraft_doc::Node, stroke: bool) -> vectorcraft_geom::Rect {
let b = mark.geometric_bounds().unwrap();
if stroke { vectorcraft_doc::appearance::stroke_paint_bounds(b, mark.appearance.stroke_width()) } else { b }
}
/// The fill (`stroke` false) or stroke of every path of series 0, the legend swatch included, has a gradient at `place` on
/// its own box, and the gradients are not all at one place in the document. Returns how many paths were checked.
fn series_0_gradients_at(s: &Session, id: NodeId, stroke: bool, place: &[f64]) -> usize {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let paths: Vec<_> = series(&n, 0).children().unwrap().iter().filter(|m| matches!(m.kind, NodeKind::Path { .. })).cloned().collect();
let paints: Vec<Paint> = paths.iter().map(|m| if stroke { m.appearance.stroke_paint() } else { m.appearance.fill_paint() }).collect();
for (m, paint) in paths.iter().zip(&paints) {
let at = gradient_place(paint, paint_box_of(m, stroke));
assert_eq!(at.len(), place.len(), "{at:?}");
assert!(at.iter().zip(place).all(|(a, b)| (a - b).abs() < 1e-9), "{at:?} is not {place:?}");
}
assert!(paints.windows(2).any(|w| w[0] != w[1]), "every mark has the gradient at one place: {paints:?}");
paths.len()
}
/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored relative to the mark's box, and every
/// mark of the series has it at the same place on its own box after the graph is drawn again, also after a data change
/// moves and resizes the marks and after the graph is moved.
#[test]
fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() {
fn a_gradient_placed_on_a_series_mark_takes_the_same_place_on_every_mark() {
let (mut s, id, _, marks) = column_marks();
// `paint.setFill` stores the gradient before it is placed.
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap();
@@ -2766,26 +2795,258 @@ fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() {
s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap();
let placed = fill_of(&s, marks[0]);
assert_ne!(placed, fill, "the command places the gradient");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(placed.clone()));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0));
let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone();
let place = gradient_place(&placed, paint_box_of(&mark, false));
let kept = spec(&s, id).series_paints[0].clone();
assert!(kept.fill_unit_box);
assert_eq!(gradient_place(kept.fill.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX), place);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
assert!(series_0_gradients_at(&s, id, false, &place) >= 4);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 50, "dy": 30})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 2], [5, 4], [1, 1]]})).unwrap();
series_0_gradients_at(&s, id, false, &place);
}
/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph.
/// A gradient placed on a series mark's stroke is kept relative to the mark's stroke box, and every mark of the series has
/// it at the same place on its own stroke box after the graph is drawn again.
#[test]
fn a_gradient_placed_on_a_series_marks_stroke_takes_the_same_place_on_every_mark() {
let (mut s, id, _, marks) = column_marks();
s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap();
s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 4})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap();
let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone();
let place = gradient_place(&mark.appearance.stroke_paint(), paint_box_of(&mark, true));
assert!(spec(&s, id).series_paints[0].stroke_unit_box);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
series_0_gradients_at(&s, id, true, &place);
}
/// A placed gradient picked up with the Eyedropper from art outside the graph is kept relative to the series mark it is
/// dropped on, and every mark of the series has it at the same place on its own box.
#[test]
fn a_placed_gradient_dropped_on_a_series_mark_takes_the_same_place_on_every_mark() {
let (mut s, id, _, marks) = column_marks();
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [r.0], "start": [610, 600], "end": [630, 620]})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": r.0, "ids": [marks[2].0]})).unwrap();
let mark = s.doc().unwrap().doc.node(marks[2]).unwrap().clone();
let place = gradient_place(&mark.appearance.fill_paint(), paint_box_of(&mark, false));
assert!(spec(&s, id).series_paints[0].fill_unit_box);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
series_0_gradients_at(&s, id, false, &place);
}
/// A graphic style with a placed gradient applied to a series mark is kept relative to that mark's box, and every mark of
/// the series has the gradient at the same place on its own box.
#[test]
fn a_graphic_style_gradient_applied_to_a_series_mark_takes_the_same_place_on_every_mark() {
let (mut s, id, _, marks) = column_marks();
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [r.0], "start": [610, 600], "end": [630, 620]})).unwrap();
s.execute("graphicStyle.new", &json!({"id": r.0, "name": "Placed"})).unwrap();
s.execute("graphicStyle.apply", &json!({"name": "Placed", "ids": [marks[2].0]})).unwrap();
let mark = s.doc().unwrap().doc.node(marks[2]).unwrap().clone();
let place = gradient_place(&mark.appearance.fill_paint(), paint_box_of(&mark, false));
assert!(spec(&s, id).series_paints[0].fill_unit_box);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
series_0_gradients_at(&s, id, false, &place);
}
/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph,
/// and every mark of the series has the gradient's points at the same place on its own box after the graph is drawn again.
#[test]
fn a_freeform_gradient_edit_on_a_series_mark_is_kept() {
let (mut s, id, _, marks) = column_marks();
let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": "#ff8000"}]);
// `paint.setFill` stores the gradient before its point is recolored.
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap();
let fill = fill_of(&s, marks[0]);
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()));
s.execute("paint.freeform.setPoint", &json!({"ids": [marks[0].0], "index": 1, "color": "#00ff00"})).unwrap();
let edited = fill_of(&s, marks[0]);
assert_ne!(edited, fill, "the command recolors the point");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(edited.clone()));
let Paint::Gradient(g) = &edited else { panic!("{edited:?}") };
assert_eq!(g.freeform.as_ref().unwrap().points[1].color.to_hex(), "#00ff00");
let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone();
let place = gradient_place(&edited, paint_box_of(&mark, false));
let kept = spec(&s, id).series_paints[0].clone();
assert!(kept.fill_unit_box);
assert_eq!(gradient_place(kept.fill.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX), place);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == edited), "{:?}", marks_of(&s, id, 0));
series_0_gradients_at(&s, id, false, &place);
}
/// A gradient a graph kept in document coordinates (a file saved before gradients were kept relative to the unit box) is
/// drawn on every mark as it was kept.
#[test]
fn a_gradient_kept_in_document_coordinates_is_drawn_as_kept() {
let (mut s, id, _, marks) = column_marks();
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap();
let placed = fill_of(&s, marks[0]);
s.edit("Kept in document coordinates", |d, _| {
let mut extras = d.node_mut(id).unwrap().extras_mut();
let slot = &mut extras.graph.as_mut().unwrap().series_paints[0];
(slot.fill, slot.fill_unit_box) = (Some(placed.clone()), false);
Ok(())
})
.unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0));
// The same gradient kept for the fill relative to the unit box leaves the stroke kept in document coordinates as it is.
s.edit("Kept fill and stroke", |d, _| {
let mut extras = d.node_mut(id).unwrap().extras_mut();
let slot = &mut extras.graph.as_mut().unwrap().series_paints[0];
(slot.fill, slot.fill_unit_box) = (Some(placed.clone()), true);
(slot.stroke, slot.stroke_unit_box, slot.stroke_width) = (Some(placed.clone()), false, Some(2.0));
Ok(())
})
.unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 2], [3, 4]]})).unwrap();
let drawn = marks_of(&s, id, 0);
assert!(drawn.iter().all(|m| m.1 == placed), "{drawn:?}");
series_0_gradients_at(&s, id, false, &gradient_place(&placed, vectorcraft_doc::GraphicStyle::UNIT_BOX));
}
/// A gradient placed on a series path with no height (a flat line's fill) is kept in document coordinates, and every
/// path of the series that takes the kept fill has it at that place in the document.
#[test]
fn a_gradient_placed_on_a_series_path_with_no_height_is_kept_in_document_coordinates() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "line");
s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 2], [3, 4], [3, 6]]})).unwrap();
let flat = |s: &Session| {
let parts = series_parts(s, id, 0);
parts.into_iter().find(|m| m.appearance.fill_paint().is_none() && m.geometric_bounds().is_some_and(|b| b.height() < 1e-9)).unwrap().id
};
let line = flat(&s);
s.execute("paint.setFill", &json!({"ids": [line.0], "gradient": {"kind": "linear"}})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [line.0], "start": [150, 120], "end": [300, 280]})).unwrap();
let placed = fill_of(&s, line);
let kept = spec(&s, id).series_paints[0].clone();
assert!(!kept.fill_unit_box);
assert_eq!(kept.fill, Some(placed.clone()));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 1], [3, 5], [3, 2]]})).unwrap();
let fills: Vec<Paint> = series_parts(&s, id, 0).iter().map(|m| m.appearance.fill_paint()).filter(|p| matches!(p, Paint::Gradient(_))).collect();
assert!(fills.len() >= 2 && fills.iter().all(|p| *p == placed), "{fills:?}");
}
/// Writing a series mark's stroke weight as it is, after the graph drew the mark's stroke from a gradient the series
/// keeps relative to the unit box, leaves the kept gradient, the document and its undo history as they are, on every mark.
#[test]
fn rewriting_a_series_marks_drawn_gradient_stroke_records_no_undo_step() {
let (mut s, id, _, marks) = column_marks();
s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap();
s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 4})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1.3, 2], [3.7, 4], [6.1, 1]]})).unwrap();
let was = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let drawn: Vec<_> = series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).collect();
assert!(drawn.len() >= 4, "{}", drawn.len());
for mark in drawn {
let row = top_row(&s, mark.id, false);
s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap();
assert!(s.doc().unwrap().doc == was, "writing mark {:?} changed the document", mark.id);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
// A mark moved with its gradient shows the kept gradient on its moved box, up to rounding.
let kept = spec(&s, id).series_paints[0].clone();
for (i, mark) in series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).enumerate() {
s.execute("select.set", &json!({"ids": [mark.id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 13.37 + i as f64 * 0.731, "dy": -7.19})).unwrap();
let moved = s.doc().unwrap().doc.node(mark.id).unwrap().geometric_bounds().unwrap();
assert!((moved.x0 - mark.geometric_bounds().unwrap().x0 - 13.37 - i as f64 * 0.731).abs() < 1e-9, "mark {:?} did not move", mark.id);
let undo = s.doc().unwrap().history.undo.len();
let row = top_row(&s, mark.id, false);
s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap();
assert_eq!(spec(&s, id).series_paints[0], kept, "writing moved mark {:?} changed the kept stroke", mark.id);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
}
/// Writing the fill of a series mark with no height (a column for a value of 0) as it is, after the graph drew the mark's
/// fill from a gradient the series keeps relative to the unit box, leaves the kept gradient and its unit-box flag as they
/// are.
#[test]
fn rewriting_a_drawn_gradient_fill_on_a_series_mark_with_no_height_keeps_the_kept_gradient() {
let (mut s, id, _, marks) = column_marks();
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert!(kept.fill_unit_box);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[0, 2], [3, 4], [6, 1]]})).unwrap();
let flat = series_parts(&s, id, 0)
.into_iter()
.find(|m| matches!(m.appearance.fill_paint(), Paint::Gradient(_)) && m.geometric_bounds().is_some_and(|b| b.height() < 1e-9))
.unwrap()
.id;
let changed = s.edit("Write the fill as it is", |d, _| Ok(super::capture_series_top_paints(d, &[(flat, true)]))).unwrap();
assert!(!changed);
assert_eq!(spec(&s, id).series_paints[0], kept);
}
/// Writing a series mark's stroke weight as it is, after the graph drew the mark's stroke from a gradient the series keeps
/// in document coordinates (a file saved before the unit box), leaves the kept gradient and its unit-box flag as they are
/// and records no undo step.
#[test]
fn rewriting_a_series_marks_stroke_drawn_from_a_gradient_kept_in_document_coordinates_keeps_it() {
let (mut s, id, _, marks) = column_marks();
s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap();
s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 2})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap();
let placed = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.stroke_paint();
s.edit("Kept in document coordinates", |d, _| {
let mut extras = d.node_mut(id).unwrap().extras_mut();
let slot = &mut extras.graph.as_mut().unwrap().series_paints[0];
(slot.stroke, slot.stroke_unit_box, slot.stroke_width) = (Some(placed.clone()), false, Some(2.0));
Ok(())
})
.unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
let undo = s.doc().unwrap().history.undo.len();
let drawn: Vec<_> = series_parts(&s, id, 0).into_iter().filter(|m| m.appearance.stroke_paint() == placed).collect();
assert!(drawn.len() >= 3, "{}", drawn.len());
for mark in drawn {
let row = top_row(&s, mark.id, false);
s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap();
assert_eq!(spec(&s, id).series_paints[0], kept, "writing mark {:?} changed the kept stroke", mark.id);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
}
/// A gradient placed on a line series' stroke is kept relative to the line's stroke box, and with Draw Filled Lines the
/// band, which takes the kept stroke as its fill, has it at the same place on its own box.
#[test]
fn a_gradient_kept_for_a_lines_stroke_takes_the_same_place_on_its_filled_line() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "line");
let line = series_parts(&s, id, 0).into_iter().find(|m| m.appearance.fill_paint().is_none()).unwrap().id;
s.execute("paint.setStroke", &json!({"ids": [line.0], "gradient": {"kind": "linear"}})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [line.0], "stroke": true, "start": [150, 120], "end": [300, 280]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert!(kept.stroke_unit_box && kept.fill.is_none());
let place = gradient_place(kept.stroke.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX);
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap();
let band = series_parts(&s, id, 0).into_iter().find(|m| matches!(m.appearance.fill_paint(), Paint::Gradient(_))).unwrap();
let at = gradient_place(&band.appearance.fill_paint(), band.geometric_bounds().unwrap());
assert!(at.iter().zip(&place).all(|(a, b)| (a - b).abs() < 1e-9), "{at:?} is not {place:?}");
}
/// A series' kept unit-box flags are read from and written to a file, and a file without them reads as document
/// coordinates.
#[test]
fn a_series_paints_unit_box_flags_round_trip() {
let kept = vectorcraft_doc::SeriesPaint { fill_unit_box: true, stroke_unit_box: true, ..Default::default() };
let text = serde_json::to_string(&kept).unwrap();
assert_eq!(text, r#"{"fillUnitBox":true,"strokeUnitBox":true}"#);
assert_eq!(serde_json::from_str::<vectorcraft_doc::SeriesPaint>(&text).unwrap(), kept);
let old: vectorcraft_doc::SeriesPaint = serde_json::from_str("{}").unwrap();
assert!(!old.fill_unit_box && !old.stroke_unit_box);
}
/// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command,
+3 -3
View File
@@ -21,7 +21,7 @@ use vectorcraft_color::{Color, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, Node, NodeId, TextStyleDef};
use super::clipboard::SwatchChoices;
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, color_value, has_doc, ids_param, index, ok, point_param, str_param};
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, color_value, has_doc, ids_param, index_opt, ok, point_param, str_param};
use crate::Clipboard;
/// The extension of a library file.
@@ -1649,11 +1649,11 @@ fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.moveGroup";
let id = library_id(s, p, C)?;
let group = group_param(p, C)?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing `to` (an index)"))?;
let to = index_opt(p, "to", C)?.ok_or_else(|| bad(C, "missing `to` (an index)"))?;
let (name, at) = s.libraries.change(&id, |lib| {
let i = group_at(lib, group, C)?;
let g = lib.groups.remove(i);
let at = index(to).min(lib.groups.len());
let at = to.min(lib.groups.len());
let name = g.name.clone();
lib.groups.insert(at, g);
Ok((name, at))
+1 -1
View File
@@ -88,7 +88,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "artboard.reorder";
let i = artboard_index(&s.doc()?.doc, p, C)?;
let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing to"))?;
let to = super::index_opt(p, "to", C)?.ok_or_else(|| bad(C, "missing to"))?;
s.edit("Reorder Artboards", |d, _| {
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
+7 -7
View File
@@ -12,7 +12,7 @@ use super::draw2::usize_opt;
use super::pathops::Beside;
use super::{
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path,
has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets,
has_selection, ids_param, index_opt, number_or, object_param, ok, str_param, switch_or, targets,
};
use crate::EngineError;
@@ -170,7 +170,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<
fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = anchor_from_json(p, "path.appendAnchor")?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
let subpath = index_opt(p, "subpath", "path.appendAnchor")?;
s.edit("Pen", |d, sel| {
let path = path_mut(d, id)?;
let si = match subpath {
@@ -208,7 +208,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
/// `at` first moves the anchor, its handles with it (the Pen's closing drag with Space held).
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
let subpath = index_opt(p, "subpath", "path.close")?;
let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) {
None => Ok(None),
Some(h) => finite_numbers(h).map(|[x, y]| Some(Point::new(x, y))).ok_or_else(|| bad("path.close", format!("`{key}` must be [x, y]"))),
@@ -310,7 +310,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?;
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
let ai = index_opt(p, "anchor", "path.setHandle")?.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 = switch_or(p, "independent", false, "path.setHandle")?;
@@ -337,7 +337,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
let ai = index_opt(p, "anchor", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
// An anchor already at (x, y) changes nothing and records no undo step.
if let Some(a) = anchor_of(s, id, si, ai)?
@@ -453,7 +453,7 @@ pub(super) fn paths_in(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<Nod
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.reverse";
let want = p.get("reversed").map(|v| v.as_bool().ok_or_else(|| bad(C, "`reversed` must be true or false"))).transpose()?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
let subpath = index_opt(p, "subpath", C)?;
let ids = paths_in(&s.doc()?.doc, &targets(s, p)?);
if ids.is_empty() {
return Err(bad(C, "select paths"));
@@ -1784,7 +1784,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
let seg = index_opt(p, "segment", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0);
let idx = s.edit("Add Anchor Point", |d, sel| {
let path = path_mut(d, id)?;
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -21,7 +21,7 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind, Shared};
use vectorcraft_geom::Rect;
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, object_param, ok, str_param, unique_name};
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index_opt, object_param, ok, str_param, unique_name};
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
@@ -585,10 +585,10 @@ fn style_merge(s: &mut Session, p: &Value) -> Result<Value> {
fn style_move(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "graphicStyle.move";
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `to` (an index)"))?;
let to = index_opt(p, "to", cmd)?.ok_or_else(|| bad(cmd, "missing `to` (an index)"))?;
let d = &s.doc()?.doc;
let from = style_index(d, name)?;
let to = index(to).min(d.graphic_styles.len() - 1);
let to = to.min(d.graphic_styles.len() - 1);
if from == to {
return ok();
}
+3 -3
View File
@@ -12,8 +12,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2};
use super::edit::selected_roots;
use super::pathops::{length_given, number_given, shape_node};
use super::{
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param,
ok, str_param, switch_or, targets, text_param,
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, index_opt, number_or,
object_param, ok, str_param, switch_or, targets, text_param,
};
pub fn specs() -> Vec<CommandSpec> {
@@ -1376,7 +1376,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setPathHandle";
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?;
let ai = index_opt(p, "anchor", C)?.ok_or_else(|| bad(C, "missing anchor"))?;
let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in");
let pos = vectorcraft_geom::Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let independent = switch_or(p, "independent", false, C)?;
+11 -1
View File
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste without Formatting",
["Edit"],
Some("Cmd+Alt+V"),
"{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind → {ids, added, merged, renamed}",
"{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind, as do the labels of pasted graphs → {ids, added, merged, renamed}",
has_clipboard,
paste_plain
),
@@ -1460,6 +1460,16 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) {
t.set_all_paras(Default::default());
refresh_bounds(t);
}
// A graph keeps the names of its labels' character and paragraph styles for each redraw.
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
let l = &mut g.label_styles;
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
st.style_name = None;
}
for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() {
pa.style_name = None;
}
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
strip_formatting(std::sync::Arc::make_mut(c));
+2 -1
View File
@@ -26,7 +26,8 @@
//! Before a run starts, its preprocessor lines are read ([`include`]): included files take the
//! place of their lines, and an error in one is reported at its own file, line and column.
//! `$.evalFile(path)` runs another file in the same global environment and gives its last value,
//! `$.fileName` is the file being run and `$.includePath` the `#includepath` list.
//! `$.fileName` is the file of the code that reads it (the file being run for code that `eval` or
//! `Function` compiled) and `$.includePath` the `#includepath` list.
//!
//! A run writes to the Script Console with `$.write` and `$.writeln`, and `vc.log` adds a line
//! there too. The session's console gets a start line, the run's output, its uncaught error, an end
+66
View File
@@ -468,6 +468,72 @@ fn styles_symbols_and_images_of_the_same_name_are_renamed() {
assert_eq!(key(&s, img2), src_key);
}
/// A graph whose series fill, value labels and legend rows paint with global swatch "Brand" and
/// whose value labels are set in character style "Emphasis" and paragraph style "Body", as a
/// later redraw would give them to its marks and labels.
fn styled_graph(s: &mut Session) -> NodeId {
s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0}})).unwrap();
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"space_after": 8.0}})).unwrap();
let v = s.execute("graph.create", &json!({"type": "column", "x": 200, "y": 200, "width": 60, "height": 40, "csv": ",2024\nQ1,3\nQ2,5"}));
let id = id_of(&v.unwrap());
let brand = linked(hex("#ff0000"), "Brand", 1.0);
let mut extras = doc_mut(s).node_mut(id).unwrap().extras_mut();
let g = extras.graph.as_mut().unwrap();
g.series_paints = vec![vectorcraft_doc::SeriesPaint { fill: Some(brand.clone()), ..Default::default() }];
let l = &mut g.label_styles;
l.values = Some(vectorcraft_doc::CharStyle { fill: brand.clone(), style_name: Some("Emphasis".into()), ..Default::default() });
l.values_para = Some(vectorcraft_doc::ParaStyle { style_name: Some("Body".into()), ..Default::default() });
let rows = vectorcraft_doc::SeriesPaint { stroke: Some(brand), ..Default::default() };
l.legend_rows = Some(vectorcraft_doc::graph::LabelRows { paint: rows, ..Default::default() });
id
}
/// The swatch names a graph's stored paints link to, and its value labels' character and
/// paragraph style names.
fn graph_links(s: &Session, id: NodeId) -> (Vec<String>, Option<String>, Option<String>) {
let n = node(s, id);
let swatches = vectorcraft_doc::swatches::graph_paints(&n).filter_map(|p| match p {
Paint::Solid { swatch: Some(w), .. } => Some(w.clone()),
_ => None,
});
let l = &n.extras().graph.as_deref().unwrap().label_styles;
let values = l.values.as_ref().and_then(|st| st.style_name.clone());
(swatches.collect(), values, l.values_para.as_ref().and_then(|pa| pa.style_name.clone()))
}
/// A pasted graph brings the swatches and text styles its series and labels keep, and follows
/// them when they come in under new names.
#[test]
fn a_pasted_graph_brings_and_follows_its_label_styles_and_paints() {
let mut s = session();
let g = styled_graph(&mut s);
copy(&mut s, &[g]);
new_doc(&mut s);
let v = s.execute("edit.pasteInPlace", &json!({})).unwrap();
let d = doc(&s);
assert!(d.swatch("Brand").is_some(), "{v}");
assert!(d.char_styles.iter().any(|x| x.name == "Emphasis") && d.para_styles.iter().any(|x| x.name == "Body"), "{v}");
let brand = vec!["Brand".to_string(); 3];
assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand.clone(), Some("Emphasis".into()), Some("Body".into())));
// Into a document that gives each name something else: they come in renamed.
new_doc(&mut s);
s.execute("swatch.new", &json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 8.0}})).unwrap();
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"space_after": 2.0}})).unwrap();
let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "add"})).unwrap();
let brand2 = vec!["Brand 2".to_string(); 3];
assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand2, Some("Emphasis 2".into()), Some("Body 2".into())), "{v}");
// Without formatting: the labels' text styles stay behind.
new_doc(&mut s);
let v = s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap();
assert!(doc(&s).char_styles.is_empty() && doc(&s).para_styles.is_empty(), "{v}");
assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand, None, None));
}
#[test]
fn pasting_again_reuses_what_an_earlier_paste_added() {
let mut s = session();
+116
View File
@@ -422,6 +422,122 @@ fn a_compound_clipping_path_in_the_art_keeps_its_shape() {
assert_eq!(seen, (true, true));
}
/// A Drop Shadow 20 pt to the right on row `row` of instance `id`, solid black with no blur.
fn shadow_row(s: &mut Session, id: NodeId, row: usize) {
let params = json!({"x": 20, "y": 0, "blur": 0, "opacity": 100, "mode": "normal"});
run(s, "effect.apply", json!({"ids": [id.0], "item": row, "effect": "stylize.dropShadow", "params": params}));
}
/// Is `p` dark: a shadow, not the white page or the red fill?
fn dark(p: [u8; 4]) -> bool {
p[..3].iter().all(|c| *c < 100)
}
/// A raster effect on a symbol instance's fill draws on the filled shapes of its art: the shadow
/// falls right of the square, the bounds take it in, a hidden fill draws none, and Expand
/// Appearance keeps the look in one undo step.
#[test]
fn a_symbol_instance_fill_shadow_shadows_its_filled_art() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
assert!(near(render(&s).pixel(160, 100), WHITE));
shadow_row(&mut s, id, row);
let before = render(&s);
assert!(dark(before.pixel(165, 100)), "the fill's shadow falls right of the square: {:?}", before.pixel(165, 100));
assert!(near(before.pixel(100, 100), RED), "the art draws over its shadow: {:?}", before.pixel(100, 100));
let doc = s.doc().unwrap().doc.clone();
let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds");
assert!(b.x1 >= 169.0, "the bounds take in the shadow: {b:?}");
// A view that shows only the shadow, right of the square: the instance is still drawn.
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let right = vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::translate((-155.0, 0.0)), &opts);
assert!(dark(right.pixel(10, 100)), "the shadow past the square's box is drawn: {:?}", right.pixel(10, 100));
assert!(expandable(&s), "the fill's shadow can be expanded");
run(&mut s, "effect.expandAppearance", json!({}));
let ch = node(&s, id).children().cloned().expect("the instance becomes a group");
assert!(matches!(ch[0].kind, NodeKind::Image(_)), "the shadow is an image under the art: {}", ch[0].kind_label());
assert!(matches!(ch[1].kind, NodeKind::SymbolInstance { .. }), "the art stays an instance: {}", ch[1].kind_label());
no_effects_left(&ch[1]);
let after = render(&s);
assert!(differing(&before, &after, 24) < 400, "expanding changed {} pixels", differing(&before, &after, 24));
run(&mut s, "edit.undo", json!({}));
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::SymbolInstance { .. }), "one undo gives the instance back");
assert!(n.appearance.items[row].effects().iter().any(|e| e.id == "stylize.dropShadow"));
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "visible": false}));
assert!(near(render(&s).pixel(165, 100), WHITE), "a hidden fill draws no shadow");
}
/// A raster effect on a symbol instance's stroke draws on the stroked shapes of its art only: the
/// stroke's shadow falls right of the stroke, and none falls where only the fill would cast one.
#[test]
fn a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", false);
shadow_row(&mut s, id, row);
let r = render(&s);
assert!(dark(r.pixel(170, 100)), "the stroke's shadow falls right of the stroke: {:?}", r.pixel(170, 100));
assert!(near(r.pixel(160, 100), WHITE), "the fill casts no shadow: {:?}", r.pixel(160, 100));
}
/// Geometry and raster effects on one fill of an instance: the shadow falls from the grown fill.
#[test]
fn a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
offset_row(&mut s, id, row, 10.0);
shadow_row(&mut s, id, row);
let r = render(&s);
assert!(near(r.pixel(44, 100), RED), "the fill grows by the offset: {:?}", r.pixel(44, 100));
assert!(dark(r.pixel(175, 100)), "the shadow falls from the grown fill: {:?}", r.pixel(175, 100));
}
/// An instance inside a symbol's art passes the outer instance's fill shadow on to its own art.
#[test]
fn a_nested_symbol_instance_takes_the_outer_fill_shadow() {
let mut s = session();
square(&mut s);
run(&mut s, "symbol.new", json!({"name": "Inner"}));
let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Outer"}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addFill", json!({"ids": [id.0]}));
let row = node(&s, id).appearance.items.len() - 1;
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true}));
shadow_row(&mut s, id, row);
assert!(dark(render(&s).pixel(165, 100)), "the inner art casts the shadow: {:?}", render(&s).pixel(165, 100));
}
/// Export draws a symbol instance's fill shadow on the filled shapes of its art: baking leaves the
/// shadow on the art's fill, not its stroke, and PDF export makes an image of it.
#[test]
fn export_draws_a_symbol_instance_fill_shadow() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
shadow_row(&mut s, id, row);
let doc = s.doc().unwrap().doc.clone();
let baked = vectorcraft_render::effects::bake_document(&doc).expect("the instance is baked");
let n = baked.node(id).expect("the baked art keeps the instance's id").clone();
assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. }));
let shadowed = |i: &vectorcraft_doc::AppearanceItem| i.effects().iter().any(|e| e.id == "stylize.dropShadow");
let (mut fills, mut strokes) = (0, 0);
n.walk(&mut |part| {
fills += part.appearance.items.iter().filter(|i| i.is_fill() && shadowed(i)).count();
strokes += part.appearance.items.iter().filter(|i| !i.is_fill() && shadowed(i)).count();
});
assert_eq!((fills, strokes), (1, 0), "the art's fill carries the shadow");
let flat = crate::flatten_raster_effects(&doc).expect("the shadow becomes an image");
let mut image = None;
flat.node(id).expect("the flattened art keeps the instance's id").walk(&mut |part| {
if matches!(part.kind, NodeKind::Image(_)) {
image = part.geometric_bounds();
}
});
let image = image.expect("the shadow is an image");
assert!(image.x1 >= 165.0, "the image holds the shadow right of the square: {image:?}");
}
#[test]
fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
let mut s = session();
+7 -6
View File
@@ -138,7 +138,7 @@ fn knockout_settings_round_trip_and_old_bools_load() {
}
#[test]
fn pdf_and_svg_write_knockout_groups_as_masks() {
fn pdf_writes_knockout_groups_and_svg_writes_them_as_masks() {
let mut s = session();
let g = knockout_group(&mut s);
let plain = s.doc().unwrap().doc.clone();
@@ -149,10 +149,11 @@ fn pdf_and_svg_write_knockout_groups_as_masks() {
let r = pdf(&plain);
assert!(!String::from_utf8_lossy(&r.bytes).contains("/SMask") && r.warnings.is_empty());
let r = pdf(&ko);
let text = String::from_utf8_lossy(&r.bytes);
// The lower rectangle is drawn through a luminosity mask made of the upper one's alpha.
assert!(text.contains("/Luminosity") && text.contains("/Alpha"), "soft masks");
assert!(r.warnings.iter().any(|w| w.contains("knockout")), "{:?}", r.warnings);
let text = String::from_utf8_lossy(&r.bytes).replace(' ', "");
// The rectangles knock out the area they paint: a PDF knockout group, without soft masks.
assert_eq!(text.matches("/Ktrue").count(), 1, "a knockout group");
assert!(!text.contains("/Luminosity"), "no soft masks");
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let svg = |d: &Document| vectorcraft_svg::export(d, &Default::default());
assert!(!svg(&plain).contains("knockout"));
@@ -173,5 +174,5 @@ fn pdf_and_svg_write_knockout_groups_as_masks() {
assert!(svg(&page).contains("isolation"));
page.page_knockout = true;
assert_eq!(svg(&page).matches("mask=\"url(#knockout-").count(), 1, "the layer is neutral: its objects knock each other out");
assert!(String::from_utf8_lossy(&pdf(&page).bytes).contains("/Luminosity"));
assert!(String::from_utf8_lossy(&pdf(&page).bytes).replace(' ', "").contains("/Ktrue"));
}
+72
View File
@@ -849,3 +849,75 @@ fn journal_entries_pass_the_check() {
assert!(check_params(id, params).is_ok(), "{id} {params}");
}
}
/// An index of a path, type, panel, library, style or ruler command given as something other than a
/// whole number of 0 or more (`"3"`, `2.5`, `-1`, `true`, `[]`, `{}`) is refused as the wrong type,
/// naming its key. A required one is not reported as missing, and an optional one (`subpath` of
/// `path.appendAnchor`, `path.close` and `path.reverse`, `artboard` of the ruler origin commands) is
/// not read as its default. A required one left out or null is still missing, and an optional one left
/// out or null keeps its default. Neither changes the document or records an undo step.
#[test]
fn a_malformed_index_is_not_missing_or_its_default() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("path.create", &json!({"d": "M0 0 L10 0 M20 0 L30 0"})).unwrap()["id"].clone();
s.execute("library.create", &json!({"name": "Brand"})).unwrap();
s.execute("library.createGroup", &json!({"name": "Warm"})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let with = |base: &serde_json::Value, key: &str, v: Option<serde_json::Value>| {
let mut p = base.clone();
let o = p.as_object_mut().unwrap();
match v {
Some(v) => o.insert(key.into(), v),
None => o.remove(key),
};
p
};
let refused = |s: &mut Session, cmd: &str, p: &serde_json::Value, want: &str| match s.execute(cmd, p) {
Err(EngineError::BadParams { cmd: c, msg }) => assert_eq!((c.as_str(), msg.as_str()), (cmd, want), "{p}"),
other => panic!("{cmd} {p}: {other:?}"),
};
let malformed = [json!("3"), json!(2.5), json!(-1), json!(true), json!([]), json!({})];
let handle = json!({"id": id, "x": 5, "y": 5});
let required = [
("path.setHandle", handle.clone(), "anchor", "missing anchor"),
("path.moveAnchor", handle.clone(), "anchor", "missing anchor"),
("path.insertAnchor", json!({"id": id}), "segment", "missing segment"),
("text.setPathHandle", handle.clone(), "anchor", "missing anchor"),
("artboard.reorder", json!({"index": 0}), "to", "missing to"),
("library.moveGroup", json!({"group": "Warm"}), "to", "missing `to` (an index)"),
("graphicStyle.move", json!({"name": "Default"}), "to", "missing `to` (an index)"),
];
for (cmd, base, key, missing) in &required {
for v in &malformed {
refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number"));
}
for p in [with(base, key, None), with(base, key, Some(json!(null)))] {
refused(&mut s, cmd, &p, missing);
}
}
let optional = [
("path.appendAnchor", handle.clone(), "subpath"),
("path.close", json!({"id": id}), "subpath"),
("path.reverse", json!({"ids": [id]}), "subpath"),
("document.rulerOrigin", json!({}), "artboard"),
("document.setRulerOrigin", json!({"x": 5, "y": 5}), "artboard"),
("document.resetRulerOrigin", json!({}), "artboard"),
];
for (cmd, base, key) in &optional {
for v in &malformed {
refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number"));
}
}
// A whole number that names no artboard is still out of range.
refused(&mut s, "document.resetRulerOrigin", &json!({"artboard": 1}), "index out of range");
assert_eq!(s.doc().unwrap().doc, doc);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
// Left out or null, an optional one keeps its default.
for (cmd, base, key) in &optional {
for p in [with(base, key, None), with(base, key, Some(json!(null)))] {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
}
}
}
+64 -12
View File
@@ -8321,7 +8321,7 @@ impl ArtBrushOptions {
/// A path with the Calligraphic brush, and one with an Art brush in Tints whose art bends along the
/// path, come back as those paths with the brush on their stroke at its weight, not as their drawn
/// look; a path with the Art brush that keeps its art's colors comes back as its drawn look.
/// look; so does a path with the Art brush that keeps its art's colors.
#[test]
fn brushed_paths_are_read_back_brushed() {
let mut doc = brushes_document();
@@ -8341,8 +8341,63 @@ fn brushed_paths_are_read_back_brushed() {
assert_eq!(art[1].path_data(), ours[1].path_data());
brushed(&mut doc, "Plain ribbon", (20.0, 180.0), (280.0, 180.0), 1.0);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
assert!(matches!(read_back(&doc).layers[0].children().unwrap()[2].kind, NodeKind::Group { .. }));
assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[2];
assert!(matches!(n.kind, NodeKind::Path { .. }), "{n:#?}");
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Plain ribbon"));
}
/// Art brushes at another width, flipped along or across, scaled proportionately or stretched
/// between guides (flipped along too, or at guides the written numbers round) are written with those
/// options, and a path with each comes back as the path with the brush on its stroke, except Scale
/// Proportionately's, which is written as its brush's art.
#[test]
fn art_brushes_with_options_are_read_back_with_them() {
use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind};
let thirds = ArtScale::BetweenGuides { start: 1.0 / 3.0, end: 2.0 / 3.0 };
let options: Vec<(&str, ArtBrush)> = vec![
("Wide", ArtBrush { width: 250.0, ..ArtBrush::default() }),
("Along", ArtBrush { flip_along: true, ..ArtBrush::default() }),
("Across", ArtBrush { flip_across: true, ..ArtBrush::default() }),
("Proportional", ArtBrush { scale: ArtScale::Proportional, width: 50.0, ..ArtBrush::default() }),
("Guides", ArtBrush { scale: ArtScale::BetweenGuides { start: 0.25, end: 0.5 }, ..ArtBrush::default() }),
("Guides along", ArtBrush { scale: ArtScale::BetweenGuides { start: 0.25, end: 0.5 }, flip_along: true, ..ArtBrush::default() }),
("Thirds", ArtBrush { scale: thirds, flip_along: true, ..ArtBrush::default() }),
];
let lib: Vec<Brush> = options
.iter()
.map(|(name, a)| Brush { name: (*name).into(), kind: BrushKind::Art(ArtBrush { art: brush_art_node(), ..a.clone() }) })
.collect();
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &lib);
for (i, b) in lib.iter().enumerate() {
let y = 20.0 + 25.0 * i as f64;
brushed(&mut doc, &b.name, (20.0, y), (280.0, y), 1.0);
}
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = read_back(&doc);
let got = vectorcraft_brush::library(&back);
assert_eq!(got.len(), lib.len());
for (g, w) in got.iter().zip(&lib) {
let (BrushKind::Art(g), BrushKind::Art(w)) = (&g.kind, &w.kind) else { panic!("art") };
if w.scale == thirds {
let ArtScale::BetweenGuides { start, end } = g.scale else { panic!("guides: {g:?}") };
assert!((start - 1.0 / 3.0).abs() < 1e-6 && (end - 2.0 / 3.0).abs() < 1e-6, "{g:?}");
assert_eq!(ArtBrushOptions::of(&ArtBrush { scale: thirds, ..g.clone() }), ArtBrushOptions::of(w));
} else {
assert_eq!(ArtBrushOptions::of(g), ArtBrushOptions::of(w));
}
}
for (n, b) in back.layers[0].children().unwrap().iter().zip(&lib) {
if b.name == "Proportional" {
assert!(matches!(n.kind, NodeKind::Group { .. }), "{}: the brush's art: {n:#?}", b.name);
continue;
}
assert!(matches!(n.kind, NodeKind::Path { .. }), "{}: {n:#?}", b.name);
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some(b.name.as_str()));
}
}
/// A compound path with the Calligraphic brush comes back as that compound path with the brush on its
@@ -8646,8 +8701,8 @@ fn brushes_write_the_same_bytes_again() {
/// beside a brush that comes back, and each changed from a brush that comes back by the one setting
/// that leaves it out: an Art brush whose art is a group with a fill of its own, a Scatter brush whose
/// art is a path without points, an Art brush whose art is a group without members, Pattern and Bristle
/// brushes, Art brushes flipped along or across, overlapping, at another width, with a width that
/// varies, in another direction or scale, Art and Scatter brushes in Tints and Shades or Hue Shift, a
/// brushes, Art brushes overlapping, with a width that varies, in another direction or with guides
/// that leave no art between them, Art and Scatter brushes in Tints and Shades or Hue Shift, a
/// brush whose name holds a slash and a second brush of a name. A brush left out doesn't take its
/// name from a later one.
#[test]
@@ -8671,13 +8726,10 @@ fn brushes_the_reader_cant_read_are_left_out() {
("empty group", art_with(&|a| a.art = Node::group(vectorcraft_doc::NodeId(1), vec![]))),
("pattern", BrushKind::Pattern(PatternBrush::default())),
("bristle", BrushKind::Bristle(Bristle::default())),
("flipped along", art_with(&|a| a.flip_along = true)),
("flipped across", art_with(&|a| a.flip_across = true)),
("overlap", art_with(&|a| a.overlap = true)),
("width", art_with(&|a| a.width = 50.0)),
("width varies", art_with(&|a| a.width_mode = Some(Variation::Pressure))),
("direction", art_with(&|a| a.direction = Direction::RightToLeft)),
("proportional", art_with(&|a| a.scale = ArtScale::Proportional)),
("guides with no art between them", art_with(&|a| a.scale = ArtScale::BetweenGuides { start: 0.5, end: 0.5 })),
("art tints and shades", art_with(&|a| a.colorization = Colorization::TintsAndShades)),
("art hue shift", art_with(&|a| a.colorization = hue)),
(
@@ -8887,8 +8939,8 @@ fn an_empty_brushes_panel_comes_back_empty() {
}
/// A path with one of a new document's brushes writes the brushes the reader reads, and keeps its
/// brush. The new document's Art brush with other options, its Pattern brushes and its Bristle
/// brushes are left out with a warning, so the file opens without them.
/// brush. The new document's Pattern brushes and its Bristle brushes are left out with a warning,
/// so the file opens without them.
#[test]
fn a_new_documents_brush_on_a_path_writes_the_brushes() {
let mut doc = Document::new(300.0, 200.0);
@@ -8898,7 +8950,7 @@ fn a_new_documents_brush_on_a_path_writes_the_brushes() {
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[0];
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("3 pt. Round"));
let left_out = ["Arrow", "Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"];
let left_out = ["Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"];
let want: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).filter(|n| !left_out.contains(n)).collect();
let lib = vectorcraft_brush::library(&back);
assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), want);
+67 -33
View File
@@ -23,7 +23,7 @@ use super::{Writer, Xy};
use crate::ai_ops;
use crate::ps;
pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't stretch their art along the path from left to right at 100% unflipped without overlap, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps";
pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't run their art from left to right, that overlap or whose width follows the pen, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps";
/// Most brushes written (the reader's limit).
const MAX_BRUSHES: usize = 4096;
@@ -145,22 +145,71 @@ fn scatter_params(s: &Scatter, art: &str) -> Option<String> {
Some(format!("2 /{art}/ 5 0 0 0 / {}", nums6(&numbers)))
}
/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read
/// (it reads an Art brush that stretches its art along the path from left to right at 100%,
/// unflipped, without overlap, in Tints or keeping its colors).
fn art_params(a: &ArtBrush, art: &str) -> Option<String> {
let plain = a.direction == Direction::LeftToRight
&& matches!(a.scale, ArtScale::Stretch)
&& a.width == 100.0
&& a.width_mode.is_none()
&& !a.flip_along
&& !a.flip_across
&& !a.overlap;
if !plain {
/// An Art brush's settings as its parameters' numbers 2 to 15 give them and a brushed stroke's filter
/// holds them ([`ART_SETTINGS`]): `None` for options the reader doesn't read (it reads an Art brush
/// that runs its art from left to right without overlap, at a fixed width, in Tints or keeping its
/// colors). The guides of Stretch Between Guides are the lengths of the art before the first and
/// after the second, measured before the art is flipped along the path.
fn art_settings(a: &ArtBrush) -> Option<[f64; 14]> {
if a.direction != Direction::LeftToRight || a.width_mode.is_some() || a.overlap {
return None;
}
let c = colorization_code(a.colorization)?;
let numbers = [1.0, c, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
let length = a.art.geometric_bounds().map_or(0.0, |b| b.width());
let (proportional, segmented, before, after) = match a.scale {
ArtScale::Stretch => (0.0, 0.0, 0.0, 0.0),
ArtScale::Proportional => (1.0, 0.0, 0.0, 0.0),
ArtScale::BetweenGuides { start, end } if start < end => {
let (before, after) = (start * length, (1.0 - end) * length);
let (before, after) = if a.flip_along { (after, before) } else { (before, after) };
(0.0, 1.0, before, after)
}
ArtScale::BetweenGuides { .. } => return None,
};
let scaling = a.width / 100.0;
let flip = |on: bool| f64::from(u8::from(on));
Some([
colorization_code(a.colorization)?,
scaling,
0.0,
1.0,
flip(a.flip_along),
flip(a.flip_across),
proportional,
scaling,
scaling,
0.0,
segmented,
before,
after,
1.0,
])
}
/// The entries of a brushed stroke's filter that hold an Art brush's settings, in the order of
/// [`art_settings`], with how each is written.
const ART_SETTINGS: [(&str, &str); 14] = [
("Colorization", "Int"),
("Scaling", "Real"),
("Spacing", "Real"),
("Adjustment", "Int"),
("Counterclockwise", "Int"),
("UpsideDown", "Int"),
("Proportional", "Bool"),
("MinScaling", "Real"),
("MaxScaling", "Real"),
("ScaleYDependsOn", "Int"),
("Segmented", "Bool"),
("StartSegmentLength", "Real"),
("EndSegmentLength", "Real"),
("FoldAdjustment", "Bool"),
];
/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read
/// ([`art_settings`]).
fn art_params(a: &ArtBrush, art: &str) -> Option<String> {
let settings = art_settings(a)?;
let mut numbers = vec![1.0];
numbers.extend(settings);
Some(format!("2 / {art}/ / / / / 0 0/ 5 0 0 0 / {}", nums6(&numbers)))
}
@@ -458,25 +507,10 @@ impl Writer<'_> {
d.push_str("0 /Int (AngleRelativeTo) ,\n");
"(Calligraphic Brush) /Title ,\n"
}
BrushKind::Art(_) => {
BrushKind::Art(a) => {
d.push_str("15 /Int (Adobe Effect Expand Before Version) ,\n"); // brand-ok: the entry's name in the files
for (key, value, kind) in [
("StartSegmentLength", "0", "Real"),
("EndSegmentLength", "0", "Real"),
("Counterclockwise", "0", "Int"),
("MinScaling", "1", "Real"),
("MaxScaling", "1", "Real"),
("Colorization", "1", "Int"),
("Spacing", "0", "Real"),
("Adjustment", "1", "Int"),
("Proportional", "0", "Bool"),
("Segmented", "0", "Bool"),
("UpsideDown", "0", "Int"),
("FoldAdjustment", "1", "Bool"),
("ScaleYDependsOn", "0", "Int"),
("Scaling", "1", "Real"),
] {
let _ = writeln!(d, "{value} /{kind} ({key}) ,");
for ((key, kind), value) in ART_SETTINGS.iter().zip(art_settings(a)?) {
let _ = writeln!(d, "{} /{kind} ({key}) ,", nums6(&[value]));
}
"(Art Brush) /Title ,\n"
}
+135 -32
View File
@@ -36,7 +36,13 @@
//! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the
//! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the
//! rotation follows the path (14), and 1 Tints (1).
//! - Art: 1 Tints (2).
//! - Art: numbers 2 to 15 are the settings a path's art style holds as entries ([`ArtSettings`]):
//! the colorization (2: 0 None, 1 Tints; other codes color the art otherwise), the width as a factor
//! (3), `Spacing` (4), `Adjustment` (5), flipped along (6) and across (7), Scale Proportionately
//! (8), the lowest and highest width when the width follows the pen (9, 10, not drawn otherwise),
//! how the width varies (11, 0 Fixed), Stretch Between Guides (12) and the lengths of the art
//! before its first guide and after its second (13, 14, in the art's units, measured before
//! the art is flipped along the path), and `FoldAdjustment` (15).
//! - Pattern: its scale as a factor (3).
//! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle
//! thickness, density, paint opacity and bristle length as fractions and the size in
@@ -45,14 +51,15 @@
//! [`super::read`]), usually in a group after its frame, an unpainted rectangle around it. A frame
//! at the art's bounds isn't kept ([`without_frame`]); a wider one is.
//!
//! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles
//! What isn't read keeps its default: an Art brush's settings that VectorCraft doesn't draw as the
//! file does ([`art_options`]) apart from its colorization, the pattern brush's tiles
//! (which slot is which tile isn't known) and the colorization of the other brushes, which is left
//! at None so the art keeps its own colors.
use std::collections::{HashMap, HashSet};
use kurbo::Affine;
use vectorcraft_brush::{ArtBrush, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
use vectorcraft_brush::{ArtBrush, ArtScale, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
@@ -118,13 +125,13 @@ pub(super) enum DraftKind {
brush: Scatter,
art: String,
},
/// The art is the section named `art`. `plain`: the brush stretches its art along the path,
/// unflipped, at 100% in Tints (see [`art_is_plain`]), so [`Self::Art`]'s `brush` draws as the
/// file's brush does.
/// The art is the section named `art`. `settings`: the brush's settings when [`art_options`]
/// reads them, so [`Self::Art`]'s `brush` draws as the file's brush does (its guides set from
/// its art's length by [`finish_brushes`]).
Art {
brush: ArtBrush,
art: String,
plain: bool,
settings: Option<ArtSettings>,
},
/// The five art sections the brush names, in its order (which is which tile isn't known), and
/// its scale (%).
@@ -226,12 +233,17 @@ pub(super) fn calligraphic(values: [f64; 3], variation: [f64; 3], codes: [f64; 3
Calligraphic { angle, roundness, size, variation, modes: Some(modes) }
}
/// An Art brush's settings: its object's numbers 2 to 15, which a path's art style holds as the
/// entries `Colorization`, `Scaling`, `Spacing`, `Adjustment`, `Counterclockwise`, `UpsideDown`,
/// `Proportional`, `MinScaling`, `MaxScaling`, `ScaleYDependsOn`, `Segmented`,
/// `StartSegmentLength`, `EndSegmentLength` and `FoldAdjustment`, in that order.
pub(super) type ArtSettings = [f64; 14];
/// The settings a path's art style gives its stroke's brush.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum PathBrush {
Calligraphic(Calligraphic),
/// An Art brush whose art stretches along the path, at the brush's own width, in Tints.
Art,
Art(ArtSettings),
}
/// Does an Art brush draw `art` as the file does? It bends paths in solid colors: other objects
@@ -250,8 +262,9 @@ pub(super) fn art_bends(art: &Node) -> bool {
bends
}
/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does.
pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap<String, Node>) -> HashSet<String> {
/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does, with the
/// length of each (its width, the art running left to right).
pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap<String, Node>) -> HashMap<String, f64> {
let names: HashSet<&String> = drafts
.iter()
.filter_map(|d| match &d.kind {
@@ -259,13 +272,15 @@ pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap<String, Node>) ->
_ => None,
})
.collect();
names.into_iter().filter(|n| art.get(*n).is_some_and(art_bends)).cloned().collect()
names.into_iter().filter_map(|n| art.get(n).filter(|a| art_bends(a)).and_then(|a| a.geometric_bounds()).map(|b| (n.clone(), b.width()))).collect()
}
/// Does the brush `draft` draw as `settings`, the settings a path's art style gives it? A path whose
/// settings differ (Options of Selected Object) keeps its drawn look: a stroke names a brush, and
/// has no settings of its own. `bending`: [`bending_art`].
pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashSet<String>) -> bool {
/// has no settings of its own. An Art brush's settings that don't draw ([`drawn`]) may differ, and
/// the path's own settings must be ones the brush would open with ([`art_options`], [`opens_live`]).
/// `bending`: [`bending_art`].
pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashMap<String, f64>) -> bool {
let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0);
match (&draft.kind, settings) {
(DraftKind::Calligraphic(d), PathBrush::Calligraphic(c)) => {
@@ -275,16 +290,90 @@ pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashS
&& d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b))
&& d.modes() == c.modes()
}
(DraftKind::Art { plain: true, art: name, .. }, PathBrush::Art) => bending.contains(name),
(DraftKind::Art { settings: Some(d), art: name, .. }, PathBrush::Art(s)) => {
let (dd, ds) = (drawn(d), drawn(s));
art_options(s).is_some()
&& bending.get(name).is_some_and(|length| {
// The guides match as lengths and as fractions of the art's length, so they differ
// neither on short art nor near the ends of long art.
dd.iter().zip(ds).all(|(a, b)| close(*a, b))
&& [11, 12].iter().all(|&i| close(dd[i] / length, ds[i] / length))
&& opens_live(d, *length)
&& opens_live(s, *length)
})
}
_ => false,
}
}
/// Are an Art brush's last 15 numbers those of a brush that stretches its art along the path,
/// unflipped, at 100% in Tints? The numbers aren't read otherwise: any other value keeps its paths'
/// drawn look.
fn art_is_plain(numbers: &[f64; 15]) -> bool {
*numbers == [1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]
/// Art brush settings `s` with those that don't draw set to 0: the lowest and highest width while the
/// width is fixed, and the guides' lengths without Stretch Between Guides.
fn drawn(s: &ArtSettings) -> ArtSettings {
let mut out = *s;
if s[9] == 0.0 {
(out[7], out[8]) = (0.0, 0.0);
}
if s[10] == 0.0 {
(out[11], out[12]) = (0.0, 0.0);
}
out
}
/// What an Art brush's settings give VectorCraft's Art brush, its guides apart ([`guides`]): `None`
/// when VectorCraft doesn't draw them as the file does: a colorization other than None or Tints, a
/// width outside [`ArtBrush::WIDTH_RANGE`] or one that varies, both Scale Proportionately and Stretch
/// Between Guides, a flip or scale that isn't 0 or 1, or a `Spacing`, `Adjustment` or
/// `FoldAdjustment` other than 0, 1 and 1 (what other values change isn't known).
fn art_options(s: &ArtSettings) -> Option<ArtBrush> {
let [colorization, scaling, spacing, adjustment, along, across, proportional, _, _, varies, segmented, _, _, fold] = *s;
let switch = |v: f64| match v {
0.0 => Some(false),
1.0 => Some(true),
_ => None,
};
let colorization = match colorization {
0.0 => Colorization::None,
1.0 => Colorization::Tints,
_ => return None,
};
let width = scaling * 100.0;
let (low, high) = ArtBrush::WIDTH_RANGE;
if !(low..=high).contains(&width) || varies != 0.0 || spacing != 0.0 || adjustment != 1.0 || fold != 1.0 {
return None;
}
let scale = match (switch(proportional)?, switch(segmented)?) {
(false, false) => ArtScale::Stretch,
(true, false) => ArtScale::Proportional,
(false, true) => ArtScale::BetweenGuides { start: 0.0, end: 1.0 },
(true, true) => return None,
};
Some(ArtBrush { scale, width, flip_along: switch(along)?, flip_across: switch(across)?, colorization, ..ArtBrush::default() })
}
/// The guides of Art brush settings `s` (with Stretch Between Guides) for art of length `length`, as
/// [`ArtScale::BetweenGuides`] fractions of the art as it runs along the path: `None` when they leave
/// no art between them. The settings measure the art before it is flipped along the path.
fn guides(s: &ArtSettings, length: f64) -> Option<(f64, f64)> {
let (before, after) = (s[11], s[12]);
if !(before >= 0.0 && after >= 0.0 && before + after < length) {
return None;
}
let (before, after) = if s[4] == 1.0 { (after, before) } else { (before, after) };
Some((before / length, 1.0 - after / length))
}
/// Do Art brush settings `s` draw art of length `length`: no Stretch Between Guides, or guides with
/// art between them?
fn guides_fit(s: &ArtSettings, length: f64) -> bool {
s[10] == 0.0 || guides(s, length).is_some()
}
/// Does a path with Art brush settings `s`, its art of length `length`, open with the brush on its
/// stroke? Its guides must fit ([`guides_fit`]). Scale Proportionately keeps the drawn look:
/// VectorCraft's Art brush sizes proportional art by the path's length and not by the stroke's
/// weight.
fn opens_live(s: &ArtSettings, length: f64) -> bool {
s[6] == 0.0 && guides_fit(s, length)
}
/// The brush named `name` of kind `kind` from its parameters `params`.
@@ -342,15 +431,22 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
Tool::Art => {
// The older form's 8 numbers aren't known: its brush keeps the defaults and its
// paths their drawn look. The count tells the form, not the first number.
// Any other count isn't a form this reads.
let (colorization, plain) = if let Ok(n15) = <&[f64; 15]>::try_from(n.as_slice()) {
(if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15))
// Any other count isn't a form this reads. Settings that aren't read keep the
// defaults but the colorization, and their paths their drawn look.
let (brush, settings) = if let Ok(&[first, ref settings @ ..]) = <&[f64; 15]>::try_from(n.as_slice()) {
match art_options(settings).filter(|_| first == 1.0) {
Some(brush) => (brush, Some(*settings)),
None => {
let colorization = if settings[0] == 1.0 { Colorization::Tints } else { Colorization::None };
(ArtBrush { colorization, ..ArtBrush::default() }, None)
}
}
} else if n.len() == 8 {
(ArtBrush::default().colorization, false)
(ArtBrush::default(), None)
} else {
return Err(BrushError::Values);
};
DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain }
DraftKind::Art { brush, art: art()?, settings }
}
_ => {
let [_, _, p3, ..] = n.as_slice() else { return Err(BrushError::Values) };
@@ -453,10 +549,15 @@ pub(super) fn finish_brushes_within(
_ if !kept => None,
DraftKind::Calligraphic(c) => Some(BrushKind::Calligraphic(c)),
DraftKind::Scatter { brush, art: name } => copy(&name).map(|a| BrushKind::Scatter(Scatter { art: a, ..brush })),
DraftKind::Art { brush, art: name, .. } => copy(&name).map(|mut a| {
DraftKind::Art { mut brush, art: name, settings } => copy(&name).map(|mut a| {
// The side of the section with y up draws on the right of the path, and a brush
// draws art's y (down here) on the left of the path.
a.transform(Affine::scale_non_uniform(1.0, -1.0), false);
if let ArtScale::BetweenGuides { start, end } = &mut brush.scale {
// Guides that leave no art between them stretch the whole art.
let length = a.geometric_bounds().map_or(0.0, |b| b.width());
(*start, *end) = settings.and_then(|s| guides(&s, length)).unwrap_or((0.0, 1.0));
}
BrushKind::Art(ArtBrush { art: a, ..brush })
}),
DraftKind::Pattern { .. } => None,
@@ -586,8 +687,8 @@ pub(crate) struct BrushRead {
pub brush: Brush,
/// A path whose art style names the brush, at the brush's own settings, opens with the brush on
/// its stroke: a Calligraphic brush, or an Art brush whose art an Art brush draws as the file
/// does ([`art_bends`]) and whose numbers are those of a brush that stretches its art along the
/// path, unflipped, at 100% in Tints.
/// does ([`art_bends`]) and whose settings VectorCraft draws as the file does ([`art_options`],
/// with guides that leave art between them and without Scale Proportionately: [`opens_live`]).
pub live: bool,
}
@@ -604,7 +705,9 @@ pub(crate) fn read_brush_object(plugin: &str, name: &str, params: &str, art: Opt
let live = kept(&drafts, &sections).first().copied().unwrap_or(false)
&& match &drafts[0].kind {
DraftKind::Calligraphic(_) => true,
DraftKind::Art { plain: true, art: section, .. } => bending_art(&drafts, &sections).contains(section),
DraftKind::Art { settings: Some(s), art: section, .. } => {
bending_art(&drafts, &sections).get(section).is_some_and(|length| opens_live(s, *length))
}
_ => false,
};
let [draft] = drafts;
@@ -643,8 +746,8 @@ pub(crate) struct BrushesRead {
/// The brush a stroke that names a brush is drawn with, by name: the first one kept
/// ([`kept`]), as an index in [`Self::drafts`].
by_name: HashMap<String, usize>,
/// The art an Art brush draws as the file does ([`bending_art`]).
bending: HashSet<String>,
/// The art an Art brush draws as the file does, with its length ([`bending_art`]).
bending: HashMap<String, f64>,
}
impl BrushesRead {
@@ -681,7 +784,7 @@ impl BrushesRead {
st.items.iter().all(|item| match item {
StyleItem::Stroke { brush: Some((name, settings)), .. } => {
let Some(draft) = self.by_name.get(name).and_then(|i| self.drafts.get(*i)) else { return false };
draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art) && !st.effects.is_empty())
draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art(_)) && !st.effects.is_empty())
}
_ => true,
})
+2 -2
View File
@@ -438,8 +438,8 @@ struct BrushesKept {
read: (usize, usize),
/// Which of [`Reader::brush_drafts`] are kept as brushes.
kept: Vec<bool>,
/// The art an Art brush draws as the file does.
bending: HashSet<String>,
/// The art an Art brush draws as the file does, with its length.
bending: HashMap<String, f64>,
}
struct Reader<'a> {
+1 -1
View File
@@ -1181,7 +1181,7 @@ mod tests {
assert!(nodes > 0);
let draft = |i: usize| brush::BrushDraft {
name: format!("Brush {i}"),
kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), plain: false },
kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), settings: None },
};
let drafts: Vec<_> = (0..5).map(draft).collect();
let (kept, left_out) = brush::finish_brushes_within(drafts.clone(), &reader.brush_art, None, 3 * nodes);
+5 -5
View File
@@ -992,8 +992,8 @@ struct StylePaints<'a> {
brushes: &'a [super::super::brush::BrushDraft],
/// Which of [`Self::brushes`] are kept as brushes ([`super::super::brush::kept`]).
kept: &'a [bool],
/// The art an Art brush draws as the file does ([`super::super::brush::bending_art`]).
bending: &'a HashSet<String>,
/// The art an Art brush draws as the file does, with its length ([`super::super::brush::bending_art`]).
bending: &'a HashMap<String, f64>,
space: Affine,
}
@@ -1270,7 +1270,7 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
if !super::super::brush::draws_as(draft, settings, paints.bending) {
return None;
}
let art = matches!(settings, super::super::brush::PathBrush::Art);
let art = matches!(settings, super::super::brush::PathBrush::Art(_));
let on_a_path = matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
if !on_a_path && (art || !slot) {
return None;
@@ -1570,7 +1570,7 @@ mod tests {
let st = &styles["Inked"];
let brushes = [parse_tool_object(Tool::Calligraphic, "N", "1 4 8 30 50 -150 210 2 5 6 0").unwrap()];
let (gradients, patterns, names, v7_patterns, bending) =
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new());
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashMap::new());
let paints = StylePaints {
gradients: &gradients,
patterns: &patterns,
@@ -1616,7 +1616,7 @@ mod tests {
// GUARDS: an image with a Warp in its art style came in as its drawn look.
let warp = ("Adobe Deform", "1 /Int (DeformStyle) ,\n0.4 /Real (DeformValue) ,\n"); // brand-ok: the filter's name in the files
let (gradients, patterns, names, v7_patterns, bending) =
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new());
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashMap::new());
let paints = StylePaints {
gradients: &gradients,
patterns: &patterns,
+29 -25
View File
@@ -1500,11 +1500,30 @@ const ART_ENTRIES: [&str; 18] = [
"Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files
];
/// An Art brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a color, names
/// no Art brush, holds an entry that isn't read, or its settings aren't those of art that stretches
/// along the path, unflipped, at the brush's own width (`Scaling` 1), in Tints (`Colorization` 1),
/// with `FoldAdjustment` 1, which is [`vectorcraft_brush::ArtBrush`]'s `overlap: false`: its drawn
/// look stays.
/// The entries of an Art brush's filter that hold its settings, in the order of
/// [`super::brush::ArtSettings`], with how each is written and its value when the filter leaves it
/// out (`None`: the filter must hold it).
const ART_SETTINGS: [(&str, Kind, Option<f64>); 14] = [
("Colorization", Kind::Int, None),
("Scaling", Kind::Real, None),
("Spacing", Kind::Real, Some(0.0)),
("Adjustment", Kind::Int, Some(1.0)),
("Counterclockwise", Kind::Int, Some(0.0)),
("UpsideDown", Kind::Int, Some(0.0)),
("Proportional", Kind::Bool, Some(0.0)),
("MinScaling", Kind::Real, Some(1.0)),
("MaxScaling", Kind::Real, Some(1.0)),
("ScaleYDependsOn", Kind::Int, Some(0.0)),
("Segmented", Kind::Bool, Some(0.0)),
("StartSegmentLength", Kind::Real, Some(0.0)),
("EndSegmentLength", Kind::Real, Some(0.0)),
("FoldAdjustment", Kind::Bool, Some(1.0)),
];
/// An Art brush's filter `f` as the stroke it paints, with its settings: `None` when it isn't a
/// stroke in a color, names no Art brush, holds an entry that isn't read or a setting that isn't a
/// number of its kind, or tints its art with a swatch's color: its drawn look stays. Whether the
/// brush draws as these settings is [`super::brush::draws_as`]'s to say.
fn art_stroke(f: &Filter) -> Option<StyleItem> {
if !f.paints.0
|| f.gradient
@@ -1519,28 +1538,13 @@ fn art_stroke(f: &Filter) -> Option<StyleItem> {
if plugin != ART_BRUSH {
return None;
}
let off = |key: &str, kind: Kind| matches!(entry(f, key, kind), Ok(None | Some(0.0)));
let plain = off("Proportional", Kind::Bool)
&& off("Segmented", Kind::Bool)
&& off("UpsideDown", Kind::Int)
&& off("Counterclockwise", Kind::Int)
&& off("ScaleYDependsOn", Kind::Int)
&& off("Spacing", Kind::Real)
&& off("StartSegmentLength", Kind::Real)
&& off("EndSegmentLength", Kind::Real);
if !plain || entry(f, "Adjustment", Kind::Int).ok()?.unwrap_or(1.0) != 1.0 || entry(f, "FoldAdjustment", Kind::Bool).ok()?.unwrap_or(1.0) != 1.0 {
return None;
}
let one = |key: &str| entry(f, key, Kind::Real).is_ok_and(|v| v.unwrap_or(1.0) == 1.0);
if entry(f, "Scaling", Kind::Real).ok()?? != 1.0 || !one("MinScaling") || !one("MaxScaling") {
return None;
}
if entry(f, "Colorization", Kind::Int).ok()?? != 1.0 {
return None;
let mut settings = [0.0; 14];
for (slot, (key, kind, missing)) in settings.iter_mut().zip(ART_SETTINGS) {
*slot = entry(f, key, kind).ok()?.or(missing)?;
}
let paint = f.stroke.clone()?;
// The tinted pieces are process colors, so a swatch's ink would be lost.
if matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
if settings[0] == 1.0 && matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
return None;
}
Some(StyleItem::Stroke {
@@ -1552,7 +1556,7 @@ fn art_stroke(f: &Filter) -> Option<StyleItem> {
dash: None,
profile: None,
align: StrokeAlign::Center,
brush: Some((name.clone(), PathBrush::Art)),
brush: Some((name.clone(), PathBrush::Art(settings))),
})
}
+137 -23
View File
@@ -9797,7 +9797,7 @@ fn a_pattern_brushs_five_slots_are_kept() {
fn an_older_art_brush_comes_in_with_its_art() {
const OLD_RIBBON: &str = "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0";
let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", OLD_RIBBON).unwrap();
assert!(matches!(d.kind, brush::DraftKind::Art { ref art, plain: false, .. } if art == "Ribbon art"), "{d:?}");
assert!(matches!(d.kind, brush::DraftKind::Art { ref art, settings: None, .. } if art == "Ribbon art"), "{d:?}");
let brush::DraftKind::Art { ref brush, .. } = d.kind else { panic!("art") };
assert_eq!(brush.colorization, vectorcraft_brush::ArtBrush::default().colorization, "the default colorization");
// A count of numbers neither form has isn't read.
@@ -9807,8 +9807,8 @@ fn an_older_art_brush_comes_in_with_its_art() {
assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"), Err(brush::BrushError::Values));
// 8 numbers are the older form whatever the first number says: read with the defaults.
let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", "0 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0").unwrap();
let brush::DraftKind::Art { ref brush, ref art, plain } = d.kind else { panic!("art") };
assert_eq!((art.as_str(), plain, brush.colorization), ("Ribbon art", false, vectorcraft_brush::ArtBrush::default().colorization));
let brush::DraftKind::Art { ref brush, ref art, settings } = d.kind else { panic!("art") };
assert_eq!((art.as_str(), settings, brush.colorization), ("Ribbon art", None, vectorcraft_brush::ArtBrush::default().colorization));
let objects = [plugin_object(ART_BRUSH, "Ribbon", OLD_RIBBON), plugin_object(ART_BRUSH, "Bare", "1 / / / / / / 0 1 / 1 0 1 0 1 0 0 0")].concat();
let r = read_ok(&format!("{}{objects}{}", brush_art("Ribbon art", 4.0), layer("")));
let lib = vectorcraft_brush::library(&r.doc);
@@ -10471,9 +10471,109 @@ fn an_art_brush_bends_only_paths_in_solid_fills() {
assert!(wrong.is_empty(), "{wrong:?}");
}
/// The entries of an Art brush's filter that hold its settings, in the order of its object's numbers 2
/// to 15, with how each is written.
const ART_KEYS: [(&str, &str); 14] = [
("Colorization", "Int"),
("Scaling", "Real"),
("Spacing", "Real"),
("Adjustment", "Int"),
("Counterclockwise", "Int"),
("UpsideDown", "Int"),
("Proportional", "Bool"),
("MinScaling", "Real"),
("MaxScaling", "Real"),
("ScaleYDependsOn", "Int"),
("Segmented", "Bool"),
("StartSegmentLength", "Real"),
("EndSegmentLength", "Real"),
("FoldAdjustment", "Bool"),
];
/// The Art brush `Ribbon` (its art 40 long: [`lopsided_art`]) whose settings, numbers 2 to 15 of its
/// object, are those of [`RIBBON`] with `changes` (index in the 14, value), and the style `Inked` that
/// gives a path the same settings.
fn ribbon_with(changes: &[(usize, &str)]) -> (String, String) {
let mut settings = ["1", "1", "0", "1", "0", "0", "0", "1", "1", "0", "0", "0", "0", "1"];
for (i, v) in changes {
settings[*i] = v;
}
let brush = format!("2 / Ribbon art/ / / / / 0 0/ 5 0 0 0 / 1 {}", settings.join(" "));
let entries: Vec<(&str, &str, &str)> = ART_KEYS.iter().zip(settings).map(|((key, kind), value)| (*key, value, *kind)).collect();
(format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", &brush)), art_style("Ribbon", 1.0, &entries, ""))
}
/// A path with an Art brush whose settings are its own comes in brushed, the brush with those
/// settings: None, another width (the lowest and highest widths aren't drawn while the width is
/// fixed), flipped along or across, and Stretch Between Guides, whose guides come in as fractions of
/// the art as it runs along the path (measured before it is flipped along it, so a flip along swaps
/// them). Lengths without Stretch Between Guides aren't guides. A brush that scales its art
/// proportionately comes into the panel with that option, and its path keeps its drawn look.
#[test]
fn an_art_brush_with_options_comes_in_brushed_with_them() {
use vectorcraft_brush::{ArtBrush, ArtScale, Colorization};
let check_file = |why: &str, (def, style): (String, String), want: &dyn Fn(&ArtBrush) -> bool| {
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
let a = art(&r.doc);
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{why}: the path, not its look: {a:#?}");
assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"), "{why}");
let lib = vectorcraft_brush::library(&r.doc);
let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("{why}: art") };
assert!(want(brush), "{why}: {brush:?}");
r
};
let check = |why: &str, changes: &[(usize, &str)], want: &dyn Fn(&ArtBrush) -> bool| check_file(why, ribbon_with(changes), want);
let near = |a: f64, b: f64| (a - b).abs() < 1e-9;
// Settings that don't draw may differ between the brush and the path: the lowest and highest
// width while the width is fixed, and lengths without Stretch Between Guides.
let (def, style) = ribbon_with(&[(1, "2"), (7, "2"), (8, "2")]);
let style = style.replace("2 /Real (MinScaling) ,\n", "").replace("2 /Real (MaxScaling) ,\n", "");
check_file("the path leaves out the widths the pen would give", (def, style), &|b| b.width == 200.0);
let (def, _) = ribbon_with(&[]);
let (_, style) = ribbon_with(&[(11, "5"), (12, "10")]);
check_file("lengths on the path alone", (def, style), &|b| b.scale == ArtScale::Stretch);
check("its art's colors", &[(0, "0")], &|b| b.colorization == Colorization::None);
check("twice as wide", &[(1, "2"), (7, "2"), (8, "2")], &|b| b.width == 200.0);
check("half as wide, the pen's widths left", &[(1, "0.5")], &|b| b.width == 50.0 && b.width_mode.is_none());
let along = check("flipped along", &[(4, "1")], &|b| b.flip_along && !b.flip_across);
let across = check("flipped across", &[(5, "1")], &|b| b.flip_across && !b.flip_along);
let (def, style) = ribbon_with(&[(6, "1")]);
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }), "proportional: the drawn look");
let lib = vectorcraft_brush::library(&r.doc);
let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("proportional: art") };
assert_eq!(brush.scale, ArtScale::Proportional);
check(
"between guides",
&[(10, "1"), (11, "5"), (12, "10")],
&|b| matches!(b.scale, ArtScale::BetweenGuides { start, end } if near(start, 5.0 / 40.0) && near(end, 30.0 / 40.0)),
);
check("between guides, flipped along", &[(10, "1"), (11, "5"), (12, "10"), (4, "1")], &|b| {
b.flip_along && matches!(b.scale, ArtScale::BetweenGuides { start, end } if near(start, 10.0 / 40.0) && near(end, 35.0 / 40.0))
});
check("lengths without guides", &[(11, "5"), (12, "10")], &|b| b.scale == ArtScale::Stretch);
// Along a straight line, y down: unflipped, the wide block is above the line and the narrow one
// below it, at the start.
let line = kurbo::BezPath::from_vec(vec![kurbo::PathEl::MoveTo(Point::new(0.0, 0.0)), kurbo::PathEl::LineTo(Point::new(100.0, 0.0))]);
let filled = |r: &Structure, x: f64, y: f64| {
let lib = vectorcraft_brush::library(&r.doc);
let st = art(&r.doc)[0].appearance.stroke().cloned().unwrap();
let ribbon = lib.iter().find(|b| b.name == "Ribbon").unwrap();
let pieces = vectorcraft_brush::stroke_pieces(&r.doc, ribbon, &line, &st);
pieces.iter().any(|n| {
let mut hit = false;
n.walk(&mut |c| hit |= c.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), Point::new(x, y)) != 0));
hit
})
};
// At weight 1 the art is 8 across: each block is 4 across, on its side of the line.
assert!(filled(&across, 50.0, 2.0) && filled(&across, 10.0, -2.0) && !filled(&across, 50.0, -2.0), "across: the wide block below");
assert!(filled(&along, 50.0, -2.0) && filled(&along, 90.0, 2.0) && !filled(&along, 10.0, 2.0), "along: the narrow block at the end");
}
/// An Art brushed path keeps its drawn look when the brush wouldn't draw it the way the file does:
/// its settings aren't those of art that stretches along the path at the brush's own width and
/// color, the brush's own aren't, its art isn't read, or the brush isn't one the panel keeps.
/// its settings differ from the brush's, the brush's are ones VectorCraft doesn't draw as the file
/// does, its art isn't read, or the brush isn't one the panel keeps.
#[test]
fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
let good = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
@@ -10516,21 +10616,7 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
),
plain_style.clone(),
),
(
"a width other than the brush's 100%",
format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", wide)),
art_style(
"Ribbon",
1.0,
&STRETCHED.map(|e| if matches!(e.0, "Scaling" | "MinScaling" | "MaxScaling") { (e.0, "2", e.2) } else { e }),
"",
),
),
(
"a brush that keeps its art's colors",
format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)),
art_style("Ribbon", 1.0, &STRETCHED.map(|e| if e.0 == "Colorization" { (e.0, "0", e.2) } else { e }), ""),
),
("a brush that keeps its art's colors, the path in Tints", format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)), plain_style.clone()),
("an entry that isn't read", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "1 /Int (SomeOtherSetting) ,\n")),
(
"an effect on the path",
@@ -10543,17 +10629,16 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
("proportional", good.clone(), style_with("Proportional", "1")),
("between guides", good.clone(), style_with("Segmented", "1")),
("its own width", good.clone(), style_with("Scaling", "2")),
("a width that varies", good.clone(), style_with("MaxScaling", "2")),
("a width on the pen", good.clone(), style_with("ScaleYDependsOn", "2")),
("spacing", good.clone(), style_with("Spacing", "1")),
("flipped", good.clone(), style_with("UpsideDown", "1")),
("a spacing close to the brush's 0", good.clone(), style_with("Spacing", "0.0005")),
("flipped",good.clone(), style_with("UpsideDown", "1")),
("flipped the other way", good.clone(), style_with("Counterclockwise", "1")),
("another adjustment", good.clone(), style_with("Adjustment", "2")),
("no fold adjustment", good.clone(), style_with("FoldAdjustment", "0")),
("its own colorization", good.clone(), style_with("Colorization", "0")),
("tints and shades", good.clone(), style_with("Colorization", "2")),
("hue shift", good.clone(), style_with("Colorization", "3")),
("start of a guide", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "5 /Real (StartSegmentLength) ,\n")),
("variable width", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "2 /Int (BSWidthSource) ,\n")),
(
"width arrays",
@@ -10570,6 +10655,35 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
("art not read", plugin_object(ART_BRUSH, "Ribbon", RIBBON), plain_style.clone()),
("no such brush", good.clone(), art_style("Other", 1.0, &STRETCHED, "")),
];
// The brush's own settings, which the path shares, but VectorCraft doesn't draw as the file does.
let own = [
("both brush and path: tints and shades", &[(0, "2")][..]),
("both: hue shift", &[(0, "3")]),
("both: spacing", &[(2, "1")]),
("both: another adjustment", &[(3, "2")]),
("both: no fold adjustment", &[(13, "0")]),
("both: a width on the pen", &[(9, "1"), (7, "0.5"), (8, "2")]),
("both: a width past the range", &[(1, "20"), (7, "20"), (8, "20")]),
("both: a flip of another value", &[(4, "2")]),
("both: proportional", &[(6, "1")]),
("both: proportional and between guides", &[(6, "1"), (10, "1"), (11, "5"), (12, "10")]),
("both: guides that leave no art between them", &[(10, "1"), (11, "30"), (12, "10")]),
("both: a guide past the start", &[(10, "1"), (11, "-5"), (12, "10")]),
];
let mut cases: Vec<(&str, String, String)> = cases
.into_iter()
.chain(own.iter().map(|(why, changes)| {
let (def, style) = ribbon_with(changes);
(*why, def, style)
}))
.collect();
// The brush's guides leave art between them, and the path's, close to them, leave none.
let (def, style) = ribbon_with(&[(10, "1"), (11, "20"), (12, "19.99")]);
cases.push(("path guides close to the brush's that leave no art between them", def, style.replace("19.99 /", "20 /")));
// On art 0.01 long, a guide at 0.0035 is 35% of the way along it, and the brush's at 0.003 is 30%.
let (def, style) = ribbon_with(&[(10, "1"), (11, "0.003"), (12, "0.002")]);
let short = format!("%AI8_BeginBrushPattern\n(Ribbon art)\n0 A\n0 g\n{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 0.01, 4.0));
cases.push(("path guides elsewhere on short art", def.replace(&lopsided_art("Ribbon art"), &short), style.replace("0.003 /", "0.0035 /")));
let mut wrong = vec![];
for (why, def, style) in cases {
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
+64 -19
View File
@@ -92,6 +92,19 @@ struct ImageCode {
}
impl ImageCode {
/// A hash of everything that is written for the image: equal images have equal keys.
fn content_key(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(&self.prelude, &self.dict, self.filter, self.op, &self.ascii).hash(&mut h);
h.finish()
}
/// Is everything written for `other` the same as for this image?
fn same_image(&self, other: &ImageCode) -> bool {
self.prelude == other.prelude && self.dict == other.dict && self.filter == other.filter && self.op == other.op && self.ascii == other.ascii
}
/// The operators and data that draw the image where it is placed.
fn push_inline(&self, s: &mut String) {
s.push_str(&self.prelude);
@@ -253,6 +266,9 @@ pub(crate) struct Scene<'a> {
images: HashMap<String, Option<usize>>,
/// The source key and solid paint together identify a colorized encoding.
colorized_images: HashMap<(String, String), Option<usize>>,
/// Freeform gradients' images by [`ImageCode::content_key`] → their indices in `codes`: a field
/// encoded the same as one already kept draws from it.
fields: HashMap<u64, Vec<usize>>,
/// The encoded images.
codes: Vec<ImageCode>,
/// Where in `body` each placement of an encoded image goes (in order) and the index of its code.
@@ -281,6 +297,7 @@ impl<'a> Scene<'a> {
brushes: None,
images: HashMap::new(),
colorized_images: HashMap::new(),
fields: HashMap::new(),
codes: vec![],
placements: vec![],
pending: 0,
@@ -478,7 +495,7 @@ impl<'a> Scene<'a> {
_ => {}
}
self.note_losses(n);
let (was, placed) = (self.body.len(), self.placements.len());
let (was, placed, codes) = (self.body.len(), self.placements.len(), self.codes.len());
match &n.kind {
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
children.iter().for_each(|c| self.node(c, false));
@@ -536,6 +553,7 @@ impl<'a> Scene<'a> {
if self.size() > self.max_body {
self.body.truncate(was);
self.unplace(placed);
self.forget_fields(codes);
self.warn(TOO_LARGE);
}
}
@@ -1364,6 +1382,14 @@ impl<'a> Scene<'a> {
if !(long > 1e-9 && long.is_finite()) {
return;
}
let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height());
if !invertible(place) {
return;
}
if self.size().saturating_add(64) > self.max_body {
self.warn(TOO_LARGE);
return;
}
let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long);
let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16;
let (cols, rows) = (along(area.width()), along(area.height()));
@@ -1383,9 +1409,43 @@ impl<'a> Scene<'a> {
let rgba: Vec<u8> = field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect();
self.encode_image(&rgba, w, h)
};
let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height());
if let Some(image) = image {
self.place_inline(&image, place);
let Some(image) = image else { return };
let key = image.content_key();
let found =
self.fields.get(&key).and_then(|bucket| bucket.iter().copied().find(|&i| self.codes.get(i).is_some_and(|c| c.same_image(&image))));
let i = match found {
Some(i) => i,
None => {
// A field that can't be placed isn't kept.
if self.size().saturating_add(image.inline_len).saturating_add(64) > self.max_body {
self.warn(TOO_LARGE);
return;
}
let i = self.add_code(image);
self.fields.entry(key).or_default().push(i);
i
}
};
self.place_image(i, place);
}
/// Forget the field images after the first `n` codes that no placement draws any longer, last
/// first, up to the first code that is drawn or isn't a field image.
fn forget_fields(&mut self, n: usize) {
while self.codes.len() > n {
let i = self.codes.len() - 1;
let Some(code) = self.codes.get(i) else { break };
if code.uses > 0 {
break;
}
let key = code.content_key();
let Some(bucket) = self.fields.get_mut(&key) else { break };
let Some(at) = bucket.iter().position(|&j| j == i) else { break };
bucket.swap_remove(at);
if bucket.is_empty() {
self.fields.remove(&key);
}
self.codes.pop();
}
}
@@ -1595,21 +1655,6 @@ impl<'a> Scene<'a> {
self.line("Q");
}
/// Draw `code`, an image drawn nowhere else, on the unit square mapped by `place`.
fn place_inline(&mut self, code: &ImageCode, place: Affine) {
if !invertible(place) {
return;
}
if self.size().saturating_add(code.inline_len).saturating_add(64) > self.max_body {
self.warn(TOO_LARGE);
return;
}
self.line("q");
let _ = writeln!(self.body, "{} cm", ps::matrix(place));
code.push_inline(&mut self.body);
self.line("Q");
}
/// Straight RGBA pixels (`w` × `h`, rows from the top) as the operators and data that draw
/// them on the unit square: in the process colour space, transparent pixels masked out at
/// Level 3 (a key colour no opaque pixel has), white at Level 2; partly transparent ones over
+98
View File
@@ -1264,6 +1264,104 @@ fn the_page_limit_counts_a_repeated_images_samples_once() {
assert!(page.body.len() + page.setup.len() <= inline + 1000 + 400, "{}", page.body.len());
}
/// A freeform gradient of `points` (x, y and color).
fn freeform(points: &[(f64, f64, Color)]) -> Paint {
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: points.iter().map(|&(x, y, c)| FreeformPoint::new(Point::new(x, y), c)).collect(), ..Default::default() });
Paint::Gradient(Box::new(g))
}
/// A square at (`x`, `y`) of side `side`, filled with a freeform gradient of `a` and `b` at a
/// fifth and four fifths of its width.
fn freeform_square(d: &mut Document, x: f64, y: f64, side: f64, a: Color, b: Color) -> Node {
let mid = y + side / 2.0;
let paint = freeform(&[(x + side / 5.0, mid, a), (x + side * 0.8, mid, b)]);
path(d, shapes::rectangle(Rect::new(x, y, x + side, y + side)), paint, Paint::None, 0.0)
}
/// A freeform gradient on a shape and on a moved copy of it is sampled the same for both: its
/// image's samples are written once, at both levels, in RGB and in CMYK.
#[test]
fn a_freeform_gradients_image_drawn_twice_writes_its_samples_once() {
let rgb = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let cmyk = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
for (a, b) in [rgb, cmyk] {
let single = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, a, b)]);
let twice = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, a, b), freeform_square(d, 64.0, 64.0, 64.0, a, b)]);
for level in Level::ALL {
let one = export(&single, &EpsOptions { level, ..opts(&single) }, None, None).unwrap();
let one_ps = text(&one);
assert!(!one_ps.contains("VCim"), "a field drawn once is written where it is drawn: {one_ps}");
let out = export(&twice, &EpsOptions { level, ..opts(&twice) }, None, None).unwrap();
let ps = text(&out);
checked_page(&ps);
assert_eq!(reuses(&ps, 0, level), 2, "{ps}");
assert!(!ps.contains("VCim1"), "{ps}");
let body = &ps[ps.find("%%EndSetup").unwrap()..];
assert!(!body.contains("currentfile") && !body.contains("~>"), "no samples in the page: {ps}");
assert_eq!(reused_samples(&ps, level), inline_samples(&one_ps, level), "{level:?}");
assert_eq!(out.warnings, one.warnings);
}
}
}
/// Fields sampled differently are each written where they are drawn: different gradients, and one
/// gradient on shapes of different sizes.
#[test]
fn freeform_gradients_sampled_differently_dont_share_an_image() {
let (red, blue, green) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0));
let colors = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, green)]);
let sizes = doc_with(|d| {
let paint = freeform(&[(20.0, 50.0, red), (80.0, 50.0, blue)]);
vec![
path(d, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint.clone(), Paint::None, 0.0),
path(d, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 100.0)), paint, Paint::None, 0.0),
]
});
for d in [colors, sizes] {
for level in Level::ALL {
let ps = page_of(&d, &EpsOptions { level, ..opts(&d) });
assert!(!ps.contains("VCim"), "{ps}");
assert_eq!(ps.matches("} exec\n").count(), 2, "{ps}");
}
}
}
/// A field's second placement that passes the page limit is left out, and the first is written
/// where it is drawn. A field taken back with the shape that passed the limit leaves nothing in the
/// setup.
#[test]
fn a_field_taken_back_at_the_page_limit_leaves_no_image_behind() {
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let single = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue)]);
let o = opts(&single);
let inline = crate::scene::Scene::new(&single, &o).run().body;
let twice = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, blue)]);
let page = crate::scene::Scene::new(&twice, &o).with_max_body(inline.len() + 50).run();
assert!(page.warnings.iter().any(|w| w.contains("too large")), "{:?}", page.warnings);
assert!(page.body.starts_with(&inline), "{}", page.body);
assert_eq!(page.body.matches("} exec\n").count(), 1, "{}", page.body);
assert!(!page.setup.contains("VCim"), "{}", page.setup);
// A many-sided shape whose stroke passes the limit after its fill's field was placed.
let big = |d: &mut Document, stroke: Paint| {
let paint = freeform(&[(60.0, 100.0, Color::rgb(0.0, 1.0, 0.0)), (140.0, 100.0, red)]);
path(d, shapes::polygon(Point::new(100.0, 100.0), 90.0, 2000, 0.0), paint, stroke, 1.0)
};
let filled = doc_with(|d| vec![big(d, Paint::None)]);
let stroked = doc_with(|d| vec![big(d, Paint::solid(Color::rgb(0.0, 0.0, 0.0)))]);
let fill = crate::scene::Scene::new(&filled, &o).run().body.len();
let both = crate::scene::Scene::new(&stroked, &o).run().body.len();
assert!(both > fill + 20_000, "{fill} {both}");
let d = doc_with(|d| {
let s = big(d, Paint::solid(Color::rgb(0.0, 0.0, 0.0)));
vec![s, freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, blue)]
});
let page = crate::scene::Scene::new(&d, &o).with_max_body(fill + (both - fill) / 2).run();
assert!(page.warnings.iter().any(|w| w.contains("too large")), "{:?}", page.warnings);
assert_eq!(reuses(&page.body, 0, Level::Three), 2, "{}", page.body);
assert!(page.setup.contains("/VCim0 VCsrc") && !page.setup.contains("VCim1"), "{}", page.setup);
}
#[test]
fn run_length_and_ascii85_round_trip() {
let cycle: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
+5 -3
View File
@@ -259,8 +259,9 @@ mod tests {
#[test]
fn offset_path_sweeps_a_long_low_path_turned_with_the_result_of_the_path_on_end() {
let mut rng = Rng(17);
// Grown past the zigzag's bends, where the stroker leaves loops whose faces are dropped by
// the distance test; grown a little; and inset.
// An open zigzag grown by 1.5, a closed one grown by 0.5 and a closed one inset by 0.3. Each
// is swept turned when low and as it is when stood on end, and the two results cover the
// same region. None of the three leaves a face for the distance test to drop.
for (closed, delta) in [(false, 1.5), (true, 0.5), (true, -0.3)] {
let what = format!("closed {closed}, offset {delta}");
let low = zigzag(3, 300, 0.4, &mut rng, closed);
@@ -274,7 +275,8 @@ mod tests {
assert_same_region(&turned, &upright(&on_end), &what);
}
// A long plate with a row of round holes: growing it by 1 closes the narrow holes, which
// the distance test drops, and keeps the wide ones.
// the distance test drops when it measures each hole's point turned back into the plate's
// own frame, and keeps the wide ones.
use kurbo::Shape;
let mut plate = kurbo::Rect::new(0.0, 0.0, 4000.0, 20.0).to_path(0.1);
for i in 0..200u32 {
+9 -1
View File
@@ -46,7 +46,7 @@ use exporter::{MAX_COORD, writable, xf};
#[cfg(test)]
use images::{png_8_bit, png_16_bit};
#[cfg(test)]
pub(crate) use nodes::MEASURES;
pub(crate) use nodes::{KNOCKOUT_BRUSHES, KNOCKOUT_SHAPES, MAX_KNOCKOUT_MASK_OBJECTS, MEASURES};
pub(crate) use writer::{Sheet, Writer};
pub(crate) struct Exporter<'a> {
@@ -127,6 +127,14 @@ pub(crate) struct Exporter<'a> {
/// The most [`Self::symbol_nodes`] may reach: [`vectorcraft_doc::MAX_SYMBOL_NODES`], or less
/// while one of the copies on a sheet is drawn ([`Writer::page`]).
pub(crate) symbol_limit: u64,
/// Objects the symbol instances in the copies drawn in knockout masks held, together: counted
/// apart from [`Self::symbol_nodes`], within [`vectorcraft_doc::MAX_SYMBOL_NODES`] of their own.
mask_symbol_nodes: u64,
/// Whether a knockout mask is being drawn: [`Self::symbol_nodes`] then counts
/// [`Self::mask_symbol_nodes`].
in_knockout_mask: bool,
/// Whether a knockout mask left out symbol art because the objects its copies may hold ran out.
mask_art_left_out: bool,
}
#[cfg(test)]
+3
View File
@@ -54,6 +54,9 @@ impl<'a> Exporter<'a> {
paper: Rect::ZERO,
symbol_nodes: 0,
symbol_limit: vectorcraft_doc::MAX_SYMBOL_NODES,
mask_symbol_nodes: 0,
in_knockout_mask: false,
mask_art_left_out: false,
overprint: false,
overprinted: false,
invisible: false,
+545 -62
View File
@@ -1,7 +1,9 @@
use std::collections::HashMap;
use super::exporter::{UNDECODED, address, blend, color, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable, xf};
use super::{
Affine, AppearanceItem, BezPath, BlendMode, Exporter, Fill, FillRule, GradientKind, LineCap, LineJoin, Mark, Node, NodeKind, NormalizedF32,
Paint, Path, Rect, Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke,
Affine, AppearanceItem, BezPath, BlendMode, Document, Exporter, Fill, FillRule, GradientKind, LineCap, LineJoin, Mark, Node, NodeKind,
NormalizedF32, Paint, Path, Rect, Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke,
};
/// An object's visual box for leaving out what is off the page. A plain group or layer also holds
@@ -57,88 +59,146 @@ impl Exporter<'_> {
/// The children of a group, as the elements of a knockout group when it is one.
pub(super) fn children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect) {
self.measured_children(s, children, page, &[]);
self.measured_children(s, children, page, &[], false);
}
/// [`Self::children`] with the boxes of the last members in `measured`.
fn measured_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured]) {
/// [`Self::children`] with the boxes of the last members in `measured`, in an isolated group
/// when `isolated`.
fn measured_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured], isolated: bool) {
if !self.knockout {
if isolated {
s.push_isolated();
}
let unmeasured = children.len().saturating_sub(measured.len());
for (i, c) in children.iter().enumerate() {
self.measured_node(s, c, page, false, i.checked_sub(unmeasured).and_then(|i| measured.get(i)));
}
if isolated {
s.pop();
}
return;
}
let elements: Vec<_> = Node::knockout_elements(children)
let elements: Vec<(&Node, Measured)> = Node::knockout_elements(children)
.into_iter()
.map(|c| (c, self.measure(c)))
.map(|c| (&**c, self.measure(c)))
.filter(|(_, m)| m.bounds.is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page)))
.collect();
if !elements.is_empty() && elements.iter().all(|(c, _)| c.knockout_outline().is_some()) {
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect();
if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) {
return self.warp_pieces(s, &elements, page);
if elements.is_empty() {
return;
}
if elements.iter().all(|(c, _)| c.knockout_outline().is_some()) {
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| *c).collect();
if isolated {
s.push_isolated();
}
for (c, m) in &elements {
self.measured_node(s, c, page, false, Some(m));
if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) {
self.warp_pieces(s, &elements, page);
} else {
for (c, m) in &elements {
self.measured_node(s, c, page, false, Some(m));
}
}
if isolated {
s.pop();
}
return;
}
// The PDF writer has no knockout groups: each element is drawn through a soft mask of
// where the elements above it don't paint (the same look for Normal blending). The masks
// nest, so element i sits inside the masks of elements i+1…n: open them outermost first.
self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)");
let Some(((first, measured), rest)) = elements.split_first() else { return };
for (c, m) in rest.iter().rev() {
let mask = self.knockout_mask(s, c, page, m);
s.push_mask(mask);
let several = elements.len() > 1;
// A PDF knockout group (`/K`) knocks out by the area each element paints. Where an element
// above another knocks out by less (its opacity or mask defines its knockout shape, or it
// paints translucently), each element is drawn through one soft mask of where the
// elements above it that overlap it paint, while those masks hold no more than
// [`MAX_KNOCKOUT_MASK_OBJECTS`] objects and no brushed stroke.
if several && !elements.iter().skip(1).all(|(c, _)| shape_is_coverage(c)) {
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
match knockout_masks(BrushSearch::new(doc, brushes), &elements) {
Ok(above) => {
// Every mask is made before anything is drawn: when the symbol objects the
// masks may hold run out, one would leave out art its element covers, and
// the group is written as a PDF knockout group instead.
let outer = std::mem::take(&mut self.mask_art_left_out);
let before = *self.mask_counter();
let mut masks = Vec::with_capacity(above.len());
for above in above {
let shapes: Vec<_> = above.into_iter().map(|j| &elements[j]).collect();
masks.push((!shapes.is_empty()).then(|| self.knockout_mask(s, &shapes, page)));
}
let left_out = std::mem::replace(&mut self.mask_art_left_out, outer);
if left_out {
*self.mask_counter() = before;
self.warn(KNOCKOUT_SHAPES);
} else {
if isolated {
s.push_isolated();
}
for ((c, m), mask) in elements.iter().zip(masks) {
if let Some(mask) = mask {
s.push_mask(mask);
self.measured_node(s, c, page, false, Some(m));
s.pop();
} else {
self.measured_node(s, c, page, false, Some(m));
}
}
if isolated {
s.pop();
}
return;
}
}
Err(warning) => self.warn(warning),
}
}
self.measured_node(s, first, page, false, Some(measured));
for (c, m) in rest {
s.pop();
let group = several || isolated;
if group {
s.push_group(krilla::blend::GroupOptions { isolated, knockout: several });
}
for (c, m) in &elements {
// In a PDF knockout group each painting operation knocks out the ones before it. An
// element drawn with several is one non-isolated group (drawn through a full alpha
// mask), so that its own fills and strokes don't knock each other out; its opacity
// or opacity mask makes it one already.
let unit = several && !one_paint(c) && c.opacity >= 1.0 && c.extras().mask.as_deref().is_none_or(|m| m.disabled);
if unit {
let all = constant_mask(s, page, 1.0);
s.push_mask(all);
}
self.measured_node(s, c, page, false, Some(m));
if unit {
s.pop();
}
}
if group {
s.pop();
}
}
/// [`Self::measured_children`] of a plain group or layer. When they are the pieces of a
/// mesh-warped image, every piece, on the page or not, takes part in the one size its pixels
/// are resampled for ([`Self::warp_image_size`]).
fn group_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured]) {
fn group_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured], isolated: bool) {
let outer = self.warp_image_size(children).map(|w| self.warp_size.replace(w));
self.measured_children(s, children, page, measured);
self.measured_children(s, children, page, measured, isolated);
if let Some(outer) = outer {
self.warp_size = outer;
}
}
/// A luminosity mask that is 1 − the knockout shape of `c`, whose box is in `measured`: white,
/// then black through an alpha mask of `c` (at full object opacity without its own mask, unless
/// those define its shape).
fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect, measured: &Measured) -> krilla::mask::Mask {
let shape = if c.knockout_shape {
c.clone()
} else {
let mut s = c.clone().with_mask(None);
s.opacity = 1.0;
s
};
self.shapes_mask(s, &[(&shape, Some(measured))], page)
}
/// The pieces of a partly transparent mesh-warped image (`elements`, each with its box) as the
/// elements of a knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece
/// is drawn through one soft mask of where the pieces above it that reach its box don't cover
/// ([`vectorcraft_effects::knockout_silhouette`]): only neighbors overlap, so each mask holds a
/// few triangles and no mask nests in another. A piece above that overlaps it only over opaque
/// pixels ([`vectorcraft_effects::WarpSeams`]) is left out of its mask.
fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&std::sync::Arc<Node>, Measured)], page: Rect) {
fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&Node, Measured)], page: Rect) {
// Each piece's triangle and its box: a piece paints inside its triangle only.
let silhouette = |c: &Node| {
let b = c.knockout_outline()?.geometric_bounds()?;
Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::WHITE)?, b))
};
let silhouettes: Vec<Option<(Node, Rect)>> = elements.iter().map(|(c, _)| silhouette(c)).collect();
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect();
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| *c).collect();
let seams = vectorcraft_effects::WarpSeams::new(self.doc, &pieces);
for (i, (c, measured)) in elements.iter().enumerate() {
let Some((_, reach)) = &silhouettes[i] else { continue };
@@ -191,6 +251,67 @@ impl Exporter<'_> {
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
}
/// A luminosity mask that is 1 − the knockout shapes of `shapes` (each with its box): white,
/// then black through an alpha mask of each (at full object opacity without its own mask,
/// unless those define its shape). The symbol instances in the copies count toward
/// [`Exporter::mask_symbol_nodes`], not toward the objects the visible art may draw. A
/// knockout mask drawn inside another one counts toward the same objects as the outer one.
fn knockout_mask(&mut self, s: &mut Surface, shapes: &[&(&Node, Measured)], page: Rect) -> krilla::mask::Mask {
if self.in_knockout_mask {
return self.knockout_mask_art(s, shapes, page);
}
self.in_knockout_mask = true;
let visible = std::mem::replace(&mut self.symbol_nodes, self.mask_symbol_nodes);
let limit = std::mem::replace(&mut self.symbol_limit, vectorcraft_doc::MAX_SYMBOL_NODES);
let mask = self.knockout_mask_art(s, shapes, page);
self.mask_symbol_nodes = std::mem::replace(&mut self.symbol_nodes, visible);
self.symbol_limit = limit;
self.in_knockout_mask = false;
mask
}
/// The symbol objects the copies drawn in knockout masks held, together: [`Self::symbol_nodes`]
/// while a knockout mask is being drawn, [`Exporter::mask_symbol_nodes`] otherwise.
fn mask_counter(&mut self) -> &mut u64 {
if self.in_knockout_mask { &mut self.symbol_nodes } else { &mut self.mask_symbol_nodes }
}
/// [`Self::knockout_mask`], drawn within the symbol objects counted at the time.
fn knockout_mask_art(&mut self, s: &mut Surface, shapes: &[&(&Node, Measured)], page: Rect) -> krilla::mask::Mask {
let transform = s.group_transform();
let mut sb = s.stream_builder();
let mut ms = Surface::stream(sb.surface());
ms.push_transform(&transform);
cover(&mut ms, page, 255, 1.0);
for (c, measured) in shapes.iter().map(|e| (e.0, &e.1)) {
let shape = if c.knockout_shape {
c.clone()
} else {
let mut s = c.clone().with_mask(None);
s.opacity = 1.0;
s
};
let alpha = {
let transform = ms.group_transform();
let mut ab = ms.stream_builder();
let mut als = Surface::stream(ab.surface());
als.push_transform(&transform);
let overprint = std::mem::take(&mut self.overprint);
self.measured_node(&mut als, &shape, page, false, Some(measured));
self.overprint = overprint;
als.pop();
als.finish();
krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha)
};
ms.push_mask(alpha);
cover(&mut ms, page, 0, 1.0);
ms.pop();
}
ms.pop();
ms.finish();
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
}
/// The box of `n`, and of the members of a plain group or layer.
fn measure(&self, n: &Node) -> Measured {
if !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) {
@@ -242,23 +363,7 @@ impl Exporter<'_> {
// Whether this container's children knock each other out (a knockout group is written as a group).
let knockout = n.knocks_out(self.knockout);
let enclosing = std::mem::replace(&mut self.knockout, knockout);
let mut pushes = 0;
if n.blend != BlendMode::Normal {
s.push_blend_mode(blend(n.blend));
pushes += 1;
}
if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) {
if !n.isolate && n.blends_through() {
// Not isolated: blending inside reaches the art below the group.
let alpha = constant_mask(s, page, n.opacity);
s.push_mask(alpha);
} else if n.opacity < 1.0 {
s.push_opacity(norm(n.opacity));
} else {
s.push_isolated();
}
pushes += 1;
}
let (mut pushes, isolated_children) = transparency(s, n, page, container, knockout);
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
let alpha = match (&n.kind, mask) {
(NodeKind::Image(im), Some(m)) => self.masked_image(im, m, !n.extras().pixelated),
@@ -279,7 +384,7 @@ impl Exporter<'_> {
}
match &n.kind {
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
self.group_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()));
self.group_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()), isolated_children);
}
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// The region every output clips to; nothing to clip by hides the clipped art. The
@@ -366,8 +471,10 @@ impl Exporter<'_> {
(art, count)
})
});
if let Some(art) = placed.flatten() {
self.node(s, &art, page, true);
match placed.flatten() {
Some(art) => self.node(s, &art, page, true),
// A knockout mask that leaves the art out no longer matches what it masks.
None => self.mask_art_left_out |= self.in_knockout_mask,
}
}
}
@@ -741,3 +848,379 @@ impl Exporter<'_> {
true
}
}
/// Opens the blend mode and the transparency group `n` is drawn with (`container` when it is a
/// group or layer whose children `knockout` each other). Returns how many were opened, and
/// whether the children are to be drawn in an isolated group of their own: the knockout group of
/// an unclipped group's elements is its isolated group too.
fn transparency(s: &mut Surface, n: &Node, page: Rect, container: bool, knockout: bool) -> (usize, bool) {
let mut pushes = 0;
if n.blend != BlendMode::Normal {
s.push_blend_mode(blend(n.blend));
pushes += 1;
}
let mut isolated_children = false;
if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) {
if !n.isolate && n.blends_through() {
// Not isolated: blending inside reaches the art below the group.
let alpha = constant_mask(s, page, n.opacity);
s.push_mask(alpha);
pushes += 1;
} else if n.opacity < 1.0 {
s.push_opacity(norm(n.opacity));
pushes += 1;
} else if knockout && matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) {
isolated_children = true;
} else {
s.push_isolated();
pushes += 1;
}
}
(pushes, isolated_children)
}
/// The warning for a knockout group whose elements are too many to draw through soft masks: it is
/// written as a PDF knockout group, where an element knocks out all of the area it paints.
pub(crate) const KNOCKOUT_SHAPES: &str = "knockout groups with too many overlapping translucent elements are written as PDF knockout groups: \
their translucent elements knock out what they cover fully";
/// The most objects the soft masks of one knockout group's elements draw between them: each mask
/// draws the elements above its own element that overlap it.
pub(crate) const MAX_KNOCKOUT_MASK_OBJECTS: usize = 1 << 16;
/// The most elements of one knockout group drawn through soft masks: finding the elements that
/// overlap compares every pair.
pub(crate) const MAX_MASKED_ELEMENTS: usize = 2048;
/// The warning for a knockout group in which a soft mask would draw an element with a brushed
/// stroke: it is written as a PDF knockout group, where an element knocks out all of the area it
/// paints.
pub(crate) const KNOCKOUT_BRUSHES: &str = "knockout groups with brushed strokes over other translucent elements are written as PDF knockout groups: \
their translucent elements knock out what they cover fully";
/// For each element of a knockout group (with its box), the indices of the elements above it whose
/// boxes overlap its own. The warning to give instead ([`KNOCKOUT_SHAPES`]) when there are more than
/// [`MAX_MASKED_ELEMENTS`] elements or the masks would draw more than
/// [`MAX_KNOCKOUT_MASK_OBJECTS`] objects, or ([`KNOCKOUT_BRUSHES`]) when a mask would draw a
/// brushed stroke ([`BrushSearch`]), whose brush art the object count leaves out.
fn knockout_masks(mut search: BrushSearch, elements: &[(&Node, Measured)]) -> Result<Vec<Vec<usize>>, &'static str> {
if elements.len() > MAX_MASKED_ELEMENTS {
return Err(KNOCKOUT_SHAPES);
}
let counts: Vec<usize> = elements.iter().map(|(c, _)| c.count_with_masks()).collect();
let mut checked: Vec<Option<bool>> = vec![None; elements.len()];
let mut objects = 0usize;
let mut out = Vec::with_capacity(elements.len());
for (i, (_, below)) in elements.iter().enumerate() {
let mut above = vec![];
for (j, (over_node, over)) in elements.iter().enumerate().skip(i + 1) {
if let (Some(a), Some(b)) = (below.bounds, over.bounds)
&& rects_overlap(a.inflate(1.0, 1.0), b.inflate(1.0, 1.0))
{
// The mask draws a copy of the element without its own opacity mask, unless the
// mask defines its knockout shape.
if *checked[j].get_or_insert_with(|| search.node(over_node, over_node.knockout_shape, 0)) {
return Err(if search.exhausted { KNOCKOUT_SHAPES } else { KNOCKOUT_BRUSHES });
}
objects = objects.checked_add(counts[j]).filter(|&o| o <= MAX_KNOCKOUT_MASK_OBJECTS).ok_or(KNOCKOUT_SHAPES)?;
above.push(j);
}
}
out.push(above);
}
Ok(out)
}
/// The art a [`BrushSearch`] looks through once: a symbol's or a pattern's, by name, or a
/// symbol's with one instance's appearance changes, by the symbol's name and the changes.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum SharedArt {
Symbol,
Pattern,
Dynamic,
}
/// A search for the brushed strokes drawing an object draws, whose brush art can hold many more
/// objects than the stroked path.
struct BrushSearch<'a> {
doc: &'a Document,
/// The brushes the export draws strokes with ([`Exporter::brush_art`]); a stroke whose brush
/// is not among them is drawn as a plain stroke.
brushes: &'a [vectorcraft_brush::Brush],
/// The answer for each symbol's and pattern's art, and each symbol's art with an instance's
/// appearance changes, once found: false while it is being looked through, so that art that
/// uses itself ends the search.
seen: HashMap<(SharedArt, String), bool>,
/// The objects the search may still visit or copy: [`MAX_KNOCKOUT_MASK_OBJECTS`] at the start.
left: usize,
/// Whether the search stopped because [`Self::left`] ran out; it then answers true.
exhausted: bool,
}
impl<'a> BrushSearch<'a> {
fn new(doc: &'a Document, brushes: &'a [vectorcraft_brush::Brush]) -> Self {
Self { doc, brushes, seen: HashMap::new(), left: MAX_KNOCKOUT_MASK_OBJECTS, exhausted: false }
}
/// Takes `n` objects from [`Self::left`]; false, and the search exhausted, when fewer are left.
fn spend(&mut self, n: usize) -> bool {
match self.left.checked_sub(n) {
Some(left) if !self.exhausted => {
self.left = left;
true
}
_ => {
(self.left, self.exhausted) = (0, true);
false
}
}
}
/// Takes the objects of `n` (its members and opacity-mask art included) from [`Self::left`],
/// counting no further than what is left; false when they don't fit.
fn spend_tree(&mut self, n: &Node) -> bool {
self.spend(1)
&& n.extras().mask.as_deref().is_none_or(|m| self.spend_tree(&m.art))
&& n.children().into_iter().flatten().all(|c| self.spend_tree(c))
}
/// Whether drawing `n` draws a brushed stroke: on `n` or its visible members (a compound
/// path's members are drawn with its own paint), in the art of the opacity masks that are on
/// (`n`'s own when `own_mask`), of symbol instances and inline graphics, of live objects as
/// evaluated, and of the patterns that fills, strokes and type paint with. `depth` counts the
/// objects and the mask, symbol and pattern art above `n`: past
/// [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`] the search stops and counts as a brush, and so
/// it does when the objects it may visit or copy ([`Self::left`]) run out.
fn node(&mut self, n: &Node, own_mask: bool, depth: usize) -> bool {
if depth > vectorcraft_doc::depth::MAX_DRAWN_DEPTH || !self.spend(1) {
return true;
}
let depth = depth + 1;
// A fill is drawn when it is visible, and a stroke when it also paints and has a width
// (as `Exporter::shape` draws them).
for item in n.appearance.items.iter().filter(|i| i.visible()) {
let paint = match item {
AppearanceItem::Fill(f) => &f.paint,
AppearanceItem::Stroke(st) if st.paint.is_none() || st.width <= 0.0 => continue,
AppearanceItem::Stroke(st) => {
if st.brush.as_deref().is_some_and(|name| self.brushes.iter().any(|b| b.name == name)) {
return true;
}
&st.paint
}
};
if self.paint(paint, depth) {
return true;
}
}
if own_mask && n.extras().mask.as_deref().is_some_and(|m| !m.disabled && self.node(&m.art, true, depth)) {
return true;
}
match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => {
// A dynamic symbol's instance draws its art with its own appearance changes: the
// art is copied once for each set of changes, counted before it is copied.
let doc = self.doc;
match doc.symbols.iter().find(|s| &s.name == symbol) {
Some(s) if !n.extras().symbol_overrides.is_empty() => {
let key = format!("{symbol}\u{0}{:?}", n.extras().symbol_overrides);
self.shared(SharedArt::Dynamic, &key, |search| {
!search.spend_tree(&s.art) || search.node(&vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n), true, depth)
})
}
_ => self.symbol(symbol, depth),
}
}
NodeKind::Text(t) => t.runs.iter().any(|r| {
let stroke = r.style.stroke_width > 0.0 && self.paint(&r.style.stroke, depth);
stroke || self.paint(&r.style.fill, depth) || r.inline.as_ref().is_some_and(|ig| self.symbol(&ig.symbol, depth))
}),
NodeKind::Compound { .. } => false,
// Live objects are drawn as their evaluated art, as `Exporter::measured_node` draws them.
NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Intertwine { .. } => {
// Each live object is reached once in the art searched (shared art is searched
// once); its evaluated art is counted before it is searched.
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
!self.spend_tree(&g) || g.children().into_iter().flatten().filter(|c| c.visible).any(|c| self.node(c, true, depth))
}
_ => n.children().into_iter().flatten().filter(|c| c.visible).any(|c| self.node(c, true, depth)),
}
}
/// Whether the art of the symbol named `name` draws a brushed stroke.
fn symbol(&mut self, name: &str, depth: usize) -> bool {
let doc = self.doc;
let Some(s) = doc.symbols.iter().find(|s| s.name == name) else { return false };
self.shared(SharedArt::Symbol, name, |search| search.node(&s.art, true, depth))
}
/// Whether `paint` is a pattern whose tile art draws a brushed stroke.
fn paint(&mut self, paint: &Paint, depth: usize) -> bool {
let Paint::Pattern { pattern, .. } = paint else { return false };
let doc = self.doc;
let Some(def) = doc.pattern(pattern) else { return false };
self.shared(SharedArt::Pattern, pattern, |search| def.art.iter().filter(|c| c.visible).any(|c| search.node(c, true, depth)))
}
/// The answer of `search` for the shared art `kind` named `name`, found once.
fn shared(&mut self, kind: SharedArt, name: &str, search: impl FnOnce(&mut Self) -> bool) -> bool {
let key = (kind, name.to_string());
if let Some(&b) = self.seen.get(&key) {
return b;
}
self.seen.insert(key.clone(), false);
let b = search(self);
self.seen.insert(key, b);
b
}
}
/// Whether `n` is drawn with one painting operation at most: a path or compound path with at most
/// one visible fill or stroke that paints, in a solid color, without effects, a brush or
/// arrowheads.
fn one_paint(n: &Node) -> bool {
let mut items = n.appearance.items.iter().filter(|i| i.visible() && !matches!(i.paint(), Paint::None));
let simple = items.next().is_none_or(|i| {
let plain = match i {
AppearanceItem::Fill(_) => true,
AppearanceItem::Stroke(st) => st.brush.is_none() && st.start_arrow.is_none() && st.end_arrow.is_none(),
};
plain && i.effects().is_empty() && matches!(i.paint(), Paint::Solid { .. })
});
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && n.appearance.effects.is_empty() && simple && items.next().is_none()
}
/// Whether element `c` of a knockout group knocks out all of the area it paints, as an element of
/// a PDF knockout group does: its opacity and opacity mask are full or don't define its knockout
/// shape, and every part of it paints opaquely.
fn shape_is_coverage(c: &Node) -> bool {
let own = !c.knockout_shape || (c.opacity >= 1.0 && c.extras().mask.as_deref().is_none_or(|m| m.disabled));
own && paints_opaque(c)
}
/// Whether what `n` paints is opaque wherever it paints, at its own opacity and mask: its visible
/// fills and strokes are solid colors or gradients with opaque stops, without effects or brushes,
/// and so are the members of a group or compound path, at full opacity without an opacity mask.
fn paints_opaque(n: &Node) -> bool {
let paint = |p: &Paint| match p {
Paint::None | Paint::Solid { .. } => true,
Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0),
_ => false,
};
let items = n.appearance.items.iter().all(|i| {
let brush = matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some());
!i.visible() || (i.opacity() >= 1.0 && i.effects().is_empty() && !brush && paint(i.paint()))
});
let member =
|c: &std::sync::Arc<Node>| !c.visible || (c.opacity >= 1.0 && c.extras().mask.as_deref().is_none_or(|m| m.disabled) && paints_opaque(c));
n.appearance.effects.is_empty()
&& items
&& match &n.kind {
NodeKind::Path { .. } => true,
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => children.iter().all(member),
NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity >= 1.0),
_ => false,
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, NodeId, Symbol, SymbolOverride};
use vectorcraft_geom::shapes;
use super::*;
/// An instance of a dynamic symbol whose art holds an instance of the same symbol with the same
/// appearance changes ends the search for brushed strokes where the art comes round again,
/// after a few objects, instead of recursing until the stack runs out.
#[test]
fn a_dynamic_symbol_holding_itself_ends_the_brush_search() {
let mut d = Document::new(200.0, 200.0);
let instance = || {
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Loop".into(), xf: Affine::translate((40.0, 0.0)) });
n.extras_mut().symbol_overrides = vec![SymbolOverride { path: vec![0], opacity: Some(0.5), ..Default::default() }];
n
};
let square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
let art = Node::group(NodeId(0), vec![Arc::new(square), Arc::new(instance())]);
d.symbols.push(Symbol { dynamic: true, name: "Loop".into(), art: Arc::new(art) });
let mut search = BrushSearch::new(&d, &[]);
assert!(!search.node(&instance(), true, 0));
assert!(!search.exhausted);
assert!(MAX_KNOCKOUT_MASK_OBJECTS - search.left < 10, "{} objects", MAX_KNOCKOUT_MASK_OBJECTS - search.left);
}
/// A group of 1,000 instances of a dynamic symbol whose art holds 100 squares, each instance
/// with appearance changes of its own: copying and looking through every instance's art would
/// take about 203,000 objects, and the search stops when [`MAX_KNOCKOUT_MASK_OBJECTS`] are
/// used, counting as a brush. With the same changes on every instance, the art is copied
/// once.
#[test]
fn the_brush_search_stops_within_its_object_limit() {
let mut d = Document::new(200.0, 200.0);
let square =
Arc::new(Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::None, Paint::None, 0.0)));
d.symbols.push(Symbol { dynamic: true, name: "Dots".into(), art: Arc::new(Node::group(NodeId(0), vec![square; 100])) });
let group = |distinct: bool| {
let instances = (0..1000u32)
.map(|i| {
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Dots".into(), xf: Affine::IDENTITY });
let path = if distinct { vec![0, i] } else { vec![0] };
n.extras_mut().symbol_overrides = vec![SymbolOverride { path, opacity: Some(0.5), ..Default::default() }];
Arc::new(n)
})
.collect();
Node::group(NodeId(0), instances)
};
let mut search = BrushSearch::new(&d, &[]);
assert!(search.node(&group(true), true, 0));
assert!(search.exhausted);
assert_eq!(search.left, 0);
let mut search = BrushSearch::new(&d, &[]);
assert!(!search.node(&group(false), true, 0));
assert!(!search.exhausted);
// The group, the 1,000 instances, and one copy of the art counted and looked through.
assert_eq!(MAX_KNOCKOUT_MASK_OBJECTS - search.left, 1 + 1000 + 101 + 101);
}
/// A placed file read as one square with a 3 pt brushed stroke.
fn brushed_art() -> (Document, Node, Rect) {
let mut d = Document::new(40.0, 20.0);
d.layers.clear();
let mut st = StrokeLayer::new(Paint::solid(vectorcraft_color::Color::BLACK), 3.0);
st.brush = Some("3 pt. Round".into());
let mut square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
square.appearance.items.push(AppearanceItem::Stroke(st));
let art = Node::group(NodeId(0), vec![Arc::new(square)]);
(d, art, Rect::new(0.0, 0.0, 40.0, 20.0))
}
/// A placed document is drawn as the art read from its file, and the brushed strokes in that
/// art count.
#[test]
fn brushed_strokes_in_a_placed_documents_art_count() {
let d = Document::new(200.0, 200.0);
let placed = vectorcraft_doc::PlacedDocument {
link: vectorcraft_doc::LinkInfo::new("/art/placed.vectorcraft"),
key: "placed-1".into(),
bounding: false,
page_box: vectorcraft_doc::placed_document::PageBox::Crop,
width: 40.0,
height: 20.0,
xf: Affine::IDENTITY,
placement: Default::default(),
};
// The art the export reads for it before drawing (`Document::with_placed_art`).
vectorcraft_doc::placed_document::prime(&placed, Arc::new(vec![]), brushed_art());
let n = Node::new(NodeId(0), NodeKind::PlacedDocument(Box::new(placed)));
let brushes = vectorcraft_brush::library(&d);
assert!(BrushSearch::new(&d, &brushes).node(&n, true, 0));
}
}
+15
View File
@@ -387,6 +387,21 @@ impl<'p> Builder<'p> {
only = fold_stop_opacity(&only, &m).unwrap_or_else(|| only.with_mask(Some(Box::new(m))));
}
Some(only)
} else if let [only] = children.as_slice()
&& matches!(only.kind, NodeKind::Group { clip: false, .. })
&& only.knockout == Knockout::On
&& only.opacity >= 0.999
&& only.blend == BlendMode::Normal
&& !only.isolate
&& only.extras().mask.is_none()
&& mask.is_none()
&& !knockout
{
// A knockout group drawn in a form that carries its opacity, blend mode or
// isolation: one knockout group with them.
let mut only = (**only).clone();
(only.opacity, only.blend, only.isolate) = (opacity, blend, isolate);
Some(only)
} else {
let mut g = Node::group(self.id(), children);
(g.opacity, g.blend, g.isolate) = (opacity, blend, isolate);
+5
View File
@@ -81,6 +81,11 @@ impl<'a> Surface<'a> {
self.group(|s| s.push_isolated());
}
/// A transparency group with `options` (isolated, knockout), as [`Self::push_isolated`].
pub fn push_group(&mut self, options: krilla::blend::GroupOptions) {
self.group(|s| s.push_group(options));
}
/// The mask's stream is already in [`Self::group_transform`]'s space, like the group's art.
pub fn push_mask(&mut self, mask: Mask) {
self.group(|s| s.push_mask(mask));
+463
View File
@@ -97,3 +97,466 @@ fn a_knockout_group_with_blending_elements_is_non_isolated_unless_isolated() {
assert_eq!(text(&d).contains("/Itrue"), isolate, "isolate {isolate}");
}
}
/// A knockout group of `n` red, green and blue squares at 50% opacity that all overlap, over an
/// opaque backdrop. `knockout_shape`: their opacity defines their knockout shape.
fn knockout_squares(n: usize, knockout_shape: bool, isolate: bool) -> Document {
let mut d = Document::new(200.0, 200.0);
add(&mut d, rect(Rect::new(0.0, 0.0, 200.0, 200.0), (0.9, 0.8, 0.2)));
let colors = [(1.0, 0.0, 0.0), (0.0, 0.8, 0.0), (0.0, 0.0, 1.0)];
let squares = (0..n)
.map(|i| {
let x = (i % 20) as f64 * 2.0;
let mut s = rect(Rect::new(20.0 + x, 50.0, 120.0 + x, 150.0), colors[i % 3]);
(s.id, s.opacity, s.knockout_shape) = (d.alloc_id(), 0.5, knockout_shape);
std::sync::Arc::new(s)
})
.collect();
let mut g = Node::group(NodeId(0), squares);
(g.knockout, g.isolate) = (vectorcraft_doc::Knockout::On, isolate);
add(&mut d, g);
d
}
/// How many objects each knockout group of `d` holds.
fn knockout_groups(d: &Document) -> Vec<usize> {
let mut out = vec![];
d.walk(|n| {
if n.knockout == vectorcraft_doc::Knockout::On {
out.push(n.children().map_or(0, |c| c.len()));
}
});
out
}
/// A knockout group whose elements knock out the area they paint is written as one PDF knockout
/// group (`/K true`), whatever the number of elements, and reads back as a knockout group that
/// draws as the original does.
#[test]
fn a_knockout_group_is_written_as_one_pdf_knockout_group() {
for n in [0, 1, 2, 49, 50, 200] {
let d = knockout_squares(n, false, false);
let t = text(&d);
assert_eq!(t.matches("/Ktrue").count(), usize::from(n > 1), "{n}");
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap_or_else(|e| panic!("{n}: {e}"));
assert_eq!(knockout_groups(&back), if n > 1 { vec![n] } else { vec![] }, "{n}");
if n == 200 {
vectorcraft_testkit::raster::assert_similar(
&vectorcraft_testkit::raster::render_artboard(&back),
&vectorcraft_testkit::raster::render_artboard(&d),
4.0,
0.01,
);
}
}
}
/// The knockout group of an isolated group is isolated too (`/I true` beside `/K true`), and the
/// group reads back as isolated and knocking out. (Each translucent element is a group of its own.)
#[test]
fn an_isolated_knockout_group_is_one_isolated_pdf_knockout_group() {
for isolate in [false, true] {
let mut d = knockout_squares(3, false, isolate);
// Blending inside, which isolation keeps from the backdrop.
let g = d.layers[0].children().unwrap()[1].id;
let top = d.node(g).unwrap().children().unwrap()[2].id;
d.node_mut(top).unwrap().blend = BlendMode::Multiply;
let t = text(&d);
assert_eq!(t.matches("/Ktrue").count(), 1, "isolate {isolate}");
assert_eq!(t.contains("/Itrue/Ktrue"), isolate, "isolate {isolate}");
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
let mut found = vec![];
back.walk(|n| {
if n.knockout == vectorcraft_doc::Knockout::On {
found.push(n.isolate);
}
});
assert_eq!(found, [isolate]);
}
}
/// A translucent knockout group is written as a form with its opacity around the `/K` group, and
/// reads back as one knockout group with that opacity and its isolation.
#[test]
fn a_translucent_knockout_group_reads_back_as_one_group() {
for isolate in [false, true] {
let mut d = knockout_squares(3, false, isolate);
let g = d.layers[0].children().unwrap()[1].id;
d.node_mut(g).unwrap().opacity = 0.5;
let top = d.node(g).unwrap().children().unwrap()[2].id;
d.node_mut(top).unwrap().blend = BlendMode::Multiply;
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
let mut found = vec![];
back.walk(|n| {
if matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. }) {
found.push((
n.knockout == vectorcraft_doc::Knockout::On,
n.isolate,
(n.opacity * 100.0).round(),
n.children().map_or(0, |c| c.len()),
));
}
});
assert_eq!(found, [(true, isolate, 50.0, 3)], "isolate {isolate}");
}
}
/// Elements whose opacity defines their knockout shape knock out less than a PDF knockout group
/// knocks out: each is drawn through one soft mask of the elements above it, so that 200 of them
/// still read back, every one of them.
#[test]
fn elements_whose_opacity_defines_their_knockout_shape_are_drawn_through_one_mask_each() {
let d = knockout_squares(200, true, false);
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
assert!(!r.warnings.iter().any(|w| w.contains("knockout")), "{:?}", r.warnings);
assert!(!text(&d).contains("/Ktrue"));
let back = import(&r.bytes).unwrap();
let mut paths = 0;
back.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
// The backdrop and the 200 squares (the masks' copies of them are opacity-mask art).
assert_eq!(paths, 201);
}
/// When the masks would draw more than [`crate::export::MAX_KNOCKOUT_MASK_OBJECTS`] objects, the
/// group is written as a PDF knockout group, with a warning, and reads back as a knockout group.
#[test]
fn a_knockout_group_too_large_to_mask_is_a_pdf_knockout_group_with_a_warning() {
// 400 elements that all overlap: 79,800 objects in their masks.
const { assert!(400 * 399 / 2 > crate::export::MAX_KNOCKOUT_MASK_OBJECTS) };
let d = knockout_squares(400, true, false);
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_SHAPES), "{:?}", r.warnings);
assert_eq!(text(&d).matches("/Ktrue").count(), 1);
assert_eq!(knockout_groups(&import(&r.bytes).unwrap()), [400]);
}
/// In a PDF knockout group each painting operation knocks out the ones before it: an element with
/// two fills, the second in Multiply, is drawn as one group (through a full alpha mask), so that
/// its second fill blends with its first. An element with one fill needs no group.
#[test]
fn an_element_with_several_paints_is_one_group_in_a_knockout_group() {
for (fills, groups) in [(1, 0), (2, 1)] {
let mut d = Document::new(200.0, 200.0);
let below = rect(Rect::new(0.0, 0.0, 100.0, 100.0), (0.0, 0.0, 1.0));
let mut top = rect(Rect::new(50.0, 50.0, 150.0, 150.0), (1.0, 0.0, 0.0));
if fills == 2 {
let mut multiply = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
multiply.blend = BlendMode::Multiply;
top.appearance.items.push(vectorcraft_doc::AppearanceItem::Fill(multiply));
}
top.id = d.alloc_id();
let below = Node { id: d.alloc_id(), ..below };
let mut g = Node::group(NodeId(0), vec![std::sync::Arc::new(below), std::sync::Arc::new(top)]);
g.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, g);
let t = text(&d);
assert_eq!(t.matches("/Ktrue").count(), 1, "{fills}");
let Some(start) = t.find("/Ktrue") else { panic!("no knockout group") };
let group = &t[start..];
let Some(end) = group.find("endstream") else { panic!("no knockout stream") };
assert_eq!(group[..end].matches("Do").count(), groups, "{fills}: {t}");
}
}
/// Elements that don't overlap need no masks of each other.
#[test]
fn knockout_masks_hold_only_the_elements_that_overlap() {
let mut d = Document::new(200.0, 100.0);
let squares = (0..3)
.map(|i| {
let x = f64::from(i) * 60.0;
let mut s = rect(Rect::new(x, 0.0, x + 40.0, 40.0), (0.0, 0.0, 1.0));
(s.id, s.opacity, s.knockout_shape) = (d.alloc_id(), 0.5, true);
std::sync::Arc::new(s)
})
.collect();
let mut g = Node::group(NodeId(0), squares);
g.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, g);
let t = text(&d);
assert!(!t.contains("/Luminosity") && !t.contains("/Ktrue"), "{t}");
}
/// The copies of symbol instances drawn in knockout masks don't count toward the objects the
/// visible symbol art of an export may hold (`MAX_SYMBOL_NODES`): of two overlapping 50% instances
/// whose art placed through 9-slice guides holds about 6,510,000 objects each (a gradient square
/// in all nine cells, each copy with a disabled opacity mask of 723,351 objects), both are written
/// in their nine cells, although the lower one's mask draws a copy of the upper one first.
#[test]
fn knockout_mask_copies_of_symbol_instances_leave_the_visible_art_its_object_limit() {
let d = panel_instances(850, &[20.0, 40.0]);
// Nine squares of each instance (the mask's copy of the upper one is opacity-mask art).
assert_eq!(painted_paths(&export(&d, &PdfOptions::default()).unwrap()), 18);
}
/// A knockout group of overlapping 50% instances, at `xs`, of a symbol whose art placed through
/// 9-slice guides holds 9 × (`mask_objects`² + `mask_objects` + 3) objects (a gradient square in
/// all nine cells, each copy with a disabled opacity mask of `mask_objects`² + `mask_objects` + 1
/// objects).
fn panel_instances(mask_objects: usize, xs: &[f64]) -> Document {
use vectorcraft_color::{Gradient, GradientPaint};
use vectorcraft_geom::Affine;
let mut d = Document::new(300.0, 200.0);
let leaf = std::sync::Arc::new(rect(Rect::new(0.0, 0.0, 1.0, 1.0), (0.0, 0.0, 0.0)));
let inner = std::sync::Arc::new(Node::group(NodeId(0), vec![leaf; mask_objects]));
let mut mask = vectorcraft_doc::OpacityMask::new(Node::group(NodeId(0), vec![inner; mask_objects]), true);
(mask.linked, mask.disabled) = (false, true);
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
let mut square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0));
square.extras_mut().mask = Some(Box::new(mask));
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(square)]);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: std::sync::Arc::new(art) });
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
let instances = xs
.iter()
.map(|&x| {
let xf = Affine::translate((x, 20.0)) * Affine::scale_non_uniform(2.0, 1.5);
let mut n = Node::new(NodeId(0), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Panel".into(), xf });
(n.id, n.opacity, n.knockout_shape) = (d.alloc_id(), 0.5, true);
std::sync::Arc::new(n)
})
.collect();
let mut g = Node::group(NodeId(0), instances);
g.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, g);
d
}
/// The paths of `pdf` read back, the art of opacity masks left out.
fn painted_paths(pdf: &[u8]) -> usize {
let mut paths = 0;
import(pdf).unwrap().walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
paths
}
/// When the copies the knockout masks draw would hold more symbol objects than they may
/// (`MAX_SYMBOL_NODES` of their own), the group is written as a PDF knockout group, with a
/// warning, instead of masks that leave out art their elements cover: four overlapping instances
/// of about 3,870,000 objects each fit the visible art's limit, and the six copies in their masks
/// do not.
#[test]
fn knockout_masks_whose_symbol_copies_run_out_give_way_to_a_pdf_knockout_group() {
let d = panel_instances(655, &[20.0, 40.0, 60.0, 80.0]);
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_SHAPES), "{:?}", r.warnings);
assert_eq!(painted_paths(&r.bytes), 36);
}
/// A brushed stroke draws brush art that the objects counted for the knockout masks leave out:
/// a group in which a mask would draw an element with a brushed stroke is written as a PDF
/// knockout group, with a warning. A brushed element that no mask draws (the lowest one) leaves
/// the masks as they are.
#[test]
fn a_knockout_group_whose_masks_would_draw_a_brushed_stroke_is_a_pdf_knockout_group() {
for (brushed, expected) in [(1, true), (0, false)] {
let mut d = Document::new(200.0, 200.0);
let squares = (0..2)
.map(|i| {
let x = f64::from(i) * 40.0;
let mut s = rect(Rect::new(20.0 + x, 50.0, 120.0 + x, 150.0), (0.0, 0.0, 1.0));
(s.id, s.opacity, s.knockout_shape) = (d.alloc_id(), 0.5, true);
if i == brushed {
let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
st.brush = Some("3 pt. Round".into());
s.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(st));
}
std::sync::Arc::new(s)
})
.collect();
let mut g = Node::group(NodeId(0), squares);
g.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, g);
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
assert_eq!(r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_BRUSHES), expected, "{brushed}: {:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_SHAPES), "{brushed}: {:?}", r.warnings);
assert_eq!(text(&d).matches("/Ktrue").count(), usize::from(expected), "{brushed}");
}
}
/// The knockout group of `upper` at 50%, whose opacity defines its knockout shape when
/// `knockout_shape`, between two 50% squares whose opacity defines theirs (so that the group is
/// drawn through masks): whether the export gives the brushed-stroke warning.
fn warns_of_brushes(mut d: Document, mut upper: Node, knockout_shape: bool) -> bool {
(upper.id, upper.opacity, upper.knockout_shape) = (d.alloc_id(), 0.5, knockout_shape);
let mut lower = rect(Rect::new(20.0, 50.0, 120.0, 150.0), (1.0, 0.0, 0.0));
(lower.id, lower.opacity, lower.knockout_shape) = (d.alloc_id(), 0.5, true);
let mut top = rect(Rect::new(100.0, 50.0, 180.0, 150.0), (0.0, 0.8, 0.0));
(top.id, top.opacity, top.knockout_shape) = (d.alloc_id(), 0.5, true);
let mut g = Node::group(NodeId(0), [lower, upper, top].map(std::sync::Arc::new).to_vec());
g.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, g);
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_BRUSHES)
}
/// A square at (60, 50) with a 3 pt brushed stroke.
fn brushed_square() -> Node {
let mut line = rect(Rect::new(60.0, 50.0, 160.0, 150.0), (1.0, 1.0, 1.0));
let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
st.brush = Some("3 pt. Round".into());
line.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(st));
line
}
/// Only brushed strokes a mask draws count: a hidden member's is not drawn, nor is one without
/// paint or width, nor the one in the opacity mask of an element whose mask does not define its
/// knockout shape (its copy in a mask leaves the opacity mask out).
#[test]
fn brushed_strokes_that_a_mask_does_not_draw_leave_the_masks_as_they_are() {
let d = Document::new(200.0, 200.0);
let mut hidden = brushed_square();
hidden.visible = false;
let plain = rect(Rect::new(60.0, 50.0, 160.0, 150.0), (0.0, 0.0, 1.0));
let group = Node::group(NodeId(0), vec![std::sync::Arc::new(plain.clone()), std::sync::Arc::new(hidden)]);
assert!(!warns_of_brushes(d.clone(), group, true));
// A brushed stroke without paint or width draws nothing.
for (paint, width) in [(Paint::None, 3.0), (Paint::solid(Color::BLACK), 0.0)] {
let mut idle = brushed_square();
if let Some(vectorcraft_doc::AppearanceItem::Stroke(st)) = idle.appearance.items.last_mut() {
(st.paint, st.width) = (paint, width);
}
assert!(!warns_of_brushes(d.clone(), idle, true), "width {width}");
}
assert!(warns_of_brushes(d.clone(), brushed_square(), true));
// A group's own brushed stroke is drawn too: the export bakes it into a path among the
// group's members.
let mut stroked_group = Node::group(NodeId(0), vec![std::sync::Arc::new(plain.clone())]);
stroked_group.appearance = brushed_square().appearance;
assert!(warns_of_brushes(d.clone(), stroked_group, true));
for (knockout_shape, expected) in [(false, false), (true, true)] {
let mut masked = plain.clone();
let mut mask = vectorcraft_doc::OpacityMask::new(brushed_square(), true);
mask.linked = false;
masked.extras_mut().mask = Some(Box::new(mask));
assert_eq!(warns_of_brushes(d.clone(), masked, knockout_shape), expected, "knockout shape {knockout_shape}");
}
}
/// A compound path's members are drawn with its own paint, so a brushed stroke on a member draws
/// nothing; one on the compound path does.
#[test]
fn a_brushed_stroke_counts_on_a_compound_path_and_not_on_its_members() {
let d = Document::new(200.0, 200.0);
let compound = |members: Vec<Node>| {
let children = members.into_iter().map(std::sync::Arc::new).collect();
let mut c = Node::new(NodeId(0), vectorcraft_doc::NodeKind::Compound { children, rule: Default::default() });
c.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
c
};
assert!(!warns_of_brushes(d.clone(), compound(vec![brushed_square()]), true));
let mut stroked = compound(vec![rect(Rect::new(60.0, 50.0, 160.0, 150.0), (1.0, 1.0, 1.0))]);
stroked.appearance = brushed_square().appearance;
assert!(warns_of_brushes(d, stroked, true));
}
/// A pattern's tiles draw the brushed strokes in its art: an element filled with such a pattern
/// counts, and one filled with a pattern without them does not.
#[test]
fn a_brushed_stroke_in_a_pattern_counts() {
for (brushed_tile, expected) in [(true, true), (false, false)] {
let mut d = Document::new(200.0, 200.0);
let tile = if brushed_tile { brushed_square() } else { rect(Rect::new(60.0, 50.0, 160.0, 150.0), (1.0, 1.0, 1.0)) };
d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Tile", vec![std::sync::Arc::new(tile)]));
let pattern = Paint::Pattern { pattern: "Tile".into(), xf: vectorcraft_geom::Affine::IDENTITY };
let filled = Node::path(NodeId(0), shapes::rectangle(Rect::new(60.0, 50.0, 160.0, 150.0)), Appearance::basic(pattern, Paint::None, 0.0));
assert_eq!(warns_of_brushes(d, filled, true), expected, "brushed tile {brushed_tile}");
}
}
/// A stroke whose brush the export does not know is drawn as a plain stroke, and a stroke without
/// width draws nothing, not even its pattern's brushed strokes.
#[test]
fn strokes_that_draw_no_brush_art_leave_the_masks_as_they_are() {
let mut unknown = brushed_square();
if let Some(vectorcraft_doc::AppearanceItem::Stroke(st)) = unknown.appearance.items.last_mut() {
st.brush = Some("No Such Brush".into());
}
assert!(!warns_of_brushes(Document::new(200.0, 200.0), unknown, true));
let mut d = Document::new(200.0, 200.0);
d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Tile", vec![std::sync::Arc::new(brushed_square())]));
let pattern = Paint::Pattern { pattern: "Tile".into(), xf: vectorcraft_geom::Affine::IDENTITY };
let mut thin = rect(Rect::new(60.0, 50.0, 160.0, 150.0), (0.0, 0.0, 1.0));
thin.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(pattern, 0.0)));
assert!(!warns_of_brushes(d, thin, true));
}
/// Type draws the brushed strokes in the patterns its characters are filled with and in the art
/// of its inline graphics.
#[test]
fn brushed_strokes_in_type_patterns_and_inline_graphics_count() {
let text = |d: &mut Document, fill: Paint, inline: bool| {
let style = vectorcraft_doc::CharStyle { size: 60.0, fill, ..Default::default() };
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(60.0, 120.0), "Type", style.clone());
if inline {
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Brushed".into(), art: std::sync::Arc::new(brushed_square()) });
let mut run = vectorcraft_doc::TextRun::new("\u{FFFC}", style);
run.inline = Some(vectorcraft_doc::InlineArt::new("Brushed"));
t.runs.push(run);
}
Node::new(NodeId(0), vectorcraft_doc::NodeKind::Text(Box::new(t)))
};
let black = Paint::solid(Color::BLACK);
let mut d = Document::new(200.0, 200.0);
let plain = text(&mut d, black.clone(), false);
assert!(!warns_of_brushes(d.clone(), plain, true));
d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Tile", vec![std::sync::Arc::new(brushed_square())]));
let patterned = text(&mut d, Paint::Pattern { pattern: "Tile".into(), xf: vectorcraft_geom::Affine::IDENTITY }, false);
assert!(warns_of_brushes(d, patterned, true));
let mut d = Document::new(200.0, 200.0);
let inline = text(&mut d, black, true);
assert!(warns_of_brushes(d, inline, true));
}
/// Type draws the brushed strokes of its appearance as brush art too.
#[test]
fn a_brushed_stroke_on_type_counts() {
let style = vectorcraft_doc::CharStyle { size: 60.0, ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(60.0, 120.0), "Type", style);
let mut n = Node::new(NodeId(0), vectorcraft_doc::NodeKind::Text(Box::new(text)));
n.appearance = brushed_square().appearance;
assert!(warns_of_brushes(Document::new(200.0, 200.0), n, true));
}
/// The brushed strokes a mask would draw are found in the opacity-mask art of a symbol's art too,
/// and a disabled opacity mask, which is not drawn, holds none.
#[test]
fn brushed_strokes_in_mask_art_count_only_where_the_mask_is_on() {
for (in_symbol, disabled, expected) in [(true, false, true), (false, true, false), (false, false, true)] {
let mut d = Document::new(200.0, 200.0);
let mut line = rect(Rect::new(0.0, 0.0, 100.0, 100.0), (1.0, 1.0, 1.0));
let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
st.brush = Some("3 pt. Round".into());
line.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(st));
let mut mask = vectorcraft_doc::OpacityMask::new(line, true);
(mask.linked, mask.disabled) = (false, disabled);
let mut masked = rect(Rect::new(0.0, 0.0, 100.0, 100.0), (0.0, 0.0, 1.0));
masked.extras_mut().mask = Some(Box::new(mask));
let mut upper = if in_symbol {
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(masked)]);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Masked".into(), art: std::sync::Arc::new(art) });
let xf = vectorcraft_geom::Affine::translate((60.0, 50.0));
Node::new(NodeId(0), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Masked".into(), xf })
} else {
masked.transform(vectorcraft_geom::Affine::translate((60.0, 50.0)), false);
masked
};
(upper.id, upper.opacity, upper.knockout_shape) = (d.alloc_id(), 0.5, true);
let mut lower = rect(Rect::new(20.0, 50.0, 120.0, 150.0), (1.0, 0.0, 0.0));
(lower.id, lower.opacity, lower.knockout_shape) = (d.alloc_id(), 0.5, true);
let mut g = Node::group(NodeId(0), vec![std::sync::Arc::new(lower), std::sync::Arc::new(upper)]);
g.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, g);
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
let warned = r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_BRUSHES);
assert_eq!(warned, expected, "symbol {in_symbol}, disabled {disabled}: {:?}", r.warnings);
}
}
/// PDF/A-2 allows transparency: a knockout group is written as one there too.
#[test]
fn a_pdf_a_2b_file_keeps_a_knockout_group() {
let mut o = PdfOptions::default();
(o.settings.standard, o.settings.compatibility) = (Standard::PdfA2b, Standard::PdfA2b.version());
let bytes = export(&knockout_squares(3, false, false), &o).unwrap();
assert_eq!(knockout_groups(&import(&bytes).unwrap()), [3]);
}
+1 -3
View File
@@ -91,9 +91,7 @@ fn fixture(form: Form, kind: GradientKind, mode: ColorMode, origin: Point) -> (D
/// Round trips preserve gradient placement for the form types the importer supports.
#[test]
fn gradients_in_forms_keep_their_position_when_read_back() {
// The importer has a pre-existing mismatch on the knockout's synthesized nested masks;
// Quartz below checks its actual PDF output, along with the other forms.
for form in FORMS.into_iter().filter(|f| !matches!(f, Form::Knockout)) {
for form in FORMS {
for kind in [GradientKind::Linear, GradientKind::Radial] {
for origin in [Point::ORIGIN, Point::new(125.0, -80.0)] {
let (d, opts) = fixture(form, kind, ColorMode::Rgb, origin);
+12 -5
View File
@@ -356,7 +356,7 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
};
let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_ref());
let b = match &reshaped {
Some(r) if matches!(n.kind, NodeKind::SymbolInstance { .. }) && effects::has_item_geometry(n) => instance_piece_bounds(symbols, r),
Some(r) if has_instance_row_effects(n) => instance_piece_bounds(symbols, r),
Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => {
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c)))
}
@@ -379,9 +379,16 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
}
}
/// Is `n` a symbol instance whose active fill or stroke has geometry or raster effects that the
/// fills and strokes of its art take ([`effects::has_item_geometry`],
/// [`effects::has_instance_row_raster`])?
fn has_instance_row_effects(n: &Node) -> bool {
matches!(n.kind, NodeKind::SymbolInstance { .. }) && (effects::has_item_geometry(n) || effects::has_instance_row_raster(n))
}
/// The bounds of `art`, the art [`effects::reshape`] made of a symbol instance whose active fill or
/// stroke has geometry effects ([`effects::has_item_geometry`]): its paths measured with the
/// geometry effects of their fills and strokes ([`visual_bounds`]), the symbol instances in it that
/// stroke has geometry or raster effects ([`has_instance_row_effects`]): its paths measured with the
/// effects of their fills and strokes, shadows and glows included ([`visual_bounds`]), the symbol instances in it that
/// took those effects by their own reshaped art in turn, its groups and layers with their own fills
/// and strokes by the art those paint ([`effects::evaluate_container`]), its clip groups by their
/// clipping paths and other objects as [`Node::visual_bounds_with`] measures them. Hidden objects are left out.
@@ -409,7 +416,7 @@ fn pieces_within(symbols: &SymbolBoxes, art: &Node, depth: usize, budget: &mut u
g.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, pieces_within(symbols, c, depth, budget)))
}
NodeKind::Path { .. } | NodeKind::Compound { .. } if has_fx(art) => visual_bounds(symbols, art),
NodeKind::SymbolInstance { symbol, .. } if depth < vectorcraft_doc::depth::MAX_DEPTH && effects::has_item_geometry(art) => {
NodeKind::SymbolInstance { symbol, .. } if depth < vectorcraft_doc::depth::MAX_DEPTH && has_instance_row_effects(art) => {
let doc = symbols.document();
let reshaped = doc.symbols.iter().find(|s| s.name == *symbol).and_then(|s| {
let count = s.art.count() as u64;
@@ -639,7 +646,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b
};
let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_ref());
let b = match &reshaped {
Some(r) if matches!(n.kind, NodeKind::SymbolInstance { .. }) && effects::has_item_geometry(n) => instance_piece_bounds(symbols, r),
Some(r) if has_instance_row_effects(n) => instance_piece_bounds(symbols, r),
Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => {
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
}
+5 -3
View File
@@ -20,12 +20,14 @@ pub(crate) fn has_fx(n: &Node) -> bool {
/// Does `n` carry visible object-level effects that only apply through its art (type, images,
/// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with
/// a geometry effect on a fill or stroke of its own or an image with a fill or stroke of its own
/// that paints a shape ([`effects::has_item_geometry`]), is it a path whose Transform effect copies
/// it with a gradient or changes its fills and strokes ([`effects::has_transform_art`]), or is it a
/// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
/// that paints a shape ([`effects::has_item_geometry`]), a symbol instance whose active fill or
/// stroke has a raster effect ([`effects::has_instance_row_raster`]), is it a path whose Transform
/// effect copies it with a gradient or changes its fills and strokes ([`effects::has_transform_art`]),
/// or is it a group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
pub(crate) fn has_object_fx(n: &Node) -> bool {
(effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some()))
|| effects::has_item_geometry(n)
|| effects::has_instance_row_raster(n)
|| effects::has_transform_art(n)
|| effects::has_container_appearance(n)
|| effects::has_crop_marks(n)
+149 -64
View File
@@ -163,6 +163,18 @@ fn pin_to_page(n: &mut Node, m: Affine) {
/// What SVG Tiny 1.2 does without masks.
const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups";
/// What a knockout group too large to mask is written as.
const KNOCKOUT_SIZE: &str = "knockout groups with too many overlapping elements to mask are written as plain groups";
/// The most knockout shapes the masks of one knockout group's elements use between them: each
/// mask uses the shapes of the elements above its own element that overlap it.
const MAX_KNOCKOUT_MASK_SHAPES: usize = 1 << 16;
/// The most elements of one knockout group drawn through masks: finding the elements that
/// overlap compares every pair.
const MAX_MASKED_ELEMENTS: usize = 2048;
pub(super) const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
/// The region of a mask or filter over art that has no box to measure: one covering any artwork.
@@ -450,20 +462,39 @@ impl Writer<'_> {
region
}
/// The box of what each of `nodes` paints, as [`Self::paint_box`] measures it.
fn paint_boxes(&mut self, nodes: &[&Node]) -> Vec<Option<Option<Rect>>> {
let symbols = self.region_boxes.take().unwrap_or_else(|| self.doc.symbol_boxes());
let mut art_reach = std::mem::take(&mut self.art_reach);
let boxes = nodes.iter().map(|n| self.paint_box(&symbols, &mut art_reach, n)).collect();
self.region_boxes = Some(symbols);
self.art_reach = art_reach;
boxes
}
/// The box of what `n` paints in the current user space, as [`Self::region_attrs`] measures
/// it: `Some(None)` when it has no box, `None` when the box can't be told (type, or nesting
/// deeper than [`MAX_REACH_DEPTH`]).
fn paint_box(&self, symbols: &vectorcraft_doc::SymbolBoxes, art_reach: &mut HashMap<String, f64>, n: &Node) -> Option<Option<Rect>> {
let Some(v) = n.visual_bounds_with(symbols) else { return Some(None) };
let r = self.paint_reach(symbols, art_reach, n, v, 0)?;
let g = n.geometric_bounds_with(symbols).unwrap_or(v);
let stroke = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1].into_iter().fold(0.0, f64::max);
let scaled = self.xf.transform_rect_bbox(v.inflate(r, r));
let unscaled = self.xf.transform_rect_bbox(g).inflate(stroke + r, stroke + r);
Some(Some(scaled.union(unscaled)))
}
/// [`Self::region_attrs`] with the document's symbol boxes and the symbols' art reach so far.
fn region_of(&self, symbols: &vectorcraft_doc::SymbolBoxes, art_reach: &mut HashMap<String, f64>, nodes: &[&Node]) -> String {
let mut painted: Option<Rect> = None;
for n in nodes {
let Some(v) = n.visual_bounds_with(symbols) else { continue };
let Some(r) = self.paint_reach(symbols, art_reach, n, v, 0) else {
let Some(b) = self.paint_box(symbols, art_reach, n) else {
return BIG.to_string();
};
let g = n.geometric_bounds_with(symbols).unwrap_or(v);
let stroke = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1].into_iter().fold(0.0, f64::max);
let scaled = self.xf.transform_rect_bbox(v.inflate(r, r));
let unscaled = self.xf.transform_rect_bbox(g).inflate(stroke + r, stroke + r);
let both = scaled.union(unscaled);
painted = Some(painted.map_or(both, |p| p.union(both)));
if let Some(both) = b {
painted = Some(painted.map_or(both, |p| p.union(both)));
}
}
let Some(b) = painted.map(|b| b.inflate(2.0, 2.0)).filter(|b| b.is_finite()) else {
return BIG.to_string();
@@ -572,8 +603,10 @@ impl Writer<'_> {
}
/// Children of the group being written. SVG has no knockout groups: in one, each element is
/// drawn through a mask of where the elements above it don't paint (the same look for Normal
/// blending). The masks nest, so element i sits inside the masks of elements i+1…n.
/// drawn through one mask of where the elements above it that overlap it don't paint (the
/// same look for Normal blending). The masks share one copy of each element's knockout shape,
/// and the elements stay siblings however many there are. A group whose masks would hold more
/// than [`MAX_KNOCKOUT_MASK_SHAPES`] shapes is written as a plain group, with a warning.
pub(super) fn children(&mut self, children: &[std::sync::Arc<Node>]) {
if self.knockout && self.tiny() {
self.warn(TINY_MASKS);
@@ -603,36 +636,124 @@ impl Writer<'_> {
}
return;
}
let Some((first, rest)) = elements.split_first() else { return };
// The elements are written first, in paint order, so that each mask copies an element
let all: Vec<&Node> = elements.iter().map(|c| &***c).collect();
let Some(above) = self.knockout_masks(&all) else {
self.warn(KNOCKOUT_SIZE);
for c in elements {
self.node(c);
}
return;
};
// The elements are written first, in paint order, so that each shape copies an element
// with the symbol art count the element itself was written with
// (`Self::symbol_nodes`): an instance left out of an element is left out of its mask.
let (depth, nest) = (self.depth, rest.len());
let first = self.detached(depth + nest, |w| w.node(first));
let mut written = Vec::with_capacity(nest);
for (i, c) in rest.iter().enumerate() {
// (`Self::symbol_nodes`): an instance left out of an element is left out of its shape.
let depth = self.depth;
let mut written = Vec::with_capacity(elements.len());
for (c, above) in elements.iter().zip(&above) {
let before = self.symbol_nodes;
written.push((before, self.detached(depth + nest - 1 - i, |w| w.node(c))));
written.push((before, self.detached(depth + usize::from(!above.is_empty()), |w| w.node(c))));
}
let after = self.symbol_nodes;
// Each mask covers what the group's elements paint.
let all: Vec<&Node> = elements.iter().map(|c| &***c).collect();
let region = self.region_attrs(&all);
for (c, (before, _)) in rest.iter().zip(&written).rev() {
self.symbol_nodes = *before;
let mid = self.knockout_mask(c, &region);
self.line(&format!("<g mask=\"url(#{mid})\">"));
self.depth += 1;
let mut shapes: Vec<Option<String>> = vec![None; elements.len()];
for &j in above.iter().flatten() {
if shapes[j].is_none() {
self.symbol_nodes = written[j].0;
shapes[j] = Some(self.knockout_shape(elements[j]));
}
}
self.symbol_nodes = after;
self.body.push_str(&first);
for (_, body) in written {
self.depth -= 1;
self.line("</g>");
for (above, (_, body)) in above.iter().zip(written) {
if above.is_empty() {
self.body.push_str(&body);
continue;
}
let ids: Vec<&str> = above.iter().filter_map(|&j| shapes[j].as_deref()).collect();
let mid = self.knockout_mask(&ids, &region);
self.line(&format!("<g mask=\"url(#{mid})\">"));
self.body.push_str(&body);
self.line("</g>");
}
}
/// For each element of a knockout group, the indices of the elements above it whose painted
/// boxes overlap its own (every one above it when a box can't be told). `None` when there are
/// more than [`MAX_MASKED_ELEMENTS`] elements or the masks would hold more than
/// [`MAX_KNOCKOUT_MASK_SHAPES`] shapes.
fn knockout_masks(&mut self, elements: &[&Node]) -> Option<Vec<Vec<usize>>> {
if elements.len() > MAX_MASKED_ELEMENTS {
return None;
}
let boxes = self.paint_boxes(elements);
let overlap = |a: Option<Option<Rect>>, b: Option<Option<Rect>>| match (a, b) {
(Some(None), _) | (_, Some(None)) => false,
(Some(Some(a)), Some(Some(b))) => a.x0 <= b.x1 + 2.0 && b.x0 <= a.x1 + 2.0 && a.y0 <= b.y1 + 2.0 && b.y0 <= a.y1 + 2.0,
_ => true,
};
let mut shapes = 0usize;
let mut out = Vec::with_capacity(elements.len());
for (i, &below) in boxes.iter().enumerate() {
let mut above = vec![];
for (j, &over) in boxes.iter().enumerate().skip(i + 1) {
if overlap(below, over) {
shapes = shapes.checked_add(1).filter(|&s| s <= MAX_KNOCKOUT_MASK_SHAPES)?;
above.push(j);
}
}
out.push(above);
}
Some(out)
}
/// One copy of the knockout shape of `c` in the definitions: `c` at full object opacity
/// without its own mask, unless those define its shape. Returns its id.
fn knockout_shape(&mut self, c: &Node) -> String {
let shape = if c.knockout_shape {
c.clone()
} else {
let mut s = c.clone().with_mask(None);
s.opacity = 1.0;
s
};
let anonymous = std::mem::replace(&mut self.anonymous, true);
let art = self.detached(2, |w| w.node(&shape));
self.anonymous = anonymous;
let id = self.fresh_id("knockout-art");
self.def(1, &format!("<g id=\"{id}\">"));
self.defs.push_str(&art);
self.def(1, "</g>");
id
}
/// A mask that is 1 − the knockout shapes `ids` ([`Self::knockout_shape`]): white, then the
/// shapes painted black. `region` is the attributes of the mask's region
/// ([`Self::region_attrs`]), which the filter that paints the shapes black shares. Returns
/// the mask id.
fn knockout_mask(&mut self, ids: &[&str], region: &str) -> String {
let filter = match self.knockout_filters.get(region) {
Some(f) => f.clone(),
None => {
let f = self.fresh_id("knockout-shape");
self.def(1, &format!("<filter id=\"{f}\" filterUnits=\"userSpaceOnUse\" {region}>"));
self.def(2, "<feColorMatrix type=\"matrix\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0\"/>");
self.def(1, "</filter>");
self.knockout_filters.insert(region.to_string(), f.clone());
f
}
};
let mid = self.fresh_id("knockout");
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {region}>"));
self.def(2, &format!("<rect {region} fill=\"white\"/>"));
self.def(2, &format!("<g filter=\"url(#{filter})\">"));
for id in ids {
self.def(3, &format!("<use xlink:href=\"#{id}\"/>"));
}
self.def(2, "</g>");
self.def(1, "</mask>");
mid
}
/// The pieces of a partly transparent mesh-warped image (`elements`) as the elements of a
/// knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece is drawn
/// through one mask of where the pieces above it that reach it don't cover
@@ -683,42 +804,6 @@ impl Writer<'_> {
}
}
/// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object
/// opacity without its own mask, unless those define its shape). `region` is the attributes of
/// the mask's region ([`Self::region_attrs`]), which the filter that paints `c` black shares.
/// Returns the mask id.
fn knockout_mask(&mut self, c: &Node, region: &str) -> String {
let filter = match self.knockout_filters.get(region) {
Some(f) => f.clone(),
None => {
let f = self.fresh_id("knockout-shape");
self.def(1, &format!("<filter id=\"{f}\" filterUnits=\"userSpaceOnUse\" {region}>"));
self.def(2, "<feColorMatrix type=\"matrix\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0\"/>");
self.def(1, "</filter>");
self.knockout_filters.insert(region.to_string(), f.clone());
f
}
};
let shape = if c.knockout_shape {
c.clone()
} else {
let mut s = c.clone().with_mask(None);
s.opacity = 1.0;
s
};
let anonymous = std::mem::replace(&mut self.anonymous, true);
let art = self.detached(3, |w| w.node(&shape));
self.anonymous = anonymous;
let mid = self.fresh_id("knockout");
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {region}>"));
self.def(2, &format!("<rect {region} fill=\"white\"/>"));
self.def(2, &format!("<g filter=\"url(#{filter})\">"));
self.defs.push_str(&art);
self.def(2, "</g>");
self.def(1, "</mask>");
mid
}
/// An object, inside `<a>` when it links to a URL (Attributes panel).
pub(super) fn node(&mut self, n: &Node) {
// Ruler guides are not written, not even as a link.
+168 -15
View File
@@ -110,8 +110,25 @@ struct ElemStyle {
word_spacing: f64,
/// `paint-order` paints the stroke before the fill.
stroke_first: bool,
/// What its characters' Character Direction ([`Ctx::elem`]) leaves out of the file's
/// `unicode-bidi`, for the import's warnings.
bidi_notes: Vec<&'static str>,
}
/// An override (`unicode-bidi="bidi-override"`) read as an embedding.
const BIDI_OVERRIDE: &str = "unicode-bidi=\"bidi-override\" imported as an embedding; directional override is not supported";
/// An isolate (`unicode-bidi="isolate"`) read as an embedding.
const BIDI_ISOLATE: &str = "unicode-bidi=\"isolate\" imported as an embedding; isolation is not supported";
/// `unicode-bidi="isolate-override"` read as an embedding.
const BIDI_ISOLATE_OVERRIDE: &str = "unicode-bidi=\"isolate-override\" imported as an embedding; isolation and override are not supported";
/// `unicode-bidi="plaintext"` read as an embedding in the direction of its first strong character.
const BIDI_PLAINTEXT: &str =
"unicode-bidi=\"plaintext\" imported as an embedding in the direction of its first strong character; isolation is not supported";
/// An embedding inside another, kept as an embedding of its own.
const BIDI_NESTED: &str = "a unicode-bidi embedding inside another imported one level deep; its characters may be ordered differently";
/// An override on the `<text>` itself, which keeps only its paragraph direction.
const BIDI_TEXT_OVERRIDE: &str = "unicode-bidi override on the text imported as its paragraph direction; directional override is not supported";
/// One character of a text and the adjustments its position needs.
struct Cell {
ch: char,
@@ -694,6 +711,9 @@ struct Ctx {
sizes: RefCell<HashMap<roxmltree::NodeId, f64>>,
weights: RefCell<HashMap<roxmltree::NodeId, u16>>,
elems: RefCell<HashMap<roxmltree::NodeId, Rc<ElemStyle>>>,
/// Whether the first strong character of each element read so far is right to left
/// ([`Self::first_strong_rtl`]); `None` where it holds none.
strong: RefCell<HashMap<roxmltree::NodeId, Option<bool>>>,
/// The path each `<textPath>` read so far follows ([`Self::text_path`]); `None` where it has
/// no usable one. The styles of a `<use>` instance share it, so each path is found and parsed
/// once however many `<use>` elements show its text.
@@ -716,6 +736,7 @@ impl Ctx {
sizes: RefCell::default(),
weights: RefCell::default(),
elems: RefCell::default(),
strong: RefCell::default(),
paths: Rc::default(),
};
let root = xml.root_element();
@@ -737,6 +758,7 @@ impl Ctx {
sizes: RefCell::default(),
weights: RefCell::default(),
elems: RefCell::default(),
strong: RefCell::default(),
paths: self.paths.clone(),
}
}
@@ -915,6 +937,8 @@ impl Ctx {
};
let parent = if n.tag_name().name() == "text" { 0.0 } else { n.parent_element().map_or(0.0, |p| self.elem(p).style.baseline_shift) };
st.baseline_shift = parent + own;
let (char_direction, bidi_notes) = self.char_direction(n);
st.char_direction = char_direction;
if self.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") {
(st.fill, st.stroke, fill_url, stroke_url) = (Paint::None, Paint::None, None, None);
}
@@ -922,11 +946,93 @@ impl Ctx {
let order = self.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order;
let at = |k| order.iter().position(|o| *o == k);
let stroke_first = at(svgtypes::PaintOrderKind::Stroke) < at(svgtypes::PaintOrderKind::Fill);
let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing, stroke_first });
let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing, stroke_first, bidi_notes });
self.elems.borrow_mut().insert(n.id(), e.clone());
e
}
/// The computed `unicode-bidi` of element `n`, in lowercase. It is not inherited: `normal`
/// unless `n` sets it, and its parent's value for `inherit`.
fn unicode_bidi(&self, n: XNode) -> String {
let mut at = Some(n);
while let Some(a) = at {
match self.css.own(a, "unicode-bidi").map(|v| v.trim().to_ascii_lowercase()) {
Some(v) if v == "inherit" => at = self.parent(a),
Some(v) => return v,
None => break,
}
}
"normal".into()
}
/// The Character Direction of the characters of element `n` inside a `<text>`, and what it
/// leaves out (SVG 1.1 §10.7.4,
/// https://www.w3.org/TR/SVG11/text.html#RelationshipWithBi-directionality, and CSS Writing
/// Modes 3 §2.2, https://www.w3.org/TR/css-writing-modes-3/#unicode-bidi). A `<tspan>`,
/// `<textPath>` or `<a>` whose `unicode-bidi` opens an embedding gives its characters an
/// embedding in its `direction`; one that opens none (`normal`) leaves them in its parent's.
/// The text model holds one level of embedding and no override or isolation: overrides and
/// isolates become embeddings, `plaintext` an embedding in the direction of its first strong
/// character, and an embedding inside another an embedding of its own, each with a note. The
/// `<text>` itself sets the paragraph direction ([`pin_direction`]) and no Character Direction.
fn char_direction(&self, n: XNode) -> (Option<ParaDirection>, Vec<&'static str>) {
let name = n.tag_name().name();
let bidi = self.unicode_bidi(n);
if !matches!(name, "tspan" | "textPath" | "a") {
let notes =
if name == "text" && matches!(bidi.as_str(), "bidi-override" | "isolate-override") { vec![BIDI_TEXT_OVERRIDE] } else { vec![] };
return (None, notes);
}
let parent = n.parent_element().map(|p| self.elem(p));
let inherited = parent.as_ref().and_then(|p| p.style.char_direction);
let mut notes = parent.map_or_else(Vec::new, |p| p.bidi_notes.clone());
let rtl = |rtl: bool| Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight });
let own = || rtl(self.prop(n, "direction").is_some_and(|d| d.trim() == "rtl"));
let (dir, note) = match bidi.as_str() {
"embed" => (own(), None),
"bidi-override" => (own(), Some(BIDI_OVERRIDE)),
"isolate" => (own(), Some(BIDI_ISOLATE)),
"isolate-override" => (own(), Some(BIDI_ISOLATE_OVERRIDE)),
"plaintext" => (rtl(self.first_strong_rtl(n) == Some(true)), Some(BIDI_PLAINTEXT)),
// `normal`, and a value CSS ignores.
_ => return (inherited, notes),
};
for note in note.into_iter().chain(inherited.map(|_| BIDI_NESTED)) {
if !notes.contains(&note) {
notes.push(note);
}
}
(dir, notes)
}
/// Whether the first strong character (UAX #9 P2) of the characters [`Self::collect`] reads
/// from element `n` is right to left; `None` when they hold none. Each element is read once.
fn first_strong_rtl(&self, n: XNode) -> Option<bool> {
if let Some(s) = self.strong.borrow().get(&n.id()) {
return *s;
}
let s = n.children().find_map(|c| {
if c.is_text() {
text_first_strong_rtl(c.text().unwrap_or(""))
} else if self.is_inline_child(c) {
self.first_strong_rtl(c)
} else {
None
}
});
self.strong.borrow_mut().insert(n.id(), s);
s
}
/// Whether child `c` of a `<text>` content element holds characters of the text: a shown
/// `<tspan>`, `<textPath>` or `<a>`.
fn is_inline_child(&self, c: XNode) -> bool {
c.is_element()
&& c.tag_name().namespace() == Some(SVG_NS)
&& matches!(c.tag_name().name(), "tspan" | "textPath" | "a")
&& self.css.own(c, "display").as_deref() != Some("none")
}
/// Characters of `el` and its `<tspan>`, `<textPath>` and `<a>` children, with white space
/// handled like `xml:space` (newlines and tabs become spaces; runs of spaces collapse unless
/// preserved).
@@ -943,11 +1049,7 @@ impl Ctx {
}
out.chars.push((ch, el));
}
} else if c.is_element()
&& c.tag_name().namespace() == Some(SVG_NS)
&& matches!(c.tag_name().name(), "tspan" | "textPath" | "a")
&& self.css.own(c, "display").as_deref() != Some("none")
{
} else if self.is_inline_child(c) {
if c.tag_name().name() == "textPath" {
out.path.get_or_insert(c);
}
@@ -1234,6 +1336,16 @@ impl Ctx {
}
})
.collect();
// What the Character Direction leaves out of the file's `unicode-bidi`, once for the text.
let mut bidi_notes: Vec<&str> = vec![];
for (el, _) in cs.spans.iter().filter(|(_, r)| !r.is_empty() && r.start < n) {
for note in &self.elem(*el).bidi_notes {
if !bidi_notes.contains(note) {
bidi_notes.push(note);
}
}
}
warnings.extend(bidi_notes.into_iter().map(|note| format!("{label}: {note}")));
let under = stroke_under(&mut cells, label, warnings);
let path = cs.path.and_then(|tp| match self.text_path(xml, tp) {
@@ -1244,7 +1356,16 @@ impl Ctx {
}
});
let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical"));
let rtl = self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl");
// With `unicode-bidi: plaintext`, the text runs in the direction of its first strong
// character, and each paragraph in that of its own ([`pin_first_strong`]).
let plaintext = self.unicode_bidi(t) == "plaintext";
let rtl = if plaintext { self.first_strong_rtl(t) == Some(true) } else { self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") };
let pin = |obj: &mut TextObject| {
pin_direction(obj, rtl);
if plaintext {
pin_first_strong(obj);
}
};
// Where the chunk starting at character `i` sits on its anchor: 0 its left (or top) edge,
// 0.5 its middle, 1 its right (or bottom) edge; on a path, its edge toward the path's
// start, its middle or its edge toward the path's end. In horizontal type `start` and
@@ -1258,7 +1379,15 @@ impl Ctx {
Some("end") => 1.0,
_ => 0.0,
};
if !vertical && self.prop(el, "direction").is_some_and(|d| d.trim() == "rtl") { 1.0 - a } else { a }
// With `unicode-bidi: plaintext` on the `<text>`, a chunk runs in the direction of its
// first strong character, left to right without one ([`pin_first_strong`]).
let chunk_rtl = || {
let end = (i + 1..n).find(|&j| x[j].is_some() || y[j].is_some()).unwrap_or(n);
let chunk: String = cs.chars[i..end].iter().map(|c| c.0).collect();
text_first_strong_rtl(&chunk) == Some(true)
};
let rtl = if plaintext { chunk_rtl() } else { self.prop(el, "direction").is_some_and(|d| d.trim() == "rtl") };
if !vertical && rtl { 1.0 - a } else { a }
};
let anchor = anchor_of(0);
self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings);
@@ -1306,7 +1435,7 @@ impl Ctx {
obj.xf = Affine::IDENTITY;
} else {
let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]);
let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, rtl, origin.x, &mut breaks);
let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, &pin, origin.x, &mut breaks);
obj.para.justify = justify;
obj.xf = Affine::translate((finite(left), finite(origin.y)));
// Lines anchored differently or at different x (paragraphs aligned or indented
@@ -1332,7 +1461,7 @@ impl Ctx {
if !para_styles.is_empty() {
obj.set_paragraph_styles(para_styles);
}
pin_direction(&mut obj, rtl);
pin(&mut obj);
keep_reader_anchors(&mut obj);
// The placeholder covers the text (laid out when a paint server needs its bounding box).
let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default();
@@ -1437,9 +1566,9 @@ fn leading_tolerance(spacing: f64) -> f64 {
/// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the
/// justification, the x of the first line's left (or anchor) edge and each line's first
/// character, alignment (as for [`justify_of`]) and anchor x. `anchor_of` gives where the chunk
/// starting at a character sits on its anchor, and `rtl` whether the text's `direction` is `rtl`
/// ([`pin_direction`]). When characters are placed on their own, every line is left-aligned and
/// its anchor x is its left edge.
/// starting at a character sits on its anchor, and `pin` sets the paragraph directions of the
/// text ([`pin_direction`]). When characters are placed on their own, every line is left-aligned
/// and its anchor x is its left edge.
#[allow(clippy::too_many_arguments)]
fn lines(
cells: &mut [Cell],
@@ -1448,7 +1577,7 @@ fn lines(
dx: &[f64],
dy: &[f64],
anchor_of: &dyn Fn(usize) -> f64,
rtl: bool,
pin: &dyn Fn(&mut TextObject),
x0: f64,
breaks: &mut Vec<Break>,
) -> (Justify, f64, Vec<(usize, f64, f64)>) {
@@ -1519,7 +1648,7 @@ fn lines(
for round in 0..4 {
let (runs, bytes) = assemble(cells, breaks);
let mut text = with_runs(runs.0);
pin_direction(&mut text, rtl);
pin(&mut text);
let (pens, ends) = pens(&measure(&text), &bytes);
// The edges of each line's first chunk, found once a round.
let first: Vec<Option<(f64, f64)>> = lines.iter().map(|&(s, _)| edges(s, &pens, &ends)).collect();
@@ -1621,6 +1750,30 @@ fn pin_direction(obj: &mut TextObject, rtl: bool) {
}
}
/// `unicode-bidi: plaintext` on a `<text>` (CSS Writing Modes 3 §2.2,
/// https://www.w3.org/TR/css-writing-modes-3/#unicode-bidi): each paragraph of `obj` is pinned to
/// the direction of its first strong character, and left to right without one (UAX #9 P2, P3).
fn pin_first_strong(obj: &mut TextObject) {
let text = obj.plain_text();
let mut styles = obj.paragraph_styles();
for (para, style) in text.split('\n').zip(styles.iter_mut()) {
let rtl = text_first_strong_rtl(para) == Some(true);
style.direction = Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight });
}
obj.set_paragraph_styles(styles);
}
/// Whether the first strong character (UAX #9 P2) of `text` is right to left; `None` when it holds
/// none. Paragraph separators count as spaces, so all of `text` is searched.
fn text_first_strong_rtl(text: &str) -> Option<bool> {
let line: String = text.chars().map(|c| if matches!(c, '\n' | '\r' | '\u{1C}'..='\u{1E}' | '\u{85}' | '\u{2029}') { ' ' } else { c }).collect();
if vectorcraft_text::paragraph_is_rtl(&line, None) {
return Some(true);
}
// Without a strong character of its own, a right-to-left letter after it is the first one.
(!vectorcraft_text::paragraph_is_rtl(&format!("{line}\u{5D0}"), None)).then_some(false)
}
/// A left-aligned point text of `runs` at the origin (for measuring).
fn with_runs(runs: Vec<TextRun>) -> TextObject {
let mut t = TextObject::point(Point::ZERO, "", CharStyle::default());
+90
View File
@@ -262,3 +262,93 @@ fn a_mask_region_covers_a_blend_as_written() {
let [x, y, w, h] = ["x", "y", "width", "height"].map(|a| mask.attribute(a).and_then(|v| v.parse::<f64>().ok()).unwrap_or(f64::NAN));
assert!(x <= 0.0 && y <= 0.0 && x + w >= 95.0 && y + h >= 35.0 && w < 200.0 && h < 200.0, "{x} {y} {w} {h} in {svg}");
}
/// A knockout group of `n` red, green and blue squares at 50% opacity that all overlap, over an
/// opaque backdrop. `knockout_shape`: their opacity defines their knockout shape.
fn knockout_squares(n: usize, knockout_shape: bool) -> Document {
let mut d = Document::new(200.0, 200.0);
let backdrop = rect(&mut d, Rect::new(0.0, 0.0, 200.0, 200.0), [230, 200, 50]);
add(&mut d, backdrop);
let colors = [[255, 0, 0], [0, 200, 0], [0, 0, 255]];
let squares = (0..n)
.map(|i| {
let x = (i % 20) as f64 * 2.0;
let mut s = rect(&mut d, Rect::new(20.0 + x, 50.0, 120.0 + x, 150.0), colors[i % 3]);
(s.opacity, s.knockout_shape) = (0.5, knockout_shape);
std::sync::Arc::new(s)
})
.collect();
let mut group = Node::group(d.alloc_id(), squares);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
d
}
/// The deepest an element of `svg` nests, counting the root element as 1.
fn element_depth(svg: &str) -> usize {
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
xml.descendants().filter(|n| n.is_element()).map(|n| n.ancestors().filter(|a| a.is_element()).count()).max().unwrap_or(0)
}
/// The elements of a knockout group are siblings, each in one mask of the overlapping elements
/// above it, however many there are: 200 translucent elements stay well within the nesting the
/// web build reads (34 levels), and the file reads back with nothing merged. Where the elements'
/// shapes are the area they paint, the file draws as the canvas does.
#[test]
fn a_knockout_group_of_many_translucent_elements_stays_shallow() {
for knockout_shape in [false, true] {
let d = knockout_squares(200, knockout_shape);
let (svg, warnings) = vectorcraft_svg::export_with_report(&d, &ExportOptions::default());
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(svg.matches("<mask").count(), 199);
// Each shape is written once.
assert_eq!(svg.matches("<use ").count(), 200 * 199 / 2);
let depth = element_depth(&svg);
assert!(depth < 12, "{depth}");
let (back, notes) = vectorcraft_svg::import_with_report(&svg).unwrap();
assert!(!notes.contains(&vectorcraft_doc::depth::merged_note()), "{notes:?}");
assert!(back.layers[0].children().is_some_and(|c| !c.is_empty()));
if !knockout_shape {
let (file, canvas) = (resvg_render(&svg, 200, 200), render_artboard(&d));
for (x, y) in [(30, 100), (100, 100), (150, 100), (10, 10)] {
assert!(
within(file.over_white(x, y), canvas.over_white(x, y), 3),
"{x},{y}: {:?} {:?}",
file.over_white(x, y),
canvas.over_white(x, y)
);
}
}
}
}
/// When the masks of a knockout group would use more than 65,536 shapes, the group is written as
/// a plain group, with a warning.
#[test]
fn a_knockout_group_too_large_to_mask_is_a_plain_group_with_a_warning() {
// 400 elements that all overlap: 79,800 shapes in their masks.
let d = knockout_squares(400, false);
let (svg, warnings) = vectorcraft_svg::export_with_report(&d, &ExportOptions::default());
assert!(warnings.iter().any(|w| w.contains("too many overlapping elements")), "{warnings:?}");
assert!(!svg.contains("<mask"));
assert!(element_depth(&svg) < 12);
}
/// Elements that don't overlap need no masks of each other.
#[test]
fn knockout_masks_hold_only_the_elements_that_overlap() {
let mut d = Document::new(200.0, 100.0);
let squares = (0..3)
.map(|i| {
let x = f64::from(i) * 60.0;
let mut s = rect(&mut d, Rect::new(x, 0.0, x + 40.0, 40.0), [0, 0, 255]);
s.opacity = 0.5;
std::sync::Arc::new(s)
})
.collect();
let mut group = Node::group(d.alloc_id(), squares);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
let svg = export(&d, &ExportOptions::default());
assert!(!svg.contains("<mask"), "{svg}");
}
+168
View File
@@ -391,6 +391,11 @@ fn rtl_point_text_start_and_end_use_directional_edges() {
(r#"<text x="200" y="60" direction="rtl" font-size="20"><tspan direction="ltr">Wave</tspan></text>"#, 0.0),
(r#"<text x="200" y="60" text-anchor="start" font-size="20">Wave</text>"#, 0.0),
(r#"<text x="200" y="60" direction="ltr" text-anchor="end" font-size="20">Wave</text>"#, 1.0),
// With `unicode-bidi: plaintext`, the direction of the chunk's first strong character.
(r#"<text x="200" y="60" unicode-bidi="plaintext" font-size="20">&#x5D0;&#x5D1;&#x5D2;</text>"#, 1.0),
(r#"<text x="200" y="60" unicode-bidi="plaintext" text-anchor="end" font-size="20">12 &#x5D0;&#x5D1;</text>"#, 0.0),
(r#"<text x="200" y="60" direction="rtl" unicode-bidi="plaintext" font-size="20">Wave</text>"#, 0.0),
(r#"<text x="200" y="60" direction="rtl" unicode-bidi="plaintext" text-anchor="end" font-size="20">12</text>"#, 1.0),
] {
let d = open(body);
let (left, right) = line_edges(only_text(&d))[0];
@@ -464,6 +469,169 @@ fn rtl_lines_use_each_chunk_first_character_direction_and_anchor() {
assert!(close((e[3].0 + e[3].1) / 2.0, 200.0, 1e-6), "a middle line is centered on 200: {e:?}");
}
/// The Character Direction of the run holding `part`.
fn char_direction_of(t: &TextObject, part: &str) -> Option<vectorcraft_doc::ParaDirection> {
t.runs.iter().find(|r| r.text.contains(part)).unwrap_or_else(|| panic!("no run holds {part:?}: {:?}", t.runs)).style.char_direction
}
/// SVG 1.1 §10.7.4: a `<tspan>` whose `unicode-bidi` is `embed` or `bidi-override` opens a bidi
/// embedding in its `direction`; with `normal`, its `direction` leaves the ordering alone. The
/// embedding becomes the Character Direction of its characters, and the `<text>` keeps its own
/// paragraph direction. https://www.w3.org/TR/SVG11/text.html#RelationshipWithBi-directionality
#[test]
fn a_tspan_bidi_embedding_becomes_its_character_direction() {
use vectorcraft_doc::ParaDirection::{LeftToRight as Ltr, RightToLeft as Rtl};
let read = |body: &str| {
let (d, w) = import_with_report(&svg(body)).unwrap_or_else(|e| panic!("{e}"));
(only_text(&d).clone(), w)
};
// (outer direction, inner direction, unicode-bidi, the inner run's Character Direction, a warning)
for (outer, inner, bidi, want, warns) in [
("ltr", "rtl", "embed", Some(Rtl), false),
("rtl", "ltr", "embed", Some(Ltr), false),
("ltr", "rtl", "bidi-override", Some(Rtl), true),
("rtl", "ltr", "bidi-override", Some(Ltr), true),
("ltr", "rtl", "normal", None, false),
("rtl", "ltr", "normal", None, false),
("ltr", "rtl", "EMBED", Some(Rtl), false),
("ltr", "rtl", "sideways", None, false),
] {
let (t, w) = read(&format!(
r#"<text x="20" y="40" font-size="20" direction="{outer}">ab <tspan direction="{inner}" unicode-bidi="{bidi}">1 2</tspan> cd</text>"#
));
let case = format!("{outer} text, {inner} {bidi} tspan");
assert_eq!(t.plain_text(), "ab 1 2 cd", "{case}: the characters stay in logical order");
assert_eq!(char_direction_of(&t, "1 2"), want, "{case}: {:?}", t.runs);
assert_eq!(char_direction_of(&t, "ab"), None, "{case}");
assert_eq!(t.para_at(0).direction == Some(Rtl), outer == "rtl", "{case}: the paragraph keeps the text's direction");
assert_eq!(w.iter().any(|w| w.contains("bidi-override") && w.contains("not supported")), warns, "{case}: {w:?}");
if !warns {
assert!(w.is_empty(), "{case}: {w:?}");
}
}
// In left-to-right text, digits in a right-to-left embedding are drawn right to left.
let order = |bidi: &str| {
let (t, _) = read(&format!(r#"<text x="20" y="40" font-size="20">ab <tspan direction="rtl" unicode-bidi="{bidi}">1 2</tspan></text>"#));
let lay = layout(FontDb::global(), &t);
let x = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x).unwrap_or_else(|| panic!("no glyph at {byte}"));
x(3) < x(5)
};
assert!(!order("embed"), "an embedding draws 2 before 1");
assert!(order("normal"), "without one, 1 comes first");
}
/// `unicode-bidi` is not inherited: a `<tspan>` without one stays in its parent's embedding, and
/// `inherit` takes the parent's value. CSS rules and `style` set it as they set any property.
#[test]
fn unicode_bidi_is_not_inherited_and_reads_from_the_cascade() {
use vectorcraft_doc::ParaDirection::{LeftToRight as Ltr, RightToLeft as Rtl};
let read = |body: &str| {
let (d, w) = import_with_report(&svg(body)).unwrap_or_else(|e| panic!("{e}"));
(only_text(&d).clone(), w)
};
// An inner `<tspan>` with only a `direction` stays in the outer embedding.
let (t, w) = read(r#"<text y="40">ab <tspan direction="rtl" unicode-bidi="embed">12 <tspan direction="ltr">34</tspan></tspan> cd</text>"#);
assert_eq!((char_direction_of(&t, "12"), char_direction_of(&t, "34"), char_direction_of(&t, "cd")), (Some(Rtl), Some(Rtl), None), "{:?}", t.runs);
assert!(w.is_empty(), "{w:?}");
// `inherit` opens an embedding of its own, inside the outer one: kept one level deep, with a warning.
let (t, w) = read(
r#"<text y="40">ab <tspan direction="rtl" unicode-bidi="embed">12 <tspan direction="ltr" unicode-bidi="inherit">34</tspan></tspan></text>"#,
);
assert_eq!((char_direction_of(&t, "12"), char_direction_of(&t, "34")), (Some(Rtl), Some(Ltr)), "{:?}", t.runs);
assert!(w.len() == 1 && w[0].contains("inside another"), "{w:?}");
// A `unicode-bidi` on the `<text>` or a group is not passed on to the `<tspan>`.
let (t, _) = read(r#"<g unicode-bidi="embed"><text y="40" unicode-bidi="embed">ab <tspan direction="rtl">12</tspan></text></g>"#);
assert!(t.runs.iter().all(|r| r.style.char_direction.is_none()), "{:?}", t.runs);
// From a CSS rule and from `style`; `direction` inherits from the `<text>`.
for body in [
r#"<style>.e{unicode-bidi:embed}</style><text y="40" direction="rtl">ab <tspan class="e">12</tspan></text>"#,
r#"<text y="40" direction="rtl">ab <tspan style="unicode-bidi: embed">12</tspan></text>"#,
] {
let (t, w) = read(body);
assert_eq!(char_direction_of(&t, "12"), Some(Rtl), "{body}: {:?}", t.runs);
assert_eq!(char_direction_of(&t, "ab"), None, "{body}: {:?}", t.runs);
assert!(w.is_empty(), "{body}: {w:?}");
}
// On a path and in a link too.
let (t, _) = read(
r##"<defs><path id="p" d="M20 100 H380"/></defs><text><textPath href="#p">ab <tspan direction="ltr" unicode-bidi="embed">12</tspan> <a href="https://example.com/"><tspan direction="rtl" unicode-bidi="embed">34</tspan></a></textPath></text>"##,
);
assert!(matches!(t.kind, TextKind::OnPath { .. }));
assert_eq!((char_direction_of(&t, "12"), char_direction_of(&t, "34")), (Some(Ltr), Some(Rtl)), "{:?}", t.runs);
}
/// The text model holds embeddings only: isolates and overrides become embeddings in their
/// `direction`, and `plaintext` one in the direction of its first strong character, each with a
/// warning once a text (CSS Writing Modes 3 §2.2,
/// https://www.w3.org/TR/css-writing-modes-3/#unicode-bidi).
#[test]
fn unicode_bidi_values_the_text_model_cannot_hold_are_reported() {
use vectorcraft_doc::ParaDirection::{LeftToRight as Ltr, RightToLeft as Rtl};
for (bidi, dir, text, want, says) in [
("isolate", "rtl", "12", Some(Rtl), "isolation is not supported"),
("isolate-override", "rtl", "12", Some(Rtl), "isolation and override are not supported"),
("plaintext", "ltr", "\u{5D0}\u{5D1}", Some(Rtl), "first strong character"),
("plaintext", "rtl", "12 ab", Some(Ltr), "first strong character"),
] {
let body = format!(
r#"<text id="t" y="40">ab <tspan direction="{dir}" unicode-bidi="{bidi}">{text}</tspan> <tspan direction="{dir}" unicode-bidi="{bidi}">{text}</tspan></text>"#
);
let (d, w) = import_with_report(&svg(&body)).unwrap_or_else(|e| panic!("{e}"));
let t = only_text(&d);
assert_eq!(char_direction_of(t, text), want, "{bidi}: {:?}", t.runs);
assert!(w.len() == 1 && w[0].starts_with("text 't': ") && w[0].contains(&format!("\"{bidi}\"")) && w[0].contains(says), "{bidi}: {w:?}");
}
// `plaintext` looks for the first strong character among the characters imported, however far in.
let far = format!("{}\u{5D0}\u{5D1}", "1 ".repeat(1500));
for (body, part, want) in [
(r#"<tspan display="none">&#x5D0;&#x5D1;</tspan>ab"#.to_string(), "ab", Ltr),
(format!("<tspan>{far}</tspan>"), "\u{5D0}", Rtl),
(format!("<tspan>\n</tspan>12 <tspan>{far}</tspan>"), "\u{5D0}", Rtl),
] {
let (d, _) =
import_with_report(&svg(&format!(r#"<text y="40">x <tspan direction="ltr" unicode-bidi="plaintext">{body}</tspan></text>"#))).unwrap();
let t = only_text(&d);
assert_eq!(char_direction_of(t, part), Some(want), "{body}: {:?}", t.runs);
}
// `plaintext` on the `<text>`: each paragraph runs in the direction of its first strong character.
for (body, want) in [
("<text y=\"40\" unicode-bidi=\"plaintext\">12 \u{5D0}\u{5D1} cd</text>", vec![Some(Rtl)]),
(r#"<text y="40" direction="rtl" unicode-bidi="plaintext">12 ab</text>"#, vec![Some(Ltr)]),
(
"<text unicode-bidi=\"plaintext\"><tspan x=\"10\" y=\"20\">ab</tspan><tspan x=\"10\" dy=\"40\">\u{5D0}\u{5D1} cd</tspan></text>",
vec![Some(Ltr), Some(Rtl)],
),
// A paragraph without a strong character runs left to right.
(
"<text unicode-bidi=\"plaintext\"><tspan x=\"10\" y=\"20\">\u{5D0}\u{5D1}</tspan><tspan x=\"10\" dy=\"40\">1-2</tspan></text>",
vec![Some(Rtl), Some(Ltr)],
),
] {
let (d, w) = import_with_report(&svg(body)).unwrap();
let t = only_text(&d);
let got: Vec<_> = (0..t.paragraph_styles().len()).map(|i| t.para_at(i).direction).collect();
assert_eq!(got, want, "{body}");
assert!(w.is_empty(), "{body}: {w:?}");
}
// Absolutely placed characters sit on their x in the direction of their own paragraph.
let (d, _) = import_with_report(&svg(
r#"<text unicode-bidi="plaintext" font-size="20"><tspan x="200" y="40">&#x5D0;&#x5D1;</tspan><tspan x="100" y="80">ab &#x5D0;&#x5D1; cd</tspan><tspan x="300">ef</tspan></text>"#,
))
.unwrap();
let t = only_text(&d);
assert_eq!((t.para_at(0).direction, t.para_at(1).direction), (Some(Rtl), Some(Ltr)));
let lay = layout(FontDb::global(), t);
for (byte, x) in [(5, 100.0), (15, 300.0)] {
let g = lay.glyphs.iter().find(|g| g.byte == byte).unwrap_or_else(|| panic!("no glyph at {byte}"));
let left = (t.xf * g.origin).x;
assert!(close(left, x, 0.05), "the character at {byte} starts at {left}, not {x}");
}
// An override on the `<text>` keeps its paragraph direction.
let (d, w) = import_with_report(&svg(r#"<text y="40" direction="rtl" unicode-bidi="bidi-override">ab</text>"#)).unwrap();
assert_eq!(only_text(&d).para_at(0).direction, Some(Rtl));
assert!(w.len() == 1 && w[0].contains("override on the text"), "{w:?}");
}
/// A line starts at its own x, also when it is anchored as the line before it is and when another
/// line holds absolutely placed characters.
#[test]
+34
View File
@@ -289,6 +289,40 @@ fn character_direction_is_written_on_its_runs() {
}
}
/// A run set Left to Right keeps its Character Direction when its SVG is opened again, on a line
/// and on a path.
#[test]
fn character_direction_reads_back() {
use vectorcraft_doc::ParaDirection;
let ltr = CharStyle { char_direction: Some(ParaDirection::LeftToRight), ..style(12.0) };
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.clone())];
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 path_doc = Document::new(300.0, 200.0);
let n = Node::new(path_doc.alloc_id(), NodeKind::Text(Box::new(on_path)));
let l = path_doc.layers[0].id;
path_doc.insert(Some(l), 0, n).unwrap();
let area = area_doc(vec![run("Due ", style(12.0)), run("21 10 2026", ltr), run(" ok", style(12.0))], 280.0, Justify::Left);
for (name, d) in [("area", area), ("path", path_doc)] {
let svg = export(&d, &ExportOptions::default());
let back = vectorcraft_svg::import(&svg).unwrap_or_else(|e| panic!("{name}: {e}\n{svg}"));
let mut texts = vec![];
back.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
texts.push((**t).clone());
}
});
assert_eq!(texts.len(), 1, "{name}: {svg}");
let t = &texts[0];
let dir = |part: &str| t.runs.iter().find(|r| r.text.contains(part)).map(|r| r.style.char_direction);
assert_eq!(dir("2026"), Some(Some(ParaDirection::LeftToRight)), "{name}: {:?}\n{svg}", t.runs);
assert_eq!(dir("Due"), Some(None), "{name}: {:?}", t.runs);
}
}
/// Digits are written as drawn: the style's numeral system, not the digits that were typed (as
/// All Caps writes capitals).
#[test]
+39 -13
View File
@@ -12,7 +12,7 @@ use skrifa::outline::DrawSettings;
use skrifa::raw::TableProvider;
use skrifa::raw::types::BoundingBox;
use skrifa::{GlyphId, MetadataProvider};
use vectorcraft_doc::color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientSpread, GradientStop, Paint};
use vectorcraft_doc::{Appearance, FillLayer, ImageBlob, Node, NodeId, NodeKind};
use vectorcraft_geom::PathData;
use vello_cpu::peniko;
@@ -279,8 +279,10 @@ impl<'a> Tree<'a> {
})
.collect()
};
let gradient = |kind, stops: Vec<GradientStop>, geom| {
let mut paint = GradientPaint::new(Gradient { kind, stops, ..Gradient::default() });
let gradient = |kind, stops: Vec<GradientStop>, spread, geom| {
// COLR repeats the interval from the first stop to the last, and one run of the
// stops spans the gradient's vector.
let mut paint = GradientPaint::new(Gradient { kind, stops, spread, ..Gradient::default() });
paint.geom = Some(geom);
Paint::Gradient(Box::new(paint))
};
@@ -290,22 +292,22 @@ impl<'a> Tree<'a> {
Some((Paint::solid(c), a))
}
Brush::LinearGradient { p0, p1, color_stops, extend } => {
if *extend != Extend::Pad {
self.unsupported.get_or_insert("a repeating or reflecting gradient");
let spread = spread(*extend);
let (p0, p1) = (point(*p0), point(*p1));
let stops = stops(color_stops, self);
// Without a stop, the others would repeat over a different interval.
if !spread.is_pad() && stops.len() < color_stops.len() {
self.unsupported.get_or_insert("a repeating or reflecting gradient with a stop missing from the palette");
return None;
}
let (p0, p1) = (point(*p0), point(*p1));
let (stops, first, last) = normalized(stops(color_stops, self))?;
let (stops, first, last) = normalized(stops)?;
// Stops past 0 and 1 move the ends instead.
let mut geom = GradientGeom { start: p0.lerp(p1, f64::from(first)), end: p0.lerp(p1, f64::from(last)), aspect: 1.0, focal: None };
geom.transform(xf, GradientKind::Linear);
Some((gradient(GradientKind::Linear, stops, geom), 1.0))
Some((gradient(GradientKind::Linear, stops, spread, geom), 1.0))
}
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend } => {
if *extend != Extend::Pad {
self.unsupported.get_or_insert("a repeating or reflecting gradient");
return None;
}
let spread = spread(*extend);
let (c0, c1, r0, r1) = (point(*c0), point(*c1), f64::from(*r0), f64::from(*r1));
let concentric = (c0 - c1).hypot() < 1e-6;
if (r0 > 1e-6 && !concentric) || r1 <= r0 || (c0 - c1).hypot() >= r1 {
@@ -317,13 +319,27 @@ impl<'a> Tree<'a> {
self.unsupported.get_or_insert("a radial gradient with stops past its circles");
return None;
}
if !spread.is_pad() {
if stops.len() < color_stops.len() {
self.unsupported.get_or_insert("a repeating or reflecting gradient with a stop missing from the palette");
return None;
}
// A run of the stops is the whole way from the center to the circle: one that
// starts at a circle (a first stop past the center comes as one) repeats over
// less of it. The normalized ends can be a rounding error off 0 and 1.
let (lo, hi) = stops.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), s| (lo.min(s.offset), hi.max(s.offset)));
if r0.abs() > 1e-6 || lo.abs() > 1e-4 || (hi - 1.0).abs() > 1e-4 {
self.unsupported.get_or_insert("a repeating or reflecting radial gradient that doesn't start at its center");
return None;
}
}
// A start circle around the centre starts the stops further out.
for s in &mut stops {
s.offset = ((r0 + f64::from(s.offset) * (r1 - r0)) / r1) as f32;
}
let mut geom = GradientGeom { start: c1, end: c1 + Vec2::new(r1, 0.0), aspect: 1.0, focal: (!concentric).then_some(c0) };
geom.transform(xf, GradientKind::Radial);
Some((gradient(GradientKind::Radial, stops, geom), 1.0))
Some((gradient(GradientKind::Radial, stops, spread, geom), 1.0))
}
Brush::SweepGradient { .. } => {
self.unsupported.get_or_insert("a sweep gradient");
@@ -345,6 +361,16 @@ impl<'a> Tree<'a> {
}
}
/// A COLR extend mode as the gradient's spread; an unknown one pads, as the COLR specification
/// says.
fn spread(extend: Extend) -> GradientSpread {
match extend {
Extend::Repeat => GradientSpread::Repeat,
Extend::Reflect => GradientSpread::Reflect,
_ => GradientSpread::Pad,
}
}
/// `stops` sorted, from 0 to 1 (as the gradient's ends then move), and where the first and last
/// were; `None` without stops.
fn normalized(mut stops: Vec<GradientStop>) -> Option<(Vec<GradientStop>, f32, f32)> {
+8
View File
@@ -53,6 +53,7 @@ pub(super) fn script_families(c: char) -> &'static [&'static str] {
"Lohit Devanagari",
"Mangal",
],
Script::Gujarati => &["Gujarati Sangam MN", "GujaratiMT", "Nirmala UI", "Shruti", "Noto Sans Gujarati", "Noto Sans Gujarati UI"],
_ => &[],
}
}
@@ -164,6 +165,13 @@ mod tests {
assert!(script_families('a').is_empty());
}
/// Gujarati takes a font that draws it (macOS Gujarati Sangam MN, Windows Nirmala UI or Shruti).
#[test]
fn gujarati_prefers_fonts_made_for_it() {
assert_eq!(script_families('ગ').first(), Some(&"Gujarati Sangam MN"));
assert!(script_families('ગ').contains(&"Nirmala UI"));
}
#[test]
fn a_name_table_of_many_records_is_cut_short() {
let many: Vec<(u16, &str)> = (0..2000).map(|_| (1, "Short")).collect();
+57 -24
View File
@@ -5,6 +5,11 @@
//! line. Each line the composer tries is placed from where the lines above it left the pen, at its
//! own tallest metrics, as the line loop in [`super::flow`] places a composed line: the span it
//! gets is the room across its whole height.
//!
//! A paragraph the frame cannot hold is still composed whole: a line the frame has no room for,
//! and every line after it, is measured as wide as the span of the last line set in the frame,
//! less its own indents. The line loop sets only the lines that fit, and the rest overflows or
//! goes on in the next threaded frame, which composes it again at its own measures.
use std::cell::Cell;
use std::collections::HashMap;
@@ -63,19 +68,33 @@ struct Start<'r> {
/// The paragraph's first line (it takes the first-line indent).
first: bool,
pen: Pen<'r>,
/// The line placed at each of the metrics it was tried with: its width and the pen after it,
/// or `None` when the frame has no room for it.
placed: Vec<(Metrics, Option<(f64, Pen<'r>)>)>,
/// The width of the span the line above was set in, `None` for the paragraph's first line.
span: Option<f64>,
/// The line above was past the frame's end: this line is too, at the width of `span`.
past: bool,
/// The line placed at each of the metrics it was tried with, or `None` when it can't be set.
placed: Vec<(Metrics, Option<Placed<'r>>)>,
/// The metrics of glyphs `pos..to`, measured so far.
to: usize,
m: Option<Metrics>,
}
/// What a line's placement depends on: its first glyph, whether it is the paragraph's first line,
/// and the pen's cell, baselines, pending space, narrowest word without room and further spans.
type Key = (usize, bool, usize, Option<u64>, Option<u64>, u64, u64, Vec<[u64; 3]>);
/// A line set from a [`Start`]: its width, the pen after it, the width of the span it was set
/// in, and whether it is past the frame's end (measured, but not in the frame).
#[derive(Clone)]
struct Placed<'r> {
width: f64,
pen: Pen<'r>,
span: f64,
past: bool,
}
fn key(pos: usize, first: bool, pen: &Pen<'_>) -> Key {
/// What a line's placement depends on: its first glyph, whether it is the paragraph's first line,
/// the pen's cell, baselines, pending space, narrowest word without room and further spans, the
/// span of the line above and whether the line is past the frame's end.
type Key = (usize, bool, usize, Option<u64>, Option<u64>, u64, u64, Vec<[u64; 3]>, Option<u64>, bool);
fn key(pos: usize, first: bool, pen: &Pen<'_>, span: Option<f64>, past: bool) -> Key {
(
pos,
first,
@@ -85,6 +104,8 @@ fn key(pos: usize, first: bool, pen: &Pen<'_>) -> Key {
pen.pending.to_bits(),
pen.no_room_for.to_bits(),
pen.queued.iter().map(|&(b, x0, x1)| [b.to_bits(), x0.to_bits(), x1.to_bits()]).collect(),
span.map(f64::to_bits),
past,
)
}
@@ -159,13 +180,14 @@ impl<'a, 'r> Shaped<'a, 'r> {
}
}
/// The start of a line at glyph `pos` with `pen`: an existing one when its placement is the same.
fn start(&mut self, pos: usize, first: bool, pen: Pen<'r>) -> usize {
let k = key(pos, first, &pen);
/// The start of a line at glyph `pos` with `pen`, after a line set in a span `span` wide (past
/// the frame's end with `past`): an existing one when its placement is the same.
fn start(&mut self, pos: usize, first: bool, pen: Pen<'r>, span: Option<f64>, past: bool) -> usize {
let k = key(pos, first, &pen, span, past);
if let Some(&id) = self.known.get(&k) {
return id;
}
self.starts.push(Start { pos, first, pen, placed: vec![], to: pos, m: None });
self.starts.push(Start { pos, first, pen, span, past, placed: vec![], to: pos, m: None });
let id = self.starts.len() - 1;
self.known.insert(k, id);
id
@@ -177,7 +199,7 @@ impl Measure for Shaped<'_, '_> {
fn first(&mut self) -> usize {
let pen = self.pen.clone();
self.start(0, !self.continued, pen)
self.start(0, !self.continued, pen, None, false)
}
fn line(&mut self, &id: &usize, pos: usize, end: usize) -> Result<Option<(f64, usize)>, Exhausted> {
@@ -186,14 +208,14 @@ impl Measure for Shaped<'_, '_> {
}
self.probes += 1;
let Some(start) = self.starts.get_mut(id) else { return Ok(None) };
let Some((width, pen)) = line(&self.para, start, pos, end)? else { return Ok(None) };
Ok(Some((width, self.start(end, false, pen))))
let Some(placed) = line(&self.para, start, pos, end)? else { return Ok(None) };
Ok(Some((placed.width, self.start(end, false, placed.pen, Some(placed.span), placed.past))))
}
/// A longer line is placed as this one is, and gets the same room, unless a glyph after it
/// changes the line's tallest metrics.
/// changes the line's tallest metrics. Past the frame's end every line is as wide.
fn may_widen(&mut self, &id: &usize, pos: usize, end: usize) -> bool {
let Some(start) = self.starts.get(id) else { return false };
let Some(start) = self.starts.get(id).filter(|start| !start.past) else { return false };
let m = if start.pos == pos && start.to == end { start.m } else { self.para.sg.get(pos..end).and_then(Metrics::max) };
match (m, self.rest.get(end).copied().flatten()) {
(Some(m), Some(rest)) => m.union(rest) != m,
@@ -212,12 +234,14 @@ impl Drop for Shaped<'_, '_> {
}
}
/// The width of the line of glyphs `pos..end` from `start`, and the pen after it; `None` when
/// the frame has no room for it.
fn line<'r>(p: &Para<'_>, start: &mut Start<'r>, pos: usize, end: usize) -> Result<Option<(f64, Pen<'r>)>, Exhausted> {
/// The line of glyphs `pos..end` from `start`; `None` when it can't be set.
fn line<'r>(p: &Para<'_>, start: &mut Start<'r>, pos: usize, end: usize) -> Result<Option<Placed<'r>>, Exhausted> {
if start.pos != pos || end <= pos {
return Ok(None);
}
if start.past {
return Ok(past_end(p, start));
}
// The line's tallest metrics, measured on from the glyphs already measured.
if end < start.to {
start.m = p.sg.get(pos..end).and_then(Metrics::max);
@@ -239,9 +263,18 @@ fn line<'r>(p: &Para<'_>, start: &mut Start<'r>, pos: usize, end: usize) -> Resu
Ok(placed)
}
/// The line from `start` placed with metrics `m`: its width and the pen after it, as the line loop
/// places and settles it; `None` when no frame cell has room for it.
fn place<'r>(p: &Para<'_>, start: &Start<'r>, m: Metrics) -> Result<Option<(f64, Pen<'r>)>, Exhausted> {
/// A line from `start` past the frame's end: as wide as the span of the last line set in the frame,
/// less its indents, and the pen left where the frame ran out. `None` when the paragraph has no line
/// in the frame to measure it by.
fn past_end<'r>(p: &Para<'_>, start: &Start<'r>) -> Option<Placed<'r>> {
let span = start.span.filter(|span| span.is_finite())?;
let (ind_l, ind_r) = line_indents(p.para, start.first, p.mirror);
Some(Placed { width: span - ind_l - ind_r, pen: start.pen.clone(), span, past: true })
}
/// The line from `start` placed with metrics `m`, as the line loop places and settles it; past the
/// frame's end ([`past_end`]) when no frame cell has room for it.
fn place<'r>(p: &Para<'_>, start: &Start<'r>, m: Metrics) -> Result<Option<Placed<'r>>, Exhausted> {
let (ind_l, ind_r) = line_indents(p.para, start.first, p.mirror);
let word = word_width(p.sg, start.pos, p.line_end, p.para.kinsoku) - (p.start_credit)(start.pos);
let mut pen = start.pen.clone();
@@ -262,12 +295,12 @@ fn place<'r>(p: &Para<'_>, start: &Start<'r>, m: Metrics) -> Result<Option<(f64,
for queued in &mut pen.queued {
queued.0 = baseline;
}
return Ok(Some((x1 - x0 - ind_l - ind_r, pen)));
return Ok(Some(Placed { width: x1 - x0 - ind_l - ind_r, pen, span: x1 - x0, past: false }));
}
Some(_) => return Ok(None),
// Out of steps: the frame may still have room further down.
None if p.steps.get() == 0 => return Err(Exhausted),
None => return Ok(None),
None => return Ok(past_end(p, start)),
}
}
}
+124 -5
View File
@@ -326,12 +326,8 @@ fn paints_documents_lack_make_the_glyph_an_image() {
let line = |extend| ColorLine::new(extend, 2, vec![ColorStop::new(F2Dot14::from_f32(0.0), 0, one), ColorStop::new(one, 1, one)]);
let w = FWord::new;
type GlyphPaint<'a> = Box<dyn Fn(write_fonts::types::GlyphId16) -> ColrPaint + 'a>;
let cases: [(&str, GlyphPaint<'_>); 3] = [
let cases: [(&str, GlyphPaint<'_>); 2] = [
("Colrtest Xor", Box::new(|o| ColrPaint::composite(ColrPaint::glyph(solid(2), o), CompositeMode::Xor, ColrPaint::glyph(solid(0), o)))),
(
"Colrtest Repeat",
Box::new(move |o| ColrPaint::glyph(ColrPaint::linear_gradient(line(Extend::Repeat), w(0), w(0), w(100), w(0), w(0), w(100)), o)),
),
(
"Colrtest Circles",
Box::new(move |o| {
@@ -362,6 +358,129 @@ fn a_radial_gradient_from_a_circle_around_its_centre_is_vector_art() {
assert_eq!(offsets, [0.25, 1.0], "the stops start a quarter of the way out");
}
/// A COLRv1 color line through `stops` (offset, palette index) that extends by `extend`.
fn color_line(extend: write_fonts::tables::colr::Extend, stops: &[(f32, u16)]) -> write_fonts::tables::colr::ColorLine {
use write_fonts::tables::colr::{ColorLine, ColorStop};
use write_fonts::types::F2Dot14;
let one = F2Dot14::from_f32(1.0);
let stops: Vec<ColorStop> = stops.iter().map(|&(at, index)| ColorStop::new(F2Dot14::from_f32(at), index, one)).collect();
ColorLine::new(extend, u16::try_from(stops.len()).unwrap(), stops)
}
/// The gradient filling the glyph's one path.
fn glyph_gradient(font: &[u8]) -> vectorcraft_doc::color::GradientPaint {
let root = art(font, COLOUR_CHAR);
let [only] = children(&root) else { panic!("one path") };
let Paint::Gradient(g) = fill(only) else { panic!("a gradient") };
*g
}
/// A linear gradient that repeats or reflects is vector art. The gradient spreads the same way,
/// and its stops run once from the first stop's place to the last stop's place.
#[test]
fn a_repeating_or_reflecting_linear_gradient_is_vector_art() {
use vectorcraft_doc::color::GradientSpread;
use write_fonts::tables::colr::Extend;
use write_fonts::types::FWord;
let w = FWord::new;
for (family, extend, spread) in
[("Colrtest Linear Repeat", Extend::Repeat, GradientSpread::Repeat), ("Colrtest Linear Reflect", Extend::Reflect, GradientSpread::Reflect)]
{
let line = color_line(extend, &[(0.2, 0), (0.6, 1)]);
let font =
v1_font(family, COLOUR_CHAR, |o| ColrPaint::glyph(ColrPaint::linear_gradient(line.clone(), w(0), w(0), w(100), w(0), w(0), w(100)), o));
let g = glyph_gradient(&font);
assert_eq!(g.gradient.kind, GradientKind::Linear, "{family}");
assert_eq!(g.gradient.painted_spread(), (spread, 1), "{family}");
let offsets: Vec<f32> = g.gradient.stops.iter().map(|s| s.offset).collect();
assert_eq!(offsets, [0.0, 1.0], "{family}");
let geom = g.geom.unwrap();
assert!((geom.start.x - 20.0).abs() < 0.01 && (geom.end.x - 60.0).abs() < 0.01, "{family}: {:?} → {:?}", geom.start, geom.end);
}
}
/// A radial gradient from its center that repeats or reflects is vector art that spreads the same
/// way, also with a focal point. Its circle is at the last stop, and its stops run once from the
/// center out to that circle.
#[test]
fn a_repeating_or_reflecting_radial_gradient_from_its_center_is_vector_art() {
use vectorcraft_doc::color::GradientSpread;
use write_fonts::tables::colr::Extend;
use write_fonts::types::{FWord, UfWord};
let w = FWord::new;
let cases = [
("Colrtest Radial Repeat", Extend::Repeat, GradientSpread::Repeat, 250, 1.0, 400.0),
("Colrtest Radial Reflect", Extend::Reflect, GradientSpread::Reflect, 300, 1.0, 400.0),
("Colrtest Radial Half", Extend::Repeat, GradientSpread::Repeat, 300, 0.5, 200.0),
// Normalized, the last stop is a rounding error short of 1.
("Colrtest Radial Fine", Extend::Repeat, GradientSpread::Repeat, 250, 41.0 / 16384.0, 400.0 * 41.0 / 16384.0),
];
for (family, extend, spread, x0, last, radius) in cases {
let line = color_line(extend, &[(0.0, 0), (last / 2.0, 2), (last, 1)]);
let font = v1_font(family, COLOUR_CHAR, |o| {
ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(x0), w(300), UfWord::new(0), w(250), w(300), UfWord::new(400)), o)
});
let g = glyph_gradient(&font);
assert_eq!(g.gradient.kind, GradientKind::Radial, "{family}");
assert_eq!(g.gradient.painted_spread(), (spread, 1), "{family}");
let offsets: Vec<f32> = g.gradient.stops.iter().map(|s| s.offset).collect();
assert!(offsets.len() == 3 && offsets.iter().zip([0.0, 0.5, 1.0]).all(|(a, b)| (a - b).abs() < 0.05), "{family}: {offsets:?}");
let geom = g.geom.unwrap();
assert!(((geom.end - geom.start).hypot() - radius).abs() < 0.1, "{family}: {geom:?}");
assert_eq!(geom.focal.is_some(), x0 != 250, "{family}");
}
}
/// A radial gradient that repeats from a circle stays an image. The circle is either a start circle
/// around the center or the place a first stop past the center puts the start.
#[test]
fn a_repeating_radial_gradient_from_a_circle_makes_the_glyph_an_image() {
use write_fonts::tables::colr::Extend;
use write_fonts::types::{FWord, UfWord};
let w = FWord::new;
for (family, r0, first) in [("Colrtest Ring Repeat", 100, 0.0), ("Colrtest Short Repeat", 0, 0.25)] {
let line = color_line(Extend::Repeat, &[(first, 0), (1.0, 1)]);
let font = v1_font(family, COLOUR_CHAR, |o| {
ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(250), w(300), UfWord::new(r0), w(250), w(300), UfWord::new(400)), o)
});
assert!(matches!(content(&font, COLOUR_CHAR), Some(ColourContent::Image(_))), "{family}");
}
}
/// A gradient that repeats or reflects with a stop whose palette index is outside the palette stays
/// an image. Without that stop, the other stops would repeat over a different interval.
#[test]
fn a_repeating_gradient_with_a_stop_missing_from_the_palette_makes_the_glyph_an_image() {
use write_fonts::tables::colr::Extend;
use write_fonts::types::{FWord, UfWord};
let w = FWord::new;
let line = color_line(Extend::Repeat, &[(0.0, 0), (0.5, 1), (1.0, 200)]);
let linear = v1_font("Colrtest Linear Missing", COLOUR_CHAR, |o| {
ColrPaint::glyph(ColrPaint::linear_gradient(line.clone(), w(0), w(0), w(100), w(0), w(0), w(100)), o)
});
let radial = v1_font("Colrtest Radial Missing", COLOUR_CHAR, |o| {
ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(250), w(300), UfWord::new(0), w(250), w(300), UfWord::new(400)), o)
});
for (family, font) in [("linear", linear), ("radial", radial)] {
assert!(matches!(content(&font, COLOUR_CHAR), Some(ColourContent::Image(_))), "{family}");
}
}
/// A color line with an extend value the COLR specification doesn't define pads.
#[test]
fn an_unknown_extend_value_pads() {
use vectorcraft_doc::color::GradientSpread;
use write_fonts::tables::colr::Extend;
use write_fonts::types::FWord;
let w = FWord::new;
let line = color_line(Extend::Unknown, &[(0.0, 0), (1.0, 1)]);
let font = v1_font("Colrtest Unknown Extend", COLOUR_CHAR, |o| {
ColrPaint::glyph(ColrPaint::linear_gradient(line.clone(), w(0), w(0), w(100), w(0), w(0), w(100)), o)
});
let g = glyph_gradient(&font);
assert_eq!(g.gradient.painted_spread(), (GradientSpread::Pad, 1));
}
/// A glyph whose layers don't use the foreground colour is read once, whatever the type's fill.
#[test]
fn a_glyph_without_the_foreground_colour_is_read_once_for_every_fill() {
+60 -6
View File
@@ -465,18 +465,72 @@ fn composer_x_mixed_sizes_in_a_frame_that_widens() {
#[test]
fn composer_x_mixed_sizes_overflow_a_shaped_frame() {
// A stepped frame too short for the paragraph: the lines that fit are set, in order, each once
// and within its room, and the frame overflows.
// A stepped frame too short for the paragraph: the every-line composer still breaks the lines
// that fit as one paragraph, with the lines past the frame's end measured as wide as the last
// line the frame holds. Here that is the narrow part, so the lines that fit break as in a frame
// tall enough for the whole paragraph, and not as the single-line composer breaks them. They
// are set in order, each once and within its room, and the frame overflows.
let w = x_width();
let mut t = stepped(w, w * 0.5, 40.0, 75.0);
t.runs = with_x(sans(36.0));
t.para.composer = Composer::EveryLine;
let l = layout(db(), &t);
let set = |bottom: f64, composer: Composer| {
let mut t = stepped(w, w * 0.5, 40.0, bottom);
t.runs = with_x(sans(36.0));
t.para.composer = composer;
let l = layout(db(), &t);
(line_texts(&t, &l), l, t)
};
let (every, l, t) = set(75.0, Composer::EveryLine);
assert!(l.overflow && l.lines.len() >= 2, "{:?}", l.lines);
assert_eq!(l.lines[0].start, 0);
assert!(l.lines.windows(2).all(|p| p[0].end == p[1].start && p[0].start < p[0].end), "{:?}", l.lines);
assert!(past_their_room(&t, &l).is_empty(), "{:?}", l.lines);
assert!(l.lines.iter().all(|li| li.baseline + li.descent <= 75.0 + 0.01), "{:?}", l.lines);
assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]);
let (whole, ..) = set(300.0, Composer::EveryLine);
assert_eq!(every, whole[..every.len()]);
let (greedy, ..) = set(75.0, Composer::SingleLine);
assert_ne!(every, greedy);
// A frame with no room for a line sets none.
for composer in [Composer::EveryLine, Composer::SingleLine] {
let mut t = stepped(w, w * 0.5, 4.0, 8.0);
t.runs = with_x(sans(36.0));
t.para.composer = composer;
let l = layout(db(), &t);
assert!(l.lines.is_empty() && l.overflow, "{composer:?}: {:?}", l.lines);
}
}
#[test]
fn composer_x_mixed_sizes_overflow_around_text_wrap() {
// A rectangle that wraps around an object beside its lower lines, too short for the
// paragraph: the every-line composer breaks the lines that fit as one paragraph, each span
// measured across its line's whole height, and the frame overflows. No glyph runs into the
// object.
let w = x_width();
let object = Rect::new(w * 0.4, 40.0, w * 0.6, 70.0);
let set = |composer: Composer| {
let mut runs = with_x(sans(36.0));
runs[0] = run("Destroy all creatures with toughness\u{a0}", sans(12.0));
let mut t = area(runs, Rect::new(0.0, 0.0, w, 80.0));
t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&object.to_path(0.1)), wrap: Default::default() }];
t.para.composer = composer;
let l = layout(db(), &t);
(line_texts(&t, &l), l, t)
};
let (every, l, t) = set(Composer::EveryLine);
assert!(l.overflow && l.lines.len() >= 3, "{:?}", l.lines);
assert!(l.lines.windows(2).all(|p| p[0].end == p[1].start && p[0].start < p[0].end), "{:?}", l.lines);
assert!(past_their_room(&t, &l).is_empty(), "{:?}", l.lines);
let text = t.plain_text();
let hits = l.glyphs.iter().filter(|g| {
let li = &l.lines[g.line];
let across = li.baseline - li.ascent < object.y1 && li.baseline + li.descent > object.y0;
across && !text[g.byte..].starts_with(char::is_whitespace) && g.origin.x < object.x1 && g.origin.x + g.advance > object.x0
});
assert_eq!(hits.count(), 0, "{:?}", l.lines);
// The lines that fit break as in `composer_x_mixed_sizes_around_text_wrap`'s taller frame.
assert_eq!(every, ["Destroy all creatures with", "toughness\u{a0}X", "or less. Then", "draw a card for", "each creature"]);
let (greedy, ..) = set(Composer::SingleLine);
assert_ne!(every, greedy);
}
#[test]
+74
View File
@@ -397,6 +397,80 @@ fn a_continued_paragraph_fills_the_frame_it_continues_into() {
assert_eq!(checked, 16);
}
/// Area type in a frame `w` wide down to `y` and `narrow` wide below it, down to `bottom`.
fn stepped_frame(w: f64, narrow: f64, y: f64, bottom: f64) -> TextObject {
let mut t = frame(0.0);
let path = kurbo::BezPath::from_vec(vec![
kurbo::PathEl::MoveTo((0.0, 0.0).into()),
kurbo::PathEl::LineTo((w, 0.0).into()),
kurbo::PathEl::LineTo((w, y).into()),
kurbo::PathEl::LineTo((narrow, y).into()),
kurbo::PathEl::LineTo((narrow, bottom).into()),
kurbo::PathEl::LineTo((0.0, bottom).into()),
kurbo::PathEl::ClosePath,
]);
t.kind = TextKind::Area { frame: PathData::from_bezpath(&path) };
t
}
/// A paragraph of mixed type sizes that a stepped frame cannot hold goes on in the next frame. The
/// every-line composer breaks the lines the first frame holds as one paragraph, as in a frame tall
/// enough for all of it and not as the single-line composer breaks them, and the first frame
/// takes the text of those lines. The next frame sets the rest at its own width, without the
/// first-line indent, and no character is lost or repeated. The frames take the same shares as
/// when each is measured with the whole rest of the story.
#[test]
fn a_mixed_size_paragraph_continued_from_a_shaped_frame_is_composed_in_both() {
use vectorcraft_doc::Composer;
let db = FontDb::global();
let small = CharStyle { size: 12.0, ..CharStyle::default() };
let big = CharStyle { size: 36.0, ..CharStyle::default() };
let probe = layout(db, &TextObject::point(vectorcraft_geom::Point::ZERO, "Destroy all creatures with toughness X or", small.clone()));
let w = probe.lines[0].x1 - probe.lines[0].x0 + 2.0;
let story = vec![
TextRun { text: "Destroy all creatures with toughness ".into(), style: small.clone(), inline: None },
TextRun { text: "X".into(), style: big, inline: None },
TextRun { text: " or less. Then draw a card for each creature destroyed this way.\nSecond paragraph.".into(), style: small, inline: None },
];
let text: String = story.iter().map(|r| r.text.as_str()).collect();
let (a, tall) = (stepped_frame(w, w * 0.5, 40.0, 85.0), stepped_frame(w, w * 0.5, 40.0, 300.0));
let b = sized(200.0, w * 0.75, 300.0);
let texts = |lay: &crate::TextLayout, shown: &str| lay.lines.iter().map(|l| shown[l.start..l.end].trim_end().to_string()).collect::<Vec<_>>();
let shares = |composer: Composer, indent: f64| {
let para = ParaStyle { composer, first_line_indent: indent, ..Default::default() };
let paras = [para.clone(), para];
let parts = distribute(db, &[&a, &b], &story, &paras);
assert_eq!(parts.iter().map(|p| p.continues).collect::<Vec<_>>(), [false, true], "{composer:?}");
let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect();
assert_eq!(joined, text, "{composer:?}: no character lost or repeated");
same_as_whole_rest(db, &[&a, &b], &story, &paras, &format!("{composer:?}, indent {indent}"));
let shown = |p: &Slice| p.runs.iter().map(|r| r.text.as_str()).collect::<String>();
let lays: Vec<_> = [&a, &b].iter().zip(&parts).map(|(f, p)| laid(db, f, p)).collect();
assert!(lays.iter().all(|l| !l.overflow), "{composer:?}: each frame holds its share");
let whole =
laid(db, &tall, &Slice { runs: story.clone(), paras: paras.to_vec(), list_count: 0, list_level_counts: vec![], continues: false });
let first = texts(&lays[0], &shown(&parts[0]));
// The first frame takes the text of the lines it sets.
assert_eq!(shown(&parts[0]).split_whitespace().collect::<Vec<_>>(), first.join(" ").split_whitespace().collect::<Vec<_>>());
(first, texts(&whole, &text), lays, parts)
};
for indent in [0.0, 20.0] {
let (every, whole, lays, parts) = shares(Composer::EveryLine, indent);
assert!(every.len() >= 2, "{every:?}");
assert_eq!(every, whole[..every.len()], "indent {indent}");
if indent == 0.0 {
assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]);
let (greedy, ..) = shares(Composer::SingleLine, indent);
assert_eq!(greedy, ["Destroy all creatures with toughness X", "or less. Then draw a", "card for each", "creature destroyed"]);
}
// The next frame starts at its left edge, and its lines keep within it.
assert!((line_start(&lays[1], 0) - 200.0).abs() < 1e-6, "{}", line_start(&lays[1], 0));
assert!(lays[1].lines.iter().all(|l| l.x1 <= 200.0 + w * 0.75 + 1e-6), "{:?}", lays[1].lines);
assert!((line_start(&lays[0], 0) - indent).abs() < 1e-6, "{}", line_start(&lays[0], 0));
assert!(parts[1].continues && lays[1].lines.len() >= 2, "{:?}", lays[1].lines);
}
}
/// `frames` holding the runs of `parts`, for [`continued`].
fn filled(frames: &[&TextObject], parts: &[Slice]) -> Vec<TextObject> {
frames
+1 -1
View File
@@ -19,7 +19,7 @@ mod lights;
pub use lights::{Light, Lights, MAX_LIGHTS};
mod caps;
mod mesh;
pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh};
pub use mesh::{Mesh, MeshedSolid, extrude_mesh, extrude_meshed, inflate_meshed, inflate_meshes, plane_mesh, revolve_mesh};
mod shadow;
pub use shadow::{MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, TOO_COMPLEX_SHADOW};
+47 -5
View File
@@ -10,8 +10,8 @@
use vectorcraft_geom::{FillRule, PathData, Rect};
use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces};
use crate::{Extrude, Inflate, Revolve, V3, View};
use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces};
use crate::{Extrude, Facet, Inflate, Revolve, V3, View};
/// Most steps the triangulation of one solid may take: a face's ears and bridges each test the
/// face's other corners.
@@ -56,12 +56,12 @@ impl Mesh {
/// Extrude & Bevel's solid ([`crate::extrude`]) as a mesh.
pub fn extrude_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Mesh, String> {
extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces, _)| mesh(&faces))
extrude_faces(path, rule, bounds, options, view, Keep::Faces).and_then(|(_, faces, _)| mesh(&faces?))
}
/// Rotate's plane ([`crate::plane`]) as a mesh.
pub fn plane_mesh(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Mesh, String> {
plane_faces(path, rule, bounds, view, true).and_then(|(_, faces, _)| mesh(&faces))
plane_faces(path, rule, bounds, view, Keep::Faces).and_then(|(_, faces, _)| mesh(&faces?))
}
/// Inflate's solids for several shapes of one object ([`crate::inflate_together`]) as meshes, one
@@ -72,7 +72,49 @@ pub fn inflate_meshes(
options: Inflate,
view: View,
) -> impl Iterator<Item = Result<Mesh, String>> + use<> {
inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces, _)| mesh(&faces)))
inflate_faces(shapes, bounds, options, view, Keep::Faces).map(|r| r.and_then(|(_, faces, _)| mesh(&faces?)))
}
/// One solid built once for both its drawing and its mesh: the facets [`crate::extrude`] or
/// [`crate::inflate_each`] gives, and the faces before projection that [`MeshedSolid::mesh`] cuts
/// into the mesh [`extrude_mesh`] or [`inflate_meshes`] gives.
#[derive(Clone, Debug)]
pub struct MeshedSolid {
pub facets: Vec<Facet>,
/// The faces before projection, or the error the mesh alone gives when there are too many.
faces: Result<Vec<Face3>, String>,
}
/// A solid with no facets and an empty mesh.
impl Default for MeshedSolid {
fn default() -> Self {
Self { facets: vec![], faces: Ok(vec![]) }
}
}
impl MeshedSolid {
/// The solid as a mesh. The faces are cut into triangles on each call.
pub fn mesh(&self) -> Result<Mesh, String> {
mesh(self.faces.as_ref().map_err(String::clone)?)
}
}
/// Extrude & Bevel's solid ([`crate::extrude`]) and its mesh ([`extrude_mesh`]) from one build. An
/// error where [`crate::extrude`] gives one.
pub fn extrude_meshed(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<MeshedSolid, String> {
extrude_faces(path, rule, bounds, options, view, Keep::Both).map(|(facets, faces, _)| MeshedSolid { facets, faces })
}
/// Inflate's solids for several shapes of one object ([`crate::inflate_each`]) and their meshes
/// ([`inflate_meshes`]), each solid built once, as the iterator reaches it. An error where
/// [`crate::inflate_each`] gives one.
pub fn inflate_meshed(
shapes: &[(&PathData, FillRule)],
bounds: Rect,
options: Inflate,
view: View,
) -> impl Iterator<Item = Result<MeshedSolid, String>> + use<> {
inflate_faces(shapes, bounds, options, view, Keep::Both).map(|r| r.map(|(facets, faces, _)| MeshedSolid { facets, faces }))
}
/// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than
+128 -1
View File
@@ -3,7 +3,7 @@ use std::collections::HashMap;
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
use super::*;
use crate::{Bevel, Edge};
use crate::{Bevel, BevelShape, Edge};
fn square(b: &mut BezPath, x: f64, y: f64, s: f64, clockwise: bool) {
b.move_to((x, y));
@@ -372,3 +372,130 @@ fn join_numbers_each_parts_corners_after_the_last() {
assert!(both.triangles.iter().all(|t| t.iter().all(|i| (*i as usize) < both.positions.len())));
assert_eq!(both.triangles[one.triangles.len()][0], one.triangles[0][0] + one.positions.len() as u32);
}
/// A mesh's positions and normals as bits, and its triangles.
type MeshBits = (Vec<[u64; 3]>, Vec<[u64; 3]>, Vec<[u32; 3]>);
/// The bits of every number in `m`, for comparing two meshes exactly.
fn bits(m: &Mesh) -> MeshBits {
(m.positions.iter().map(|p| p.map(f64::to_bits)).collect(), m.normals.iter().map(|n| n.map(f64::to_bits)).collect(), m.triangles.clone())
}
/// A star of `points` points about (100, 100), with a square hole: curved and straight edges both.
fn star(points: usize) -> (PathData, Rect) {
let mut b = BezPath::new();
for k in 0..2 * points {
let (s, c) = (k as f64 * std::f64::consts::PI / points as f64).sin_cos();
let r = if k % 2 == 0 { 90.0 } else { 45.0 };
let p = (100.0 + r * c, 100.0 + r * s);
if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 3.0, p.1), (p.0, p.1 + 3.0), p) }
}
b.close_path();
square(&mut b, 90.0, 90.0, 20.0, true);
shape(&b)
}
/// The views the solids are compared in: straight on, turned, with perspective, and with colored
/// lights (Inflate's smooth triangles).
fn views() -> Vec<View> {
let turned = View { rotation_x: -25.0, rotation_y: 35.0, rotation_z: 10.0, ..View::default() };
vec![front(), turned, View { perspective: 60.0, ..turned }, View { light_color: [1.0, 0.5, 0.25], ..turned }]
}
/// Extrude & Bevel built once for its facets and its mesh gives what [`crate::extrude`] and
/// [`extrude_mesh`] give apart, to the bit, errors included.
#[test]
fn an_extruded_solid_and_its_mesh_match_the_two_built_apart() {
let bevel = Bevel { shape: BevelShape::Rounded, width: 6.0, height: 5.0, ..Bevel::default() };
let options = [
Extrude::default(),
Extrude { depth: 0.0, ..Extrude::default() },
Extrude { cap: false, ..Extrude::default() },
Extrude { hidden: true, ..Extrude::default() },
Extrude { twist: 90.0, taper: 0.6, ..Extrude::default() },
Extrude { bevel: Some(bevel), ..Extrude::default() },
Extrude { bevel: Some(Bevel { both_sides: true, inside: true, ..bevel }), twist: 45.0, ..Extrude::default() },
];
let (p, r) = star(7);
for view in views() {
for o in options {
let meshed = extrude_meshed(&p, FillRule::NonZero, r, o, view).unwrap();
let facets = crate::extrude(&p, FillRule::NonZero, r, o, view).unwrap();
assert_eq!(format!("{:?}", meshed.facets), format!("{facets:?}"), "{o:?} {view:?}");
let apart = extrude_mesh(&p, FillRule::NonZero, r, o, view).unwrap();
assert!(!apart.is_empty());
assert_eq!(bits(&meshed.mesh().unwrap()), bits(&apart), "{o:?} {view:?}");
}
}
// A solid that can't be built gives the same error.
let flat = Rect::new(0.0, 0.0, f64::INFINITY, 10.0);
let o = Extrude::default();
let error = crate::extrude(&p, FillRule::NonZero, flat, o, front()).err();
assert!(error.is_some());
assert_eq!(extrude_meshed(&p, FillRule::NonZero, flat, o, front()).err(), error);
}
/// Inflate's solids built once for their facets and meshes give what [`crate::inflate_each`] and
/// [`inflate_meshes`] give apart, to the bit: several puffs under one eye, both sides, open
/// backs, and twisted sides cut finer than room is left for.
#[test]
fn inflated_solids_and_their_meshes_match_the_two_built_apart() {
let (star, _) = star(5);
let mut b = BezPath::new();
square(&mut b, 200.0, 20.0, 60.0, false);
let (box_, _) = shape(&b);
let ring: Vec<vectorcraft_geom::Point> = (0..600)
.map(|k| {
let (s, c) = (k as f64 * std::f64::consts::TAU / 600.0).sin_cos();
vectorcraft_geom::Point::new(100.0 + 90.0 * c, 100.0 + 90.0 * s)
})
.collect();
let ring = PathData::single(vectorcraft_geom::SubPath::polyline(&ring, true));
let sets = [vec![(&star, FillRule::NonZero), (&box_, FillRule::NonZero), (&star, FillRule::EvenOdd)], vec![(&ring, FillRule::NonZero)]];
let bounds = Rect::new(0.0, 0.0, 280.0, 200.0);
let options = [
Inflate::default(),
Inflate { depth: 30.0, both_sides: true, ..Inflate::default() },
Inflate { depth: 20.0, cap: false, volume: 80.0, ..Inflate::default() },
Inflate { depth: 40.0, twist: 360.0, taper: 0.7, both_sides: true, ..Inflate::default() },
// Thin and barely twisted: the star's sides want more pieces than room is left after its
// front and back, and the mesh, which keeps the faces turned away, has less room left.
Inflate { depth: 0.1, twist: 0.1, both_sides: true, ..Inflate::default() },
];
for shapes in &sets {
for view in views() {
for o in options {
let meshed: Vec<_> = inflate_meshed(shapes, bounds, o, view).collect();
let facets: Vec<_> = crate::inflate_each(shapes, bounds, o, view).collect();
let apart: Vec<_> = inflate_meshes(shapes, bounds, o, view).collect();
assert_eq!(meshed.len(), shapes.len());
for ((m, f), a) in meshed.iter().zip(&facets).zip(&apart) {
let (m, f, a) = (m.as_ref().unwrap(), f.as_ref().unwrap(), a.as_ref().unwrap());
assert_eq!(format!("{:?}", m.facets), format!("{f:?}"), "{o:?} {view:?}");
assert!(!a.is_empty());
assert_eq!(bits(&m.mesh().unwrap()), bits(a), "{o:?} {view:?}");
}
}
}
}
}
/// One build gives both the facets and the mesh: each solid is built once.
#[test]
fn a_meshed_solid_is_built_once() {
let (p, r) = star(5);
let extruded = || crate::solid::EXTRUDED.with(|n| n.get());
let start = extruded();
let solid = extrude_meshed(&p, FillRule::NonZero, r, Extrude::default(), front()).unwrap();
assert!(!solid.facets.is_empty() && solid.mesh().is_ok_and(|m| !m.is_empty()));
assert_eq!(extruded(), start + 1);
let inflated = || crate::solid::INFLATED.with(|n| n.get());
let start = inflated();
let mut b = BezPath::new();
square(&mut b, 200.0, 20.0, 60.0, false);
let (q, _) = shape(&b);
for solid in inflate_meshed(&[(&p, FillRule::NonZero), (&q, FillRule::NonZero)], Rect::new(0.0, 0.0, 280.0, 200.0), Inflate::default(), front()) {
assert!(solid.is_ok_and(|s| !s.facets.is_empty() && s.mesh().is_ok_and(|m| !m.is_empty())));
}
assert_eq!(inflated(), start + 2);
}
+7 -7
View File
@@ -11,7 +11,7 @@ use std::ops::Range;
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect};
use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces};
use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces};
use crate::{Extrude, Inflate, MAX_SOLID_FACES, Revolve, V3, View, finite, perspective_scale};
/// Where the shadow falls.
@@ -160,14 +160,14 @@ impl ShadowCaster {
/// Add Extrude & Bevel's solid ([`crate::extrude`]).
pub fn extrude(&mut self, path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Range<usize>, String> {
let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, true)?;
self.add(faces, projection)
let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, Keep::Faces)?;
self.add(faces?, projection)
}
/// Add Rotate's plane ([`crate::plane`]).
pub fn plane(&mut self, path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Range<usize>, String> {
let (_, faces, projection) = plane_faces(path, rule, bounds, view, true)?;
self.add(faces, projection)
let (_, faces, projection) = plane_faces(path, rule, bounds, view, Keep::Faces)?;
self.add(faces?, projection)
}
/// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each
@@ -182,11 +182,11 @@ impl ShadowCaster {
options: Inflate,
view: View,
) -> Vec<Result<Range<usize>, String>> {
inflate_faces(shapes, bounds, options, view, true)
inflate_faces(shapes, bounds, options, view, Keep::Faces)
.enumerate()
.map(|(i, r)| {
let (_, faces, projection) = r?;
if casts.get(i).copied().unwrap_or(true) { self.add(faces, projection) } else { Ok(self.faces.len()..self.faces.len()) }
if casts.get(i).copied().unwrap_or(true) { self.add(faces?, projection) } else { Ok(self.faces.len()..self.faces.len()) }
})
.collect()
}
+3 -1
View File
@@ -20,9 +20,11 @@ mod membrane;
mod outline;
mod rings;
#[cfg(test)]
pub(crate) use build::EXTRUDED;
#[cfg(test)]
use build::{Camera, cuts, edge_distance, newell, section};
pub(crate) use build::{Face3, extrude_faces, plane_faces};
pub(crate) use build::{Face3, Keep, extrude_faces, plane_faces};
pub use build::{extrude, plane};
pub use face_map::{FaceMap, extrude_face_map, plane_face_map};
#[cfg(test)]
+76 -23
View File
@@ -64,9 +64,25 @@ impl Camera {
/// and its outward normal.
pub(crate) type Face3 = (Vec<Vec<V3>>, V3);
/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection,
/// and the projection it used (`None` when nothing was built).
pub(crate) type Built = (Vec<Facet>, Vec<Face3>, Option<Projection>);
/// What a solid's builder makes: its facets, its faces before projection when a mesh is asked for
/// (an error when they pass [`MAX_SOLID_FACES`] while the facets are built too), and the
/// projection it used (`None` when nothing was built).
pub(crate) type Built = (Vec<Facet>, Result<Vec<Face3>, String>, Option<Projection>);
#[cfg(test)]
thread_local! {
/// How many Extrude & Bevel solids this thread has built, for the tests.
pub(crate) static EXTRUDED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// What a solid's builder keeps: its facets, its faces before projection for a mesh, or both
/// from one build.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Keep {
Facets,
Faces,
Both,
}
/// Facets being collected for one solid.
pub(super) struct Builder<'a> {
@@ -80,6 +96,11 @@ pub(super) struct Builder<'a> {
pub(super) facets: Vec<Facet>,
/// With `Some`, every face goes here as it is, none hidden or projected, for a mesh.
pub(super) mesh: Option<Vec<Face3>>,
/// The faces are projected into `facets`; without it only `mesh` is kept.
pub(super) draw: bool,
/// The faces kept for the mesh passed [`MAX_SOLID_FACES`] while the facets were built too: the
/// mesh is too complex, and `mesh` keeps no more faces.
pub(super) lost: bool,
}
/// Where a face goes in the order the facets are drawn, back to front.
@@ -98,20 +119,46 @@ pub(super) enum Order {
Last,
}
impl Builder<'_> {
impl<'a> Builder<'a> {
/// A builder for one solid seen by `camera`, keeping what `keep` asks for.
pub(super) fn new(camera: &'a Camera, closed: bool, hidden: bool, keep: Keep) -> Self {
Self { camera, closed, hidden, facets: vec![], mesh: (keep != Keep::Facets).then(Vec::new), draw: keep != Keep::Faces, lost: false }
}
/// Add a face of view-space contours with outward normal `n` (view space), drawn in `order`.
pub(super) fn face(&mut self, contours: Vec<Vec<V3>>, n: V3, order: Order) -> Result<(), String> {
let count: usize = contours.iter().map(Vec::len).sum();
if count < 3 {
return Ok(());
}
if let Some(mesh) = &mut self.mesh {
mesh.push((contours, n.unit()));
if mesh.len() > MAX_SOLID_FACES {
if self.draw {
self.facet(&contours, count, n, order)?;
}
let Some(mesh) = &mut self.mesh else { return Ok(()) };
mesh.push((contours, n.unit()));
if mesh.len() > MAX_SOLID_FACES {
if !self.draw {
return Err(TOO_COMPLEX_SURFACE.into());
}
return Ok(());
self.lose_mesh();
}
Ok(())
}
/// Keep no more faces for the mesh, which is too complex.
pub(super) fn lose_mesh(&mut self) {
self.mesh = None;
self.lost = true;
}
/// How many faces the mesh holds, while the facets are built too and the mesh is kept.
pub(super) fn faces_kept(&self) -> Option<usize> {
self.mesh.as_ref().filter(|_| self.draw).map(Vec::len)
}
/// Project a face of `count` view-space corners with outward normal `n` into a facet, drawn in
/// `order`, unless it is hidden or has no area.
fn facet(&mut self, contours: &[Vec<V3>], count: usize, n: V3, order: Order) -> Result<(), String> {
let centre = contours.iter().flatten().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2));
let centre = V3(centre.0 / count as f64, centre.1 / count as f64, centre.2 / count as f64);
let n = n.unit();
@@ -138,7 +185,7 @@ impl Builder<'_> {
let flat = !held && contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3);
let plane = flat.then_some(DepthPlane { normal: [n.0, n.1, n.2], offset, center: self.camera.origin, eye: self.camera.eye });
#[cfg(test)]
let view = contours.clone();
let view = contours.to_vec();
let contours: Vec<Vec<Point>> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect();
if contours.iter().flatten().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return Err("The 3D projection is not finite".into());
@@ -262,7 +309,7 @@ impl Builder<'_> {
/// Give the facet that [`Builder::face`] added, when it added one after `before` facets, the
/// outline `outline` makes from its projected contours.
pub(super) fn outline(&mut self, before: usize, outline: impl FnOnce(&[Vec<Point>]) -> Option<PathData>) {
if self.mesh.is_some() || self.facets.len() <= before {
if !self.draw || self.facets.len() <= before {
return;
}
if let Some(f) = self.facets.last_mut() {
@@ -273,7 +320,7 @@ impl Builder<'_> {
/// Mark the facet that [`Builder::face`] added, when it added one after `before` facets, as the
/// planar end `end` of the solid.
pub(super) fn end(&mut self, before: usize, end: PlanarFace) {
if self.mesh.is_some() || self.facets.len() <= before {
if !self.draw || self.facets.len() <= before {
return;
}
if let Some(f) = self.facets.last_mut() {
@@ -281,15 +328,19 @@ impl Builder<'_> {
}
}
/// How many faces the builder holds: facets, or faces kept for a mesh.
/// How many faces the builder holds: facets, or without them the faces kept for a mesh.
pub(super) fn len(&self) -> usize {
self.mesh.as_ref().map_or(self.facets.len(), Vec::len)
match &self.mesh {
Some(mesh) if !self.draw => mesh.len(),
_ => self.facets.len(),
}
}
/// The facets back to front, and the faces kept for a mesh (none without [`Builder::mesh`]).
pub(super) fn finish(mut self) -> Built {
self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth));
(self.facets, self.mesh.unwrap_or_default(), Some(self.camera.projection()))
let faces = if self.lost { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(self.mesh.unwrap_or_default()) };
(self.facets, faces, Some(self.camera.projection()))
}
}
@@ -536,16 +587,18 @@ fn steps(o: &Extrude) -> Vec<Step> {
/// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels
/// and caps.
pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Vec<Facet>, String> {
extrude_faces(path, rule, bounds, options, view, false).map(|(facets, ..)| facets)
extrude_faces(path, rule, bounds, options, view, Keep::Facets).map(|(facets, ..)| facets)
}
/// [`extrude`], or with `mesh` the solid's faces before projection instead of its facets.
pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, mesh: bool) -> Result<Built, String> {
/// [`extrude`], keeping the solid's facets, its faces before projection, or both, as `keep` asks.
pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, keep: Keep) -> Result<Built, String> {
#[cfg(test)]
EXTRUDED.with(|n| n.set(n.get() + 1));
check_bounds(bounds)?;
let o = options.sanitized();
// No depth: the flat shape, seen from either side.
if o.depth <= 0.0 {
return plane_faces(path, rule, bounds, view, mesh);
return plane_faces(path, rule, bounds, view, keep);
}
let steps = steps(&o);
// One face for each edge of the outline in each band between two steps, and the two caps:
@@ -561,7 +614,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
return Err(TOO_COMPLEX_SURFACE.into());
}
let camera = Camera::new(view, bounds, o.depth);
let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) };
let mut b = Builder::new(&camera, o.cap, o.hidden, keep);
let z = |t: f64| o.depth / 2.0 - o.depth * t;
let ring_at =
|ring: &[Point], s: &Step| -> Vec<V3> { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() };
@@ -647,15 +700,15 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
/// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the
/// shape's own, so that every part of one object turns about the same point).
pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Vec<Facet>, String> {
plane_faces(path, rule, bounds, view, false).map(|(facets, ..)| facets)
plane_faces(path, rule, bounds, view, Keep::Facets).map(|(facets, ..)| facets)
}
/// [`plane`], or with `mesh` its face before projection instead of its facet.
pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result<Built, String> {
/// [`plane`], keeping its facet, its face before projection, or both, as `keep` asks.
pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, keep: Keep) -> Result<Built, String> {
check_bounds(bounds)?;
let rings = solid_rings_traced(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS, CURVE_PIECES)?;
let camera = Camera::new(view, bounds, 0.0);
let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) };
let mut b = Builder::new(&camera, false, false, keep);
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.points.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
// A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so
// it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in.
+41 -16
View File
@@ -1,4 +1,4 @@
use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward};
use super::build::{Builder, Built, Camera, Keep, Order, Turn, check_bounds, cuts, fit, newell, outward};
use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, crossings, filled, material};
use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings};
use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View};
@@ -73,26 +73,26 @@ pub fn inflate_each(
options: Inflate,
view: View,
) -> impl Iterator<Item = Result<Vec<Facet>, String>> + use<> {
inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets))
inflate_faces(shapes, bounds, options, view, Keep::Facets).map(|r| r.map(|(facets, ..)| facets))
}
/// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets.
/// Every shape's membrane is solved at once (the eye depends on the highest puff); each solid is
/// built as the iterator reaches it.
/// [`inflate_together`], keeping each solid's facets, its faces before projection, or both, as
/// `keep` asks. Every shape's membrane is solved at once (the eye depends on the highest puff);
/// each solid is built as the iterator reaches it.
pub(crate) fn inflate_faces(
shapes: &[(&PathData, FillRule)],
bounds: Rect,
options: Inflate,
view: View,
mesh: bool,
keep: Keep,
) -> impl Iterator<Item = Result<Built, String>> + use<> {
let o = options.sanitized();
let puffs: Vec<Result<Option<Puff>, String>> =
shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect();
let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max);
puffs.into_iter().map(move |p| match p {
Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh),
Ok(None) => Ok((vec![], vec![], None)),
Ok(Some(p)) => inflated(p, bounds, o, view, reach, keep),
Ok(None) => Ok((vec![], Ok(vec![]), None)),
Err(e) => Err(e),
})
}
@@ -106,9 +106,9 @@ thread_local! {
}
/// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the
/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. With `mesh`,
/// its faces before projection instead.
fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: bool) -> Result<Built, String> {
/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. Its faces
/// before projection instead, or both, as `keep` asks.
fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, keep: Keep) -> Result<Built, String> {
#[cfg(test)]
INFLATED.with(|n| n.set(n.get() + 1));
let Puff { rings, edges, cell, nx, ny, m, height } = p;
@@ -149,7 +149,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo
let smooth = view.shade && view.has_colored_lights();
let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach));
// Without its back cap the object is a shell open behind, its inside showing.
let mut b = Builder { camera: &camera, closed: o.cap, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) };
let mut b = Builder::new(&camera, o.cap, false, keep);
let half = o.depth / 2.0;
// The back: turned and scaled by the twist and taper, its puff too.
let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0);
@@ -375,15 +375,40 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo
let turn = Turn::new(bounds, o.twist, o.taper, t);
ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect()
};
let mut pieces: Vec<Vec<usize>> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect();
let cut: Vec<Vec<usize>> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect();
let mut pieces = cut.clone();
fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len()));
let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add);
if b.len().saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES {
return Err(TOO_COMPLEX_SURFACE.into());
}
for (ring, pieces) in rings.iter().zip(&pieces) {
for k in 0..slices {
b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?;
let sides = |b: &mut Builder<'_>, pieces: &[Vec<usize>]| -> Result<(), String> {
for (ring, pieces) in rings.iter().zip(pieces) {
for k in 0..slices {
b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?;
}
}
Ok(())
};
// A mesh kept with the facets has the sides a mesh alone has: limited, and with a twist
// cut, by the faces the mesh holds, which can be more than the facets.
let mut own_sides = None;
if let Some(kept) = b.faces_kept() {
let mut own = cut;
fit(&mut own, slices, MAX_FACES.saturating_sub(kept));
if kept.saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES {
b.lose_mesh();
} else if own != pieces {
own_sides = Some((own, b.mesh.take()));
}
}
sides(&mut b, &pieces)?;
if let Some((own, faces)) = own_sides {
let mut m = Builder::new(&camera, o.cap, false, Keep::Faces);
m.mesh = faces;
match sides(&mut m, &own) {
Ok(()) => b.mesh = m.mesh,
Err(_) => b.lose_mesh(),
}
}
}
+2 -2
View File
@@ -225,12 +225,12 @@ fn a_flat_facet_measures_its_depth_on_its_plane() {
/// eye, has no plane: its drawing no longer matches the plane's projection there.
#[test]
fn a_face_reaching_past_the_eye_has_no_plane() {
use super::build::{Builder, Order};
use super::build::{Builder, Keep, Order};
let (_, b) = shape(&square(0.0, 0.0, 100.0));
let camera = Camera::new(View { perspective: 50.0, ..front() }, b, 0.0);
let eye = camera.center_and_eye().1.unwrap();
for (far, has_plane) in [(eye / 2.0, true), (2.0 * eye, false)] {
let mut builder = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: None };
let mut builder = Builder::new(&camera, false, false, Keep::Facets);
let corners = vec![V3(-10.0, -10.0, 0.0), V3(10.0, -10.0, 0.0), V3(0.0, 10.0, far)];
builder.face(vec![corners], V3(0.0, -far, 20.0), Order::Depth).unwrap();
assert_eq!(builder.facets.len(), 1, "{far}");
+118
View File
@@ -1028,6 +1028,124 @@ fn a_drawn_point_lands_on_a_corner_of_a_glyphs_bounds() {
}
}
/// Snap to Glyph: both ends of a gradient vector drawn on the art with the Gradient tool, a click
/// that applies one, and its dragged start and end handles, land on a glyph's anchor point as a
/// Pen click does, with the guides shown while hovering, and not with Snap to Glyph off. A drawn
/// end also lands on a slanted path's segment, which no bounds line passes along, and on a
/// construction guide through its start, and with Shift on an anchor along its 45° step. With nothing
/// selected, hovering off the art shows no guides. A press that snaps still starts a drag only
/// after the pointer moves 3 px from where it was pressed.
#[test]
fn gradient_vector_ends_land_on_a_glyph_anchor() {
use crate::xform::GradientTool;
use crate::{Action, PointerEvent, PointerKind, Tool};
use vectorcraft_color::{Gradient, GradientGeom, GradientPaint, Paint};
use vectorcraft_doc::Appearance;
use vectorcraft_geom::shapes;
let (mut d, t) = glyph_doc("H", 0.0);
let lay = layout_text(FontDb::global(), &t);
let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline");
// An anchor inside the glyph's bounds, which no glyph bounds line passes through.
let bb = g.outline.bounding_box();
let inside = |p: Point| p.x > bb.x0 + 1.0 && p.x < bb.x1 - 1.0 && p.y > bb.y0 + 1.0 && p.y < bb.y1 - 1.0;
let anchor = t.xf * g.outline.segments().map(|s| s.start()).find(|p| inside(*p)).expect("an anchor inside the glyph");
let type_id = d.children(Some(d.layers[0].id)).unwrap()[0].id;
// A rectangle away from the type, with a gradient from (300, 100) to (400, 100).
let (l, rect) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::path(rect, shapes::rectangle(Rect::new(300.0, 50.0, 400.0, 150.0)), Appearance::default_art())).unwrap();
let mut gp = GradientPaint::new(Gradient::default());
gp.geom = Some(GradientGeom { start: Point::new(300.0, 100.0), end: Point::new(400.0, 100.0), aspect: 1.0, focal: None });
d.node_mut(rect).unwrap().appearance.set_fill(Paint::Gradient(Box::new(gp)));
// A slanted line, its middle far from its bounds' edges.
let (a0, a1) = (Point::new(200.0, 420.0), Point::new(280.0, 480.0));
let line = d.alloc_id();
d.insert(Some(l), 0, Node::path(line, shapes::line(a0, a1), Appearance::default_art())).unwrap();
let mut on_type = Selection::default();
on_type.add(type_id);
let mut on_rect = Selection::default();
on_rect.add(rect);
let p = paint();
let click = anchor + Vec2::new(1.5, -1.0);
let away = anchor + Vec2::new(-83.3, -61.7);
let ev = |kind, q: Point| PointerEvent::new(kind, q.x, q.y);
let preview = |acts: &[Action]| {
acts.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v.clone()) } else { None }).unwrap_or_else(|| panic!("{acts:?}"))
};
let at = |v: &serde_json::Value| Point::new(v[0].as_f64().unwrap(), v[1].as_f64().unwrap());
for glyphs in [true, false] {
let c = ToolContext { snap_to_glyph: glyphs, ..cx(&d, &on_type, &p) };
let lands = |q: Point| q.distance(anchor) < 1e-9;
// The pointer hovering at the click shows the guides of the anchor a vector would start on.
let mut tool = GradientTool::default();
tool.pointer(&c, &ev(PointerKind::Move, click));
assert_eq!(!tool.overlays(&c).is_empty(), glyphs, "hover, Snap to Glyph {glyphs}");
// A vector drawn from the click starts on the anchor.
tool.pointer(&c, &ev(PointerKind::Down, click));
let v = preview(&tool.pointer(&c, &ev(PointerKind::Drag, click + Vec2::new(80.0, 60.0))));
assert_eq!(lands(at(&v["start"])), glyphs, "start, Snap to Glyph {glyphs}: {v}");
tool.pointer(&c, &ev(PointerKind::Up, click + Vec2::new(80.0, 60.0)));
// One drawn to beside the slanted line, a quarter of the way along (15 pt or more from its
// bounds' edges, centre and anchors), ends on the line.
let dir = (a1 - a0) / (a1 - a0).hypot();
let quarter = a0 + (a1 - a0) * 0.25;
tool.pointer(&c, &ev(PointerKind::Down, away));
let v = preview(&tool.pointer(&c, &ev(PointerKind::Drag, quarter + Vec2::new(-dir.y, dir.x) * 2.0)));
assert!((at(&v["end"]) - a0).cross(dir).abs() < 1e-6, "end on the line: {v}");
tool.pointer(&c, &ev(PointerKind::Up, quarter));
// One drawn to the click ends on it.
tool.pointer(&c, &ev(PointerKind::Down, away));
let v = preview(&tool.pointer(&c, &ev(PointerKind::Drag, click)));
assert_eq!(lands(at(&v["end"])), glyphs, "end, Snap to Glyph {glyphs}: {v}");
tool.pointer(&c, &ev(PointerKind::Up, click));
// A click at the click applies a gradient that starts on the anchor.
tool.pointer(&c, &ev(PointerKind::Down, click));
let acts = tool.pointer(&c, &ev(PointerKind::Up, click));
let v = acts.iter().find_map(|a| if let Action::Exec(_, v) = a { Some(v.clone()) } else { None }).unwrap_or_else(|| panic!("{acts:?}"));
assert_eq!(lands(at(&v["start"])), glyphs, "click, Snap to Glyph {glyphs}: {v}");
// One drawn to 2 px below the horizontal through its start, far from any target, ends on
// that construction guide, as a Line Segment's end does.
tool.pointer(&c, &ev(PointerKind::Down, away));
let v = preview(&tool.pointer(&c, &ev(PointerKind::Drag, away + Vec2::new(150.0, 2.0))));
assert!((at(&v["end"]).y - away.y).abs() < 1e-9, "end on the construction guide: {v}");
tool.pointer(&c, &ev(PointerKind::Up, away + Vec2::new(150.0, 2.0)));
// With Shift, one drawn at 45° toward the line's first anchor slides along that step onto it.
let shift = |kind, q: Point| PointerEvent { mods: crate::Mods { shift: true, ..Default::default() }, ..ev(kind, q) };
tool.pointer(&c, &shift(PointerKind::Down, a0 + Vec2::new(60.0, -60.0)));
let v = preview(&tool.pointer(&c, &shift(PointerKind::Drag, a0 + Vec2::new(-1.5, 0.5))));
assert!(at(&v["end"]).distance(a0) < 1e-9, "Shift end on the anchor: {v}");
tool.pointer(&c, &shift(PointerKind::Up, a0));
// A press 3 px from the anchor, then a move of 2 px away from it: no drag yet.
let near = anchor + Vec2::new(2.5, -1.5);
tool.pointer(&c, &ev(PointerKind::Down, near));
let acts = tool.pointer(&c, &ev(PointerKind::Drag, near + Vec2::new(1.5, -1.0)));
assert!(acts.is_empty(), "drag start, Snap to Glyph {glyphs}: {acts:?}");
tool.pointer(&c, &ev(PointerKind::Up, near + Vec2::new(1.5, -1.0)));
// The end handle of the rectangle's gradient dragged to the click lands on the anchor.
let c = ToolContext { snap_to_glyph: glyphs, ..cx(&d, &on_rect, &p) };
let mut tool = GradientTool::default();
// Hovering over the handle (on the rectangle's edge) shows no guides.
tool.pointer(&c, &ev(PointerKind::Move, Point::new(400.0, 100.0)));
assert!(!tool.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { .. })), "hover over the handle");
tool.pointer(&c, &ev(PointerKind::Down, Point::new(400.0, 100.0)));
let v = preview(&tool.pointer(&c, &ev(PointerKind::Drag, click)));
assert_eq!(lands(at(&v["end"])), glyphs, "end handle, Snap to Glyph {glyphs}: {v}");
tool.pointer(&c, &ev(PointerKind::Up, click));
// The start handle dragged to the click lands on the anchor.
tool.pointer(&c, &ev(PointerKind::Down, Point::new(300.0, 100.0)));
let v = preview(&tool.pointer(&c, &ev(PointerKind::Drag, click)));
assert_eq!(lands(at(&v["start"])), glyphs, "start handle, Snap to Glyph {glyphs}: {v}");
tool.pointer(&c, &ev(PointerKind::Up, click));
// With nothing selected, hovering beside the rectangle (off the art and its 1 pt stroke,
// where a press draws nothing, but within the snapping tolerance of its edge) shows no
// guides.
let none = Selection::default();
let c = ToolContext { snap_to_glyph: glyphs, ..cx(&d, &none, &p) };
let mut tool = GradientTool::default();
tool.pointer(&c, &ev(PointerKind::Move, Point::new(296.2, 75.0)));
assert!(tool.overlays(&c).is_empty(), "hover off the art: {:?}", tool.overlays(&c));
}
}
/// With Smart Guides off, View → Snap to Point still pulls a drawn point onto an anchor, but
/// draws no Smart Guides: no "anchor" label, no lines.
#[test]
+58 -31
View File
@@ -7,8 +7,10 @@
//!
//! The annotator is a bar from a round start handle to a square end handle. Drag the start handle
//! (or the bar) to move the gradient, the end handle to change its length and angle, and just past
//! the end to rotate it. Dragged handles snap to anchors, edges and smart guides; Shift instead
//! constrains the vector to 45° steps from the Constrain Angle preference. Colour stops sit under the bar: click the bar to add
//! the end to rotate it. A vector drawn on the art, a click that applies one, and the dragged
//! handles snap as drawn points do (the anchors and paths of the art, and with Snap to Glyph the
//! guides of live type); Shift keeps the dragged end or handle at 45° steps from the Constrain Angle
//! preference, snapping along that step. Colour stops sit under the bar: click the bar to add
//! one, drag one to move it (Alt drags a copy), drag it off the bar to delete it, and drag a
//! diamond above the bar to move a midpoint. Double-clicking a stop opens its popover. The
//! selected stop (`gradient.selectStop`) is shared with the Gradient and Color panels: Delete or
@@ -43,7 +45,7 @@ use vectorcraft_geom::{Affine, Point, Shape, Vec2, constrain_angle_from};
use super::freeform::{self, Annotator as FreeformAnnotator};
use super::paint_owner;
use crate::guides::snap_draw;
use crate::guides::{DrawSnap, Leave};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
const BAR: [u8; 3] = [0x20, 0x20, 0x20];
@@ -239,6 +241,8 @@ struct Gesture {
grab: Grab,
/// Where the press happened.
at: Point,
/// Where a vector drawn on the art starts: the press, snapped.
start: Point,
/// Moved past the drag threshold (an interaction is open).
began: bool,
/// The vector drawn so far (Art drags).
@@ -255,8 +259,9 @@ pub struct GradientTool {
free: Option<freeform::Gesture>,
/// The line Lines mode is drawing on a freeform gradient.
lines: freeform::Lines,
/// Smart-guide feedback of the handle being dragged.
guides: Vec<Overlay>,
/// Smart Guides for the vector being drawn or the handle being dragged, and for the pointer
/// hovering over the art.
snap: DrawSnap,
}
/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop; a
@@ -378,18 +383,40 @@ impl GradientTool {
Some((a, Part::Aspect)) => Grab::Aspect(GradientGeom { aspect: a.geom.aspect.abs(), ..a.geom }),
None => {
if cx.selection.is_empty() {
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return out };
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else {
self.snap.clear();
return out;
};
out.push(Action::Exec("select.set".into(), json!({ "ids": [paint_owner(cx.doc, h.leaf).0] })));
}
Grab::Art
}
};
// A vector drawn on the art starts on the snapped point.
let at = if grab == Grab::Art { snap_draw(cx, p, &[]).0 } else { p };
self.gesture = Some(Gesture { grab, at, began: false, vector: None, stop: None });
// The targets are gathered for a drag that snaps. A vector drawn on the art starts on the
// snapped point; the drag threshold still counts from the press.
let snaps = matches!(grab, Grab::Art | Grab::Move { .. } | Grab::End(_));
let snapped = if snaps { self.snap.press(cx, p, &[], None) } else { p };
let start = if grab == Grab::Art { snapped } else { p };
if grab != Grab::Art {
self.snap.clear();
}
self.gesture = Some(Gesture { grab, at: p, start, began: false, vector: None, stop: None });
out
}
/// Snap the pointer hovering where a press would draw a vector (anywhere with art selected,
/// over art with nothing selected), to show where the vector starts; no guides over the
/// annotator, elsewhere with nothing selected, or during a press.
fn hover(&mut self, cx: &ToolContext, p: Point) {
let on_annotator = || Annotator::of(cx).is_some_and(|a| a.hit(cx, p).is_some());
let off_art = || cx.selection.is_empty() && hit_test(cx.doc, p, cx.hit_options()).is_none();
if self.gesture.is_some() || self.free.is_some() || FreeformAnnotator::of(cx).is_some() || on_annotator() || off_art() {
self.snap.clear();
} else {
self.snap.hover(cx, p, &[], None);
}
}
fn drag(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> {
let Some(g) = &mut self.gesture else { return vec![] };
let mut out = vec![];
@@ -401,23 +428,16 @@ impl GradientTool {
out.push(Action::Begin("Gradient".into()));
}
let constrain = |v: Vec2| constrain_angle_from(v, 45.0, cx.constrain_angle);
// Where a handle dragged to `to` lands: Shift constrains the vector from `from`, else it
// snaps (keeping the guides to show).
let mut guides = vec![];
let mut place = |from: Point, to: Point| {
if mods.shift {
from + constrain(to - from)
} else {
let (q, o) = snap_draw(cx, to, &[]);
guides = o;
q
}
};
// Where a handle dragged to `to` lands: it snaps as a drawn segment's end leaving `from`
// does, Shift keeping it at 45° steps from `from` (keeping the guides to show).
let snap = &mut self.snap;
snap.clear();
let mut place = |from: Point, to: Point| snap.drag(cx, to, Some(&Leave::segment(cx, from, mods.shift)));
let (cmd, params) = match &g.grab {
Grab::Art => {
let end = place(g.at, p);
g.vector = Some((g.at, end));
("paint.setGradientGeom", Self::geom_params(cx, g.at, end))
let end = place(g.start, p);
g.vector = Some((g.start, end));
("paint.setGradientGeom", Self::geom_params(cx, g.start, end))
}
Grab::Move { geom, .. } => {
// The start handle snaps; Shift constrains the move.
@@ -469,13 +489,12 @@ impl GradientTool {
("paint.editGradient", Self::stops_params(cx, &set_midpoint(stops, *index, m)))
}
};
self.guides = guides;
out.push(Action::Preview(cmd.into(), params));
out
}
fn release(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
self.guides.clear();
self.snap.clear();
let Some(g) = self.gesture.take() else { return vec![] };
if g.began {
let mut out = vec![Action::Commit];
@@ -494,12 +513,12 @@ impl GradientTool {
let (stops, i) = insert_stop(&a.gradient, t);
vec![Action::Exec("paint.editGradient".into(), Self::stops_params(cx, &stops)), Self::select_stop(i)]
}
// A click inside selected art applies the gradient from that point.
// A click inside selected art applies the gradient from that point, snapped.
Grab::Art => {
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return vec![] };
let id = paint_owner(cx.doc, h.leaf);
let Some(v) = cx.selection.objects.contains(&id).then(|| click_vector(cx, id)).flatten() else { return vec![] };
let mut params = Self::geom_params(cx, p, p + v);
let mut params = Self::geom_params(cx, g.start, g.start + v);
// Without `ids`, the engine refuses the gradient while an object of a dynamic symbol
// instance's art is selected.
if cx.selection.symbol_part.is_some() {
@@ -550,6 +569,9 @@ impl Tool for GradientTool {
}
_ => {}
}
if matches!(ev.kind, PointerKind::Move) {
self.hover(cx, ev.pos);
}
match ev.kind {
PointerKind::Down => self.press(cx, ev.pos, ev.mods),
PointerKind::Drag => self.drag(cx, ev.pos, ev.mods),
@@ -579,14 +601,19 @@ impl Tool for GradientTool {
return self.key(cx, ToolKey::Escape, Mods::default());
}
(self.gesture, self.free) = (None, None);
self.guides.clear();
self.snap.clear();
vec![]
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.snap.clear();
vec![]
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.busy() {
self.gesture = None;
self.guides.clear();
self.snap.clear();
return self.free.take().map_or_else(|| vec![Action::Cancel], |g| freeform::cancel(&g));
}
if self.busy() || !self.claims_key(cx, key) {
@@ -626,7 +653,7 @@ impl Tool for GradientTool {
}
_ => vec![],
};
o.extend(self.guides.iter().cloned());
o.extend(self.snap.guides().iter().cloned());
o
}
+40 -3
View File
@@ -425,7 +425,7 @@ pub fn report() -> String {
}
}
let mut md = String::from(
"## Centre lines\n\nCode-drawn scenes only: 23 scenes, each anti-aliased (8×8 coverage samples) and hard (that coverage thresholded at 0.5). Coordinates, widths and scene Stroke values are fixed; junction/letter/crossing coordinates include the 0.27/0.41 px offset. Create Fills and Strokes are on, Stroke is the scene's limit (13 px, mix 6 px, varying 12 px), Black and White threshold 128, Noise 4, Ignore White on, other settings at their defaults. Today's tracer has no minimum-length setting (the dashed scenes specify 0 for future use).\n\nDistances use dense exact centre samples (≤0.25 px), emitted curves sampled at ≤0.5 px control-polygon steps, and nearest-point searches capped at 30 px. Found is the fraction of true samples within 1.5 px; width errors use output samples within 2 px of a true stroke. Samples inside exact fills are excluded. Topology is recovered from emitted subpath endpoints and intersections, merging coincident positions within 0.05 px; an endpoint contributes one arm, an interior two. Junction matching radius is local true width + 3 px; ends use half-width + 4 px. Each match is used once. Free ends exclude true junctions, corners, other strokes and fills; unmatched emitted ends are spurs. Junctions/ends within 2 px of fills are excluded. End error is distance, with signed error along the outward tangent in parentheses. Coverage is a union of painted output at pixel centres (0.02 px flattening tolerance, actual emitted caps), compared with the exact swept shape at pixel centres, for both input variants. Area error is signed relative to exact area. Anchors counts stroke anchors, including all compound subpaths; paths counts stroked + filled paths. Time covers tracing only, one scene at a time. Profile-dependent columns are unavailable (–).\n\n### Centre line and width (px)\n\n| scene | input | paths | out→true mean / p95 | true→out mean / p95 | found % | width err uniform mean / p95 | variable mean / p95 | profile mean |\n|---|---|---|---|---|---|---|---|---|\n",
"## Centre lines\n\nCode-drawn scenes only: 24 scenes, each anti-aliased (8×8 coverage samples) and hard (that coverage thresholded at 0.5). Coordinates, widths and scene Stroke values are fixed; junction/letter/crossing/mixed-width body coordinates include the 0.27/0.41 px offset. Create Fills and Strokes are on, Stroke is the scene's limit (13 px, mix and mixed-width body 6 px, varying 12 px), Black and White threshold 128, Noise 4, Ignore White on, other settings at their defaults. Today's tracer has no minimum-length setting (the dashed scenes specify 0 for future use).\n\nDistances use dense exact centre samples (≤0.25 px), emitted curves sampled at ≤0.5 px control-polygon steps, and nearest-point searches capped at 30 px. Found is the fraction of true samples within 1.5 px; width errors use output samples within 2 px of a true stroke. Samples inside exact fills are excluded. Topology is recovered from emitted subpath endpoints and intersections, merging coincident positions within 0.05 px; an endpoint contributes one arm, an interior two. Junction matching radius is local true width + 3 px; ends use half-width + 4 px. Each match is used once. Free ends exclude true junctions, corners, other strokes and fills; unmatched emitted ends are spurs. Junctions/ends within 2 px of fills are excluded. End error is distance, with signed error along the outward tangent in parentheses. Coverage is a union of painted output at pixel centres (0.02 px flattening tolerance, actual emitted caps), compared with the exact swept shape at pixel centres, for both input variants. Area error is signed relative to exact area. Anchors counts stroke anchors, including all compound subpaths; paths counts stroked + filled paths. Time covers tracing only, one scene at a time. Profile-dependent columns are unavailable (–).\n\n### Centre line and width (px)\n\n| scene | input | paths | out→true mean / p95 | true→out mean / p95 | found % | width err uniform mean / p95 | variable mean / p95 | profile mean |\n|---|---|---|---|---|---|---|---|---|\n",
);
for m in &rows {
writeln!(
@@ -544,7 +544,44 @@ fn known_geometry_measures_junctions_ends_missing_ink_and_caps() {
let square = render(&TraceResult { paths: vec![TracedPath { square_caps: true, ..diagonal }], ..Default::default() }, 50, 50, false);
assert!(square.iter().filter(|p| **p).count() > round.iter().filter(|p| **p).count());
// Preserve the exact catalogue and special scene limits.
assert_eq!(scenes::all().len(), 23);
assert_eq!((scenes::mix().wmax, scenes::varying().wmax, scenes::dashed(2.0).min_len), (6.0, 12.0, 0.0));
assert_eq!(scenes::all().len(), 24);
assert_eq!((scenes::mix().wmax, scenes::mixed_width_body().wmax, scenes::varying().wmax, scenes::dashed(2.0).min_len), (6.0, 6.0, 12.0, 0.0));
assert_eq!(scenes::junctions(4.0).junctions[0].0, P::new(60.27, 40.41));
}
#[test]
fn mixed_width_body_is_one_shape_traced_as_a_fill_and_strokes() {
let sc = scenes::mixed_width_body();
assert_eq!((sc.fills.len(), sc.strokes.len(), sc.junctions.len(), sc.corners.len()), (1, 3, 0, 0));
let cov = sc.rasterise();
let hard: Vec<f32> = cov.iter().map(|&c| if c >= 0.5 { 1.0 } else { 0.0 }).collect();
// The inked pixels form one 8-connected component.
let ink: Vec<bool> = hard.iter().map(|&c| c > 0.0).collect();
let start = ink.iter().position(|&v| v).unwrap();
let mut seen = vec![false; ink.len()];
let mut stack = vec![start];
seen[start] = true;
let mut reached = 0;
while let Some(i) = stack.pop() {
reached += 1;
let (x, y) = ((i % sc.w) as i64, (i / sc.w) as i64);
for (dx, dy) in [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || ny < 0 || nx >= sc.w as i64 || ny >= sc.h as i64 {
continue;
}
let j = ny as usize * sc.w + nx as usize;
if ink[j] && !seen[j] {
seen[j] = true;
stack.push(j);
}
}
}
assert_eq!(reached, ink.iter().filter(|&&v| v).count());
// Create Strokes splits the shape: its body is one fill and each arm is a stroke reaching its free end.
for (input, c) in [("aa", &cov), ("hard", &hard)] {
let m = evaluate(&sc, input, c);
assert_eq!((m.strokes, m.fills, m.ends_gt, m.ends_found), (3, 1, 3, 3), "{m:?}");
assert!(m.complete > 0.95 && m.fill_as_fill > 0.95, "{m:?}");
}
}
@@ -388,6 +388,19 @@ pub fn mix() -> Scene {
s
}
/// One connected shape: a wide disc with three thin arms of 2, 3 and 5 px reaching out of it,
/// and nothing else; Stroke width 6.
pub fn mixed_width_body() -> Scene {
let mut s = Scene::new("mixed-width body", 260, 220, 6.0);
s.offset = P::new(0.27, 0.41);
let c = P::new(100.0, 100.0);
s.fills.push(GtFill::Disc(c + s.offset, 24.0));
s.line(&[c, P::new(220.0, 100.0)], 3.0);
s.line(&[c, P::new(100.0, 20.0)], 5.0);
s.line(&[c, P::new(25.0, 185.0)], 2.0);
s
}
/// Strokes whose width varies: tapers, a lens, a swelling brush curve; Stroke width 12.
pub fn varying() -> Scene {
let mut s = Scene::new("varying", 480, 300, 12.0);
@@ -441,6 +454,7 @@ pub fn all() -> Vec<Scene> {
v.push(letters(w));
}
v.push(mix());
v.push(mixed_width_body());
v.push(varying());
v
}
+54 -17
View File
@@ -55,6 +55,42 @@ fn selection_tool_with_selection(app: &VectorcraftApp) -> bool {
&& app.session.active().is_some_and(|st| !st.selection.is_empty())
}
/// Whether the bar shows its blank canvas actions, Draw (Pencil) and Place Image: a selection tool is
/// in use and the active document has no artwork, only layers and guides.
fn blank_canvas(app: &VectorcraftApp) -> bool {
fn no_art(nodes: &[std::sync::Arc<vectorcraft_doc::Node>]) -> bool {
nodes.iter().all(|n| n.is_guide() || (n.is_layer() && n.children().is_none_or(|c| no_art(c))))
}
matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection")
&& app.session.active().is_some_and(|st| st.selection.is_empty() && no_art(&st.doc.layers))
}
/// Where the bar's top edge centers, on screen: under the lowest corner on screen of the selected
/// artboards or the selection, centered on them (the view may be rotated); on a blank canvas, which
/// has no selection, [`blank_canvas_anchor`]. None with nothing to sit under.
fn bar_anchor(app: &mut VectorcraftApp, xf: &Xf, on_artboards: bool, blank: bool) -> Option<Pos2> {
if blank {
return Some(blank_canvas_anchor(app, xf));
}
let b = if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }?;
let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
Some(pos2(xf.to_screen(b.center()).x, lowest))
}
/// Where the blank canvas bar's top edge centers, on screen: under the bottom of the active artboard
/// when part of it is in view, else the canvas's center.
fn blank_canvas_anchor(app: &VectorcraftApp, xf: &Xf) -> Pos2 {
let board = app.session.active().and_then(|st| {
let last = st.doc.artboards.len().checked_sub(1)?;
st.doc.artboards.get(app.view().map_or(0, |v| v.artboard).min(last)).map(|a| a.rect)
});
let shown = board.map(|b| (b, xf.quad(b))).filter(|(_, q)| egui::Rect::from_points(q).intersects(xf.rect));
match shown {
Some((b, q)) => pos2(xf.to_screen(b.center()).x, q.iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max)),
None => xf.rect.center(),
}
}
/// Whether a Type tool edits type, or has type selected: its bar then shows only the text
/// formatting controls.
fn type_tool_on_type(app: &VectorcraftApp) -> bool {
@@ -75,6 +111,8 @@ pub(crate) fn task_bar_shows_type(app: &VectorcraftApp) -> bool {
/// While a Type tool edits type, or has type selected, the bar shows only those controls and the
/// Fill. When one row would be wider than the canvas, the font controls take a row of their own,
/// narrowed to fit it, and the actions wrap onto rows under the handle's ([`bar_layout`]).
/// On a blank canvas, a document with no artwork and a selection tool in use, it sits under the
/// active artboard and offers Draw (Pencil) and Place Image.
/// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the
/// selection, pinned it stays put ([`crate::state::TaskBarPlace`]).
pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
@@ -83,13 +121,15 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
// Text added with a Type tool shows its formatting controls, as the help page describes
// (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>).
let typing = type_tool_on_type(app);
if !(on_artboards || typing || selection_tool_with_selection(app)) {
// A blank canvas offers Draw (Pencil) and Place Image, as the same help page describes.
let blank = !on_artboards && blank_canvas(app);
if !(on_artboards || typing || blank || selection_tool_with_selection(app)) {
return;
}
// Selected type has the font controls too, as the help page describes
// (<https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/font-options-overview.html>).
let fonts = task_bar_shows_type(app);
let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return };
let Some(anchor) = bar_anchor(app, xf, on_artboards, blank) else { return };
let Some(st) = app.session.active() else { return };
let n = st.selection.len();
let first = st.selection.objects.first().and_then(|id| st.doc.node(*id));
@@ -137,6 +177,10 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none()));
}
items.push((tl!("Export"), "share-2", "file.exportForScreens", none()));
} else if blank {
// Draw (Pencil) switches to the Pencil tool; Place Image picks files for the Place dialog.
items.push((tl!("Draw (Pencil)"), "pencil", "tool.select", json!({"tool": "pencil"})));
items.push((tl!("Place Image"), "image", "file.place", none()));
} else if !typing {
// The selection tools' object actions. A Type tool's bar leaves them out, Create Outlines
// and the type conversions included.
@@ -234,27 +278,20 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
let paints = chip_paints(app, ui.ctx());
let colored = has_colors(&st.doc, &st.selection.objects);
let doc = st.uid;
// Under the selection's lowest corner on screen, centered on the selection (the view may be
// rotated).
let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
let anchor = pos2(xf.to_screen(b.center()).x, lowest);
let bounds = xf.rect.shrink(8.0);
// The canvas's width less the frame's margins and stroke.
let room = bounds.width() - BAR_FRAME;
// On a canvas too narrow for the type controls at their narrowest, the bar leaves them out.
let fonts = fonts && room >= crate::panels::character::task_bar_min_room();
// The selected objects' Fill, Stroke, Recolor Artwork (when they have colours to recolour) and
// Lock (a basic shape's properties instead), left out with the Artboard tool. A Type tool's bar
// has the Fill alone.
let chips: Vec<Chip> = [
(!on_artboards, Chip::Fill),
(!on_artboards && !typing, Chip::Stroke),
(!on_artboards && !typing && colored, Chip::Recolor),
(!on_artboards && !typing, Chip::Lock),
]
.into_iter()
.filter_map(|(shown, chip)| shown.then_some(chip))
.collect();
// Lock (a basic shape's properties instead), left out with the Artboard tool and on a blank
// canvas, which have no selected objects. A Type tool's bar has the Fill alone.
let objects = !on_artboards && !blank;
let chips: Vec<Chip> =
[(objects, Chip::Fill), (objects && !typing, Chip::Stroke), (objects && !typing && colored, Chip::Recolor), (objects && !typing, Chip::Lock)]
.into_iter()
.filter_map(|(shown, chip)| shown.then_some(chip))
.collect();
let widths: Vec<f32> = items.iter().map(|(label, ..)| action_width(ui, label)).collect();
let font_w = fonts.then(|| {
let w = crate::panels::character::TASK_BAR_WIDTHS;
+2 -2
View File
@@ -10333,7 +10333,7 @@ fn task_bar_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String
/// With the Artboard tool, the Contextual Task Bar sits under the selected artboards and offers
/// Resize, Duplicate, Rearrange All (with two or more artboards) and Export instead of the
/// selected objects' actions. With the Selection tool it offers the objects' actions again, and
/// with nothing selected it doesn't show.
/// with nothing in the document the blank canvas actions.
#[test]
fn the_task_bar_offers_the_artboard_tools_actions() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -10341,7 +10341,7 @@ fn the_task_bar_offers_the_artboard_tools_actions() {
app.select_tool("selection");
let ctx = egui::Context::default();
let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::<Vec<_>>();
assert!(names(&mut app).is_empty() && task_bar_rect(&ctx).is_none(), "nothing selected: no bar");
assert_eq!(names(&mut app), ["Draw (Pencil)", "Place Image"], "a blank canvas");
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 1);
+141 -52
View File
@@ -30,7 +30,8 @@ pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls {
/// What the Control bar and the Properties panel offer for selected anchors ([`anchor_controls`]):
/// "Convert:" corner or smooth, then "Anchors:" remove, connect (Join) and cut. Each group is a
/// row of its own: inline in the Control bar, one under the other in a panel.
/// row of its own: inline in the Control bar, one under the other in a panel. In the Control bar's
/// row, a group shows only while the row has room for its label and all its buttons.
pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorControls) {
let t = Tokens::get(ui.ctx());
let mut run = None;
@@ -56,9 +57,14 @@ pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorCon
AnchorControls::Convert => 1,
AnchorControls::All => groups.len(),
};
let in_row = ui.layout().main_dir() == egui::Direction::LeftToRight;
for (label, buttons) in groups.into_iter().take(shown) {
let text = egui::RichText::new(label).size(12.0).color(t.text);
if in_row && ui.available_width() < row_width(ui, &[widgets::text_width(ui, text.clone())]) + buttons_width(ui, buttons.len()) {
break;
}
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label).size(12.0).color(t.text));
ui.label(text);
for &(icon, tip, id, to) in buttons {
if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() {
run = Some((id, to.map_or_else(|| json!({}), |to| json!({ "to": to }))));
@@ -78,6 +84,12 @@ pub(super) const ISOLATION_BUTTON: &str = "cb-isolation";
/// less, the menu is left out so those controls still fit the narrowest window.
const ARTBOARD_SNAPPING_WIDTH: f32 = 900.0;
/// The width of [`snapping_menu`]'s button: its label, as a button draws it, and the button's padding.
fn snapping_width(ui: &Ui) -> f32 {
let label = egui::WidgetText::from(tl!("Snapping")).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button);
label.size().x + 2.0 * ui.spacing().button_padding.x
}
/// The Control bar's snapping quick access; every change uses its existing command, and Pixel
/// Snapping Options… opens the dialog of `view.snapToPixel.options`.
fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -115,9 +127,10 @@ fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
/// while a symbol's art is the isolated layer. Otherwise, with one object selected that
/// [`vectorcraft_tools::select::isolates`], it is Isolate Selected Object, which also isolates a
/// path inside a group that Direct Selection selected. An envelope's contents get no button: the
/// envelope's Edit Envelope and Edit Contents buttons show instead.
/// envelope's Edit Envelope and Edit Contents buttons show instead. It is left out when the bar has
/// no room left for it.
fn isolation_button(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(st) = app.session.active() else { return };
let Some(st) = app.session.active().filter(|_| ui.available_width() >= BUTTON) else { return };
let (icon, tip, id) = if st.isolation.is_some() {
if st.doc.pattern_edit.is_some() {
return;
@@ -151,9 +164,9 @@ const GLOBAL_EDIT_OPTIONS_WIDTH: f32 = 24.0;
/// It opens the Properties panel's Global Edit Options, which set the same session options, and is
/// off and closed while Global Edit is on, as that panel's dropdown is. It sits after Recolor
/// Artwork, ahead of the X/Y/W/H fields that the bar leaves out when it runs short of room, and is
/// left out only when no room is left for it.
fn global_edit_options(app: &mut VectorcraftApp, ui: &mut Ui) {
if !crate::menus::enabled(app, "select.globalEdit") || ui.available_width() < GLOBAL_EDIT_OPTIONS_WIDTH {
/// left out when the room left, less `reserve` ([`transform_reserve`]), cannot hold it.
fn global_edit_options(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
if !crate::menus::enabled(app, "select.globalEdit") || ui.available_width() < row_width(ui, &[GLOBAL_EDIT_OPTIONS_WIDTH]) + reserve {
return;
}
let on = app.session.active().is_some_and(|d| d.global_edit.is_some());
@@ -204,8 +217,27 @@ fn buttons_width(ui: &Ui, n: usize) -> f32 {
(BUTTON + ui.spacing().item_spacing.x) * n as f32
}
/// The room controls `widths` wide take side by side in the bar's row, with the spacing after each.
fn row_width(ui: &Ui, widths: &[f32]) -> f32 {
widths.iter().map(|w| w + ui.spacing().item_spacing.x).sum()
}
/// The size of the Control bar's Fill, Stroke and Graphic Style chips.
const PAINT_CHIP: f32 = 22.0;
/// The width each of those chips takes with the chevron after it.
const CHIP_WIDTH: f32 = PAINT_CHIP + 16.0;
/// The widths of the Control bar's stroke weight and Opacity fields.
const WEIGHT_FIELD_WIDTH: f32 = 100.0;
const OPACITY_FIELD_WIDTH: f32 = 56.0;
/// The separator that ends a group of the Control bar's controls, as [`group_end_width`] measures it.
/// It is left out when the bar has no room left for it.
fn group_end(ui: &mut Ui) {
if ui.available_width() < group_end_width(ui) {
return;
}
ui.add_space(GROUP_GAP);
boundary(ui, separator);
}
@@ -256,8 +288,11 @@ fn boundary(ui: &mut Ui, draw: impl FnOnce(&mut Ui)) {
}
/// The separator that ends a group of the Control bar's controls, drawn as `ui.separator()` draws
/// one, and left out when no control follows it ([`boundary`]).
/// one, and left out when no control follows it ([`boundary`]) or the row has no room left for it.
pub(crate) fn group_separator(ui: &mut Ui) {
if ui.layout().main_dir() == egui::Direction::LeftToRight && ui.available_width() < separator_width(ui) {
return;
}
boundary(ui, |ui| {
ui.separator();
});
@@ -298,20 +333,30 @@ fn end_width(app: &VectorcraftApp, ui: &Ui, nothing_selected: bool) -> f32 {
}
}
/// The room the Control bar's Fill and Stroke, Opacity, Graphic Style and Character groups leave
/// for the Transform link: the link and the gap and separator ahead of it, while something is
/// `selected` and the panel menu has the Transform group on, or none. Those groups show each control
/// only while this room is left after it, so the bar leaves them out before the link.
fn transform_reserve(app: &VectorcraftApp, ui: &Ui, selected: bool) -> f32 {
if selected && group_on(app, "transform") { group_end_width(ui) + crate::panels::transform::link_width(ui) } else { 0.0 }
}
/// Draws the Control bar's groups between its Character controls and the Transform link, and
/// returns whether the Transform link and X/Y/W/H follow (false: nothing is selected, and Document
/// Setup and Preferences, drawn here, end the bar, or the Transform group is off). The groups are
/// the Paragraph section and area type's vertical alignment (#3759), then the Align buttons, the
/// Align To menu and, for two or more objects, the Distribute buttons (a single object keeps the bar
/// as it was); each shows only while the end of the bar keeps its room. Groups the panel menu has
/// off are left out, and take no room.
fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize) -> bool {
/// off are left out, and take no room. Without `room` (the Character group had none), all of these
/// groups are left out, so the bar trims them from its right end before the Character group.
fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize, room: bool) -> bool {
let nothing = objects == 0;
let end = end_width(app, ui, nothing);
if group_on(app, "paragraph") && crate::panels::paragraph::control_bar(app, ui, BUTTON, |ui| group_end_width(ui) + end) {
if room && group_on(app, "paragraph") && crate::panels::paragraph::control_bar(app, ui, BUTTON, |ui| group_end_width(ui) + end) {
group_end(ui);
}
if group_on(app, "verticalAlignment")
if room
&& group_on(app, "verticalAlignment")
&& crate::panels::properties::vertical_align::shown(app).is_some()
&& ui.available_width() >= buttons_width(ui, 4) + group_end_width(ui) + end
&& crate::panels::properties::vertical_align::buttons(app, ui, BUTTON)
@@ -319,11 +364,13 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize
group_end(ui);
}
if nothing {
// Each button shows only while the bar has room left for it, so Preferences can show
// when Document Setup did not fit.
if group_on(app, "documentOptions") {
if widgets::flat_button(ui, tl!("Document Setup"), DOCUMENT_SETUP_WIDTH).clicked() {
if ui.available_width() >= DOCUMENT_SETUP_WIDTH && widgets::flat_button(ui, tl!("Document Setup"), DOCUMENT_SETUP_WIDTH).clicked() {
app.run("file.documentSetup", json!({})).ok();
}
if widgets::flat_button(ui, tl!("Preferences"), PREFERENCES_WIDTH).clicked() {
if ui.available_width() >= PREFERENCES_WIDTH && widgets::flat_button(ui, tl!("Preferences"), PREFERENCES_WIDTH).clicked() {
app.run("edit.preferences", json!({})).ok();
}
}
@@ -331,7 +378,7 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize
}
let transform = group_on(app, "transform");
let end = if transform { separator_width(ui) + end } else { 0.0 };
if group_on(app, "alignDistribute") && ui.available_width() >= buttons_width(ui, 7) + end {
if room && group_on(app, "alignDistribute") && ui.available_width() >= buttons_width(ui, 7) + end {
crate::panels::align::align_buttons(app, ui, BUTTON);
if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x + end {
crate::panels::align::align_to_menu(app, ui, BUTTON);
@@ -545,55 +592,80 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
/// The Control bar's Fill and Stroke group: the fill and stroke chips, the Stroke link, the weight
/// and the width profile, and the separator after them while a control follows ([`group_separator`]).
fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui) {
/// Short of room, the bar leaves out the width profile first, then the Stroke link with the weight,
/// then the two chips; each shows only while the bar has room for all of it and `reserve` after it
/// ([`transform_reserve`]).
fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
if ui.available_width() < row_width(ui, &[CHIP_WIDTH, CHIP_WIDTH]) + reserve {
return;
}
let shown_stroke = crate::panels::current_stroke(app);
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed);
crate::panels::paint_chip(app, ui, false, 22.0, true);
crate::panels::paint_chip(app, ui, true, 22.0, true);
crate::panels::paint_chip(app, ui, false, PAINT_CHIP, true);
crate::panels::paint_chip(app, ui, true, PAINT_CHIP, true);
// The link opens the Stroke panel as a popover under it; then the weight spinner
// (with presets) and the width profile.
stroke_panel::link(app, ui, tl!("Stroke:"));
stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0);
if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) {
app.run("stroke.set", json!({"profile": id})).ok();
let link = widgets::text_width(ui, stroke_panel::link_text(ui, tl!("Stroke:")));
if ui.available_width() >= row_width(ui, &[link, WEIGHT_FIELD_WIDTH]) + reserve {
stroke_panel::link(app, ui, tl!("Stroke:"));
stroke_panel::weight_field(app, ui, "cb-stroke", weight, WEIGHT_FIELD_WIDTH);
if ui.available_width() >= row_width(ui, &[stroke_panel::PROFILE_DROPDOWN_WIDTH]) + reserve
&& let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref()))
{
app.run("stroke.set", json!({"profile": id})).ok();
}
}
ui.add_space(4.0);
group_separator(ui);
group_end(ui);
}
/// The Control bar's Opacity group (its link and field, showing `opacity`) and, with something
/// `selected`, its Graphic Style group (Style, Recolor Artwork and Global Edit Options), each while
/// the panel menu has it on, and the separator after them while a control follows.
fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: f32, selected: bool) {
/// the panel menu has it on, and the separator after them while a control follows. Short of room,
/// the bar leaves out Global Edit Options first, then Recolor Artwork, then the Style link with its
/// chip. The Opacity link with its field and the Style link with its chip each show only while the
/// bar has room for all of it and `reserve` after it ([`transform_reserve`]), so the narrower Style
/// part can show without the Opacity part.
fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: f32, selected: bool, reserve: f32) {
let (opacity_on, style_on) = (group_on(app, "opacity"), group_on(app, "graphicStyle") && selected);
let mut drawn = false;
if opacity_on {
// A click opens the Transparency panel; a drag scrubs the field (#4088).
if crate::scrub::link_label(ui, egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("transparency".into());
}
if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0)
&& selected
{
app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
let text = egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline();
if ui.available_width() >= row_width(ui, &[widgets::text_width(ui, text.clone()), OPACITY_FIELD_WIDTH]) + reserve {
// A click opens the Transparency panel; a drag scrubs the field (#4088).
if crate::scrub::link_label(ui, text).clicked() {
app.ui.open_panel = Some("transparency".into());
}
if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, OPACITY_FIELD_WIDTH)
&& selected
{
app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
}
drawn = true;
}
}
if style_on {
let text = egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline();
if style_on && ui.available_width() >= GROUP_GAP + row_width(ui, &[widgets::text_width(ui, text.clone()), CHIP_WIDTH]) + reserve {
// Style picker: the selection's graphic style; its menu applies another.
ui.add_space(4.0);
if ui.link(egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline()).clicked() {
ui.add_space(GROUP_GAP);
if ui.link(text).clicked() {
app.ui.open_panel = Some("graphicStyles".into());
}
let resp = widgets::chip_button_named(ui, 22.0, true, tl!("Graphic Style"), |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r));
let resp =
widgets::chip_button_named(ui, PAINT_CHIP, true, tl!("Graphic Style"), |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r));
egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui));
// Recolor Artwork, when the selection has colours to recolour.
let colored = app.session.active().is_some_and(|st| crate::canvas::has_colors(&st.doc, &st.selection.objects));
if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 24.0).clicked() {
if colored
&& ui.available_width() >= buttons_width(ui, 1) + reserve
&& widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, BUTTON).clicked()
{
crate::menus::invoke(app, "ui.recolorDialog", json!({}));
}
global_edit_options(app, ui);
global_edit_options(app, ui, reserve);
drawn = true;
}
if opacity_on || style_on {
if drawn {
group_separator(ui);
}
}
@@ -709,11 +781,16 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
}
gripper(app, ui, t, bar, dock_room);
if app.session.active().is_none() {
ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim));
let text = egui::RichText::new(tl!("No Document")).color(t.text_dim);
if ui.available_width() >= widgets::text_width(ui, text.clone()) {
ui.label(text);
}
return;
}
// The Artboard tool's controls fill the narrowest window, so they keep it to themselves there.
if group_on(app, "snapping") && (app.session.tool_id() != "artboard" || ui.available_width() >= ARTBOARD_SNAPPING_WIDTH) {
// Any other tool's bar shows the menu while it has room for it.
let snapping_room = if app.session.tool_id() == "artboard" { ARTBOARD_SNAPPING_WIDTH } else { snapping_width(ui) };
if group_on(app, "snapping") && ui.available_width() >= snapping_room {
snapping_menu(app, ui);
}
let Some(st) = app.session.active() else { return };
@@ -739,7 +816,10 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
_ => tl!(crate::panels::appearance::object_label(app)),
};
let has_first = first.is_some();
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
let label = egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text);
if ui.available_width() >= widgets::text_width(ui, label.clone()) {
ui.label(label);
}
// With the Artboard tool in use, the bar shows the active artboard's controls in place
// of the object controls.
let (artboard_options, transform) = (group_on(app, "artboardOptions"), group_on(app, "transform"));
@@ -755,7 +835,9 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
if object_options {
isolation_button(app, ui);
}
ui.add_space(6.0);
if ui.available_width() >= 6.0 {
ui.add_space(6.0);
}
// An image or an Image Trace object shows its own controls in place of Fill and Stroke,
// and leaves Fill and Stroke out while the Image Options group is off too.
let image = if group_on(app, "imageOptions") {
@@ -771,19 +853,26 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
crate::panels::symbols::control_bar(app, ui);
}
let opacity = if has_first { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 };
let reserve = transform_reserve(app, ui, !sel.is_empty());
if !image && group_on(app, "fillStroke") {
fill_and_stroke(app, ui);
fill_and_stroke(app, ui, reserve);
}
opacity_and_style(app, ui, t, opacity, !sel.is_empty());
if group_on(app, "character") {
crate::panels::character::control_bar(app, ui);
}
if !groups_before_transform(app, ui, sel.len()) {
opacity_and_style(app, ui, t, opacity, !sel.is_empty(), reserve);
// The Character group shows whole, with the Transform link's room left after it, or not at
// all; left out, it leaves out the groups between it and the link too.
let room = !group_on(app, "character") || crate::panels::character::control_bar(app, ui, reserve);
if !groups_before_transform(app, ui, sel.len(), room) {
erase_trailing(ui);
return;
}
// The Transform link opens the whole Transform panel (reference point, rotate,
// shear, options) in a popover; X/Y/W/H follow inline while the bar has room.
// shear, options) in a popover; X/Y/W/H follow inline while the bar has room. The
// groups from Fill and Stroke on keep its room, but the controls ahead of them can
// still take it.
if ui.available_width() < crate::panels::transform::link_width(ui) {
erase_trailing(ui);
return;
}
crate::panels::transform::link(app, ui);
// The bounding box, rotated with rotated objects: its centre and its own sides.
if let Some(b) = app.selection_box()
+121
View File
@@ -3037,6 +3037,127 @@ fn no_separator_is_left_at_the_end_of_the_control_bar() {
assert!(stray.is_empty(), "a separator with nothing after it: {stray:#?}");
}
/// The shapes of the Control bar's controls drawn in window `screen` that reach past the left edge
/// of its panel menu button, or past the room a floating bar clips its controls to, or `None` when
/// the bar leaves the button out.
fn control_bar_overflow(app: &mut crate::VectorcraftApp, ctx: &egui::Context, screen: egui::Rect) -> Option<Vec<egui::Rect>> {
control_bar_frame_in(app, ctx, screen, vec![]);
let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui));
let nodes: Vec<_> = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default();
let menu_left = nodes.iter().find(|n| n.label().is_some_and(|l| l == "Panel menu")).and_then(|n| n.bounds())?.x0 as f32;
// The bar's frame spans the whole bar, the panel menu button included.
let bar_width = if app.ui.control_bar_floating { screen.width().min(800.0) } else { screen.width() };
let shapes = out.shapes.iter().filter_map(|cs| {
let r = cs.shape.visual_bounding_rect();
let control = r.is_finite() && cs.clip_rect.intersects(r) && r.width() < bar_width - 4.0 && r.min.x < menu_left - 0.5;
let clipped = cs.clip_rect.right() < menu_left && r.right() > cs.clip_rect.right() + 0.5;
(control && (r.right() > menu_left + 0.5 || clipped)).then_some(r)
});
Some(shapes.collect())
}
/// In a narrow window, the Control bar leaves out a control it has no room for, Fill and Stroke,
/// Opacity and Character included: every control it shows is drawn whole, left of the panel menu button, which
/// shows in every width, with the bar docked to the top or the bottom or floating.
#[test]
fn the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window() {
use crate::tests_removeanchors::{control_frame, has};
crate::i18n::set_current(crate::i18n::Lang::EN);
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
let mut faults = vec![];
let mut check = |app: &mut crate::VectorcraftApp, case: &str| {
for (bottom, floating) in [(false, false), (true, false), (false, true)] {
(app.ui.control_bar_bottom, app.ui.control_bar_floating) = (bottom, floating);
for w in (150..=1500).step_by(9) {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0));
match control_bar_overflow(app, &ctx, screen) {
None => faults.push(format!("{case}, bottom {bottom}, floating {floating}, {w} wide: no panel menu")),
Some(over) if !over.is_empty() => faults.push(format!("{case}, bottom {bottom}, floating {floating}, {w} wide: {over:?}")),
Some(_) => continue,
}
break;
}
}
(app.ui.control_bar_bottom, app.ui.control_bar_floating) = (false, false);
};
app.run("select.set", json!({ "ids": [id] })).unwrap();
let bar = control_frame(&mut app, &ctx, vec![]);
assert!(has(&bar, "Stroke:") && has(&bar, "Opacity:"), "Fill and Stroke and Opacity show in a wide window: {bar:?}");
check(&mut app, "a rectangle selected");
app.select_tool("directSelection");
app.run("select.anchors", json!({"id": id, "anchors": [[0, 0]]})).unwrap();
check(&mut app, "an anchor selected");
app.select_tool("selection");
let text = app.run("text.create", json!({"x": 20, "y": 120, "text": "Point"})).unwrap()["id"].clone();
app.run("select.set", json!({ "ids": [text] })).unwrap();
let bar = control_frame(&mut app, &ctx, vec![]);
assert!(has(&bar, "Character:"), "the Character group shows in a wide window: {bar:?}");
check(&mut app, "type selected");
app.run("select.set", json!({ "ids": [] })).unwrap();
check(&mut app, "nothing selected");
assert!(faults.is_empty(), "a control drawn past the bar's room: {faults:#?}");
}
/// With Opacity and Graphic Style on, a bar too narrow for the Opacity link and its field still
/// shows the narrower Style link and its chip when it has room for them.
#[test]
fn a_narrow_control_bar_shows_the_style_link_without_the_opacity_field() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
app.run("select.set", json!({ "ids": [id] })).unwrap();
app.run("window.control.options", json!({"group": "fillStroke", "visible": false})).unwrap();
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
let shows = |nodes: &[egui::accesskit::Node], name: &str| nodes.iter().any(|n| n.label().is_some_and(|l| l == name));
let (mut both, mut style_only) = (false, false);
for w in (150..=900).step_by(2) {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0));
control_bar_frame_in(&mut app, &ctx, screen, vec![]);
let nodes = control_bar_frame_in(&mut app, &ctx, screen, vec![]);
let (opacity, style) = (shows(&nodes, "Opacity:"), shows(&nodes, "Style:"));
both |= opacity && style;
style_only |= style && !opacity;
}
assert!(both, "a wide bar shows the Opacity and Style links");
assert!(style_only, "some width has room for the Style link and its chip but not the Opacity link and its field");
}
/// With nothing selected, a bar too narrow for the Document Setup button still shows the narrower
/// Preferences button when it has room for it.
#[test]
fn a_narrow_control_bar_shows_preferences_without_document_setup() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
app.run("select.set", json!({ "ids": [] })).unwrap();
for group in ["fillStroke", "opacity", "graphicStyle"] {
app.run("window.control.options", json!({"group": group, "visible": false})).unwrap();
}
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
let shows = |nodes: &[egui::accesskit::Node], name: &str| nodes.iter().any(|n| n.label().is_some_and(|l| l == name));
let (mut both, mut preferences_only) = (false, false);
for w in (150..=900).step_by(2) {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0));
control_bar_frame_in(&mut app, &ctx, screen, vec![]);
let nodes = control_bar_frame_in(&mut app, &ctx, screen, vec![]);
let (setup, preferences) = (shows(&nodes, "Document Setup"), shows(&nodes, "Preferences"));
both |= setup && preferences;
preferences_only |= preferences && !setup;
}
assert!(both, "a wide bar shows Document Setup and Preferences");
assert!(preferences_only, "some width has room for Preferences but not Document Setup");
}
/// The Anchor Points group holds Convert and Anchors for direct-selected anchors; off, the bar keeps
/// its Anchor Point label.
#[test]
+13 -6
View File
@@ -1,8 +1,10 @@
//! A selection dragged across document tabs remains editable and leaves its source intact.
use std::sync::Arc;
use egui::{Context, Id, Response};
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_geom::Point;
use crate::{VectorcraftApp, canvas, widgets};
@@ -19,7 +21,11 @@ struct Origin {
struct Artwork {
origin: Origin,
ids: Vec<NodeId>,
revision: u64,
/// The source document when the drag left it. The drag ends when the source's content differs
/// from it ([`vectorcraft_engine::DocState::same_content`]). A redraw that raises its revision
/// (another document's assigned color profiles brought to the front) and a change that is not
/// an edit (its type measured again) keep it.
source: Arc<Document>,
target: u64,
}
@@ -75,7 +81,7 @@ pub(crate) fn cleanup(app: &VectorcraftApp, ctx: &Context) {
}
});
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)
let valid = app.session.documents().iter().any(|d| d.uid == art.origin.uid && d.same_content(&art.source))
&& app.session.active().is_some_and(|d| d.uid == art.target);
let cancel = ctx.input(|i| {
!i.focused
@@ -143,13 +149,14 @@ pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, win: crate::d
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();
let Some((ids, source)) = app.session.active().map(|d| (d.selection.objects.clone(), d.doc.clone())) else {
return true;
};
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 });
egui::DragAndDrop::set_payload(ctx, Artwork { origin, ids, source, target: uid });
}
if transfer && let Some(art) = egui::DragAndDrop::payload::<Artwork>(ctx) {
let mut art = (*art).clone();
+27 -5
View File
@@ -97,7 +97,7 @@ fn plural_polish(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 28] = [
pub static LANGUAGES: [LangInfo; 29] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
// Hindi shares the integer zero/one rule with French. Desktop UI fonts are loaded lazily
// from installed faces by ui_fonts; the web needs a Devanagari font in its font set.
@@ -109,6 +109,18 @@ pub static LANGUAGES: [LangInfo; 28] = [
complete_menus: true,
catalog: OnceLock::new(),
},
// Gujarati: menus and dialogs, with partial status/error message coverage and loanwords
// transliterated into Gujarati script; zero/one singular plurals. Gujarati text is drawn with an installed system font
// (Gujarati Sangam MN, Nirmala UI, Shruti) or a craft-fonts face tagged `Gujr`. Web selection
// requires a bundled Gujarati interface face; otherwise `gu-*` locales use the fallback.
LangInfo {
code: "gu",
name: "ગુજરાતી",
source: include_str!("i18n/gu.tsv"),
plural: plural_french,
complete_menus: true,
catalog: OnceLock::new(),
},
// Greek: the whole interface and the status and error messages, keeping the same names in
// English as Spanish; every `el-*` locale (including `el-GR` and `el-CY`) resolves here.
LangInfo {
@@ -355,6 +367,16 @@ pub static LANGUAGES: [LangInfo; 28] = [
];
impl LangInfo {
fn available(&self) -> bool {
self.available_with_fonts(cfg!(target_arch = "wasm32"), vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Gujr")))
}
fn available_with_fonts(&self, web: bool, gujarati_font: bool) -> bool {
// Browsers cannot use the native installed-font fallback. Do not offer a language
// whose text would be missing-glyph boxes, including when restoring a preference.
self.code != "gu" || !web || gujarati_font
}
fn catalog(&self) -> &Catalog {
self.catalog.get_or_init(|| Catalog::parse(self.source))
}
@@ -387,7 +409,7 @@ impl Lang {
/// A language by its exact code.
pub fn from_code(code: &str) -> Option<Lang> {
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code) && l.available()).map(Lang)
}
/// Resolve the `interfaceLanguage` preference: a language code, or `auto` (and anything
@@ -396,12 +418,12 @@ impl Lang {
Lang::from_code(pref).unwrap_or_else(system_lang)
}
/// Every registered language.
/// Every language available in this build.
pub fn all() -> impl Iterator<Item = Lang> {
LANGUAGES.iter().map(Lang)
LANGUAGES.iter().filter(|l| l.available()).map(Lang)
}
/// Every registered language in the order a picker lists them: English first (the source
/// Every available language in the order a picker lists them: English first (the source
/// language and the fallback), then the rest by their own name with accents folded, so
/// Čeština sits under C rather than after Z. Names in other scripts follow the Latin ones in
/// code point order: Greek, Cyrillic, Arabic, then the CJK languages.
+2
View File
@@ -691,6 +691,8 @@ font-technology Variable Променливи
Crop Image Изрязване на изображение
Image Tools Инструменти за изображения
Remove Background… Премахване на фона…
Draw (Pencil) Рисуване (Молив)
Place Image Вмъкване на изображение
Remove Background Премахване на фона
Background Фон
Edge Color Цвят на ръба
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -201,6 +201,8 @@
Crop Image Izreži sliku
Image Tools Alati za slike
Remove Background… Ukloni pozadinu…
Draw (Pencil) Crtaj (olovka)
Place Image Postavi sliku
Remove Background Ukloni pozadinu
Background Pozadina
Edge Color Boja ruba
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -1006,6 +1006,8 @@ antiAlias Sharp Nítid
Crop Image Retalla la imatge
Image Tools Eines d’imatge
Remove Background… Elimina el fons…
Draw (Pencil) Dibuixa (llapis)
Place Image Col·loca una imatge
Remove Background Elimina el fons
Background Fons
Edge Color Color de la vora
@@ -5293,6 +5295,7 @@ digits Malayalam Malaiàlam
@msg `{key}` must be a #rrggbb colour `{key}` ha de ser un color #rrggbb
@msg `{key}` must be a number `{key}` ha de ser un nombre
@msg `{key}` must be a number not below 0 `{key}` ha de ser un nombre no inferior a 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` han de ser quatre nombres no inferiors a 0: [tl, tr, br, bl]
@msg `{key}` must be a number, not {v} `{key}` ha de ser un nombre, no {v}
@msg `{key}` must be a string `{key}` ha de ser una cadena
@msg `{key}` must be a profile name or null `{key}` ha de ser un nom de perfil o null
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7 -4
View File
@@ -207,6 +207,8 @@
Crop Image Oříznout obraz
Image Tools Nástroje obrazu
Remove Background… Odstranit pozadí…
Draw (Pencil) Kreslit (tužka)
Place Image Umístit obraz
Remove Background Odstranit pozadí
Background Pozadí
Edge Color Barva okraje
@@ -4800,6 +4802,7 @@ imageTrace Outline Obrys
@msg `select` must be true or false `select` musí být true nebo false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` a `maxAnchors` musí být celá čísla
@msg `{key}` must be a number not below 0 `{key}` musí být číslo nejméně 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` musí být čtyři čísla nejméně 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` musí být [x, y, width, height]: čtyři konečná čísla, šířka a výška nesmí být záporné
@msg `{min_key}` is greater than `{max_key}` `{min_key}` je větší než `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` zadejte alespoň jeden z parametrů `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -7578,7 +7581,7 @@ breakText Glyphs Glyfy
@msg `alignOn` must be a string `alignOn` musí být řetězec
@msg `align` is start, center or end `align` je start, center nebo end
@msg `align` must be a string `align` musí být řetězec
@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` musí být pole dvojic [dílčí cesta, kotevní bod]
@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` musí být pole dvojic [subpath, anchor]
@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` musí uvádět indexy kotevních bodů (0, 1, 2…), ne {k}
@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` musí uvádět indexy kotevních bodů (0, 1, 2…), ne {v}
@msg `answer` for this {kind} dialog must be one of: {_1} `answer` pro tento dialog {kind} musí být jedna z hodnot: {_1}
@@ -7643,7 +7646,7 @@ breakText Glyphs Glyfy
@msg `fidelity` must be from {lo} to {hi} pt `fidelity` musí být od {lo} do {hi} pt
@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` musí být 1 až 1000 cest k souborům písem, které našlo poslední hledání text.findFontFiles
@msg `focal` and `radius` cannot be used together `focal` a `radius` nelze použít společně
@msg `focal` must be an array of [subpath, anchor] pairs `focal` musí být pole dvojic [dílčí cesta, kotevní bod]
@msg `focal` must be an array of [subpath, anchor] pairs `focal` musí být pole dvojic [subpath, anchor]
@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` uvádí [{si}, {ai}], což není kotevní bod cesty
@msg `folder` must be an absolute folder path `folder` musí být absolutní cesta ke složce
@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` musí být objekt se značkami os a hodnotami, například {"wght"}
@@ -8308,7 +8311,7 @@ breakText Glyphs Glyfy
@msg instruction {pc} reached with stack depths {seen} and {d} instrukce {pc} byla dosažena s hloubkami zásobníku {seen} a {d}
@msg invalid `color` neplatné `color`
@msg invalid anchor index {v}: expected a non-negative integer neplatný index kotevního bodu {v}: očekáváno nezáporné celé číslo
@msg invalid anchor reference {v}: expected [subpath, anchor] neplatný odkaz na kotevní bod {v}: očekáváno [dílčí cesta, kotevní bod]
@msg invalid anchor reference {v}: expected [subpath, anchor] neplatný odkaz na kotevní bod {v}: očekáváno [subpath, anchor]
@msg invalid angle neplatný úhel
@msg invalid array length neplatná délka pole
@msg invalid brush: {e} neplatný štětec: {e}
@@ -8417,7 +8420,7 @@ breakText Glyphs Glyfy
@msg missing `{k}` chybí `{k}`
@msg missing anchor chybí kotevní bod
@msg missing anchors chybí kotevní body
@msg missing anchors [[subpath, anchor]…] chybí kotevní body [[dílčí cesta, kotevní bod]…]
@msg missing anchors [[subpath, anchor]…] chybí kotevní body [[subpath, anchor]…]
@msg missing array `{key}` chybí pole `{key}`
@msg missing case chybí case
@msg missing color chybí barva
Can't render this file because it is too large.
+2
View File
@@ -194,6 +194,8 @@
Crop Image Beskær billede
Image Tools Billedværktøjer
Remove Background… Fjern baggrund…
Draw (Pencil) Tegn (blyant)
Place Image Placér billede
Remove Background Fjern baggrund
Background Baggrund
Edge Color Kantfarve
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -684,6 +684,8 @@ font-technology Variable Variabel
Crop Image Bild beschneiden
Image Tools Bildwerkzeuge
Remove Background… Hintergrund entfernen…
Draw (Pencil) Zeichnen (Buntstift)
Place Image Bild platzieren
Remove Background Hintergrund entfernen
Background Hintergrund
Edge Color Randfarbe
@@ -4413,6 +4415,7 @@ theme Appearance Erscheinungsbild
@msg `select` must be true or false `select` muss true oder false sein
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` und `maxAnchors` müssen ganze Zahlen sein
@msg `{key}` must be a number not below 0 `{key}` muss eine Zahl nicht kleiner als 0 sein
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` muss aus vier Zahlen nicht kleiner als 0 bestehen: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` muss [x, y, width, height] sein: vier endliche Zahlen, Breite und Höhe nicht negativ
@msg `{min_key}` is greater than `{max_key}` `{min_key}` ist größer als `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` geben Sie mindestens einen der Parameter `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` an
Can't render this file because it is too large.
+3
View File
@@ -665,6 +665,8 @@ font-technology Variable Μεταβλητές
Crop Image Περικοπή εικόνας
Image Tools Εργαλεία εικόνας
Remove Background… Αφαίρεση φόντου…
Draw (Pencil) Σχεδίαση (μολύβι)
Place Image Τοποθέτηση εικόνας
Remove Background Αφαίρεση φόντου
Background Φόντο
Edge Color Χρώμα άκρης
@@ -2559,6 +2561,7 @@ axis Both Και τα δύο
@msg `select` must be true or false Το `select` πρέπει να είναι true ή false
@msg `minAnchors` and `maxAnchors` must be whole numbers Τα `minAnchors` και `maxAnchors` πρέπει να είναι ακέραιοι αριθμοί
@msg `{key}` must be a number not below 0 Το `{key}` πρέπει να είναι αριθμός όχι μικρότερος από 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] Το `{key}` πρέπει να είναι τέσσερις αριθμοί όχι μικρότεροι από 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative Το `{key}` πρέπει να είναι [x, y, width, height]: τέσσερις πεπερασμένοι αριθμοί, με πλάτος και ύψος όχι αρνητικά
@msg `{min_key}` is greater than `{max_key}` Το `{min_key}` είναι μεγαλύτερο από το `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` δώστε τουλάχιστον ένα από τα `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
Can't render this file because it is too large.
+3
View File
@@ -705,6 +705,8 @@ font-technology Variable Variables
Crop Image Recortar imagen
Image Tools Herramientas de imagen
Remove Background… Quitar fondo…
Draw (Pencil) Dibujar (Lápiz)
Place Image Colocar imagen
Remove Background Quitar fondo
Background Fondo
Edge Color Color del borde
@@ -4490,6 +4492,7 @@ axis Both Ambos
@msg `select` must be true or false `select` debe ser true o false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` y `maxAnchors` deben ser números enteros
@msg `{key}` must be a number not below 0 `{key}` debe ser un número no inferior a 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` debe ser cuatro números no inferiores a 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` debe ser [x, y, width, height]: cuatro números finitos, con anchura y altura no negativas
@msg `{min_key}` is greater than `{max_key}` `{min_key}` es mayor que `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` indica al menos uno de `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -214,6 +214,8 @@
Crop Image Rajaa kuva
Image Tools Kuvatyökalut
Remove Background… Poista tausta…
Draw (Pencil) Piirrä (lyijykynä)
Place Image Sijoita kuva
Remove Background Poista tausta
Background Tausta
Edge Color Reunan väri
@@ -4714,6 +4716,7 @@ imageTrace Outline Ääriviiva
@msg `select` must be true or false `select` on oltava true tai false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` ja `maxAnchors` on oltava kokonaislukuja
@msg `{key}` must be a number not below 0 `{key}` on oltava luku, joka on vähintään 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` on oltava neljä lukua, jotka ovat vähintään 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` on oltava [x, y, width, height]: neljä äärellistä lukua, joista leveys ja korkeus eivät ole negatiivisia
@msg `{min_key}` is greater than `{max_key}` `{min_key}` on suurempi kuin `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` anna vähintään yksi parametreista `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
Can't render this file because it contains an unexpected character in line 2 and column 73.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1140,6 +1140,8 @@ antiAlias Sharp शार्प
Crop Image इमेज क्रॉप करें
Image Tools इमेज टूल
Remove Background… पृष्ठभूमि हटाएँ…
Draw (Pencil) ड्रा (पेंसिल)
Place Image इमेज प्लेस करें
Remove Background पृष्ठभूमि हटाएँ
Background पृष्ठभूमि
Edge Color किनारे का रंग
@@ -5943,6 +5945,7 @@ antiAlias Sharp शार्प
@msg `{key}` must be a number `{key}` एक संख्या होना चाहिए
@msg `{key}` must be a number {_1}..{_2} `{key}` {_1}..{_2} की एक संख्या होना चाहिए
@msg `{key}` must be a number not below 0 `{key}` 0 से कम नहीं होने वाली संख्या होना चाहिए
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` 0 से कम नहीं होने वाली चार संख्याएँ होनी चाहिए: [tl, tr, br, bl]
@msg `{key}` must be a number, not {v} `{key}` एक संख्या होना चाहिए, {v} नहीं
@msg `{key}` must be a profile name or null `{key}` को प्रोफ़ाइल नाम या null होना चाहिए
@msg `{key}` must be a string `{key}` एक स्ट्रिंग होना चाहिए
Can't render this file because it is too large.
+3
View File
@@ -906,6 +906,8 @@ font-technology Variable Változó
Crop Image Kép körülvágása
Image Tools Képeszközök
Remove Background… Háttér eltávolítása…
Draw (Pencil) Rajzolás (Ceruza)
Place Image Kép elhelyezése
Remove Background Háttér eltávolítása
Background Háttér
Edge Color Szélszín
@@ -4915,6 +4917,7 @@ imageTrace Outline Körvonal
@msg `select` must be true or false `select`: true vagy false kell
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` és `maxAnchors`: egész szám kell
@msg `{key}` must be a number not below 0 `{key}`: legalább 0 értékű szám kell
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}`: négy legalább 0 értékű szám kell: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}`: [x, y, width, height] kell, négy véges szám, a szélesség és a magasság nem negatív
@msg `{min_key}` is greater than `{max_key}` `{min_key}` nagyobb, mint `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` adj meg legalább egyet ezek közül: `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -194,6 +194,8 @@
Crop Image Pangkas gambar
Image Tools Alat Gambar
Remove Background… Hapus latar belakang…
Draw (Pencil) Menggambar (pensil)
Place Image Tempatkan gambar
Remove Background Hapus latar belakang
Background Latar belakang
Edge Color Warna tepi
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
194
195
196
197
198
199
200
201
+3
View File
@@ -705,6 +705,8 @@ font-technology Variable Variabili
Crop Image Ritaglia immagine
Image Tools Strumenti immagine
Remove Background… Rimuovi sfondo…
Draw (Pencil) Disegna (Matita)
Place Image Inserisci immagine
Remove Background Rimuovi sfondo
Background Sfondo
Edge Color Colore del bordo
@@ -4542,6 +4544,7 @@ axis Both Entrambi
@msg `select` must be true or false `select` deve essere true o false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` e `maxAnchors` devono essere numeri interi
@msg `{key}` must be a number not below 0 `{key}` deve essere un numero non inferiore a 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` deve essere quattro numeri non inferiori a 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` deve essere [x, y, width, height]: quattro numeri finiti, con larghezza e altezza non negative
@msg `{min_key}` is greater than `{max_key}` `{min_key}` è maggiore di `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` indica almeno uno tra `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
Can't render this file because it is too large.
+3
View File
@@ -243,6 +243,8 @@ digits Malayalam マラヤーラム数字
Crop Image 画像の切り抜き
Image Tools 画像ツール
Remove Background… 背景を削除…
Draw (Pencil) 描画 (鉛筆)
Place Image 画像を配置
Remove Background 背景を削除
Background 背景
Edge Color 端の色
@@ -5012,6 +5014,7 @@ swatch Bright Violet ビビッドバイオレット
@msg `select` must be true or false `select` には true または false を指定してください
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` と `maxAnchors` は整数にしてください
@msg `{key}` must be a number not below 0 `{key}` には 0 以上の数値を指定してください
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` には 0 以上の数値を 4 つ指定してください: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` は [x, y, width, height] の形式で指定してください: 有限の数値 4 つで、幅と高さは負の値にできません
@msg `{min_key}` is greater than `{max_key}` `{min_key}` が `{max_key}` より大きくなっています
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` 次のうち少なくとも 1 つを指定してください: `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -166,6 +166,8 @@
Crop Image გამოსახულების ჩამოჭრა
Image Tools გამოსახულების ხელსაწყოები
Remove Background… ფონის მოშორება…
Draw (Pencil) ხატვა (ფანქარი)
Place Image გამოსახულების განთავსება
Remove Background ფონის მოშორება
Background ფონი
Edge Color კიდის ფერი
@@ -4187,6 +4189,7 @@ font-technology Variable ცვლადი
@msg `select` must be true or false `select` უნდა იყოს true ან false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` და `maxAnchors` უნდა იყოს მთელი რიცხვები
@msg `{key}` must be a number not below 0 `{key}` უნდა იყოს რიცხვი, არანაკლებ 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` უნდა იყოს ოთხი რიცხვი, თითოეული არანაკლებ 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` უნდა იყოს [x, y, width, height]: ოთხი სასრული რიცხვი, სიგანე და სიმაღლე არ უნდა იყოს უარყოფითი
@msg `{min_key}` is greater than `{max_key}` `{min_key}` მეტია, ვიდრე `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` მიუთითეთ ერთი მაინც: `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
Can't render this file because it is too large.
File diff suppressed because it is too large Load Diff

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