mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 07:16:52 +00:00
Merge main into fix/edit-copy-artboard-route
This commit is contained in:
@@ -20,6 +20,12 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
@@ -60,7 +66,7 @@ jobs:
|
||||
PY
|
||||
|
||||
release:
|
||||
needs: git-sources
|
||||
needs: [git-sources, credits]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
@@ -72,6 +78,7 @@ jobs:
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
@@ -125,6 +125,13 @@ impl App {
|
||||
}
|
||||
open_files(&mut self.app, files);
|
||||
}
|
||||
let (now, zoom) = (ctx.input(|i| i.time), ctx.zoom_factor());
|
||||
if let Some(w) = &self.winit_window
|
||||
&& window::monitors_due(self.monitors_listed, now, zoom)
|
||||
{
|
||||
self.app.services.dock_monitors = window::dock_monitors(w, zoom);
|
||||
self.monitors_listed = Some((now, zoom));
|
||||
}
|
||||
self.app.tick(ctx);
|
||||
if self.pending_maximize {
|
||||
if let Some(w) = &self.winit_window {
|
||||
|
||||
@@ -298,13 +298,16 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
// up its Format menu before the sheet begins (#866); rfd shows the others.
|
||||
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
|
||||
// The dialog thread blocks here until the sheet answers.
|
||||
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
|
||||
None => {
|
||||
Ok(mac_save_format::Begin::Shown(picked)) => Box::pin(async move { picked.recv().unwrap_or_default() }),
|
||||
Ok(mac_save_format::Begin::DeferToRfd) => {
|
||||
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
|
||||
// made recent macOS versions dismiss the sheet at once (#1800).
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
// AppKit returned nil for the panel. rfd uses the same `+savePanel` and would panic
|
||||
// on that nil; treat the pick as cancelled instead (#5980).
|
||||
Err(()) => Box::pin(async move { Vec::new() }),
|
||||
},
|
||||
PickRequest::Folder => {
|
||||
let picked = dialog.pick_folder();
|
||||
|
||||
@@ -21,7 +21,7 @@ use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::rc::{Retained, Weak};
|
||||
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2::{ClassType, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
|
||||
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
@@ -108,21 +108,42 @@ fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
|
||||
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
|
||||
}
|
||||
|
||||
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
|
||||
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
|
||||
/// named file types, or `window` has no AppKit window; nothing is shown then.
|
||||
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
/// How [`begin`] decided to show a save sheet.
|
||||
#[derive(Debug)]
|
||||
pub enum Begin {
|
||||
/// The Format-menu sheet is open; wait on the receiver for the path (empty when canceled).
|
||||
Shown(Receiver<Vec<String>>),
|
||||
/// Fewer than two named file types: the caller should show an rfd panel instead.
|
||||
DeferToRfd,
|
||||
}
|
||||
|
||||
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu.
|
||||
///
|
||||
/// `Ok(Begin::Shown(…))` when the sheet is open. `Ok(Begin::DeferToRfd)` when the request has
|
||||
/// fewer than two named file types (or no AppKit window yet) so rfd should show the panel.
|
||||
/// `Err(())` when AppKit returned no panel: the caller must not call rfd, whose `+savePanel`
|
||||
/// panics on the same nil (#5980).
|
||||
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Result<Begin, ()> {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return Ok(Begin::DeferToRfd);
|
||||
};
|
||||
let filters = menu_formats(&pick.filters);
|
||||
if filters.is_empty() {
|
||||
return None;
|
||||
return Ok(Begin::DeferToRfd);
|
||||
}
|
||||
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
|
||||
let Some(RawWindowHandle::AppKit(handle)) = window.window_handle().ok().map(|h| h.as_raw()) else {
|
||||
return Ok(Begin::DeferToRfd);
|
||||
};
|
||||
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
|
||||
// window is open, and winit hands out the handle only while it is.
|
||||
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
|
||||
let parent = view.window()?;
|
||||
let panel = NSSavePanel::savePanel(mtm);
|
||||
let Some(parent) = view.window() else {
|
||||
return Ok(Begin::DeferToRfd);
|
||||
};
|
||||
let Some(panel) = new_panel(mtm) else {
|
||||
log::error!("macOS save panel unavailable: AppKit returned nil for NSSavePanel (#5980)");
|
||||
return Err(());
|
||||
};
|
||||
let selected = initial_format(&pick.name, &filters);
|
||||
set_allowed_types(&panel, &filters, selected);
|
||||
if let Some(folder) = &pick.folder {
|
||||
@@ -145,7 +166,23 @@ pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver
|
||||
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
|
||||
// until the sheet closes.
|
||||
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
|
||||
Some(rx)
|
||||
Ok(Begin::Shown(rx))
|
||||
}
|
||||
|
||||
/// An `NSSavePanel` without panicking when AppKit returns nil (#5980).
|
||||
///
|
||||
/// Prefers `-[NSSavePanel init]` (Apple's current Swift API) over `+[NSSavePanel savePanel]`,
|
||||
/// which objc2 turns into a panic on nil. Falls back to the class method, still as `Option`.
|
||||
fn new_panel(mtm: MainThreadMarker) -> Option<Retained<NSSavePanel>> {
|
||||
// SAFETY: designated initializer for a bare save panel; returns nil when AppKit refuses.
|
||||
let panel: Option<Retained<NSSavePanel>> = unsafe { msg_send![NSSavePanel::alloc(mtm), init] };
|
||||
if panel.is_some() {
|
||||
return panel;
|
||||
}
|
||||
// SAFETY: class method; returns an autoreleased panel or nil. Typed as Option so nil is not a
|
||||
// panic (unlike `NSSavePanel::savePanel`).
|
||||
let panel: Option<Retained<NSSavePanel>> = unsafe { msg_send![NSSavePanel::class(), savePanel] };
|
||||
panel
|
||||
}
|
||||
|
||||
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
|
||||
@@ -234,6 +271,16 @@ mod tests {
|
||||
assert_eq!(allowed_types(&filters(), 9), ["vectorcraft", "svg", "pdf"]);
|
||||
}
|
||||
|
||||
/// A nil AppKit panel must be `None`, not a panic. `NSSavePanel::savePanel` used to panic here
|
||||
/// (#5980); `new_panel` types the same message send as `Option`.
|
||||
#[test]
|
||||
fn a_null_save_panel_pointer_is_none_not_a_panic() {
|
||||
let null: *mut NSSavePanel = std::ptr::null_mut();
|
||||
// SAFETY: retaining a null object pointer is defined to yield None.
|
||||
let panel: Option<Retained<NSSavePanel>> = unsafe { Retained::retain(null) };
|
||||
assert!(panel.is_none());
|
||||
}
|
||||
|
||||
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
|
||||
#[test]
|
||||
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
|
||||
|
||||
@@ -111,6 +111,8 @@ struct App {
|
||||
pending_maximize: bool,
|
||||
/// Frames left to retry [`window::apply_pending_maximize`].
|
||||
maximize_tries: u8,
|
||||
/// The time and UI zoom of the last monitor list ([`window::monitors_due`]).
|
||||
monitors_listed: Option<(f64, f32)>,
|
||||
}
|
||||
|
||||
/// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s.
|
||||
@@ -505,6 +507,7 @@ fn main() -> std::process::ExitCode {
|
||||
winit_window,
|
||||
pending_maximize,
|
||||
maximize_tries: 8,
|
||||
monitors_listed: None,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved
|
||||
//! onto a monitor that is still connected.
|
||||
|
||||
use vectorcraft_ui_egui::DockMonitor;
|
||||
use vectorcraft_ui_egui::state::WindowGeometry;
|
||||
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
|
||||
|
||||
@@ -14,6 +15,9 @@ pub const MIN_SIZE: [f32; 2] = [800.0, 500.0];
|
||||
/// say how much of the screen they take): a monitor with less room opens it maximized.
|
||||
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
|
||||
|
||||
/// Seconds between readings of the monitor list, which picks up a monitor connected while the app runs.
|
||||
const MONITORS_EVERY: f64 = 1.0;
|
||||
|
||||
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Monitor {
|
||||
@@ -67,6 +71,15 @@ impl Monitor {
|
||||
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// This monitor for floating panels, in the points egui reports for a window on it: physical
|
||||
/// pixels divided by the monitor's scale factor and the UI zoom.
|
||||
fn docking(&self, name: Option<String>, zoom: f32) -> DockMonitor {
|
||||
let scale = self.scale * zoom;
|
||||
let [x, y] = self.pos;
|
||||
let [w, h] = self.size;
|
||||
DockMonitor { name, rect: [x, y, w, h].map(|v| v as f32 / scale), scale }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
|
||||
@@ -109,6 +122,33 @@ fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
|
||||
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
|
||||
}
|
||||
|
||||
/// Whether to list the monitors again: on the first frame, when the UI zoom changed, and at most
|
||||
/// every [`MONITORS_EVERY`] seconds while the app draws. `listed` holds the last list's time and zoom.
|
||||
pub fn monitors_due(listed: Option<(f64, f32)>, now: f64, zoom: f32) -> bool {
|
||||
listed.is_none_or(|(at, listed_zoom)| listed_zoom != zoom || now - at >= MONITORS_EVERY)
|
||||
}
|
||||
|
||||
/// The connected monitors for floating panels (`Services::dock_monitors`).
|
||||
pub fn dock_monitors(window: &winit::window::Window, zoom: f32) -> Vec<DockMonitor> {
|
||||
let monitors: Vec<_> = window.available_monitors().map(|m| (Monitor::new(m.position(), m.size(), m.scale_factor()), m.name())).collect();
|
||||
docking_list(&monitors, zoom, cfg!(target_os = "macos"))
|
||||
}
|
||||
|
||||
/// The list for floating panels, empty when `monitors` have different scales and `shared_points` is
|
||||
/// false. macOS has one desktop of points. Elsewhere a new window multiplies its saved position by the
|
||||
/// scale of the monitor it is created on, which can put a window saved on another scale off every
|
||||
/// monitor. With an empty list craft-ui opens the window on the main window's monitor.
|
||||
///
|
||||
/// A name that two monitors share is left out, so craft-ui finds those monitors by their rects: macOS
|
||||
/// can give two displays of one model the same name.
|
||||
fn docking_list(monitors: &[(Monitor, Option<String>)], zoom: f32, shared_points: bool) -> Vec<DockMonitor> {
|
||||
if !shared_points && monitors.windows(2).any(|pair| pair[0].0.scale != pair[1].0.scale) {
|
||||
return Vec::new();
|
||||
}
|
||||
let unique = |name: &String| monitors.iter().filter(|(_, other)| other.as_ref() == Some(name)).count() == 1;
|
||||
monitors.iter().map(|(monitor, name)| monitor.docking(name.clone().filter(unique), zoom)).collect()
|
||||
}
|
||||
|
||||
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
|
||||
/// geometry. On Linux the window may not be mapped yet, so [`apply_pending_maximize`] must run
|
||||
/// until the host reports maximized (fixes #3635).
|
||||
@@ -204,6 +244,51 @@ mod tests {
|
||||
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_panels_get_monitors_in_the_points_a_window_on_them_reports() {
|
||||
let dock = |m: Monitor, zoom| m.docking(Some("m".into()), zoom);
|
||||
let plain = dock(monitor(1920, 0, 1920, 1080, 1.0), 1.0);
|
||||
assert_eq!(plain, DockMonitor { name: Some("m".into()), rect: [1920.0, 0.0, 1920.0, 1080.0], scale: 1.0 });
|
||||
// A window at (3000, 200) on this monitor reports (1500, 100).
|
||||
let scaled = dock(monitor(1920, -200, 3840, 2160, 2.0), 1.0);
|
||||
assert_eq!(scaled, DockMonitor { name: Some("m".into()), rect: [960.0, -100.0, 1920.0, 1080.0], scale: 2.0 });
|
||||
// The UI zoom divides what egui reports as well.
|
||||
let zoomed = dock(monitor(-2560, 0, 2560, 1440, 1.0), 1.25);
|
||||
assert_eq!(zoomed, DockMonitor { name: Some("m".into()), rect: [-2048.0, 0.0, 2048.0, 1152.0], scale: 1.25 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitors_of_different_scales_are_listed_only_on_a_desktop_of_shared_points() {
|
||||
let named = |m: Monitor, name: &str| (m, Some(name.to_string()));
|
||||
let same = [named(monitor(0, 0, 1920, 1080, 1.5), "a"), named(monitor(1920, 0, 1920, 1080, 1.5), "b")];
|
||||
assert_eq!(docking_list(&same, 1.0, false).len(), 2);
|
||||
let mixed = [named(monitor(0, 0, 3840, 2160, 2.0), "a"), named(monitor(3840, 0, 1920, 1080, 1.0), "b")];
|
||||
assert_eq!(docking_list(&mixed, 1.0, false), Vec::new());
|
||||
assert_eq!(docking_list(&mixed, 1.0, true).len(), 2);
|
||||
assert_eq!(docking_list(&[], 1.0, false), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_two_monitors_share_is_left_out_of_the_list() {
|
||||
let list = [
|
||||
(monitor(0, 0, 1920, 1080, 1.0), Some("Monitor #1".to_string())),
|
||||
(monitor(1920, 0, 1920, 1080, 1.0), Some("Monitor #1".to_string())),
|
||||
(monitor(3840, 0, 1920, 1080, 1.0), Some("Monitor #2".to_string())),
|
||||
(monitor(5760, 0, 1920, 1080, 1.0), None),
|
||||
];
|
||||
let names: Vec<_> = docking_list(&list, 1.0, false).into_iter().map(|m| m.name).collect();
|
||||
assert_eq!(names, [None, None, Some("Monitor #2".to_string()), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_monitor_list_is_read_on_the_first_frame_then_once_a_second_or_on_a_zoom_change() {
|
||||
assert!(monitors_due(None, 0.0, 1.0));
|
||||
assert!(!monitors_due(Some((5.0, 1.0)), 5.0, 1.0));
|
||||
assert!(!monitors_due(Some((5.0, 1.0)), 5.5, 1.0));
|
||||
assert!(monitors_due(Some((5.0, 1.0)), 6.0, 1.0));
|
||||
assert!(monitors_due(Some((5.0, 1.0)), 5.5, 1.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_window_comes_back_where_it_was() {
|
||||
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
|
||||
|
||||
@@ -348,11 +348,14 @@ impl Document {
|
||||
t.cached_bounds = None;
|
||||
t.cached_baselines.clear();
|
||||
}
|
||||
let on_path = n.extras().objects_on_path.is_some();
|
||||
let mut renamed = std::collections::HashMap::new();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old: Vec<Arc<Node>> = std::mem::take(ch);
|
||||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||||
if on_path {
|
||||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||||
}
|
||||
}
|
||||
// An Objects on Path group names its path and its objects by id: the copy names the copies.
|
||||
if let Some(e) = n.extras_if_any()
|
||||
|
||||
@@ -669,6 +669,9 @@ impl SymbolOptions {
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Imported {
|
||||
relink: Relink,
|
||||
/// Whether relinking can change a pasted copy: a resource has another name or color in the
|
||||
/// document, or the objects link a graphic style.
|
||||
relinks: bool,
|
||||
/// Resources added to the document (renamed ones included).
|
||||
pub added: usize,
|
||||
/// Swatch conflicts resolved by merging.
|
||||
@@ -690,7 +693,9 @@ impl Imported {
|
||||
}
|
||||
/// Point a pasted copy at the document's resources.
|
||||
pub(crate) fn apply(&self, n: &mut Node) {
|
||||
self.relink.node(n);
|
||||
if self.relinks {
|
||||
self.relink.node(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,6 +827,7 @@ impl Clipboard {
|
||||
}
|
||||
vectorcraft_brush::store(d, &lib);
|
||||
}
|
||||
out.relinks = !(out.relink.renames.is_empty() && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,64 @@ fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A dimension whose live link [`end_links`] ended, as it was before.
|
||||
pub(crate) struct EndedLink {
|
||||
id: NodeId,
|
||||
recipe: Dimension,
|
||||
art: Vec<Arc<Node>>,
|
||||
}
|
||||
|
||||
/// The dimensions under `roots` or holding one of them. A dimension can be listed more than once.
|
||||
pub(crate) fn reached(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut ids = Vec::new();
|
||||
for n in roots.iter().filter_map(|r| doc.node(*r)) {
|
||||
n.walk(&mut |c| {
|
||||
if c.extras().dimension.is_some() {
|
||||
ids.push(c.id);
|
||||
}
|
||||
});
|
||||
if doc.dimensions {
|
||||
let holders = doc.ancestry(n.id).unwrap_or_default();
|
||||
ids.extend(holders.into_iter().filter(|id| doc.node(*id).is_some_and(|h| h.extras().dimension.is_some())));
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
impl EndedLink {
|
||||
/// Is the art this dimension measures, changed from `before` to `after`, still art it can link to?
|
||||
pub(crate) fn source_kept(&self, before: &Document, after: &Document) -> bool {
|
||||
!self.recipe.source.as_ref().is_some_and(|s| s.topology_changed(before, after)) && self.recipe.clone().resolve(after).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it
|
||||
/// measured. A command that replaces art calls this before it changes anything, since replacing
|
||||
/// the art a dimension measures would remove the dimension, and then [`restore_unchanged`].
|
||||
pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec<EndedLink> {
|
||||
let mut ended = Vec::new();
|
||||
for id in reached(doc, roots) {
|
||||
let Some(n) = doc.node_mut(id) else { continue };
|
||||
let art = n.children().cloned().unwrap_or_default();
|
||||
if let Some(recipe) = n.extras_mut().dimension.take() {
|
||||
ended.push(EndedLink { id, recipe: *recipe, art });
|
||||
}
|
||||
}
|
||||
ended
|
||||
}
|
||||
|
||||
/// The dimensions in `ended` whose art is as it was are live again.
|
||||
pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec<EndedLink>) {
|
||||
for e in ended {
|
||||
if doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art)
|
||||
&& let Some(n) = doc.node_mut(e.id)
|
||||
{
|
||||
n.extras_mut().dimension = Some(Box::new(e.recipe));
|
||||
doc.dimensions = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called inside every edit, so dimensions and their undo history stay in step with the art they
|
||||
/// measure. Returns at once in documents without dimensions.
|
||||
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
|
||||
|
||||
@@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cut",
|
||||
["Edit"],
|
||||
Some("Cmd+X"),
|
||||
"{selectedArtboards?: [n…], art?: bool} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut)",
|
||||
"{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut)",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
cut
|
||||
),
|
||||
@@ -148,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy",
|
||||
["Edit"],
|
||||
Some("Cmd+C"),
|
||||
"{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}",
|
||||
"{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
copy
|
||||
),
|
||||
@@ -707,11 +707,11 @@ fn takes_artboards(s: &Session, p: &Value) -> bool {
|
||||
s.tool_id() == "artboard" || p.get("selectedArtboards").is_some()
|
||||
}
|
||||
|
||||
/// Copy or Cut of artboards: `selectedArtboards` and `art` of `p`, or their defaults from the
|
||||
/// Artboard tool, which the journal entry records.
|
||||
/// Copy or Cut of artboards: `selectedArtboards`, `art` and `lockedAndHidden` of `p`, or their
|
||||
/// defaults from the Artboard tool, which the journal entry records.
|
||||
fn copy_artboards(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
|
||||
let given: serde_json::Map<String, Value> =
|
||||
["selectedArtboards", "art"].into_iter().filter_map(|key| p.get(key).map(|v| (key.to_string(), v.clone()))).collect();
|
||||
["selectedArtboards", "art", "lockedAndHidden"].into_iter().filter_map(|key| p.get(key).map(|v| (key.to_string(), v.clone()))).collect();
|
||||
let given = Value::Object(given);
|
||||
super::panelcmds::boards_and_art_noted(s, &given, if cut { "edit.cut" } else { "edit.copy" })?;
|
||||
super::panelcmds::artboard_copy(s, &given, cut)
|
||||
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
@@ -828,12 +828,17 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
let before = d.clone();
|
||||
let mut ended = super::dimension::end_links(d, &roots);
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut out = vec![];
|
||||
let threaded = super::threads::frames(d);
|
||||
for id in &roots {
|
||||
expand_node(d, *id, &brushes, &mut out);
|
||||
}
|
||||
// A dimension whose source was replaced stays plain: its link no longer fits that art.
|
||||
ended.retain(|e| e.source_kept(&before, d));
|
||||
super::dimension::restore_unchanged(d, ended);
|
||||
// Outlined frames of threaded text leave their threads with the text they show; type that
|
||||
// stays type under a new id takes its frame's place.
|
||||
super::threads::leave_threads(d, &threaded);
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! as Break Link to Symbol places it. A linear or radial gradient fill becomes a
|
||||
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
|
||||
//! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip
|
||||
//! group shaped like the object. Create Gradient Mesh shares the sampling.
|
||||
//! group shaped like the object. Create Gradient Mesh shares the sampling. A dimension whose art
|
||||
//! Expand changes becomes a plain group of that art.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
@@ -19,6 +20,7 @@ use vectorcraft_doc::pattern::{PatternDef, place_art};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes};
|
||||
|
||||
use super::dimension::EndedLink;
|
||||
use super::edit::selected_roots;
|
||||
use super::menucmds::{run_raw, squash};
|
||||
use super::object::as_clipping_path;
|
||||
@@ -41,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -239,8 +241,12 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let stroke = bool_or(p, "stroke", true);
|
||||
let to = GradientTo::parse(p)?;
|
||||
let from = s.doc()?.history.undo.len();
|
||||
let rev0 = s.doc()?.revision;
|
||||
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
// Before anything expands: outlining the art a dimension measures would remove the dimension.
|
||||
let before = s.doc()?.doc.clone();
|
||||
let mut ended = vec![];
|
||||
end_dimension_links(s, &mut ended)?;
|
||||
let rev0 = s.doc()?.revision;
|
||||
// Placed documents first: their art (type, strokes, gradients) expands below.
|
||||
if object && e.placed {
|
||||
let roots = selected_roots(s)?;
|
||||
@@ -286,6 +292,8 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| super::patterncmds::expand_repeats(d, &roots).map(|_| ()))?;
|
||||
}
|
||||
// The dimensions in the art those steps made.
|
||||
end_dimension_links(s, &mut ended)?;
|
||||
if object && e.effects {
|
||||
// Effects that Expand Appearance can't expand are left as they are. When it refuses to
|
||||
// leave a process color on a grayscale image with transparency, Expand fails as a whole.
|
||||
@@ -331,10 +339,28 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc()?.revision == rev0 {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
}
|
||||
if !ended.is_empty() {
|
||||
// A dimension whose source Expand replaced stays plain: its link no longer fits that art.
|
||||
let after = s.doc()?.doc.clone();
|
||||
ended.retain(|e| e.source_kept(&before, &after));
|
||||
s.edit("Expand", |d, _| {
|
||||
super::dimension::restore_unchanged(d, std::mem::take(&mut ended));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
squash(s, from, "Expand");
|
||||
Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Ends the live link of each dimension under or holding the selection, adding it to `ended`.
|
||||
fn end_dimension_links(s: &mut Session, ended: &mut Vec<EndedLink>) -> Result<()> {
|
||||
let roots = selected_roots(s)?;
|
||||
if !super::dimension::reached(&s.doc()?.doc, &roots).is_empty() {
|
||||
ended.extend(s.edit("Expand", |d, _| Ok(super::dimension::end_links(d, &roots)))?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drop_live(n: &mut Node) {
|
||||
if let NodeKind::Path { live, .. } = &mut n.kind {
|
||||
*live = None;
|
||||
|
||||
@@ -9,7 +9,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
|
||||
@@ -373,7 +373,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
Ok(o) => super::pdfimport::import(bytes, o, opts, format.id != "pdf"),
|
||||
Err(e) => Err(err(e)),
|
||||
};
|
||||
let read = restore_or_import(editing, || {
|
||||
let mut read = restore_or_import(editing, || {
|
||||
// Type all from the text document and nothing that needs the PDF part: the layers
|
||||
// alone, the PDF part not imported. Else what was read of the layers is kept for
|
||||
// reading them with the PDF part.
|
||||
@@ -440,6 +440,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
if !read.2 {
|
||||
file_info = opened.as_ref().ok().map(|o| o.file_info());
|
||||
}
|
||||
// A restored document opens on the artboard that the editing copy beside it names as
|
||||
// active, as a file read from that copy does (the restored document never holds one).
|
||||
if read.2
|
||||
&& let Some(private) = opened.as_ref().ok().and_then(|o| o.illustrator_data())
|
||||
{
|
||||
read.0.opened_artboard = vectorcraft_eps::ai_opened_artboard(&private, &read.0);
|
||||
}
|
||||
read
|
||||
}
|
||||
"dxf" => {
|
||||
|
||||
@@ -1072,7 +1072,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -148,6 +148,53 @@ fn an_ai_file_opens_with_its_active_artboard() {
|
||||
assert_eq!(d.opened_artboard, Some(d.artboards[1].id), "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` or `.ait` file VectorCraft wrote reopens, restored from the document it carries, on the
|
||||
/// artboard its editing copy names as active; without the copy, on the first artboard.
|
||||
#[test]
|
||||
fn a_restored_ai_file_opens_on_the_artboard_active_when_saved() {
|
||||
let mut doc = vectorcraft_doc::Document::new(100.0, 100.0);
|
||||
let first = doc.artboards[0].clone();
|
||||
for (i, id) in [(1.0, 7), (2.0, 9)] {
|
||||
let x0 = i * 150.0;
|
||||
doc.artboards.push(vectorcraft_doc::Artboard { id, rect: vectorcraft_geom::Rect::new(x0, 0.0, x0 + 100.0, 100.0), ..first.clone() });
|
||||
}
|
||||
let mut s = Session::new();
|
||||
// The view's artboard, one the document doesn't have, none, and the first.
|
||||
for (active, opened, reopened) in
|
||||
[(Some(7), None, Some(7)), (Some(99), Some(9), Some(9)), (None, Some(9), Some(9)), (Some(first.id), Some(9), None)]
|
||||
{
|
||||
for (cmd, format) in [("document.save", "ai"), ("file.saveAsTemplate", "ait")] {
|
||||
let mut d = doc.clone();
|
||||
d.opened_artboard = opened;
|
||||
s.add_document(d, None);
|
||||
s.doc_mut().unwrap().active_artboard = active;
|
||||
let saved = s.execute(cmd, &json!({"format": format})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let r = open(&mut s, &format!("again.{format}"), &bytes, json!({})).unwrap();
|
||||
assert_eq!(r["restored"], json!(true), "{r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.artboards.iter().map(|a| a.id).collect::<Vec<_>>(), [first.id, 7, 9]);
|
||||
assert_eq!(d.opened_artboard, reopened, "{format} {active:?} {opened:?}");
|
||||
}
|
||||
}
|
||||
// A file saved without its PDF part still has the editing copy.
|
||||
s.add_document(doc.clone(), None);
|
||||
s.doc_mut().unwrap().active_artboard = Some(7);
|
||||
let saved = s.execute("document.save", &json!({"format": "ai", "options": {"pdfCompatible": false}})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap();
|
||||
assert_eq!(r["restored"], json!(true), "{r}");
|
||||
assert_eq!(s.doc().unwrap().doc.opened_artboard, Some(7), "{r}");
|
||||
// Without the editing copy, the file reopens on the first artboard.
|
||||
s.add_document(doc.clone(), None);
|
||||
s.doc_mut().unwrap().active_artboard = Some(7);
|
||||
let saved = s.execute("document.save", &json!({"format": "ai", "options": {"editingData": false}})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap();
|
||||
assert_eq!(r["restored"], json!(true), "{r}");
|
||||
assert_eq!(s.doc().unwrap().doc.opened_artboard, None, "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` save names the view's active artboard in its editing copy, else the one the document
|
||||
/// opened on, else the first; the file read from that copy opens on it.
|
||||
#[test]
|
||||
|
||||
@@ -955,7 +955,7 @@ fn any_empty_artboards(d: &Document) -> bool {
|
||||
|
||||
fn has_empty_artboards(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if any_empty_artboards(&st.doc) { Ok(()) } else { Err("no empty artboards to delete".into()) }
|
||||
if st.any_empty_artboards(any_empty_artboards) { Ok(()) } else { Err("no empty artboards to delete".into()) }
|
||||
}
|
||||
|
||||
fn artboard_delete_empty(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -178,7 +178,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rearrange All Artboards…",
|
||||
["Object", "Artboards"],
|
||||
None,
|
||||
"{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (each object of a layer or sublayer moves with the artboard its center is on; locked and hidden art, and art on a locked artboard, only with prefs moveLockedWithArtboard), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's; a locked artboard stays where it is → {artboards, rows, columns}",
|
||||
"{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (each object of a layer or sublayer moves with the artboard its center is on; locked and hidden art, and art on a locked artboard, only with lockedAndHidden), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (default: prefs moveLockedWithArtboard), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's; a locked artboard stays where it is → {artboards, rows, columns}",
|
||||
has_doc,
|
||||
rearrange_artboards
|
||||
),
|
||||
@@ -1274,7 +1274,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||||
let move_art = switch_or(p, "moveArtwork", true, CMD)?;
|
||||
check_switches(p, &["patterns"], CMD)?;
|
||||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||||
let locked_and_hidden = super::panelcmds::locked_and_hidden(s, p, move_art, CMD)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
|
||||
@@ -1291,7 +1291,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// With moveArtwork, each top-level object (a child of a layer or sublayer) moves with the
|
||||
// artboard its center is on, with symbol instances measured by their art as
|
||||
// `Document::art_on_artboard` measures them; locked and hidden art, and art on a locked
|
||||
// artboard, moves only when prefs moveLockedWithArtboard is on.
|
||||
// artboard, moves only with `locked_and_hidden`.
|
||||
fn objects<'a>(n: &'a Node, all: bool, out: &mut Vec<&'a Node>) {
|
||||
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
|
||||
if c.is_layer() { objects(c, all, out) } else { out.push(c) }
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun};
|
||||
use vectorcraft_geom::{Rect, Vec2};
|
||||
|
||||
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices};
|
||||
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices, switch_or};
|
||||
use crate::{Clipboard, EngineError};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}",
|
||||
"{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (locked and hidden art goes too; default: prefs moveLockedWithArtboard)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art`, in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}",
|
||||
has_doc,
|
||||
artboard_duplicate
|
||||
),
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index? | indices?, selectedArtboards?, art?} (as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
"{index? | indices?, selectedArtboards?, art?, lockedAndHidden?} (defaults as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
boards_and_art_noted(s, p, "artboard.copy")?;
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cut Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index? | indices?, selectedArtboards?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
"{index? | indices?, selectedArtboards?, art?, lockedAndHidden?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless lockedAndHidden); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
boards_and_art_noted(s, p, "artboard.cut")?;
|
||||
@@ -252,14 +252,24 @@ pub(crate) fn refuse_locked(d: &vectorcraft_doc::Document, indices: &[usize]) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Does locked and hidden art go with an artboard (`lockedAndHidden` param, else prefs
|
||||
/// moveLockedWithArtboard)? Noted in the journal when `art` goes along, so a replay takes the same
|
||||
/// art whatever the preference is then.
|
||||
pub(crate) fn locked_and_hidden(s: &mut Session, p: &Value, art: bool, cmd: &str) -> Result<bool> {
|
||||
let all = switch_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard, cmd)?;
|
||||
if art {
|
||||
s.note_journal("lockedAndHidden", json!(all));
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
|
||||
/// ones only with prefs moveLockedWithArtboard), in paint order. With `moving` (a move or a cut),
|
||||
/// the art also on another locked artboard stays unless prefs moveLockedWithArtboard
|
||||
/// ([`vectorcraft_doc::Document::art_moving_with`]); a copy takes it.
|
||||
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Result<Vec<NodeId>> {
|
||||
/// ones only when `all`), in paint order. With `moving` (a move or a cut), the art also on another
|
||||
/// locked artboard stays unless `all` ([`vectorcraft_doc::Document::art_moving_with`]); a copy
|
||||
/// takes it.
|
||||
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, all: bool, moving: bool) -> Result<Vec<NodeId>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let all = s.prefs.move_locked_with_artboard;
|
||||
Ok(match (art, moving) {
|
||||
(false, _) => vec![],
|
||||
(true, false) => d.art_on_artboard(rect, all),
|
||||
@@ -269,11 +279,11 @@ pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Re
|
||||
|
||||
/// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object
|
||||
/// counted once, with the first of them it is on (artboards can overlap).
|
||||
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, moving: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
|
||||
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, all: bool, moving: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut arts = vec![];
|
||||
for &i in indices {
|
||||
let art = artboard_art(s, i, art, moving)?.into_iter().filter(|id| seen.insert(*id)).collect();
|
||||
let art = artboard_art(s, i, art, all, moving)?.into_iter().filter(|id| seen.insert(*id)).collect();
|
||||
arts.push((i, art));
|
||||
}
|
||||
Ok(arts)
|
||||
@@ -290,7 +300,8 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
|
||||
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &["patterns"], "artboard.duplicate")?;
|
||||
let (indices, art) = boards_and_art_noted(s, p, "artboard.duplicate")?;
|
||||
let arts = artboards_art(s, &indices, art, false)?;
|
||||
let locked_art = locked_and_hidden(s, p, art, "artboard.duplicate")?;
|
||||
let arts = artboards_art(s, &indices, art, locked_art, false)?;
|
||||
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() };
|
||||
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
|
||||
@@ -315,8 +326,10 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Artboard Copy / Cut: the artboards and their art onto the clipboard (pasting brings them all);
|
||||
/// a cut then deletes them.
|
||||
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
|
||||
let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
|
||||
let arts = artboards_art(s, &indices, art, cut)?;
|
||||
let cmd = if cut { "artboard.cut" } else { "artboard.copy" };
|
||||
let (indices, art) = boards_and_art(s, p, cmd)?;
|
||||
let locked_art = locked_and_hidden(s, p, art, cmd)?;
|
||||
let arts = artboards_art(s, &indices, art, locked_art, cut)?;
|
||||
let st = s.doc()?;
|
||||
if cut && st.doc.artboards.len() <= indices.len() {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
|
||||
@@ -8,15 +8,19 @@
|
||||
//! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't
|
||||
//! described key by key, so only the top level is checked.
|
||||
//!
|
||||
//! Callers outside the app (the control channel, MCP, the CLI) are answered with a parameter error
|
||||
//! for a key the description doesn't name ([`check_params`]): a misspelt `widht` no longer runs the
|
||||
//! command with its default. So are the `params` of an effect (#1592): `effect.apply` and
|
||||
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
|
||||
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
|
||||
//! commands a command runs, the steps of a `command.batch` or an `action.batch` included: a
|
||||
//! misspelled `widht` no longer runs the command with its default. So are the `params` of an
|
||||
//! effect (#1592): `effect.apply` and
|
||||
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
|
||||
//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes
|
||||
//! what it is given, so the journal of an older version still replays, except for an effect's
|
||||
//! `color` that the effect can't read and a Levels or Curves `channel` that the effect doesn't
|
||||
//! have, which `effect.apply` and `effect.setParams` refuse themselves ([`check_effect_color`],
|
||||
//! [`check_effect_channel`]).
|
||||
//! default, with a parameter error ([`check_effect_params`]).
|
||||
//!
|
||||
//! Saved steps play through [`Session::replay`] instead: a journal or an action recorded by an older
|
||||
//! version, with a key a command no longer reads or an effect parameter it no longer takes, still
|
||||
//! plays. Even there, an effect's `color` that the effect can't read and a Levels or Curves
|
||||
//! `channel` that the effect doesn't have are refused by `effect.apply` and `effect.setParams`
|
||||
//! themselves ([`check_effect_color`], [`check_effect_channel`]).
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -473,10 +477,16 @@ pub const UNCHECKED: &[&str] = &[
|
||||
"webExport.settings",
|
||||
];
|
||||
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` included, and
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included, and
|
||||
/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have
|
||||
/// passes: running it reports that.
|
||||
pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
check_params_in(id, params, false)
|
||||
}
|
||||
|
||||
/// [`check_params`] for a step of a batch when `in_batch`: batches can't nest, so the steps of a
|
||||
/// `command.batch` or `action.batch` step aren't checked (running it reports that), however deep they go.
|
||||
fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
|
||||
let Some(spec) = find_command(id) else { return Ok(()) };
|
||||
if let ("effect.apply" | "appearance.addEffect", Some(effect), Some(given)) =
|
||||
(id, params.get("effect").or_else(|| params.get("id")).and_then(Value::as_str), params.get("params"))
|
||||
@@ -487,10 +497,10 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
check_keys(id, spec.params, params)?;
|
||||
if id == "command.batch" {
|
||||
if matches!(id, "command.batch" | "action.batch") && !in_batch {
|
||||
for step in params.get("commands").and_then(Value::as_array).into_iter().flatten() {
|
||||
if let (Some(step_id), Some(step_params)) = (step.get("command").and_then(Value::as_str), step.get("params")) {
|
||||
check_params(step_id, step_params)?;
|
||||
check_params_in(step_id, step_params, true)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Outline Stroke",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{} replace stroked paths with filled outlines → {ids}",
|
||||
"{} replace stroked paths with filled outlines; a dimension whose lines are outlined, or selected with the art it measures when that art is outlined, becomes a plain group of its art → {ids}",
|
||||
has_selection,
|
||||
outline_stroke
|
||||
),
|
||||
@@ -980,6 +980,8 @@ pub(crate) fn replace_leaves(d: &mut Document, root: NodeId, mut f: impl FnMut(&
|
||||
fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Outline Stroke", |d, sel| {
|
||||
let before = d.clone();
|
||||
let mut ended = super::dimension::end_links(d, &roots);
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut new_sel = vec![];
|
||||
let mut changed = 0;
|
||||
@@ -988,6 +990,9 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
new_sel.push(id);
|
||||
changed += n;
|
||||
}
|
||||
// A dimension whose source was outlined stays plain: its link no longer fits that art.
|
||||
ended.retain(|e| e.source_kept(&before, d));
|
||||
super::dimension::restore_unchanged(d, ended);
|
||||
if changed == 0 {
|
||||
return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into()));
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Outlines",
|
||||
["Type"],
|
||||
Some("Cmd+Shift+O"),
|
||||
"{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows → {ids}",
|
||||
"{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}",
|
||||
has_selection,
|
||||
create_outlines
|
||||
),
|
||||
@@ -432,7 +432,7 @@ fn columns_hold(lines: &[vectorcraft_text::LineInfo], cols: usize, y0: f64, inse
|
||||
/// Transform panel and hit testing would fall back to [`TextObject::estimate_bounds`] (a rough
|
||||
/// 0.55 em per character) until the text is edited — the box comes up short and alignment looks off.
|
||||
pub(crate) fn refresh_bounds_of(d: &mut Document, ids: &[NodeId]) {
|
||||
for id in text_ids(d, ids) {
|
||||
for id in text_ids_where(d, ids, |t| t.cached_bounds.is_none()) {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
|
||||
&& t.cached_bounds.is_none()
|
||||
{
|
||||
@@ -498,11 +498,22 @@ fn overflow_note(ids: &[NodeId], left_out: &str) -> Option<String> {
|
||||
|
||||
/// Text objects among `ids` and their descendants.
|
||||
fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
text_ids_where(d, ids, |_| true)
|
||||
}
|
||||
|
||||
/// The text objects among `ids` and their descendants that `keep` accepts, each once, in the order
|
||||
/// they are found. All of `ids` are looked up in one walk of the tree.
|
||||
fn text_ids_where(d: &Document, ids: &[NodeId], keep: impl Fn(&TextObject) -> bool) -> Vec<NodeId> {
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
if let Some(n) = d.node(*id) {
|
||||
if let Some(n) = paths.get(id).and_then(|p| d.node_at(p)) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) {
|
||||
if let NodeKind::Text(t) = &c.kind
|
||||
&& keep(t)
|
||||
&& seen.insert(c.id)
|
||||
{
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
@@ -554,6 +565,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if texts.is_empty() {
|
||||
return Err(EngineError::Other("Create Outlines: select text objects".into()));
|
||||
}
|
||||
let ended = super::dimension::end_links(d, &roots);
|
||||
let mut new_sel: Vec<NodeId> = roots.iter().copied().filter(|r| !texts.contains(r)).collect();
|
||||
let mut out = vec![];
|
||||
let threaded = super::threads::frames(d);
|
||||
@@ -648,6 +660,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
new_sel.push(gid);
|
||||
}
|
||||
}
|
||||
super::dimension::restore_unchanged(d, ended);
|
||||
// Outlined frames of threaded text leave their threads with the text they show.
|
||||
super::threads::leave_threads(d, &threaded);
|
||||
sel.set(new_sel);
|
||||
|
||||
@@ -108,11 +108,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
/// Text objects among `ids` and their descendants.
|
||||
pub(crate) fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
if let Some(n) = d.node(*id) {
|
||||
if let Some(n) = paths.get(id).and_then(|p| d.node_at(p)) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) {
|
||||
if matches!(c.kind, NodeKind::Text(_)) && seen.insert(c.id) {
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} move artboards (and the art fully inside them; locked and hidden art, and art also on another locked artboard, only with prefs moveLockedWithArtboard), several in one step (an error when one is locked); `copy` (Alt-drag) leaves them and moves copies, which start unlocked → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}",
|
||||
"{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (default: prefs moveLockedWithArtboard)} move artboards (and the art fully inside them; locked and hidden art, and art also on another locked artboard, only with lockedAndHidden), several in one step (an error when one is locked); `copy` (Alt-drag) leaves them and moves copies, which start unlocked → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}",
|
||||
has_doc,
|
||||
artboard_move
|
||||
),
|
||||
@@ -384,13 +384,14 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let move_art = bool_or(p, "moveArt", false);
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let locked_art = super::panelcmds::locked_and_hidden(s, p, move_art, "artboard.move")?;
|
||||
if !copy {
|
||||
super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?;
|
||||
}
|
||||
// Each artboard's art, an object counted once (artboards can overlap).
|
||||
let mut arts: Vec<(usize, Vec<NodeId>)> = vec![];
|
||||
for &i in &indices {
|
||||
let art = super::panelcmds::artboard_art(s, i, move_art, !copy)?;
|
||||
let art = super::panelcmds::artboard_art(s, i, move_art, locked_art, !copy)?;
|
||||
let art = art.into_iter().filter(|id| !arts.iter().any(|(_, a)| a.contains(id))).collect();
|
||||
arts.push((i, art));
|
||||
}
|
||||
|
||||
@@ -102,6 +102,24 @@ pub struct DocState {
|
||||
/// paths the document links to are looked at within them later too, when the Links panel
|
||||
/// asks about a link outside any caller's scope.
|
||||
pub opened_by: Option<Arc<file_access::AutomationRoots>>,
|
||||
/// What [`DocState::any_empty_artboards`] last measured. Not saved.
|
||||
empty_artboards: std::cell::RefCell<EmptyArtboards>,
|
||||
}
|
||||
|
||||
/// Whether a document has an artboard for Delete Empty Artboards to delete, with the document that
|
||||
/// was measured.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct EmptyArtboards {
|
||||
/// The document measured. While this reference lasts, its allocation holds no other document,
|
||||
/// and `Arc::make_mut` moves a document it changes to a new allocation.
|
||||
doc: std::sync::Weak<Document>,
|
||||
any: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: how many times [`DocState::any_empty_artboards`] measured a document on this thread.
|
||||
pub(crate) static EMPTY_ARTBOARD_MEASURES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -280,8 +298,21 @@ impl DocState {
|
||||
import_losses: vec![],
|
||||
last_perspective: None,
|
||||
opened_by: file_access::current(),
|
||||
empty_artboards: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `measure`'s answer for the document now. `measure` runs once for each version of the document,
|
||||
/// so a menu that asks on every frame measures it once, and a change of the selection or the view
|
||||
/// keeps the answer.
|
||||
pub(crate) fn any_empty_artboards(&self, measure: impl FnOnce(&Document) -> bool) -> bool {
|
||||
let mut last = self.empty_artboards.borrow_mut();
|
||||
if !std::ptr::eq(last.doc.as_ptr(), Arc::as_ptr(&self.doc)) {
|
||||
#[cfg(test)]
|
||||
EMPTY_ARTBOARD_MEASURES.with(|c| c.set(c.get() + 1));
|
||||
*last = EmptyArtboards { doc: Arc::downgrade(&self.doc), any: measure(&self.doc) };
|
||||
}
|
||||
last.any
|
||||
}
|
||||
/// Unsaved changes: the document differs from the saved one (selection changes and changes that
|
||||
/// are not edits to save don't count, [`DocState::same_content`]).
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
|
||||
@@ -70,6 +70,9 @@ pub struct Session {
|
||||
/// cursor asked for again over an unchanged document and selection measures nothing again.
|
||||
pub(crate) preview_memo: vectorcraft_tools::select::PreviewMemo,
|
||||
depth: u32,
|
||||
/// Set while saved steps play ([`Session::replay`]): [`Session::execute`] then takes keys the
|
||||
/// command's params description doesn't name instead of refusing them.
|
||||
replaying: bool,
|
||||
/// Set when the active tool panicked (see [`guard`]); reported by the next tool event.
|
||||
tool_panic: Option<EngineError>,
|
||||
/// How many times switching or closing documents, or replacing the active one, has started the
|
||||
|
||||
@@ -44,6 +44,7 @@ impl Session {
|
||||
last_view: ViewInfo::default(),
|
||||
preview_memo: Default::default(),
|
||||
depth: 0,
|
||||
replaying: false,
|
||||
tool_panic: None,
|
||||
tool_resets: 0,
|
||||
tool_set_since_reset: false,
|
||||
|
||||
@@ -35,10 +35,14 @@ impl Session {
|
||||
/// Execute a command by id. This is THE entry point for every frontend.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
|
||||
// The crate's own tests are the audit of the params descriptions (#1442): a key a test
|
||||
// passes that its command's description doesn't name fails the test.
|
||||
#[cfg(test)]
|
||||
self.check_params(id, params)?;
|
||||
// A key the command's params description doesn't name is a parameter error (#1442), before
|
||||
// anything runs, for the commands other commands run too. A `command.batch` or an
|
||||
// `action.batch` checks its steps before the first one runs, and each step is checked again
|
||||
// as it runs, against the document the steps before it left (the effects `effect.setParams`
|
||||
// edits). Saved steps played with `replay` aren't checked.
|
||||
if !self.replaying {
|
||||
self.check_params(id, params)?;
|
||||
}
|
||||
// Objects given as `id` (where it stands for `ids` of one) or as `ids` beside it are checked
|
||||
// before anything else: a malformed one, or one naming no object, is an error under this
|
||||
// command's id, never the selection in its place. `effect.apply {id: "stylize.dropShadow"}`
|
||||
@@ -162,8 +166,8 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Record `key: value` in the running top-level command's journal entry (or its interaction's
|
||||
/// preview, or its step in a batch) unless its params give `key`: a value it resolved from the
|
||||
/// preferences or the clock.
|
||||
/// preview, or its step in a batch) unless its params give `key` (a null gives nothing): a value
|
||||
/// it resolved from the preferences or the clock.
|
||||
pub fn note_journal(&mut self, key: &str, value: Value) {
|
||||
if self.depth == self.note_depth {
|
||||
self.journal_note.insert(key.to_string(), value);
|
||||
@@ -190,6 +194,29 @@ impl Session {
|
||||
Ok((r?, noted))
|
||||
}
|
||||
|
||||
/// [`Session::execute`] for saved steps: a journal entry or an action's steps, recorded by this
|
||||
/// version or an older one. Keys a command's params description doesn't name (a key it no longer
|
||||
/// reads, an effect parameter it no longer takes) are taken, by the commands it runs too, so what
|
||||
/// was recorded still plays (#1442). Everything else is checked as `execute` checks it.
|
||||
pub fn replay(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let outer = self.set_replaying(true);
|
||||
let r = self.execute(id, params);
|
||||
self.set_replaying(outer);
|
||||
r
|
||||
}
|
||||
|
||||
/// Whether [`Session::execute`] plays saved steps as [`Session::replay`] does. A frontend that
|
||||
/// runs saved steps through its own path to `execute` sets it around that call, and puts back the
|
||||
/// value this returns, which it had before.
|
||||
pub fn set_replaying(&mut self, on: bool) -> bool {
|
||||
std::mem::replace(&mut self.replaying, on)
|
||||
}
|
||||
|
||||
/// Whether saved steps are playing ([`Session::set_replaying`]).
|
||||
pub fn replaying(&self) -> bool {
|
||||
self.replaying
|
||||
}
|
||||
|
||||
/// The journal from entry `start` on, as an action records it: without the params that only
|
||||
/// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now.
|
||||
pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> {
|
||||
@@ -208,9 +235,14 @@ impl Session {
|
||||
pub(super) fn noted(&mut self, params: &Value) -> Value {
|
||||
let mut note = std::mem::take(&mut self.journal_note);
|
||||
match params {
|
||||
// The params' own values win.
|
||||
// The params' own values win, except a null: it reads as a key left out, so the
|
||||
// noted value is the one the command resolved.
|
||||
Value::Object(m) if !note.is_empty() => {
|
||||
note.extend(m.clone());
|
||||
for (k, v) in m {
|
||||
if !v.is_null() || !note.contains_key(k) {
|
||||
note.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(note)
|
||||
}
|
||||
Value::Null if !note.is_empty() => Value::Object(note),
|
||||
|
||||
@@ -268,9 +268,24 @@ fn artboard_commands_record_the_selected_artboards_for_a_replay_and_not_for_an_a
|
||||
for (id, params, recorded, action) in [
|
||||
("artboard.switchOrientation", json!({}), json!({"selectedArtboards": [1], "scaleArt": false}), json!({"scaleArt": false})),
|
||||
("artboard.rename", json!({"name": "Back"}), json!({"name": "Back", "selectedArtboards": [1]}), json!({"name": "Back"})),
|
||||
("artboard.duplicate", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})),
|
||||
("artboard.cut", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})),
|
||||
("artboard.copy", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})),
|
||||
(
|
||||
"artboard.duplicate",
|
||||
json!({}),
|
||||
json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}),
|
||||
json!({"art": true, "lockedAndHidden": false}),
|
||||
),
|
||||
(
|
||||
"artboard.cut",
|
||||
json!({}),
|
||||
json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}),
|
||||
json!({"art": true, "lockedAndHidden": false}),
|
||||
),
|
||||
(
|
||||
"artboard.copy",
|
||||
json!({}),
|
||||
json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}),
|
||||
json!({"art": true, "lockedAndHidden": false}),
|
||||
),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
let start = second_artboard_selected(&mut s);
|
||||
@@ -303,14 +318,14 @@ fn artboard_commands_keep_a_named_artboard_and_note_a_batch_steps_artboards() {
|
||||
s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1, json!({"index": 0, "scaleArt": false}));
|
||||
s.execute("artboard.duplicate", &json!({"indices": [0]})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1, json!({"indices": [0], "art": true}));
|
||||
assert_eq!(s.journal.last().unwrap().1, json!({"indices": [0], "art": true, "lockedAndHidden": false}));
|
||||
let steps = json!([{"command": "artboard.switchOrientation", "params": {}}]);
|
||||
s.execute("command.batch", &json!({ "commands": steps })).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1["commands"][0]["params"], json!({"selectedArtboards": [1], "scaleArt": false}));
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s));
|
||||
let action = s.journal_for_action(start);
|
||||
assert_eq!(action[0].1, json!({"index": 0, "scaleArt": false}));
|
||||
assert_eq!(action[1].1, json!({"indices": [0], "art": true}));
|
||||
assert_eq!(action[1].1, json!({"indices": [0], "art": true, "lockedAndHidden": false}));
|
||||
assert_eq!(action[2].1["commands"][0]["params"], json!({"scaleArt": false}));
|
||||
}
|
||||
|
||||
@@ -348,7 +363,7 @@ fn edit_copy_and_cut_with_the_artboard_tool_replay_on_its_artboards() {
|
||||
let r = replayed(&s);
|
||||
assert_ne!(r.tool_id(), "artboard");
|
||||
assert_eq!(documents(&r), documents(&s), "{id} after {select}");
|
||||
assert_eq!(entry, (id.to_string(), json!({"selectedArtboards": [1], "art": true})), "{id} after {select}");
|
||||
assert_eq!(entry, (id.to_string(), json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false})), "{id} after {select}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,7 +377,7 @@ fn edit_copy_after_a_paste_with_the_artboard_tool_replays_on_the_pasted_artboard
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [3], "art": true}));
|
||||
assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [3], "art": true, "lockedAndHidden": false}));
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 5);
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s));
|
||||
@@ -390,7 +405,7 @@ fn edit_copy_and_cut_with_the_artboard_tool_record_nothing_for_an_action() {
|
||||
let start = second_artboard_selected(&mut s);
|
||||
let steps = json!([{"command": id, "params": {}}, {"command": "edit.paste", "params": {}}]);
|
||||
s.execute("command.batch", &json!({ "commands": steps })).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1["commands"][0]["params"], json!({"selectedArtboards": [1], "art": true}), "{id}");
|
||||
assert_eq!(s.journal.last().unwrap().1["commands"][0]["params"], json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), "{id}");
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s), "{id}");
|
||||
assert_eq!(s.journal_for_action(start)[0].1["commands"][0]["params"], json!({}), "{id}");
|
||||
}
|
||||
|
||||
@@ -485,6 +485,11 @@ fn artboard_commands_refuse_a_boolean_or_number_given_as_text() {
|
||||
("artboard.duplicate", "art", json!({"index": 0, "art": "false"})),
|
||||
("artboard.copy", "art", json!({"index": 0, "art": "true"})),
|
||||
("artboard.cut", "art", json!({"index": 1, "art": 0})),
|
||||
("artboard.move", "lockedAndHidden", json!({"index": 0, "dx": 10, "moveArt": true, "lockedAndHidden": "true"})),
|
||||
("artboard.duplicate", "lockedAndHidden", json!({"index": 0, "lockedAndHidden": "true"})),
|
||||
("artboard.copy", "lockedAndHidden", json!({"index": 0, "lockedAndHidden": 1})),
|
||||
("artboard.cut", "lockedAndHidden", json!({"index": 1, "lockedAndHidden": 0})),
|
||||
("artboard.rearrange", "lockedAndHidden", json!({"spacing": 100, "lockedAndHidden": "true"})),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(cmd, key, p)| not_a_switch(cmd, p, key))
|
||||
@@ -961,6 +966,86 @@ fn art_also_on_a_locked_artboard_stays_when_another_artboard_moves() {
|
||||
assert_eq!(ids(&moved["moved"]), [free.0, held.0], "with the preference on, it moves");
|
||||
}
|
||||
|
||||
/// Two artboards with the Artboard tool on the second, which holds a plain, a locked and a hidden
|
||||
/// rectangle, under prefs moveLockedWithArtboard `on`.
|
||||
fn locked_art_session(on: bool) -> Session {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
let mut shape = |x: u32| s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 40, "height": 20})).unwrap()["id"].clone();
|
||||
let (_, locked, hidden) = (shape(840), shape(900), shape(960));
|
||||
s.execute("object.lock", &json!({"ids": [locked]})).unwrap();
|
||||
s.execute("object.hide", &json!({"ids": [hidden]})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("artboard", ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("selected", &json!([1]));
|
||||
s.prefs.move_locked_with_artboard = on;
|
||||
s
|
||||
}
|
||||
|
||||
/// The bounds of every object in the document, and how many objects the clipboard holds.
|
||||
fn art_and_clipboard(s: &Session) -> (Vec<Option<vectorcraft_geom::Rect>>, usize) {
|
||||
let mut art = vec![];
|
||||
for layer in &s.doc().unwrap().doc.layers {
|
||||
layer.walk(&mut |n| {
|
||||
if !n.is_layer() {
|
||||
art.push(n.geometric_bounds());
|
||||
}
|
||||
});
|
||||
}
|
||||
(art, s.clipboard.nodes.len())
|
||||
}
|
||||
|
||||
/// A command that takes art with an artboard journals whether locked and hidden art went along, so
|
||||
/// a replay under the other preference takes the same art, in one undo step.
|
||||
#[test]
|
||||
fn artboard_commands_journal_the_locked_art_preference() {
|
||||
let run = |on: bool, cmd: &str, p: &serde_json::Value| {
|
||||
let mut s = locked_art_session(on);
|
||||
s.execute(cmd, p).unwrap();
|
||||
(art_and_clipboard(&s), s.journal.last().unwrap().clone())
|
||||
};
|
||||
let mut differ = vec![];
|
||||
for (cmd, p) in [
|
||||
("artboard.duplicate", json!({"index": 1, "art": true})),
|
||||
("artboard.copy", json!({"index": 1, "art": true})),
|
||||
("artboard.cut", json!({"index": 1, "art": true})),
|
||||
("artboard.move", json!({"index": 1, "dx": 0, "dy": 1000, "moveArt": true})),
|
||||
("artboard.move", json!({"index": 1, "dx": 0, "dy": 1000, "moveArt": true, "copy": true})),
|
||||
("artboard.rearrange", json!({"layout": "row", "spacing": 100})),
|
||||
("edit.copy", json!({})),
|
||||
("edit.cut", json!({})),
|
||||
// A null reads as a key left out.
|
||||
("artboard.duplicate", json!({"index": 1, "art": true, "lockedAndHidden": null})),
|
||||
("artboard.rearrange", json!({"layout": "row", "spacing": 100, "lockedAndHidden": null})),
|
||||
("edit.cut", json!({"lockedAndHidden": null})),
|
||||
] {
|
||||
let (with, (id_with, p_with)) = run(true, cmd, &p);
|
||||
let (without, (id_without, p_without)) = run(false, cmd, &p);
|
||||
assert_ne!(with, without, "{cmd} {p}: an omitted param reads the preference");
|
||||
if run(false, &id_with, &p_with).0 != with {
|
||||
differ.push(format!("{cmd} {p}, replayed with the preference off"));
|
||||
}
|
||||
if run(true, &id_without, &p_without).0 != without {
|
||||
differ.push(format!("{cmd} {p}, replayed with the preference on"));
|
||||
}
|
||||
|
||||
let mut s = locked_art_session(true);
|
||||
let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
s.execute(cmd, &p).unwrap();
|
||||
if !cmd.ends_with("copy") {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{cmd} {p}: one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, doc, "{cmd} {p}: undone");
|
||||
}
|
||||
}
|
||||
assert!(differ.is_empty(), "{differ:#?}");
|
||||
// With the Artboard tool chosen, a bad value is refused as it is by the command run.
|
||||
let mut s = locked_art_session(false);
|
||||
let calls: Vec<_> =
|
||||
["edit.copy", "edit.cut"].map(|cmd| crate::tests::not_a_switch(cmd, json!({"lockedAndHidden": "true"}), "lockedAndHidden")).to_vec();
|
||||
assert_eq!(crate::tests::not_refused(&mut s, &calls), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// A locked artboard keeps its place and size: `artboard.setProps` refuses a new rectangle but
|
||||
/// still renames it, `artboard.move`, `artboard.delete`, `artboard.cut` and `artboard.fitToArt`
|
||||
/// refuse it, and Rearrange leaves it and the art on it where they are. A copy of it starts
|
||||
@@ -1621,6 +1706,141 @@ fn delete_empty_artboards_enabled_state_matches_the_command_on_varied_documents(
|
||||
assert!(enabled_count > 5 && enabled_count < 55, "both answers come up: {enabled_count} of 60 enabled");
|
||||
}
|
||||
|
||||
/// Delete Empty Artboards' enabled state, as a menu asks for it, and how many times asking measured
|
||||
/// a document on this thread.
|
||||
fn delete_empty_enabled_counted(s: &Session) -> (bool, usize) {
|
||||
let spec = crate::cmd::command_specs().iter().find(|c| c.id == "artboard.deleteEmpty").unwrap();
|
||||
let measures = || crate::doc_state::EMPTY_ARTBOARD_MEASURES.with(std::cell::Cell::get);
|
||||
let before = measures();
|
||||
let enabled = (spec.enabled)(s).is_ok();
|
||||
(enabled, measures() - before)
|
||||
}
|
||||
|
||||
/// A menu asks on every frame while it is open: the first frame measures the document, and the
|
||||
/// others, a change of the selection among them, do not.
|
||||
#[test]
|
||||
fn delete_empty_artboards_check_measures_a_document_once() {
|
||||
let mut s = row_of_artboards(2);
|
||||
let art = box_at(&mut s, 10.0, 20.0);
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "the first frame");
|
||||
for _ in 0..10 {
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "a later frame");
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "after a change of the selection");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "after another change of the selection");
|
||||
}
|
||||
|
||||
/// The enabled state follows an edit, an undo, a redo, an added and a removed artboard, a locked
|
||||
/// artboard, and each change measures the document once.
|
||||
#[test]
|
||||
fn delete_empty_artboards_check_follows_each_change_to_the_document() {
|
||||
let mut s = row_of_artboards(2);
|
||||
box_at(&mut s, 10.0, 20.0);
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "artboard 2 is empty");
|
||||
|
||||
let on_second = box_at(&mut s, 410.0, 20.0);
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "an edit puts art on artboard 2");
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (false, 0));
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "Undo takes the art off artboard 2");
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0));
|
||||
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "Redo puts it back");
|
||||
|
||||
s.execute("select.set", &json!({"ids": [on_second.0]})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap();
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "an edit moves the art off artboard 2");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1));
|
||||
|
||||
s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 200, "height": 150})).unwrap();
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "a new artboard is empty");
|
||||
|
||||
s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap();
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "a locked artboard stays");
|
||||
s.execute("artboard.setProps", &json!({"index": 2, "locked": false})).unwrap();
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "unlocked, it can go");
|
||||
|
||||
s.execute("artboard.delete", &json!({"index": 2})).unwrap();
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "with the empty artboard removed, none is left");
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (false, 0));
|
||||
}
|
||||
|
||||
/// A change made to the document directly, with nothing else holding it and no new revision, is
|
||||
/// measured again too.
|
||||
#[test]
|
||||
fn delete_empty_artboards_check_follows_a_change_in_place() {
|
||||
let mut s = row_of_artboards(2);
|
||||
box_at(&mut s, 10.0, 20.0);
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1));
|
||||
let st = s.doc_mut().unwrap();
|
||||
assert_eq!(Arc::strong_count(&st.doc), 1, "the undo history holds the document before the edit");
|
||||
let revision = st.revision;
|
||||
Arc::make_mut(&mut st.doc).artboards.truncate(1);
|
||||
assert_eq!(st.revision, revision);
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "one artboard left");
|
||||
}
|
||||
|
||||
/// Each open document has its own answer: switching documents gives the other's without measuring
|
||||
/// again, and an edit to one leaves the other's alone.
|
||||
#[test]
|
||||
fn delete_empty_artboards_check_follows_a_document_switch() {
|
||||
let mut s = row_of_artboards(2);
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "two empty artboards: the second can go");
|
||||
s.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "the new document has one artboard");
|
||||
assert!(s.set_active(0));
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "the first document's answer stands");
|
||||
assert!(s.set_active(1));
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (false, 0), "the second document's answer stands");
|
||||
s.execute("artboard.new", &json!({"x": 400, "y": 0, "width": 200, "height": 150})).unwrap();
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "the second document has an empty artboard now");
|
||||
assert!(s.set_active(0));
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "the edit to the second document leaves the first's answer");
|
||||
}
|
||||
|
||||
/// On documents made from a fixed seed, after each of a run of changes, the enabled state is what
|
||||
/// measuring every artboard of the document gives.
|
||||
#[test]
|
||||
fn delete_empty_artboards_check_matches_measuring_after_varied_changes() {
|
||||
let mut seed: u64 = 0x0de1_e7e0_e3b7;
|
||||
let mut next = |n: u64| {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(seed >> 33) % n
|
||||
};
|
||||
let mut answers = [0usize; 2];
|
||||
let mut measured = 0;
|
||||
for _ in 0..30 {
|
||||
let mut s = row_of_artboards(1 + next(3) as usize);
|
||||
for _ in 0..12 {
|
||||
let artboards = s.doc().unwrap().doc.artboards.len();
|
||||
let index = next(artboards as u64);
|
||||
// A change can be refused (nothing to undo, a locked artboard): the state still has to match.
|
||||
let _ = match next(7) {
|
||||
0 => s.execute("shape.rectangle", &json!({"x": next(1200) as f64 - 50.0, "y": 10, "width": 20, "height": 20})),
|
||||
1 => s.execute("edit.undo", &json!({})),
|
||||
2 => s.execute("edit.redo", &json!({})),
|
||||
3 => s.execute("artboard.new", &json!({"x": 400 * (artboards + 1), "y": 0, "width": 200, "height": 150})),
|
||||
4 => s.execute("artboard.delete", &json!({"index": index})),
|
||||
5 => s.execute("artboard.setProps", &json!({"index": index, "locked": next(2) == 0})),
|
||||
_ => s.execute("select.all", &json!({})),
|
||||
};
|
||||
let (enabled, count) = delete_empty_enabled_counted(&s);
|
||||
measured += count;
|
||||
answers[usize::from(enabled)] += 1;
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(enabled, d.artboards.len() > 1 && d.artboards.iter().any(|a| !a.locked && !d.artboard_has_art(a.rect)));
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (enabled, 0), "a second frame");
|
||||
}
|
||||
}
|
||||
assert!(answers[0] > 30 && answers[1] > 30, "both answers come up: {answers:?}");
|
||||
assert!(measured < 360, "changes of the selection alone do not measure: {measured} of 360");
|
||||
}
|
||||
|
||||
/// On paths and compound paths made from a fixed seed (curves, open and closed, with strokes of
|
||||
/// every join, cap and alignment, miter limits, arrowheads, width profiles and dashes), an
|
||||
/// artboard has art exactly when an object's visual bounds meet it, for artboards just inside,
|
||||
|
||||
@@ -904,6 +904,31 @@ fn coordinate_moves_journal_the_locked_art_preference() {
|
||||
s.prefs.move_locked_with_artboard = false;
|
||||
s.execute(&id, &p).unwrap();
|
||||
assert_eq!(bounds(&s, locked), Rect::new(130.0, 100.0, 330.0, 200.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// A null reads as a key left out, and journals the value it resolved to.
|
||||
s.prefs.move_locked_with_artboard = true;
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "x": 30, "moveArt": true, "lockedAndHidden": null})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1["lockedAndHidden"], true);
|
||||
}
|
||||
|
||||
/// A drag with the Artboard tool journals whether locked art went along, so a replay under the
|
||||
/// other preference moves the same art.
|
||||
#[test]
|
||||
fn artboard_tool_drag_journals_the_locked_art_preference() {
|
||||
let mut s = session();
|
||||
let locked = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.prefs.move_locked_with_artboard = true;
|
||||
gesture(&mut s, "artboard", &[(400.0, 300.0), (420.0, 310.0), (450.0, 330.0), (450.0, 330.0)], Mods::default());
|
||||
let moved = Rect::new(150.0, 130.0, 200.0, 180.0);
|
||||
assert!(close(bounds(&s, locked), moved));
|
||||
let (id, p) = s.journal.last().unwrap().clone();
|
||||
assert_eq!((id.as_str(), &p["lockedAndHidden"]), ("artboard.move", &json!(true)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.prefs.move_locked_with_artboard = false;
|
||||
s.execute(&id, &p).unwrap();
|
||||
assert!(close(bounds(&s, locked), moved));
|
||||
}
|
||||
|
||||
/// Locked and hidden art scales only with Move Locked and Hidden Artwork with Artboard, as it
|
||||
|
||||
@@ -3043,6 +3043,36 @@ fn arb_area_options() -> impl Strategy<Value = String> {
|
||||
prop::collection::vec((keys, values), 0..8).prop_map(|kv| kv.iter().map(|(k, v)| format!("/{k} {v}")).collect::<Vec<_>>().join(" "))
|
||||
}
|
||||
|
||||
/// Type on a Path Options as a frame might keep them at key 11: nothing, a value that isn't a
|
||||
/// dictionary, or a dictionary of codes and flags of any value or type.
|
||||
fn arb_path_options() -> impl Strategy<Value = String> {
|
||||
let keys = prop::sample::select(vec!["0", "1", "2", "3", "4", "6", "7", "18", "22"]);
|
||||
let values = prop::sample::select(vec![
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"-1",
|
||||
"2.5",
|
||||
"1e400",
|
||||
"NaN",
|
||||
"inf",
|
||||
"(x)",
|
||||
"true",
|
||||
"false",
|
||||
"/Name",
|
||||
"[ 1 ]",
|
||||
"<< /0 1 >>",
|
||||
]);
|
||||
(prop::collection::vec((keys, values), 0..6), 0usize..3).prop_map(|(kv, shape)| match shape {
|
||||
0 => format!("/11 << {} >>", kv.iter().map(|(k, v)| format!("/{k} {v}")).collect::<Vec<_>>().join(" ")),
|
||||
1 => "/11 3".to_string(),
|
||||
_ => String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A text document of `kinds.len()` stories, story `i` in frame `i` of kind `kinds[i]` (0 point type,
|
||||
/// 1 area type, 2 type on a path, others a kind the reader doesn't know), with a line break inside
|
||||
/// its paragraph (`\x03`) at each of `soft` (by character, wrapping, the story's end included) and
|
||||
@@ -3192,6 +3222,18 @@ proptest! {
|
||||
survive(".ai text frames with hostile area options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// The type on a path of the same text document with hostile Type on a Path Options, opened as
|
||||
/// an EPS document and as a `.ai`'s editing data.
|
||||
#[test]
|
||||
fn text_frames_with_hostile_path_options_never_panic(options in arb_path_options()) {
|
||||
let text = frames_document_text().replacen("/6 [ 0.5 2.0 ]", &format!("/6 [ 0.5 2.0 ] {options}"), 1);
|
||||
let editing = text_editing_of(&text, FRAME_OBJECTS);
|
||||
let bytes = editing_eps(&editing);
|
||||
survive("EPS text frames with hostile path options", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?;
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&editing)].concat();
|
||||
survive(".ai text frames with hostile path options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// The same for vertical type and Tate-chu-yoko runs: orientations and baseline directions of
|
||||
/// any value, type or size.
|
||||
#[test]
|
||||
@@ -3260,6 +3302,23 @@ proptest! {
|
||||
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// The active artboard that a restored `.ai` opens on, read from damaged or hostile editing data.
|
||||
#[test]
|
||||
fn ai_active_artboard_never_panics(cut in 0usize..600, edits in prop::collection::vec((0usize..600, prop::sample::select(vec!['0', '9', '-', ' ', '\n', '/', ':', ';', ',', '%', '_'])), 0..12)) {
|
||||
let board = |x: u32| format!("%_/Dictionary :\n%_{x} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", x + 100);
|
||||
let text = format!(
|
||||
"%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n{}{}%_; (ArtboardArray) ,\n%_1 /Int (CropAreaActive) ,\n%_; /Recorded ,\n%_;\n%AI9_EndDocumentData\n",
|
||||
board(0),
|
||||
board(150)
|
||||
);
|
||||
let private = mutate_text(&text, cut, &edits).into_bytes();
|
||||
survive("hostile CropAreaActive", || {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
doc.opened_artboard = vectorcraft_eps::ai_opened_artboard(&private, &doc);
|
||||
Some(doc)
|
||||
})?;
|
||||
}
|
||||
|
||||
/// A `.ai` whose PDF part can't be read (#4366), its private data damaged or hostile, under
|
||||
/// any key and filter: it opens from its editing data, or with the error of its PDF part.
|
||||
#[test]
|
||||
|
||||
@@ -280,6 +280,348 @@ fn a_moved_linked_dimension_stays_where_it_was_dropped() {
|
||||
assert!((recipe(&s, id).offset - moved).abs() < 1e-6, "moved together");
|
||||
}
|
||||
|
||||
/// How many nodes a dimension's art holds, how many of them are type, and whether it has a recipe.
|
||||
fn art(s: &Session, id: u64) -> (usize, usize, bool) {
|
||||
let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap();
|
||||
let (mut nodes, mut labels) = (0, 0);
|
||||
n.walk(&mut |c| {
|
||||
nodes += 1;
|
||||
labels += usize::from(matches!(c.kind, NodeKind::Text(_)));
|
||||
});
|
||||
(nodes, labels, n.extras().dimension.is_some())
|
||||
}
|
||||
fn bounds(s: &Session, id: u64) -> [f64; 4] {
|
||||
let b = s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
[b.x0, b.y0, b.x1, b.y1]
|
||||
}
|
||||
/// The art of `id`, which holds no type and no recipe, follows a move, a scale, undo and redo as
|
||||
/// plain art does.
|
||||
fn stays_plain_art(s: &mut Session, id: u64) {
|
||||
let plain = art(s, id);
|
||||
assert_eq!(plain.1, 0, "no type left: {plain:?}");
|
||||
let at = bounds(s, id);
|
||||
run(s, "select.set", json!({"ids":[id]}));
|
||||
run(s, "object.move", json!({"dx":30,"dy":-20}));
|
||||
assert_eq!(art(s, id), plain, "after a move");
|
||||
let moved = bounds(s, id);
|
||||
for (k, (a, b)) in at.iter().zip(moved).enumerate() {
|
||||
let d = if k % 2 == 0 { 30.0 } else { -20.0 };
|
||||
assert!((b - a - d).abs() < 1e-6, "{at:?} moved to {moved:?}");
|
||||
}
|
||||
run(s, "object.scale", json!({"sx":200,"sy":100}));
|
||||
assert_eq!(art(s, id), plain, "after a scale");
|
||||
let scaled = bounds(s, id);
|
||||
assert!(((scaled[2] - scaled[0]) - 2.0 * (at[2] - at[0])).abs() < 1e-6, "{at:?} scaled to {scaled:?}");
|
||||
run(s, "edit.undo", json!({}));
|
||||
assert_eq!(art(s, id), plain, "after undoing the scale");
|
||||
run(s, "edit.redo", json!({}));
|
||||
assert_eq!(art(s, id), plain, "after redoing the scale");
|
||||
run(s, "edit.undo", json!({}));
|
||||
run(s, "edit.undo", json!({}));
|
||||
assert_eq!(art(s, id), plain);
|
||||
assert_eq!(bounds(s, id), at);
|
||||
assert!(!plain.2, "no recipe left");
|
||||
}
|
||||
|
||||
/// Object › Expand turns a dimension into plain art for good: it no longer follows the art it
|
||||
/// measured, and one undo gives the live dimension back.
|
||||
#[test]
|
||||
fn an_expanded_dimension_stays_plain_art() {
|
||||
let mut s = session();
|
||||
let line = edge(&mut s);
|
||||
let id = dim(&mut s, line);
|
||||
let other = dim(&mut s, line);
|
||||
let live = art(&s, id);
|
||||
assert_eq!((live.1, live.2), (1, true));
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
stays_plain_art(&mut s, id);
|
||||
let plain = art(&s, id);
|
||||
let at = bounds(&s, id);
|
||||
assert_eq!(art(&s, other), live, "the dimension left out of the selection stays live");
|
||||
// The art it measured no longer moves it, and deleting that art leaves it.
|
||||
run(&mut s, "select.set", json!({"ids":[line]}));
|
||||
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
|
||||
assert_eq!(text(&s, other), "20.00 in");
|
||||
assert_eq!((art(&s, id), bounds(&s, id)), (plain, at));
|
||||
run(&mut s, "edit.clear", json!({}));
|
||||
assert_eq!((art(&s, id), bounds(&s, id)), (plain, at));
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none());
|
||||
// Saved and opened again, it is still plain art.
|
||||
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let mut reopened = Session::new();
|
||||
reopened.add_document(doc, None);
|
||||
stays_plain_art(&mut reopened, id);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(art(&s, id), live, "one undo gives the live dimension back");
|
||||
assert_eq!(text(&s, id), "10.00 in");
|
||||
run(&mut s, "select.set", json!({"ids":[line]}));
|
||||
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
|
||||
assert_eq!(text(&s, id), "20.00 in", "linked again");
|
||||
}
|
||||
|
||||
/// Expand's options: any one that changes a dimension's art ends its link, and an Expand that
|
||||
/// changes nothing leaves it live.
|
||||
#[test]
|
||||
fn expand_options_that_change_a_dimension_end_its_link() {
|
||||
let mut s = session();
|
||||
let line = edge(&mut s);
|
||||
let id = dim(&mut s, line);
|
||||
let live = art(&s, id);
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
assert!(s.execute("object.expand", &json!({"object":false,"stroke":false})).is_err(), "no fills to expand");
|
||||
assert_eq!(art(&s, id), live);
|
||||
assert!(s.doc().unwrap().doc.dimensions);
|
||||
// Stroke alone leaves type as it is, so a stroked label on its own does not end the link.
|
||||
let label = {
|
||||
let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap();
|
||||
children.iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().id.0
|
||||
};
|
||||
run(&mut s, "paint.setStroke", json!({"ids":[label],"color":"#ff0000"}));
|
||||
run(&mut s, "select.set", json!({"ids":[label]}));
|
||||
assert!(s.execute("object.expand", &json!({"object":false,"fill":false})).is_err(), "no paths to outline");
|
||||
assert!(art(&s, id).2, "still live");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(art(&s, id), live);
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
// Stroke alone: the label stays type, in art that no longer redraws.
|
||||
run(&mut s, "object.expand", json!({"object":false,"fill":false}));
|
||||
let plain = art(&s, id);
|
||||
assert_eq!(plain.1, 1, "{plain:?}");
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
|
||||
assert_eq!(art(&s, id), plain);
|
||||
assert!(!plain.2, "no recipe left");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(art(&s, id), live);
|
||||
// Object alone: the lines stay stroked paths, in art that no longer redraws.
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
run(&mut s, "object.expand", json!({"stroke":false,"fill":false}));
|
||||
let plain = art(&s, id);
|
||||
assert_eq!(plain.1, 0, "{plain:?}");
|
||||
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
|
||||
assert_eq!(art(&s, id), plain);
|
||||
assert!(!plain.2, "no recipe left");
|
||||
}
|
||||
|
||||
/// An Expand that ends up changing no art fails and leaves the dimension live: Outline Stroke skips
|
||||
/// guides, here the dimension's lines.
|
||||
#[test]
|
||||
fn an_expand_that_changes_no_art_leaves_a_dimension_live() {
|
||||
let mut s = session();
|
||||
let line = edge(&mut s);
|
||||
let id = dim(&mut s, line);
|
||||
let lines: Vec<u64> = {
|
||||
let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap();
|
||||
children.iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).map(|c| c.id.0).collect()
|
||||
};
|
||||
run(&mut s, "select.set", json!({"ids":lines}));
|
||||
run(&mut s, "view.guides.make", json!({}));
|
||||
let live = art(&s, id);
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
assert!(s.execute("object.expand", &json!({"object":false,"fill":false})).is_err());
|
||||
assert_eq!(art(&s, id), live);
|
||||
assert!(live.2);
|
||||
// A stroked path selected with it expands, and the dimension's art stays live.
|
||||
let other = run(&mut s, "shape.rectangle", json!({"x":300,"y":300,"width":50,"height":50}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "paint.setStroke", json!({"ids":[other],"color":"#000000"}));
|
||||
let moved_with = s.doc().unwrap().doc.node_count();
|
||||
run(&mut s, "select.set", json!({"ids":[id, other]}));
|
||||
run(&mut s, "object.expand", json!({"object":false,"fill":false}));
|
||||
assert_ne!(s.doc().unwrap().doc.node_count(), moved_with, "the rectangle's stroke is outlined");
|
||||
assert_eq!(art(&s, id), live);
|
||||
}
|
||||
|
||||
/// A dimension selected with the art it measures, or held by a selected group, expands the same
|
||||
/// way: outlining that art does not remove it.
|
||||
#[test]
|
||||
fn a_dimension_expanded_with_its_art_or_in_a_group_stays_plain_art() {
|
||||
let mut s = session();
|
||||
let line = edge(&mut s);
|
||||
let id = dim(&mut s, line);
|
||||
run(&mut s, "select.set", json!({"ids":[line, id]}));
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
stays_plain_art(&mut s, id);
|
||||
let mut s = session();
|
||||
let line = edge(&mut s);
|
||||
let id = dim(&mut s, line);
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
run(&mut s, "object.group", json!({}));
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
stays_plain_art(&mut s, id);
|
||||
}
|
||||
|
||||
/// Expand replacing only the art a selected dimension measures leaves the dimension's art as it
|
||||
/// is, without a link to the art that went.
|
||||
#[test]
|
||||
fn a_dimension_keeps_no_link_to_the_art_expand_replaced() {
|
||||
for circle in [false, true] {
|
||||
let mut s = session();
|
||||
let shape = json!({"x":100,"y":100,"width":72,"height":72});
|
||||
let art_id = run(&mut s, if circle { "shape.ellipse" } else { "shape.rectangle" }, shape)["id"].as_u64().unwrap();
|
||||
run(&mut s, "paint.setFill", json!({"ids":[art_id],"gradient":{"kind":"linear"}}));
|
||||
let id = if circle {
|
||||
run(&mut s, "dimension.create", json!({"mode":"radius","source":{"kind":"circle","node":art_id}}))["id"].as_u64().unwrap()
|
||||
} else {
|
||||
dim(&mut s, art_id)
|
||||
};
|
||||
let (nodes, labels, _) = art(&s, id);
|
||||
run(&mut s, "select.set", json!({"ids":[art_id, id]}));
|
||||
run(&mut s, "object.expand", json!({"object":false,"stroke":false}));
|
||||
assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}");
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
|
||||
assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline Stroke and Expand Appearance replacing the art a selected dimension measures leave the
|
||||
/// dimension as a plain group, whether or not they change its own art.
|
||||
#[test]
|
||||
fn a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced() {
|
||||
for command in ["object.path.outlineStroke", "effect.expandAppearance"] {
|
||||
for whole in [true, false] {
|
||||
let mut s = session();
|
||||
let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"}));
|
||||
run(&mut s, "select.set", json!({"ids":[art_id]}));
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
let id = dim(&mut s, art_id);
|
||||
let lines: Vec<u64> = {
|
||||
let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap();
|
||||
children.iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).map(|c| c.id.0).collect()
|
||||
};
|
||||
run(&mut s, "select.set", json!({"ids":lines}));
|
||||
run(&mut s, "view.guides.make", json!({}));
|
||||
let picked = if whole { id } else { lines[0] };
|
||||
run(&mut s, "select.set", json!({"ids":[art_id, picked]}));
|
||||
run(&mut s, command, json!({}));
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(id)).is_some(), "{command} removed the dimension, whole: {whole}");
|
||||
let plain = art(&s, id);
|
||||
assert!(!plain.2, "{command}: no recipe left, whole: {whole}");
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
|
||||
assert_eq!(art(&s, id), plain, "{command}, whole: {whole}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A part of a dimension expanded on its own ends the link too, so the move does not undo it.
|
||||
#[test]
|
||||
fn expanding_a_part_of_a_dimension_ends_its_link() {
|
||||
let mut s = session();
|
||||
let line = edge(&mut s);
|
||||
let id = dim(&mut s, line);
|
||||
let part = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap()[0].id;
|
||||
run(&mut s, "select.set", json!({"ids":[part]}));
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
let plain = art(&s, id);
|
||||
assert_eq!(plain.1, 1, "{plain:?}");
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
|
||||
assert_eq!(art(&s, id), plain);
|
||||
assert!(!plain.2, "no recipe left");
|
||||
}
|
||||
|
||||
/// The instance of a symbol whose art is a dimension expands to art that no longer redraws.
|
||||
#[test]
|
||||
fn a_dimension_in_an_expanded_symbol_instance_stays_plain_art() {
|
||||
let mut s = session();
|
||||
let id = run(&mut s, "dimension.create", json!({"a":[100,100],"b":[172,100],"options":{"units":"pt","precision":0}}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
let instance = run(&mut s, "symbol.new", json!({"name":"Measure"}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "select.set", json!({"ids":[instance]}));
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
let root = s.doc().unwrap().selection.objects[0].0;
|
||||
let mut recipes = 0;
|
||||
s.doc().unwrap().doc.node(NodeId(root)).unwrap().walk(&mut |c| recipes += usize::from(c.extras().dimension.is_some()));
|
||||
assert_eq!(recipes, 0);
|
||||
stays_plain_art(&mut s, root);
|
||||
}
|
||||
|
||||
/// Create Outlines on a dimension, or on its label alone, ends the link in the same undo step.
|
||||
#[test]
|
||||
fn outlining_a_dimension_or_its_label_ends_its_link() {
|
||||
for label_alone in [false, true] {
|
||||
let mut s = session();
|
||||
let line = edge(&mut s);
|
||||
let id = dim(&mut s, line);
|
||||
let live = art(&s, id);
|
||||
let label = {
|
||||
let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap();
|
||||
children.iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().id
|
||||
};
|
||||
let target = if label_alone { label.0 } else { id };
|
||||
run(&mut s, "select.set", json!({"ids":[target]}));
|
||||
run(&mut s, "type.createOutlines", json!({}));
|
||||
stays_plain_art(&mut s, id);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(art(&s, id), live, "one undo gives the live dimension back");
|
||||
assert_eq!(text(&s, id), "10.00 in");
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline Stroke on a dimension ends its link: the label stays type and the art does not redraw.
|
||||
#[test]
|
||||
fn outlining_a_dimensions_strokes_ends_its_link() {
|
||||
let mut s = session();
|
||||
let line = edge(&mut s);
|
||||
let id = dim(&mut s, line);
|
||||
let live = art(&s, id);
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
run(&mut s, "object.path.outlineStroke", json!({}));
|
||||
let plain = art(&s, id);
|
||||
assert_eq!(plain.1, 1, "{plain:?}");
|
||||
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
|
||||
assert_eq!(art(&s, id), plain);
|
||||
assert!(!plain.2, "no recipe left");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(art(&s, id), live, "one undo gives the live dimension back");
|
||||
}
|
||||
|
||||
/// Expand Appearance, which the menus offer for a dimension in place of Expand, ends its link too.
|
||||
#[test]
|
||||
fn expanding_a_dimensions_appearance_ends_its_link() {
|
||||
let mut s = session();
|
||||
let line = edge(&mut s);
|
||||
let id = dim(&mut s, line);
|
||||
let live = art(&s, id);
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], true);
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
stays_plain_art(&mut s, id);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(art(&s, id), live, "one undo gives the live dimension back");
|
||||
assert_eq!(text(&s, id), "10.00 in");
|
||||
}
|
||||
|
||||
/// Ungroup takes the group that holds the recipe away, so the parts stay ordinary art.
|
||||
#[test]
|
||||
fn an_ungrouped_dimension_stays_ordinary_art() {
|
||||
let mut s = session();
|
||||
let line = edge(&mut s);
|
||||
let id = dim(&mut s, line);
|
||||
let (nodes, ..) = art(&s, id);
|
||||
run(&mut s, "select.set", json!({"ids":[id]}));
|
||||
run(&mut s, "object.ungroup", json!({}));
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(id)).is_none());
|
||||
let parts = s.doc().unwrap().selection.objects.clone();
|
||||
assert_eq!(parts.len(), nodes - 1);
|
||||
let boxes = |s: &Session| parts.iter().map(|p| s.doc().unwrap().doc.node(*p).unwrap().geometric_bounds().unwrap()).collect::<Vec<_>>();
|
||||
let at = boxes(&s);
|
||||
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
|
||||
for (a, b) in at.iter().zip(boxes(&s)) {
|
||||
assert!((b.x0 - a.x0 - 30.0).abs() < 1e-6 && (b.y0 - a.y0).abs() < 1e-6, "{a:?} moved to {b:?}");
|
||||
}
|
||||
run(&mut s, "select.set", json!({"ids":[line]}));
|
||||
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
|
||||
assert!(!s.doc().unwrap().doc.dimensions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_options_are_persistent_and_invalid_updates_report_errors() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -22,6 +22,7 @@ mod known_bugs;
|
||||
mod lossy_overwrite;
|
||||
mod model_based;
|
||||
mod param_keys;
|
||||
mod paste_many;
|
||||
mod pen_click_after_smooth;
|
||||
mod pen_endpoint_picking;
|
||||
mod ruler_origin;
|
||||
|
||||
@@ -1,24 +1,112 @@
|
||||
//! A caller's params are checked against the command's description, but a journal replays as it
|
||||
//! was recorded (#1442).
|
||||
//! `Session::execute` checks params against the command's description, but a journal replays as
|
||||
//! it was recorded (#1442).
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::{Session, check_params};
|
||||
|
||||
/// `Session::execute` takes what it is given: the journal of an older version, an action recorded
|
||||
/// with a key a command no longer reads, still plays. The check is the caller's.
|
||||
/// The open document's art and undo history, and the session's journal length.
|
||||
fn state(s: &Session) -> (Option<vectorcraft_doc::Document>, usize, usize) {
|
||||
let st = s.active();
|
||||
(st.map(|st| (*st.doc).clone()), st.map_or(0, |st| st.history.undo.len()), s.journal.len())
|
||||
}
|
||||
|
||||
/// `Session::execute` answers a key the command's description doesn't name with a parameter
|
||||
/// error, in a release build too, and the document, its undo history and the journal stay as
|
||||
/// they were.
|
||||
#[test]
|
||||
fn execute_refuses_a_key_its_command_does_not_name() {
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("file.new", &json!({"width": 300, "widht": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("widht"), "{e}");
|
||||
assert!(s.active().is_none());
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let before = state(&s);
|
||||
let e = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40, "cornerStyle": "round"})).unwrap_err();
|
||||
assert!(e.to_string().contains("cornerStyle"), "{e}");
|
||||
assert!(state(&s) == before);
|
||||
}
|
||||
|
||||
/// A step of a `command.batch` with such a key fails the batch before any step runs, and a step
|
||||
/// whose `effect.setParams` gives an effect a parameter it doesn't take fails it too, though that
|
||||
/// effect is only there once the steps before it ran. Nothing the batch did stays.
|
||||
#[test]
|
||||
fn execute_refuses_a_batch_step_with_a_key_its_command_does_not_name() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let before = state(&s);
|
||||
let rect = json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 40}});
|
||||
let bad_step = json!({"commands": [rect, {"command": "object.move", "params": {"dx": 5, "dyy": 5}}]});
|
||||
let e = s.execute("command.batch", &bad_step).unwrap_err().to_string();
|
||||
assert!(e.contains("dyy"), "{e}");
|
||||
assert!(state(&s) == before);
|
||||
let bad_effect = json!({"commands": [
|
||||
rect,
|
||||
{"command": "effect.apply", "params": {"effect": "blur.gaussian"}},
|
||||
{"command": "effect.setParams", "params": {"index": 0, "params": {"blurr": 3}}},
|
||||
]});
|
||||
assert!(check_params("command.batch", &bad_effect).is_ok());
|
||||
let e = s.execute("command.batch", &bad_effect).unwrap_err().to_string();
|
||||
assert!(e.contains("blurr"), "{e}");
|
||||
assert!(state(&s) == before);
|
||||
}
|
||||
|
||||
/// The steps of a `command.batch` step aren't checked, however deep they go: batches can't nest,
|
||||
/// which running the batch reports.
|
||||
#[test]
|
||||
fn the_steps_of_a_nested_batch_are_not_checked() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let mut batch = json!({"commands": [{"command": "object.move", "params": {"dyy": 5}}]});
|
||||
assert!(check_params("command.batch", &batch).is_err());
|
||||
for _ in 0..3 {
|
||||
batch = json!({"commands": [{"command": "command.batch", "params": batch}]});
|
||||
}
|
||||
assert!(check_params("command.batch", &batch).is_ok());
|
||||
let e = s.execute("command.batch", &batch).unwrap_err().to_string();
|
||||
assert!(e.contains("batches cannot nest"), "{e}");
|
||||
}
|
||||
|
||||
/// An `action.batch` with a step that has such a key fails before it opens a source file or runs
|
||||
/// a step.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn execute_refuses_an_action_batch_step_with_a_key_its_command_does_not_name() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-param-keys-action-batch-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="10" height="10"/></svg>"#;
|
||||
std::fs::write(dir.join("a.svg"), svg).unwrap();
|
||||
let mut s = Session::new();
|
||||
let batch = json!({
|
||||
"source": "folder",
|
||||
"folder": dir.to_string_lossy(),
|
||||
"commands": [{"command": "select.all", "params": {}}, {"command": "object.move", "params": {"dx": 5, "dyy": 5}}],
|
||||
});
|
||||
let e = s.execute("action.batch", &batch).unwrap_err().to_string();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert!(e.contains("dyy"), "{e}");
|
||||
assert!(s.documents().is_empty());
|
||||
assert!(s.journal.is_empty());
|
||||
}
|
||||
|
||||
/// `Session::replay` takes what it is given: the journal of an older version, an action recorded
|
||||
/// with a key a command no longer reads, still plays, as a step of a `command.batch` too.
|
||||
#[test]
|
||||
fn a_journal_with_a_key_no_longer_read_still_replays() {
|
||||
let old = [
|
||||
("file.new", json!({"width": 300, "height": 200, "widht": 1})),
|
||||
("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40, "cornerStyle": "round"})),
|
||||
("command.batch", json!({"commands": [{"command": "object.move", "params": {"dx": 5, "dy": 5, "dyy": 5}}]})),
|
||||
];
|
||||
let mut s = Session::new();
|
||||
for (id, params) in &old {
|
||||
assert!(check_params(id, params).is_err(), "{id}");
|
||||
s.execute(id, params).unwrap();
|
||||
s.replay(id, params).unwrap();
|
||||
}
|
||||
assert!(!s.replaying());
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!((st.doc.artboards[0].rect.width(), st.doc.layers[0].children().unwrap().len()), (300.0, 1));
|
||||
assert_eq!(s.journal.len(), 3);
|
||||
assert!(s.execute("object.move", &json!({"dx": 5, "dyy": 5})).is_err());
|
||||
}
|
||||
|
||||
/// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't
|
||||
@@ -34,7 +122,8 @@ fn effect_params_no_longer_taken_still_replay_and_open() {
|
||||
];
|
||||
for (id, params) in &old {
|
||||
assert!(s.check_params(id, params).is_err(), "{id}");
|
||||
s.execute(id, params).unwrap();
|
||||
assert!(s.execute(id, params).is_err(), "{id}");
|
||||
s.replay(id, params).unwrap();
|
||||
}
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Paste of many objects: what lands in the document (nodes, ids, names, order, selection, one undo
|
||||
//! step), and the shortcuts the paste takes for objects that have nothing to relink or rename.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ids_of(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
id_of(&run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 8, "height": 8})))
|
||||
}
|
||||
|
||||
/// Three named groups of three named rectangles each, on the document's only layer.
|
||||
fn named_groups() -> Session {
|
||||
let mut doc = Document::new(400.0, 400.0);
|
||||
let layer = doc.layers[0].id;
|
||||
for g in 0..3 {
|
||||
let group = doc.alloc_id();
|
||||
let mut node = Node::group(group, vec![]);
|
||||
node.name = Some(format!("group {g}"));
|
||||
doc.insert(Some(layer), usize::MAX, node).unwrap();
|
||||
for r in 0..3 {
|
||||
let x = f64::from(g * 3 + r) * 10.0;
|
||||
let shape = vectorcraft_geom::rect_from_points(Point::new(x, 0.0), Point::new(x + 8.0, 8.0));
|
||||
let appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0);
|
||||
let id = doc.alloc_id();
|
||||
let mut node = Node::path(id, vectorcraft_geom::shapes::rectangle(shape), appearance);
|
||||
node.name = Some(format!("rect {g}.{r}"));
|
||||
doc.insert(Some(group), usize::MAX, node).unwrap();
|
||||
}
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, None);
|
||||
s
|
||||
}
|
||||
|
||||
/// `n` and its descendants in tree order, as (id, name).
|
||||
fn tree(n: &Node) -> Vec<(NodeId, Option<String>)> {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| out.push((c.id, c.name.clone())));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paste_of_groups_gives_the_same_nodes_new_ids_in_tree_order_and_one_undo_step() {
|
||||
let mut s = named_groups();
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
let (first_id, steps, before) = (st.doc.peek_next_id(), st.history.undo.len(), st.doc.clone());
|
||||
let sources: Vec<NodeId> = st.selection.objects.clone();
|
||||
|
||||
let pasted = run(&mut s, "edit.paste", json!({"dx": 5, "dy": 7}));
|
||||
let ids = ids_of(&pasted);
|
||||
let st = s.doc().unwrap();
|
||||
|
||||
// The copies, on top of the layer in the order of their sources, are selected.
|
||||
assert_eq!(ids.len(), sources.len());
|
||||
assert_eq!(st.selection.objects, ids);
|
||||
let top: Vec<NodeId> = st.doc.layers[0].children().unwrap().iter().map(|c| c.id).collect();
|
||||
assert_eq!(top[sources.len()..], ids[..]);
|
||||
assert_eq!(top[..sources.len()], sources[..]);
|
||||
|
||||
// Names and geometry match the sources; ids run on from the counter in tree order.
|
||||
let mut next = first_id;
|
||||
for (source, copy) in sources.iter().zip(&ids) {
|
||||
let (source, copy) = (st.doc.node(*source).unwrap(), st.doc.node(*copy).unwrap());
|
||||
let (a, b) = (tree(source), tree(copy));
|
||||
assert_eq!(a.len(), b.len());
|
||||
for ((_, name_a), (id_b, name_b)) in a.iter().zip(&b) {
|
||||
assert_eq!(name_a, name_b);
|
||||
assert_eq!(id_b.0, next);
|
||||
next += 1;
|
||||
}
|
||||
let (from, to) = (source.geometric_bounds().unwrap(), copy.geometric_bounds().unwrap());
|
||||
assert!((to.x0 - from.x0 - 5.0).abs() < 1e-9 && (to.y0 - from.y0 - 7.0).abs() < 1e-9, "{from:?} {to:?}");
|
||||
}
|
||||
assert_eq!(st.doc.peek_next_id(), next);
|
||||
|
||||
// One undo step, back to the document as it was.
|
||||
assert_eq!(st.history.undo.len(), steps + 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), steps);
|
||||
assert_eq!(*st.doc, *before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_type_in_a_group_gets_its_layout_bounds() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 500, "height": 200}));
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="500" height="200"><g><text x="0" y="40" font-family="Source Sans 3" font-size="20">WWW</text><text x="0" y="80" font-family="Source Sans 3" font-size="20">MMM</text></g></svg>"#;
|
||||
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
let mut checked = 0;
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
assert!(t.cached_bounds.is_some(), "{:?} has no layout bounds", n.id);
|
||||
checked += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(checked, 2);
|
||||
}
|
||||
|
||||
/// A group holding point type and a rectangle: (the group, the type).
|
||||
fn group_with_type() -> (Session, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 500, "height": 200}));
|
||||
let text = id_of(&run(&mut s, "text.create", json!({"x": 10, "y": 40, "text": "a", "size": 12})));
|
||||
let other = rect(&mut s, 100.0);
|
||||
run(&mut s, "select.set", json!({"ids": [text.0, other.0]}));
|
||||
let group = id_of(&run(&mut s, "object.group", json!({})));
|
||||
(s, group, text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_replace_given_a_group_and_type_in_it_changes_that_type_once() {
|
||||
let (mut s, group, text) = group_with_type();
|
||||
// Replacing "a" with "aa" twice would leave "aaaa".
|
||||
run(&mut s, "edit.findReplace", json!({"find": "a", "replace": "aa", "ids": [group.0, text.0, group.0]}));
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let NodeKind::Text(t) = &doc.node(text).unwrap().kind else { panic!("the type is gone") };
|
||||
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<String>(), "aa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_type_command_given_a_group_and_type_in_it_lists_that_type_once() {
|
||||
let (mut s, group, text) = group_with_type();
|
||||
let changed = run(&mut s, "type.orientation.vertical", json!({"ids": [group.0, text.0, group.0]}));
|
||||
assert_eq!(ids_of(&changed), vec![text]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_objects_on_path_group_names_its_own_copies() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 500, "height": 200}));
|
||||
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 40.0));
|
||||
let path = id_of(&run(&mut s, "shape.line", json!({"x1": 0, "y1": 100, "x2": 300, "y2": 100})));
|
||||
let group = id_of(&run(&mut s, "object.objectsOnPath.attach", json!({"path": path.0, "ids": [a.0, b.0]})));
|
||||
|
||||
// Inside another group too: every level of a copy gets new ids.
|
||||
let plain = rect(&mut s, 200.0);
|
||||
run(&mut s, "select.set", json!({"ids": [group.0, plain.0]}));
|
||||
let outer = id_of(&run(&mut s, "object.group", json!({})));
|
||||
run(&mut s, "select.set", json!({"ids": [outer.0]}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let pasted = ids_of(&run(&mut s, "edit.paste", json!({})));
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let copy = doc.node(pasted[0]).unwrap().children().unwrap().iter().find(|c| c.extras().objects_on_path.is_some()).unwrap();
|
||||
let rec = copy.extras().objects_on_path.as_deref().unwrap();
|
||||
let children: HashSet<NodeId> = copy.children().unwrap().iter().map(|c| c.id).collect();
|
||||
assert!(children.contains(&rec.path), "the path {:?} is not in the copy", rec.path);
|
||||
assert_ne!(rec.path, path);
|
||||
assert_eq!(rec.members.len(), 2);
|
||||
for m in &rec.members {
|
||||
assert!(children.contains(&m.id), "the member {:?} is not in the copy", m.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_object_keeps_its_graphic_style_under_the_id_it_gets_in_the_document() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 500, "height": 200}));
|
||||
let source = rect(&mut s, 10.0);
|
||||
run(&mut s, "paint.setFill", json!({"ids": [source.0], "color": "#ff0000"}));
|
||||
run(&mut s, "graphicStyle.new", json!({"name": "Copied", "id": source.0}));
|
||||
run(&mut s, "select.set", json!({"ids": [source.0]}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
|
||||
// A second document whose own style already has the id the copied one has.
|
||||
run(&mut s, "file.new", json!({"width": 500, "height": 200}));
|
||||
let own = rect(&mut s, 10.0);
|
||||
run(&mut s, "paint.setFill", json!({"ids": [own.0], "color": "#0000ff"}));
|
||||
run(&mut s, "graphicStyle.new", json!({"name": "Own", "id": own.0}));
|
||||
let pasted = ids_of(&run(&mut s, "edit.paste", json!({})));
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let link = doc.node(pasted[0]).unwrap().extras().graphic_style;
|
||||
let copied = doc.graphic_styles.iter().find(|g| g.name == "Copied").unwrap().id;
|
||||
assert_ne!(copied, doc.graphic_styles.iter().find(|g| g.name == "Own").unwrap().id);
|
||||
assert_eq!(link, Some(copied));
|
||||
assert_eq!(doc.node(own).unwrap().extras().graphic_style, doc.graphic_styles.iter().find(|g| g.name == "Own").map(|g| g.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_object_without_a_graphic_style_stays_without_one() {
|
||||
let mut s = named_groups();
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
for id in ids_of(&run(&mut s, "edit.paste", json!({}))) {
|
||||
s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| assert_eq!(n.extras().graphic_style, None));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,27 @@
|
||||
//! `0 (name) count Pg` before its swatches, `PB`), then the artboards as the `ArtboardArray` of the
|
||||
//! `/Document` dictionary's `/Recorded` part and the canvas size as its `AIDocumentCanvasSize`, on
|
||||
//! `%_` lines.
|
||||
//! - Variables (`writer::variables`): after the canvas size, the variable library as XML nodes
|
||||
//! (`/XMLNode`: `xmlnode-nodename`, `xmlnode-nodevalue`, `xmlnode-nodetype`, `xmlnode-children`,
|
||||
//! `xmlnode-attributes`) of an XML document `#document`: a `metadata` element holding
|
||||
//! `variableSets`, one `variableSet` (`locked`: `none`, or `variableAndTypes` when the variables
|
||||
//! are locked), its `variables` (a `variable` element with `varName` and `trait`: `textcontent`,
|
||||
//! `visibility`, `fileref` or `graphdata`, none for a variable of unknown kind; a variable bound to
|
||||
//! objects declared once for each, its `docRef` the object's XML id, `/XMLUIDREF`) and its
|
||||
//! `v:sampleDataSets` (`i:activeDataSet`, the data set shown) of `v:sampleDataSet` elements
|
||||
//! (`dataSetName`), each holding an element named after each variable it gives a value: a `p`
|
||||
//! element for each line of a text value, a `/Bool` text whether the objects show, a linked file's
|
||||
//! `file://` URL or path. An object bound to a variable without a name gets an XML id and an empty
|
||||
//! `AIArtName`. Bindings to layers, to type written as outlines, to graphs and to linked images
|
||||
//! written as other objects, and graph values, values that don't fit their variable, linked files
|
||||
//! that aren't absolute paths, paths from the root that the reader reads as other paths (`/`
|
||||
//! alone, `/C:…`), drive and network paths with white space at either end, values of variables
|
||||
//! whose names have a colon, and a data set shown that isn't written, are left out, as is what
|
||||
//! the reader leaves out
|
||||
//! (variables and data sets whose names are empty, repeated, longer than 255 characters or start
|
||||
//! or end with white space, variables past 256, data sets past 1,000, bindings past 100,000 or to XML ids longer than
|
||||
//! 255 characters, names and values past 8,388,608 bytes in all), and values past 200,000 text
|
||||
//! lines and values in all.
|
||||
//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0
|
||||
//! dimPercent clipping Lb` (14 operands; a color that isn't one of the preset layer colors has the
|
||||
//! index -1; `clipping` is 1 for a clipping layer), `(name) Ln`, the layer's name as an XML id in an
|
||||
@@ -220,6 +241,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
|
||||
// The palette first: its gradient and pattern swatches add definitions.
|
||||
let mut palette = String::new();
|
||||
w.palette(&mut palette);
|
||||
let library = w.variable_library();
|
||||
let first = to_art.transform_rect_bbox(board);
|
||||
let art = doc
|
||||
.layers
|
||||
@@ -235,7 +257,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
|
||||
s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n");
|
||||
w.definitions(&mut s);
|
||||
s.push_str(&palette);
|
||||
artboards(&mut s, doc, to_art, origin);
|
||||
artboards(&mut s, doc, to_art, origin, &library);
|
||||
if !w.texts.is_empty() {
|
||||
s.push_str(&w.texts.section(doc));
|
||||
}
|
||||
@@ -449,7 +471,7 @@ fn ruler_units(u: Unit) -> u8 {
|
||||
/// [`Document::opened_artboard`] names, else the first) and the canvas's size. Every record holds
|
||||
/// all of these entries. The artboards, the active artboard and the canvas size are written in the
|
||||
/// `/Recorded` part of the `/Document` dictionary, followed by an empty `/NotRecorded` part.
|
||||
fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) {
|
||||
fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64), library: &str) {
|
||||
let active = doc.opened_artboard.and_then(|id| doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0);
|
||||
s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n");
|
||||
for (i, ab) in doc.artboards.iter().enumerate() {
|
||||
@@ -471,6 +493,8 @@ fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64))
|
||||
s.push_str("%_; (ArtboardArray) ,\n");
|
||||
let _ = writeln!(s, "%_{active} /Int (CropAreaActive) ,");
|
||||
let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,");
|
||||
// The variable library, after the canvas size.
|
||||
s.push_str(library);
|
||||
s.push_str("%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -1015,6 +1015,26 @@ fn the_active_artboard_follows_the_artboards() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The copy's active artboard read on its own, for a document restored from the file beside the
|
||||
/// copy: `None` for the first, and when the copy names an artboard that document doesn't have or
|
||||
/// can't be decoded.
|
||||
#[test]
|
||||
fn a_restored_document_opens_on_the_artboard_the_copy_names() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
for (id, x0) in [(2, 250.0), (3, 500.0)] {
|
||||
doc.artboards.push(Artboard { id, rect: Rect::new(x0, 0.0, x0 + 200.0, 100.0), ..doc.artboards[0].clone() });
|
||||
}
|
||||
let mut one_artboard = doc.clone();
|
||||
one_artboard.artboards.truncate(1);
|
||||
for (opened, read) in [(Some(3), Some(3)), (Some(2), Some(2)), (Some(1), None), (None, None), (Some(9), None)] {
|
||||
doc.opened_artboard = opened;
|
||||
let data = private_data(&doc, &opts()).unwrap().0.data;
|
||||
assert_eq!(crate::ai_opened_artboard(&data, &doc), read, "{opened:?}");
|
||||
assert_eq!(crate::ai_opened_artboard(&data, &one_artboard), None, "{opened:?}");
|
||||
}
|
||||
assert_eq!(crate::ai_opened_artboard(b"%AI24_ZStandard_Data not zstd", &doc), None);
|
||||
}
|
||||
|
||||
/// A guide is its path followed by `(n) *` (closed) or `(N) *` (open), with no `(N) *` before
|
||||
/// it, and reads back as the same guide.
|
||||
#[test]
|
||||
@@ -1376,8 +1396,8 @@ fn transparency_is_written_in_its_forms() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A clipping path filled and stroked keeps its stroke; the reader reads its fill as a path of
|
||||
/// its own under the clipped art, as the file's PDF page draws it.
|
||||
/// A clipping path filled and stroked is written with its fill and stroke, and reads back with
|
||||
/// both on the clipping path.
|
||||
#[test]
|
||||
fn a_painted_clipping_path_is_written_with_its_paint() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
@@ -1401,8 +1421,7 @@ fn a_painted_clipping_path_is_written_with_its_paint() {
|
||||
assert_eq!(
|
||||
painted,
|
||||
[
|
||||
(true, None, Some(paint(&solid(Color::BLACK)))),
|
||||
(false, Some(paint(&solid(Color::rgb(1.0, 1.0, 0.0)))), None),
|
||||
(true, Some(paint(&solid(Color::rgb(1.0, 1.0, 0.0)))), Some(paint(&solid(Color::BLACK)))),
|
||||
(false, Some(paint(&solid(Color::rgb(0.0, 0.0, 1.0)))), None)
|
||||
]
|
||||
);
|
||||
@@ -2070,8 +2089,7 @@ fn gradients_and_patterns_are_written_in_their_forms() {
|
||||
assert!(art.contains(" p\n") && art.contains(" P\n"), "{art}");
|
||||
}
|
||||
|
||||
/// A clipping path filled with a gradient keeps it: the reader reads it as the clipping path and
|
||||
/// its fill as a path of its own under the art.
|
||||
/// A clipping path filled with a gradient keeps it, and reads back with it.
|
||||
#[test]
|
||||
fn a_clipping_path_keeps_its_gradient() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
@@ -2087,7 +2105,9 @@ fn a_clipping_path_keeps_its_gradient() {
|
||||
assert!(text.contains("h\nW\nBb\n0 (Gradient) "), "{text}");
|
||||
let back = read_back(&doc);
|
||||
let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") };
|
||||
let fill = &children[1];
|
||||
assert_eq!(children.len(), 2, "the clipping path and the art");
|
||||
let fill = &children[0];
|
||||
assert!(matches!(fill.kind, NodeKind::Path { clipping: true, .. }), "{:?}", fill.kind);
|
||||
let bounds = Rect::new(10.0, 10.0, 90.0, 60.0);
|
||||
let want = drawn(&doc, &gradient_paint(sunset(), None, None), bounds);
|
||||
assert_eq!(drawn(&back, fill.appearance.items[0].paint(), bounds), want);
|
||||
@@ -4132,3 +4152,279 @@ fn type_with_an_opacity_mask_is_written_as_its_outlines() {
|
||||
assert!(!matches!(top.kind, NodeKind::Text(_)));
|
||||
assert!(top.extras().mask.is_some());
|
||||
}
|
||||
|
||||
// ---------- variables ----------
|
||||
|
||||
/// A 200 × 100 document with variables: a text variable `name` bound to point type, a visibility
|
||||
/// variable `show` bound to an unnamed square and to a rectangle `Badge`, an image variable
|
||||
/// `picture` bound to a linked image, a graph variable `chart` and a variable of unknown kind
|
||||
/// `other`; the data sets `First` (two lines of text, hidden, a linked file in a folder with a space
|
||||
/// and a `%`) and `Second` (empty text, shown, a linked file on a drive), `Second` shown; the
|
||||
/// variables locked.
|
||||
fn variables_doc() -> Document {
|
||||
use vectorcraft_doc::{DataSet, DataValue, Variable, VariableKind};
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let square = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::basic(solid(GOLD), Paint::None, 0.0));
|
||||
let mut badge =
|
||||
Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(40.0, 10.0, 90.0, 30.0)), Appearance::basic(solid(BRAND), Paint::None, 0.0));
|
||||
badge.name = Some("Badge".into());
|
||||
let pic = image_node(
|
||||
&mut doc,
|
||||
ImageBlob::png(rgb_png()),
|
||||
(3, 2),
|
||||
Affine::new([10.0, 0.0, 0.0, 10.0, 100.0, 10.0]),
|
||||
Some(vectorcraft_doc::LinkInfo::new("/tmp/a b/one.png")),
|
||||
);
|
||||
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Ann", style(18.0, Color::BLACK));
|
||||
let t = text_node(&mut doc, t);
|
||||
let ids = [square.id, badge.id, pic.id, t.id];
|
||||
for n in [square, badge, pic, t] {
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let vars = &mut doc.variables;
|
||||
for (name, kind) in [
|
||||
("name", VariableKind::Text),
|
||||
("show", VariableKind::Visibility),
|
||||
("picture", VariableKind::Image),
|
||||
("chart", VariableKind::Graph),
|
||||
("other", VariableKind::Unknown),
|
||||
] {
|
||||
vars.variables.push(Variable { name: name.into(), kind });
|
||||
}
|
||||
vars.bind(ids[0], "show");
|
||||
vars.bind(ids[1], "show");
|
||||
vars.bind(ids[2], "picture");
|
||||
vars.bind(ids[3], "name");
|
||||
let row = |name: &str, text: &str, show: bool, file: &str| DataSet {
|
||||
name: name.into(),
|
||||
values: [
|
||||
("name".to_string(), DataValue::Text(text.into())),
|
||||
("show".to_string(), DataValue::Visible(show)),
|
||||
("picture".to_string(), DataValue::Image(file.into())),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
vars.datasets = vec![row("First", "Ann (one)\nLee & é", false, "/Users/test/Job 100%/pic.png"), row("Second", "", true, "C:\\Art\\two.png")];
|
||||
vars.active_dataset = Some("Second".into());
|
||||
vars.locked = true;
|
||||
doc
|
||||
}
|
||||
|
||||
/// The objects of `doc` bound to each variable, by kind and name, in document order.
|
||||
fn bound_objects(doc: &Document) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
for v in &doc.variables.variables {
|
||||
for id in doc.variables.objects_of(&v.name) {
|
||||
let n = doc.node(id).unwrap();
|
||||
let kind = match &n.kind {
|
||||
NodeKind::Image(im) => format!("image {:?}", im.link.as_ref().map(|l| l.path.clone())),
|
||||
NodeKind::Path { .. } => "path".to_string(),
|
||||
NodeKind::Text(t) => format!("type {:?}", t.runs.iter().map(|r| r.text.as_str()).collect::<String>()),
|
||||
_ => "other".to_string(),
|
||||
};
|
||||
out.push(format!("{} → {kind} {:?}", v.name, n.name));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The warnings the reader gives about the variable library of `doc`'s editing data.
|
||||
fn library_notes(doc: &Document) -> Vec<String> {
|
||||
let (_, warnings) = crate::ai_alone(&private_data(doc, &opts()).unwrap().0.data).unwrap();
|
||||
warnings.into_iter().filter(|w| w.contains("variable")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_data_sets_and_bindings_are_read_back() {
|
||||
let doc = variables_doc();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
assert_eq!(back.variables.variables, doc.variables.variables);
|
||||
assert_eq!(back.variables.datasets, doc.variables.datasets);
|
||||
assert_eq!(back.variables.active_dataset.as_deref(), Some("Second"));
|
||||
assert!(back.variables.locked);
|
||||
assert_eq!(bound_objects(&back), bound_objects(&doc));
|
||||
assert_eq!(
|
||||
bound_objects(&back),
|
||||
["name → type \"Ann\" None", "show → path None", "show → path Some(\"Badge\")", "picture → image Some(\"/tmp/a b/one.png\") None"]
|
||||
);
|
||||
assert_eq!(library_notes(&doc), Vec::<String>::new());
|
||||
// The document read back is written the same way again.
|
||||
let library = |p: String| p[p.find("%_/XMLNode :").unwrap()..p.find("(#document)").unwrap()].to_string();
|
||||
assert_eq!(library(program_shown(&back)), library(program_shown(&doc)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_and_data_sets_past_the_readers_caps_are_left_out() {
|
||||
use vectorcraft_doc::{DataSet, Variable, VariableKind};
|
||||
let mut doc = variables_doc();
|
||||
for i in 0..300 {
|
||||
doc.variables.variables.push(Variable { name: format!("extra{i}"), kind: VariableKind::Text });
|
||||
}
|
||||
for i in 0..1_000 {
|
||||
doc.variables.datasets.push(DataSet { name: format!("Row {i}"), values: Default::default() });
|
||||
}
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::LIMITS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
assert_eq!(back.variables.variables[..], doc.variables.variables[..256]);
|
||||
assert_eq!(back.variables.datasets[..], doc.variables.datasets[..1_000]);
|
||||
assert_eq!(bound_objects(&back).len(), 4);
|
||||
assert_eq!(library_notes(&doc), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// Bindings that fill the reader's byte budget: the bindings past it are left out, and every
|
||||
/// variable and every binding written reads back.
|
||||
#[test]
|
||||
fn bindings_that_fill_the_readers_budget_keep_every_variable() {
|
||||
use vectorcraft_doc::{Variable, VariableKind};
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut ids = vec![];
|
||||
for i in 0..170 {
|
||||
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(1.0, 1.0, 2.0, 2.0)), Appearance::basic(solid(GOLD), Paint::None, 0.0));
|
||||
n.name = Some(format!("o{i:0>249}"));
|
||||
ids.push(n.id);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
for v in 0..120 {
|
||||
let name = format!("v{v:0>254}");
|
||||
doc.variables.variables.push(Variable { name: name.clone(), kind: VariableKind::Visibility });
|
||||
for id in &ids {
|
||||
doc.variables.bind(*id, &name);
|
||||
}
|
||||
}
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::LIMITS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
assert_eq!(back.variables.variables, doc.variables.variables);
|
||||
let bound: usize = back.variables.bindings.values().map(|names| names.len()).sum();
|
||||
assert!(bound > 0 && bound < 120 * 170, "{bound}");
|
||||
assert_eq!(library_notes(&doc), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_xml_ids_and_text_of_many_lines_are_left_out() {
|
||||
use vectorcraft_doc::DataValue;
|
||||
let mut doc = variables_doc();
|
||||
let badge = doc.variables.objects_of("show")[1];
|
||||
doc.node_mut(badge).unwrap().name = Some("_".repeat(60));
|
||||
let lines = "\n".repeat(150_000);
|
||||
doc.variables.datasets[0].values.insert("name".into(), DataValue::Text(lines));
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::LIMITS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
assert_eq!(bound_objects(&back), ["name → type \"Ann\" None", "show → path None", "picture → image Some(\"/tmp/a b/one.png\") None"]);
|
||||
assert_eq!(back.variables.datasets[0].values.get("name"), None);
|
||||
assert_eq!(back.variables.datasets[1].values.get("name"), Some(&DataValue::Text(String::new())));
|
||||
// Nothing is written that the reader leaves out.
|
||||
assert_eq!(library_notes(&doc), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_bound_to_a_visibility_variable_is_reported_left_out() {
|
||||
let mut doc = variables_doc();
|
||||
assert!(export_ai_editing(&doc, &opts()).unwrap().warnings.iter().all(|w| w != writer::BINDINGS));
|
||||
let layer = doc.layers[0].id;
|
||||
doc.variables.bind(layer, "show");
|
||||
assert!(export_ai_editing(&doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::BINDINGS));
|
||||
assert_eq!(bound_objects(&read_back(&doc)).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_binding_to_an_object_no_longer_in_the_document_is_not_reported() {
|
||||
let mut doc = variables_doc();
|
||||
let gone = doc.alloc_id();
|
||||
doc.variables.bind(gone, "show");
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
assert_eq!(bound_objects(&read_back(&doc)), bound_objects(&variables_doc()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_data_set_shown_that_is_not_written_is_reported_left_out() {
|
||||
for active in [" Second", "Nope"] {
|
||||
let mut doc = variables_doc();
|
||||
doc.variables.active_dataset = Some(active.into());
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::ACTIVE], "{active:?}");
|
||||
assert_eq!(read_back(&doc).variables.active_dataset, None, "{active:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_library_without_variables_stays_locked() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
doc.variables.locked = true;
|
||||
assert!(read_back(&doc).variables.locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_the_reader_does_not_read_as_they_are_are_left_out() {
|
||||
use vectorcraft_doc::{DataSet, Variable, VariableKind};
|
||||
let mut doc = variables_doc();
|
||||
for name in ["x".repeat(256), " padded".into(), String::new(), "show".into()] {
|
||||
doc.variables.variables.push(Variable { name: name.clone(), kind: VariableKind::Text });
|
||||
doc.variables.datasets.push(DataSet { name, values: Default::default() });
|
||||
}
|
||||
doc.variables.datasets.push(DataSet { name: "First".into(), values: Default::default() });
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::LIMITS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let expected = variables_doc();
|
||||
assert_eq!(back.variables.variables, expected.variables.variables);
|
||||
let mut datasets = expected.variables.datasets.clone();
|
||||
datasets.push(DataSet { name: "show".into(), values: Default::default() });
|
||||
assert_eq!(back.variables.datasets, datasets);
|
||||
assert_eq!(library_notes(&doc), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_without_variables_has_no_library() {
|
||||
let p = program(&sample());
|
||||
assert!(!p.contains("XMLNode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlocked_library_without_a_data_set_is_read_back() {
|
||||
let mut doc = variables_doc();
|
||||
doc.variables.datasets.clear();
|
||||
doc.variables.active_dataset = None;
|
||||
doc.variables.locked = false;
|
||||
let back = read_back(&doc);
|
||||
let expected = vectorcraft_doc::Variables { bindings: back.variables.bindings.clone(), ..(*doc.variables).clone() };
|
||||
assert_eq!(*back.variables, expected);
|
||||
assert_eq!(bound_objects(&back), bound_objects(&doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bindings_and_values_the_reader_cant_take_are_left_out() {
|
||||
use vectorcraft_doc::{DataValue, VariableKind};
|
||||
let mut doc = variables_doc();
|
||||
let layer = doc.layers[0].id;
|
||||
// Type of only white space is written as its outlines.
|
||||
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), " ", style(18.0, Color::BLACK));
|
||||
let t = text_node(&mut doc, t);
|
||||
let text_id = t.id;
|
||||
doc.insert(Some(layer), usize::MAX, t).unwrap();
|
||||
doc.variables.bind(text_id, "name");
|
||||
doc.variables.variables.push(vectorcraft_doc::Variable { name: "a:b".into(), kind: VariableKind::Text });
|
||||
let first = &mut doc.variables.datasets[0].values;
|
||||
first.insert("chart".into(), DataValue::Graph("a,b\n1,2".into()));
|
||||
first.insert("a:b".into(), DataValue::Text("lost".into()));
|
||||
first.insert("picture".into(), DataValue::Image("/a:b/pic.png".into()));
|
||||
let second = &mut doc.variables.datasets[1].values;
|
||||
second.insert("picture".into(), DataValue::Image("relative/pic.png".into()));
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::BINDINGS), "{:?}", out.warnings);
|
||||
assert!(out.warnings.iter().any(|w| w == writer::VALUES), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
assert_eq!(back.variables.objects_of("name").len(), 1);
|
||||
assert_eq!(back.variables.datasets[0].values.get("chart"), None);
|
||||
assert_eq!(back.variables.datasets[0].values.get("a:b"), None);
|
||||
assert_eq!(back.variables.datasets[0].values.get("picture"), None);
|
||||
assert_eq!(back.variables.datasets[1].values.get("picture"), None);
|
||||
assert_eq!(bound_objects(&back).len(), 4);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::ps;
|
||||
mod images;
|
||||
mod paints;
|
||||
mod texts;
|
||||
mod variables;
|
||||
|
||||
pub(super) use images::PAYLOAD;
|
||||
#[cfg(test)]
|
||||
@@ -26,6 +27,8 @@ pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED};
|
||||
pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name};
|
||||
#[cfg(test)]
|
||||
pub(super) use texts::TYPE_ATTRIBUTES;
|
||||
#[cfg(test)]
|
||||
pub(super) use variables::{ACTIVE, BINDINGS, LIMITS, VALUES};
|
||||
|
||||
/// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left
|
||||
/// out). Groups and layers need no limit of their own: a document is never deeper than
|
||||
@@ -160,6 +163,13 @@ pub(super) struct Writer<'a> {
|
||||
/// How many parts of the data written on `%_` lines (opacity masks' art, patterns' tiles) the
|
||||
/// art being written is in.
|
||||
hidden: u32,
|
||||
/// The image just written was written as a placed file.
|
||||
placed: bool,
|
||||
/// The type just written was written as type, not as its outlines.
|
||||
typed: bool,
|
||||
/// The objects bound to variables so far, in the order they are written: each one's id, its XML
|
||||
/// id and the variables it is bound to ([`Self::bound_variables`]).
|
||||
bound: Vec<(vectorcraft_doc::NodeId, String, Vec<String>)>,
|
||||
/// The text document: the stories of the type written as text.
|
||||
pub(super) texts: texts::TextDoc,
|
||||
}
|
||||
@@ -181,6 +191,9 @@ impl<'a> Writer<'a> {
|
||||
canvas_origin: origin,
|
||||
payloads: vec![],
|
||||
hidden: 0,
|
||||
placed: false,
|
||||
typed: false,
|
||||
bound: vec![],
|
||||
texts: texts::TextDoc::default(),
|
||||
cms: vectorcraft_color::cms::active(),
|
||||
body: String::new(),
|
||||
@@ -491,23 +504,34 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`])
|
||||
/// or an opacity mask (`AI9_Mask`, see [`Self::mask`]).
|
||||
/// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]),
|
||||
/// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an
|
||||
/// empty `AIArtName`) or has an opacity mask (`AI9_Mask`, see [`Self::mask`]).
|
||||
fn dictionary(&mut self, n: &Node) {
|
||||
let name = n.name.as_deref().filter(|s| !s.is_empty());
|
||||
let placed = std::mem::take(&mut self.placed);
|
||||
let typed = std::mem::take(&mut self.typed);
|
||||
let bound = self.bound_variables(n, placed, typed);
|
||||
let mut mask = n.extras().mask.as_deref();
|
||||
// Masks nested too deep are left out (an empty mask would hide the object).
|
||||
if mask.is_some() && self.nest >= MAX_NEST {
|
||||
self.warn(DEEP_MASKS);
|
||||
mask = None;
|
||||
}
|
||||
if name.is_none() && mask.is_none() {
|
||||
if name.is_none() && mask.is_none() && bound.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.body.push_str("%_/ArtDictionary :\n");
|
||||
if let Some(name) = name {
|
||||
let id = self.take_id(name);
|
||||
if name.is_some() || !bound.is_empty() {
|
||||
let id = self.take_id(name.unwrap_or("XMLID"));
|
||||
let _ = writeln!(self.body, "%_/XMLUID : {} ; (AI10_ArtUID) ,", ps::bytes(id.as_bytes()));
|
||||
if name.is_none() {
|
||||
// The XML id names the object unless the dictionary gives it another name.
|
||||
self.body.push_str("%_() /String (AIArtName) ,\n");
|
||||
}
|
||||
if !bound.is_empty() {
|
||||
self.bound.push((n.id, id, bound));
|
||||
}
|
||||
}
|
||||
if let Some(m) = mask {
|
||||
self.mask(m);
|
||||
@@ -931,6 +955,7 @@ impl<'a> Writer<'a> {
|
||||
None
|
||||
};
|
||||
let Some(((story, frame), lost)) = placed else { return self.outlined_text(n, xy) };
|
||||
self.typed = true;
|
||||
if lost {
|
||||
self.warn(texts::TYPE_ATTRIBUTES);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,11 @@ impl Writer<'_> {
|
||||
let fit =
|
||||
Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(samples.width), f64::from(im.height.max(1)) / f64::from(samples.height));
|
||||
match &im.link {
|
||||
Some(link) if self.hidden == 0 => self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf),
|
||||
Some(link) if self.hidden == 0 => {
|
||||
// Cut to a region, the placed file is in a clipping group.
|
||||
self.placed = cut.is_none();
|
||||
self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf);
|
||||
}
|
||||
other => {
|
||||
if other.is_some() {
|
||||
self.warn(LINKS_EMBEDDED);
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! The variable library: the document's variables, data sets and the objects bound to them, as XML
|
||||
//! nodes of the `/Document` dictionary (see [`crate::ai_editing`] for the form).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write;
|
||||
|
||||
use vectorcraft_doc::{DataValue, Node, NodeId, NodeKind, VariableKind};
|
||||
|
||||
use super::Writer;
|
||||
use crate::ps;
|
||||
|
||||
pub(in crate::ai_editing) const BINDINGS: &str = "variable bindings to layers, to type written as outlines, to graphs, and to linked images written as other objects are left out of the editing data for other apps";
|
||||
pub(in crate::ai_editing) const VALUES: &str = "data set values of graphs, values that don't fit their variable, linked files that aren't absolute paths or that would read back as other paths, and values of variables whose names have a colon are left out of the editing data for other apps";
|
||||
pub(in crate::ai_editing) const ACTIVE: &str =
|
||||
"the data set shown is left out of the editing data for other apps when it isn't one of the data sets written";
|
||||
|
||||
pub(in crate::ai_editing) const LIMITS: &str = "variables and data sets whose names are empty, repeated, longer than 255 characters or start or end with white space, variables past 256, data sets past 1,000, bindings past 100,000, bindings to objects whose XML ids are longer than 255 characters, values past 200,000 text lines and values in all, and names and values past 8,388,608 bytes in all are left out of the editing data for other apps";
|
||||
|
||||
/// The most bytes of a string literal on one line; a longer string is written across lines.
|
||||
const LITERAL: usize = 64;
|
||||
/// The reader's caps on a variable library: the variables, bindings and data sets it keeps, the
|
||||
/// characters of a name and of an object's XML id, and the bytes of the names (a binding's variable name and
|
||||
/// object id) and values it keeps.
|
||||
const MAX_VARIABLES: usize = 256;
|
||||
const MAX_BINDINGS: usize = 100_000;
|
||||
const MAX_DATASETS: usize = 1_000;
|
||||
const MAX_ID: usize = 255;
|
||||
const MAX_BYTES: usize = 8 << 20;
|
||||
/// The most XML nodes the data sets' values are written with: a value's element, and a text value's
|
||||
/// `p` element and text for each line.
|
||||
const MAX_VALUE_NODES: usize = 200_000;
|
||||
|
||||
/// The bytes of names and values written so far, as the reader counts them, the XML nodes of the
|
||||
/// values, and whether anything was left out past the caps.
|
||||
#[derive(Default)]
|
||||
struct Budget {
|
||||
bytes: usize,
|
||||
nodes: usize,
|
||||
past: bool,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
/// Whether `len` more bytes fit in [`MAX_BYTES`] (they are then counted).
|
||||
fn keep(&mut self, len: usize) -> bool {
|
||||
self.keep_value(len, 0)
|
||||
}
|
||||
|
||||
/// Whether a value of `len` bytes written with `nodes` XML nodes fits in [`MAX_BYTES`] and
|
||||
/// [`MAX_VALUE_NODES`] (it is then counted).
|
||||
fn keep_value(&mut self, len: usize, nodes: usize) -> bool {
|
||||
let fits = self.bytes.saturating_add(len) <= MAX_BYTES && self.nodes.saturating_add(nodes) <= MAX_VALUE_NODES;
|
||||
if fits {
|
||||
self.bytes += len;
|
||||
self.nodes += nodes;
|
||||
}
|
||||
fits
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of an XML node's entry, with the type it is written with.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Value<'a> {
|
||||
/// A `/String`.
|
||||
Str(&'a str),
|
||||
/// A `/UnicodeString`.
|
||||
Unicode(&'a str),
|
||||
/// A `/Bool`.
|
||||
Bool(bool),
|
||||
/// An object's XML id, `/XMLUIDREF`.
|
||||
Uid(&'a str),
|
||||
}
|
||||
|
||||
/// `s` as string literals on `%_` lines, at most [`LITERAL`] bytes each, the first line's `%_`
|
||||
/// included and the last line left open for what follows it.
|
||||
fn literals(s: &str) -> String {
|
||||
let mut parts = vec![];
|
||||
let mut start = 0;
|
||||
for (at, c) in s.char_indices() {
|
||||
if at + c.len_utf8() - start > LITERAL {
|
||||
parts.push(&s[start..at]);
|
||||
start = at;
|
||||
}
|
||||
}
|
||||
parts.push(&s[start..]);
|
||||
parts.iter().map(|p| format!("%_{}", ps::bytes(p.as_bytes()))).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// `v` written before an entry's key, from the start of its line.
|
||||
fn value(v: Value) -> String {
|
||||
match v {
|
||||
Value::Str(s) => format!("{} /String", literals(s)),
|
||||
Value::Unicode(s) => format!("{} /UnicodeString", literals(s)),
|
||||
Value::Bool(b) => format!("%_{} /Bool", u8::from(b)),
|
||||
Value::Uid(id) => format!("%_/XMLUIDREF : {} ;", ps::bytes(id.as_bytes())),
|
||||
}
|
||||
}
|
||||
|
||||
/// An XML node (`/XMLNode`) up to the `;` that closes it: its name, its value, its type `kind` (1 an
|
||||
/// element, 2 an attribute, 3 text, 9 the document), its children (an array, each closed as an item)
|
||||
/// and its attribute nodes (a dictionary, each under its name).
|
||||
fn node(kind: u8, name: &str, v: Value, attributes: &[(&str, Value)], children: &[String]) -> String {
|
||||
let mut s = format!(
|
||||
"%_/XMLNode :\n{} (xmlnode-nodename) ,\n{} (xmlnode-nodevalue) ,\n%_{kind} /Int (xmlnode-nodetype) ,\n%_/Array :\n",
|
||||
value(Value::Str(name)),
|
||||
value(v)
|
||||
);
|
||||
for c in children {
|
||||
s.push_str(c);
|
||||
s.push_str("%_; ,\n");
|
||||
}
|
||||
s.push_str("%_; (xmlnode-children) ,\n%_/Dictionary :\n");
|
||||
for (k, v) in attributes {
|
||||
s.push_str(&node(2, k, *v, &[], &[]));
|
||||
let _ = writeln!(s, "%_; {} ,", ps::bytes(k.as_bytes()));
|
||||
}
|
||||
s.push_str("%_; (xmlnode-attributes) ,\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// An element `name` with `attributes` and `children`, up to the `;` that closes it.
|
||||
fn element(name: &str, attributes: &[(&str, Value)], children: &[String]) -> String {
|
||||
node(1, name, Value::Str(""), attributes, children)
|
||||
}
|
||||
|
||||
/// A text node (`#text`) holding `v`, up to the `;` that closes it.
|
||||
fn text(v: Value) -> String {
|
||||
node(3, "#text", v, &[], &[])
|
||||
}
|
||||
|
||||
/// A linked file's path as a data set holds it: a path from the root (`/…`) as a `file://` URL
|
||||
/// (`file:///` and the path, with `%`, `#`, `?`, white space, control characters and bytes outside ASCII
|
||||
/// escaped as `%XX`), another absolute path (a drive's, or a network path) as it is. `None` for a
|
||||
/// path that isn't absolute, for the root alone (`/`), for a path from the root whose first part
|
||||
/// starts like a drive (`/C:…`, which the reader reads as the drive's path), and for one written as
|
||||
/// it is with white space at either end.
|
||||
fn file_value(path: &str) -> Option<String> {
|
||||
// A drive's path from its root (`C:\…`, `C:/…`); `C:name` is relative to the drive's folder.
|
||||
let drive = matches!(path.as_bytes(), [l, b':', b'\\' | b'/', ..] if l.is_ascii_alphabetic());
|
||||
if path.starts_with('/') && !path.starts_with("//") {
|
||||
let rest = &path.as_bytes()[1..];
|
||||
if rest.is_empty() || matches!(rest, [l, b':', ..] if l.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
let mut url = String::from("file:///");
|
||||
for &b in path.as_bytes() {
|
||||
if matches!(b, b'%' | b'#' | b'?') || !(0x21..=0x7e).contains(&b) {
|
||||
let _ = write!(url, "%{b:02X}");
|
||||
} else {
|
||||
url.push(char::from(b));
|
||||
}
|
||||
}
|
||||
return Some(url);
|
||||
}
|
||||
// The reader takes white space off both ends of a path written as it is.
|
||||
let trimmed = path.trim_matches([' ', '\t', '\n', '\r']) == path;
|
||||
(trimmed && path.len() > 1 && (path.starts_with('/') || path.starts_with("\\\\") || drive)).then(|| path.to_string())
|
||||
}
|
||||
|
||||
/// Whether the reader reads `name` as a variable's or a data set's name as it is: not empty, at most
|
||||
/// [`MAX_ID`] characters, and without white space at either end.
|
||||
fn readable_name(name: &str) -> bool {
|
||||
!name.is_empty() && name.trim() == name && name.chars().count() <= MAX_ID
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
/// The variables `n` is bound to that its XML id is written for: visibility variables, image
|
||||
/// variables when `n` was written as a placed file (`placed`, see [`Self::placed`]), and text
|
||||
/// variables when `n` was written as type (`typed`, see [`Self::typed`]). Only objects of the
|
||||
/// document's own tree are bound, not copies of them (symbol art, live objects' art, opacity
|
||||
/// masks' art, pattern tiles).
|
||||
/// The bindings left out are reported by [`Self::variable_library`].
|
||||
pub(super) fn bound_variables(&self, n: &Node, placed: bool, typed: bool) -> Vec<String> {
|
||||
let doc = self.doc;
|
||||
let Some(names) = doc.variables.bindings.get(&n.id) else { return vec![] };
|
||||
if self.nest > 0 || self.hidden > 0 || !doc.node(n.id).is_some_and(|d| std::ptr::eq(d, n)) {
|
||||
return vec![];
|
||||
}
|
||||
let fits = |name: &&String| match doc.variables.variable(name).map(|v| v.kind) {
|
||||
Some(VariableKind::Visibility) => true,
|
||||
Some(VariableKind::Image) => placed && matches!(n.kind, NodeKind::Image(_)),
|
||||
Some(VariableKind::Text) => typed && matches!(n.kind, NodeKind::Text(_)),
|
||||
_ => false,
|
||||
};
|
||||
names.iter().filter(fits).cloned().collect()
|
||||
}
|
||||
|
||||
/// The `#document` entry of the `/Document` dictionary's `/Recorded` part that holds the
|
||||
/// variable library, on `%_` lines: nothing when the document has no variables or data sets.
|
||||
pub(in crate::ai_editing) fn variable_library(&mut self) -> String {
|
||||
let doc = self.doc;
|
||||
let vars = &doc.variables;
|
||||
if vars.variables.is_empty() && vars.datasets.is_empty() && !vars.locked {
|
||||
return String::new();
|
||||
}
|
||||
// Bindings not written: to layers, to type written as outlines, to graphs, and to objects
|
||||
// not written as themselves. Bindings to objects no longer in the document have nothing to
|
||||
// write.
|
||||
let pairs: HashSet<(NodeId, &str)> = self.bound.iter().flat_map(|(n, _, names)| names.iter().map(|name| (*n, name.as_str()))).collect();
|
||||
let written = |id: &NodeId, name: &String| pairs.contains(&(*id, name.as_str()));
|
||||
let kind = |name: &str| vars.variable(name).map(|v| v.kind);
|
||||
let missing = vars.bindings.iter().any(|(id, names)| {
|
||||
doc.node(*id).is_some() && names.iter().any(|name| kind(name).is_some_and(|k| k != VariableKind::Unknown) && !written(id, name))
|
||||
});
|
||||
if missing {
|
||||
self.warn(BINDINGS);
|
||||
}
|
||||
// What the reader keeps: names it doesn't read, and names, bindings and values past its
|
||||
// caps, are left out.
|
||||
let mut budget = Budget::default();
|
||||
let mut kept: Vec<&vectorcraft_doc::Variable> = vec![];
|
||||
for v in &vars.variables {
|
||||
let new = readable_name(&v.name) && !kept.iter().any(|k| k.name == v.name);
|
||||
if new && kept.len() < MAX_VARIABLES && budget.keep(v.name.len()) {
|
||||
kept.push(v);
|
||||
} else {
|
||||
budget.past = true;
|
||||
}
|
||||
}
|
||||
let mut declared = vec![];
|
||||
let mut bindings = 0usize;
|
||||
for v in &kept {
|
||||
let mut attributes = vec![];
|
||||
let kind = match v.kind {
|
||||
VariableKind::Text => Some(("textcontent", "&ns_flows;")),
|
||||
VariableKind::Visibility => Some(("visibility", "&ns_vars;")),
|
||||
VariableKind::Image => Some(("fileref", "&ns_vars;")),
|
||||
VariableKind::Graph => Some(("graphdata", "&ns_vars;")),
|
||||
VariableKind::Unknown => None,
|
||||
};
|
||||
if let Some((t, category)) = kind {
|
||||
attributes.push(("trait", Value::Str(t)));
|
||||
attributes.push(("category", Value::Str(category)));
|
||||
}
|
||||
attributes.push(("varName", Value::Unicode(&v.name)));
|
||||
// A variable bound to several objects is declared once for each.
|
||||
let objects = self.bound.iter().filter(|(_, _, names)| names.contains(&v.name)).map(|(_, id, _)| id.as_str());
|
||||
let mut written = 0;
|
||||
for id in objects {
|
||||
if bindings >= MAX_BINDINGS || id.chars().count() > MAX_ID || !budget.keep(v.name.len().saturating_add(id.len())) {
|
||||
budget.past = true;
|
||||
continue;
|
||||
}
|
||||
bindings += 1;
|
||||
written += 1;
|
||||
let mut with = attributes.clone();
|
||||
with.push(("docRef", Value::Uid(id)));
|
||||
declared.push(element("variable", &with, &[]));
|
||||
}
|
||||
if written == 0 {
|
||||
declared.push(element("variable", &attributes, &[]));
|
||||
}
|
||||
}
|
||||
let mut left_out = false;
|
||||
let mut rows = vec![];
|
||||
let mut row_names: Vec<&str> = vec![];
|
||||
for ds in &vars.datasets {
|
||||
let new = readable_name(&ds.name) && !row_names.contains(&ds.name.as_str());
|
||||
if !new || rows.len() >= MAX_DATASETS || !budget.keep(ds.name.len()) {
|
||||
budget.past = true;
|
||||
continue;
|
||||
}
|
||||
let mut values = vec![];
|
||||
for (name, v) in &ds.values {
|
||||
let Some(kind) = kept.iter().find(|k| &k.name == name).map(|k| k.kind) else {
|
||||
// A value of a variable left out, or of none.
|
||||
left_out |= vars.variable(name).is_none();
|
||||
continue;
|
||||
};
|
||||
// The value's bytes as the reader counts them, and the nodes it is written with.
|
||||
let (len, nodes) = match (v, kind) {
|
||||
_ if name.contains(':') => {
|
||||
left_out = true;
|
||||
continue;
|
||||
}
|
||||
(DataValue::Text(t), VariableKind::Text) => {
|
||||
let lines = t.bytes().filter(|b| *b == b'\n').count() + 1;
|
||||
(t.len(), 1 + 2 * lines)
|
||||
}
|
||||
(DataValue::Visible(_), VariableKind::Visibility) => (1, 2),
|
||||
(DataValue::Image(path), VariableKind::Image) if file_value(path).is_some() => (path.len(), 2),
|
||||
_ => {
|
||||
left_out = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !budget.keep_value(len, nodes) {
|
||||
budget.past = true;
|
||||
continue;
|
||||
}
|
||||
let children = match v {
|
||||
DataValue::Text(t) => t
|
||||
.split('\n')
|
||||
.map(|line| {
|
||||
let words = if line.is_empty() { vec![] } else { vec![text(Value::Unicode(line))] };
|
||||
element("p", &[], &words)
|
||||
})
|
||||
.collect(),
|
||||
DataValue::Visible(show) => vec![text(Value::Bool(*show))],
|
||||
DataValue::Image(path) => file_value(path).map(|f| vec![text(Value::Unicode(&f))]).unwrap_or_default(),
|
||||
DataValue::Graph(_) => vec![],
|
||||
};
|
||||
values.push(element(name, &[], &children));
|
||||
}
|
||||
rows.push(element("v:sampleDataSet", &[("dataSetName", Value::Unicode(&ds.name))], &values));
|
||||
row_names.push(&ds.name);
|
||||
}
|
||||
if left_out {
|
||||
self.warn(VALUES);
|
||||
}
|
||||
if budget.past {
|
||||
self.warn(LIMITS);
|
||||
}
|
||||
let mut attributes = vec![];
|
||||
// The data set shown, when a data set of that exact name is written.
|
||||
if let Some(active) = &vars.active_dataset {
|
||||
if row_names.contains(&active.as_str()) {
|
||||
attributes.push(("i:activeDataSet", Value::Unicode(active)));
|
||||
} else {
|
||||
self.warn(ACTIVE);
|
||||
}
|
||||
}
|
||||
attributes.push(("xmlns", Value::Str("&ns_custom;")));
|
||||
attributes.push(("xmlns:v", Value::Str("&ns_vars;")));
|
||||
let data_sets = element("v:sampleDataSets", &attributes, &rows);
|
||||
let locked = if vars.locked { "variableAndTypes" } else { "none" };
|
||||
let set = element(
|
||||
"variableSet",
|
||||
&[("locked", Value::Str(locked)), ("varSetName", Value::Str("binding1"))],
|
||||
&[element("variables", &[], &declared), data_sets],
|
||||
);
|
||||
let sets = element("variableSets", &[("xmlns", Value::Str("&ns_vars;"))], &[set]);
|
||||
let document = node(9, "#document", Value::Str(""), &[], &[element("metadata", &[], &[sets])]);
|
||||
format!("{document}%_; (#document) ,\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn paths_from_the_root_are_file_urls() {
|
||||
assert_eq!(file_value("/Users/a b/100%.png").as_deref(), Some("file:////Users/a%20b/100%25.png"));
|
||||
assert_eq!(file_value("/tmp/é.png").as_deref(), Some("file:////tmp/%C3%A9.png"));
|
||||
assert_eq!(file_value("C:\\Art\\pic.png").as_deref(), Some("C:\\Art\\pic.png"));
|
||||
assert_eq!(file_value("\\\\server\\share\\pic.png").as_deref(), Some("\\\\server\\share\\pic.png"));
|
||||
assert_eq!(file_value("//server/share/pic.png").as_deref(), Some("//server/share/pic.png"));
|
||||
assert_eq!(file_value("/a/#1?.png").as_deref(), Some("file:////a/%231%3F.png"));
|
||||
assert_eq!(file_value("pic.png"), None);
|
||||
assert_eq!(file_value("C:pic.png"), None);
|
||||
assert_eq!(file_value("C:\\Art\\pic.png "), None);
|
||||
assert_eq!(file_value("/tmp/pic.png ").as_deref(), Some("file:////tmp/pic.png%20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_the_reader_reads_as_other_paths_are_left_out() {
|
||||
assert_eq!(file_value("/"), None);
|
||||
assert_eq!(file_value("/a:b/x.png"), None);
|
||||
assert_eq!(file_value("/C:/x.png"), None);
|
||||
assert_eq!(file_value("/1:b/x.png").as_deref(), Some("file:////1:b/x.png"));
|
||||
assert_eq!(file_value("/ab:c/x.png").as_deref(), Some("file:////ab:c/x.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_strings_are_written_across_lines() {
|
||||
// 140 bytes: two lines of 64 bytes and one of 12.
|
||||
let s = "é".repeat(70);
|
||||
let lines = literals(&s);
|
||||
assert_eq!(lines.lines().count(), 3);
|
||||
assert!(lines.lines().all(|l| l.starts_with("%_(")));
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ use obj::PsError;
|
||||
|
||||
pub(crate) use ai::MAX_GRADIENT_STOPS;
|
||||
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES};
|
||||
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from};
|
||||
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from};
|
||||
pub(crate) use native::{MAX_TEXT, scale_document};
|
||||
pub use text::family_style;
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN;
|
||||
pub(crate) use frames::text_frames;
|
||||
pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS;
|
||||
pub use read::{
|
||||
BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside,
|
||||
variables,
|
||||
BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette,
|
||||
read, read_beside, variables,
|
||||
};
|
||||
pub(crate) use read::{join_and, slot_frame, slot_of};
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ const TOOLS: [(&str, Tool); 5] = [
|
||||
pub(super) const MAX_BRUSHES: usize = 4096;
|
||||
/// Most brush art sections read from one file (a pattern brush has up to five).
|
||||
pub(super) const MAX_BRUSH_ART: usize = 5 * MAX_BRUSHES;
|
||||
/// Most named colors kept from one brush art section, and brought in from the art of one file's
|
||||
/// brushes, each name once per section: the rest are left out.
|
||||
pub(super) const MAX_BRUSH_ART_COLORS: usize = 20_000;
|
||||
/// Most [`art_weight`] copied into the brushes of one file, each brush counting its own copy.
|
||||
const MAX_BRUSH_ART_NODES: usize = 250_000;
|
||||
|
||||
|
||||
@@ -78,6 +78,17 @@ struct ParaRun {
|
||||
tabs_version: u64,
|
||||
}
|
||||
|
||||
/// An overflow paragraph break (`(\r) TX`) read before any more type, with the state in force
|
||||
/// where it was read.
|
||||
#[derive(Debug)]
|
||||
struct PendingBreak {
|
||||
/// `TV` at the break.
|
||||
vertical: bool,
|
||||
/// The attributes and [`TypeState::tabs_version`] of the empty paragraph the break ends, when
|
||||
/// it ends one that no character started.
|
||||
para: Option<(ParaStyle, u64)>,
|
||||
}
|
||||
|
||||
/// A text object being read.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct LegacyText {
|
||||
@@ -101,7 +112,7 @@ pub(super) struct LegacyText {
|
||||
/// The current paragraph's attributes are in `paras`.
|
||||
para_taken: bool,
|
||||
/// An overflow paragraph break (`(\r) TX`) not yet followed by type: the story's end.
|
||||
pending_break: bool,
|
||||
pending_break: Option<PendingBreak>,
|
||||
/// Characters were set in a mask rendering mode (`Tr` 4 to 7): the text object is a clipping
|
||||
/// path (§5.7).
|
||||
clips: bool,
|
||||
@@ -632,7 +643,10 @@ impl Reader<'_> {
|
||||
}
|
||||
// Overflow text ending the story with a paragraph break (`(\r) TX`) adds no paragraph.
|
||||
if w == "TX" && decoded == "\n" {
|
||||
t.pending_break = true;
|
||||
if t.pending_break.is_none() {
|
||||
let para = (!t.para_taken).then(|| (st.para.clone(), st.tabs_version));
|
||||
t.pending_break = Some(PendingBreak { vertical: st.vertical, para });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// As many characters as the file's allowance has left.
|
||||
@@ -642,10 +656,13 @@ impl Reader<'_> {
|
||||
}
|
||||
let decoded: String = decoded.chars().take(room).collect();
|
||||
st.chars += decoded.chars().count();
|
||||
// A break read before these characters keeps the direction and paragraph attributes it was
|
||||
// read with, and is the story's first character when nothing came before it.
|
||||
let brk = t.pending_break.take();
|
||||
if t.runs.is_empty() {
|
||||
t.vertical = st.vertical;
|
||||
t.vertical = brk.as_ref().map_or(st.vertical, |b| b.vertical);
|
||||
}
|
||||
t.direction_changed |= st.vertical != t.vertical;
|
||||
t.direction_changed |= st.vertical != t.vertical || brk.as_ref().is_some_and(|b| b.vertical != t.vertical);
|
||||
let gs = &self.gs;
|
||||
let mut style = st.style.clone();
|
||||
style.tate_chu_yoko |= st.kumi_recorded && !t.vertical;
|
||||
@@ -664,7 +681,10 @@ impl Reader<'_> {
|
||||
(style.stroke_cap, style.stroke_join, style.stroke_miter_limit, style.stroke_dash) = (gs.cap, gs.join, gs.miter, gs.dash.clone());
|
||||
(style.overprint_fill, style.overprint_stroke) = (gs.overprint_fill, gs.overprint_stroke);
|
||||
let mut text = String::new();
|
||||
if std::mem::take(&mut t.pending_break) {
|
||||
if let Some(brk) = brk {
|
||||
if let Some((mut para, tabs_version)) = brk.para {
|
||||
t.begin_para(&mut para, tabs_version);
|
||||
}
|
||||
text.push('\n');
|
||||
t.para_taken = false;
|
||||
}
|
||||
|
||||
@@ -134,10 +134,12 @@ mod types;
|
||||
|
||||
pub(crate) use art::join_and;
|
||||
use art::preview_png;
|
||||
pub use document::{BindingRef, Brushes, PaletteSwatches, VariableRead, artboards, bindings_note, brushes, graphic_styles, palette, variables};
|
||||
pub use document::{
|
||||
BindingRef, Brushes, PaletteSwatches, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, variables,
|
||||
};
|
||||
pub(crate) use document::{slot_frame, slot_of};
|
||||
pub use types::IMAGE_TOO_LARGE;
|
||||
use types::{BLEND_BUDGET, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead};
|
||||
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead};
|
||||
#[cfg(test)]
|
||||
pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED};
|
||||
pub(super) use types::{blend_mode, blend_name};
|
||||
@@ -513,7 +515,8 @@ struct Reader<'a> {
|
||||
/// The state a layer's last group or compound path had before an `Xy` right after its end,
|
||||
/// which is the layer's instead when the layer's own `7 () XW` follows.
|
||||
container_undo: Option<(f32, BlendMode, bool, Knockout, bool)>,
|
||||
/// Painted copies of clipping paths that have a fill (drawn under what they clip).
|
||||
/// The fills of compound clipping paths whose subpaths paint differently, as compound paths of their own (drawn
|
||||
/// under what they clip).
|
||||
clip_fills: HashSet<NodeId>,
|
||||
/// Groups closed as an object's drawn look (`9 () XW`).
|
||||
looks: HashSet<NodeId>,
|
||||
@@ -585,6 +588,8 @@ struct Reader<'a> {
|
||||
brush_drafts: Vec<super::brush::BrushDraft>,
|
||||
/// The art of the brushes (`%AI8_BeginBrushPattern`), by name.
|
||||
brush_art: HashMap<String, Node>,
|
||||
/// The colors of the art in [`Reader::brush_art`], by name.
|
||||
brush_art_colors: HashMap<String, BrushArtColors>,
|
||||
/// The brush art sections read so far, repeated names included.
|
||||
brush_art_sections: usize,
|
||||
/// What the brushes and brush art read so far give a brushed stroke.
|
||||
@@ -735,6 +740,7 @@ impl<'a> Reader<'a> {
|
||||
brush_order: None,
|
||||
brush_drafts: Vec::new(),
|
||||
brush_art: HashMap::new(),
|
||||
brush_art_colors: HashMap::new(),
|
||||
brush_art_sections: 0,
|
||||
brushes_kept: BrushesKept::default(),
|
||||
brushes_unread: 0,
|
||||
|
||||
@@ -216,13 +216,11 @@ impl<'a> Reader<'a> {
|
||||
// A gradient instance's paint replaces the missing pattern.
|
||||
let unread = (fill && self.pending_unread_fill && fill_paint.is_none()) || (stroke && self.pending_unread_stroke && stroke_paint.is_none());
|
||||
let mut items = Vec::new();
|
||||
if fill && !clip {
|
||||
items.push(AppearanceItem::Fill(FillLayer {
|
||||
overprint: gs.overprint_fill,
|
||||
..FillLayer::new(fill_paint.clone().unwrap_or(gs.fill.clone()))
|
||||
}));
|
||||
// A clipping path keeps its fill and its stroke: its fill paints under the art it clips and its stroke over it
|
||||
// (`Node::clip_paint`), as the file's PDF part draws them.
|
||||
if fill {
|
||||
items.push(AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(fill_paint.unwrap_or(gs.fill.clone())) }));
|
||||
}
|
||||
// A clipping path keeps its stroke, which paints over the art it clips.
|
||||
if stroke {
|
||||
let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.line_width());
|
||||
st.cap = gs.cap;
|
||||
@@ -254,23 +252,6 @@ impl<'a> Reader<'a> {
|
||||
if clip && let Some(f) = self.frames.last_mut() {
|
||||
f.clips.push(f.children.len());
|
||||
}
|
||||
// A clipping path's fill is painted too, under the art it clips, as the file's PDF part draws it.
|
||||
let painted = clip && fill && !matches!(gs.fill, Paint::None);
|
||||
let copy = painted.then(|| {
|
||||
let mut copy = node.clone();
|
||||
copy.id = self.doc.alloc_id();
|
||||
if let NodeKind::Path { clipping, .. } = &mut copy.kind {
|
||||
*clipping = false;
|
||||
}
|
||||
let paint = fill_paint.clone().unwrap_or_else(|| gs.fill.clone());
|
||||
copy.appearance = Appearance {
|
||||
items: vec![AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(paint) })],
|
||||
..Appearance::default()
|
||||
}
|
||||
.into();
|
||||
copy
|
||||
});
|
||||
// The note and Show Center are the path's, not its painted copy's.
|
||||
node.edit_attrs(|a| {
|
||||
a.note = gs.note.clone().unwrap_or_default();
|
||||
a.show_center = gs.show_center;
|
||||
@@ -297,10 +278,6 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(copy) = copy {
|
||||
self.clip_fills.insert(copy.id);
|
||||
self.add(copy, hidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -342,19 +319,13 @@ impl<'a> Reader<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. A clipping path
|
||||
/// takes the stroke itself, not the painted copy of its fill.
|
||||
/// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state.
|
||||
pub(super) fn stroke_last(&mut self) {
|
||||
let gs = self.gs.clone();
|
||||
if !self.after_object {
|
||||
return;
|
||||
}
|
||||
let Some(kids) = self.frames.last_mut().map(|f| &mut f.children) else { return };
|
||||
let Some(mut at) = kids.len().checked_sub(1) else { return };
|
||||
if kids.get(at).is_some_and(|n| self.clip_fills.contains(&n.id)) && at > 0 {
|
||||
at -= 1;
|
||||
}
|
||||
let Some(n) = kids.get_mut(at) else { return };
|
||||
let Some(n) = self.frames.last_mut().and_then(|f| f.children.last_mut()) else { return };
|
||||
if !matches!(n.kind, NodeKind::Path { .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -147,10 +147,19 @@ impl<'a> Reader<'a> {
|
||||
self.new_node(NodeKind::Group { children, clip }, &gs)
|
||||
}
|
||||
Kind::Compound => {
|
||||
// A group in a compound path (`*u u … U *U`) only holds its paths. Clipping subpaths'
|
||||
// painted copies make a compound of their own, under what it clips.
|
||||
let (fills, kids): (Vec<Node>, Vec<Node>) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id));
|
||||
painted = self.compound(fills, &gs);
|
||||
// A group in a compound path (`*u u … U *U`) only holds its paths.
|
||||
let mut kids = ungrouped(kids);
|
||||
// A compound clipping path keeps its subpaths' fill when they all paint alike. When they paint
|
||||
// differently, the fills of the filled clipping ones are a compound path of their own, under what it clips.
|
||||
let look = |n: &Node| (n.appearance.clone(), n.opacity, n.blend);
|
||||
if clipping && kids.windows(2).any(|w| look(&w[0]) != look(&w[1])) {
|
||||
let fills: Vec<Node> = kids
|
||||
.iter_mut()
|
||||
.filter(|k| matches!(k.kind, NodeKind::Path { clipping: true, .. }))
|
||||
.filter_map(|k| self.fill_apart(k))
|
||||
.collect();
|
||||
painted = self.compound(fills, &gs);
|
||||
}
|
||||
let Some(n) = self.compound(kids, &gs) else {
|
||||
if gs.hidden || !self.shown() {
|
||||
self.hidden_unread.extend(unread);
|
||||
@@ -228,9 +237,9 @@ impl<'a> Reader<'a> {
|
||||
/// Several clipping paths in a clipping group or a layer clip to their intersection, as each `W` reduces the clip
|
||||
/// set by the ones before it: each clipping path after the first clips a clipping group nested in the one before
|
||||
/// it, and the innermost one holds the art. As with one clipping path, every object in the group is clipped by all
|
||||
/// of them, wherever it is written. A clipping path's painted fill stays at its path's level. In a compound path
|
||||
/// (`union`), several clipping paths are one compound path. `at`: how deep the group or layer made of them is in the
|
||||
/// document.
|
||||
/// of them, wherever it is written. The fill of a compound clipping path read apart from it (`clip_fills`) stays at
|
||||
/// its path's level. In a compound path (`union`), several clipping paths are one compound path. `at`: how deep the
|
||||
/// group or layer made of them is in the document.
|
||||
fn clip_first(&mut self, kids: Vec<Node>, clips: &[usize], union: bool, at: usize) -> Result<(Vec<Arc<Node>>, bool), String> {
|
||||
if union || clips.len() < 2 {
|
||||
return Ok(self.clip_union(kids, clips));
|
||||
@@ -238,7 +247,7 @@ impl<'a> Reader<'a> {
|
||||
let clips: std::collections::HashSet<usize> = clips.iter().copied().collect();
|
||||
let (clip, rest): (Vec<Indexed>, Vec<Indexed>) = kids.into_iter().enumerate().partition(|(i, _)| clips.contains(i));
|
||||
let innermost = clip.len() - 1;
|
||||
// A painted fill follows its clipping path; the art goes in the innermost level.
|
||||
// A fill read apart follows its clipping path; the art goes in the innermost level.
|
||||
let level = |i: usize, n: &Node| {
|
||||
if self.clip_fills.contains(&n.id) { clip.iter().rposition(|(c, _)| *c < i).unwrap_or(0) } else { innermost }
|
||||
};
|
||||
@@ -251,8 +260,7 @@ impl<'a> Reader<'a> {
|
||||
if at + deepest > MAX_DEPTH || (at + deepest > vectorcraft_doc::MAX_DEPTH && at + flat <= vectorcraft_doc::MAX_DEPTH) {
|
||||
return Err("its clipping masks are nested too deeply".into());
|
||||
}
|
||||
// Each level: its clipping path, the clipping paths' own fills, which paint under the art they clip, then the
|
||||
// art.
|
||||
// Each level: its clipping path, the fills read apart from it, which paint under the art they clip, then the art.
|
||||
let mut fills_at: Vec<Vec<Node>> = clip.iter().map(|_| Vec::new()).collect();
|
||||
let mut art_at: Vec<Vec<Node>> = clip.iter().map(|_| Vec::new()).collect();
|
||||
for (i, n) in rest {
|
||||
@@ -282,13 +290,18 @@ impl<'a> Reader<'a> {
|
||||
fn clip_union(&mut self, kids: Vec<Node>, clips: &[usize]) -> (Vec<Arc<Node>>, bool) {
|
||||
let clips: std::collections::HashSet<usize> = clips.iter().copied().collect();
|
||||
let (mut clip, rest): (Vec<Indexed>, Vec<Indexed>) = kids.into_iter().enumerate().partition(|(i, _)| clips.contains(i));
|
||||
let mut fills = Vec::new();
|
||||
let clip_node = match clip.len() {
|
||||
0 => None,
|
||||
1 => clip.pop().map(|(_, n)| n),
|
||||
_ => {
|
||||
let rule = clip.first().map_or(FillRule::NonZero, |(_, n)| path_rule(n));
|
||||
// The compound path keeps the clipping paths' stroke, opacity and blend mode when they all have the same
|
||||
// ones, and the knockout shape flag when they all have it: it has one appearance for all its paths.
|
||||
// Each clipping path's fill is a path of its own, under the art it clips, as each one paints it. The
|
||||
// compound path keeps the clipping paths' stroke, opacity and blend mode when they all have the same ones,
|
||||
// and the knockout shape flag when they all have it: it has one appearance for all its paths.
|
||||
for (_, n) in &mut clip {
|
||||
fills.extend(self.fill_apart(n));
|
||||
}
|
||||
let shape = clip.iter().all(|(_, n)| n.knockout_shape);
|
||||
let look = |n: &Node| (n.appearance.clone(), n.opacity, n.blend);
|
||||
let shared =
|
||||
@@ -305,12 +318,66 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
};
|
||||
let clips = clip_node.is_some();
|
||||
// A clipping path's own fill paints under the art it clips.
|
||||
let (fills, rest): (Vec<Indexed>, Vec<Indexed>) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id));
|
||||
let children = clip_node.into_iter().chain(fills.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect();
|
||||
// A fill read apart paints under the art it clips.
|
||||
let (apart, rest): (Vec<Indexed>, Vec<Indexed>) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id));
|
||||
let children = clip_node.into_iter().chain(fills).chain(apart.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect();
|
||||
(children, clips)
|
||||
}
|
||||
|
||||
/// Clipping path `n`'s fills as a path of their own, taken off `n`; `None` when it has no fill. The path keeps `n`'s
|
||||
/// transparency, visibility, lock and opacity mask, and none of its other attributes.
|
||||
pub(super) fn fill_apart(&mut self, n: &mut Node) -> Option<Node> {
|
||||
let is_fill = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(_));
|
||||
if !n.appearance.items.iter().any(is_fill) {
|
||||
return None;
|
||||
}
|
||||
let mut kind = n.kind.clone();
|
||||
if let NodeKind::Path { clipping, .. } = &mut kind {
|
||||
*clipping = false;
|
||||
}
|
||||
let mut fill = Node::new(self.doc.alloc_id(), kind);
|
||||
// The copies of a path read on `%_` lines are on those lines too, as `add` records them.
|
||||
let hidden = self.hidden_ids.contains(&n.id);
|
||||
if hidden {
|
||||
self.hidden_ids.insert(fill.id);
|
||||
}
|
||||
// A compound clipping path's subpaths: copies with ids of their own, linked to the same swatches, that don't clip.
|
||||
for c in fill.children_mut().into_iter().flatten() {
|
||||
let mut copy = self.doc.reid(c);
|
||||
self.reid_masks(&mut copy, 0);
|
||||
if let NodeKind::Path { clipping, .. } = &mut copy.kind {
|
||||
*clipping = false;
|
||||
}
|
||||
if hidden {
|
||||
self.hidden_ids.insert(copy.id);
|
||||
}
|
||||
*c = Arc::new(copy);
|
||||
}
|
||||
(fill.opacity, fill.blend, fill.isolate, fill.knockout, fill.knockout_shape, fill.visible, fill.locked) =
|
||||
(n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape, n.visible, n.locked);
|
||||
// The opacity mask's art is a copy with ids of its own, linked to the same swatches.
|
||||
if let Some(mut mask) = n.extras().mask.clone() {
|
||||
let mut art = self.doc.reid(&mask.art);
|
||||
self.reid_masks(&mut art, 0);
|
||||
mask.art = Arc::new(art);
|
||||
fill.extras_mut().mask = Some(mask);
|
||||
}
|
||||
let (mut fills, mut rest) = ((*n.appearance).clone(), (*n.appearance).clone());
|
||||
fills.items.retain(is_fill);
|
||||
rest.items.retain(|i| !is_fill(i));
|
||||
fill.appearance = fills.into();
|
||||
n.appearance = rest.into();
|
||||
// The new path, its subpaths and their points count toward the objects read, as a multi-layer mask's copies do.
|
||||
let mut weight = fill.count_with_masks();
|
||||
fill.walk(&mut |p| {
|
||||
if let NodeKind::Path { path, .. } = &p.kind {
|
||||
weight = weight.saturating_add(path.anchor_count());
|
||||
}
|
||||
});
|
||||
self.nodes = self.nodes.saturating_add(weight);
|
||||
Some(fill)
|
||||
}
|
||||
|
||||
pub(super) fn end_layer(&mut self) -> Result<(), String> {
|
||||
self.pending_unread_fill = false;
|
||||
self.pending_unread_stroke = false;
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::types::{
|
||||
FLIP, GState, Kind, MAX_DEPTH, MAX_HEX, MAX_LISTED, MAX_NODES, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, MAX_SYMBOLS,
|
||||
PatternHead, Place, Raster, SKIPPED, SymbolHead, VERSIONED_UNENDED,
|
||||
};
|
||||
use super::{Arc, Global, Lexer, Node, NodeKind, PatternDef, Reader, Rect, Tok, V, marker, obj};
|
||||
use super::{Arc, BrushArtColors, Global, Lexer, Node, NodeKind, PatternDef, Reader, Rect, Tok, V, marker, obj};
|
||||
use vectorcraft_doc::{NodeId, Symbol, SymbolRegistration};
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
@@ -648,10 +648,10 @@ impl<'a> Reader<'a> {
|
||||
named
|
||||
}
|
||||
|
||||
/// Give the art of the opacity masks in `n` (a copy of a symbol's art with ids of this
|
||||
/// Give the art of the opacity masks in `n` (a copy of a symbol's art or of a clipping path with ids of this
|
||||
/// document, [`vectorcraft_doc::Document::reid`], which leaves masks as they are) ids of this
|
||||
/// document too.
|
||||
fn reid_masks(&mut self, n: &mut Node, depth: usize) {
|
||||
pub(super) fn reid_masks(&mut self, n: &mut Node, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
@@ -714,7 +714,10 @@ impl<'a> Reader<'a> {
|
||||
|
||||
/// A brush's art (`section`: what follows `%AI8_BeginBrushPattern`, its name as a string, then
|
||||
/// the art), read by a reader of its own as a pattern's is, in a space with y down. Art it can't
|
||||
/// read whole isn't kept: the brushes that use it aren't read.
|
||||
/// read whole isn't kept: the brushes that use it aren't read. Kept art's colors are kept with
|
||||
/// it, and count as the page's once a brush that is kept uses the art
|
||||
/// ([`Reader::count_brush_art_colors`]): each name's first definition in the art, at most
|
||||
/// [`brush::MAX_BRUSH_ART_COLORS`] names.
|
||||
fn define_brush_art(&mut self, section: &[u8]) {
|
||||
if self.brush_art_sections >= brush::MAX_BRUSH_ART || self.subreader_depth >= MAX_SUBREADERS {
|
||||
return;
|
||||
@@ -760,7 +763,10 @@ impl<'a> Reader<'a> {
|
||||
_ => Some(Node::group(self.doc.alloc_id(), art)),
|
||||
};
|
||||
let Some(node) = node else { return };
|
||||
self.brush_art.insert(name, node);
|
||||
self.brush_art.insert(name.clone(), node);
|
||||
let mut colors = BrushArtColors { spots: sub.spots, stop_spots: sub.stop_spots, cmyk: sub.cmyk_colors, rgb: sub.rgb_colors };
|
||||
colors.keep_first(brush::MAX_BRUSH_ART_COLORS);
|
||||
self.brush_art_colors.insert(name, colors);
|
||||
}
|
||||
|
||||
/// Lend `sub`, a reader of a definition, what this import may still spend on laying out blends
|
||||
|
||||
@@ -149,15 +149,7 @@ pub fn artboards(data: &[u8]) -> (Vec<(String, Rect)>, Option<usize>) {
|
||||
}
|
||||
// Reading can stop before the artboards (art kept in the document's data comes first), or
|
||||
// between them and the active artboard's entry: read their array on its own.
|
||||
let Some((array, active)) = artboard_array(data) else { return found };
|
||||
let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec();
|
||||
alone.extend_from_slice(array);
|
||||
if let Some(active) = active {
|
||||
alone.push(b'\n');
|
||||
alone.extend_from_slice(active);
|
||||
}
|
||||
alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n");
|
||||
let (boards, active) = artboards_read(&alone);
|
||||
let Some((boards, active)) = artboard_array_read(data) else { return found };
|
||||
if found.0.is_empty() {
|
||||
(boards, active)
|
||||
} else if boards == found.0 {
|
||||
@@ -168,6 +160,29 @@ pub fn artboards(data: &[u8]) -> (Vec<(String, Rect)>, Option<usize>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The index of the active artboard (`CropAreaActive`) in the `ArtboardArray` of the editing data
|
||||
/// `data`, as [`artboards`] reads it, from that array and the entry beside it alone.
|
||||
pub fn active_artboard(data: &[u8]) -> Option<usize> {
|
||||
artboard_array_read(data)?.1
|
||||
}
|
||||
|
||||
/// Artboards as [`artboards`] gives them: names and rects in art space, and the active one's index.
|
||||
type ArtboardsRead = (Vec<(String, Rect)>, Option<usize>);
|
||||
|
||||
/// The `ArtboardArray` entry of the editing data `data` ([`artboard_array`]), with the
|
||||
/// `CropAreaActive` entry beside it, read on their own as [`artboards_read`] reads them.
|
||||
fn artboard_array_read(data: &[u8]) -> Option<ArtboardsRead> {
|
||||
let (array, active) = artboard_array(data)?;
|
||||
let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec();
|
||||
alone.extend_from_slice(array);
|
||||
if let Some(active) = active {
|
||||
alone.push(b'\n');
|
||||
alone.extend_from_slice(active);
|
||||
}
|
||||
alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n");
|
||||
Some(artboards_read(&alone))
|
||||
}
|
||||
|
||||
/// The key that ends the `ArtboardArray` entry of the editing data.
|
||||
const ARTBOARD_ARRAY: &[u8] = b"%_; (ArtboardArray) ,";
|
||||
|
||||
@@ -931,6 +946,41 @@ impl<'a> Reader<'a> {
|
||||
Some((lib, left_out + self.brushes_unread))
|
||||
}
|
||||
|
||||
/// Count the colors of the brush art that the brushes `lib` use after the colors read so far,
|
||||
/// in the order the art was read: its spot colors make swatches, and its colors count toward
|
||||
/// the color mode. Art that no brush of `lib` uses brings none of its colors. The art brings
|
||||
/// at most [`brush::MAX_BRUSH_ART_COLORS`] different color names in all, each name's color
|
||||
/// operators before its gradient stops, as [`Self::file_swatches`] takes them; its colors past
|
||||
/// them still count toward the color mode.
|
||||
fn count_brush_art_colors(&mut self, lib: &[vectorcraft_brush::Brush]) {
|
||||
let used: std::collections::HashSet<NodeId> = lib.iter().flat_map(|b| b.art()).map(|a| a.id).collect();
|
||||
let mut names: Vec<(NodeId, String)> = self.brush_art.iter().filter(|(_, a)| used.contains(&a.id)).map(|(n, a)| (a.id, n.clone())).collect();
|
||||
names.sort();
|
||||
// The names brought so far: every section's color operators', then every section's stops'.
|
||||
let mut brought = std::collections::HashSet::new();
|
||||
let mut stops = vec![];
|
||||
for (_, name) in names {
|
||||
let Some(colors) = self.brush_art_colors.remove(&name) else { continue };
|
||||
for n in colors.spots {
|
||||
if brought.contains(&n.name) || brought.len() >= brush::MAX_BRUSH_ART_COLORS {
|
||||
continue;
|
||||
}
|
||||
brought.insert(n.name.clone());
|
||||
self.spots.push(n);
|
||||
}
|
||||
stops.push(colors.stop_spots);
|
||||
self.cmyk_colors += colors.cmyk;
|
||||
self.rgb_colors += colors.rgb;
|
||||
}
|
||||
for n in stops.into_iter().flatten() {
|
||||
if brought.contains(&n.name) || brought.len() >= brush::MAX_BRUSH_ART_COLORS {
|
||||
continue;
|
||||
}
|
||||
brought.insert(n.name.clone());
|
||||
self.stop_spots.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphic styles of the style list, in its order: each listed name the art styles define with
|
||||
/// an appearance [`appearance_from_art_style`] reads, once (a name listed again, empty or not
|
||||
/// defined is left out), with ids from 1. Also the id of each kept name.
|
||||
@@ -1041,6 +1091,10 @@ impl<'a> Reader<'a> {
|
||||
};
|
||||
take_ruler_guides(&mut doc.layers, &pick, &mut doc.guides, 0);
|
||||
}
|
||||
let mut brushes = self.brush_library();
|
||||
if let Some((lib, _)) = &brushes {
|
||||
self.count_brush_art_colors(lib);
|
||||
}
|
||||
if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) {
|
||||
doc.color_mode = ColorMode::Cmyk;
|
||||
let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
|
||||
@@ -1061,7 +1115,6 @@ impl<'a> Reader<'a> {
|
||||
if let Some(author) = self.author.take() {
|
||||
doc.metadata.author = author;
|
||||
}
|
||||
let mut brushes = self.brush_library();
|
||||
// The file's graphic styles in place of the defaults, and the objects linked to them.
|
||||
let (styles, ids) = self.graphic_styles();
|
||||
let styles_read = !styles.is_empty();
|
||||
|
||||
@@ -57,32 +57,49 @@ impl Reader<'_> {
|
||||
/// `Md`: the clipping path painted last since `Mb`, in the same group or layer, is the mask.
|
||||
fn define_multi_mask(&mut self) -> Result<(), String> {
|
||||
let depth = self.frames.len();
|
||||
let Some(m) = self.multi_mask.as_mut() else { return Err("a multi-layer mask's operators are out of order".into()) };
|
||||
let Some(m) = self.multi_mask.as_ref() else { return Err("a multi-layer mask's operators are out of order".into()) };
|
||||
if m.mask.is_some() || depth == 0 {
|
||||
return Err("a multi-layer mask's operators are out of order".into());
|
||||
}
|
||||
if m.depth != depth {
|
||||
return Err("a group crosses a multi-layer mask".into());
|
||||
}
|
||||
let start = m.start;
|
||||
let Some(f) = self.frames.last_mut() else { return Err("a multi-layer mask's operators are out of order".into()) };
|
||||
let Some(at) = f.clips.iter().copied().filter(|i| *i >= m.start && *i < f.children.len()).max() else {
|
||||
let Some(at) = f.clips.iter().copied().filter(|i| *i >= start && *i < f.children.len()).max() else {
|
||||
return Err("a multi-layer mask needs one clipping path".into());
|
||||
};
|
||||
let mask = f.children.remove(at);
|
||||
let mut mask = f.children.remove(at);
|
||||
f.clips.retain(|i| *i != at);
|
||||
for i in &mut f.clips {
|
||||
if *i > at {
|
||||
*i -= 1;
|
||||
}
|
||||
}
|
||||
// The mask's fill paints once, where its path is, under the objects masked after it: it stays a path of its own
|
||||
// among them, and the copies of the mask in each layer it covers have no fill.
|
||||
if let Some(fill) = self.fill_apart(&mut mask) {
|
||||
if self.nodes > MAX_NODES {
|
||||
return Err(format!("it has more than {MAX_NODES} objects"));
|
||||
}
|
||||
let Some(f) = self.frames.last_mut() else { return Err("a multi-layer mask's operators are out of order".into()) };
|
||||
f.children.insert(at, fill);
|
||||
for i in &mut f.clips {
|
||||
if *i >= at {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut weight = mask.count_with_masks();
|
||||
mask.walk(&mut |n| {
|
||||
if let NodeKind::Path { path, .. } = &n.kind {
|
||||
weight = weight.saturating_add(path.anchor_count());
|
||||
}
|
||||
});
|
||||
m.weight = weight;
|
||||
m.mask = Some(mask);
|
||||
if let Some(m) = self.multi_mask.as_mut() {
|
||||
m.weight = weight;
|
||||
m.mask = Some(mask);
|
||||
}
|
||||
self.after_object = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ impl<'a> Reader<'a> {
|
||||
/// `AdobeURL` tag sets the object's URL, and every other tag is added to the object's data in
|
||||
/// order, up to [`MAX_TAGS`]. A tag with no object before it, with an empty identifier, or with
|
||||
/// an identifier or string longer than [`MAX_TAG_TEXT`] bytes is ignored, and so are tags in a
|
||||
/// pattern's or brush's art, which is not an object of the document. A clipping path's tags are
|
||||
/// its own, not those of the copy that paints its fill.
|
||||
/// pattern's or brush's art, which is not an object of the document. A compound clipping path's tags are its own,
|
||||
/// not those of the compound path that paints its fill apart from it.
|
||||
fn object_tag(&mut self, vals: &[V], hidden: bool) {
|
||||
let [.., V::Name(id), V::Str(value)] = vals else {
|
||||
self.note_unknown("XT", hidden);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Affine, BlendMode, Dash, FillRule, HAIRLINE, Knockout, LayerColor, LineCap, LineJoin, Node, Obj, Paint, Rect, V};
|
||||
use super::{Affine, BlendMode, Dash, FillRule, HAIRLINE, Knockout, LayerColor, LineCap, LineJoin, Named, Node, Obj, Paint, Rect, V};
|
||||
|
||||
/// Deepest nesting of layers, groups and dictionaries.
|
||||
pub(super) const MAX_DEPTH: usize = 256;
|
||||
@@ -249,6 +249,29 @@ pub(super) struct SymbolHead {
|
||||
pub(super) missing_stroke: bool,
|
||||
}
|
||||
|
||||
/// The colors a brush's art uses, counted as the page's are once a brush that is kept uses the art.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct BrushArtColors {
|
||||
/// The named colors its color operators define.
|
||||
pub(super) spots: Vec<Named>,
|
||||
/// The named colors its gradient stops name.
|
||||
pub(super) stop_spots: Vec<Named>,
|
||||
/// Its colors set in each model.
|
||||
pub(super) cmyk: usize,
|
||||
pub(super) rgb: usize,
|
||||
}
|
||||
|
||||
impl BrushArtColors {
|
||||
/// Keep each name's first definition only, its color operators' before its gradient stops',
|
||||
/// and at most `limit` names in all. The process-color counts stay as they are.
|
||||
pub(super) fn keep_first(&mut self, limit: usize) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut keep = |n: &Named| seen.len() < limit && seen.insert(n.name.clone());
|
||||
self.spots.retain(|n| keep(n));
|
||||
self.stop_spots.retain(|n| keep(n));
|
||||
}
|
||||
}
|
||||
|
||||
/// An image's operands, from `XN` and before `XI`.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct Raster {
|
||||
|
||||
@@ -187,9 +187,9 @@ fn a_note_before_a_group_or_compound_path_is_not_theirs() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A clipping path's note is its own, not that of the copy that paints its fill.
|
||||
/// A filled clipping path keeps its note, and no other object takes it.
|
||||
#[test]
|
||||
fn a_clipping_paths_note_is_not_copied_to_its_painted_fill() {
|
||||
fn a_filled_clipping_paths_note_is_its_own() {
|
||||
let body =
|
||||
format!("0 Ae\nq\n{RED}%AI3_Note:clip\n{}W\nf\n%AI3_Note:\n{}f\nQ\n", rect_path(5.0, 5.0, 25.0, 25.0), rect_path(0.0, 0.0, 30.0, 30.0));
|
||||
let doc = read_ok(&layer(&body)).doc;
|
||||
@@ -203,7 +203,7 @@ fn a_clipping_paths_note_is_not_copied_to_its_painted_fill() {
|
||||
walk(&art(&doc), &mut all);
|
||||
let noted: Vec<_> = all.iter().filter(|(_, n)| n == "clip").collect();
|
||||
assert_eq!(noted, [&(true, "clip".to_string())], "{all:?}");
|
||||
assert!(all.len() >= 3, "the painted copy is there: {all:?}");
|
||||
assert_eq!(all.len(), 3, "the group, its clipping path and the art: {all:?}");
|
||||
}
|
||||
|
||||
/// `1 Ap` shows the center point of the paths after it until `0 Ap` (§12, showCenter), which leaves
|
||||
@@ -294,8 +294,8 @@ fn object_tags_without_an_object_before_them_tag_nothing() {
|
||||
assert!(err.contains("operators VectorCraft doesn't know"), "{err}");
|
||||
}
|
||||
|
||||
/// A clipping path's tags are its own, not those of the copy that paints its fill, and a clip
|
||||
/// group's tags after `Q` are the group's. One object keeps at most 64 tags in its data.
|
||||
/// A filled clipping path's tags are its own, and a clip group's tags after `Q` are the group's.
|
||||
/// One object keeps at most 64 tags in its data.
|
||||
#[test]
|
||||
fn object_tags_on_clipping_paths_and_the_tag_limit() {
|
||||
let body = format!(
|
||||
@@ -307,6 +307,7 @@ fn object_tags_on_clipping_paths_and_the_tag_limit() {
|
||||
let top = &art(&doc);
|
||||
assert_eq!(top.len(), 1);
|
||||
assert_eq!(tags(&top[0]).1, [tag("group", "clipped")]);
|
||||
assert_eq!(top[0].children().unwrap().len(), 2, "the clipping path and the art");
|
||||
let tagged: Vec<_> = top[0].children().unwrap().iter().filter(|k| !tags(k).1.is_empty()).map(|k| k.kind.clone()).collect();
|
||||
assert_eq!(tagged.len(), 1, "{tagged:?}");
|
||||
assert!(matches!(tagged[0], NodeKind::Path { clipping: true, .. }), "{tagged:?}");
|
||||
@@ -374,6 +375,28 @@ fn a_drawn_look_keeps_its_objects_tags() {
|
||||
assert_eq!(tags(&a[0]), ("https://example.com/n".to_string(), vec![tag("own", "L"), tag("sku", "N-1")]));
|
||||
}
|
||||
|
||||
/// A clipping subpath on `%_` lines in a drawn look leaves with the fill read apart from it.
|
||||
#[test]
|
||||
fn a_hidden_clipping_paths_fill_leaves_the_look_with_it() {
|
||||
let (a, b, c) = (rect_path(0.0, 0.0, 30.0, 30.0), rect_path(5.0, 5.0, 25.0, 25.0), rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
let two = rect_path(8.0, 8.0, 20.0, 20.0);
|
||||
let second: String = format!("0 0 1 0 0 0 1 Xa\n{two}h\nW\nf\n").lines().map(|l| format!("%_{l}\n")).collect();
|
||||
let hidden_clip = format!("*u\n{RED}{a}h\nW\nf\n{second}*U\n");
|
||||
let body = format!("0 Ae\nu\nu\n{hidden_clip}{b}f\nU\nU\n0 0 Xd\n6 () XW\n%_{}n\n1 (Gone style) XW\n", c.replace('\n', "\n%_"));
|
||||
let doc = read_ok(&layer(&body)).doc;
|
||||
let mut leaves = vec![];
|
||||
doc.layers.iter().for_each(|l| {
|
||||
l.walk(&mut |n| {
|
||||
if let NodeKind::Path { clipping, .. } = &n.kind {
|
||||
leaves.push((*clipping, fill(n).color()));
|
||||
}
|
||||
})
|
||||
});
|
||||
// The first subpath, its fill and the look's plain path stay; the second subpath and its fill leave.
|
||||
let (red, blue) = (Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
assert_eq!(leaves, [(true, None), (false, red), (false, blue)]);
|
||||
}
|
||||
|
||||
/// Tags in a brush's art don't keep it from being read, and stay out of the art: it isn't an object
|
||||
/// of the document.
|
||||
#[test]
|
||||
@@ -1245,20 +1268,76 @@ fn compound_paths_and_clipping() {
|
||||
// The clipping path goes first.
|
||||
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
assert!(matches!(children[1].kind, NodeKind::Path { clipping: false, .. }));
|
||||
// A clipping path with a fill paints it too, under the art it clips; without one, nothing.
|
||||
// A clipping path with a fill keeps it, and paints it under the art it clips (`Node::clip_paint`); without one,
|
||||
// nothing.
|
||||
let blue = Some(Color::rgb(0.0, 0.0, 1.0));
|
||||
let filled = format!("0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n{}W\nf\nQ\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(5.0, 5.0, 25.0, 25.0));
|
||||
let doc = read_ok(&layer(&filled)).doc;
|
||||
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
|
||||
let kinds: Vec<_> = children.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect();
|
||||
assert_eq!(kinds, [(true, None), (false, Some(Color::rgb(0.0, 0.0, 1.0))), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]);
|
||||
assert_eq!(kinds, [(true, blue), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]);
|
||||
assert_eq!(children[0].clip_paint().fill.map(|f| fill(&f).color()), Some(blue));
|
||||
// A clipping path in a layer clips the layer.
|
||||
let doc = read_ok(&layer(&format!("{}f\n{}W\nn\n", rect_path(0.0, 0.0, 9.0, 9.0), rect_path(0.0, 0.0, 5.0, 5.0)))).doc;
|
||||
let NodeKind::Layer { clip, children, .. } = &doc.layers[0].kind else { panic!() };
|
||||
assert!(*clip && matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
// A filled clipping path in a layer keeps its fill too.
|
||||
let doc = read_ok(&layer(&format!("{RED}{}f\n0 0 1 0 0 0 1 Xa\n{}W\nf\n", rect_path(0.0, 0.0, 9.0, 9.0), rect_path(0.0, 0.0, 5.0, 5.0)))).doc;
|
||||
let NodeKind::Layer { clip: true, children, .. } = &doc.layers[0].kind else { panic!("{:?}", doc.layers[0].kind) };
|
||||
let kinds: Vec<_> = children.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect();
|
||||
assert_eq!(kinds, [(true, blue), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]);
|
||||
// A compound clipping path keeps its subpaths' fill.
|
||||
let compound = format!(
|
||||
"0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n*u\n{}h\nW\nf\n{}h\nW\nf\n*U\nQ\n",
|
||||
rect_path(0.0, 0.0, 30.0, 30.0),
|
||||
rect_path(5.0, 5.0, 25.0, 25.0),
|
||||
rect_path(10.0, 10.0, 20.0, 20.0)
|
||||
);
|
||||
let doc = read_ok(&layer(&compound)).doc;
|
||||
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
|
||||
assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children.len() == 2, "{children:?}");
|
||||
assert_eq!((fill(&children[0]).color(), fill(&children[1]).color()), (blue, Some(Color::rgb(1.0, 0.0, 0.0))));
|
||||
// One subpath filled and a later one stroked: the compound clipping path keeps the stroke, and the filled subpath's
|
||||
// fill is a compound path of its own under the art, so the stroked subpath stays unfilled.
|
||||
let mixed = format!(
|
||||
"0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n0 1 0 0 0 1 0 XA\n3 w\n*u\n{}h\nW\nf\n{}h\nW\nS\n*U\nQ\n",
|
||||
rect_path(0.0, 0.0, 30.0, 30.0),
|
||||
rect_path(5.0, 5.0, 25.0, 25.0),
|
||||
rect_path(10.0, 10.0, 20.0, 20.0)
|
||||
);
|
||||
let doc = read_ok(&layer(&mixed)).doc;
|
||||
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
|
||||
assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children.len() == 3, "{children:?}");
|
||||
let stroke = children[0].appearance.stroke().map(|s| (s.paint.color(), s.width));
|
||||
assert_eq!((fill(&children[0]).color(), stroke), (None, Some((Some(Color::rgb(0.0, 1.0, 0.0)), 3.0))));
|
||||
let NodeKind::Compound { children: filled, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) };
|
||||
assert_eq!(filled.len(), 1, "only the filled subpath: {filled:?}");
|
||||
assert!(filled.iter().all(|p| matches!(p.kind, NodeKind::Path { clipping: false, .. })), "{filled:?}");
|
||||
assert_eq!((fill(&children[1]).color(), children[1].appearance.stroke().is_none()), (blue, true));
|
||||
assert_eq!(fill(&children[2]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
// A subpath that doesn't clip keeps its fill; only the clipping subpath's fill is read apart.
|
||||
let green = Some(Color::rgb(0.0, 1.0, 0.0));
|
||||
let plain = format!(
|
||||
"0 Ae\nq\n{RED}{}f\n*u\n0 0 1 0 0 0 1 Xa\n{}f\n0 1 0 0 0 1 0 Xa\n{}h\nW\nf\n*U\nQ\n",
|
||||
rect_path(0.0, 0.0, 30.0, 30.0),
|
||||
rect_path(5.0, 5.0, 25.0, 25.0),
|
||||
rect_path(10.0, 10.0, 20.0, 20.0)
|
||||
);
|
||||
let doc = read_ok(&layer(&plain)).doc;
|
||||
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
|
||||
let NodeKind::Compound { children: subpaths, .. } = &children[0].kind else { panic!("{:?}", children[0].kind) };
|
||||
let kinds: Vec<_> = subpaths.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect();
|
||||
assert_eq!(kinds, [(false, blue), (true, None)]);
|
||||
let NodeKind::Compound { children: apart, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) };
|
||||
assert_eq!((apart.len(), fill(&children[1]).color()), (1, green), "only the clipping subpath's fill: {apart:?}");
|
||||
// A tag after the compound clipping path is its own.
|
||||
let doc = read_ok(&layer(&mixed.replace("*U\n", "*U\n/edge (clip) XT\n"))).doc;
|
||||
let NodeKind::Group { children, .. } = &art(&doc)[0].kind else { panic!("a clipping group") };
|
||||
assert_eq!((tags(&children[0]).1, tags(&children[1]).1), (vec![tag("edge", "clip")], vec![]));
|
||||
}
|
||||
|
||||
/// A stroked clipping path (`W S`, `W B`, or a gradient fill with `1 BB`) keeps its stroke on the clipping path, and a
|
||||
/// fill goes on the painted copy under the clipped art.
|
||||
/// A stroked clipping path (`W S`, `W B`, or a gradient fill with `1 BB`) keeps its stroke and its fill on the clipping
|
||||
/// path.
|
||||
#[test]
|
||||
fn a_clipping_paths_stroke_stays_on_the_clipping_path() {
|
||||
let blue = Some(Color::rgb(0.0, 0.0, 1.0));
|
||||
@@ -1279,17 +1358,11 @@ fn a_clipping_paths_stroke_stays_on_the_clipping_path() {
|
||||
let stroked = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nS\nQ\n");
|
||||
assert_eq!(children(&read_ok(&layer(&stroked)).doc), [(true, None, false, Some((blue, 3.0))), (false, red, false, None)]);
|
||||
let both = format!("0 Ae\nq\n{RED}{under}f\n0 1 0 0 0 1 0 Xa\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nB\nQ\n");
|
||||
assert_eq!(
|
||||
children(&read_ok(&layer(&both)).doc),
|
||||
[(true, None, false, Some((blue, 3.0))), (false, Some(Color::rgb(0.0, 1.0, 0.0)), false, None), (false, red, false, None)]
|
||||
);
|
||||
assert_eq!(children(&read_ok(&layer(&both)).doc), [(true, Some(Color::rgb(0.0, 1.0, 0.0)), false, Some((blue, 3.0))), (false, red, false, None)]);
|
||||
let defs = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n0 0 0 0 1 0 0 2 0 6 %_BS\n%_0 0 0 0 1 0 0 2 0 6 0 0 Bs\n\
|
||||
%_0 0 0 0 0 0 1 2 1 6 100 50 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let gradient = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nBb\n0 0 0 0 Bh\n1 (G) 5 5 0 20 1 0 0 1 0 0 1 Bg\nf\n1 BB\nQ\n");
|
||||
assert_eq!(
|
||||
children(&read_ok(&format!("{defs}{}", layer(&gradient))).doc),
|
||||
[(true, None, false, Some((blue, 3.0))), (false, None, true, None), (false, red, false, None)]
|
||||
);
|
||||
assert_eq!(children(&read_ok(&format!("{defs}{}", layer(&gradient))).doc), [(true, None, true, Some((blue, 3.0))), (false, red, false, None)]);
|
||||
// Several clipping paths each keep their own stroke, opacity and blend mode: the second one clips a clipping group
|
||||
// inside the first one's.
|
||||
let inner = rect_path(10.0, 10.0, 20.0, 20.0);
|
||||
@@ -1331,8 +1404,7 @@ fn the_compound_of_clipping_paths_keeps_their_knockout_shape() {
|
||||
|
||||
/// Several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection: each `W`
|
||||
/// reduces the clip set by the ones before it (AI7 spec §5.6.2). Each clipping path after the first clips a clipping
|
||||
/// group inside the one before it, and the innermost one holds the art; a clipping path's painted fill stays at its own
|
||||
/// level.
|
||||
/// group inside the one before it, and the innermost one holds the art; a filled clipping path keeps its fill.
|
||||
#[test]
|
||||
fn several_clipping_paths_clip_to_their_intersection() {
|
||||
let square = rect_path(0.0, 0.0, 80.0, 80.0);
|
||||
@@ -1364,11 +1436,12 @@ fn several_clipping_paths_clip_to_their_intersection() {
|
||||
assert_eq!((two.len(), two[0].len()), (2, 2), "{two:?}");
|
||||
assert_eq!(two[0][0], (true, None, ra));
|
||||
assert_eq!(two[1], [(true, None, rb), (false, red, r(0.0, 0.0, 80.0, 80.0))]);
|
||||
// Three clipping paths nest twice; a filled clipping path's painted copy is clipped by the paths before it only.
|
||||
// Three clipping paths nest twice; a filled clipping path keeps its fill at its own level, so the fill is clipped
|
||||
// by that path and the paths before it only.
|
||||
let three =
|
||||
group(&format!("0 Ae\nq\n{a}h\nW\nn\n0 0 1 0 0 0 1 Xa\n{b}h\nW\nf\n{}h\nW\nn\n{RED}{square}f\nQ\n", rect_path(30.0, 10.0, 50.0, 90.0)));
|
||||
assert_eq!(three.len(), 3, "{three:?}");
|
||||
assert_eq!(three[1][..2], [(true, None, rb), (false, blue, rb)]);
|
||||
assert_eq!((three[1].len(), three[1][0]), (2, (true, blue, rb)), "{three:?}");
|
||||
assert_eq!(three[2], [(true, None, r(30.0, 10.0, 50.0, 90.0)), (false, red, r(0.0, 0.0, 80.0, 80.0))]);
|
||||
// As with one clipping path, every object in the group is clipped by all of them, in the order written, wherever
|
||||
// it is written.
|
||||
@@ -1766,6 +1839,61 @@ fn a_compound_path_holding_its_drawn_look_paints_its_paths_apart() {
|
||||
assert_eq!(fills, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{a:#?}");
|
||||
}
|
||||
|
||||
/// Clipping paths in a compound path that holds its drawn look clip as one compound path. Each one's fill is a path of
|
||||
/// its own under the art they clip, with the clipping path's opacity mask, so overlapping fills and fills that differ
|
||||
/// paint as each clipping path paints them.
|
||||
#[test]
|
||||
fn clipping_paths_beside_a_compound_paths_look_keep_their_fills() {
|
||||
let (a, b) = (rect_path(0.0, 0.0, 20.0, 20.0), rect_path(5.0, 5.0, 25.0, 25.0));
|
||||
let look = format!("0 Ae\nu\n{RED}{}f\n0 0 0 0 0 0 1 Xa\n{}f\nU\n9 () XW\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(10.0, 10.0, 15.0, 15.0));
|
||||
let mask_art: String =
|
||||
format!("0 0 0 0 (Global white) 1 0 Xk\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)).lines().map(|l| format!("%_{l}\n")).collect();
|
||||
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_; (AI9_Mask) ,\n%_;\n");
|
||||
let children = |second: &str, after_first: &str| {
|
||||
let body = format!("0 Ae\n*u\n{RED}{a}h\nW\nf\n{after_first}{second}{b}h\nW\nf\n{look}*U\n");
|
||||
let doc = read_ok(&layer(&body)).doc;
|
||||
// Every object, opacity masks' art included, has an id of its own.
|
||||
fn ids(n: &Node, seen: &mut std::collections::HashSet<vectorcraft_doc::NodeId>) {
|
||||
assert!(seen.insert(n.id), "{:?} appears twice", n.id);
|
||||
if let Some(m) = &n.extras().mask {
|
||||
ids(&m.art, seen);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
ids(c, seen);
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
doc.layers.iter().for_each(|l| ids(l, &mut seen));
|
||||
let a = art(&doc);
|
||||
let NodeKind::Group { children, clip: true } = &a[0].kind else { panic!("{a:#?}") };
|
||||
// An opacity mask: the swatch its art's fill is linked to.
|
||||
let mask = |c: &Node| {
|
||||
c.extras().mask.as_ref().map(|m| {
|
||||
let mut swatch = None;
|
||||
m.art.walk(&mut |p| {
|
||||
if let Paint::Solid { swatch: Some(s), .. } = fill(p) {
|
||||
swatch = Some(s);
|
||||
}
|
||||
});
|
||||
swatch
|
||||
})
|
||||
};
|
||||
children.iter().map(|c| (c.kind.clone(), fill(c).color(), mask(c))).collect::<Vec<_>>()
|
||||
};
|
||||
let (red, blue) = (Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
// A child's kind, fill color and, with an opacity mask, the swatch its art is linked to.
|
||||
type Child = (NodeKind, Option<Color>, Option<Option<String>>);
|
||||
let paths = |c: &[Child]| {
|
||||
assert!(matches!(c[0].0, NodeKind::Compound { .. }) && c.len() == 4, "{c:?}");
|
||||
c[..3].iter().map(|(k, c, m)| (matches!(k, NodeKind::Path { clipping: false, .. }), *c, m.clone())).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(paths(&children("", "")), [(false, None, None), (true, red, None), (true, red, None)]);
|
||||
assert_eq!(paths(&children("0 0 0 0 0 0 1 Xa\n", "")), [(false, None, None), (true, red, None), (true, blue, None)]);
|
||||
// The fill's copy of the mask stays linked to the mask art's global swatch.
|
||||
let linked = Some(Some("Global white".to_string()));
|
||||
assert_eq!(paths(&children("0 0 0 0 0 0 1 Xa\n", &mask)), [(false, None, None), (true, red, linked), (true, blue, None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compound_path_of_a_plain_group_stays_one_compound_path() {
|
||||
// GUARDS: #980. Files write `9 () XW` after every group, the one a compound path keeps its
|
||||
@@ -3426,6 +3554,31 @@ fn legacy_type_keeps_its_writing_direction() {
|
||||
let after = point_type("", "Hi").replace("TO\n", "1 TV\nTO\n");
|
||||
let after = legacy_texts(&format!("{after}{}", point_type("", "Next")));
|
||||
assert_eq!((after[0].vertical, after[1].vertical), (false, true));
|
||||
// An overflow break (`(\r) TX`) keeps the direction in force where it was read: at the start of
|
||||
// the story it sets the object's direction, and later it warns when that differs.
|
||||
let leading = legacy_read(&point_type("1 TV\n(\\r) TX\n0 TV", "Body"));
|
||||
let leading = (art_texts(&leading.doc), leading.warnings);
|
||||
assert_eq!((leading.0[0].vertical, leading.0[0].plain_text().as_str()), (true, "\nBody"));
|
||||
assert_eq!(leading.1, [DIRECTION_CHANGE_LEFT_OUT]);
|
||||
let inside = legacy_read(&point_type("", "a").replace("TO\n", "1 TV\n(\\r) TX\n0 TV\n(b) Tx\nTO\n"));
|
||||
let inside = (art_texts(&inside.doc), inside.warnings);
|
||||
assert_eq!((inside.0[0].vertical, inside.0[0].plain_text().as_str()), (false, "a\nb"));
|
||||
assert_eq!(inside.1, [DIRECTION_CHANGE_LEFT_OUT]);
|
||||
}
|
||||
|
||||
/// An overflow break (`(\r) TX`) that type follows ends a paragraph as `(\r) Tx` does: an empty
|
||||
/// paragraph it ends keeps the attributes in force where the break was read.
|
||||
#[test]
|
||||
fn an_overflow_break_keeps_the_paragraph_attributes_it_was_read_with() {
|
||||
use vectorcraft_doc::Justify::{Center, Right};
|
||||
for op in ["Tx", "TX"] {
|
||||
let leading = &legacy_texts(&point_type(&format!("1 Ta\n(\\r) {op}\n2 Ta"), "Body"))[0];
|
||||
assert_eq!((leading.para_at(0).justify, leading.para_at(1).justify), (Center, Right), "{op}");
|
||||
let empty = point_type("1 Ta", "a\\r").replace("TO\n", &format!("(\\r) {op}\n2 Ta\n(b) Tx\nTO\n"));
|
||||
let empty = &legacy_texts(&empty)[0];
|
||||
assert_eq!(empty.plain_text(), "a\n\nb", "{op}");
|
||||
assert_eq!((empty.para_at(1).justify, empty.para_at(2).justify), (Center, Right), "{op}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph origins of a legacy-format text object.
|
||||
@@ -3700,7 +3853,7 @@ fn legacy_type_in_a_mask_rendering_mode_clips_its_clipping_group() {
|
||||
assert!(matches!(inner[0].kind, NodeKind::Path { clipping: true, .. }) && matches!(inner[1].kind, NodeKind::Path { clipping: false, .. }));
|
||||
}
|
||||
|
||||
/// A filled clipping path (`W` then `f`) beside type used as a mask: its painted fill stays at its own path's level,
|
||||
/// A filled clipping path (`W` then `f`) beside type used as a mask keeps its fill, which paints at its own path's level,
|
||||
/// as beside another clipping path (the reader's rule for `W` paths). Written after the type, it is painted within the
|
||||
/// type's mask, since a later mask narrows the clip set by the earlier ones (§5.6.2); written before it, the type doesn't
|
||||
/// clip it.
|
||||
@@ -3709,23 +3862,21 @@ fn a_filled_clipping_path_beside_legacy_type_used_as_a_mask() {
|
||||
let square = format!("1 0 0 Xa\n{}f\n", rect_path(0.0, 0.0, 100.0, 100.0));
|
||||
let filled = format!("0 0 1 Xa\n{}h\nW\nf\n", rect_path(0.0, 0.0, 30.0, 30.0));
|
||||
let text = point_type("7 Tr", "Hi");
|
||||
let is_fill = |n: &Node| matches!(n.kind, NodeKind::Path { clipping: false, .. }) && fill(n).color() == Some(Color::rgb(0.0, 0.0, 1.0));
|
||||
let is_clip = |n: &Node| matches!(n.kind, NodeKind::Path { clipping: true, .. }) && fill(n).color() == Some(Color::rgb(0.0, 0.0, 1.0));
|
||||
let is_art = |n: &Node| matches!(n.kind, NodeKind::Path { clipping: false, .. }) && fill(n).color() == Some(Color::rgb(1.0, 0.0, 0.0));
|
||||
// The type first: the path's fill is in the clipping group the path clips, inside the type's.
|
||||
// The type first: the filled path clips a clipping group inside the type's.
|
||||
let doc = legacy_read(&format!("q\n{text}{filled}{square}Q\n")).doc;
|
||||
let a = art(&doc);
|
||||
let NodeKind::Group { children, clip: true } = &a[0].kind else { panic!("{:?}", a[0].kind) };
|
||||
assert!(matches!(children[0].kind, NodeKind::Text(_)) && children.len() == 2, "{children:?}");
|
||||
let NodeKind::Group { children: inner, clip: true } = &children[1].kind else { panic!("{:?}", children[1].kind) };
|
||||
assert!(matches!(inner[0].kind, NodeKind::Path { clipping: true, .. }), "{inner:?}");
|
||||
assert!(is_fill(&inner[1]) && is_art(&inner[2]) && inner.len() == 3, "{inner:?}");
|
||||
// The path first: its fill is in the outer clipping group, which only the path clips; the type clips the art.
|
||||
assert!(is_clip(&inner[0]) && is_art(&inner[1]) && inner.len() == 2, "{inner:?}");
|
||||
// The path first: it clips the outer clipping group, so only the path clips its fill; the type clips the art.
|
||||
let doc = legacy_read(&format!("q\n{filled}{text}{square}Q\n")).doc;
|
||||
let a = art(&doc);
|
||||
let NodeKind::Group { children, clip: true } = &a[0].kind else { panic!("{:?}", a[0].kind) };
|
||||
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }), "{children:?}");
|
||||
assert!(is_fill(&children[1]) && children.len() == 3, "{children:?}");
|
||||
let NodeKind::Group { children: inner, clip: true } = &children[2].kind else { panic!("{:?}", children[2].kind) };
|
||||
assert!(is_clip(&children[0]) && children.len() == 2, "{children:?}");
|
||||
let NodeKind::Group { children: inner, clip: true } = &children[1].kind else { panic!("{:?}", children[1].kind) };
|
||||
assert!(matches!(inner[0].kind, NodeKind::Text(_)) && is_art(&inner[1]) && inner.len() == 2, "{inner:?}");
|
||||
}
|
||||
|
||||
@@ -5206,8 +5357,9 @@ fn artboards_after_what_stops_the_reading() {
|
||||
|
||||
#[test]
|
||||
fn a_compound_clipping_path_with_a_fill_paints_once_under_its_clip() {
|
||||
// GUARDS: the painted copies of a compound's clipping subpaths went into the compound itself,
|
||||
// doubling each subpath, so under even-odd the clip cancelled to nothing.
|
||||
// GUARDS: copies that painted a compound's clipping subpaths went into the compound itself,
|
||||
// doubling each subpath, so under even-odd the clip cancelled to nothing. The compound
|
||||
// clipping path keeps the fill itself.
|
||||
let art_ = format!(
|
||||
"0 Ae\nq\n{RED}{}f\n1 XR\n*u\n0 0 0 0 0 0 1 Xa\n{}W\nf\n{}W\nf\n*U\nQ\n",
|
||||
rect_path(0.0, 0.0, 100.0, 100.0),
|
||||
@@ -5216,14 +5368,13 @@ fn a_compound_clipping_path_with_a_fill_paints_once_under_its_clip() {
|
||||
);
|
||||
let doc = read_ok(&layer(&art_)).doc;
|
||||
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
|
||||
assert_eq!(children.len(), 3, "{children:#?}");
|
||||
assert_eq!(children.len(), 2, "{children:#?}");
|
||||
let NodeKind::Compound { children: clip, rule } = &children[0].kind else { panic!("{:?}", children[0].kind) };
|
||||
assert_eq!(*rule, vectorcraft_geom::FillRule::EvenOdd);
|
||||
assert_eq!(clip.len(), 2);
|
||||
assert!(clip.iter().all(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })));
|
||||
let NodeKind::Compound { children: painted, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) };
|
||||
assert_eq!((painted.len(), fill(&children[1]).color()), (2, Some(Color::rgb(0.0, 0.0, 1.0))));
|
||||
assert_eq!(fill(&children[2]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert_eq!(fill(&children[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
assert_eq!(fill(&children[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7797,6 +7948,118 @@ fn a_brushs_art_takes_the_patterns_defined_before_it() {
|
||||
assert!(matches!(fill(&s.art), Paint::Pattern { ref pattern, .. } if pattern == "Dots"), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A spot color that only a brush's art uses comes in as a swatch, and the art's process colors
|
||||
/// count toward the document's color mode, as a pattern's do.
|
||||
#[test]
|
||||
fn a_brushs_art_brings_its_spot_colors_and_color_mode() {
|
||||
let section = format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n0 1 1 0 (Ink) 0 x\n{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 4.0, 4.0));
|
||||
let r = read_ok(&format!("{section}{}{}", plugin_object(SCATTER, "Spray", SPRAY), layer("")));
|
||||
let lib = vectorcraft_brush::library(&r.doc);
|
||||
assert!(matches!(brush_named(&lib, "Spray"), vectorcraft_brush::BrushKind::Scatter(_)), "{:?}", r.warnings);
|
||||
let ink = r.doc.swatch("Ink").map(|s| (s.paint.color(), s.global, s.spot));
|
||||
assert_eq!(ink, Some((Some(Color::cmyk(0.0, 1.0, 1.0, 0.0)), true, true)), "{:?}", r.warnings);
|
||||
assert_eq!(r.doc.color_mode, ColorMode::Cmyk);
|
||||
// Art that isn't kept brings nothing.
|
||||
let odd = "%AI8_BeginBrushPattern\n(Odd art)\n0 1 1 0 (Lost ink) 0 x\n0 0 m\n1 1 L\nnotanoperator\nf\n%AI8_EndBrushPattern\n";
|
||||
let r = read_ok(&format!("{odd}{}{}", plugin_object(SCATTER, "Odd", &SPRAY.replace("Dot art", "Odd art")), layer("")));
|
||||
assert!(r.doc.swatch("Lost ink").is_none());
|
||||
assert_eq!(r.doc.color_mode, ColorMode::Rgb);
|
||||
// Nor does art that only a brush left out uses (a pattern brush).
|
||||
let tile = "%AI8_BeginBrushPattern\n(Tile one)\n0 A\n0 1 1 0 (Fence ink) 0 x\n0 0 m\n4 0 L\n4 4 L\nf\n%AI8_EndBrushPattern\n";
|
||||
let r = read_ok(&format!("{tile}{}{}", plugin_object(PATTERN_BRUSH, "Fence", FENCE), layer("")));
|
||||
assert!(vectorcraft_brush::library(&r.doc).is_empty(), "{:?}", r.warnings);
|
||||
assert!(r.doc.swatch("Fence ink").is_none());
|
||||
assert_eq!(r.doc.color_mode, ColorMode::Rgb);
|
||||
}
|
||||
|
||||
/// A spot color that the page's art and a brush's art both define keeps the page's value, and a
|
||||
/// brush art section that a later one of the same name replaces brings none of its colors.
|
||||
#[test]
|
||||
fn a_brushs_art_colors_come_after_the_pages_and_a_replaced_sections_bring_nothing() {
|
||||
let section = |ink: &str, color: &str| {
|
||||
format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n{color} ({ink}) 0 x\n{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 4.0, 4.0))
|
||||
};
|
||||
let page = layer(&format!("0 0 1 0 (Ink) 0 x\n{}f", rect_path(0.0, 0.0, 10.0, 10.0)));
|
||||
let body = format!("{}{}{}{page}", section("Gone ink", "1 0 0 0"), section("Ink", "0 1 1 0"), plugin_object(SCATTER, "Spray", SPRAY));
|
||||
let r = read_ok(&body);
|
||||
assert!(matches!(brush_named(&vectorcraft_brush::library(&r.doc), "Spray"), vectorcraft_brush::BrushKind::Scatter(_)));
|
||||
assert_eq!(r.doc.swatch("Ink").and_then(|s| s.paint.color()), Some(Color::cmyk(0.0, 0.0, 1.0, 0.0)), "{:?}", r.warnings);
|
||||
assert!(r.doc.swatch("Gone ink").is_none());
|
||||
}
|
||||
|
||||
/// The art of a file's brushes brings each color name's first definition once, and at most
|
||||
/// [`super::brush::MAX_BRUSH_ART_COLORS`] names; the colors it leaves out still count toward the
|
||||
/// color mode, and art that no brush uses, or that a later section replaces, takes no places.
|
||||
#[test]
|
||||
fn brush_art_keeps_each_color_name_once_and_a_bounded_number_of_names() {
|
||||
let open = |sections: &[(&str, &str)]| {
|
||||
let defs: String = sections
|
||||
.iter()
|
||||
.map(|(name, colors)| {
|
||||
format!("%AI8_BeginBrushPattern\n({name})\n0 A\n{colors}{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 4.0, 4.0))
|
||||
})
|
||||
.collect();
|
||||
let mist = plugin_object(SCATTER, "Mist", &SPRAY.replace("Dot art", "Dash art"));
|
||||
read_ok(&format!("{defs}{}{mist}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(""))).doc
|
||||
};
|
||||
let max = super::brush::MAX_BRUSH_ART_COLORS;
|
||||
let ink = "0 1 1 0 (Ink) 0 x\n";
|
||||
let many: String = (0..max).map(|i| format!("1 0 0 0 (Name {i}) 0 x\n")).collect();
|
||||
// One name repeated takes one place.
|
||||
let doc = open(&[("Dot art", &format!("{}{ink}", "1 0 0 0 (Same) 0 x\n".repeat(max)))]);
|
||||
assert!(doc.swatch("Same").is_some() && doc.swatch("Ink").is_some());
|
||||
// Different names fill the places: the name past them is left out, and its color still counts.
|
||||
let doc = open(&[("Dot art", &format!("{many}{ink}"))]);
|
||||
assert!(doc.swatch("Name 0").is_some() && doc.swatch("Ink").is_none());
|
||||
assert_eq!(doc.color_mode, ColorMode::Cmyk);
|
||||
// The places are shared by the art of every brush, and a name in the art of two takes one.
|
||||
let doc = open(&[("Dot art", &many), ("Dash art", ink)]);
|
||||
assert_eq!(vectorcraft_brush::library(&doc).len(), 2);
|
||||
assert!(doc.swatch("Name 0").is_some() && doc.swatch("Ink").is_none());
|
||||
let half: String = many.lines().take(max / 2).map(|l| format!("{l}\n")).collect();
|
||||
let doc = open(&[("Dot art", &half), ("Dash art", &format!("{half}{ink}"))]);
|
||||
assert!(doc.swatch("Name 0").is_some() && doc.swatch("Ink").is_some());
|
||||
// The color operators of every brush's art take places before the gradient stops of any.
|
||||
let stops: String = (0..max / 250)
|
||||
.map(|g| {
|
||||
let named: String = (0..250).map(|s| format!("%_0 0 1 0 (Stop {g} {s}) 0 0 5 1 6 50 {} Bs\n", s * 100 / 250)).collect();
|
||||
format!("%AI5_BeginGradient: (G{g})\n(G{g}) 0 250 Bd\n[\n<00FF>\n0\n{named}BD\n%AI5_EndGradient\n")
|
||||
})
|
||||
.collect();
|
||||
let doc = open(&[("Dot art", &stops), ("Dash art", ink)]);
|
||||
assert!(doc.swatch("Stop 0 0").is_some() && doc.swatch("Ink").is_some(), "{:?}", doc.swatch("Stop 0 0"));
|
||||
// Art that no brush uses, and art that a later section of its name replaces, take no places.
|
||||
let doc = open(&[("Other art", &many), ("Dot art", &many), ("Dot art", ink)]);
|
||||
assert!(doc.swatch("Ink").is_some() && doc.swatch("Name 0").is_none());
|
||||
}
|
||||
|
||||
/// A global process color that only a brush's art defines with its color operator stays a process
|
||||
/// color when a gradient of the file names it as a stop, as the page's own definition does (#1771).
|
||||
#[test]
|
||||
fn a_brushs_art_defines_a_process_color_that_a_gradient_stop_names() {
|
||||
let defs = "%AI5_BeginGradient: (Brand)\n(Brand) 0 2 Bd\n[\n<00FF>\n0\n\
|
||||
%_0.93 0.1 0 0 (Test Cyan) 0 0 5 1 6 50 0 Bs\n%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let section = format!(
|
||||
"%AI8_BeginBrushPattern\n(Dot art)\n0 A\n0.93 0.1 0 0 (Test Cyan) 0 0 Xk\n{}f\n%AI8_EndBrushPattern\n",
|
||||
rect_path(0.0, 0.0, 4.0, 4.0)
|
||||
);
|
||||
let doc = read_ok(&format!("{defs}{section}{}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(""))).doc;
|
||||
assert!(matches!(brush_named(&vectorcraft_brush::library(&doc), "Spray"), vectorcraft_brush::BrushKind::Scatter(_)));
|
||||
let sw = doc.swatch("Test Cyan").unwrap();
|
||||
assert!(sw.global && !sw.spot, "a global process color, not an ink: {sw:?}");
|
||||
}
|
||||
|
||||
/// A brush art section inside a symbol's definition isn't kept, so its spot colors make no swatch.
|
||||
#[test]
|
||||
fn brush_art_inside_a_symbol_brings_no_colors() {
|
||||
let section =
|
||||
format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n0 1 1 0 (Hidden ink) 0 x\n{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 4.0, 4.0));
|
||||
let body = format!("{}{}{}", symbol("S", &format!("{section}{}", dot()), 5), plugin_object(SCATTER, "Spray", SPRAY), layer(""));
|
||||
let r = read_ok(&body);
|
||||
assert!(r.doc.symbols.iter().any(|s| s.name == "S"), "{:?}", r.warnings);
|
||||
assert!(r.doc.swatch("Hidden ink").is_none());
|
||||
}
|
||||
|
||||
/// A brush's art filled with a pattern defined in the version 7 form places the tiles with the
|
||||
/// fill's offset, scale, rotation, reflection and shear operands, and a brush whose art has such a
|
||||
/// fill without all nine of them isn't read.
|
||||
@@ -8552,13 +8815,31 @@ fn a_multi_layer_mask_in_a_group_or_sublayer() {
|
||||
|
||||
#[test]
|
||||
fn a_multi_layer_masks_path_can_be_painted_or_compound() {
|
||||
// `W f`: the path also paints, under what the mask covers.
|
||||
// `W f`: the path also paints, once, under what the mask covers after it: its fill is a path of its own in the first
|
||||
// layer, and the copies of the mask in each layer have no fill.
|
||||
let black = Some(Color::cmyk(0.0, 0.0, 0.0, 1.0));
|
||||
let painted = format!("{}Mb\n0 0 0 1 k\n{}h\nW\nf\nMd\n{}MB\n{END7}", begin7("A", 1), rect_path(40.0, 40.0, 120.0, 120.0), masked(15.0));
|
||||
let doc = read7(&painted).doc;
|
||||
assert_eq!(layer_trees(&doc)[0].1, "k[c p p]");
|
||||
let group = doc.layers[0].children().unwrap()[0].clone();
|
||||
let kids = group.children().unwrap();
|
||||
assert_eq!(fill(&kids[1]).color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the painted path first");
|
||||
assert_eq!(fill(&kids[0]), Paint::None, "the clipping path has no fill");
|
||||
assert_eq!(fill(&kids[1]).color(), black, "the painted path first");
|
||||
let two = format!(
|
||||
"{}Mb\n0 0 0 1 k\n{}h\nW\nf\nMd\n{}{END7}{}{}MB\n{END7}",
|
||||
begin7("A", 1),
|
||||
rect_path(40.0, 40.0, 120.0, 120.0),
|
||||
masked(15.0),
|
||||
begin7("B", 1),
|
||||
masked(30.0)
|
||||
);
|
||||
let doc = read7(&two).doc;
|
||||
assert_eq!(layer_trees(&doc), [("A".to_string(), "k[c p p]".to_string(), false), ("B".to_string(), "k[c p]".to_string(), false)]);
|
||||
let kids = |l: usize| doc.layers[l].children().unwrap()[0].children().unwrap().to_vec();
|
||||
let (a, b) = (kids(0), kids(1));
|
||||
assert_eq!((fill(&a[0]), fill(&b[0])), (Paint::None, Paint::None), "no copy of the mask paints");
|
||||
assert_eq!(fill(&a[1]).color(), black);
|
||||
assert_eq!(fill(&b[1]).color(), Some(Color::cmyk(0.5, 0.0, 0.0, 0.0)), "layer B's square, unpainted over");
|
||||
// A compound path of clipping paths.
|
||||
let compound = format!(
|
||||
"{}Mb\n*u\n{}h\nW\nn\n{}h\nW\nn\n*U\nMd\n{}MB\n{END7}",
|
||||
@@ -8568,6 +8849,28 @@ fn a_multi_layer_masks_path_can_be_painted_or_compound() {
|
||||
masked(15.0)
|
||||
);
|
||||
assert_eq!(layer_trees(&read7(&compound).doc)[0].1, "k[o p]");
|
||||
// A filled one over two layers: its fill is a compound path of its own in the first, whose paths don't clip.
|
||||
let filled = format!(
|
||||
"{}Mb\n0 0 0 1 k\n*u\n{}h\nW\nf\n{}h\nW\nf\n*U\nMd\n{}{END7}{}{}MB\n{END7}",
|
||||
begin7("A", 1),
|
||||
rect_path(40.0, 40.0, 80.0, 80.0),
|
||||
rect_path(90.0, 90.0, 120.0, 120.0),
|
||||
masked(15.0),
|
||||
begin7("B", 1),
|
||||
masked(30.0)
|
||||
);
|
||||
let doc = read7(&filled).doc;
|
||||
assert_eq!(layer_trees(&doc), [("A".to_string(), "k[o o p]".to_string(), false), ("B".to_string(), "k[o p]".to_string(), false)]);
|
||||
let kids = |l: usize| doc.layers[l].children().unwrap()[0].children().unwrap().to_vec();
|
||||
let (a, b) = (kids(0), kids(1));
|
||||
assert_eq!((fill(&a[0]), fill(&b[0])), (Paint::None, Paint::None), "no copy of the mask paints");
|
||||
assert_eq!(fill(&a[1]).color(), black);
|
||||
let NodeKind::Compound { children: subpaths, .. } = &a[1].kind else { panic!("{:?}", a[1].kind) };
|
||||
assert!(subpaths.iter().all(|p| matches!(p.kind, NodeKind::Path { clipping: false, .. })), "{subpaths:#?}");
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for l in &doc.layers {
|
||||
l.walk(&mut |n| assert!(ids.insert(n.id), "{:?} appears twice", n.id));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mv` starts a mask as `Mb` does, `ME` defines one as `Md` does, and `Mi` changes nothing.
|
||||
@@ -8650,4 +8953,8 @@ fn copies_of_a_multi_layer_mask_count_toward_the_limit() {
|
||||
let two = format!("{}{mask}{}{END7}{}{}MB\n{END7}", begin7("A", 1), masked(15.0), begin7("B", 1), masked(30.0));
|
||||
let e = read(mask_stream7(&two).as_bytes()).unwrap_err();
|
||||
assert!(e.contains("objects"), "{e}");
|
||||
// A filled mask's fill, a path of its own, counts too.
|
||||
let filled = format!("{}{}{}MB\n{END7}", begin7("A", 1), mask.replace("W\nn\n", "W\nf\n"), masked(15.0));
|
||||
let e = read(mask_stream7(&filled).as_bytes()).unwrap_err();
|
||||
assert!(e.contains("objects"), "{e}");
|
||||
}
|
||||
|
||||
+469
-33
@@ -5,12 +5,13 @@
|
||||
//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys. A text object
|
||||
//! in a layer is only a stub naming its story in it (`/StoryIndex`).
|
||||
//!
|
||||
//! The format isn't documented. What is read here was worked out from the files of the project's
|
||||
//! users, used locally and never committed, by comparing it with the type their pages draw: the
|
||||
//! meaning of each key below was checked against the page's own type in more than a thousand text
|
||||
//! objects, and it is only used for the keys that matched. Point type, area type and type on a path
|
||||
//! are read; a story in a frame of any other kind is left alone: it has no text, and the importer
|
||||
//! says so.
|
||||
//! The format isn't documented. Most of what is read here was worked out from the files of the
|
||||
//! project's users, used locally and never committed, by comparing it with the type their pages
|
||||
//! draw: the meaning of those keys was checked against the page's own type in more than a thousand
|
||||
//! text objects, and it is only used for the keys that matched. The Type on a Path Options of key
|
||||
//! 11 of a frame come from test files saved with one option changed at a time, used locally and
|
||||
//! never committed. Point type, area type and type on a path are read; a story in a frame of any
|
||||
//! other kind is left alone: it has no text, and the importer says so.
|
||||
//!
|
||||
//! Where type sits: a story lays its lines out round a point `F` (the anchor: the start of a line of
|
||||
//! left-aligned type, the middle of a centred one, the end of a right-aligned one), each line with an
|
||||
@@ -24,7 +25,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
|
||||
/// Longest story read (bytes).
|
||||
@@ -254,7 +255,46 @@ enum FrameKind {
|
||||
/// Area Type Options the frame keeps.
|
||||
Area(Vec<[f64; 8]>, AreaFrame),
|
||||
/// Type on a path, from `start` to `end` (segment index and the place in that segment).
|
||||
OnPath { segments: Vec<[f64; 8]>, start: f64, end: f64 },
|
||||
OnPath { segments: Vec<[f64; 8]>, start: f64, end: f64, options: PathFrame },
|
||||
}
|
||||
|
||||
/// The Type on a Path Options a frame of type on a path keeps at key 11. Each stays at its default
|
||||
/// where the frame leaves the key out or keeps a value that can't be read. A frame with key 6 true
|
||||
/// keeps its defaults: what that state stores for these options isn't known.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
struct PathFrame {
|
||||
/// Key 1: 1 Skew, 2 3D Ribbon, 3 Stair Step, 4 Gravity. Rainbow is the key left out.
|
||||
effect: PathEffect,
|
||||
/// Key 2: 0 Ascender, 1 Descender, 2 Center. Baseline is the key left out.
|
||||
align: PathAlign,
|
||||
/// Key 0: the type is flipped across its path. The frame's path runs as it did before the flip,
|
||||
/// from the start of its range.
|
||||
flip: bool,
|
||||
}
|
||||
|
||||
impl PathFrame {
|
||||
/// The options frame data `carries` keeps.
|
||||
fn read(carries: &Val) -> PathFrame {
|
||||
let Some(options) = carries.get("11") else { return PathFrame::default() };
|
||||
if matches!(options.get("6"), Some(Val::Bool(true))) {
|
||||
return PathFrame::default();
|
||||
}
|
||||
let code = |k: &str| options.get(k).and_then(Val::num);
|
||||
let effect = match code("1") {
|
||||
Some(1.0) => PathEffect::Skew,
|
||||
Some(2.0) => PathEffect::Ribbon3d,
|
||||
Some(3.0) => PathEffect::StairStep,
|
||||
Some(4.0) => PathEffect::Gravity,
|
||||
_ => PathEffect::Rainbow,
|
||||
};
|
||||
let align = match code("2") {
|
||||
Some(0.0) => PathAlign::Ascender,
|
||||
Some(1.0) => PathAlign::Descender,
|
||||
Some(2.0) => PathAlign::Center,
|
||||
_ => PathAlign::Baseline,
|
||||
};
|
||||
PathFrame { effect, align, flip: matches!(options.get("0"), Some(Val::Bool(true))) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The Area Type Options an area frame keeps, in the units of the text document: each `None`
|
||||
@@ -484,7 +524,7 @@ impl Texts {
|
||||
let start = finite(range.first()).unwrap_or(0.0).clamp(0.0, n);
|
||||
(start, finite(range.get(1)).unwrap_or(n).clamp(start, n))
|
||||
};
|
||||
Some(FrameKind::OnPath { segments, start, end })
|
||||
Some(FrameKind::OnPath { segments, start, end, options: PathFrame::read(carries) })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -527,8 +567,10 @@ impl Texts {
|
||||
if let Some(f) = view.get("2").and_then(|l| find_node(l, "F")) {
|
||||
// Vertical type on a path turns its glyphs across the path: their matrices don't give
|
||||
// the spacing.
|
||||
if matches!(frames.first(), Some(FrameKind::OnPath { .. })) && !vertical {
|
||||
path_spacing = self::path_spacing(f);
|
||||
if let Some(FrameKind::OnPath { options, .. }) = frames.first()
|
||||
&& !vertical
|
||||
{
|
||||
path_spacing = self::path_spacing(f, options.effect);
|
||||
}
|
||||
if let Some(a) = f.get("0").and_then(|a| a.get("0")).and_then(Val::nums)
|
||||
&& let [x, y, ..] = a.as_slice()
|
||||
@@ -721,16 +763,32 @@ fn length_fraction(segments: &[[f64; 8]], t: f64) -> f64 {
|
||||
/// (`vectorcraft_text`'s `path_steps`): between two glyphs' centres, the path's length `s`, their
|
||||
/// advances' mean `a` and how far it turns `t` (away from their tops) make `s + t × spacing = a`.
|
||||
/// Fitted over every pair of the layout's glyphs; none where the path doesn't turn under them.
|
||||
fn path_spacing(f: &Val) -> f64 {
|
||||
/// The length `s` is on the path: Align to Path moves the baselines off it, by the distance `G`
|
||||
/// keeps. Under 3D Ribbon the glyphs' baselines stay level, so their feet show how the path turns.
|
||||
fn path_spacing(f: &Val, effect: PathEffect) -> f64 {
|
||||
let mut found = vec![];
|
||||
find_nodes(f, "G", &mut found);
|
||||
let (mut sum, mut turns) = (0.0, 0.0);
|
||||
for g in found {
|
||||
// How far the baseline lies from the path, towards the glyphs' feet. Only Rainbow's and 3D
|
||||
// Ribbon's feet are square to the path; Skew's are upright, so its alignment moves every
|
||||
// baseline the same way and leaves the distances between them.
|
||||
let offset = if matches!(effect, PathEffect::Rainbow | PathEffect::Ribbon3d) {
|
||||
g.get("0").and_then(|o| o.get("0")).and_then(Val::nums).and_then(|o| o.get(1).copied()).filter(|o| o.is_finite()).unwrap_or(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Each glyph's origin, its matrix (along its baseline, then towards its foot) and where it
|
||||
// ends along the line; the lists end with where the line ends.
|
||||
let Some(at) = g.get("21") else { continue };
|
||||
let list = |k: &str| at.get(k).and_then(Val::nums).unwrap_or_default();
|
||||
let (xs, ys, matrices, ends) = (list("1"), list("2"), list("3"), list("4"));
|
||||
if effect == PathEffect::StairStep {
|
||||
let (s, t) = stair_step_fit(&xs, &ys, &ends);
|
||||
sum += s;
|
||||
turns += t;
|
||||
continue;
|
||||
}
|
||||
let glyphs: Vec<(Point, f64, kurbo::Vec2, kurbo::Vec2)> = xs
|
||||
.iter()
|
||||
.zip(&ys)
|
||||
@@ -743,13 +801,30 @@ fn path_spacing(f: &Val) -> f64 {
|
||||
})
|
||||
.collect();
|
||||
for w in glyphs.windows(2) {
|
||||
let [(p, a0, along, foot), (q, a1, next, _)] = w else { continue };
|
||||
let turn = next.dot(*foot).atan2(next.dot(*along));
|
||||
let [(p, a0, along, foot), (q, a1, next, next_foot)] = w else { continue };
|
||||
// 3D Ribbon's level baseline faces the way the path runs, so where the path runs left its
|
||||
// baseline and foot are turned round: `side` turns them back.
|
||||
let side = |baseline: &kurbo::Vec2| if effect == PathEffect::Ribbon3d && baseline.x < 0.0 { -1.0 } else { 1.0 };
|
||||
let (side, next_side) = (side(along), side(next));
|
||||
let (along, next) = if effect == PathEffect::Ribbon3d {
|
||||
(kurbo::Vec2::new(foot.y, -foot.x) * side, kurbo::Vec2::new(next_foot.y, -next_foot.x) * next_side)
|
||||
} else {
|
||||
(*along, *next)
|
||||
};
|
||||
// The turn is measured against a foot square to the path: Rainbow's and 3D Ribbon's own,
|
||||
// else the baseline turned a right angle, as theirs is (Skew's feet stay upright).
|
||||
let square =
|
||||
if matches!(effect, PathEffect::Rainbow | PathEffect::Ribbon3d) { *foot * side } else { kurbo::Vec2::new(-along.y, along.x) };
|
||||
let turn = next.dot(square).atan2(next.dot(along));
|
||||
// The length of the arc between the centres (the path is taken to be round there). A
|
||||
// corner (a turn of more than a radian between two glyphs) isn't round: left out.
|
||||
// corner (a turn of more than a radian between two glyphs) isn't round: left out. So is a
|
||||
// 3D Ribbon pair either side of where the path turns back, when an alignment puts their
|
||||
// baselines on opposite sides of the path.
|
||||
let half = turn.abs() * 0.5;
|
||||
let length = if half > 1e-9 { (*q - *p).hypot() * half / half.sin() } else { (*q - *p).hypot() };
|
||||
if turn.abs() <= 1.0 && length.is_finite() && a0.is_finite() && a1.is_finite() {
|
||||
let chord = (*q - *p).hypot();
|
||||
let length = if half > 1e-9 { chord * half / half.sin() } else { chord } + turn * offset * side;
|
||||
let apart = side != next_side && offset != 0.0;
|
||||
if !apart && turn.abs() <= 1.0 && length.is_finite() && a0.is_finite() && a1.is_finite() {
|
||||
sum += ((a0 + a1) * 0.5 - length) * turn;
|
||||
turns += turn * turn;
|
||||
}
|
||||
@@ -759,6 +834,45 @@ fn path_spacing(f: &Val) -> f64 {
|
||||
if turns > 1e-6 && spacing.is_finite() { spacing.clamp(-1000.0, 1000.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// [`path_spacing`]'s sums for one line of Stair Step type, from its glyphs' origins `xs`, `ys` and
|
||||
/// where they end along the line, `ends`. Stair Step's glyphs stay upright, so their matrices don't
|
||||
/// show how the path turns; each origin lies on the path (all of them moved the same way by an
|
||||
/// alignment), and a glyph's step is the path's length from its origin to the next. The path is taken
|
||||
/// to be round through three origins in a row, which gives how far it turns over that step `t`; the
|
||||
/// text engine makes the step `s` with `s + t × spacing = a`, `a` the glyph's advance.
|
||||
fn stair_step_fit(xs: &[f64], ys: &[f64], ends: &[f64]) -> (f64, f64) {
|
||||
let origins: Vec<Point> = xs.iter().zip(ys).map(|(x, y)| Point::new(*x, *y)).collect();
|
||||
let (mut sum, mut turns) = (0.0, 0.0);
|
||||
for i in 0..origins.len().saturating_sub(1) {
|
||||
let (Some(p), Some(q)) = (origins.get(i), origins.get(i + 1)) else { continue };
|
||||
// The third origin: the one before, else the one after.
|
||||
let triple = match i.checked_sub(1).and_then(|h| origins.get(h)) {
|
||||
Some(o) => (*o, *p, *q, *o),
|
||||
None => match origins.get(i + 2) {
|
||||
Some(r) => (*p, *q, *r, *r),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
let (a, b, c, third) = triple;
|
||||
// The inscribed angle at the third origin is half the arc's angle from `p` to `q`.
|
||||
let (to_p, to_q) = ((*p - third).hypot(), (*q - third).hypot());
|
||||
let sine = (b - a).cross(c - a) / (to_p * to_q);
|
||||
if !(sine.is_finite() && sine.abs() < 1.0) {
|
||||
continue;
|
||||
}
|
||||
let turn = 2.0 * sine.asin();
|
||||
let half = turn.abs() * 0.5;
|
||||
let chord = (*q - *p).hypot();
|
||||
let length = if half > 1e-9 { chord * half / half.sin() } else { chord };
|
||||
let advance = ends.get(i + 1).zip(ends.get(i)).map_or(f64::NAN, |(e1, e0)| e1 - e0);
|
||||
if turn.abs() <= 1.0 && length.is_finite() && advance.is_finite() {
|
||||
sum += (advance - length) * turn;
|
||||
turns += turn * turn;
|
||||
}
|
||||
}
|
||||
(sum, turns)
|
||||
}
|
||||
|
||||
impl Story {
|
||||
/// How many bytes of text the story has.
|
||||
pub(super) fn len(&self) -> usize {
|
||||
@@ -850,11 +964,12 @@ impl Story {
|
||||
(scale, Affine::new([a / scale, b / scale, c / scale, d / scale, at.x, at.y]), TextKind::Point)
|
||||
}
|
||||
FrameKind::Area(s, _) => (1.0, Affine::IDENTITY, TextKind::Area { frame: path_of(s, template, to_doc) }),
|
||||
FrameKind::OnPath { segments, start, end } => {
|
||||
FrameKind::OnPath { segments, start, end, options } => {
|
||||
let n = segments.len() as f64;
|
||||
// A closed path keeps its end bracket wherever it is (the same place as the start: a
|
||||
// full turn, see `TextKind::path_span`); an open one's end at its end is none.
|
||||
let end = (closed(segments) || *end < n - 0.02).then(|| length_fraction(segments, *end));
|
||||
// full turn, see `TextKind::path_span`); an open one's end at its end is none, unless
|
||||
// a flip makes it the place the type starts.
|
||||
let end = (closed(segments) || options.flip || *end < n - 0.02).then(|| length_fraction(segments, *end));
|
||||
(1.0, Affine::IDENTITY, TextKind::OnPath { path: path_of(segments, template, to_doc), start: length_fraction(segments, *start), end })
|
||||
}
|
||||
};
|
||||
@@ -913,8 +1028,13 @@ impl Story {
|
||||
t.path_spacing = self.path_spacing * scale;
|
||||
}
|
||||
// Each frame of a thread keeps its own options.
|
||||
if let Some(FrameKind::Area(_, options)) = self.frames.get(frame) {
|
||||
options.apply(&mut t.area, scale);
|
||||
match self.frames.get(frame) {
|
||||
Some(FrameKind::Area(_, options)) => options.apply(&mut t.area, scale),
|
||||
Some(FrameKind::OnPath { options, .. }) => {
|
||||
t.path_effect = options.effect;
|
||||
t.path_align = options.align;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
t.vertical = self.vertical;
|
||||
t.kind = kind;
|
||||
@@ -922,6 +1042,17 @@ impl Story {
|
||||
Some(Node::new(id, NodeKind::Text(Box::new(t))))
|
||||
}
|
||||
|
||||
/// Flips type on a path (`node`, made from frame `frame`) when its frame keeps Flip on. The
|
||||
/// frame's path runs as it did before the flip, so this runs once, on the path the object ends
|
||||
/// up with.
|
||||
pub(super) fn flip_on_path(&self, node: &mut Node, frame: usize) {
|
||||
if let Some(FrameKind::OnPath { options: PathFrame { flip: true, .. }, .. }) = self.frames.get(frame)
|
||||
&& let NodeKind::Text(t) = &mut node.kind
|
||||
{
|
||||
t.flip_on_path();
|
||||
}
|
||||
}
|
||||
|
||||
/// Area type that the file lays lines out in, in a frame too short for its first line as it is
|
||||
/// laid out here: a font that isn't available shows in the fallback font, whose lines may be
|
||||
/// taller, and type that doesn't fit its frame isn't drawn. The frame is made taller, from its
|
||||
@@ -1241,9 +1372,14 @@ mod tests {
|
||||
/// A text document's frame on a path of `segments` (each `x0 y0 x1 y1 x2 y2 x3 y3`) with the
|
||||
/// range `range`, written by hand.
|
||||
fn on_path_frame(segments: &[[f64; 8]], range: [f64; 2]) -> Option<FrameKind> {
|
||||
on_path_frame_with(segments, range, "")
|
||||
}
|
||||
|
||||
/// The same with `options` in the frame data too.
|
||||
fn on_path_frame_with(segments: &[[f64; 8]], range: [f64; 2], options: &str) -> Option<FrameKind> {
|
||||
let nums = |v: &[f64]| v.iter().map(|n| format!("{n}")).collect::<Vec<_>>().join(" ");
|
||||
let all: Vec<f64> = segments.iter().flatten().copied().collect();
|
||||
let doc = format!("/0 << /1 << /0 [ {} ] >> /2 << /0 2 /6 [ {} ] >> >>", nums(&all), nums(&range));
|
||||
let doc = format!("/0 << /1 << /0 [ {} ] >> /2 << /0 2 /6 [ {} ] {options} >> >>", nums(&all), nums(&range));
|
||||
let texts = Texts {
|
||||
stories: vec![],
|
||||
fonts: vec![],
|
||||
@@ -1277,6 +1413,123 @@ mod tests {
|
||||
(*t, b - a)
|
||||
}
|
||||
|
||||
/// Key 11's codes for the effect and the alignment, each read on its own. The defaults are the
|
||||
/// keys left out.
|
||||
#[test]
|
||||
fn type_on_a_path_keeps_its_effect_and_alignment() {
|
||||
use PathAlign as A;
|
||||
use PathEffect as E;
|
||||
let read = |options: &str| {
|
||||
let (t, _) = on_path("type", on_path_frame_with(&circle(), [0.0, 3.99], options).unwrap());
|
||||
(t.path_effect, t.path_align)
|
||||
};
|
||||
assert_eq!(read(""), (E::Rainbow, A::Baseline));
|
||||
assert_eq!(read("/11 << /4 21 /18 21.0 /6 false /22 0.025 >>"), (E::Rainbow, A::Baseline));
|
||||
for (code, effect) in [(1, E::Skew), (2, E::Ribbon3d), (3, E::StairStep), (4, E::Gravity)] {
|
||||
assert_eq!(read(&format!("/11 << /1 {code} >>")), (effect, A::Baseline), "effect {code}");
|
||||
}
|
||||
for (code, align) in [(0, A::Ascender), (1, A::Descender), (2, A::Center)] {
|
||||
assert_eq!(read(&format!("/11 << /2 {code} >>")), (E::Rainbow, align), "alignment {code}");
|
||||
}
|
||||
assert_eq!(read("/11 << /1 3 /2 1 >>"), (E::StairStep, A::Descender));
|
||||
}
|
||||
|
||||
/// A frame with key 6 true keeps the defaults, Flip included.
|
||||
#[test]
|
||||
fn type_on_a_path_with_key_6_true_keeps_the_defaults() {
|
||||
let Some(FrameKind::OnPath { options, .. }) = on_path_frame_with(&circle(), [0.0, 3.99], "/11 << /6 true /0 true /1 1 /2 0 >>") else {
|
||||
panic!("on a path")
|
||||
};
|
||||
assert_eq!(options, PathFrame::default());
|
||||
}
|
||||
|
||||
/// Key 11 where it can't be read leaves the options at their defaults, each on its own.
|
||||
#[test]
|
||||
fn type_on_a_path_options_that_cannot_be_read_keep_their_defaults() {
|
||||
let bad = ["5", "-1", "1.5", "1e300", "(1)", "true", "/Skew", "[ 1 ]", "<< /0 1 >>"];
|
||||
for value in bad {
|
||||
for key in ["1", "2", "0"] {
|
||||
let Some(FrameKind::OnPath { options, .. }) = on_path_frame_with(&circle(), [0.0, 3.99], &format!("/11 << /{key} {value} >>")) else {
|
||||
panic!("on a path")
|
||||
};
|
||||
let expected = PathFrame { flip: key == "0" && value == "true", ..PathFrame::default() };
|
||||
assert_eq!(options, expected, "key {key} value {value}");
|
||||
}
|
||||
}
|
||||
for whole in ["3", "true", "(x)", "[ /1 1 ]"] {
|
||||
let Some(FrameKind::OnPath { options, .. }) = on_path_frame_with(&circle(), [0.0, 3.99], &format!("/11 {whole}")) else {
|
||||
panic!("on a path")
|
||||
};
|
||||
assert_eq!(options, PathFrame::default(), "{whole}");
|
||||
}
|
||||
// One option that can't be read leaves the other as the frame keeps it.
|
||||
let Some(FrameKind::OnPath { options, .. }) = on_path_frame_with(&circle(), [0.0, 3.99], "/11 << /1 9 /2 2 >>") else { panic!("on a path") };
|
||||
assert_eq!((options.effect, options.align), (PathEffect::Rainbow, PathAlign::Center));
|
||||
}
|
||||
|
||||
/// The first point of type on a path's path, and its span.
|
||||
fn flipped_on_path(frames: Vec<FrameKind>, frame: usize) -> (f64, (f64, f64)) {
|
||||
let mut st = story("type", &[0.0], &Para::default());
|
||||
st.frames = frames;
|
||||
let mut node = st.node(NodeId(1), frame, (0.0, 0.0), Affine::IDENTITY).unwrap();
|
||||
st.flip_on_path(&mut node, frame);
|
||||
let NodeKind::Text(t) = node.kind else { panic!("not type") };
|
||||
let vectorcraft_doc::TextKind::OnPath { path, .. } = &t.kind else { panic!("type on a path") };
|
||||
let Some(kurbo::PathEl::MoveTo(first)) = path.to_bezpath().elements().first().copied() else { panic!("a path") };
|
||||
(first.x + CANVAS_CENTRE, t.kind.path_span().unwrap())
|
||||
}
|
||||
|
||||
/// Flip is a flag: the frame's path and range run as before the flip, so the type flips once,
|
||||
/// keeps its stretch of the path, and the path runs from its far end.
|
||||
#[test]
|
||||
fn flipped_type_on_a_path_runs_the_path_the_other_way_once() {
|
||||
let open = [[0.0, 0.0, 30.0, 0.0, 70.0, 0.0, 100.0, 0.0], [100.0, 0.0, 130.0, 0.0, 170.0, 0.0, 200.0, 0.0]];
|
||||
let frame = |options: &str| vec![on_path_frame_with(&open, [0.5, 1.5], options).unwrap()];
|
||||
let (first, (from, to)) = flipped_on_path(frame("/11 << /0 false >>"), 0);
|
||||
assert_eq!(first, 0.0);
|
||||
assert!((from - 0.25).abs() < 1e-3 && (to - 0.75).abs() < 1e-3, "{from} {to}");
|
||||
let (first, (from, to)) = flipped_on_path(frame("/11 << /0 true >>"), 0);
|
||||
assert_eq!(first, 200.0, "from the path's far end");
|
||||
assert!((from - 0.25).abs() < 1e-3 && (to - 0.75).abs() < 1e-3, "the same stretch: {from} {to}");
|
||||
}
|
||||
|
||||
/// The end of an open path's range, which doesn't move unflipped type, is where flipped type
|
||||
/// starts: a range that ends just short of the path's end starts the type that far from it.
|
||||
#[test]
|
||||
fn flipped_type_on_an_open_path_starts_at_the_end_of_its_range() {
|
||||
let third = 100.0 / 3.0;
|
||||
let line = [[0.0, 0.0, third, 0.0, 2.0 * third, 0.0, 100.0, 0.0]];
|
||||
let frame = |options: &str| vec![on_path_frame_with(&line, [0.0, 0.99], options).unwrap()];
|
||||
let (_, (from, to)) = flipped_on_path(frame("/11 << /0 true >>"), 0);
|
||||
assert!((from - 0.01).abs() < 1e-3 && (to - 1.0).abs() < 1e-3, "{from} {to}");
|
||||
let (_, (from, to)) = flipped_on_path(frame(""), 0);
|
||||
assert!(from.abs() < 1e-3 && (to - 1.0).abs() < 1e-3, "unflipped: {from} {to}");
|
||||
}
|
||||
|
||||
/// Round a closed path, a flip keeps the stretch too, and each frame of a thread keeps its own
|
||||
/// options.
|
||||
#[test]
|
||||
fn each_frame_of_type_on_a_path_keeps_its_options() {
|
||||
let mut st = story("type", &[0.0], &Para::default());
|
||||
st.frames = vec![
|
||||
on_path_frame_with(&circle(), [0.5, 2.5], "/11 << /0 true /1 1 >>").unwrap(),
|
||||
on_path_frame_with(&circle(), [0.0, 3.99], "/11 << /2 0 >>").unwrap(),
|
||||
];
|
||||
let mut first = st.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY).unwrap();
|
||||
let mut second = st.node(NodeId(2), 1, (0.0, 0.0), Affine::IDENTITY).unwrap();
|
||||
let before = first.clone();
|
||||
st.flip_on_path(&mut first, 0);
|
||||
assert_ne!(before, first, "the first frame flips");
|
||||
let before = second.clone();
|
||||
st.flip_on_path(&mut second, 1);
|
||||
assert_eq!(before, second, "the second frame doesn't");
|
||||
let (NodeKind::Text(a), NodeKind::Text(b)) = (&first.kind, &second.kind) else { panic!("not type") };
|
||||
assert_eq!((a.path_effect, a.path_align), (PathEffect::Skew, PathAlign::Baseline));
|
||||
assert_eq!((b.path_effect, b.path_align), (PathEffect::Rainbow, PathAlign::Ascender));
|
||||
let (from, to) = a.kind.path_span().unwrap();
|
||||
assert!((to - from - 0.5).abs() < 1e-3, "{from} {to}");
|
||||
}
|
||||
|
||||
/// Round a closed path the file writes the end bracket unwrapped, past the last segment (the
|
||||
/// start plus the run): the type keeps that run, nearly the whole circle here, not the bit
|
||||
/// from the start to the path's end.
|
||||
@@ -1355,6 +1608,12 @@ mod tests {
|
||||
/// outside it (their feet towards its centre) or inside it, the length of the circle between
|
||||
/// their centres `fraction` of their advances' mean.
|
||||
fn round_layout(advances: &[f64], radius: f64, outside: bool, fraction: f64) -> Val {
|
||||
round_layout_with(advances, radius, outside, fraction, "", false)
|
||||
}
|
||||
|
||||
/// The same with `extra` in the line's `G`, and baselines that stay level as 3D Ribbon's do when
|
||||
/// `level`.
|
||||
fn round_layout_with(advances: &[f64], radius: f64, outside: bool, fraction: f64, extra: &str, level: bool) -> Val {
|
||||
let sign = if outside { 1.0 } else { -1.0 };
|
||||
let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![0.0]);
|
||||
let mut angle = std::f64::consts::PI;
|
||||
@@ -1365,14 +1624,15 @@ mod tests {
|
||||
// Outside, clockwise on the page (y down); inside, the other way round.
|
||||
let along = (-angle.sin() * sign, angle.cos() * sign);
|
||||
let centre = (radius * angle.cos(), radius * angle.sin());
|
||||
xs.push(centre.0 - along.0 * a * 0.5);
|
||||
ys.push(centre.1 - along.1 * a * 0.5);
|
||||
matrices.extend([along.0, along.1, -along.1, along.0, 0.0, 0.0]);
|
||||
let baseline = if level { (1.0, 0.0) } else { along };
|
||||
xs.push(centre.0 - baseline.0 * a * 0.5);
|
||||
ys.push(centre.1 - baseline.1 * a * 0.5);
|
||||
matrices.extend([baseline.0, baseline.1, -along.1, along.0, 0.0, 0.0]);
|
||||
ends.push(ends[i] + a);
|
||||
}
|
||||
let nums = |v: &[f64]| v.iter().map(|n| format!("{n}")).collect::<Vec<_>>().join(" ");
|
||||
let doc = format!(
|
||||
"/0 << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >>",
|
||||
"/0 << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G {extra} /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >>",
|
||||
nums(&xs),
|
||||
nums(&ys),
|
||||
nums(&matrices),
|
||||
@@ -1388,18 +1648,194 @@ mod tests {
|
||||
fn the_layout_of_type_on_a_path_gives_its_spacing() {
|
||||
let advances = [20.0, 30.0, 10.0, 25.0, 20.0];
|
||||
// Outside a circle of radius 100, 10 above the path: the centres are 100/110 of the advances apart.
|
||||
let spacing = path_spacing(&round_layout(&advances, 100.0, true, 100.0 / 110.0));
|
||||
let rainbow = PathEffect::Rainbow;
|
||||
let spacing = path_spacing(&round_layout(&advances, 100.0, true, 100.0 / 110.0), rainbow);
|
||||
assert!((spacing - 10.0).abs() < 1e-6, "{spacing}");
|
||||
// Inside it, 20 above the path: 100/80 of them.
|
||||
let spacing = path_spacing(&round_layout(&advances, 100.0, false, 100.0 / 80.0));
|
||||
let spacing = path_spacing(&round_layout(&advances, 100.0, false, 100.0 / 80.0), rainbow);
|
||||
assert!((spacing - 20.0).abs() < 1e-6, "{spacing}");
|
||||
// At their advances: none.
|
||||
assert!(path_spacing(&round_layout(&advances, 100.0, true, 1.0)).abs() < 1e-6);
|
||||
assert!(path_spacing(&round_layout(&advances, 100.0, true, 1.0), rainbow).abs() < 1e-6);
|
||||
// Corners (here a turn of about four radians from glyph to glyph) aren't taken for a curve.
|
||||
assert_eq!(path_spacing(&round_layout(&advances, 5.0, true, 1.0)), 0.0);
|
||||
assert_eq!(path_spacing(&round_layout(&advances, 5.0, true, 1.0), rainbow), 0.0);
|
||||
// A straight line turns nowhere, and a layout without glyphs says nothing.
|
||||
assert_eq!(path_spacing(&round_layout(&advances, 1e12, true, 0.9)), 0.0);
|
||||
assert_eq!(path_spacing(&read(b"/99 /F").unwrap()), 0.0);
|
||||
assert_eq!(path_spacing(&round_layout(&advances, 1e12, true, 0.9), rainbow), 0.0);
|
||||
assert_eq!(path_spacing(&read(b"/99 /F").unwrap(), rainbow), 0.0);
|
||||
}
|
||||
|
||||
/// Align to Path moves the baselines off the path, and the spacing is the one on the path. On a
|
||||
/// circle of radius 100 with spacing 10, the path's length between two centres is 100/110 of
|
||||
/// their advances. Baselines on a smaller or larger circle are that much closer or further.
|
||||
#[test]
|
||||
fn the_spacing_of_aligned_type_is_measured_on_its_path() {
|
||||
let advances = [20.0, 30.0, 10.0, 25.0, 20.0];
|
||||
let fit =
|
||||
|radius: f64, extra: &str| path_spacing(&round_layout_with(&advances, radius, true, radius / 110.0, extra, false), PathEffect::Rainbow);
|
||||
// 20 towards the feet: Ascender.
|
||||
let spacing = fit(80.0, "/0 << /0 [ 0 20 ] >>");
|
||||
assert!((spacing - 10.0).abs() < 1e-6, "{spacing}");
|
||||
// 8 away from them: Descender.
|
||||
let spacing = fit(108.0, "/0 << /0 [ 0 -8 ] >>");
|
||||
assert!((spacing - 10.0).abs() < 1e-6, "{spacing}");
|
||||
// A distance that can't be read counts as none.
|
||||
for bad in ["/0 3", "/0 << /0 (x) >>", "/0 << /0 [ 0 ] >>"] {
|
||||
let spacing = fit(100.0, bad);
|
||||
assert!((spacing - 10.0).abs() < 1e-6, "{bad}: {spacing}");
|
||||
}
|
||||
// Skew's feet are upright: its alignment moves the baselines without changing their
|
||||
// lengths, so the distance doesn't count.
|
||||
let layout = |extra: &str| round_layout_with(&advances, 100.0, true, 100.0 / 110.0, extra, false);
|
||||
let plain = path_spacing(&layout(""), PathEffect::Skew);
|
||||
let aligned = path_spacing(&layout("/0 << /0 [ 0 20 ] >>"), PathEffect::Skew);
|
||||
assert!((aligned - plain).abs() < 1e-9, "{plain} {aligned}");
|
||||
// 3D Ribbon's feet are square to the path, as Rainbow's are.
|
||||
let level = round_layout_with(&advances, 80.0, true, 80.0 / 110.0, "/0 << /0 [ 0 20 ] >>", true);
|
||||
let spacing = path_spacing(&level, PathEffect::Ribbon3d);
|
||||
assert!((spacing - 10.0).abs() < 1e-6, "{spacing}");
|
||||
}
|
||||
|
||||
/// Skew's feet stay upright, so on a straight path at 30 degrees they are not square to it. The
|
||||
/// path turns nowhere, so glyphs set further apart than their advances give no spacing, going
|
||||
/// right or left.
|
||||
#[test]
|
||||
fn skewed_type_on_a_straight_tilted_path_has_no_spacing() {
|
||||
let advances = [20.0, 30.0, 10.0, 25.0, 20.0];
|
||||
for degrees in [30.0f64, 210.0] {
|
||||
let along = kurbo::Vec2::new(degrees.to_radians().cos(), degrees.to_radians().sin());
|
||||
let (mut at, mut glyphs) = (0.0, vec![]);
|
||||
for (i, a) in advances.iter().enumerate() {
|
||||
if i > 0 {
|
||||
at += (advances[i - 1] + a) * 0.5 * 1.2;
|
||||
}
|
||||
glyphs.push((Point::ORIGIN + along * at, along));
|
||||
}
|
||||
let layout = upright_layout(&advances, &glyphs);
|
||||
for effect in [PathEffect::Skew, PathEffect::StairStep, PathEffect::Gravity] {
|
||||
let spacing = path_spacing(&layout, effect);
|
||||
assert!(spacing.abs() < 1e-9, "{degrees}, {effect:?}: {spacing}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Round the bottom of a circle the path runs left, and Skew's upright feet point away from the
|
||||
/// side the path turns to: the turn is still measured as Rainbow's is. Outside a circle of
|
||||
/// radius 100, 10 above the path, the centres are 100/110 of the advances apart: spacing 10.
|
||||
#[test]
|
||||
fn skewed_type_running_left_round_a_curve_gives_its_spacing() {
|
||||
let advances = [20.0, 30.0, 10.0, 25.0, 20.0];
|
||||
let mut angle = std::f64::consts::FRAC_PI_2 - 0.2;
|
||||
let mut glyphs = vec![];
|
||||
for (i, a) in advances.iter().enumerate() {
|
||||
if i > 0 {
|
||||
angle += (advances[i - 1] + a) * 0.5 * (100.0 / 110.0) / 100.0;
|
||||
}
|
||||
glyphs.push((Point::new(100.0 * angle.cos(), 100.0 * angle.sin()), kurbo::Vec2::new(-angle.sin(), angle.cos())));
|
||||
}
|
||||
let spacing = path_spacing(&upright_layout(&advances, &glyphs), PathEffect::Skew);
|
||||
assert!((spacing - 10.0).abs() < 1e-6, "{spacing}");
|
||||
}
|
||||
|
||||
/// A line's layout whose glyphs have their centres and baselines `glyphs` and upright feet, as
|
||||
/// Skew's are.
|
||||
fn upright_layout(advances: &[f64], glyphs: &[(Point, kurbo::Vec2)]) -> Val {
|
||||
let glyphs: Vec<(Point, [f64; 4])> = glyphs.iter().map(|(centre, along)| (*centre, [along.x, along.y, 0.0, 1.0])).collect();
|
||||
line_layout(advances, &glyphs, "")
|
||||
}
|
||||
|
||||
/// A line's layout, `extra` in its `G`, whose glyphs have their centres and matrices `glyphs`.
|
||||
fn line_layout(advances: &[f64], glyphs: &[(Point, [f64; 4])], extra: &str) -> Val {
|
||||
let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![0.0]);
|
||||
for (i, (a, (centre, [m0, m1, m2, m3]))) in advances.iter().zip(glyphs).enumerate() {
|
||||
xs.push(centre.x - m0 * a * 0.5);
|
||||
ys.push(centre.y - m1 * a * 0.5);
|
||||
matrices.extend([*m0, *m1, *m2, *m3, 0.0, 0.0]);
|
||||
ends.push(ends[i] + a);
|
||||
}
|
||||
let nums = |v: &[f64]| v.iter().map(|n| format!("{n}")).collect::<Vec<_>>().join(" ");
|
||||
let doc = format!(
|
||||
"/0 << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G {extra} /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >>",
|
||||
nums(&xs),
|
||||
nums(&ys),
|
||||
nums(&matrices),
|
||||
nums(&ends)
|
||||
);
|
||||
read(doc.as_bytes()).unwrap().get("0").unwrap().clone()
|
||||
}
|
||||
|
||||
/// 3D Ribbon keeps its baselines level, so its glyphs' feet show how the path turns.
|
||||
#[test]
|
||||
fn the_spacing_of_3d_ribbon_type_follows_the_feet_of_its_glyphs() {
|
||||
let advances = [20.0, 30.0, 10.0, 25.0, 20.0];
|
||||
let layout = round_layout_with(&advances, 100.0, true, 100.0 / 110.0, "", true);
|
||||
let spacing = path_spacing(&layout, PathEffect::Ribbon3d);
|
||||
assert!((spacing - 10.0).abs() < 1e-6, "{spacing}");
|
||||
}
|
||||
|
||||
/// Where the path runs left, 3D Ribbon's level baseline faces left and its foot turns round with
|
||||
/// it. Round a circle of radius 100 through the place where the path runs straight up, the glyphs
|
||||
/// on either side of it are still read as following one curve: spacing 10.
|
||||
#[test]
|
||||
fn the_spacing_of_3d_ribbon_type_holds_where_the_path_turns_back() {
|
||||
let advances = [20.0, 30.0];
|
||||
let mut angle = std::f64::consts::PI - 0.1;
|
||||
let mut glyphs = vec![];
|
||||
for (i, a) in advances.iter().enumerate() {
|
||||
if i > 0 {
|
||||
angle += (advances[i - 1] + a) * 0.5 * (100.0 / 110.0) / 100.0;
|
||||
}
|
||||
let along = kurbo::Vec2::new(-angle.sin(), angle.cos());
|
||||
let side = if along.x < 0.0 { -1.0 } else { 1.0 };
|
||||
glyphs.push((Point::new(100.0 * angle.cos(), 100.0 * angle.sin()), [side, 0.0, -along.y * side, along.x * side]));
|
||||
}
|
||||
assert!(glyphs.first().is_some_and(|(_, m)| m[0] < 0.0) && glyphs.last().is_some_and(|(_, m)| m[0] > 0.0));
|
||||
let spacing = path_spacing(&line_layout(&advances, &glyphs, ""), PathEffect::Ribbon3d);
|
||||
assert!((spacing - 10.0).abs() < 1e-6, "{spacing}");
|
||||
}
|
||||
|
||||
/// Stair Step's glyphs stay upright, each origin on the path, its step the path's length to the
|
||||
/// next. On a circle of radius 100 with spacing 10, each step is 100/110 of the glyph's advance;
|
||||
/// moved 20 down by an alignment, the origins give the same spacing.
|
||||
#[test]
|
||||
fn the_spacing_of_stair_step_type_follows_the_origins_of_its_glyphs() {
|
||||
let advances = [20.0, 30.0, 10.0, 25.0, 20.0];
|
||||
for rise in [0.0, 20.0] {
|
||||
let mut s = 0.0;
|
||||
let mut glyphs = vec![];
|
||||
for a in advances {
|
||||
let angle = std::f64::consts::PI + s / 100.0;
|
||||
// `line_layout` takes centres: half an advance along the level baseline.
|
||||
glyphs.push((Point::new(100.0 * angle.cos() + a * 0.5, 100.0 * angle.sin() + rise), [1.0, 0.0, 0.0, 1.0]));
|
||||
s += a * 100.0 / 110.0;
|
||||
}
|
||||
let spacing = path_spacing(&line_layout(&advances, &glyphs, ""), PathEffect::StairStep);
|
||||
assert!((spacing - 10.0).abs() < 1e-6, "{rise}: {spacing}");
|
||||
}
|
||||
// Two glyphs say nothing of how the path turns.
|
||||
let two = line_layout(&advances[..2], &[(Point::new(0.0, 0.0), [1.0, 0.0, 0.0, 1.0]), (Point::new(30.0, 5.0), [1.0, 0.0, 0.0, 1.0])], "");
|
||||
assert_eq!(path_spacing(&two, PathEffect::StairStep), 0.0);
|
||||
}
|
||||
|
||||
/// With Ascender (baselines 20 towards the feet), glyphs facing left lie outside the path and
|
||||
/// glyphs facing right inside it. The pair either side of where the path runs straight up is left
|
||||
/// out, and the others give spacing 10.
|
||||
#[test]
|
||||
fn aligned_3d_ribbon_type_leaves_out_the_pair_where_the_path_turns_back() {
|
||||
let advances = [20.0, 30.0, 10.0, 25.0, 20.0];
|
||||
let mut angle = std::f64::consts::PI - 0.5;
|
||||
let mut glyphs = vec![];
|
||||
for (i, a) in advances.iter().enumerate() {
|
||||
if i > 0 {
|
||||
angle += (advances[i - 1] + a) * 0.5 / 110.0;
|
||||
}
|
||||
let along = kurbo::Vec2::new(-angle.sin(), angle.cos());
|
||||
let side = if along.x < 0.0 { -1.0 } else { 1.0 };
|
||||
let radius = 100.0 - 20.0 * side;
|
||||
glyphs.push((Point::new(radius * angle.cos(), radius * angle.sin()), [side, 0.0, -along.y * side, along.x * side]));
|
||||
}
|
||||
let sides: Vec<f64> = glyphs.iter().map(|(_, m)| m[0]).collect();
|
||||
assert_eq!(sides, [-1.0, -1.0, -1.0, 1.0, 1.0]);
|
||||
let spacing = path_spacing(&line_layout(&advances, &glyphs, "/0 << /0 [ 0 20 ] >>"), PathEffect::Ribbon3d);
|
||||
assert!((spacing - 10.0).abs() < 1e-6, "{spacing}");
|
||||
}
|
||||
|
||||
/// The leading of the text object a story comes in as.
|
||||
|
||||
@@ -54,7 +54,7 @@ use build::{Frame, MADE, prune_unseen};
|
||||
use compare::{
|
||||
MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour,
|
||||
};
|
||||
pub use layers::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, layered_ai, layered_ai_from};
|
||||
pub use layers::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, layered_ai, layered_ai_from};
|
||||
pub(super) use layers::{layered, program_alone};
|
||||
pub(crate) use slots::MAX_TEXT;
|
||||
pub use slots::is_loss;
|
||||
|
||||
@@ -223,6 +223,15 @@ impl AiLayers {
|
||||
}
|
||||
}
|
||||
|
||||
/// The id of the artboard of `doc` that the editing copy `private` (as [`layered_ai`] takes it)
|
||||
/// names as its active one (`CropAreaActive`, an index into its `ArtboardArray`), for `doc`
|
||||
/// restored from the document a `.ai` file carries beside that copy: `None` when the copy names
|
||||
/// the first artboard, names none, can't be decoded, or names an artboard `doc` doesn't have.
|
||||
pub fn ai_opened_artboard(private: &[u8], doc: &Document) -> Option<u32> {
|
||||
let data = ai::decode_private(private).ok()?;
|
||||
active_page(ai::active_artboard(&data), doc)
|
||||
}
|
||||
|
||||
/// `doc` (a file read as its page or PDF part, in place of its layers) with the variables, data
|
||||
/// sets, active data set and lock of the variable library of its editing data `data`, as [`build`]
|
||||
/// gives a document read from the layers, and the note about what of the library wasn't read
|
||||
|
||||
@@ -74,6 +74,7 @@ pub(super) fn make_slots(
|
||||
// Area type and type on a path in the object's own frame: the text document's frame
|
||||
// of a copied object keeps its original's path.
|
||||
frame_text_from_art(info, &mut node, &mut region);
|
||||
story.flip_on_path(&mut node, info.frame);
|
||||
if let NodeKind::Text(text) = &mut node.kind
|
||||
&& story.fit_first_line(text)
|
||||
{
|
||||
|
||||
@@ -177,11 +177,12 @@ fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
|
||||
let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n") + "\n" + clip_fill;
|
||||
let r = import(&eps(&page, &art)).unwrap();
|
||||
let outer = &r.document.layers[0];
|
||||
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[3]", "group[1]", "other"]);
|
||||
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[2]", "group[1]", "other"]);
|
||||
let children = outer.children().unwrap();
|
||||
assert!(children[0].visible && !children[3].visible, "the hidden object is hidden, not left out");
|
||||
// The clip path comes first in its group.
|
||||
assert!(matches!(children[2].children().unwrap()[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
// The clip path comes first in its group, with its fill.
|
||||
let clip = &children[2].children().unwrap()[0];
|
||||
assert!(matches!(clip.kind, NodeKind::Path { clipping: true, .. }) && clip.clip_paint().fill.is_some(), "{clip:?}");
|
||||
// The sublayer is a layer of the layer.
|
||||
assert!(matches!(&children[4].kind, NodeKind::Layer { .. }));
|
||||
assert_eq!(children[4].name.as_deref(), Some("Inner"));
|
||||
@@ -3857,6 +3858,37 @@ fn area_type_and_type_on_a_path_come_from_the_text_document() {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Type on a path keeps the effect, alignment and flip its frame keeps. The flip applies to the
|
||||
/// object's own path, the one its art draws, once.
|
||||
#[test]
|
||||
fn type_on_a_path_keeps_its_type_on_a_path_options() {
|
||||
let (a, b, c) = (canvas(10.0, 70.0), canvas(30.0, 70.0), canvas(50.0, 70.0));
|
||||
let segments =
|
||||
format!("{} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}", a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1, b.0, b.1, b.0, b.1, c.0, c.1, c.0, c.1);
|
||||
let frame = |options: &str| format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {segments} ] >> /2 << /0 2 /6 [ 0.5 2.0 ] {options} >> >> >>");
|
||||
let frames = [frame("/11 << /0 true /1 1 /2 0 /4 -28 /18 -28.0 /6 false /22 0.025 >>"), frame("")];
|
||||
let stories = [story("Flipped\\r", &[0], "/0 0", "/1 8.0"), story("Plain\\r", &[1], "/0 0", "/1 8.0")];
|
||||
let doc = document_of(&frames, &stories);
|
||||
let objects = text_object(0) + &text_object(1);
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &objects)), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts = text_of(&r.document.layers[1]);
|
||||
assert_eq!(texts.len(), 2, "{:?}", r.warnings);
|
||||
let (flipped, plain) = (texts[0], texts[1]);
|
||||
assert_eq!((flipped.path_effect, flipped.path_align), (vectorcraft_doc::PathEffect::Skew, vectorcraft_doc::PathAlign::Ascender));
|
||||
assert_eq!((plain.path_effect, plain.path_align), (vectorcraft_doc::PathEffect::Rainbow, vectorcraft_doc::PathAlign::Baseline));
|
||||
let first_x = |t: &vectorcraft_doc::TextObject| {
|
||||
let vectorcraft_doc::TextKind::OnPath { path, .. } = &t.kind else { panic!("type on a path: {:?}", t.kind) };
|
||||
let Some(kurbo::PathEl::MoveTo(p)) = path.to_bezpath().elements().first().copied() else { panic!("a path") };
|
||||
p.x
|
||||
};
|
||||
assert_eq!(first_x(plain), 10.0);
|
||||
assert_eq!(first_x(flipped), 50.0);
|
||||
// The file's range, 0.25 to the end of the path, stays that stretch of it.
|
||||
let (from, to) = flipped.kind.path_span().unwrap();
|
||||
assert!(from.abs() < 1e-3 && (to - 0.75).abs() < 1e-3, "{from} {to}");
|
||||
}
|
||||
|
||||
/// Area type keeps the rows, columns, gutters and first baseline its frame keeps, and so does
|
||||
/// each frame of a thread; a frame without them keeps the defaults.
|
||||
#[test]
|
||||
@@ -4139,6 +4171,11 @@ fn a_thai_story_stays_one_text_object() {
|
||||
/// A story of `text` on frame 0 whose layout sets glyphs `advance` wide round the outside of a
|
||||
/// circle of radius `radius`, the circle's length between their centres `fraction` of an advance.
|
||||
fn round_story(text: &str, advance: f64, radius: f64, fraction: f64) -> String {
|
||||
round_story_with(text, advance, radius, fraction, "")
|
||||
}
|
||||
|
||||
/// The same with `extra` in the line's `G`.
|
||||
fn round_story_with(text: &str, advance: f64, radius: f64, fraction: f64, extra: &str) -> String {
|
||||
let n = text.chars().count();
|
||||
let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![]);
|
||||
for i in 0..=n {
|
||||
@@ -4154,7 +4191,7 @@ fn round_story(text: &str, advance: f64, radius: f64, fraction: f64) -> String {
|
||||
format!(
|
||||
"<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {n} >> ] >> \
|
||||
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /1 12.0 >> >> >> /1 {n} >> ] >> >> /1 << /0 [ << /0 0 >> ] \
|
||||
/2 [ << /99 /PC /6 [ << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >> ] >> ] >> >>",
|
||||
/2 [ << /99 /PC /6 [ << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G {extra} /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >> ] >> ] >> >>",
|
||||
nums(&xs),
|
||||
nums(&ys),
|
||||
matrices.join(" "),
|
||||
@@ -4214,6 +4251,57 @@ fn type_round_a_circle_is_spaced_as_the_file_lays_it_out() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type aligned to its path by its ascender: the file's baselines run on a smaller circle than the
|
||||
/// path, and the type comes in spaced as it runs along the path, not along those baselines.
|
||||
#[test]
|
||||
fn aligned_type_round_a_circle_runs_along_its_path_as_the_file_lays_it_out() {
|
||||
let k = 40.0 * 0.552_284_75;
|
||||
let points = [
|
||||
(90.0, 50.0),
|
||||
(90.0, 50.0 - k),
|
||||
(50.0 + k, 10.0),
|
||||
(50.0, 10.0),
|
||||
(50.0 - k, 10.0),
|
||||
(10.0, 50.0 - k),
|
||||
(10.0, 50.0),
|
||||
(10.0, 50.0 + k),
|
||||
(50.0 - k, 90.0),
|
||||
(50.0, 90.0),
|
||||
(50.0 + k, 90.0),
|
||||
(90.0, 50.0 + k),
|
||||
(90.0, 50.0),
|
||||
];
|
||||
let all: Vec<(f64, f64)> = points.iter().map(|(x, y)| canvas(*x, *y)).collect();
|
||||
let segment = |i: usize| (0..4).map(|j| format!("{} {}", all[i * 3 + j].0, all[i * 3 + j].1)).collect::<Vec<_>>().join(" ");
|
||||
let frame = format!(
|
||||
"<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 2 /6 [ 2.0 5.99 ] /11 << /2 0 >> >> >> >>",
|
||||
(0..4).map(segment).collect::<Vec<_>>().join(" ")
|
||||
);
|
||||
// Baselines 10 inside the circle of radius 40. Along the path the centres of glyphs 5 wide are
|
||||
// 5 × 40/48 apart (spacing 8), so on the baselines they are 5 × 30/48 apart.
|
||||
let doc = document_of(&[frame], &[round_story_with("abcdefgh", 5.0, 30.0, 30.0 / 48.0, "/0 << /0 [ 0 10 ] >>")]);
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts = text_of(&r.document.layers[1]);
|
||||
assert_eq!(texts.len(), 1, "{:?}", r.warnings);
|
||||
let t = texts[0];
|
||||
assert_eq!(t.path_align, vectorcraft_doc::PathAlign::Ascender);
|
||||
assert!((t.path_spacing - 8.0).abs() < 0.05, "{}", t.path_spacing);
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let centres: Vec<(f64, f64)> = layout
|
||||
.glyphs
|
||||
.iter()
|
||||
.map(|g| (g.origin.x + g.angle.cos() * g.advance * 0.5 - 50.0, g.origin.y + g.angle.sin() * g.advance * 0.5 - 50.0, g.advance))
|
||||
.map(|(x, y, a)| (y.atan2(x), a))
|
||||
.collect();
|
||||
assert_eq!(centres.len(), 8);
|
||||
for w in centres.windows(2) {
|
||||
let turn = (w[1].0 - w[0].0 + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
|
||||
let ratio = turn.abs() * 40.0 / ((w[0].1 + w[1].1) * 0.5);
|
||||
assert!((ratio - 40.0 / 48.0).abs() < 0.01, "{ratio}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threaded_area_type_flows_through_its_frames() {
|
||||
let frames = [
|
||||
@@ -4430,6 +4518,26 @@ fn a_copied_area_type_object_is_framed_where_its_own_art_path_is() {
|
||||
assert!((b.x0 - 30.0).abs() < 1e-6 && (b.y0 - 30.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
/// A copy of flipped type on a path takes its own `/Art` path, and that path is the one that flips.
|
||||
#[test]
|
||||
fn a_copied_flipped_type_on_a_path_flips_its_own_art_path() {
|
||||
let (a, b) = (canvas(10.0, 20.0), canvas(60.0, 20.0));
|
||||
let path = format!(
|
||||
"<< /0 << /0 [ 0 0 ] /1 << /0 [ {} {} {} {} {} {} {} {} ] >> /2 << /0 2 /6 [ 0.0 1.0 ] /11 << /0 true >> >> >> >>",
|
||||
a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1
|
||||
);
|
||||
let doc = document_of(&[path], &[story("Copied\r", &[0], "/0 0", "/1 8.0")]);
|
||||
let copy = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n/Art :\nX=\n30 50 m\n80 50 L\nN\n;\n/ConfiningPath ,\n2 /TextAntialiasing ,\n;\n";
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, copy)), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts = text_of(&r.document.layers[1]);
|
||||
assert_eq!(texts.len(), 1, "{:?}", r.warnings);
|
||||
let vectorcraft_doc::TextKind::OnPath { path, .. } = &texts[0].kind else { panic!("type on a path: {:?}", texts[0].kind) };
|
||||
// The copy's path runs from x 30 to x 80 at art y 50, so flipped it starts at x 80, 50 down the page.
|
||||
let Some(kurbo::PathEl::MoveTo(first)) = path.to_bezpath().elements().first().copied() else { panic!("a path") };
|
||||
assert_eq!((first.x, first.y), (80.0, 50.0));
|
||||
}
|
||||
|
||||
/// A raster print page can carry a fully vector editing copy. The legacy mesh has no
|
||||
/// opacity operand and its vector mask hides half of it: ignoring either loses this import.
|
||||
#[test]
|
||||
|
||||
@@ -44,8 +44,8 @@ mod tiff;
|
||||
|
||||
pub use ai_editing::{AiEditingOptions, AiEditingOutput, CREATOR_VERSION, export_ai_editing, private_data as ai_private_data};
|
||||
pub use import::{
|
||||
AiLayers, Imported, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_layers_alone, family_style, import, import_trusting, import_with, is_loss,
|
||||
layered_ai, layered_ai_from, page,
|
||||
AiLayers, Imported, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, family_style, import, import_trusting,
|
||||
import_with, is_loss, layered_ai, layered_ai_from, page,
|
||||
};
|
||||
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai, writes_image_mask};
|
||||
pub use print::{PrintJob, PrintPage, print};
|
||||
|
||||
+332
-37
@@ -8,7 +8,7 @@ use vectorcraft_geom::path::measured_arclen;
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
|
||||
|
||||
use crate::boolean::{Seg, segs_to_subpath};
|
||||
use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, sample, start_tangent};
|
||||
use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, largest_second_difference, sample, start_tangent, unit};
|
||||
|
||||
/// Options for [`simplify_with`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -96,14 +96,66 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option<SubPath> {
|
||||
let k = (0..n).find(|&i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) > opts.corner_angle_deg).unwrap_or(0);
|
||||
segs.rotate_left(k);
|
||||
}
|
||||
let result = simplify_run(&segs, opts);
|
||||
// A smoothed loop with no corner is fitted along one direction across its start, when no segment
|
||||
// stays as it is and the fit has one part. A nearly straight first or last curve becomes a line
|
||||
// along its chord, and `no_worse` can return the original subpath, so the start can keep a kink.
|
||||
let across = if sp.closed && opts.smooth > 0.0 { unit(end_tangent(&segs[n - 1].c) + start_tangent(&segs[0].c)) } else { None };
|
||||
let across = across.filter(|_| (0..n).all(|i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) <= opts.corner_angle_deg));
|
||||
let result = simplify_run_within(&segs, opts, MAX_RUN_SAMPLES, across);
|
||||
let s = segs_to_subpath(&result, sp.closed)?;
|
||||
Some(if opts.straight_lines { s } else { no_worse(sp, s) })
|
||||
}
|
||||
|
||||
/// How far a segment may be from the lines between its samples, as a share of the tolerance.
|
||||
const SAMPLE_GAP: f64 = 0.005;
|
||||
|
||||
/// Most samples of one segment. A segment that needs more stays as it is.
|
||||
const MAX_SEGMENT_SAMPLES: usize = 1 << 16;
|
||||
|
||||
/// Most samples of one fit, which limits the memory a fit holds. A run with more is fitted in parts,
|
||||
/// with an anchor between them.
|
||||
pub(crate) const MAX_FIT_SAMPLES: usize = 1 << 20;
|
||||
|
||||
/// Most samples of one run, which limits its time. The segments after the run reaches this stay as
|
||||
/// they are.
|
||||
const MAX_RUN_SAMPLES: usize = 1 << 24;
|
||||
|
||||
/// Most segments of the result of one run before its other segments stay as they are, which limits
|
||||
/// the memory it holds. A fit under way can add up to [`MAX_FIT_SAMPLES`] more.
|
||||
pub(crate) const MAX_RUN_RESULT: usize = 1 << 21;
|
||||
|
||||
/// How many evenly spaced steps along `c` keep it within [`SAMPLE_GAP`] of `tol` from the lines
|
||||
/// between its samples. Over a step of `1 / m`, a curve stays within `0.75 * s / (m * m)` of the line
|
||||
/// between its ends, where `s` is its largest second difference. The samples of a straight segment
|
||||
/// are on its line, so their spacing follows its length and `tol`, up to 256.
|
||||
pub(crate) fn sample_steps(c: &CubicBez, tol: f64) -> usize {
|
||||
// Guarded: kurbo subdivides without end on huge finite coordinates.
|
||||
let len = measured_arclen(&PathSeg::Cubic(*c), 1e-6);
|
||||
let by_length = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256);
|
||||
if is_straight(c, tol * SAMPLE_GAP * 0.5) {
|
||||
return by_length;
|
||||
}
|
||||
by_length.max((0.75 * largest_second_difference(c) / (tol * SAMPLE_GAP)).sqrt().ceil() as usize)
|
||||
}
|
||||
|
||||
/// How many evenly spaced straight lines keep `c` within `tol` of them.
|
||||
fn line_steps(c: &CubicBez, tol: f64) -> usize {
|
||||
(0.75 * largest_second_difference(c) / tol).sqrt().ceil() as usize
|
||||
}
|
||||
|
||||
/// The refit of the consecutive segments `run`, split at its corners sharper than the threshold:
|
||||
/// it starts and ends where `run` does, leaving the same way.
|
||||
fn simplify_run(run: &[Seg], opts: &SimplifyOptions) -> Vec<Seg> {
|
||||
simplify_run_within(run, opts, MAX_RUN_SAMPLES, None)
|
||||
}
|
||||
|
||||
/// [`simplify_run`] with at most `budget` samples. A segment that needs more samples than
|
||||
/// [`MAX_SEGMENT_SAMPLES`] or than the budget has left, or that comes after [`MAX_RUN_RESULT`] segments
|
||||
/// of result, stays as it is, and the segments on each side of it are refitted without it. With
|
||||
/// `straight_lines` such a segment becomes [`line_steps`] lines, or as many as those limits allow when
|
||||
/// that is fewer, and then it can pass the tolerance. `ends`, when the whole run is refitted as one
|
||||
/// part, is the direction the refit leaves and arrives along.
|
||||
pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budget: usize, ends: Option<Vec2>) -> Vec<Seg> {
|
||||
let tol = opts.tolerance.max(1e-6);
|
||||
let mut cuts: Vec<usize> = (1..run.len()).filter(|&i| turn_deg(&run[i - 1], &run[i]) > opts.corner_angle_deg).collect();
|
||||
cuts.insert(0, 0);
|
||||
@@ -111,39 +163,85 @@ fn simplify_run(run: &[Seg], opts: &SimplifyOptions) -> Vec<Seg> {
|
||||
let mut result: Vec<Seg> = Vec::new();
|
||||
for w in cuts.windows(2) {
|
||||
let piece = &run[w[0]..w[1]];
|
||||
if piece.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
for (k, s) in piece.iter().enumerate() {
|
||||
// Guarded: kurbo subdivides without end on huge finite coordinates.
|
||||
let len = measured_arclen(&PathSeg::Cubic(s.c), 1e-6);
|
||||
let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256);
|
||||
sample(&s.c, m, &mut pts, k == 0);
|
||||
}
|
||||
if opts.smooth > 0.0 {
|
||||
pts = smooth_points(&pts, opts.smooth);
|
||||
}
|
||||
if opts.straight_lines {
|
||||
let keep = rdp(&pts, tol);
|
||||
result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1])));
|
||||
continue;
|
||||
}
|
||||
let t0 = start_tangent(&piece[0].c);
|
||||
let t1 = end_tangent(&piece[piece.len() - 1].c);
|
||||
for c in fit_cubics(&pts, t0, t1, tol) {
|
||||
let line = is_straight(&c, tol * 0.02);
|
||||
result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } });
|
||||
let mut part: Vec<(&Seg, usize)> = Vec::new();
|
||||
let mut part_samples = 0;
|
||||
for s in piece {
|
||||
let steps = sample_steps(&s.c, tol);
|
||||
if steps <= budget.min(MAX_SEGMENT_SAMPLES) && result.len() < MAX_RUN_RESULT {
|
||||
if part_samples + steps > MAX_FIT_SAMPLES {
|
||||
refit_part(&part, opts, tol, None, &mut result);
|
||||
part.clear();
|
||||
part_samples = 0;
|
||||
}
|
||||
budget -= steps;
|
||||
part_samples += steps;
|
||||
part.push((s, steps));
|
||||
} else {
|
||||
part_samples = 0;
|
||||
refit_part(&part, opts, tol, None, &mut result);
|
||||
part.clear();
|
||||
if opts.straight_lines {
|
||||
let room = MAX_RUN_RESULT.saturating_sub(result.len());
|
||||
let lines = line_steps(&s.c, tol).min(budget.min(MAX_SEGMENT_SAMPLES)).min(room).max(1);
|
||||
budget = budget.saturating_sub(lines);
|
||||
let mut pts = Vec::new();
|
||||
sample(&s.c, lines, &mut pts, true);
|
||||
result.extend(pts.windows(2).map(|p| Seg::line(p[0], p[1])));
|
||||
} else {
|
||||
result.push(*s);
|
||||
}
|
||||
}
|
||||
}
|
||||
refit_part(&part, opts, tol, ends.filter(|_| part.len() == run.len()), &mut result);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Adds the refit of `part`, consecutive segments with no corner between them and the steps to
|
||||
/// sample each one with, to `result`. `ends` is the direction it leaves and arrives along, when not
|
||||
/// those of its first and last segments.
|
||||
fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, ends: Option<Vec2>, result: &mut Vec<Seg>) {
|
||||
let (Some((first, _)), Some((last, _))) = (part.first(), part.last()) else { return };
|
||||
let mut pts = Vec::new();
|
||||
for (k, (s, steps)) in part.iter().enumerate() {
|
||||
sample(&s.c, *steps, &mut pts, k == 0);
|
||||
}
|
||||
if opts.smooth > 0.0 {
|
||||
pts = smooth_points(&pts, opts.smooth);
|
||||
}
|
||||
if opts.straight_lines {
|
||||
let keep = rdp(&pts, tol * (1.0 - SAMPLE_GAP));
|
||||
result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1])));
|
||||
return;
|
||||
}
|
||||
// The fit may be `tol * 0.98` from the lines between the samples, those lines may be
|
||||
// `SAMPLE_GAP` of `tol` from the path, and the curve the test below turns into a line moves by up
|
||||
// to 1.5% of `tol`: 3/4 of how far its handles may be from the line.
|
||||
let (t0, t1) = ends.map_or((start_tangent(&first.c), end_tangent(&last.c)), |d| (d, d));
|
||||
for c in fit_cubics(&pts, t0, t1, tol * 0.98) {
|
||||
let line = is_straight(&c, tol * 0.02);
|
||||
result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } });
|
||||
}
|
||||
}
|
||||
|
||||
/// `pts` (a run's samples, in order) smoothed along the path: each point becomes the Gaussian-weighted
|
||||
/// mean of the points around it, `sigma` points of arc length wide, the window narrowing towards the
|
||||
/// ends so that the first and last points stay where they are. Most points looked at per point is
|
||||
/// bounded by the samples within three spreads of it.
|
||||
/// ends so that the first and last points stay where they are. Consecutive samples within
|
||||
/// 1/[`SMOOTH_GROUP`] of a spread of each other along the path are weighed together, as their count at
|
||||
/// their mean place, so the work per point is bounded by the groups within three spreads of it however
|
||||
/// densely the path is sampled.
|
||||
fn smooth_points(pts: &[Point], sigma: f64) -> Vec<Point> {
|
||||
smooth_points_counted(pts, sigma, &mut 0)
|
||||
}
|
||||
|
||||
/// How many groups of samples a spread holds at most in [`smooth_points`]. Weighing them in groups
|
||||
/// leaves each smoothed point within a thousandth of a spread of the mean of its samples weighed one
|
||||
/// by one.
|
||||
const SMOOTH_GROUP: f64 = 16.0;
|
||||
|
||||
/// [`smooth_points`], adding the number of sample groups it looks at to `work`.
|
||||
fn smooth_points_counted(pts: &[Point], sigma: f64, work: &mut usize) -> Vec<Point> {
|
||||
let Some(&origin) = pts.first() else { return Vec::new() };
|
||||
if pts.len() < 3 || !(sigma.is_finite() && sigma > 0.0) {
|
||||
return pts.to_vec();
|
||||
}
|
||||
@@ -155,6 +253,7 @@ fn smooth_points(pts: &[Point], sigma: f64) -> Vec<Point> {
|
||||
}
|
||||
s.push(total);
|
||||
}
|
||||
let groups = SampleGroups::new(pts, &s, origin);
|
||||
let (mut lo, mut hi) = (0usize, 0usize);
|
||||
pts.iter()
|
||||
.zip(&s)
|
||||
@@ -170,17 +269,99 @@ fn smooth_points(pts: &[Point], sigma: f64) -> Vec<Point> {
|
||||
while s.get(hi + 1).is_some_and(|&v| v <= si + reach) {
|
||||
hi += 1;
|
||||
}
|
||||
let (mut sum, mut weight) = (Vec2::ZERO, 0.0);
|
||||
for (q, &sj) in pts.iter().zip(&s).take(hi + 1).skip(lo) {
|
||||
let w = (-(sj - si) * (sj - si) / (2.0 * spread * spread)).exp();
|
||||
sum += q.to_vec2() * w;
|
||||
weight += w;
|
||||
}
|
||||
if weight > 0.0 { (sum / weight).to_point() } else { p }
|
||||
let window = Window { lo, end: hi + 1, at: si, spread, group: spread / SMOOTH_GROUP };
|
||||
let mut acc = Weighed::default();
|
||||
groups.weigh(0, (0, pts.len()), &window, &mut acc);
|
||||
*work += acc.work;
|
||||
if acc.weight > 0.0 { origin + acc.sum / acc.weight } else { p }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The samples [`smooth_points`] weighs for one point: indices `lo..end`, weighed by a Gaussian of
|
||||
/// `spread` around arc length `at`, in groups at most `group` long along the path.
|
||||
struct Window {
|
||||
lo: usize,
|
||||
end: usize,
|
||||
at: f64,
|
||||
spread: f64,
|
||||
group: f64,
|
||||
}
|
||||
|
||||
/// The sums of halves, quarters and so on of a run's samples, by index: node 0 holds all of them and
|
||||
/// node `k`'s halves are nodes `2k + 1` and `2k + 2`.
|
||||
struct SampleGroups<'a> {
|
||||
/// Arc length of each sample.
|
||||
s: &'a [f64],
|
||||
/// Sum of the arc lengths of each node's samples.
|
||||
sum_s: Vec<f64>,
|
||||
/// Sum of each node's samples, measured from the first sample.
|
||||
sum_q: Vec<Vec2>,
|
||||
}
|
||||
|
||||
impl<'a> SampleGroups<'a> {
|
||||
fn new(pts: &[Point], s: &'a [f64], origin: Point) -> Self {
|
||||
let size = 4 * pts.len();
|
||||
let mut groups = Self { s, sum_s: vec![0.0; size], sum_q: vec![Vec2::ZERO; size] };
|
||||
groups.build(0, 0, pts.len(), pts, origin);
|
||||
groups
|
||||
}
|
||||
|
||||
fn build(&mut self, node: usize, l: usize, r: usize, pts: &[Point], origin: Point) {
|
||||
if r <= l {
|
||||
return;
|
||||
}
|
||||
let (sum_s, sum_q) = if r - l == 1 {
|
||||
(self.s.get(l).copied().unwrap_or(0.0), pts.get(l).map_or(Vec2::ZERO, |&p| p - origin))
|
||||
} else {
|
||||
let m = l + (r - l) / 2;
|
||||
self.build(2 * node + 1, l, m, pts, origin);
|
||||
self.build(2 * node + 2, m, r, pts, origin);
|
||||
let at = |v: &Vec<f64>, k: usize| v.get(k).copied().unwrap_or(0.0);
|
||||
let q = |k: usize| self.sum_q.get(k).copied().unwrap_or(Vec2::ZERO);
|
||||
(at(&self.sum_s, 2 * node + 1) + at(&self.sum_s, 2 * node + 2), q(2 * node + 1) + q(2 * node + 2))
|
||||
};
|
||||
if let Some(a) = self.sum_s.get_mut(node) {
|
||||
*a = sum_s;
|
||||
}
|
||||
if let Some(a) = self.sum_q.get_mut(node) {
|
||||
*a = sum_q;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the weighted samples of node `node` (indices `l..r`) that are inside `w` to `acc`: a node
|
||||
/// inside `w` no longer than its groups as its count at its mean arc length, any other node by
|
||||
/// its halves.
|
||||
fn weigh(&self, node: usize, (l, r): (usize, usize), w: &Window, acc: &mut Weighed) {
|
||||
if r <= w.lo || l >= w.end || r <= l {
|
||||
return;
|
||||
}
|
||||
acc.work += 1;
|
||||
let length = self.s.get(r - 1).copied().unwrap_or(0.0) - self.s.get(l).copied().unwrap_or(0.0);
|
||||
// One sample is always a group, even where arc lengths that overflowed leave `length` NaN.
|
||||
if r - l == 1 || (w.lo <= l && r <= w.end && length <= w.group) {
|
||||
let count = (r - l) as f64;
|
||||
let t = self.sum_s.get(node).copied().unwrap_or(0.0) / count - w.at;
|
||||
let g = (-t * t / (2.0 * w.spread * w.spread)).exp();
|
||||
acc.sum += self.sum_q.get(node).copied().unwrap_or(Vec2::ZERO) * g;
|
||||
acc.weight += count * g;
|
||||
return;
|
||||
}
|
||||
let m = l + (r - l) / 2;
|
||||
self.weigh(2 * node + 1, (l, m), w, acc);
|
||||
self.weigh(2 * node + 2, (m, r), w, acc);
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`SampleGroups::weigh`] has added up: the weighted sum of the samples (from the first
|
||||
/// sample), the sum of the weights, and the number of nodes looked at.
|
||||
#[derive(Default)]
|
||||
struct Weighed {
|
||||
sum: Vec2,
|
||||
weight: f64,
|
||||
work: usize,
|
||||
}
|
||||
|
||||
/// What [`simplify_part`] made of a path.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SimplifiedPart {
|
||||
@@ -295,7 +476,8 @@ fn with_handle(a: Anchor, out: bool, h: Point) -> Anchor {
|
||||
Anchor { p: a.p, h_in, h_out, kind }
|
||||
}
|
||||
|
||||
/// Ramer–Douglas–Peucker polyline reduction.
|
||||
/// Ramer–Douglas–Peucker polyline reduction. Each point dropped is within `tol` of the line segment
|
||||
/// that replaces it: a point beyond either end of the segment is measured to that end.
|
||||
fn rdp(pts: &[Point], tol: f64) -> Vec<Point> {
|
||||
if pts.len() < 3 {
|
||||
return pts.to_vec();
|
||||
@@ -317,10 +499,10 @@ fn rdp(pts: &[Point], tol: f64) -> Vec<Point> {
|
||||
};
|
||||
let (pa, pb) = (pts[a], pts[b]);
|
||||
let chord = pb - pa;
|
||||
let len = chord.hypot();
|
||||
let len2 = chord.hypot2();
|
||||
let mut best = (0.0, a);
|
||||
for (i, &p) in pts.iter().enumerate().take(b).skip(a + 1) {
|
||||
let d = if len < 1e-12 { p.distance(pa) } else { (p - pa).cross(chord).abs() / len };
|
||||
let d = if len2 < 1e-24 { p.distance(pa) } else { p.distance(pa + chord * ((p - pa).dot(chord) / len2).clamp(0.0, 1.0)) };
|
||||
if d > best.0 {
|
||||
best = (d, i);
|
||||
}
|
||||
@@ -808,3 +990,116 @@ pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, (row_gutter, col_gu
|
||||
fn end_point(sp: &SubPath, last: bool) -> Option<Point> {
|
||||
if last { sp.anchors.last() } else { sp.anchors.first() }.map(|a| a.p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The samples Object › Path › Smooth at `level` smooths for `sp`, a subpath with no corner,
|
||||
/// and the spread it smooths them with.
|
||||
fn smooth_samples(sp: &SubPath, level: f64) -> (Vec<Point>, f64) {
|
||||
let opts = smooth_options(&PathData::single(sp.clone()), level);
|
||||
let mut pts = Vec::new();
|
||||
for (k, s) in subpath_segs(sp).iter().enumerate() {
|
||||
sample(&s.c, sample_steps(&s.c, opts.tolerance), &mut pts, k == 0);
|
||||
}
|
||||
(pts, opts.smooth)
|
||||
}
|
||||
|
||||
/// A traced outline: `n` straight segments around a circle of radius 200, each anchor moved in
|
||||
/// or out by up to 0.5 pt.
|
||||
fn traced_circle(n: usize) -> SubPath {
|
||||
let pts: Vec<Point> = (0..n)
|
||||
.map(|i| {
|
||||
let a = i as f64 / n as f64 * std::f64::consts::TAU;
|
||||
let r = 200.0 + 0.5 * ((i * 7919) % 13) as f64 / 13.0;
|
||||
Point::new(300.0 + r * a.cos(), 300.0 + r * a.sin())
|
||||
})
|
||||
.collect();
|
||||
SubPath::polyline(&pts, true)
|
||||
}
|
||||
|
||||
/// [`smooth_points`] weighing every sample on its own.
|
||||
fn smooth_points_one_by_one(pts: &[Point], sigma: f64) -> Vec<Point> {
|
||||
let mut s = vec![0.0];
|
||||
for w in pts.windows(2) {
|
||||
s.push(s.last().copied().unwrap_or(0.0) + w[0].distance(w[1]));
|
||||
}
|
||||
let total = s.last().copied().unwrap_or(0.0);
|
||||
pts.iter()
|
||||
.zip(&s)
|
||||
.map(|(&p, &si)| {
|
||||
let reach = (3.0 * sigma).min(si).min(total - si);
|
||||
if reach <= 1e-9 {
|
||||
return p;
|
||||
}
|
||||
let spread = reach / 3.0;
|
||||
let (mut sum, mut weight) = (Vec2::ZERO, 0.0);
|
||||
for (q, &sj) in pts.iter().zip(&s).filter(|(_, sj)| (*sj - si).abs() <= reach) {
|
||||
let w = (-(sj - si) * (sj - si) / (2.0 * spread * spread)).exp();
|
||||
sum += (*q - p) * w;
|
||||
weight += w;
|
||||
}
|
||||
p + sum / weight
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Next value in 0..1 of a small linear congruential generator.
|
||||
fn next(seed: &mut u64) -> f64 {
|
||||
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(*seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothing_work_per_sample_stays_bounded_on_dense_paths() {
|
||||
// Weighing every sample within three spreads took 360 steps per sample for 1,000 anchors and
|
||||
// 2,949 for 16,000: the work grew with the square of the anchor count.
|
||||
for n in [1_000usize, 16_000] {
|
||||
let (pts, sigma) = smooth_samples(&traced_circle(n), 100.0);
|
||||
let mut work = 0;
|
||||
smooth_points_counted(&pts, sigma, &mut work);
|
||||
assert!(work <= 400 * pts.len(), "{n} anchors: {work} steps for {} samples", pts.len());
|
||||
}
|
||||
// A curve sampled at a tiny tolerance and smoothed far beyond its length.
|
||||
let c = CubicBez::new((0.0, 0.0), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0));
|
||||
let mut pts = Vec::new();
|
||||
sample(&c, sample_steps(&c, 0.00001), &mut pts, true);
|
||||
let mut work = 0;
|
||||
smooth_points_counted(&pts, 1000.0, &mut work);
|
||||
assert!(work <= 400 * pts.len(), "{work} steps for {} samples", pts.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothing_points_whose_arc_length_overflows_returns_every_point() {
|
||||
let pts: Vec<Point> = (0..40).map(|i| Point::new(if i % 2 == 0 { -1e308 } else { 1e308 }, f64::from(i))).collect();
|
||||
assert_eq!(smooth_points(&pts, 10.0).len(), pts.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_smoothing_stays_within_a_thousandth_of_a_spread_of_weighing_one_by_one() {
|
||||
let mut seed = 7u64;
|
||||
for case in 0..300 {
|
||||
let n = 3 + (next(&mut seed) * 2000.0) as usize;
|
||||
let mut p = Point::ZERO;
|
||||
let mut dir: f64 = 0.0;
|
||||
let dense = next(&mut seed) < 0.5;
|
||||
let pts: Vec<Point> = (0..n)
|
||||
.map(|_| {
|
||||
dir += (next(&mut seed) - 0.5) * 1.5;
|
||||
let step = if dense { next(&mut seed) * 0.05 } else { next(&mut seed) * 5.0 };
|
||||
p += Vec2::new(dir.cos(), dir.sin()) * step;
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
let sigma = 0.01 + next(&mut seed) * 50.0;
|
||||
let exact = smooth_points_one_by_one(&pts, sigma);
|
||||
let grouped = smooth_points(&pts, sigma);
|
||||
assert_eq!(grouped.len(), exact.len());
|
||||
for (i, (a, b)) in exact.iter().zip(&grouped).enumerate() {
|
||||
assert!(a.distance(*b) <= 1e-3 * sigma, "case {case}, point {i}: {a:?} and {b:?}, spread {sigma}");
|
||||
}
|
||||
assert_eq!((grouped.first(), grouped.last()), (pts.first(), pts.last()), "case {case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+666
-19
@@ -1,12 +1,22 @@
|
||||
//! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end
|
||||
//! tangents, then reparameterise with Newton steps, then split at the worst point" approach from
|
||||
//! tangents, then reparameterise with Newton steps, then split near the worst point" approach from
|
||||
//! Graphics Gems, written from the published description).
|
||||
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, Point, Vec2};
|
||||
use kurbo::{CubicBez, Line, ParamCurve, ParamCurveDeriv, ParamCurveNearest, Point, Vec2};
|
||||
|
||||
/// Maximum Newton reparameterisation rounds per fit.
|
||||
const MAX_REPARAM: usize = 6;
|
||||
|
||||
/// Most pieces joined to the first piece of a fitted curve. Each join refits every point of the
|
||||
/// curve, so this limits how many times a point is refitted.
|
||||
const MAX_JOINS: usize = 32;
|
||||
|
||||
/// Most samples of a fitted curve checked between two neighboring points. See [`bulge`].
|
||||
const MAX_BULGE_SAMPLES: f64 = 1024.0;
|
||||
|
||||
/// About how many points of a long piece its first fit uses.
|
||||
const COARSE_POINTS: usize = 16;
|
||||
|
||||
/// Unit vector or `None` if (nearly) zero.
|
||||
pub(crate) fn unit(v: Vec2) -> Option<Vec2> {
|
||||
let l = v.hypot();
|
||||
@@ -28,15 +38,43 @@ pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 {
|
||||
/// `t0` is the unit tangent leaving `pts[0]`; `t1` is the unit tangent arriving at the last point
|
||||
/// (forward direction). Consecutive output cubics are G1 continuous.
|
||||
pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec<CubicBez> {
|
||||
let mut out = Vec::new();
|
||||
let pts = dedup(pts);
|
||||
if pts.len() < 2 {
|
||||
return out;
|
||||
return Vec::new();
|
||||
}
|
||||
let tol = tol.max(1e-9);
|
||||
let mut pieces = Vec::new();
|
||||
fit_rec(&pts, 0, pts.len() - 1, t0, t1, tol, &mut pieces);
|
||||
join_pieces(&pts, &pieces, t0, tol)
|
||||
}
|
||||
|
||||
/// The curves of `pieces`, which follow each other along `pts` from its first point, leaving it
|
||||
/// along `t0`. Each curve takes up to [`MAX_JOINS`] of the pieces after its first one, as long as
|
||||
/// the fit of them together is within `tol`.
|
||||
fn join_pieces(pts: &[Point], pieces: &[Piece], t0: Vec2, tol: f64) -> Vec<CubicBez> {
|
||||
let mut out = Vec::new();
|
||||
let (mut start, mut leaving, mut next) = (0, t0, 0);
|
||||
while let Some(first) = pieces.get(next) {
|
||||
let (mut curve, mut end, mut arriving) = (first.curve, first.end, first.t1);
|
||||
next += 1;
|
||||
for piece in pieces.iter().skip(next).take(MAX_JOINS) {
|
||||
let Ok(joined) = fit_piece(&pts[start..=piece.end], leaving, piece.t1, tol) else { break };
|
||||
(curve, end, arriving) = (joined, piece.end, piece.t1);
|
||||
next += 1;
|
||||
}
|
||||
out.push(curve);
|
||||
(start, leaving) = (end, arriving);
|
||||
}
|
||||
fit_rec(&pts, t0, t1, tol.max(1e-9), &mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// A fitted piece: its curve, the index of the point it ends on and its tangent there.
|
||||
struct Piece {
|
||||
curve: CubicBez,
|
||||
end: usize,
|
||||
t1: Vec2,
|
||||
}
|
||||
|
||||
fn dedup(pts: &[Point]) -> Vec<Point> {
|
||||
let mut v: Vec<Point> = Vec::with_capacity(pts.len());
|
||||
for &p in pts {
|
||||
@@ -47,23 +85,65 @@ fn dedup(pts: &[Point]) -> Vec<Point> {
|
||||
v
|
||||
}
|
||||
|
||||
fn fit_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec<CubicBez>, depth: usize) {
|
||||
/// Fits `all[start..=end]`, splitting a piece in two until each piece is within `tol`. A piece of
|
||||
/// two points is a cubic through both with handles along the tangents, at most `tol` long, so it
|
||||
/// stays within `tol` of the line between them.
|
||||
///
|
||||
/// The split point is the piece's worst point moved, when it is near an end, to two fifths of the
|
||||
/// piece from that end. Each half then holds about three fifths of the points at most, so the
|
||||
/// recursion depth is logarithmic in the number of points.
|
||||
fn fit_rec(all: &[Point], start: usize, end: usize, t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec<Piece>) {
|
||||
let pts = &all[start..=end];
|
||||
let n = pts.len();
|
||||
if n == 2 {
|
||||
let d = pts[0].distance(pts[1]) / 3.0;
|
||||
out.push(CubicBez::new(pts[0], pts[0] + t0 * d, pts[1] - t1 * d, pts[1]));
|
||||
let d = (pts[0].distance(pts[1]) / 3.0).min(tol);
|
||||
out.push(Piece { curve: CubicBez::new(pts[0], pts[0] + t0 * d, pts[1] - t1 * d, pts[1]), end, t1 });
|
||||
return;
|
||||
}
|
||||
let (c, err, split) = fit_single(pts, t0, t1);
|
||||
if err <= tol || depth > 40 {
|
||||
out.push(c);
|
||||
return;
|
||||
let split = match fit_piece(pts, t0, t1, tol) {
|
||||
Ok(curve) => {
|
||||
out.push(Piece { curve, end, t1 });
|
||||
return;
|
||||
}
|
||||
Err(worst) => worst,
|
||||
};
|
||||
let margin = (n * 2 / 5).max(1);
|
||||
let split = split.clamp(margin, n - 1 - margin);
|
||||
let tc = center_tangent(pts, split, tol).unwrap_or(t0);
|
||||
fit_rec(all, start, start + split, t0, tc, tol, out);
|
||||
fit_rec(all, start + split, end, tc, t1, tol, out);
|
||||
}
|
||||
|
||||
/// One cubic for three or more `pts`, when every point is within `tol` of it and it is within `tol`
|
||||
/// of the lines between the points; otherwise the index of a point where it is not. A first fit over
|
||||
/// 8 times `tol` plus 5% of the chord is not reparameterized.
|
||||
///
|
||||
/// A piece of many points is first fitted to about [`COARSE_POINTS`] of them, and it is split when
|
||||
/// that fit is over `tol` at one of those. Each split can be undone by [`join_pieces`].
|
||||
fn fit_piece(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Result<CubicBez, usize> {
|
||||
let give_up = tol * 8.0 + pts[0].distance(pts[pts.len() - 1]) * 0.05;
|
||||
if pts.len() >= COARSE_POINTS * 2 {
|
||||
let stride = pts.len() / COARSE_POINTS;
|
||||
let some: Vec<Point> = pts.iter().step_by(stride).chain(pts.last()).copied().collect();
|
||||
let (_, err, worst, _) = fit_single_within(&some, chord_params(&some), t0, t1, give_up);
|
||||
if err > tol {
|
||||
return Err((worst * stride).min(pts.len() - 1));
|
||||
}
|
||||
}
|
||||
// Split at the worst point with a centre tangent estimated from its neighbours.
|
||||
let split = split.clamp(1, n - 2);
|
||||
let tc = unit(pts[split + 1] - pts[split - 1]).unwrap_or(t0);
|
||||
fit_rec(&pts[..=split], t0, tc, tol, out, depth + 1);
|
||||
fit_rec(&pts[split..], tc, t1, tol, out, depth + 1);
|
||||
let (c, err, worst, u) = fit_single_within(pts, chord_params(pts), t0, t1, give_up);
|
||||
if err > tol {
|
||||
return Err(worst);
|
||||
}
|
||||
bulge(pts, &c, &u, tol).map_or(Ok(c), Err)
|
||||
}
|
||||
|
||||
/// Direction at `pts[split]`, between the nearest points on each side that are over `tol` from it,
|
||||
/// or the first and last points when none is, so that details smaller than `tol` do not turn it.
|
||||
fn center_tangent(pts: &[Point], split: usize, tol: f64) -> Option<Vec2> {
|
||||
let p = pts[split];
|
||||
let before = pts[..split].iter().rev().find(|q| q.distance(p) > tol).unwrap_or(&pts[0]);
|
||||
let after = pts[split + 1..].iter().find(|q| q.distance(p) > tol).unwrap_or(&pts[pts.len() - 1]);
|
||||
unit(*after - *before)
|
||||
}
|
||||
|
||||
/// Best single cubic for `pts` with the given end tangents. Returns (curve, max error, index of
|
||||
@@ -74,9 +154,20 @@ pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, u
|
||||
|
||||
/// [`fit_single`] starting from the parameters `u` (one per point, 0 to 1) instead of the
|
||||
/// chord-length ones.
|
||||
pub(crate) fn fit_single_from(pts: &[Point], mut u: Vec<f64>, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
|
||||
pub(crate) fn fit_single_from(pts: &[Point], u: Vec<f64>, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
|
||||
let (c, err, worst, _) = fit_single_within(pts, u, t0, t1, f64::INFINITY);
|
||||
(c, err, worst)
|
||||
}
|
||||
|
||||
/// [`fit_single_from`] that also returns the fit's parameters, and returns the first fit without
|
||||
/// any reparameterization when its error is over `give_up`.
|
||||
fn fit_single_within(pts: &[Point], mut u: Vec<f64>, t0: Vec2, t1: Vec2, give_up: f64) -> (CubicBez, f64, usize, Vec<f64>) {
|
||||
let mut best = generate(pts, &u, t0, t1);
|
||||
let (mut best_err, mut best_split) = max_error(pts, &best, &u);
|
||||
if best_err > give_up {
|
||||
return (best, best_err, best_split, u);
|
||||
}
|
||||
let mut best_u = u.clone();
|
||||
for _ in 0..MAX_REPARAM {
|
||||
for (i, ui) in u.iter_mut().enumerate() {
|
||||
*ui = newton(&best, pts[i], *ui);
|
||||
@@ -87,11 +178,42 @@ pub(crate) fn fit_single_from(pts: &[Point], mut u: Vec<f64>, t0: Vec2, t1: Vec2
|
||||
best = c;
|
||||
best_err = e;
|
||||
best_split = s;
|
||||
best_u.copy_from_slice(&u);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(best, best_err, best_split)
|
||||
(best, best_err, best_split, best_u)
|
||||
}
|
||||
|
||||
/// Index of the first point of `pts` after which the curve `c`, between that point's parameter in
|
||||
/// `u` and the next point's, is over 99% of `tol` from the line between the two points at a sample.
|
||||
///
|
||||
/// Over a parameter step `h`, a curve stays within `h * h * m / 8` of the chord between its ends,
|
||||
/// where `m` is its largest second derivative, at most 6 times the control points' largest second
|
||||
/// difference. The samples are close enough to keep that to 1% of `tol`. A pair of points that would
|
||||
/// need more than [`MAX_BULGE_SAMPLES`] counts as a point where the curve is too far.
|
||||
fn bulge(pts: &[Point], c: &CubicBez, u: &[f64], tol: f64) -> Option<usize> {
|
||||
let step = (0.01 * tol * 8.0 / (6.0 * largest_second_difference(c))).sqrt();
|
||||
let limit = tol * 0.99;
|
||||
pts.windows(2).zip(u.windows(2)).position(|(p, t)| {
|
||||
let line = Line::new(p[0], p[1]);
|
||||
let samples = ((t[1] - t[0]).abs() / step).ceil().max(1.0);
|
||||
if samples > MAX_BULGE_SAMPLES {
|
||||
return true;
|
||||
}
|
||||
let samples = samples as usize;
|
||||
(1..=samples).any(|k| {
|
||||
let at = t[0] + (t[1] - t[0]) * k as f64 / (samples + 1) as f64;
|
||||
line.nearest(c.eval(at), 1e-9).distance_sq > limit * limit
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The larger second difference of the control points of `c`. Its largest second derivative is 6
|
||||
/// times this.
|
||||
pub(crate) fn largest_second_difference(c: &CubicBez) -> f64 {
|
||||
(c.p2 - c.p1 - (c.p1 - c.p0)).hypot().max((c.p3 - c.p2 - (c.p2 - c.p1)).hypot())
|
||||
}
|
||||
|
||||
fn chord_params(pts: &[Point]) -> Vec<f64> {
|
||||
@@ -200,6 +322,7 @@ pub(crate) fn sample(c: &CubicBez, n: usize, out: &mut Vec<Point>, include_start
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::{Anchor, PathData, SubPath};
|
||||
|
||||
#[test]
|
||||
fn fits_a_cubic_exactly() {
|
||||
@@ -222,6 +345,530 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Numbers from 0 to 1, the same ones for the same `seed`.
|
||||
fn random(mut seed: u64) -> impl FnMut() -> f64 {
|
||||
move || {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest distance from a point of `pts` to the nearest of the cubics `cs`, or infinity when
|
||||
/// a point is more than `reach` from the bounding box of every cubic.
|
||||
fn farthest(pts: &[Point], cs: &[CubicBez], reach: f64) -> f64 {
|
||||
use kurbo::ParamCurveExtrema;
|
||||
let boxes: Vec<_> = cs.iter().map(|c| c.bounding_box().inflate(reach, reach)).collect();
|
||||
pts.iter()
|
||||
.map(|&p| {
|
||||
cs.iter().zip(&boxes).filter(|(_, b)| b.contains(p)).map(|(c, _)| c.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min)
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
fn cubics(path: &PathData) -> Vec<CubicBez> {
|
||||
path.subpaths.iter().flat_map(|s| (0..s.segment_count()).map(|i| s.segment(i))).collect()
|
||||
}
|
||||
|
||||
fn samples(cs: &[CubicBez]) -> Vec<Point> {
|
||||
let mut pts = Vec::new();
|
||||
for c in cs {
|
||||
sample(c, 16, &mut pts, true);
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// Samples of `cs` that follow each curve to within 1% of `tol`, at least 16 per curve.
|
||||
fn close_samples(cs: &[CubicBez], tol: f64) -> Vec<Point> {
|
||||
let mut pts = Vec::new();
|
||||
for c in cs {
|
||||
let steps = (0.75 * largest_second_difference(c) / (0.01 * tol)).sqrt().ceil().clamp(16.0, 20000.0);
|
||||
sample(c, steps as usize, &mut pts, true);
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// Simplify of `path` has no more anchors, and each of the path and its result is within `tol`
|
||||
/// of the other, measured on the curves themselves. Returns the result.
|
||||
fn assert_simplifies_within(path: &PathData, tol: f64, name: &str) -> PathData {
|
||||
let out = crate::simplify_with(path, &crate::SimplifyOptions { tolerance: tol, ..Default::default() });
|
||||
assert!(out.anchor_count() <= path.anchor_count(), "{name}: {} anchors from {}", out.anchor_count(), path.anchor_count());
|
||||
assert_within(&cubics(path), &cubics(&out), tol, name);
|
||||
out
|
||||
}
|
||||
|
||||
/// Each of `before` and `after` is within `tol` of the other, measured on the curves themselves.
|
||||
fn assert_within(before: &[CubicBez], after: &[CubicBez], tol: f64, name: &str) {
|
||||
let limit = tol * (1.0 + 1e-6);
|
||||
let to_result = farthest(&close_samples(before, tol), after, limit * 8.0);
|
||||
assert!(to_result <= limit, "{name}: a point of the path is {to_result} from the result at a tolerance of {tol}");
|
||||
let to_path = farthest(&close_samples(after, tol), before, limit * 8.0);
|
||||
assert!(to_path <= limit, "{name}: a point of the result is {to_path} from the path at a tolerance of {tol}");
|
||||
}
|
||||
|
||||
/// The segments of `sp` as a run for `simplify_run_within`.
|
||||
fn run_of(sp: &SubPath) -> Vec<crate::boolean::Seg> {
|
||||
(0..sp.segment_count()).map(|i| crate::boolean::Seg { c: sp.segment(i), line: false }).collect()
|
||||
}
|
||||
|
||||
/// Anchors along a circle of radius `r` about the origin, from `first` degrees in steps of
|
||||
/// `step` degrees, smooth, with the handles of a circular arc.
|
||||
fn arc_anchors(r: f64, first: f64, step: f64, count: usize) -> Vec<Anchor> {
|
||||
let handle = r * 4.0 / 3.0 * (step.to_radians() / 4.0).tan();
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let (s, c) = (first + i as f64 * step).to_radians().sin_cos();
|
||||
let p = Point::new(r * c, r * s);
|
||||
Anchor::smooth(p, p + Vec2::new(-s, c) * handle)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `count` corner anchors 0.5 pt apart, going on from `from` along `dir`.
|
||||
fn short_anchors(from: Point, dir: Vec2, count: usize) -> Vec<Anchor> {
|
||||
(1..=count).map(|i| Anchor::corner(from + dir * (i as f64 * 0.5))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_arcs_stay_within_the_tolerance() {
|
||||
// Simplify keeps a subpath as it was when the refit has more anchors, so this measures the
|
||||
// refit itself.
|
||||
for (r, tol) in [(1_000_000.0, 2.0), (1_000_000.0, 1.0), (100_000.0, 0.1), (100_000.0, 1.0 / 32.0), (20_000.0, 0.01), (5_000.0, 0.01)] {
|
||||
let run = run_of(&SubPath::new(arc_anchors(r, 0.0, 90.0, 4), false));
|
||||
let opts = crate::SimplifyOptions { tolerance: tol, ..Default::default() };
|
||||
let out = crate::edit::simplify_run_within(&run, &opts, usize::MAX, None);
|
||||
let (before, after): (Vec<_>, Vec<_>) = (run.iter().map(|s| s.c).collect(), out.iter().map(|s| s.c).collect());
|
||||
assert_within(&before, &after, tol, &format!("arcs of {r} pt"));
|
||||
assert!(after.len() < 200, "arcs of {r} pt at {tol} pt give {} curves", after.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_arcs_and_short_anchors_stay_within_the_tolerance() {
|
||||
for (r, tol) in [(100_000.0, 1.0), (100_000.0, 0.1), (100_000.0, 1.0 / 32.0), (20_000.0, 0.01), (5_000.0, 0.01), (1_000_000.0, 1.0)] {
|
||||
let mut anchors = arc_anchors(r, 0.0, 90.0, 4);
|
||||
let end = anchors[3].p;
|
||||
anchors.extend(short_anchors(end, Vec2::new(1.0, 0.0), 3000));
|
||||
let out = assert_simplifies_within(&PathData::single(SubPath::new(anchors, false)), tol, &format!("arcs of {r} pt and short anchors"));
|
||||
assert!(out.anchor_count() < 200, "arcs of {r} pt at {tol} pt give {} anchors", out.anchor_count());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_arcs_converted_to_straight_lines_stay_within_the_tolerance() {
|
||||
for (r, tol) in [(100_000.0, 0.1), (20_000.0, 0.01)] {
|
||||
let mut anchors = arc_anchors(r, 0.0, 90.0, 4);
|
||||
let end = anchors[3].p;
|
||||
anchors.extend(short_anchors(end, Vec2::new(1.0, 0.0), 3000));
|
||||
let path = PathData::single(SubPath::new(anchors, false));
|
||||
let out = crate::simplify_with(&path, &crate::SimplifyOptions { tolerance: tol, straight_lines: true, ..Default::default() });
|
||||
assert_within(&cubics(&path), &cubics(&out), tol, &format!("lines along arcs of {r} pt"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_turns_back_past_its_end_converted_to_straight_lines_keeps_its_tip() {
|
||||
// A line to (100, 0), a smooth 0.2 pt U-turn and a line back to (50, 0.2): every point is
|
||||
// within 0.4 pt of the line through the ends, but the tip is 50 pt past the end.
|
||||
let k = 0.2 * 2.0 / 3.0;
|
||||
let (a, b) = (Point::new(100.0, 0.0), Point::new(100.0, 0.2));
|
||||
let anchors = vec![
|
||||
Anchor::corner(Point::new(0.0, 0.0)),
|
||||
Anchor::with_handles(a, a, Point::new(100.0 + k, 0.0)),
|
||||
Anchor::with_handles(b, Point::new(100.0 + k, 0.2), b),
|
||||
Anchor::corner(Point::new(50.0, 0.2)),
|
||||
];
|
||||
let path = PathData::single(SubPath::new(anchors, false));
|
||||
let out = crate::simplify_with(&path, &crate::SimplifyOptions { tolerance: 1.0, straight_lines: true, ..Default::default() });
|
||||
assert_within(&cubics(&path), &cubics(&out), 1.0, "a U-turn past the end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_and_short_segments_stay_within_the_tolerance() {
|
||||
for (r, tol) in [(50_000.0, 2.0), (50_000.0, 1.0 / 32.0), (8_000.0, 0.01), (500.0, 0.5)] {
|
||||
let mut anchors = Vec::new();
|
||||
for block in 0..6 {
|
||||
let first = f64::from(block) * 60.0;
|
||||
let arc = arc_anchors(r, first, 20.0, 3);
|
||||
let (s, c) = (first + 40.0).to_radians().sin_cos();
|
||||
let end = arc[2].p;
|
||||
anchors.extend(arc);
|
||||
anchors.extend(short_anchors(end, Vec2::new(-s, c), 300));
|
||||
}
|
||||
assert_simplifies_within(&PathData::single(SubPath::new(anchors, false)), tol, &format!("radius {r}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_that_needs_too_many_samples_stays_as_it_is() {
|
||||
// Radius 10,000,000 pt at 0.001 pt needs about 770,000 steps per quarter circle.
|
||||
let mut anchors = arc_anchors(10_000_000.0, 0.0, 90.0, 4);
|
||||
let end = anchors[3].p;
|
||||
anchors.extend(short_anchors(end, Vec2::new(1.0, 0.0), 500));
|
||||
let path = PathData::single(SubPath::new(anchors, false));
|
||||
let out = crate::simplify_with(&path, &crate::SimplifyOptions { tolerance: 0.001, ..Default::default() });
|
||||
assert_eq!(cubics(&out)[..3], cubics(&path)[..3]);
|
||||
assert!(out.anchor_count() < 20, "{} anchors", out.anchor_count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_that_needs_too_many_samples_becomes_lines_when_asked_for_lines() {
|
||||
// About 55,000 lines keep this arc within 0.001 pt, and about 770,000 samples would be needed
|
||||
// to fit it.
|
||||
let (r, tol) = (10_000_000.0, 0.001);
|
||||
let path = PathData::single(SubPath::new(arc_anchors(r, 0.0, 90.0, 2), false));
|
||||
let arc = cubics(&path)[0];
|
||||
let out = cubics(&crate::simplify_with(&path, &crate::SimplifyOptions { tolerance: tol, straight_lines: true, ..Default::default() }));
|
||||
assert!(out.iter().all(|c| is_straight(c, 1e-6)));
|
||||
for (k, line) in out.iter().enumerate() {
|
||||
let middle = arc.eval((k as f64 + 0.5) / out.len() as f64);
|
||||
assert!(middle.distance(line.p0.midpoint(line.p3)) <= tol, "line {k} of {}", out.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_for_segments_that_need_too_many_samples_are_limited() {
|
||||
// Each of these 35 segments takes the most lines a segment can have.
|
||||
let run = run_of(&SubPath::new(arc_anchors(1e9, 0.0, 10.0, 36), false));
|
||||
let opts = crate::SimplifyOptions { tolerance: 1e-3, straight_lines: true, ..Default::default() };
|
||||
let out = crate::edit::simplify_run_within(&run, &opts, usize::MAX, None);
|
||||
assert!(out.len() <= crate::edit::MAX_RUN_RESULT + run.len(), "{} lines", out.len());
|
||||
assert!(out.iter().all(|s| s.line));
|
||||
assert_eq!(out[out.len() - 1].c.p3, run[run.len() - 1].c.p3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_past_the_samples_of_one_fit_has_an_anchor_where_the_parts_meet() {
|
||||
let (r, tol) = (1_000_000.0, 0.0005);
|
||||
let run = run_of(&SubPath::new(arc_anchors(r, 0.0, 15.0, 24), false));
|
||||
let steps = crate::edit::sample_steps(&run[0].c, tol);
|
||||
let segments = crate::edit::MAX_FIT_SAMPLES / steps;
|
||||
assert!(segments + 1 < run.len(), "{steps} steps per segment");
|
||||
let opts = crate::SimplifyOptions { tolerance: tol, ..Default::default() };
|
||||
let out = crate::edit::simplify_run_within(&run, &opts, usize::MAX, None);
|
||||
let meet = run[segments - 1].c.p3;
|
||||
assert!(out.iter().any(|s| s.c.p3 == meet), "no anchor at {meet:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segments_past_the_sample_budget_stay_as_they_are() {
|
||||
// Every segment takes the same steps, so the budget covers the first ten.
|
||||
let sp = SubPath::new(arc_anchors(100.0, 0.0, 15.0, 20), false);
|
||||
let run = run_of(&sp);
|
||||
let opts = crate::SimplifyOptions { tolerance: 1.0, ..Default::default() };
|
||||
let steps = crate::edit::sample_steps(&run[0].c, 1.0);
|
||||
let out = crate::edit::simplify_run_within(&run, &opts, steps * 10, None);
|
||||
assert!(out.len() < 19, "{} segments", out.len());
|
||||
let tail: Vec<CubicBez> = out[out.len() - 9..].iter().map(|s| s.c).collect();
|
||||
let want: Vec<CubicBez> = run[10..].iter().map(|s| s.c).collect();
|
||||
assert_eq!(tail, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_that_needs_too_many_checks_is_rejected() {
|
||||
// The first curve goes along the line and back. The second one goes along it evenly.
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(1e9, 0.0)];
|
||||
let c = CubicBez::new(pts[0], pts[1], pts[0], pts[1]);
|
||||
assert_eq!(bulge(&pts, &c, &[0.0, 1.0], 1e-3), Some(0));
|
||||
let c = CubicBez::new(pts[0], Point::new(1e9 / 3.0, 0.0), Point::new(2e9 / 3.0, 0.0), pts[1]);
|
||||
assert_eq!(bulge(&pts, &c, &[0.0, 1.0], 1e-3), None);
|
||||
}
|
||||
|
||||
/// `rows` rows of `cols` smooth anchors 1.6 pt apart, alternately 2 pt up.
|
||||
fn serpentine(rows: usize, cols: usize, closed: bool) -> SubPath {
|
||||
let mut anchors = Vec::with_capacity(rows * cols);
|
||||
for row in 0..rows {
|
||||
let dir = if row % 2 == 0 { 1.0 } else { -1.0 };
|
||||
for col in 0..cols {
|
||||
let x = if row % 2 == 0 { col } else { cols - 1 - col };
|
||||
let p = Point::new(x as f64 * 1.6, row as f64 * 6.0 + if col % 2 == 0 { 0.0 } else { 2.0 });
|
||||
anchors.push(Anchor::smooth(p, p + Vec2::new(0.5 * dir, 0.0)));
|
||||
}
|
||||
}
|
||||
SubPath::new(anchors, closed)
|
||||
}
|
||||
|
||||
/// `n` straight segments of `step` pt that turn by `degrees` left and right in turn.
|
||||
fn zigzag(n: usize, degrees: f64, step: f64, closed: bool) -> SubPath {
|
||||
let half = (degrees / 2.0).to_radians();
|
||||
let mut p = Point::ZERO;
|
||||
let mut pts = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
pts.push(p);
|
||||
let a = if i % 2 == 0 { half } else { -half };
|
||||
p += Vec2::new(a.cos(), a.sin()) * step;
|
||||
}
|
||||
SubPath::polyline(&pts, closed)
|
||||
}
|
||||
|
||||
/// Smooth anchors along arcs that bend one way and then the other, 40 anchors per arc.
|
||||
fn alternating_arcs(arcs: usize) -> SubPath {
|
||||
let mut anchors = Vec::with_capacity(arcs * 40);
|
||||
let (mut p, mut heading) = (Point::ZERO, 0.0_f64);
|
||||
for arc in 0..arcs {
|
||||
for _ in 0..40 {
|
||||
heading += if arc % 2 == 0 { 0.08 } else { -0.08 };
|
||||
let d = Vec2::new(heading.cos(), heading.sin()) * 2.0;
|
||||
anchors.push(Anchor::smooth(p, p + d / 3.0));
|
||||
p += d;
|
||||
}
|
||||
}
|
||||
SubPath::new(anchors, false)
|
||||
}
|
||||
|
||||
/// A polyline of random steps, with some points repeated when `repeats` is set.
|
||||
fn random_walk(rnd: &mut impl FnMut() -> f64, n: usize, repeats: bool, closed: bool) -> SubPath {
|
||||
let mut p = Point::new(rnd() * 100.0, rnd() * 100.0);
|
||||
let mut pts = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
pts.push(p);
|
||||
if repeats && rnd() < 0.3 {
|
||||
pts.push(p);
|
||||
}
|
||||
p += Vec2::new(rnd() * 6.0 - 3.0, rnd() * 6.0 - 3.0);
|
||||
}
|
||||
SubPath::polyline(&pts, closed)
|
||||
}
|
||||
|
||||
/// A chain of smooth anchors with random steps and handles.
|
||||
fn random_curves(rnd: &mut impl FnMut() -> f64, n: usize, closed: bool) -> SubPath {
|
||||
let mut p = Point::ZERO;
|
||||
let mut anchors = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
anchors.push(Anchor::smooth(p, p + Vec2::new(1.0 + rnd() * 6.0, rnd() * 8.0 - 4.0)));
|
||||
p += Vec2::new(5.0 + rnd() * 20.0, rnd() * 30.0 - 15.0);
|
||||
}
|
||||
SubPath::new(anchors, closed)
|
||||
}
|
||||
|
||||
/// `n` smooth anchors 1.6 pt apart, each at a random height of up to 3 pt.
|
||||
fn random_ripple(rnd: &mut impl FnMut() -> f64, n: usize, closed: bool) -> SubPath {
|
||||
let anchors = (0..n)
|
||||
.map(|i| {
|
||||
let p = Point::new(i as f64 * 1.6, rnd() * 3.0);
|
||||
Anchor::smooth(p, p + Vec2::new(0.5, 0.0))
|
||||
})
|
||||
.collect();
|
||||
SubPath::new(anchors, closed)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_fitted_piece_is_farther_than_the_tolerance() {
|
||||
for (rows, cols) in [(1, 300), (1, 1000), (5, 400)] {
|
||||
assert_simplifies_within(&PathData::single(serpentine(rows, cols, false)), 1.0, &format!("serpentine {rows} x {cols}"));
|
||||
}
|
||||
assert_simplifies_within(&PathData::single(serpentine(1, 1000, false)), 0.25, "serpentine at 0.25 pt");
|
||||
assert_simplifies_within(&PathData::single(alternating_arcs(25)), 0.5, "alternating arcs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zigzag_stays_within_the_tolerance() {
|
||||
// The turns are below the 30 degree corner threshold, so the fit runs across them.
|
||||
assert_simplifies_within(&PathData::single(zigzag(1500, 20.0, 3.0, false)), 0.5, "zigzag");
|
||||
assert_simplifies_within(&PathData::single(zigzag(1500, 29.0, 1.0, false)), 0.1, "fine zigzag");
|
||||
// These turns are corners, so every segment is a run of its own.
|
||||
assert_simplifies_within(&PathData::single(zigzag(500, 60.0, 3.0, false)), 2.0, "zigzag of corners");
|
||||
// Simplify samples a segment this long more than `tol` apart, so a curve between two
|
||||
// samples can leave the path without a sample showing it.
|
||||
assert_simplifies_within(&PathData::single(zigzag(60, 25.0, 900.0, false)), 1.0, "zigzag of long segments");
|
||||
assert_simplifies_within(&PathData::single(zigzag(60, 8.0, 2500.0, true)), 0.5, "closed zigzag of long segments");
|
||||
// The short anchors make the result smaller than the path, so Simplify does not return the
|
||||
// path as it was.
|
||||
for (step, tol) in [(1000.0, 0.1), (5000.0, 1.0), (20000.0, 1.0)] {
|
||||
let mut sp = zigzag(40, 25.0, step, false);
|
||||
let end = sp.anchors[sp.anchors.len() - 1].p;
|
||||
sp.anchors.extend((1..=2000).map(|i| Anchor::corner(end + Vec2::new(i as f64 * 0.5, 0.0))));
|
||||
assert_simplifies_within(&PathData::single(sp), tol, &format!("zigzag of {step} pt segments and short anchors"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_paths_stay_within_the_tolerance() {
|
||||
assert_simplifies_within(&PathData::single(serpentine(3, 1000, true)), 1.0, "closed serpentine");
|
||||
assert_simplifies_within(&PathData::single(zigzag(800, 20.0, 3.0, true)), 0.5, "closed zigzag");
|
||||
let circle: Vec<Anchor> = (0..2000)
|
||||
.map(|i| {
|
||||
let a = i as f64 / 2000.0 * std::f64::consts::TAU;
|
||||
let p = Point::new(300.0 * a.cos(), 300.0 * a.sin());
|
||||
Anchor::smooth(p, p + Vec2::new(-a.sin(), a.cos()) * 0.3)
|
||||
})
|
||||
.collect();
|
||||
assert_simplifies_within(&PathData::single(SubPath::new(circle, true)), 0.05, "closed circle of 2,000 anchors");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_paths_stay_within_the_tolerance() {
|
||||
let mut rnd = random(11);
|
||||
let compound = PathData::new(vec![
|
||||
serpentine(1, 1000, false),
|
||||
zigzag(400, 20.0, 3.0, true),
|
||||
random_curves(&mut rnd, 100, false),
|
||||
SubPath::polyline(&[Point::new(0.0, -50.0), Point::new(100.0, -50.0)], false),
|
||||
]);
|
||||
assert_simplifies_within(&compound, 0.75, "compound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_anchors_stay_within_the_tolerance() {
|
||||
let mut rnd = random(23);
|
||||
assert_simplifies_within(&PathData::single(random_walk(&mut rnd, 600, true, false)), 1.0, "repeated points");
|
||||
assert_simplifies_within(&PathData::single(random_walk(&mut rnd, 600, true, true)), 1.0, "repeated points, closed");
|
||||
let p = Point::new(5.0, 5.0);
|
||||
let same = SubPath::new(vec![Anchor::smooth(p, p + Vec2::new(1.0, 0.0)); 50], false);
|
||||
assert_simplifies_within(&PathData::single(same), 0.5, "one place");
|
||||
let mut doubled = serpentine(1, 1500, false);
|
||||
let copy = doubled.anchors.clone();
|
||||
doubled.anchors = copy.iter().flat_map(|a| [*a, *a]).collect();
|
||||
assert_simplifies_within(&PathData::single(doubled), 1.0, "every anchor twice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_paths_stay_within_the_tolerance() {
|
||||
let mut rnd = random(0x9e37_79b9_7f4a_7c15);
|
||||
for case in 0..60 {
|
||||
let tol = [0.05, 0.25, 1.0, 4.0][case % 4];
|
||||
let n = 2 + (rnd() * 300.0) as usize;
|
||||
let closed = case % 3 == 0;
|
||||
let sp = if case % 2 == 0 { random_walk(&mut rnd, n, false, closed) } else { random_curves(&mut rnd, n, closed) };
|
||||
assert_simplifies_within(&PathData::single(sp), tol, &format!("case {case}"));
|
||||
}
|
||||
for (case, tol) in [0.5, 1.0, 2.0].into_iter().enumerate() {
|
||||
assert_simplifies_within(&PathData::single(random_ripple(&mut rnd, 2000, case == 1)), tol, &format!("ripple at {tol} pt"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_stays_within_the_tolerance_between_wide_points() {
|
||||
let up = Vec2::new(0.6, 0.8);
|
||||
let down = Vec2::new(0.6, -0.8);
|
||||
let runs: [&[Point]; 3] = [
|
||||
// Two points: the tangents alone shape the curve.
|
||||
&[Point::new(0.0, 0.0), Point::new(600.0, 0.0)],
|
||||
// Three points: a curve through the middle one can still bulge on each side of it.
|
||||
&[Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(600.0, 0.0)],
|
||||
&[Point::new(0.0, 0.0), Point::new(100.0, 20.0), Point::new(200.0, 0.0), Point::new(300.0, 20.0), Point::new(400.0, 0.0)],
|
||||
];
|
||||
for pts in runs {
|
||||
let out = fit_cubics(pts, up, down, 1.0);
|
||||
let far = samples(&out)
|
||||
.iter()
|
||||
.map(|&q| pts.windows(2).map(|w| Line::new(w[0], w[1]).nearest(q, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min))
|
||||
.fold(0.0, f64::max);
|
||||
assert!(far <= 1.0 + 1e-6, "a point of the curve is {far} from the {} points", pts.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_stays_within_the_tolerance_between_its_checks() {
|
||||
// A curve with handles this long has its peak between evenly spaced samples.
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(600.0, 0.0)];
|
||||
let tol = 105.47;
|
||||
let mut dense = Vec::new();
|
||||
for c in fit_cubics(&pts, Vec2::new(0.6, 0.8), Vec2::new(0.8, -0.6), tol) {
|
||||
sample(&c, 4096, &mut dense, true);
|
||||
}
|
||||
let far = dense.iter().map(|q| q.y.abs()).fold(0.0, f64::max);
|
||||
assert!(far <= tol, "a point of the curve is {far} from the line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_point_is_within_the_tolerance_of_its_chain() {
|
||||
let mut rnd = random(0x2545_f491_4f6c_dd1d);
|
||||
for case in 0..400 {
|
||||
let tol = [0.01, 0.1, 1.0, 5.0][case % 4];
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
match case % 5 {
|
||||
// Open random walk.
|
||||
0 => {
|
||||
let mut p = Point::new(rnd() * 100.0, rnd() * 100.0);
|
||||
for _ in 0..2 + (rnd() * 400.0) as usize {
|
||||
pts.push(p);
|
||||
p += Vec2::new(rnd() * 4.0 - 1.0, rnd() * 4.0 - 2.0);
|
||||
}
|
||||
}
|
||||
// Closed noisy loop: the last point is the first.
|
||||
1 => {
|
||||
let n = 3 + (rnd() * 300.0) as usize;
|
||||
for i in 0..n {
|
||||
let a = i as f64 / n as f64 * std::f64::consts::TAU;
|
||||
let r = 50.0 + rnd() * 3.0;
|
||||
pts.push(Point::new(r * a.cos(), r * a.sin()));
|
||||
}
|
||||
pts.push(pts[0]);
|
||||
}
|
||||
// Very short runs.
|
||||
2 => {
|
||||
for _ in 0..2 + (rnd() * 3.0) as usize {
|
||||
pts.push(Point::new(rnd() * 20.0, rnd() * 20.0));
|
||||
}
|
||||
}
|
||||
// Coincident points.
|
||||
3 => {
|
||||
let mut p = Point::new(0.0, 0.0);
|
||||
for _ in 0..2 + (rnd() * 200.0) as usize {
|
||||
pts.push(p);
|
||||
if rnd() < 0.3 {
|
||||
pts.push(p);
|
||||
}
|
||||
p += Vec2::new(rnd() * 3.0, rnd() * 6.0 - 3.0);
|
||||
}
|
||||
}
|
||||
// Samples of a chain of wavy cubics, as Simplify takes them.
|
||||
_ => {
|
||||
let mut p0 = Point::new(0.0, 0.0);
|
||||
for k in 0..1 + (rnd() * 60.0) as usize {
|
||||
let p3 = p0 + Vec2::new(5.0 + rnd() * 10.0, rnd() * 10.0 - 5.0);
|
||||
let c = CubicBez::new(p0, p0 + Vec2::new(2.0, rnd() * 8.0 - 4.0), p3 - Vec2::new(2.0, rnd() * 8.0 - 4.0), p3);
|
||||
sample(&c, 4 + (rnd() * 20.0) as usize, &mut pts, k == 0);
|
||||
p0 = p3;
|
||||
}
|
||||
}
|
||||
}
|
||||
let t0 = unit(Vec2::new(rnd() - 0.5, rnd() - 0.5)).unwrap_or(Vec2::new(1.0, 0.0));
|
||||
let t1 = unit(Vec2::new(rnd() - 0.5, rnd() - 0.5)).unwrap_or(Vec2::new(1.0, 0.0));
|
||||
let out = fit_cubics(&pts, t0, t1, tol);
|
||||
assert_eq!(out.first().map(|c| c.p0), pts.first().copied(), "case {case}");
|
||||
assert_eq!(out.last().map(|c| c.p3), pts.last().copied(), "case {case}");
|
||||
for w in out.windows(2) {
|
||||
assert_eq!(w[0].p3, w[1].p0, "case {case}");
|
||||
}
|
||||
let far = farthest(&pts, &out, tol);
|
||||
assert!(far <= tol + 1e-9, "case {case}: a point is {far} from the fit at a tolerance of {tol}");
|
||||
}
|
||||
// Samples of a long serpentine, which split lopsidedly at their worst points.
|
||||
let pts = samples(&cubics(&PathData::single(serpentine(1, 1000, false))));
|
||||
let out = fit_cubics(&pts, Vec2::new(1.0, 0.0), Vec2::new(1.0, 0.0), 1.0);
|
||||
let far = farthest(&pts, &out, 1.0);
|
||||
assert!(far <= 1.0 + 1e-9, "a point of the serpentine is {far} from the fit");
|
||||
}
|
||||
|
||||
/// `n` pieces of one segment each along a straight line, and the line's points.
|
||||
fn straight_pieces(n: usize) -> (Vec<Point>, Vec<Piece>) {
|
||||
let pts: Vec<Point> = (0..=n).map(|i| Point::new(i as f64, 0.0)).collect();
|
||||
let t1 = Vec2::new(1.0, 0.0);
|
||||
let pieces = (1..=n).map(|end| Piece { curve: CubicBez::new(pts[end - 1], pts[end - 1], pts[end], pts[end]), end, t1 }).collect();
|
||||
(pts, pieces)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joined_pieces_make_one_curve_up_to_the_limit() {
|
||||
let t0 = Vec2::new(1.0, 0.0);
|
||||
let (pts, pieces) = straight_pieces(MAX_JOINS + 1);
|
||||
let out = join_pieces(&pts, &pieces, t0, 1.0);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!((out[0].p0, out[0].p3), (pts[0], pts[MAX_JOINS + 1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_joins_no_more_pieces_than_the_limit() {
|
||||
let (pts, pieces) = straight_pieces(1000);
|
||||
let out = join_pieces(&pts, &pieces, Vec2::new(1.0, 0.0), 1.0);
|
||||
assert_eq!(out.len(), 1000_usize.div_ceil(MAX_JOINS + 1));
|
||||
assert_eq!((out[0].p0, out[out.len() - 1].p3), (pts[0], pts[1000]));
|
||||
for w in out.windows(2) {
|
||||
assert_eq!(w[0].p3, w[1].p0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_detection() {
|
||||
let c = CubicBez::new((0.0, 0.0), (3.0, 0.0), (6.0, 0.0), (9.0, 0.0));
|
||||
|
||||
@@ -255,6 +255,7 @@ impl VectorcraftApp {
|
||||
let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten();
|
||||
if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.tick_frame(ctx))) {
|
||||
self.picks.recover();
|
||||
self.session.set_replaying(false);
|
||||
self.status(format!("Internal error: {msg} (please report this bug)"));
|
||||
}
|
||||
}
|
||||
@@ -494,6 +495,7 @@ impl VectorcraftApp {
|
||||
})
|
||||
}) {
|
||||
self.picks.recover();
|
||||
self.session.set_replaying(false);
|
||||
self.status(format!("Internal error: {msg} (please report this bug)"));
|
||||
}
|
||||
crate::ime::finish_ui(self, &ctx);
|
||||
|
||||
@@ -84,13 +84,24 @@ impl VectorcraftApp {
|
||||
result
|
||||
}
|
||||
|
||||
/// [`Self::run`] for saved steps (an action's): keys their commands no longer read are taken,
|
||||
/// as [`vectorcraft_engine::Session::replay`] takes them, so an action recorded by an older
|
||||
/// version still plays.
|
||||
pub fn replay(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
let outer = self.session.set_replaying(true);
|
||||
let result = self.run(id, params);
|
||||
self.session.set_replaying(outer);
|
||||
result
|
||||
}
|
||||
|
||||
/// [`Self::run`] it, inside what asks for file dialogs.
|
||||
fn run_now(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size);
|
||||
self.run_count = self.run_count.wrapping_add(1);
|
||||
// In debug builds the app's own calls are held to the params descriptions too (#1442): a
|
||||
// panel passing a key its command doesn't read shows up in the tests.
|
||||
if cfg!(debug_assertions) {
|
||||
// panel passing a key its command doesn't read shows up in the tests. Saved steps aren't
|
||||
// checked ([`Self::replay`]).
|
||||
if cfg!(debug_assertions) && !self.session.replaying() {
|
||||
menus::check_params(&self.session, id, ¶ms)?;
|
||||
}
|
||||
// A `.ai` file a command writes (a save, an export, a timestamped copy, a batch's save) names
|
||||
|
||||
@@ -49,6 +49,7 @@ impl VectorcraftApp {
|
||||
Ok(outcome) => outcome,
|
||||
Err(msg) => {
|
||||
self.picks.recover();
|
||||
self.session.set_replaying(false);
|
||||
control::err(format!("internal error: {msg} (please report this bug)"))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod action_batch;
|
||||
pub mod action_new;
|
||||
pub mod action_playback;
|
||||
pub mod action_set_options;
|
||||
pub mod action_stop;
|
||||
pub mod artboard_options;
|
||||
pub mod blend_options;
|
||||
mod brush_art_options;
|
||||
@@ -372,6 +373,8 @@ registry! {
|
||||
ActionSetOptions: [action_set_options::KIND] => action_set_options::SPEC,
|
||||
ActionNew: [action_new::KIND] => action_new::SPEC,
|
||||
ActionPlayback: [action_playback::KIND] => action_playback::SPEC,
|
||||
ActionStopRecord: [action_stop::RECORD_KIND] => action_stop::RECORD_SPEC,
|
||||
ActionStopMessage: [action_stop::MESSAGE_KIND] => action_stop::MESSAGE_SPEC,
|
||||
SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC,
|
||||
DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC,
|
||||
EpsOptions: [eps_options::KIND] => eps_options::SPEC,
|
||||
|
||||
@@ -23,6 +23,9 @@ pub fn open(app: &mut VectorcraftApp, set: usize, action: usize) -> Result<Value
|
||||
if a.steps.iter().enumerate().all(|(i, _)| a.excluded_steps.contains(&i)) {
|
||||
return Err("the action has no included steps".into());
|
||||
}
|
||||
if crate::panels::actions::has_stop(&a.included_commands()) {
|
||||
return Err("Batch cannot play an action with a stop".into());
|
||||
}
|
||||
let name = a.name.clone();
|
||||
app.ui.dialog = Some(Dialog::new(KIND, initial_fields(set, action, &name)));
|
||||
Ok(Value::Null)
|
||||
@@ -100,6 +103,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
if commands.is_empty() {
|
||||
return Err("the action has no included steps".into());
|
||||
}
|
||||
if crate::panels::actions::has_stop(&commands) {
|
||||
return Err("Batch cannot play an action with a stop".into());
|
||||
}
|
||||
let source = d.str("source");
|
||||
if source == "folder" && d.str("folder").trim().is_empty() {
|
||||
return Err("choose a source folder".into());
|
||||
@@ -112,7 +118,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
return Err("open a document with data sets".into());
|
||||
}
|
||||
let params = params(d, &a.name, commands);
|
||||
let r = app.run("action.batch", params)?;
|
||||
let r = app.replay("action.batch", params)?;
|
||||
app.ui.dialog = None;
|
||||
let count = r["count"].as_u64().unwrap_or(0);
|
||||
let failed = r["failed"].as_u64().unwrap_or(0);
|
||||
@@ -183,6 +189,25 @@ mod tests {
|
||||
assert!(!shown("folder").iter().any(|label| label == "File Name:"));
|
||||
}
|
||||
|
||||
/// Batch plays an action recorded with a key its command no longer reads, as Play does.
|
||||
#[test]
|
||||
fn an_action_with_a_key_no_longer_read_still_plays_in_a_batch() {
|
||||
use crate::panels::actions::{Action, ActionSet};
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.run("dataset.new", json!({"name": "a"})).unwrap();
|
||||
let old = json!({"x": 10, "y": 10, "width": 20, "height": 20, "cornerStyle": "round"});
|
||||
assert!(app.run("shape.rectangle", old.clone()).is_err());
|
||||
app.ui.action_sets = vec![ActionSet::new("Test", vec![Action::new("Draw", vec![("shape.rectangle".into(), old)])])];
|
||||
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Draw"));
|
||||
d.fields["source"] = json!("datasets");
|
||||
d.fields["destination"] = json!("none");
|
||||
let r = confirm(&mut app, &d).unwrap();
|
||||
assert_eq!((r["count"].clone(), r["failed"].clone()), (json!(1), json!(0)), "{r}");
|
||||
assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 1);
|
||||
assert!(!app.session.replaying());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_set_naming_params_follow_dialog_choice() {
|
||||
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test"));
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Actions panel menu › Insert Stop… (`ui.action.insertStop`) and the message box a playing action
|
||||
//! shows at a stop.
|
||||
//!
|
||||
//! Record Stop's fields: `message`, `allowContinue`, and `set`, `action` and `step`, the place of the
|
||||
//! new stop (no `set` and `action`: the recording in progress). An invalid place is an error, and
|
||||
//! the dialog stays open.
|
||||
//!
|
||||
//! The message box's fields: `action` (the action's name, shown as the heading), `message` and
|
||||
//! `allowContinue`. Stop or Cancel closes it, and the playback keeps waiting for Play Current
|
||||
//! Selection or Stop Playing/Recording. With `allowContinue`, Continue or `ui.dialog.confirm` plays
|
||||
//! on; without it, `ui.dialog.confirm` presses Stop.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{DialogAction, DialogSpec};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Record Stop.
|
||||
pub const RECORD_KIND: &str = "actionStopRecord";
|
||||
|
||||
/// The dialog kind of a stop's message box.
|
||||
pub const MESSAGE_KIND: &str = "actionStopMessage";
|
||||
|
||||
/// The width of Record Stop's label column.
|
||||
const LABEL_W: f32 = 70.0;
|
||||
|
||||
pub(super) const RECORD_SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("Record Stop").into(), body: record_body, confirm: record_confirm, min_width: 340.0, ..DialogSpec::FORM };
|
||||
|
||||
pub(super) const MESSAGE_SPEC: DialogSpec = DialogSpec {
|
||||
heading: |d| d.str("action"),
|
||||
body_with_actions: Some(message_body),
|
||||
confirm: message_confirm,
|
||||
ok: None,
|
||||
min_width: 300.0,
|
||||
max_width: Some(460.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// Opens Record Stop for action `target` as (set, action), after `step`, else for the recording in
|
||||
/// progress.
|
||||
pub fn open_record(app: &mut VectorcraftApp, target: Option<(usize, usize)>, step: Option<usize>) {
|
||||
let mut fields = json!({"message": "", "allowContinue": false});
|
||||
if let Some((set, action)) = target {
|
||||
fields["set"] = json!(set);
|
||||
fields["action"] = json!(action);
|
||||
if let Some(step) = step {
|
||||
fields["step"] = json!(step);
|
||||
}
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(RECORD_KIND, fields));
|
||||
}
|
||||
|
||||
fn record_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
widgets::label_row(ui, "Message:", LABEL_W, |ui| {
|
||||
super::form::text(ui, d, "message", 240.0);
|
||||
});
|
||||
super::form::check(ui, d, "allowContinue", "Allow Continue");
|
||||
false
|
||||
}
|
||||
|
||||
fn record_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut params = json!({"message": d.str("message"), "allowContinue": d.bool("allowContinue")});
|
||||
for key in ["set", "action", "step"] {
|
||||
if let Some(value) = d.fields.get(key) {
|
||||
params[key] = value.clone();
|
||||
}
|
||||
}
|
||||
let r = app.run(crate::panels::actions::INSERT_STOP, params)?;
|
||||
app.ui.dialog = None;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Shows the message box of a stop in the action named `action`.
|
||||
pub(crate) fn open_message(app: &mut VectorcraftApp, action: &str, message: &str, allow_continue: bool) {
|
||||
app.ui.dialog = Some(Dialog::new(MESSAGE_KIND, json!({"action": action, "message": message, "allowContinue": allow_continue})));
|
||||
}
|
||||
|
||||
fn message_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> DialogAction {
|
||||
let message = d.str("message");
|
||||
if !message.is_empty() {
|
||||
ui.label(message);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("Press Play Current Selection in the Actions panel to go on.")).color(Tokens::get(ui.ctx()).text_dim));
|
||||
ui.add_space(16.0);
|
||||
let mut action = DialogAction::None;
|
||||
ui.horizontal(|ui| {
|
||||
if d.bool("allowContinue") && widgets::primary_button_enabled(ui, tl!("Continue"), true).clicked() {
|
||||
action = DialogAction::Confirm;
|
||||
}
|
||||
if widgets::secondary_button(ui, tl!("Stop")).clicked() {
|
||||
action = DialogAction::Cancel;
|
||||
}
|
||||
});
|
||||
action
|
||||
}
|
||||
|
||||
fn message_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
if d.bool("allowContinue") {
|
||||
return app.run(crate::panels::actions::CONTINUE_PLAYBACK, json!({}));
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -253,35 +253,27 @@ fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
fn hex_field(ui: &mut egui::Ui, hex: &str) -> Option<Color> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = ui.scope_id().with("swatch-hex");
|
||||
let editing = ui.memory(|m| m.has_focus(id));
|
||||
// As in `widgets::hex_field`: until the user types into it, the field shows `hex` as it is now
|
||||
// (#1692).
|
||||
let edited_id = id.with("edited");
|
||||
let edited = editing && ui.data_mut(|m| m.get_temp::<bool>(edited_id)).unwrap_or(false);
|
||||
let mut buf: String = if edited { ui.data_mut(|m| m.get_temp::<String>(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() };
|
||||
let resp = egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
.stroke(egui::Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||||
.corner_radius(2)
|
||||
.inner_margin(egui::Margin::symmetric(6, 4))
|
||||
.show(ui, |ui| {
|
||||
crate::theme::strong_selected_text(ui);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut buf)
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.align(egui::Align2::LEFT_CENTER)
|
||||
.desired_width(70.0)
|
||||
.char_limit(7),
|
||||
)
|
||||
})
|
||||
.inner;
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
ui.data_mut(|m| {
|
||||
m.insert_temp(id, buf.clone());
|
||||
m.insert_temp(edited_id, (edited || resp.changed()) && focused);
|
||||
});
|
||||
(resp.lost_focus() && buf != hex).then(|| parse_hex(&buf)).flatten()
|
||||
widgets::hex_text(ui, id, hex, |ui, buf, editing| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
.stroke(egui::Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||||
.corner_radius(2)
|
||||
.inner_margin(egui::Margin::symmetric(6, 4))
|
||||
.show(ui, |ui| {
|
||||
crate::theme::strong_selected_text(ui);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(buf)
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.align(egui::Align2::LEFT_CENTER)
|
||||
.desired_width(70.0)
|
||||
.char_limit(widgets::HEX_FIELD_CHARS),
|
||||
)
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.as_deref()
|
||||
.and_then(parse_hex)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -952,6 +952,115 @@ fn tab_through_the_hex_field_keeps_the_value_typed_before_it() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A hex code pasted into the # field of the Color Picker or Swatch Options after a click replaces
|
||||
/// its digits, with or without `#`. Enter, Tab and a press on another field each take it. A paste
|
||||
/// that is not a color changes nothing, and Enter then closes on the color from before it.
|
||||
#[test]
|
||||
fn a_hex_code_pasted_into_the_hex_field_replaces_its_digits() {
|
||||
let mut app = app();
|
||||
app.run("swatch.new", json!({"name": "Sky", "color": "#3366cc"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
// One frame of the dialog layer: every text drawn, with its center.
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut texts = vec![];
|
||||
out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts));
|
||||
texts
|
||||
};
|
||||
let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
// The context keeps a key down until it is let go, and a dialog ignores a repeat of Enter.
|
||||
let release = |key| egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() };
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
type Open = fn(&mut VectorcraftApp);
|
||||
type Applied = fn(&VectorcraftApp) -> String;
|
||||
let dialogs: [(&str, Open, Applied, &str, &str); 2] = [
|
||||
(
|
||||
"Color Picker",
|
||||
|app| drop(app.run("ui.colorPicker", json!({"stroke": false, "color": "#ffffff"}))),
|
||||
|app| crate::panels::current_paints(app).0.color().unwrap().to_hex(),
|
||||
"FFFFFF",
|
||||
"255",
|
||||
),
|
||||
(
|
||||
"Swatch Options",
|
||||
|app| drop(app.run("ui.swatchOptions", json!({"name": "Sky"}))),
|
||||
|app| app.session.active().unwrap().doc.swatch("Sky").unwrap().paint.color().unwrap().to_hex(),
|
||||
"3366CC",
|
||||
"204",
|
||||
),
|
||||
];
|
||||
for (name, open, applied, hex, other) in dialogs {
|
||||
// Opens the dialog and clicks into its # field. → The texts it shows.
|
||||
let click_in = |app: &mut VectorcraftApp| {
|
||||
open(app);
|
||||
for _ in 0..3 {
|
||||
frame(app, vec![]);
|
||||
}
|
||||
let texts = frame(app, vec![]);
|
||||
let at = texts.iter().find(|(t, _)| t == hex).unwrap_or_else(|| panic!("{name}: no # field shows {hex}")).1;
|
||||
frame(app, vec![egui::Event::PointerMoved(at), button(at, true)]);
|
||||
frame(app, vec![button(at, false)]);
|
||||
frame(app, vec![]);
|
||||
texts
|
||||
};
|
||||
let before = applied(&app);
|
||||
// The click selects the digits, so typing replaces them.
|
||||
click_in(&mut app);
|
||||
frame(&mut app, vec![egui::Event::Text("00FF00".into())]);
|
||||
assert!(frame(&mut app, vec![]).iter().any(|(t, _)| t == "00FF00"), "{name}: typing after a click replaces the digits");
|
||||
cancel(&mut app);
|
||||
frame(&mut app, vec![]);
|
||||
// A paste that is not a color leaves the field as it was.
|
||||
for paste in ["hello", "12345", "#12345", "12345678", "#00FF00 and more", ""] {
|
||||
click_in(&mut app);
|
||||
let shows = frame(&mut app, vec![egui::Event::Paste(paste.into())]);
|
||||
assert!(shows.iter().any(|(t, _)| t == hex), "{name}: the # field keeps {hex} after pasting {paste:?}");
|
||||
frame(&mut app, vec![key(egui::Key::Enter)]);
|
||||
frame(&mut app, vec![release(egui::Key::Enter)]);
|
||||
assert!(app.ui.dialog.is_none(), "{name}: Enter closes the dialog after pasting {paste:?}");
|
||||
assert_eq!(applied(&app), before, "{name}: pasting {paste:?} changes no color");
|
||||
}
|
||||
// Tab takes the pasted code.
|
||||
click_in(&mut app);
|
||||
frame(&mut app, vec![egui::Event::Paste("#00FF00".into())]);
|
||||
frame(&mut app, vec![key(egui::Key::Tab)]);
|
||||
frame(&mut app, vec![]);
|
||||
assert!(frame(&mut app, vec![]).iter().any(|(t, _)| t == "00FF00"), "{name}: Tab takes the pasted code");
|
||||
cancel(&mut app);
|
||||
frame(&mut app, vec![]);
|
||||
// So does a press on another field, which is drawn before the # field.
|
||||
let texts = click_in(&mut app);
|
||||
let away = texts.iter().find(|(t, _)| t == other).unwrap_or_else(|| panic!("{name}: no field shows {other}: {texts:?}")).1;
|
||||
frame(&mut app, vec![egui::Event::Paste("00FF00".into())]);
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(away), button(away, true)]);
|
||||
frame(&mut app, vec![button(away, false)]);
|
||||
frame(&mut app, vec![]);
|
||||
assert!(frame(&mut app, vec![]).iter().any(|(t, _)| t == "00FF00"), "{name}: a press elsewhere takes the pasted code");
|
||||
cancel(&mut app);
|
||||
frame(&mut app, vec![]);
|
||||
// Enter takes it and closes the dialog with the color.
|
||||
for paste in ["00FF00", "#00ff00", " #00FF00\n", "0f0"] {
|
||||
click_in(&mut app);
|
||||
frame(&mut app, vec![egui::Event::Paste(paste.into())]);
|
||||
frame(&mut app, vec![key(egui::Key::Enter)]);
|
||||
frame(&mut app, vec![release(egui::Key::Enter)]);
|
||||
assert!(app.ui.dialog.is_none(), "{name}: Enter closes the dialog after pasting {paste:?}");
|
||||
assert_eq!(applied(&app), "#00ff00", "{name}: Enter applies {paste:?}");
|
||||
app.run("swatch.edit", json!({"name": "Sky", "color": "#3366cc"})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1000: a count typed in Object › Create Object Mosaic… reaches the command (a whole number typed
|
||||
/// in a command's dialog is an integer), so the mosaic has that many columns, not the 10 × 10 grid.
|
||||
#[test]
|
||||
|
||||
@@ -453,9 +453,10 @@ fn simplify_slider_escape_puts_the_paths_back() {
|
||||
let undo_len = app.session.doc().unwrap().history.undo.len();
|
||||
crate::menus::invoke(&mut app, "object.path.simplify", json!({}));
|
||||
settle(&ctx, &mut app);
|
||||
// Level 0's tolerance of 32 pt takes out the path's 0.1 pt zigzag; level 100's 1/32 pt keeps it.
|
||||
let (_, _, rail, _) = slider_strip(&ctx);
|
||||
drag(&ctx, &mut app, rail, vec2(100.0, 0.0));
|
||||
assert_eq!(simplify_preview(&app), json!({"level": 100.0}));
|
||||
drag(&ctx, &mut app, rail + vec2(90.0, 0.0), vec2(-190.0, 0.0));
|
||||
assert_eq!(simplify_preview(&app), json!({"level": 0.0}));
|
||||
assert_ne!(app.session.doc().unwrap().doc, before, "the preview changes the path");
|
||||
let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), events: vec![escape], ..Default::default() };
|
||||
|
||||
@@ -71,6 +71,50 @@ fn define_grid_opens_on_the_grid_and_applies_its_fields() {
|
||||
assert_eq!(kind(&app), Some(super::perspective_grid::KIND));
|
||||
}
|
||||
|
||||
/// A three-digit code pasted or typed into a Define Grid color field sets that grid color.
|
||||
#[test]
|
||||
fn define_grid_takes_a_three_digit_color() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
// One frame of the dialog layer: every text drawn, with its center.
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut texts = vec![];
|
||||
out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts));
|
||||
texts
|
||||
};
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
// The context keeps a key down until it is let go, and a dialog ignores a repeat of Enter.
|
||||
let enter = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||||
for (how, input) in [("pasted", egui::Event::Paste("0f0".into())), ("typed", egui::Event::Text("0f0".into()))] {
|
||||
app.run("ui.perspectiveGridDialog", json!({})).unwrap();
|
||||
set(&mut app, "leftColor", json!("#123456"));
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, vec![]);
|
||||
}
|
||||
let texts = frame(&mut app, vec![]);
|
||||
let at = texts.iter().find(|(t, _)| t == "123456").unwrap_or_else(|| panic!("no Left Grid field in {texts:?}")).1;
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true)]);
|
||||
frame(&mut app, vec![button(at, false)]);
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, vec![input]);
|
||||
frame(&mut app, vec![enter(true)]);
|
||||
frame(&mut app, vec![enter(false)]);
|
||||
assert_eq!(kind(&app), None, "{how}: Enter closes the dialog");
|
||||
assert_eq!(grid(&app).left_color.hex(), "#00ff00", "{how}: the grid takes the color");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn define_grid_is_a_view_menu_item() {
|
||||
let app = app();
|
||||
|
||||
@@ -5680,6 +5680,19 @@ axis Y Axis Y ос
|
||||
Action Options… Опции на действието…
|
||||
Action Options Опции на действието
|
||||
Playback Options… Опции за възпроизвеждане…
|
||||
Insert Stop… Вмъкване на спиране…
|
||||
Insert Stop Вмъкване на спиране
|
||||
Record Stop Записване на спиране
|
||||
Allow Continue Разрешаване на продължаване
|
||||
Continue Продължаване
|
||||
Continue Action Продължаване на действието
|
||||
Delete Action Step Изтриване на стъпка от действието
|
||||
Press Play Current Selection in the Actions panel to go on. За да продължите, натиснете „Изпълнение на текущия избор“ в панела „Действия“.
|
||||
Stop: {message} Спиране: {message}
|
||||
@msg no action is waiting at a stop няма действие, което чака при спиране
|
||||
@msg message must be text message трябва да бъде текст
|
||||
@msg allowContinue must be true or false allowContinue трябва да бъде true или false
|
||||
@msg Batch cannot play an action with a stop Пакет не може да изпълни действие със спиране
|
||||
Playback Options Опции за възпроизвеждане
|
||||
Accelerated Ускорено
|
||||
Step by Step Стъпка по стъпка
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5703,6 +5703,19 @@ axis Y Axis Y osa
|
||||
Action Options… Opcije akcije…
|
||||
Action Options Opcije akcije
|
||||
Playback Options… Opcije reprodukcije…
|
||||
Insert Stop… Umetni zaustavljanje…
|
||||
Insert Stop Umetni zaustavljanje
|
||||
Record Stop Snimi zaustavljanje
|
||||
Allow Continue Dozvoli nastavak
|
||||
Continue Nastavi
|
||||
Continue Action Nastavi akciju
|
||||
Delete Action Step Izbriši korak akcije
|
||||
Press Play Current Selection in the Actions panel to go on. Za nastavak pritisnite „Reprodukuj trenutni odabir“ na panelu Akcije.
|
||||
Stop: {message} Zaustavljanje: {message}
|
||||
@msg no action is waiting at a stop nijedna akcija ne čeka na zaustavljanju
|
||||
@msg message must be text message mora biti tekst
|
||||
@msg allowContinue must be true or false allowContinue mora biti true ili false
|
||||
@msg Batch cannot play an action with a stop Paketna obrada ne može reprodukovati akciju sa zaustavljanjem
|
||||
Playback Options Opcije reprodukcije
|
||||
Accelerated Ubrzano
|
||||
Step by Step Korak po korak
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3803,6 +3803,19 @@ digits Malayalam Malaiàlam
|
||||
Action Options… Opcions de l’acció…
|
||||
Action Options Opcions de l’acció
|
||||
Playback Options… Opcions de reproducció…
|
||||
Insert Stop… Insereix una aturada…
|
||||
Insert Stop Insereix una aturada
|
||||
Record Stop Enregistra una aturada
|
||||
Allow Continue Permet continuar
|
||||
Continue Continua
|
||||
Continue Action Continua l’acció
|
||||
Delete Action Step Suprimeix el pas de l’acció
|
||||
Press Play Current Selection in the Actions panel to go on. Per continuar, premeu «Reprodueix la selecció actual» al tauler Accions.
|
||||
Stop: {message} Aturada: {message}
|
||||
@msg no action is waiting at a stop cap acció no espera en una aturada
|
||||
@msg message must be text message ha de ser text
|
||||
@msg allowContinue must be true or false allowContinue ha de ser true o false
|
||||
@msg Batch cannot play an action with a stop Lot no pot reproduir una acció amb una aturada
|
||||
Playback Options Opcions de reproducció
|
||||
Accelerated Accelerada
|
||||
Step by Step Pas a pas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5796,6 +5796,19 @@ axis Y Axis osa Y
|
||||
Action Options… Možnosti akce…
|
||||
Action Options Možnosti akce
|
||||
Playback Options… Možnosti přehrávání…
|
||||
Insert Stop… Vložit zastavení…
|
||||
Insert Stop Vložit zastavení
|
||||
Record Stop Zaznamenat zastavení
|
||||
Allow Continue Povolit pokračování
|
||||
Continue Pokračovat
|
||||
Continue Action Pokračovat v akci
|
||||
Delete Action Step Odstranit krok akce
|
||||
Press Play Current Selection in the Actions panel to go on. Chcete-li pokračovat, stiskněte na panelu Akce tlačítko Přehrát aktuální výběr.
|
||||
Stop: {message} Zastavení: {message}
|
||||
@msg no action is waiting at a stop žádná akce nečeká na zastavení
|
||||
@msg message must be text message musí být text
|
||||
@msg allowContinue must be true or false allowContinue musí být true nebo false
|
||||
@msg Batch cannot play an action with a stop Dávka nemůže přehrát akci se zastavením
|
||||
Playback Options Možnosti přehrávání
|
||||
Accelerated Zrychleně
|
||||
Step by Step Krok za krokem
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5695,6 +5695,19 @@ axis Y Axis Y-akse
|
||||
Action Options… Indstillinger for handling…
|
||||
Action Options Indstillinger for handling
|
||||
Playback Options… Afspilningsindstillinger…
|
||||
Insert Stop… Indsæt stop…
|
||||
Insert Stop Indsæt stop
|
||||
Record Stop Optag stop
|
||||
Allow Continue Tillad fortsæt
|
||||
Continue Fortsæt
|
||||
Continue Action Fortsæt handling
|
||||
Delete Action Step Slet handlingstrin
|
||||
Press Play Current Selection in the Actions panel to go on. Tryk på Afspil aktuel markering i panelet Handlinger for at fortsætte.
|
||||
Stop: {message} Stop: {message}
|
||||
@msg no action is waiting at a stop ingen handling venter ved et stop
|
||||
@msg message must be text message skal være tekst
|
||||
@msg allowContinue must be true or false allowContinue skal være true eller false
|
||||
@msg Batch cannot play an action with a stop Batch kan ikke afspille en handling med et stop
|
||||
Playback Options Afspilningsindstillinger
|
||||
Accelerated Accelereret
|
||||
Step by Step Trin for trin
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5378,6 +5378,19 @@ axis Y Axis Y-Achse
|
||||
Action Options… Aktionsoptionen…
|
||||
Action Options Aktionsoptionen
|
||||
Playback Options… Wiedergabeoptionen…
|
||||
Insert Stop… Stopp einfügen…
|
||||
Insert Stop Stopp einfügen
|
||||
Record Stop Stopp aufzeichnen
|
||||
Allow Continue Fortsetzen erlauben
|
||||
Continue Fortsetzen
|
||||
Continue Action Aktion fortsetzen
|
||||
Delete Action Step Aktionsschritt löschen
|
||||
Press Play Current Selection in the Actions panel to go on. Klicken Sie im Bedienfeld „Aktionen“ auf „Aktuelle Auswahl abspielen“, um fortzufahren.
|
||||
Stop: {message} Stopp: {message}
|
||||
@msg no action is waiting at a stop keine Aktion wartet an einem Stopp
|
||||
@msg message must be text message muss Text sein
|
||||
@msg allowContinue must be true or false allowContinue muss true oder false sein
|
||||
@msg Batch cannot play an action with a stop Die Stapelverarbeitung kann keine Aktion mit einem Stopp abspielen
|
||||
Playback Options Wiedergabeoptionen
|
||||
Accelerated Beschleunigt
|
||||
Step by Step Schrittweise
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5747,6 +5747,19 @@ axis Y Axis Άξονας Y
|
||||
Action Options… Επιλογές ενέργειας…
|
||||
Action Options Επιλογές ενέργειας
|
||||
Playback Options… Επιλογές αναπαραγωγής…
|
||||
Insert Stop… Εισαγωγή διακοπής…
|
||||
Insert Stop Εισαγωγή διακοπής
|
||||
Record Stop Καταγραφή διακοπής
|
||||
Allow Continue Να επιτρέπεται η συνέχεια
|
||||
Continue Συνέχεια
|
||||
Continue Action Συνέχεια ενέργειας
|
||||
Delete Action Step Διαγραφή βήματος ενέργειας
|
||||
Press Play Current Selection in the Actions panel to go on. Για να συνεχίσετε, πατήστε «Αναπαραγωγή τρέχουσας επιλογής» στο πάνελ Ενέργειες.
|
||||
Stop: {message} Διακοπή: {message}
|
||||
@msg no action is waiting at a stop καμία ενέργεια δεν περιμένει σε διακοπή
|
||||
@msg message must be text Το message πρέπει να είναι κείμενο
|
||||
@msg allowContinue must be true or false Το allowContinue πρέπει να είναι true ή false
|
||||
@msg Batch cannot play an action with a stop Η μαζική επεξεργασία δεν μπορεί να αναπαράγει ενέργεια με διακοπή
|
||||
Playback Options Επιλογές αναπαραγωγής
|
||||
Accelerated Επιταχυμένα
|
||||
Step by Step Βήμα προς βήμα
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 163 and column 5.
|
@@ -5737,6 +5737,19 @@ axis Y Axis Eje Y
|
||||
Action Options… Opciones de acción…
|
||||
Action Options Opciones de acción
|
||||
Playback Options… Opciones de reproducción…
|
||||
Insert Stop… Insertar parada…
|
||||
Insert Stop Insertar parada
|
||||
Record Stop Grabar parada
|
||||
Allow Continue Permitir continuar
|
||||
Continue Continuar
|
||||
Continue Action Continuar acción
|
||||
Delete Action Step Eliminar paso de la acción
|
||||
Press Play Current Selection in the Actions panel to go on. Para continuar, pulse Reproducir la selección actual en el panel Acciones.
|
||||
Stop: {message} Parada: {message}
|
||||
@msg no action is waiting at a stop ninguna acción está esperando en una parada
|
||||
@msg message must be text message debe ser texto
|
||||
@msg allowContinue must be true or false allowContinue debe ser true o false
|
||||
@msg Batch cannot play an action with a stop Lote no puede reproducir una acción con una parada
|
||||
Playback Options Opciones de reproducción
|
||||
Accelerated Acelerada
|
||||
Step by Step Paso a paso
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5826,6 +5826,19 @@ axis Y Axis Y-akseli
|
||||
Action Options… Toiminnon asetukset…
|
||||
Action Options Toiminnon asetukset
|
||||
Playback Options… Toistoasetukset…
|
||||
Insert Stop… Lisää pysäytys…
|
||||
Insert Stop Lisää pysäytys
|
||||
Record Stop Nauhoita pysäytys
|
||||
Allow Continue Salli jatkaminen
|
||||
Continue Jatka
|
||||
Continue Action Jatka toimintoa
|
||||
Delete Action Step Poista toiminnon vaihe
|
||||
Press Play Current Selection in the Actions panel to go on. Jatka valitsemalla Toiminnot-paneelista Toista nykyinen valinta.
|
||||
Stop: {message} Pysäytys: {message}
|
||||
@msg no action is waiting at a stop mikään toiminto ei odota pysäytyksessä
|
||||
@msg message must be text message on oltava tekstiä
|
||||
@msg allowContinue must be true or false allowContinue on oltava true tai false
|
||||
@msg Batch cannot play an action with a stop Erä ei voi toistaa toimintoa, jossa on pysäytys
|
||||
Playback Options Toistoasetukset
|
||||
Accelerated Nopeutettu
|
||||
Step by Step Vaihe kerrallaan
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5791,6 +5791,19 @@ axis Y Axis Axe Y
|
||||
Action Options… Options de script…
|
||||
Action Options Options de script
|
||||
Playback Options… Options d’exécution…
|
||||
Insert Stop… Insérer un arrêt…
|
||||
Insert Stop Insérer un arrêt
|
||||
Record Stop Enregistrer un arrêt
|
||||
Allow Continue Autoriser la poursuite
|
||||
Continue Continuer
|
||||
Continue Action Continuer le script
|
||||
Delete Action Step Supprimer l’étape du script
|
||||
Press Play Current Selection in the Actions panel to go on. Pour continuer, cliquez sur Exécuter la sélection actuelle dans le panneau Scripts.
|
||||
Stop: {message} Arrêt : {message}
|
||||
@msg no action is waiting at a stop aucun script n’attend à un arrêt
|
||||
@msg message must be text message doit être du texte
|
||||
@msg allowContinue must be true or false allowContinue doit valoir true ou false
|
||||
@msg Batch cannot play an action with a stop Le traitement par lots ne peut pas exécuter un script comportant un arrêt
|
||||
Playback Options Options d’exécution
|
||||
Accelerated Accélérée
|
||||
Step by Step Pas à pas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3290,6 +3290,19 @@
|
||||
Play Action खेल कार्रवाई
|
||||
Play Current Selection वर्तमान सिलेक्शन
|
||||
Play: चलाएँ:
|
||||
Insert Stop… स्टॉप जोड़ें…
|
||||
Insert Stop स्टॉप जोड़ें
|
||||
Record Stop स्टॉप रिकॉर्ड करें
|
||||
Allow Continue जारी रखने दें
|
||||
Continue जारी रखें
|
||||
Continue Action एक्शन जारी रखें
|
||||
Delete Action Step एक्शन का चरण हटाएं
|
||||
Press Play Current Selection in the Actions panel to go on. आगे बढ़ने के लिए एक्शन्स पैनल में वर्तमान सिलेक्शन प्ले करें दबाएँ।
|
||||
Stop: {message} स्टॉप: {message}
|
||||
@msg no action is waiting at a stop कोई एक्शन स्टॉप पर प्रतीक्षा नहीं कर रहा है
|
||||
@msg message must be text message टेक्स्ट होना चाहिए
|
||||
@msg allowContinue must be true or false allowContinue true या false होना चाहिए
|
||||
@msg Batch cannot play an action with a stop बैच स्टॉप वाला एक्शन नहीं चला सकता
|
||||
Playback Options प्लेबैक ऑप्शन्स
|
||||
Playback Options… प्लेबैक ऑप्शन्स…
|
||||
Plug-in Info प्लगइन जानकारी
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5779,6 +5779,19 @@ axis Y Axis Y tengely
|
||||
Action Options… Művelet beállításai…
|
||||
Action Options Művelet beállításai
|
||||
Playback Options… Lejátszási beállítások…
|
||||
Insert Stop… Megállás beszúrása…
|
||||
Insert Stop Megállás beszúrása
|
||||
Record Stop Megállás rögzítése
|
||||
Allow Continue Folytatás engedélyezése
|
||||
Continue Folytatás
|
||||
Continue Action Művelet folytatása
|
||||
Delete Action Step Műveletlépés törlése
|
||||
Press Play Current Selection in the Actions panel to go on. A folytatáshoz nyomja meg a Műveletek panelen a Kijelölt művelet lejátszása gombot.
|
||||
Stop: {message} Megállás: {message}
|
||||
@msg no action is waiting at a stop egyetlen művelet sem várakozik megállásnál
|
||||
@msg message must be text message: szöveg kell
|
||||
@msg allowContinue must be true or false allowContinue: true vagy false kell
|
||||
@msg Batch cannot play an action with a stop A kötegelt feldolgozás nem játszhat le megállást tartalmazó műveletet
|
||||
Playback Options Lejátszási beállítások
|
||||
Accelerated Gyorsított
|
||||
Step by Step Lépésenként
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5695,6 +5695,19 @@ axis Y Axis Sumbu Y
|
||||
Action Options… Opsi tindakan…
|
||||
Action Options Opsi tindakan
|
||||
Playback Options… Opsi pemutaran…
|
||||
Insert Stop… Sisipkan henti…
|
||||
Insert Stop Sisipkan henti
|
||||
Record Stop Rekam henti
|
||||
Allow Continue Izinkan lanjutkan
|
||||
Continue Lanjutkan
|
||||
Continue Action Lanjutkan tindakan
|
||||
Delete Action Step Hapus langkah tindakan
|
||||
Press Play Current Selection in the Actions panel to go on. Untuk melanjutkan, tekan Putar seleksi saat ini di panel Tindakan.
|
||||
Stop: {message} Henti: {message}
|
||||
@msg no action is waiting at a stop tidak ada tindakan yang menunggu di titik henti
|
||||
@msg message must be text message harus berupa teks
|
||||
@msg allowContinue must be true or false allowContinue harus true atau false
|
||||
@msg Batch cannot play an action with a stop Batch tidak dapat memutar tindakan dengan titik henti
|
||||
Playback Options Opsi pemutaran
|
||||
Accelerated Dipercepat
|
||||
Step by Step Langkah demi langkah
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2240 and column 5.
|
@@ -5412,6 +5412,19 @@ axis Y Axis Asse Y
|
||||
Action Options… Opzioni azione…
|
||||
Action Options Opzioni azione
|
||||
Playback Options… Opzioni di riproduzione…
|
||||
Insert Stop… Inserisci interruzione…
|
||||
Insert Stop Inserisci interruzione
|
||||
Record Stop Registra interruzione
|
||||
Allow Continue Consenti di continuare
|
||||
Continue Continua
|
||||
Continue Action Continua azione
|
||||
Delete Action Step Elimina passaggio dell’azione
|
||||
Press Play Current Selection in the Actions panel to go on. Per continuare, premi Esegui selezione corrente nel pannello Azioni.
|
||||
Stop: {message} Interruzione: {message}
|
||||
@msg no action is waiting at a stop nessuna azione è in attesa a un'interruzione
|
||||
@msg message must be text message deve essere testo
|
||||
@msg allowContinue must be true or false allowContinue deve essere true o false
|
||||
@msg Batch cannot play an action with a stop L'elaborazione batch non può eseguire un'azione con un'interruzione
|
||||
Playback Options Opzioni di riproduzione
|
||||
Accelerated Accelerata
|
||||
Step by Step Passo per passo
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5477,6 +5477,19 @@ axis Y Axis Y 軸
|
||||
Action Options… アクションオプション…
|
||||
Action Options アクションオプション
|
||||
Playback Options… 再生オプション…
|
||||
Insert Stop… 停止を挿入…
|
||||
Insert Stop 停止を挿入
|
||||
Record Stop 停止を記録
|
||||
Allow Continue 続行を許可
|
||||
Continue 続行
|
||||
Continue Action アクションを続行
|
||||
Delete Action Step アクションステップを削除
|
||||
Press Play Current Selection in the Actions panel to go on. 続行するには、アクションパネルの「選択項目を再生」を押します。
|
||||
Stop: {message} 停止: {message}
|
||||
@msg no action is waiting at a stop 停止で待機中のアクションはありません
|
||||
@msg message must be text message にはテキストを指定してください
|
||||
@msg allowContinue must be true or false allowContinue には true または false を指定してください
|
||||
@msg Batch cannot play an action with a stop バッチでは停止を含むアクションを再生できません
|
||||
Playback Options 再生オプション
|
||||
Accelerated 高速
|
||||
Step by Step ステップ実行
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5719,6 +5719,19 @@ axis Y Axis Y ღერძი
|
||||
Action Options… მოქმედების პარამეტრები…
|
||||
Action Options მოქმედების პარამეტრები
|
||||
Playback Options… შესრულების პარამეტრები…
|
||||
Insert Stop… გაჩერების ჩასმა…
|
||||
Insert Stop გაჩერების ჩასმა
|
||||
Record Stop გაჩერების ჩაწერა
|
||||
Allow Continue გაგრძელების დაშვება
|
||||
Continue გაგრძელება
|
||||
Continue Action მოქმედების გაგრძელება
|
||||
Delete Action Step მოქმედების ნაბიჯის წაშლა
|
||||
Press Play Current Selection in the Actions panel to go on. გასაგრძელებლად მოქმედებების პანელზე დააჭირეთ „მიმდინარე მონიშნულის შესრულებას“.
|
||||
Stop: {message} გაჩერება: {message}
|
||||
@msg no action is waiting at a stop გაჩერებაზე არცერთი მოქმედება არ ელოდება
|
||||
@msg message must be text message უნდა იყოს ტექსტი
|
||||
@msg allowContinue must be true or false allowContinue უნდა იყოს true ან false
|
||||
@msg Batch cannot play an action with a stop პაკეტურ დამუშავებას არ შეუძლია გაჩერების მქონე მოქმედების შესრულება
|
||||
Playback Options შესრულების პარამეტრები
|
||||
Accelerated დაჩქარებული
|
||||
Step by Step ნაბიჯ-ნაბიჯ
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5373,6 +5373,19 @@ axis Y Axis Y축
|
||||
Action Options… 액션 옵션…
|
||||
Action Options 액션 옵션
|
||||
Playback Options… 재생 옵션…
|
||||
Insert Stop… 중지 삽입…
|
||||
Insert Stop 중지 삽입
|
||||
Record Stop 중지 기록
|
||||
Allow Continue 계속 허용
|
||||
Continue 계속
|
||||
Continue Action 액션 계속
|
||||
Delete Action Step 액션 단계 삭제
|
||||
Press Play Current Selection in the Actions panel to go on. 계속하려면 액션 패널에서 현재 선택 항목 재생을 누르세요.
|
||||
Stop: {message} 중지: {message}
|
||||
@msg no action is waiting at a stop 중지에서 대기 중인 액션이 없습니다
|
||||
@msg message must be text message의 값은 텍스트여야 합니다
|
||||
@msg allowContinue must be true or false allowContinue의 값은 true 또는 false여야 합니다
|
||||
@msg Batch cannot play an action with a stop 일괄 처리에서는 중지가 있는 액션을 재생할 수 없습니다
|
||||
Playback Options 재생 옵션
|
||||
Accelerated 가속
|
||||
Step by Step 단계별
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5741,6 +5741,19 @@ axis Y Axis Y-as
|
||||
Action Options… Actieopties…
|
||||
Action Options Actieopties
|
||||
Playback Options… Afspeelopties…
|
||||
Insert Stop… Stop invoegen…
|
||||
Insert Stop Stop invoegen
|
||||
Record Stop Stop opnemen
|
||||
Allow Continue Doorgaan toestaan
|
||||
Continue Doorgaan
|
||||
Continue Action Actie voortzetten
|
||||
Delete Action Step Actiestap verwijderen
|
||||
Press Play Current Selection in the Actions panel to go on. Druk in het paneel Acties op Huidige selectie afspelen om door te gaan.
|
||||
Stop: {message} Stop: {message}
|
||||
@msg no action is waiting at a stop geen actie wacht bij een stop
|
||||
@msg message must be text message moet tekst zijn
|
||||
@msg allowContinue must be true or false allowContinue moet true of false zijn
|
||||
@msg Batch cannot play an action with a stop Batch kan geen actie met een stop afspelen
|
||||
Playback Options Afspeelopties
|
||||
Accelerated Versneld
|
||||
Step by Step Stap voor stap
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5649,6 +5649,19 @@ axis Y Axis Oś Y
|
||||
Action Options… Opcje operacji…
|
||||
Action Options Opcje operacji
|
||||
Playback Options… Opcje odtwarzania…
|
||||
Insert Stop… Wstaw zatrzymanie…
|
||||
Insert Stop Wstaw zatrzymanie
|
||||
Record Stop Nagraj zatrzymanie
|
||||
Allow Continue Zezwalaj na kontynuację
|
||||
Continue Kontynuuj
|
||||
Continue Action Kontynuuj operację
|
||||
Delete Action Step Usuń krok operacji
|
||||
Press Play Current Selection in the Actions panel to go on. Aby kontynuować, naciśnij Odtwórz bieżące zaznaczenie w panelu Operacje.
|
||||
Stop: {message} Zatrzymanie: {message}
|
||||
@msg no action is waiting at a stop żadna operacja nie czeka na zatrzymaniu
|
||||
@msg message must be text message musi być tekstem
|
||||
@msg allowContinue must be true or false allowContinue musi mieć wartość true lub false
|
||||
@msg Batch cannot play an action with a stop Przetwarzanie wsadowe nie może odtworzyć operacji z zatrzymaniem
|
||||
Playback Options Opcje odtwarzania
|
||||
Accelerated Przyspieszone
|
||||
Step by Step Krok po kroku
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5711,6 +5711,19 @@ axis Y Axis Eixo Y
|
||||
Action Options… Opções da Ação…
|
||||
Action Options Opções da Ação
|
||||
Playback Options… Opções de Reprodução…
|
||||
Insert Stop… Inserir Parada…
|
||||
Insert Stop Inserir Parada
|
||||
Record Stop Gravar Parada
|
||||
Allow Continue Permitir Continuar
|
||||
Continue Continuar
|
||||
Continue Action Continuar Ação
|
||||
Delete Action Step Excluir Etapa da Ação
|
||||
Press Play Current Selection in the Actions panel to go on. Para continuar, pressione Reproduzir Seleção Atual no painel Ações.
|
||||
Stop: {message} Parada: {message}
|
||||
@msg no action is waiting at a stop nenhuma ação está aguardando em uma parada
|
||||
@msg message must be text message deve ser texto
|
||||
@msg allowContinue must be true or false allowContinue deve ser true ou false
|
||||
@msg Batch cannot play an action with a stop Lote não pode reproduzir uma ação com uma parada
|
||||
Playback Options Opções de Reprodução
|
||||
Accelerated Acelerada
|
||||
Step by Step Passo a Passo
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -5407,6 +5407,19 @@ axis Y Axis Ось Y
|
||||
Action Options… Параметры действия…
|
||||
Action Options Параметры действия
|
||||
Playback Options… Параметры воспроизведения…
|
||||
Insert Stop… Вставить остановку…
|
||||
Insert Stop Вставить остановку
|
||||
Record Stop Записать остановку
|
||||
Allow Continue Разрешить продолжение
|
||||
Continue Продолжить
|
||||
Continue Action Продолжить действие
|
||||
Delete Action Step Удалить шаг действия
|
||||
Press Play Current Selection in the Actions panel to go on. Чтобы продолжить, нажмите «Воспроизвести текущее выделение» на панели «Действия».
|
||||
Stop: {message} Остановка: {message}
|
||||
@msg no action is waiting at a stop ни одно действие не ожидает на остановке
|
||||
@msg message must be text message должно быть текстом
|
||||
@msg allowContinue must be true or false allowContinue должно быть true или false
|
||||
@msg Batch cannot play an action with a stop Пакет не может воспроизвести действие с остановкой
|
||||
Playback Options Параметры воспроизведения
|
||||
Accelerated Ускоренно
|
||||
Step by Step Пошагово
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5819,6 +5819,19 @@ axis Y Axis Y-axel
|
||||
Action Options… Alternativ för åtgärd…
|
||||
Action Options Alternativ för åtgärd
|
||||
Playback Options… Uppspelningsalternativ…
|
||||
Insert Stop… Infoga stopp…
|
||||
Insert Stop Infoga stopp
|
||||
Record Stop Spela in stopp
|
||||
Allow Continue Tillåt fortsättning
|
||||
Continue Fortsätt
|
||||
Continue Action Fortsätt åtgärd
|
||||
Delete Action Step Radera åtgärdssteg
|
||||
Press Play Current Selection in the Actions panel to go on. Fortsätt genom att trycka på Spela upp aktuell markering i panelen Åtgärder.
|
||||
Stop: {message} Stopp: {message}
|
||||
@msg no action is waiting at a stop ingen åtgärd väntar vid ett stopp
|
||||
@msg message must be text message måste vara text
|
||||
@msg allowContinue must be true or false allowContinue måste vara true eller false
|
||||
@msg Batch cannot play an action with a stop Batch kan inte spela upp en åtgärd med ett stopp
|
||||
Playback Options Uppspelningsalternativ
|
||||
Accelerated Accelererad
|
||||
Step by Step Steg för steg
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -5753,6 +5753,19 @@ axis Y Axis Y ekseni
|
||||
Action Options… Eylem Seçenekleri…
|
||||
Action Options Eylem Seçenekleri
|
||||
Playback Options… Oynatma Seçenekleri…
|
||||
Insert Stop… Durak Ekle…
|
||||
Insert Stop Durak Ekle
|
||||
Record Stop Durak Kaydet
|
||||
Allow Continue Devam Etmeye İzin Ver
|
||||
Continue Devam Et
|
||||
Continue Action Eyleme Devam Et
|
||||
Delete Action Step Eylem Adımını Sil
|
||||
Press Play Current Selection in the Actions panel to go on. Devam etmek için Eylemler panelinde Geçerli Seçimi Oynat'a basın.
|
||||
Stop: {message} Durak: {message}
|
||||
@msg no action is waiting at a stop durakta bekleyen eylem yok
|
||||
@msg message must be text "message" metin olmalıdır
|
||||
@msg allowContinue must be true or false "allowContinue" true veya false olmalıdır
|
||||
@msg Batch cannot play an action with a stop Toplu işlem durak içeren bir eylemi oynatamaz
|
||||
Playback Options Oynatma Seçenekleri
|
||||
Accelerated Hızlandırılmış
|
||||
Step by Step Adım Adım
|
||||
@@ -5931,8 +5944,8 @@ anchor Points: Noktalar:
|
||||
Hue/Saturation Ton/Doygunluk
|
||||
Temperature/Tint Sıcaklık/Renk Tonu
|
||||
@msg `{id}` is not a colour adjustment `{id}` bir renk ayarı değil
|
||||
@msg missing `effect` "isim" eksik
|
||||
@msg `params` must be an object "gradyan" bir nesne olmalıdır
|
||||
@msg missing `effect` `effect` eksik
|
||||
@msg `params` must be an object `params` bir nesne olmalıdır
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Yol Hassasiyet Araçları
|
||||
Anchor Eraser Brush Bağlantı Noktası Silgi Fırçası
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -5392,6 +5392,19 @@ axis Y Axis Вісь Y
|
||||
Action Options… Параметри дії…
|
||||
Action Options Параметри дії
|
||||
Playback Options… Параметри відтворення…
|
||||
Insert Stop… Вставити зупинку…
|
||||
Insert Stop Вставити зупинку
|
||||
Record Stop Записати зупинку
|
||||
Allow Continue Дозволити продовження
|
||||
Continue Продовжити
|
||||
Continue Action Продовжити дію
|
||||
Delete Action Step Видалити крок дії
|
||||
Press Play Current Selection in the Actions panel to go on. Щоб продовжити, натисніть «Відтворити поточне виділення» на панелі «Дії».
|
||||
Stop: {message} Зупинка: {message}
|
||||
@msg no action is waiting at a stop жодна дія не очікує на зупинці
|
||||
@msg message must be text message має бути текстом
|
||||
@msg allowContinue must be true or false allowContinue має бути true або false
|
||||
@msg Batch cannot play an action with a stop Пакет не може відтворити дію із зупинкою
|
||||
Playback Options Параметри відтворення
|
||||
Accelerated Прискорено
|
||||
Step by Step Покроково
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 177 and column 5.
|
@@ -5393,6 +5393,19 @@ axis Y Axis Trục Y
|
||||
Action Options… Tùy chọn hành động…
|
||||
Action Options Tùy chọn hành động
|
||||
Playback Options… Tùy chọn phát lại…
|
||||
Insert Stop… Chèn điểm dừng…
|
||||
Insert Stop Chèn điểm dừng
|
||||
Record Stop Ghi điểm dừng
|
||||
Allow Continue Cho phép tiếp tục
|
||||
Continue Tiếp tục
|
||||
Continue Action Tiếp tục hành động
|
||||
Delete Action Step Xóa bước hành động
|
||||
Press Play Current Selection in the Actions panel to go on. Để tiếp tục, nhấn Phát vùng chọn hiện tại trong bảng Hành động.
|
||||
Stop: {message} Điểm dừng: {message}
|
||||
@msg no action is waiting at a stop không có hành động nào đang chờ ở điểm dừng
|
||||
@msg message must be text message phải là văn bản
|
||||
@msg allowContinue must be true or false allowContinue phải là true hoặc false
|
||||
@msg Batch cannot play an action with a stop Hàng loạt không thể phát hành động có điểm dừng
|
||||
Playback Options Tùy chọn phát lại
|
||||
Accelerated Tăng tốc
|
||||
Step by Step Từng bước
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 199 and column 5.
|
@@ -5634,6 +5634,19 @@ axis Y Axis Y 轴
|
||||
Action Options… 动作选项…
|
||||
Action Options 动作选项
|
||||
Playback Options… 播放选项…
|
||||
Insert Stop… 插入停止…
|
||||
Insert Stop 插入停止
|
||||
Record Stop 记录停止
|
||||
Allow Continue 允许继续
|
||||
Continue 继续
|
||||
Continue Action 继续动作
|
||||
Delete Action Step 删除动作步骤
|
||||
Press Play Current Selection in the Actions panel to go on. 要继续,请按“动作”面板中的“播放当前选择项目”。
|
||||
Stop: {message} 停止:{message}
|
||||
@msg no action is waiting at a stop 没有动作在停止处等待
|
||||
@msg message must be text message 必须是文本
|
||||
@msg allowContinue must be true or false allowContinue 必须是 true 或 false
|
||||
@msg Batch cannot play an action with a stop 批处理无法播放含有停止的动作
|
||||
Playback Options 播放选项
|
||||
Accelerated 加速
|
||||
Step by Step 逐步
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5430,6 +5430,19 @@ axis Y Axis Y 軸
|
||||
Action Options… 動作選項…
|
||||
Action Options 動作選項
|
||||
Playback Options… 播放選項…
|
||||
Insert Stop… 插入停止…
|
||||
Insert Stop 插入停止
|
||||
Record Stop 記錄停止
|
||||
Allow Continue 允許繼續
|
||||
Continue 繼續
|
||||
Continue Action 繼續動作
|
||||
Delete Action Step 刪除動作步驟
|
||||
Press Play Current Selection in the Actions panel to go on. 若要繼續,請按「動作」面板中的「播放目前選取項目」。
|
||||
Stop: {message} 停止:{message}
|
||||
@msg no action is waiting at a stop 沒有動作在停止處等待
|
||||
@msg message must be text message 必須是文字
|
||||
@msg allowContinue must be true or false allowContinue 必須是 true 或 false
|
||||
@msg Batch cannot play an action with a stop 批次無法播放含有停止的動作
|
||||
Playback Options 播放選項
|
||||
Accelerated 加速
|
||||
Step by Step 逐步
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -114,6 +114,7 @@ mod services;
|
||||
pub use app::MAX_RECENT_FONTS;
|
||||
pub use caches::{CacheKey, CanvasCache, LivePaintGapsCache, Perf, PrintTilingCache, SliceCache};
|
||||
pub use clock::{now_ms, now_secs};
|
||||
pub use craft_ui::docking::Monitor as DockMonitor;
|
||||
#[cfg(test)]
|
||||
pub(crate) use services::browsable_url;
|
||||
pub use services::{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user