Merge main into fix/hu-missing-message-rows

This commit is contained in:
dotCooCoo
2026-11-14 01:54:03 -08:00
79 changed files with 7693 additions and 560 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",
+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.
+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)
+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 {
+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"]);
File diff suppressed because one or more lines are too long
+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"));
}
+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
@@ -260,6 +260,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)> {
+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
+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
}
+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();
+1
View File
@@ -5292,6 +5292,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.
+1
View File
@@ -4799,6 +4799,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`
Can't render this file because it is too large.
+1
View File
@@ -4412,6 +4412,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.
+1
View File
@@ -2559,6 +2559,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.
+1
View File
@@ -4489,6 +4489,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.
+1
View File
@@ -4713,6 +4713,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
+1
View File
@@ -5942,6 +5942,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.
+1
View File
@@ -4914,6 +4914,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.
+1
View File
@@ -4541,6 +4541,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.
+1
View File
@@ -5011,6 +5011,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.
+1
View File
@@ -4186,6 +4186,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
+1
View File
@@ -157,6 +157,7 @@
@msg `select` must be true or false `select` moet true of false zijn
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` en `maxAnchors` moeten gehele getallen zijn
@msg `{key}` must be a number not below 0 `{key}` moet een getal van minstens 0 zijn
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` moet uit vier getallen van minstens 0 bestaan: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` moet [x, y, width, height] zijn: vier eindige getallen, width en height niet negatief
@msg `{min_key}` is greater than `{max_key}` `{min_key}` is groter dan `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` geef minstens een van `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.
+1
View File
@@ -4623,6 +4623,7 @@ imageTrace Outline Kontur
@msg `select` must be true or false `select` musi mieć wartość true lub false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` i `maxAnchors` muszą być liczbami całkowitymi
@msg `{key}` must be a number not below 0 `{key}` musi być liczbą nie mniejszą niż 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` musi być czterema liczbami nie mniejszymi niż 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` musi mieć postać [x, y, width, height]: cztery skończone liczby, szerokość i wysokość nieujemne
@msg `{min_key}` is greater than `{max_key}` `{min_key}` jest większe niż `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` podaj co najmniej jeden z parametrów `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.
+1
View File
@@ -3953,6 +3953,7 @@ axis Both Ambos
@msg `select` must be true or false `select` deve ser true ou false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` e `maxAnchors` devem ser números inteiros
@msg `{key}` must be a number not below 0 `{key}` deve ser um número não inferior a 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` deve ser quatro números não inferiores a 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` deve ser [x, y, width, height]: quatro números finitos, com largura e altura não negativas
@msg `{min_key}` is greater than `{max_key}` `{min_key}` é maior que `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` informe pelo menos um de `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.
+117
View File
@@ -4526,6 +4526,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`
@@ -7584,3 +7585,119 @@ shape Open Открытый
Grommets Люверсы
Grommet Inset Отступ люверсов от края
Diameter Диаметр
# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import.
@msg 3D effects require finite coordinates within 100 million points 3D-эффектам нужны конечные координаты в пределах 100 миллионов пунктов
@msg 3D effects require one to ten lights 3D-эффектам нужно от одного до десяти источников света
@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Глубина цвета BMP {_1}: допустимо 1, 4, 8, 16, 24 или 32 бита на пиксель
@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Ошибка кодирования BMP: {width} × {height} пикселей не соответствует формату или буферу пикселей
@msg GIF encoding failed: the palette image is malformed Ошибка кодирования GIF: изображение с палитрой имеет неверную структуру
@msg GIF encoding failed: {e} Ошибка кодирования GIF: {e}
@msg JPEG colour profile: {e} Цветовой профиль JPEG: {e}
@msg JPEG encoding failed: pixel buffer doesn't match the image size Ошибка кодирования JPEG: буфер пикселей не соответствует размеру изображения
@msg JPEG encoding failed: {e} Ошибка кодирования JPEG: {e}
@msg PNG encoding failed: pixel buffer doesn't match the image size Ошибка кодирования PNG: буфер пикселей не соответствует размеру изображения
@msg PNG encoding failed: the palette image is malformed Ошибка кодирования PNG: изображение с палитрой имеет неверную структуру
@msg PNG encoding failed: {e} Ошибка кодирования PNG: {e}
@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Ошибка кодирования PNG: {width} × {height} пикселей — недопустимый размер изображения
@msg PSD colour profile: {e} Цветовой профиль PSD: {e}
@msg PSD encoding failed: the pixel buffer doesn't match the image size Ошибка кодирования PSD: буфер пикселей не соответствует размеру изображения
@msg Revolve profile contains invalid coordinates Профиль вращения содержит недопустимые координаты
@msg Revolve profile is too complex Профиль вращения слишком сложен
@msg Revolve projection is not finite Проекция вращения не является конечной
@msg Revolve requires finite profile coordinates within 100 million points Для вращения нужны конечные координаты профиля в пределах 100 миллионов пунктов
@msg Revolve surface is too complex; reduce Segments or simplify the profile Поверхность вращения слишком сложна; уменьшите «Сегменты» или упростите профиль
@msg Revolve surface is too complex; simplify the profile Поверхность вращения слишком сложна; упростите профиль
@msg Revolve visibility cannot split the projected boundary Расчёт видимости вращения не может разделить спроецированную границу
@msg Revolve visibility creates too many fragments Расчёт видимости вращения создаёт слишком много фрагментов
@msg Revolve visibility creates too many vector anchors Расчёт видимости вращения создаёт слишком много векторных опорных точек
@msg Revolve visibility exceeds its geometry work limit Расчёт видимости вращения превышает свой предел геометрических вычислений
@msg Revolve visibility exceeds its spatial index limit Расчёт видимости вращения превышает свой предел пространственного индекса
@msg Revolve visibility has too many boundary crossings В расчёте видимости вращения слишком много пересечений границы
@msg Revolve visibility has too many input anchors В расчёте видимости вращения слишком много входных опорных точек
@msg Revolve visibility requires convex cap sectors Для расчёта видимости вращения нужны выпуклые секторы торцов
@msg Revolve visibility requires finite projected faces Для расчёта видимости вращения нужны конечные спроецированные грани
@msg SVG options: {e} Параметры SVG: {e}
@msg Save for Web settings: {e} Параметры «Сохранить для web»: {e}
@msg TIFF colour profile: {e} Цветовой профиль TIFF: {e}
@msg TIFF encoding failed: the pixel buffer doesn't match the image size Ошибка кодирования TIFF: буфер пикселей не соответствует размеру изображения
@msg TIFF encoding failed: {e} Ошибка кодирования TIFF: {e}
@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Глубина цвета Targa {_1}: допустимо 16, 24 или 32 бита на пиксель
@msg Targa encoding failed: the pixel buffer doesn't match the image size Ошибка кодирования Targa: буфер пикселей не соответствует размеру изображения
@msg The 3D projection is not finite 3D-проекция не является конечной
@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D-тень слишком сложна; отключите «Тени» или упростите графику
@msg The shape contains invalid coordinates Фигура содержит недопустимые координаты
@msg Too many Revolve faces for visibility evaluation Слишком много граней вращения для расчёта видимости
@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft читает из файла обмена образцами не более {ASE_MAX_ENTRIES} цветов и групп, а в этой библиотеке их {entries}
@msg WebP encoding failed: {e} Ошибка кодирования WebP: {e}
@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why}
@msg `answers[{i}]` {e} `answers[{i}]` {e}
@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` принимает значения smallThumb, mediumThumb, largeThumb, smallList или largeList
@msg a line can't run from a point to itself линия не может идти от точки к самой себе
@msg a line needs at least two points линии нужны хотя бы две точки
@msg a number cannot be read не удаётся прочитать число
@msg a swatch exchange block is too long блок файла обмена образцами слишком длинный
@msg a swatch exchange name is too long имя в файле обмена образцами слишком длинное
@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels размер изображения {sw} × {sh} нельзя изменить до {width} × {height} пикселей
@msg a {width} × {height} image is too large изображение {width} × {height} слишком велико
@msg a {width} × {height} image: {e} изображение {width} × {height}: {e}
@msg an image could not be written: {e} не удалось записать изображение: {e}
@msg an image that couldn't be read was left out изображение, которое не удалось прочитать, пропущено
@msg can't open “{name}”: {e} не удаётся открыть «{name}»: {e}
@msg cannot align zip entries to {align} bytes не удаётся выровнять записи zip по {align} байт
@msg cannot open the automation {kind} root `{_1}`: {e} не удаётся открыть корневую папку автоматизации ({kind}) `{_1}`: {e}
@msg cannot read profile: {0} не удаётся прочитать профиль: {0}
@msg color book version {version} isn't supported (only 1) версия цветовой книги {version} не поддерживается (только 1)
@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) цветовые книги в цветовом пространстве {other} не поддерживаются (только RGB, CMYK и Lab)
@msg file name too long to zip: {_1} имя файла слишком длинное для zip: {_1}
@msg file name too long to zip: {name} имя файла слишком длинное для zip: {name}
@msg freeform line {i}: {e} произвольная линия {i}: {e}
@msg freeform point {i}: {e} произвольная точка {i}: {e}
@msg give `{key}` or `{other}`, not both укажите `{key}` или `{other}`, но не то и другое
@msg grayscale profiles can't be working spaces yet профили оттенков серого пока не могут быть рабочими пространствами
@msg input contains NaN or infinite coordinates входные данные содержат координаты NaN или бесконечные координаты
@msg input path could not be closed не удалось замкнуть входной контур
@msg invalid ICC profile: {0} недопустимый профиль ICC: {0}
@msg invalid scale {scale} недопустимый масштаб {scale}
@msg its localized names take more than {_1} KB его локализованные имена занимают более {_1} КБ
@msg its objects are nested too deep ({e}) его объекты вложены слишком глубоко ({e})
@msg its tag table lists the `{_1}` tag more than once в его таблице тегов тег `{_1}` указан более одного раза
@msg not a graphic style library: {e} не библиотека графических стилей: {e}
@msg not a swatch library (.vcswatches or .gpl) не библиотека образцов (.vcswatches или .gpl)
@msg not a swatch library (format `{_1}`) не библиотека образцов (формат `{_1}`)
@msg not a swatch library: {e} не библиотека образцов: {e}
@msg not an action set file ({e}) не файл набора действий ({e})
@msg profile colour space {other} is not RGB, CMYK or Gray цветовое пространство профиля {other} не является RGB, CMYK или оттенками серого
@msg regular expression too large регулярное выражение слишком велико
@msg script file is not UTF-8 text: {value} файл скрипта не является текстом UTF-8: {value}
@msg stop {i}: {e} точка перехода {i}: {e}
@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) версия файла обмена образцами {major}.{minor} не поддерживается (только 1.x)
@msg the color book holds more than {ASE_MAX_ENTRIES} colors в цветовой книге больше {ASE_MAX_ENTRIES} цветов
@msg the color book is cut short цветовая книга обрезана
@msg the color book's names take more than {_1} MB имена цветовой книги занимают более {_1} МБ
@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution изображение имело бы размер {w} × {h} пикселей; растровый экспорт ограничен {MAX_RASTER_SIDE} пикселями по стороне и {_1} мегапикселями: уменьшите масштаб или разрешение
@msg the optimised image can't be read back: {e} оптимизированное изображение не удаётся прочитать обратно: {e}
@msg the pixel buffer doesn't match the image size буфер пикселей не соответствует размеру изображения
@msg the preview: {e} изображение для просмотра: {e}
@msg the request may not read the New Document profiles folder запросу не разрешено читать папку профилей нового документа
@msg the shapes are too degenerate to combine фигуры слишком вырождены для объединения
@msg the shapes cross themselves and each other too often to combine фигуры слишком часто пересекают себя и друг друга для объединения
@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups в файле обмена образцами больше {ASE_MAX_ENTRIES} цветов и групп
@msg the swatch exchange file is cut short файл обмена образцами обрезан
@msg the {_1} plate can't be read: {e} не удаётся прочитать пластину {_1}: {e}
@msg this binary DXF file is damaged or cut short этот двоичный файл DXF повреждён или обрезан
@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that это чертёж DWG, который невозможно прочитать: сохраните его из приложения CAD как DXF и откройте его
@msg too many classes слишком много классов
@msg too many groups слишком много групп
@msg too many layers for PSD (at most 32767) слишком много слоёв для PSD (не более 32767)
@msg too many swatch exchange blocks слишком много блоков в файле обмена образцами
@msg unknown colour profile: {0} неизвестный цветовой профиль: {0}
@msg {_1} clashes with a built-in profile name {_1} совпадает с именем встроенного профиля
@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} пикселей — слишком большой размер для GIF (не более 65535 пикселей по стороне): уменьшите разрешение
@msg {_1}: no usable transform to/from sRGB {_1}: нет пригодного преобразования в sRGB или из sRGB
@msg {name} is not a CMYK profile {name} — не профиль CMYK
@msg {name} is not an RGB profile {name} — не профиль RGB
@msg {path}: could not be read {path}: не удалось прочитать
@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} пикселей — слишком большой размер для JPEG (не более 65535 пикселей по стороне): уменьшите разрешение
@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} пикселей — слишком большой размер для Targa (не более 65535 пикселей по стороне): уменьшите разрешение
@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} пикселей — слишком большой размер для PSD (не более {MAX_SIDE} пикселей по стороне): уменьшите разрешение
Can't render this file because it is too large.
+1
View File
@@ -4397,6 +4397,7 @@ axis Both Båda
@msg `select` must be true or false `select` måste vara true eller false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` och `maxAnchors` måste vara heltal
@msg `{key}` must be a number not below 0 `{key}` måste vara ett tal som inte är under 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` måste vara fyra tal som inte är under 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` måste vara [x, y, width, height]: fyra ändliga tal, där bredd och höjd inte är negativa
@msg `{min_key}` is greater than `{max_key}` `{min_key}` är större än `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` ange minst en av `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.
+1
View File
@@ -4246,6 +4246,7 @@ axis Both Her İkisi
@msg `select` must be true or false `select` true veya false olmalıdır
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` ve `maxAnchors` tam sayı olmalıdır
@msg `{key}` must be a number not below 0 `{key}` 0'dan küçük olmayan bir sayı olmalıdır
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` 0'dan küçük olmayan dört sayı olmalıdır: [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] olmalıdır: dört sonlu sayı, genişlik ve yükseklik negatif olmamalı
@msg `{min_key}` is greater than `{max_key}` `{min_key}`, `{max_key}` değerinden büyük
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` şunlardan en az birini verin: `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 5 and column 25.
+1
View File
@@ -4525,6 +4525,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.
+1
View File
@@ -4574,6 +4574,7 @@ theme Appearance Giao diện
@msg `select` must be true or false `select` phải là true hoặc false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` và `maxAnchors` phải là số nguyên
@msg `{key}` must be a number not below 0 `{key}` phải là một số không nhỏ hơn 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` phải là bốn số không nhỏ hơn 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` phải là [x, y, width, height]: bốn số hữu hạn, chiều rộng và chiều cao không âm
@msg `{min_key}` is greater than `{max_key}` `{min_key}` lớn hơn `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` hãy cung cấp ít nhất một trong `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 204 and column 5.
+117
View File
@@ -4694,6 +4694,7 @@ imageTrace Outline 轮廓
@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`
@@ -9148,3 +9149,119 @@ breakText Glyphs 字形
@msg {what} can't be intertwined {what} 不能交织
@msg {what} doesn't fit the paper with its bleed and marks {what} 连同出血和标记在纸张上放不下
@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} 会打印成超过 {MAX_TILES} 个拼贴:请将其缩小
# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import.
@msg 3D effects require finite coordinates within 100 million points 3D 效果需要 1 亿点以内的有限坐标
@msg 3D effects require one to ten lights 3D 效果需要一到十个光源
@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP 位深度 {_1}:须为每像素 1、4、8、16、24 或 32 位
@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP 编码失败:{width} × {height} 像素与该格式或像素缓冲区不符
@msg GIF encoding failed: the palette image is malformed GIF 编码失败:调板图像格式错误
@msg GIF encoding failed: {e} GIF 编码失败:{e}
@msg JPEG colour profile: {e} JPEG 颜色配置文件:{e}
@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG 编码失败:像素缓冲区与图像尺寸不符
@msg JPEG encoding failed: {e} JPEG 编码失败:{e}
@msg PNG encoding failed: pixel buffer doesn't match the image size PNG 编码失败:像素缓冲区与图像尺寸不符
@msg PNG encoding failed: the palette image is malformed PNG 编码失败:调板图像格式错误
@msg PNG encoding failed: {e} PNG 编码失败:{e}
@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG 编码失败:{width} × {height} 像素不是有效的图像尺寸
@msg PSD colour profile: {e} PSD 颜色配置文件:{e}
@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD 编码失败:像素缓冲区与图像尺寸不符
@msg Revolve profile contains invalid coordinates 回转轮廓包含无效坐标
@msg Revolve profile is too complex 回转轮廓过于复杂
@msg Revolve projection is not finite 回转投影不是有限值
@msg Revolve requires finite profile coordinates within 100 million points 回转需要 1 亿点以内的有限轮廓坐标
@msg Revolve surface is too complex; reduce Segments or simplify the profile 回转表面过于复杂;请减少分段数或简化轮廓
@msg Revolve surface is too complex; simplify the profile 回转表面过于复杂;请简化轮廓
@msg Revolve visibility cannot split the projected boundary 回转可见性计算无法分割投影边界
@msg Revolve visibility creates too many fragments 回转可见性计算产生的碎片过多
@msg Revolve visibility creates too many vector anchors 回转可见性计算产生的矢量锚点过多
@msg Revolve visibility exceeds its geometry work limit 回转可见性计算超出几何运算上限
@msg Revolve visibility exceeds its spatial index limit 回转可见性计算超出空间索引上限
@msg Revolve visibility has too many boundary crossings 回转可见性计算中的边界交叉过多
@msg Revolve visibility has too many input anchors 回转可见性计算的输入锚点过多
@msg Revolve visibility requires convex cap sectors 回转可见性计算需要凸形端盖扇区
@msg Revolve visibility requires finite projected faces 回转可见性计算需要有限的投影面
@msg SVG options: {e} SVG 选项:{e}
@msg Save for Web settings: {e} 存储为 Web 所用格式设置:{e}
@msg TIFF colour profile: {e} TIFF 颜色配置文件:{e}
@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF 编码失败:像素缓冲区与图像尺寸不符
@msg TIFF encoding failed: {e} TIFF 编码失败:{e}
@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa 位深度 {_1}:须为每像素 16、24 或 32 位
@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa 编码失败:像素缓冲区与图像尺寸不符
@msg The 3D projection is not finite 3D 投影不是有限值
@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D 阴影过于复杂;请关闭阴影或简化图稿
@msg The shape contains invalid coordinates 形状包含无效坐标
@msg Too many Revolve faces for visibility evaluation 回转的面过多,无法进行可见性计算
@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft 最多从色板交换文件中读取 {ASE_MAX_ENTRIES} 个颜色和组,而此库有 {entries} 个
@msg WebP encoding failed: {e} WebP 编码失败:{e}
@msg [{j}] `{_1}`: {why} [{j}] `{_1}`:{why}
@msg `answers[{i}]` {e} `answers[{i}]` {e}
@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` 必须是 smallThumb、mediumThumb、largeThumb、smallList 或 largeList
@msg a line can't run from a point to itself 曲线不能从一个点连到该点自身
@msg a line needs at least two points 曲线至少需要两个点
@msg a number cannot be read 无法读取数字
@msg a swatch exchange block is too long 色板交换文件中的某个块过长
@msg a swatch exchange name is too long 色板交换文件中的某个名称过长
@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels 无法将 {sw} × {sh} 的图像调整为 {width} × {height} 像素
@msg a {width} × {height} image is too large {width} × {height} 的图像过大
@msg a {width} × {height} image: {e} {width} × {height} 的图像:{e}
@msg an image could not be written: {e} 无法写入某个图像:{e}
@msg an image that couldn't be read was left out 已略去一个无法读取的图像
@msg can't open “{name}”: {e} 无法打开“{name}”:{e}
@msg cannot align zip entries to {align} bytes 无法将 zip 条目对齐到 {align} 字节
@msg cannot open the automation {kind} root `{_1}`: {e} 无法打开自动化 {kind} 根目录 `{_1}`:{e}
@msg cannot read profile: {0} 无法读取配置文件:{0}
@msg color book version {version} isn't supported (only 1) 不支持色谱版本 {version} (仅支持 1)
@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) 不支持色彩空间为 {other} 的色谱 (仅支持 RGB、CMYK 和 Lab)
@msg file name too long to zip: {_1} 文件名过长,无法放入 zip:{_1}
@msg file name too long to zip: {name} 文件名过长,无法放入 zip:{name}
@msg freeform line {i}: {e} 自由曲线 {i}:{e}
@msg freeform point {i}: {e} 自由点 {i}:{e}
@msg give `{key}` or `{other}`, not both 请提供 `{key}` 或 `{other}`,不要同时提供两者
@msg grayscale profiles can't be working spaces yet 灰度配置文件目前还不能用作工作空间
@msg input contains NaN or infinite coordinates 输入包含 NaN 或无穷大坐标
@msg input path could not be closed 无法闭合输入路径
@msg invalid ICC profile: {0} ICC 配置文件无效:{0}
@msg invalid scale {scale} 缩放比例 {scale} 无效
@msg its localized names take more than {_1} KB 其本地化名称超过 {_1} KB
@msg its objects are nested too deep ({e}) 其对象嵌套过深 ({e})
@msg its tag table lists the `{_1}` tag more than once 其标签表多次列出 `{_1}` 标签
@msg not a graphic style library: {e} 不是图形样式库:{e}
@msg not a swatch library (.vcswatches or .gpl) 不是色板库 (.vcswatches 或 .gpl)
@msg not a swatch library (format `{_1}`) 不是色板库 (格式为 `{_1}`)
@msg not a swatch library: {e} 不是色板库:{e}
@msg not an action set file ({e}) 不是动作组文件 ({e})
@msg profile colour space {other} is not RGB, CMYK or Gray 配置文件的色彩空间 {other} 不是 RGB、CMYK 或灰度
@msg regular expression too large 正则表达式过大
@msg script file is not UTF-8 text: {value} 脚本文件不是 UTF-8 文本:{value}
@msg stop {i}: {e} 色标 {i}:{e}
@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) 不支持色板交换文件版本 {major}.{minor} (仅支持 1.x)
@msg the color book holds more than {ASE_MAX_ENTRIES} colors 色谱包含的颜色超过 {ASE_MAX_ENTRIES} 种
@msg the color book is cut short 色谱数据不完整
@msg the color book's names take more than {_1} MB 色谱的名称超过 {_1} MB
@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution 图像将为 {w} × {h} 像素;栅格导出限制为每边 {MAX_RASTER_SIDE} 像素和 {_1} 百万像素:请降低缩放比例或分辨率
@msg the optimised image can't be read back: {e} 无法回读优化后的图像:{e}
@msg the pixel buffer doesn't match the image size 像素缓冲区与图像尺寸不符
@msg the preview: {e} 预览:{e}
@msg the request may not read the New Document profiles folder 此请求无权读取新建文档配置文件文件夹
@msg the shapes are too degenerate to combine 这些形状过于退化,无法合并
@msg the shapes cross themselves and each other too often to combine 这些形状的自身交叉和相互交叉过多,无法合并
@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups 色板交换文件包含的颜色和组超过 {ASE_MAX_ENTRIES} 个
@msg the swatch exchange file is cut short 色板交换文件不完整
@msg the {_1} plate can't be read: {e} 无法读取 {_1} 印版:{e}
@msg this binary DXF file is damaged or cut short 此二进制 DXF 文件已损坏或不完整
@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that 这是 DWG 图纸,无法读取:请在 CAD 应用中将其保存为 DXF,然后打开该文件
@msg too many classes 字符类过多
@msg too many groups 分组过多
@msg too many layers for PSD (at most 32767) 图层过多,PSD 无法容纳 (最多 32767 个)
@msg too many swatch exchange blocks 色板交换文件中的块过多
@msg unknown colour profile: {0} 未知的颜色配置文件:{0}
@msg {_1} clashes with a built-in profile name {_1} 与内置配置文件名称冲突
@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} 像素对 GIF 而言过大 (每边最多 65535 像素):请降低分辨率
@msg {_1}: no usable transform to/from sRGB {_1}:没有可用的与 sRGB 之间的转换
@msg {name} is not a CMYK profile {name} 不是 CMYK 配置文件
@msg {name} is not an RGB profile {name} 不是 RGB 配置文件
@msg {path}: could not be read {path}:无法读取
@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} 像素对 JPEG 而言过大 (每边最多 65535 像素):请降低分辨率
@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} 像素对 Targa 而言过大 (每边最多 65535 像素):请降低分辨率
@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} 像素对 PSD 而言过大 (每边最多 {MAX_SIDE} 像素):请降低分辨率
Can't render this file because it is too large.
+1
View File
@@ -2408,6 +2408,7 @@ file-filter Templates 範本
@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.
+8 -2
View File
@@ -171,14 +171,20 @@ mod tests {
app.run("file.save", json!({"path": "/out/b.vectorcraft"})).unwrap();
assert_eq!((app.ui.status.as_str(), &app.ui.status_notes), ("Saved /out/b.vectorcraft", &None));
// A PDF export of a knockout group and of type in a font that isn't installed.
// A PDF export of a knockout group whose soft masks would draw a brushed stroke, and of type
// in a font that isn't installed.
app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
let lower = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
let upper = app.run("shape.rectangle", json!({"x": 25, "y": 15, "width": 30, "height": 20})).unwrap()["id"].clone();
app.run("brush.apply", json!({"ids": [upper], "name": "3 pt. Round"})).unwrap();
app.run("object.setProps", json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap();
app.run("select.set", json!({"ids": [lower, upper]})).unwrap();
app.run("object.group", json!({})).unwrap();
app.run("transparency.set", json!({"knockout": "on"})).unwrap();
let r = export(&mut app, Some("pdf"), Some("/out/c.pdf".into()), &json!({})).unwrap();
let notes = notes_of(&r);
assert_eq!(notes.len(), 2, "{r}");
assert!(notes.iter().any(|n| n.contains("brushed strokes")), "{r}");
assert_eq!(app.ui.status, exported_with_notes("/out/c.pdf", notes.len(), &notes[0]));
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
}
+28 -8
View File
@@ -1219,20 +1219,40 @@ fn axes_cmd(app: &mut VectorcraftApp, axes: Value, phase: widgets::Live) {
}
/// The Control bar's type controls while type is selected or edited: the Character link (the
/// Character panel in a popover), the font family and style menus and the Font Size combo.
pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some((s, _)) = text_style(app) else { return };
let link = ui
.link(egui::RichText::new(tl!("Character:")).size(12.0).color(Tokens::get(ui.ctx()).text).underline())
.on_hover_text(tl!("Character options"));
/// Character panel in a popover), the font family and style menus and the Font Size combo. They
/// show whole, with `reserve` left after them, or not at all: it returns false when type is selected
/// or edited and the row has no room for them.
pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) -> bool {
let Some((s, _)) = text_style(app) else { return true };
if ui.available_width() < control_bar_width(ui) + reserve {
return false;
}
let link = ui.link(control_bar_link_text(ui)).on_hover_text(tl!("Character options"));
widgets::popover(&link, link.clicked(), |ui| {
ui.set_width(260.0);
show_owned(app, ui, crate::font_menu::WheelOwner::ControlPopover(link.id));
});
font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (150.0, 96.0));
size_field(app, ui, "cb-font-size", 96.0);
let [family, style, size] = CONTROL_BAR_WIDTHS;
font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (family, style));
size_field(app, ui, "cb-font-size", size);
ui.add_space(4.0);
crate::chrome::group_separator(ui);
true
}
/// The Control bar's widths of the font family menu, the font style dropdown and the Font Size combo.
const CONTROL_BAR_WIDTHS: [f32; 3] = [150.0, 96.0, 96.0];
/// The text of the Control bar's Character link, as [`control_bar`] draws it.
fn control_bar_link_text(ui: &Ui) -> egui::RichText {
egui::RichText::new(tl!("Character:")).size(12.0).color(Tokens::get(ui.ctx()).text).underline()
}
/// The room [`control_bar`]'s link, menus and Font Size combo take in the Control bar's row, with
/// the spacing after each.
fn control_bar_width(ui: &Ui) -> f32 {
let spacing = ui.spacing().item_spacing.x;
widgets::text_width(ui, control_bar_link_text(ui)) + CONTROL_BAR_WIDTHS.iter().sum::<f32>() + 4.0 * spacing
}
/// The Contextual Task Bar's widths of the font family menu, the font style dropdown and the
+10 -3
View File
@@ -167,15 +167,19 @@ pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weig
/// "Stroke"): a click toggles the Stroke panel in a popover under it, and a drag scrubs the weight
/// field after it ([`crate::scrub`]).
pub(crate) fn link(app: &mut VectorcraftApp, ui: &mut Ui, label: &str) {
let t = Tokens::get(ui.ctx());
// A drag on it scrubs the weight field after it (#4088).
let resp = crate::scrub::link_label(ui, egui::RichText::new(label).size(12.0).color(t.text).underline()).on_hover_text(tl!("Stroke options"));
let resp = crate::scrub::link_label(ui, link_text(ui, label)).on_hover_text(tl!("Stroke options"));
widgets::popover(&resp, resp.clicked(), |ui| {
ui.set_width(260.0);
show(app, ui);
});
}
/// The text of the Stroke [`link`] labeled `label`, as it draws it and the Control bar measures it.
pub(crate) fn link_text(ui: &Ui, label: &str) -> egui::RichText {
egui::RichText::new(label).size(12.0).color(Tokens::get(ui.ctx()).text).underline()
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let st = current_stroke(app);
@@ -491,12 +495,15 @@ pub fn paint_profile(ui: &Ui, r: Rect, points: Option<&[(f64, f64, f64)]>, color
ui.painter().with_clip_rect(r).add(egui::Shape::mesh(mesh));
}
/// The width of the [`profile_dropdown`] button.
pub(crate) const PROFILE_DROPDOWN_WIDTH: f32 = 100.0;
/// The Profile dropdown (Stroke panel, Control bar): the stroke's own silhouette on the button
/// (a custom profile too), the built-in profiles and then the saved ones in the list. Returns the
/// id or name picked.
pub(crate) fn profile_dropdown(app: &VectorcraftApp, ui: &mut Ui, cur: Option<&WidthProfile>) -> Option<String> {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(PROFILE_DROPDOWN_WIDTH, 24.0), Sense::click());
ui.painter().rect_filled(r, 2, t.input);
ui.painter().rect_stroke(r, 2, Stroke::new(1.0, if resp.hovered() { t.text } else { t.input_border }), StrokeKind::Inside);
let body = Rect::from_min_max(r.min + vec2(6.0, 5.0), pos2(r.right() - 20.0, r.bottom() - 5.0));
+100
View File
@@ -291,6 +291,54 @@ fn hovering_back_to_source_resumes_move_instead_of_copying() {
assert!((bounds.x0 - 150.0).abs() < 0.001 && (bounds.y0 - 150.0).abs() < 0.001, "{bounds:?}");
}
/// Back in the source tab, the move resumes even when coming to the front measured the source's
/// type again in its own composite fonts, which replaces the document without editing it.
#[test]
fn hovering_back_to_a_source_whose_type_is_measured_again_resumes_move() {
let font = |roman: u32| {
json!({
"name": "CF Tab Drag",
"parts": {"kanji": {"font": "Source Sans 3"}, "roman": {"font": "JetBrains Mono", "size": roman}},
})
};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width":400,"height":300,"name":"Source"})).unwrap();
app.run("type.compositeFonts.set", json!({"fonts": [font(100)]})).unwrap();
app.run("text.create", json!({"x": 200, "y": 250, "text": "Ab12", "size": 20, "font": "CF Tab Drag"})).unwrap();
let id = vectorcraft_doc::NodeId(app.run("shape.rectangle", json!({"x":50,"y":50,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap());
app.run("file.new", json!({"width":400,"height":300,"name":"Target"})).unwrap();
app.run("type.compositeFonts.set", json!({"fonts": [font(50)]})).unwrap();
app.session.set_active(0);
let undo = app.session.active().unwrap().history.undo.len();
app.select_tool("selection");
app.ui.task_bar = false;
app.ui.view.rulers = false;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&mut app, &ctx, 0.0, vec![]);
let tabs = frame(&mut app, &ctx, 0.01, vec![]);
let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(75.0, 80.0));
frame(&mut app, &ctx, 0.05, vec![Event::PointerMoved(grab), button(grab, true)]);
frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]);
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[1].center())]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
assert_eq!(app.session.active_index(), Some(1));
let left = app.session.documents()[0].doc.clone();
frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(tabs[0].center())]);
frame(&mut app, &ctx, 1.4, vec![]);
assert_eq!(app.session.active_index(), Some(0));
assert!(!std::sync::Arc::ptr_eq(&app.session.documents()[0].doc, &left), "the source's type is measured again");
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(175.0, 180.0));
frame(&mut app, &ctx, 1.5, vec![Event::PointerMoved(drop)]);
frame(&mut app, &ctx, 1.6, vec![button(drop, false)]);
let st = app.session.active().unwrap();
assert_eq!(st.selection.objects, vec![id]);
assert_eq!(st.history.undo.len(), undo + 1);
let bounds = st.doc.node(id).unwrap().geometric_bounds().unwrap();
assert!((bounds.x0 - 150.0).abs() < 0.001 && (bounds.y0 - 150.0).abs() < 0.001, "{bounds:?}");
}
/// Verify Alt-drag transfers restored originals across tabs without retaining preview duplicates.
#[test]
fn alt_drag_can_continue_across_multiple_tabs_without_copying_the_source_preview() {
@@ -380,6 +428,58 @@ fn source_edits_target_closure_switches_and_focus_loss_cancel_stale_transfers()
}
}
/// Artwork held over the tab of a document with other assigned color profiles switches to it and
/// drops there: bringing those profiles to the front redraws the source without editing it.
#[test]
fn a_drop_on_a_document_with_other_assigned_profiles_copies_the_artwork() {
const CHILD: &str = "VECTORCRAFT_TEST_DRAG_ASSIGNED_PROFILES";
if std::env::var_os(CHILD).is_none() {
// Color settings are process-wide: change them in a separate test process.
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "tests::document_drag::a_drop_on_a_document_with_other_assigned_profiles_copies_the_artwork", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
"isolated drag test failed:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
return;
}
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width":400,"height":300,"name":"Source"})).unwrap();
app.run("shape.rectangle", json!({"x":50,"y":50,"width":100,"height":100})).unwrap();
app.run("file.new", json!({"width":400,"height":300,"name":"Target"})).unwrap();
app.run("edit.assignProfile", json!({"cmyk": vectorcraft_color::cms::DEVICE_CMYK})).unwrap();
app.session.set_active(0);
let source = app.session.active().unwrap().doc.clone();
app.select_tool("selection");
app.ui.task_bar = false;
app.ui.view.rulers = false;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&mut app, &ctx, 0.0, vec![]);
let tabs = frame(&mut app, &ctx, 0.01, vec![]);
let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(75.0, 80.0));
frame(&mut app, &ctx, 0.05, vec![Event::PointerMoved(grab), button(grab, true)]);
frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]);
assert!(app.session.in_interaction());
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[1].center())]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
assert_eq!(app.session.active_index(), Some(1));
let undo = app.session.active().unwrap().history.undo.len();
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(175.0, 180.0));
frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(drop)]);
frame(&mut app, &ctx, 1.0, vec![button(drop, false)]);
let placed = app.session.active().unwrap().selection.objects.len();
let steps = app.session.active().unwrap().history.undo.len();
assert_eq!((placed, steps), (1, undo + 1), "the drop places the artwork");
assert_eq!(app.session.documents()[0].doc, source);
}
/// Verify tab-strip relayout under a stationary pointer cannot trigger another document switch.
#[test]
fn scrolling_tabs_under_a_stationary_drag_does_not_cascade_to_other_documents() {
+3 -1
View File
@@ -522,7 +522,9 @@ fn the_wheel_steps_the_families_in_properties_and_the_control_bar() {
type Draw = fn(&mut VectorcraftApp, &mut egui::Ui);
let places: [(&str, Draw); 2] = [
("Properties", |app, ui| crate::panels::properties::type_sections(app, ui)),
("Control bar", |app, ui| crate::panels::character::control_bar(app, ui)),
("Control bar", |app, ui| {
crate::panels::character::control_bar(app, ui, 0.0);
}),
];
let at = |f: &str| FontDb::global().menu_family_list().iter().position(|x| x == f);
for (place, draw) in places {
+64 -54
View File
@@ -1240,8 +1240,10 @@ One trace at a time on this machine (all cores); memory is the peak allocated ab
## Centre lines
Measured 2026-10-17 with maximal-disc pruning and junction merging, against base `3e44478f`.
Earlier preset and cost tables retain their previous measurements. Regenerate only this class:
Measured 2026-11-13 against `1e4e3534`, with Create Strokes deciding stroke or fill per feature.
Two runs gave identical rows in every column but ms, which varies between runs. Earlier preset and
cost tables retain their previous measurements.
Regenerate only this class:
```text
TRACE_QUALITY_CLASS=centre-lines TRACE_QUALITY_REPORT=centre-lines.md cargo test --release -p vectorcraft-trace --test quality quality_report -- --ignored --nocapture
@@ -1252,9 +1254,13 @@ branches shorter than `r + max(2, 0.5 × min(2r, Stroke))` are pruned, shortest
self-loops inside the junction disc are removed; overlapping junctions and geometrically split
crossings merge, and runs join through degree-two nodes. Simplification is bounded to 32 rounds;
nodes and branches are each capped at 200,000. An over-budget blob stays with the fill path.
Whole-blob selection, ink refinement, cap detection and curve fitting retain their current rules.
A blob wider than Stroke keeps its wide part as a fill, and its thin features are traced as
strokes. The mixed-width body scene measures that split on its own: one disc of radius 24 px with
three arms of 2, 3 and 5 px that start inside it, Stroke 6 px, and no other ink. Its found % covers
the arms outside the disc, its fill→fill % covers the disc, and its paths column counts both kinds.
Ink refinement, cap detection and curve fitting retain their current rules.
Code-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).
Code-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).
Distances 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 (–).
@@ -1304,8 +1310,10 @@ Distances use dense exact centre samples (≤0.25 px), emitted curves sampled at
| letters w3 | hard | 7+0 | 0.33 / 0.92 | 0.34 / 0.71 | 99.0 | 0.06 / 0.20 | – | – |
| letters w5 | aa | 7+0 | 0.27 / 1.49 | 0.29 / 1.31 | 95.9 | 0.02 / 0.20 | – | – |
| letters w5 | hard | 7+0 | 0.40 / 1.48 | 0.41 / 1.29 | 96.2 | 0.05 / 0.20 | – | – |
| mix | aa | 3+1 | 0.10 / 0.21 | 12.89 / 30.00 | 57.3 | 0.03 / 0.14 | – | – |
| mix | hard | 3+1 | 0.22 / 0.43 | 12.95 / 30.00 | 57.3 | 0.08 / 0.15 | – | – |
| mix | aa | 6+3 | 0.09 / 0.19 | 0.15 / 0.28 | 99.9 | 0.02 / 0.14 | – | – |
| mix | hard | 6+3 | 0.24 / 0.52 | 0.28 / 0.56 | 99.8 | 0.07 / 0.20 | – | – |
| mixed-width body | aa | 3+1 | 0.09 / 0.27 | 0.15 / 0.31 | 100.0 | 0.05 / 0.14 | – | – |
| mixed-width body | hard | 3+1 | 0.18 / 0.42 | 0.22 / 0.42 | 100.0 | 0.05 / 0.12 | – | – |
| varying | aa | 5+0 | 0.13 / 0.28 | 0.19 / 0.31 | 99.5 | 1.89 / 4.13 | – | – |
| varying | hard | 5+0 | 0.20 / 0.51 | 0.24 / 0.52 | 99.7 | 1.90 / 4.22 | – | – |
@@ -1313,56 +1321,58 @@ Distances use dense exact centre samples (≤0.25 px), emitted curves sampled at
| scene | input | junctions ok / wrong deg / missed / extra | junction pos err | ends found | end err (signed) | spurs | IoU uniform / profile | area err uniform / profile | fill→fill % | anchors | width pts | ms |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| lines w1 | aa | 0 / 0 / 0 / 76 | – | 43/48 | 0.71 (-0.69) | 88 | 0.790 / – | -6.2% / – | – | 186 | – | 4.6 |
| lines w1 | hard | 0 / 0 / 0 / 0 | – | 42/48 | 1.50 (-1.42) | 142 | 0.644 / – | -25.8% / – | – | 184 | – | 3.6 |
| lines w2 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.32 (-0.20) | 0 | 0.914 / – | -0.3% / – | – | 71 | – | 4.3 |
| lines w2 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.38 (-0.10) | 0 | 0.808 / – | -2.0% / – | – | 58 | – | 4.2 |
| lines w3 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.26 (-0.17) | 0 | 0.931 / – | -3.0% / – | – | 64 | – | 4.4 |
| lines w3 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.46 (-0.24) | 0 | 0.881 / – | -2.5% / – | – | 50 | – | 4.3 |
| lines w4 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.25 (-0.18) | 0 | 0.964 / – | -0.6% / – | – | 70 | – | 4.6 |
| lines w4 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.40 (-0.15) | 0 | 0.906 / – | -0.1% / – | – | 54 | – | 4.5 |
| lines w6 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.30 (-0.18) | 0 | 0.965 / – | -0.3% / – | – | 65 | – | 5.1 |
| lines w6 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.45 (-0.12) | 0 | 0.923 / – | 1.4% / – | – | 55 | – | 4.9 |
| lines w8 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.37 (-0.20) | 0 | 0.967 / – | -1.0% / – | – | 66 | – | 5.4 |
| lines w8 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.46 (-0.18) | 0 | 0.934 / – | -0.3% / – | – | 53 | – | 5.4 |
| lines w10 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.35 (-0.16) | 0 | 0.969 / – | -0.7% / – | – | 66 | – | 5.9 |
| lines w10 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.50 (-0.12) | 0 | 0.942 / – | -0.1% / – | – | 52 | – | 5.9 |
| lines w12 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.40 (-0.16) | 0 | 0.969 / – | -0.6% / – | – | 70 | – | 6.4 |
| lines w12 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.54 (-0.11) | 0 | 0.949 / – | -0.2% / – | – | 53 | – | 6.3 |
| curves w2 | aa | 0 / 0 / 0 / 0 | – | 8/8 | 0.18 (-0.13) | 0 | 0.921 / – | -0.6% / – | – | 34 | – | 4.7 |
| curves w2 | hard | 0 / 0 / 0 / 0 | – | 8/8 | 0.22 (-0.05) | 0 | 0.821 / – | -2.4% / – | – | 29 | – | 4.7 |
| curves w4 | aa | 0 / 0 / 0 / 0 | – | 8/8 | 0.56 (-0.42) | 0 | 0.949 / – | -0.8% / – | – | 35 | – | 5.1 |
| curves w4 | hard | 0 / 0 / 0 / 0 | – | 8/8 | 0.66 (-0.38) | 0 | 0.904 / – | -0.5% / – | – | 30 | – | 5.2 |
| curves w8 | aa | 0 / 0 / 0 / 0 | – | 8/8 | 2.24 (-2.01) | 0 | 0.956 / – | -0.9% / – | – | 42 | – | 5.9 |
| curves w8 | hard | 0 / 0 / 0 / 0 | – | 8/8 | 1.97 (-1.67) | 0 | 0.936 / – | -0.5% / – | – | 31 | – | 5.9 |
| circles | aa | 0 / 0 / 0 / 0 | – | 0/0 | – (–) | 32 | 0.964 / – | -0.9% / – | – | 71 | – | 7.4 |
| circles | hard | 0 / 0 / 0 / 0 | – | 0/0 | – (–) | 28 | 0.888 / – | -0.7% / – | – | 52 | – | 8.0 |
| junctions w2 | aa | 7 / 0 / 0 / 0 | 1.17 | 23/23 | 0.32 (-0.16) | 0 | 0.889 / – | 6.1% / – | – | 84 | – | 3.7 |
| junctions w2 | hard | 7 / 0 / 0 / 0 | 1.17 | 23/23 | 0.49 (-0.15) | 0 | 0.771 / – | 4.9% / – | – | 59 | – | 3.6 |
| junctions w4 | aa | 7 / 0 / 0 / 1 | 1.62 | 23/23 | 0.33 (-0.26) | 0 | 0.919 / – | 5.0% / – | – | 100 | – | 4.1 |
| junctions w4 | hard | 7 / 0 / 0 / 1 | 1.62 | 23/23 | 0.65 (-0.32) | 0 | 0.850 / – | 7.0% / – | – | 81 | – | 4.0 |
| junctions w8 | aa | 7 / 0 / 0 / 2 | 2.76 | 23/23 | 0.43 (-0.27) | 0 | 0.937 / – | 2.7% / – | – | 93 | – | 4.7 |
| junctions w8 | hard | 7 / 0 / 0 / 1 | 2.76 | 23/23 | 0.80 (-0.35) | 0 | 0.913 / – | 2.5% / – | – | 77 | – | 4.7 |
| crossings w3 | aa | 5 / 0 / 0 / 2 | 0.25 | 20/20 | 0.27 (-0.18) | 0 | 0.935 / – | -1.1% / – | – | 74 | – | 2.8 |
| crossings w3 | hard | 5 / 0 / 0 / 5 | 0.25 | 20/20 | 0.41 (-0.17) | 0 | 0.791 / – | -7.6% / – | – | 69 | – | 2.8 |
| crossings w6 | aa | 5 / 0 / 0 / 1 | 0.25 | 20/20 | 0.35 (-0.17) | 0 | 0.925 / – | -3.6% / – | – | 112 | – | 3.3 |
| crossings w6 | hard | 5 / 0 / 0 / 4 | 0.25 | 20/20 | 0.51 (-0.20) | 0 | 0.872 / – | -2.0% / – | – | 81 | – | 3.4 |
| dashed w2 | aa | 0 / 0 / 0 / 0 | – | 62/62 | 0.28 (-0.20) | 0 | 0.927 / – | -2.6% / – | – | 64 | – | 2.0 |
| dashed w2 | hard | 0 / 0 / 0 / 0 | – | 62/62 | 0.33 (-0.11) | 0 | 0.926 / – | -3.6% / – | – | 67 | – | 1.9 |
| dashed w4 | aa | 0 / 0 / 0 / 0 | – | 58/58 | 0.30 (-0.20) | 0 | 0.952 / – | -0.1% / – | – | 61 | – | 2.1 |
| dashed w4 | hard | 0 / 0 / 0 / 0 | – | 58/58 | 0.37 (-0.08) | 0 | 0.944 / – | -1.5% / – | – | 60 | – | 2.1 |
| letters w3 | aa | 7 / 0 / 0 / 6 | 1.47 | 17/17 | 0.62 (-0.47) | 1 | 0.923 / – | -0.9% / – | – | 114 | – | 3.3 |
| letters w3 | hard | 7 / 0 / 0 / 3 | 1.47 | 17/17 | 0.38 (-0.21) | 1 | 0.859 / – | -0.6% / – | – | 81 | – | 3.3 |
| letters w5 | aa | 6 / 1 / 0 / 6 | 2.10 | 17/17 | 0.99 (-0.83) | 0 | 0.929 / – | -1.7% / – | – | 124 | – | 3.7 |
| letters w5 | hard | 6 / 1 / 0 / 8 | 2.10 | 17/17 | 1.05 (-0.79) | 0 | 0.887 / – | -2.0% / – | – | 100 | – | 3.7 |
| mix | aa | 0 / 0 / 0 / 0 | – | 6/7 | 0.24 (-0.17) | 0 | 0.986 / – | 0.1% / – | 99.7 | 11 | – | 6.5 |
| mix | hard | 0 / 0 / 0 / 0 | – | 6/7 | 0.57 (-0.46) | 0 | 0.969 / – | -0.0% / – | 99.1 | 11 | – | 6.4 |
| varying | aa | 0 / 0 / 0 / 0 | – | 10/10 | 2.03 (-1.08) | 0 | 0.735 / – | 4.8% / – | – | 22 | – | 5.2 |
| varying | hard | 0 / 0 / 0 / 0 | – | 10/10 | 1.92 (-0.94) | 0 | 0.736 / – | 5.2% / – | – | 22 | – | 5.3 |
| lines w1 | aa | 0 / 0 / 0 / 76 | – | 43/48 | 0.71 (-0.69) | 88 | 0.790 / – | -6.2% / – | – | 186 | – | 8.0 |
| lines w1 | hard | 0 / 0 / 0 / 0 | – | 42/48 | 1.50 (-1.42) | 142 | 0.644 / – | -25.8% / – | – | 184 | – | 6.8 |
| lines w2 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.32 (-0.20) | 0 | 0.914 / – | -0.3% / – | – | 71 | – | 7.7 |
| lines w2 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.38 (-0.10) | 0 | 0.808 / – | -2.0% / – | – | 58 | – | 7.2 |
| lines w3 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.26 (-0.17) | 0 | 0.931 / – | -3.0% / – | – | 64 | – | 8.0 |
| lines w3 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.46 (-0.24) | 0 | 0.881 / – | -2.5% / – | – | 50 | – | 7.7 |
| lines w4 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.25 (-0.18) | 0 | 0.964 / – | -0.6% / – | – | 70 | – | 7.8 |
| lines w4 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.40 (-0.15) | 0 | 0.906 / – | -0.1% / – | – | 54 | – | 7.6 |
| lines w6 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.30 (-0.18) | 0 | 0.965 / – | -0.3% / – | – | 65 | – | 8.4 |
| lines w6 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.45 (-0.12) | 0 | 0.923 / – | 1.4% / – | – | 55 | – | 8.2 |
| lines w8 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.37 (-0.20) | 0 | 0.967 / – | -1.0% / – | – | 66 | – | 9.8 |
| lines w8 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.46 (-0.18) | 0 | 0.934 / – | -0.3% / – | – | 53 | – | 9.1 |
| lines w10 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.35 (-0.16) | 0 | 0.969 / – | -0.7% / – | – | 66 | – | 10.1 |
| lines w10 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.50 (-0.12) | 0 | 0.942 / – | -0.1% / – | – | 52 | – | 9.8 |
| lines w12 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.40 (-0.16) | 0 | 0.969 / – | -0.6% / – | – | 70 | – | 11.0 |
| lines w12 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.54 (-0.11) | 0 | 0.949 / – | -0.2% / – | – | 53 | – | 10.7 |
| curves w2 | aa | 0 / 0 / 0 / 0 | – | 8/8 | 0.18 (-0.13) | 0 | 0.921 / – | -0.6% / – | – | 34 | – | 7.6 |
| curves w2 | hard | 0 / 0 / 0 / 0 | – | 8/8 | 0.22 (-0.05) | 0 | 0.821 / – | -2.4% / – | – | 29 | – | 7.5 |
| curves w4 | aa | 0 / 0 / 0 / 0 | – | 8/8 | 0.56 (-0.42) | 0 | 0.949 / – | -0.8% / – | – | 35 | – | 8.3 |
| curves w4 | hard | 0 / 0 / 0 / 0 | – | 8/8 | 0.66 (-0.38) | 0 | 0.904 / – | -0.5% / – | – | 30 | – | 8.4 |
| curves w8 | aa | 0 / 0 / 0 / 0 | – | 8/8 | 2.24 (-2.01) | 0 | 0.956 / – | -0.9% / – | – | 42 | – | 10.7 |
| curves w8 | hard | 0 / 0 / 0 / 0 | – | 8/8 | 1.97 (-1.67) | 0 | 0.936 / – | -0.5% / – | – | 31 | – | 9.8 |
| circles | aa | 0 / 0 / 0 / 0 | – | 0/0 | – (–) | 32 | 0.964 / – | -0.9% / – | – | 71 | – | 11.3 |
| circles | hard | 0 / 0 / 0 / 0 | – | 0/0 | – (–) | 28 | 0.888 / – | -0.7% / – | – | 52 | – | 11.9 |
| junctions w2 | aa | 7 / 0 / 0 / 0 | 1.17 | 23/23 | 0.32 (-0.16) | 0 | 0.889 / – | 6.1% / – | – | 84 | – | 6.3 |
| junctions w2 | hard | 7 / 0 / 0 / 0 | 1.17 | 23/23 | 0.49 (-0.15) | 0 | 0.771 / – | 4.9% / – | – | 59 | – | 6.1 |
| junctions w4 | aa | 7 / 0 / 0 / 1 | 1.62 | 23/23 | 0.33 (-0.26) | 0 | 0.919 / – | 5.0% / – | – | 100 | – | 6.9 |
| junctions w4 | hard | 7 / 0 / 0 / 1 | 1.62 | 23/23 | 0.65 (-0.32) | 0 | 0.850 / – | 7.0% / – | – | 81 | – | 6.8 |
| junctions w8 | aa | 7 / 0 / 0 / 2 | 2.76 | 23/23 | 0.43 (-0.27) | 0 | 0.937 / – | 2.7% / – | – | 93 | – | 7.9 |
| junctions w8 | hard | 7 / 0 / 0 / 1 | 2.76 | 23/23 | 0.80 (-0.35) | 0 | 0.913 / – | 2.5% / – | – | 77 | – | 7.7 |
| crossings w3 | aa | 5 / 0 / 0 / 2 | 0.25 | 20/20 | 0.27 (-0.18) | 0 | 0.935 / – | -1.1% / – | – | 74 | – | 4.6 |
| crossings w3 | hard | 5 / 0 / 0 / 5 | 0.25 | 20/20 | 0.41 (-0.17) | 0 | 0.791 / – | -7.6% / – | – | 69 | – | 4.5 |
| crossings w6 | aa | 5 / 0 / 0 / 1 | 0.25 | 20/20 | 0.35 (-0.17) | 0 | 0.925 / – | -3.6% / – | – | 112 | – | 5.6 |
| crossings w6 | hard | 5 / 0 / 0 / 4 | 0.25 | 20/20 | 0.51 (-0.20) | 0 | 0.872 / – | -2.0% / – | – | 81 | – | 5.4 |
| dashed w2 | aa | 0 / 0 / 0 / 0 | – | 62/62 | 0.28 (-0.20) | 0 | 0.927 / – | -2.6% / – | – | 64 | – | 3.7 |
| dashed w2 | hard | 0 / 0 / 0 / 0 | – | 62/62 | 0.33 (-0.11) | 0 | 0.926 / – | -3.6% / – | – | 67 | – | 3.7 |
| dashed w4 | aa | 0 / 0 / 0 / 0 | – | 58/58 | 0.30 (-0.20) | 0 | 0.952 / – | -0.1% / – | – | 61 | – | 3.9 |
| dashed w4 | hard | 0 / 0 / 0 / 0 | – | 58/58 | 0.37 (-0.08) | 0 | 0.944 / – | -1.5% / – | – | 60 | – | 3.8 |
| letters w3 | aa | 7 / 0 / 0 / 6 | 1.47 | 17/17 | 0.62 (-0.47) | 1 | 0.923 / – | -0.9% / – | – | 114 | – | 5.3 |
| letters w3 | hard | 7 / 0 / 0 / 3 | 1.47 | 17/17 | 0.38 (-0.21) | 1 | 0.859 / – | -0.6% / – | – | 81 | – | 5.2 |
| letters w5 | aa | 6 / 1 / 0 / 6 | 2.10 | 17/17 | 0.99 (-0.83) | 0 | 0.929 / – | -1.7% / – | – | 124 | – | 5.9 |
| letters w5 | hard | 6 / 1 / 0 / 8 | 2.10 | 17/17 | 1.05 (-0.79) | 0 | 0.887 / – | -2.0% / – | – | 100 | – | 5.7 |
| mix | aa | 0 / 0 / 0 / 0 | – | 7/7 | 0.25 (-0.18) | 0 | 0.988 / – | 0.2% / – | 99.9 | 22 | – | 10.2 |
| mix | hard | 0 / 0 / 0 / 0 | – | 7/7 | 0.56 (-0.46) | 0 | 0.956 / – | -0.4% / – | 98.9 | 22 | – | 10.3 |
| mixed-width body | aa | 0 / 0 / 0 / 0 | – | 3/3 | 0.23 (-0.05) | 0 | 0.985 / – | 0.3% / – | 99.3 | 9 | – | 4.4 |
| mixed-width body | hard | 0 / 0 / 0 / 0 | – | 3/3 | 0.27 (-0.03) | 0 | 0.972 / – | -0.6% / – | 98.3 | 8 | – | 3.7 |
| varying | aa | 0 / 0 / 0 / 0 | – | 10/10 | 2.03 (-1.08) | 0 | 0.735 / – | 4.8% / – | – | 22 | – | 9.4 |
| varying | hard | 0 / 0 / 0 / 0 | – | 10/10 | 1.92 (-0.94) | 0 | 0.736 / – | 5.2% / – | – | 22 | – | 8.7 |
**aa totals**: junctions 44 correct, 1 wrong degree, 0 missed, 94 extra (of 45); ends 682 of 688; spurs 121.
**aa totals**: junctions 44 correct, 1 wrong degree, 0 missed, 94 extra (of 45); ends 686 of 691; spurs 121.
**hard totals**: junctions 44 correct, 1 wrong degree, 0 missed, 22 extra (of 45); ends 681 of 688; spurs 171.
**hard totals**: junctions 44 correct, 1 wrong degree, 0 missed, 22 extra (of 45); ends 685 of 691; spurs 171.
**Scope of the improvement:** correct junction degrees rise from 30/45 to 44/45 in each variant;
missed junctions fall from two to zero. Extra junctions fall from 104 to 94 aa and 27 to 22 hard.
+3 -3
View File
@@ -29,17 +29,17 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a
| Language | Code | UI strings translated | Dialogs / tooltips / messages | Script support | Native review | Status | To `full` (h) |
|---|---|---|---|---|---|---|---|
| English | `en` | source, 100% | all | Latin | — | full | — |
| Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all (3,439 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 40 missing messages for it) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) |
| Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all (3,554 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 55 missing messages for it, which open #7116, #7211, #7330, #7378 and #7405 translate) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) |
| Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) |
| Hindi | `hi` | 8,782 rows (2026-11-13): 3,014 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font |
| Gujarati | `gu` | 4,673 rows (2026-11-14): original 4,286 unchanged; all 387 additions contributor-reviewed and approved | menus and dialogs; status/error messages partial | installed Gujarati fonts on desktop; optional `Gujr` craft-fonts fallback; no Gujarati face in the pinned CI/release fonts or web startup list | original catalog reviewed by a native speaker; all 387 later additions approved by the contributor, with 11 wording corrections | contributor-reviewed catalog | remaining messages and a bundled Gujarati web font |
| Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 |
| French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) |
| French | `fr` | 9,166 rows, 3,436 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 178 missing messages for it | all | Latin | no | full | — (review) |
| Portuguese | `pt-br` | 9,260 rows, 3,445 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 39 missing messages for it; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) |
| Indonesian | `id` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one plural form | dialogs and tooltips all; 750 message rows (French has 796) | Latin | no | full | — (review) |
| Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) |
| German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (3,433 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 48 missing messages for it) | Latin | contributor-written, not reviewed | full | — (review) |
| Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) |
| Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all (3,438 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 39 missing messages for it) | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) |
| Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) |
**Historical 12-language score: ~82%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 100 + 100 + 100 + 98 + 100 + 100, over 12).
+51 -11
View File
@@ -1305,7 +1305,9 @@ Drive a tool like a mouse:
Edit a gradient with the Gradient tool's annotator (the bar runs along the vector; stop chips sit 10 px under
it, midpoint diamonds 7 px above): a click on the bar adds a stop, dragging a chip moves it (drag it off the bar to
delete it, hold Alt to copy it), dragging the end handle changes the vector. The selected stop
delete it, hold Alt to copy it), dragging the end handle changes the vector. Both ends of a vector drawn on the art,
the start of one a click applies, and the dragged start and end handles snap as drawn points do; with Shift, the end
or the handle keeps to 45° steps and snaps along them. The selected stop
(`gradient.selectStop`) takes Delete and ←/→:
```json
@@ -2158,7 +2160,7 @@ VectorCraft's own, and `vc.api.has("dollar")` is true where they are available:
| `build`, `version` | VectorCraft's version |
| `buildDate` | `null` |
| `decimalPoint` | `","` for a locale whose language writes a decimal comma (such as `de`, `fr`, `ru`), otherwise `"."` |
| `engineName` | `"VectorCraft"` |
| `engineName` | the name `#targetengine` gave the engine the script runs in, or `"VectorCraft"` without that line |
| `error` | the last `Error` object the run threw, caught or not, or `null` before one |
| `flags` | `0`, or `512` while localization is on; only `0` and `512` can be set, and setting `512` turns `$.localize` on |
| `gc()` | does nothing; the interpreter collects its own garbage |
@@ -2166,7 +2168,7 @@ VectorCraft's own, and `vc.api.has("dollar")` is true where they are available:
| `global` | the global object |
| `hiresTimer` | the microseconds since the run last read it, or since the run started; `0` on the web |
| `level`, `memCache` | `0`; setting `memCache` throws |
| `line`, `stack` | the line of the code that reads it, counted in the file that code is in (the script, a file it included or a file `$.evalFile` ran, in this run or an earlier run of its named engine); the running code, outermost first, one a line as `name@file:line` (`[global]` for the script's own code, `[anonymous]` for a function without a name; the file as `$.fileName` gives it, or the run's name for a script given as `source`), at most 64 lines. Code that `eval` or `Function` compiled keeps the line of the source they compiled, in the file `[eval]`; code whose place a named engine no longer keeps is at line `0` of `[unknown]` |
| `line`, `stack` | `$.line` is the line of the code that reads it, counted in the file that code is in (the script, a file it included or a file `$.evalFile` ran, in this run or an earlier run of its named engine). `$.stack` lists the running code, outermost first, one frame a line as `name@file:line`: `name` is `[global]` for code outside a function (in the script, in a file it included, or in code that `$.evalFile` or `eval` ran) and `[anonymous]` for a function without a name, and `file` and `line` are the file and line that code is in, so `file` is the file `$.fileName` gives in that code. It lists the innermost 64 frames; when there are more, a first line `…` stands for the ones left out. In code from a script given as `source`, `file` is that run's name, while `$.fileName` is empty. Code that `eval` or `Function` compiled keeps the line of the source they compiled, in the file `[eval]`, while `$.fileName` there is the file being run; code whose place a named engine no longer keeps is at line `0` of `[unknown]`, and `$.fileName` there is empty |
| `locale` | the interface's language as a locale such as `de` or `pt_BR` (`en` when the interface follows the system); a script can set `ll` or `ll_RR`, and `null` or `""` sets the interface's again |
| `localize` | `false`; while `true`, `String(object)` of a localization object gives its text for `$.locale` |
| `os` | `Windows`, `macOS`, `Linux`, `FreeBSD`, `Unix` or `Web`, without a version |
@@ -2510,8 +2512,12 @@ neutral, the default). In a knockout group each child hides what it covers of th
pass the enclosing group's setting to their children, off groups never knock out. `knockoutShape: true` makes the
object's opacity and opacity mask scale how much it knocks out. `transparency.togglePageKnockoutGroup` and
`transparency.togglePageIsolatedBlending` (`{value?}`) treat the whole page as a knockout or isolated group; they are
saved with the document and undoable, and `transparency.info` reports them. PDF and SVG write knockout groups as
soft-masked groups with the same look (the PDF export reports a warning).
saved with the document and undoable, and `transparency.info` reports them. PDF writes a knockout group as a PDF
knockout group, which PDF import reads back as one. Where an element knocks out less than the area it paints
(`knockoutShape: true` with opacity or a mask, or translucent paint), PDF draws each element through one soft mask of
the overlapping elements above it, as SVG does for every knockout group of translucent art. A group whose masks would
hold more than 65,536 objects (PDF) or shapes (SVG), or that has more than 2,048 elements, is written as a PDF knockout
group or a plain SVG group instead, and the export reports a warning.
Groups are not isolated unless Isolate Blending is on: a blend mode inside a group with opacity, a blend mode, an
opacity mask, knockout or a clip reaches the art below the group, on screen and in PDF (a knockout group's elements
@@ -4798,7 +4804,10 @@ type, with the image under it. A group's or layer's own fills and strokes become
members are expanded too. A symbol instance whose topmost fill or topmost stroke is visible, has an opacity above 0
and has a geometry effect becomes a group of its art: that fill's effects reshape the filled shapes of the art and that
stroke's effects reshape its stroked shapes, which keep their colors, as the canvas draws them (VectorCraft's own
rule); the instance's other fills and strokes are dropped. The result is `{ids}`; when an image in the art can't be decoded, the images made from
rule); the instance's other fills and strokes are dropped. A raster effect on that fill or stroke draws on the filled
or stroked shapes of the art, and expanding makes it an image as for other objects; with no geometry effect, the
instance stays an instance over a shadow or glow image, and a blur, feather or inner glow replaces it with the image.
The result is `{ids}`; when an image in the art can't be decoded, the images made from
raster effects do not show it, and the result also carries the `warning` that Flatten Transparency gives.
A path's strokes stay strokes, as the
[Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help
@@ -6596,8 +6605,8 @@ number, naming the key, and place nothing; null is the same as leaving the key o
`edit.copy` keeps the copied objects with the document resources they use: image blobs, symbols, patterns, global
and spot swatches (with the tint swatches of the tints used; never the built-in [Registration] swatch), the gradient
swatches their gradients came from, graphic styles the objects are linked to, character and paragraph styles and
brushes. Width profiles live in the preferences, so they need no copying. Every `edit.paste*` command (also `edit.pasteWithoutFormatting`, which leaves
text styles behind) brings them into the active document in the same undo step: an identical resource is reused,
brushes, with the swatches, patterns and text styles a graph keeps for its series and labels. Width profiles live in the preferences, so they need no copying. Every `edit.paste*` command (also `edit.pasteWithoutFormatting`, which leaves
text styles behind, those of graph labels included) brings them into the active document in the same undo step: an identical resource is reused,
a missing one added, and one of the same name that differs is added renamed ("Mark 2"; a copy an earlier paste
added is reused), which the pasted objects follow. Linked colours show the document's swatch at their tint (a Lab
spot colour as its Spot Colors option says). The result reports `{ids, added, merged, renamed: [{kind, from,
@@ -10488,9 +10497,13 @@ give the same result there. A paragraph whose characters differ in line metrics
scale, or an inline graphic taller than the text) is composed with Every-line in area type of any shape, rows,
columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured
where the lines above it put it, across the line's own height, so a taller line that reaches where the frame is
narrower, or beside a wrap object, gets the room there. Such a paragraph falls back to Single-line when the frame has
no room for all of it, or when the search for its breaks passes VectorCraft's own limits (1,000,000 lines tried and
2,000,000 baseline steps for one paragraph, 2,000,000 lines and 4,000,000 steps for all the paragraphs of a frame).
narrower, or beside a wrap object, gets the room there. When the frame has no room for all of such a paragraph, the
lines it holds are still composed with the whole paragraph: VectorCraft measures the lines past the frame's end as
wide as the span of the last line the frame holds, less their indents. Those lines overflow, or go on in the next
threaded frame, which composes them again at its own widths. Such a paragraph falls back to Single-line when the
frame has no room for its first line, or when the search for its breaks passes VectorCraft's own limits (1,000,000
lines tried and 2,000,000 baseline steps for one paragraph, 2,000,000 lines and 4,000,000 steps for all the
paragraphs of a frame).
The UI command `type.lineComposer` (Cmd+Alt+Shift+C, in remote mode `press_key {key: "C", mods: {cmd: true, alt: true,
shift: true}}` or `invoke_menu`) sets `singleLine` when every paragraph of the selected type (or every paragraph the
Type tool's selection touches) uses Every-line, else `everyLine`, and returns the composer it set.
@@ -11051,6 +11064,33 @@ whole points and Escape cancels. A drag keeps a Contour's own corners and miter
options `corners` and `miterLimit` (`tool.setOption`). A border with a gap is two Contours of one shape (the gap first, so
it ends up in front), baked with `effect.expandAppearance`, then `object.pathfinder.minusFront`.
## 3D Box and shapes add-on
The `addon.shapes.*` commands draw rotatable 3D boxes (`addon.shapes.box3d`) and rectangles, ellipses, polygons, stars and
spirals that keep their settings (`addon.shapes.rectangle`, `ellipse`, `polygon`, `star`, `spiral`), and
`addon.shapes.configure` changes those settings on the selected shapes (or `ids`). In a drawing command, an omitted or
null value takes the default in the table. In `configure`, an omitted or null value keeps each shape's setting. A value
outside its range is an error that names the parameter and the range, and the command then draws or changes nothing.
The ranges are VectorCraft's own:
| Parameter | Commands | Range (default) |
|---|---|---|
| `width`, `height` | `rectangle`, `ellipse`, `box3d` | pt, 0 or more |
| `radius` | `polygon`, `spiral` | pt, 0 or more |
| `radius` (corners) | `rectangle`, `configure` | pt, 0 or more (0); `configure` fits a larger radius to half each rectangle's shorter side |
| `radii` | `rectangle`, `configure` | four numbers, 0 or more: `[tl, tr, br, bl]` |
| `radius1` | `star` | pt, 0 or more |
| `radius2` | `star` | 0.01 to 1 times `radius1` (half of it) |
| `sides`, `points` | `polygon`, `star`, `configure` (`sides`) | a whole number from 3 to 1000 (6 sides, 5 points) |
| `rounded`, `randomized` | `polygon`, `star`, `configure` | −10 to 10 (0) |
| `spokeRatio` | `configure` | 0.01 to 1 |
| `turns` | `spiral`, `configure` | 0.01 to 1024 (3) |
| `divergence` | `spiral`, `configure` | 0 to 1000 (1) |
| `innerRadius` | `spiral`, `configure` | 0 to 0.999 of the radius (0) |
| `arcMode` | `rectangle`, `ellipse`, `polygon`, `star`, `configure` | `whole`, `pie` (or `slice`), `chord`, `arc` (or `open`) (`whole`) |
A `configure` call that changes no shape records no undo step and returns `{updated: 0}`.
## Cutting Tools add-on
Cutting Tools is a built-in add-on that prepares art for cutting. Its commands are `addon.cutting.bridges`,
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -39,7 +39,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending |
| Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` |
| Fallback glyphs keep the text's weight and slant | done | #2491: a character from a fallback face is set in that family's face nearest the run's weight and slant; Bold Italic of a missing family is drawn Bold (Source Sans 3 has no Bold Italic) rather than Italic; a letter and its combining marks (a grapheme cluster) are drawn in one face, the first that has all of them, so marks the text's font lacks attach to their letter instead of drawing as missing glyphs (#4491). Vietnamese letters with two marks (Ể, Ứ, Ự) sit on their letter whether typed precomposed, decomposed or as a vowel plus a tone mark, built from GPOS mark-to-mark in fonts without the precomposed letters. Evidence: `tests::vietnamese` |
| Colour fonts (bitmap, COLR, OpenType SVG) | partial | bitmap glyphs (`sbix`, `CBDT`) done: the largest strike's PNG, transformed with the type, fill and stroke ignored; PDF Type 3 glyphs or images, SVG images with outlined text (live text leaves emoji to the viewer, their fonts not embedded), EPS and legacy `.ai` images, Create Outlines images; not `sbix` duplicate, flipped, JPEG or TIFF glyphs, or `CBDT` BGRA or mask data, or ruby; in envelopes they warp as images do, and a Warp effect moves them without bending them. COLR (v0 and v1) done: a glyph's layers as vector art (solid colours, linear and radial gradients, clip glyphs and boxes, blend modes, transforms, palette 0, the foreground colour from the run's fill, variable fonts at the face's instance), transformed with the type; drawn on the canvas, written as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as text in PDF (Type 3 glyphs), and Create Outlines makes groups of editable paths. Paints documents have no form for draw the glyph as an image (256 px per em): sweep gradients, Porter-Duff compositing, radial gradients from a circle that isn't the centre, repeating or reflecting gradients. Not ruby. COLR and SVG glyphs also go with type that geometry effects reshape (on the canvas and in exports), into Expand Appearance and EMF/WMF export, as their art, and bitmap glyphs as images. OpenType SVG done: a glyph's SVG document (its element `glyph<gid>`) is read by the SVG importer into vector art in font units, `currentColor` in the run's fill (and the legacy `context-fill` and `context-stroke` in its fill and stroke), a `viewBox` mapped onto the em square, and then drawn, exported and outlined as COLR glyphs are; in PDF real text such glyphs are text that draws nothing, with their art drawn where they sit. Images in glyph documents are in the art, holding their pixels until a document takes them in; text, scripts and embedded content are left out, as the table rules them out. Not CPAL palette variables. Evidence: `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::svg_glyph_images_draw_and_outline`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`, `tests_colourfonts::expand_appearance_keeps_the_colour_glyphs_of_type_with_effects`, `render::tests_colrglyphs::colour_glyphs_go_with_type_that_a_geometry_effect_reshapes`, `tests_bitmaps`, `tests_colr`, `render::tests_bitmapglyphs`, `render::tests_colrglyphs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/it/colour_glyphs.rs`, `eps` `colour_bitmap_glyphs_*` and `colr_glyphs_*`, `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups` |
| Colour fonts (bitmap, COLR, OpenType SVG) | partial | bitmap glyphs (`sbix`, `CBDT`) done: the largest strike's PNG, transformed with the type, fill and stroke ignored; PDF Type 3 glyphs or images, SVG images with outlined text (live text leaves emoji to the viewer, their fonts not embedded), EPS and legacy `.ai` images, Create Outlines images; not `sbix` duplicate, flipped, JPEG or TIFF glyphs, or `CBDT` BGRA or mask data, or ruby; in envelopes they warp as images do, and a Warp effect moves them without bending them. COLR (v0 and v1) done: a glyph's layers as vector art (solid colours, linear and radial gradients that pad, repeat or reflect (the [COLR specification](https://learn.microsoft.com/en-us/typography/opentype/spec/colr)'s extend modes), clip glyphs and boxes, blend modes, transforms, palette 0, the foreground colour from the run's fill, variable fonts at the face's instance), transformed with the type; drawn on the canvas, written as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as text in PDF (Type 3 glyphs), and Create Outlines makes groups of editable paths. Paints documents have no form for draw the glyph as an image (256 px per em): sweep gradients, Porter-Duff compositing, radial gradients from a circle that isn't the centre, radial gradients that repeat or reflect and start at a circle, gradients that repeat or reflect with a stop whose color isn't in the palette. Not ruby. COLR and SVG glyphs also go with type that geometry effects reshape (on the canvas and in exports), into Expand Appearance and EMF/WMF export, as their art, and bitmap glyphs as images. OpenType SVG done: a glyph's SVG document (its element `glyph<gid>`) is read by the SVG importer into vector art in font units, `currentColor` in the run's fill (and the legacy `context-fill` and `context-stroke` in its fill and stroke), a `viewBox` mapped onto the em square, and then drawn, exported and outlined as COLR glyphs are; in PDF real text such glyphs are text that draws nothing, with their art drawn where they sit. Images in glyph documents are in the art, holding their pixels until a document takes them in; text, scripts and embedded content are left out, as the table rules them out. Not CPAL palette variables. Evidence: `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::svg_glyph_images_draw_and_outline`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`, `tests_colourfonts::expand_appearance_keeps_the_colour_glyphs_of_type_with_effects`, `render::tests_colrglyphs::colour_glyphs_go_with_type_that_a_geometry_effect_reshapes`, `tests_bitmaps`, `tests_colr`, `render::tests_bitmapglyphs`, `render::tests_colrglyphs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/it/colour_glyphs.rs`, `eps` `colour_bitmap_glyphs_*` and `colr_glyphs_*`, `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups` |
| Every-line and Single-line composers | done | tuned by eye, never compared side by side |
| Optical Margin Alignment | done | Type › Optical Margin Alignment, `type.opticalMarginAlignment`: per type object, with one setting for all the frames of a thread, in point and area type of left-to-right paragraphs, and at the brackets of type on a path whose first paragraph runs left to right; right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, do not hang |
| System Layout | done | Character panel menu › System Layout, `type.systemLayout`: the whole document, off by default and for documents from before it, saved only when on. On, every text object advances by whole points (at least one when a character takes space). New point, area and path type, threaded frames, placed text, graph and dimension labels and pasted plain text inherit it. Evidence: `tests::system_layout_rounds_positive_advances_to_whole_points`, `typecmd::area_tests::system_layout_sets_the_document_and_every_text_object`, `panels::character::tests::system_layout_menu_item_toggles_the_document` |