mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
Merge main into fix/a-reverted-document-keeps-its-uid-so-a-trace
This commit is contained in:
@@ -358,6 +358,34 @@ mod tests {
|
||||
assert!((p.x - 25.0).abs() < 1e-6 && (p.y - 25.0).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coons_places_are_where_the_patch_bends() {
|
||||
// A rectangle's sides have 65 evenly spaced points.
|
||||
let [u, v] = Coons::rect(Rect::new(0.0, 0.0, 100.0, 50.0)).places();
|
||||
assert_eq!((u.len(), v.len()), (65, 65));
|
||||
assert!(u.iter().enumerate().all(|(i, x)| (x - i as f64 / 64.0).abs() < 1e-12), "{u:?}");
|
||||
// A top object with a curved top: between two places next to each other the patch is
|
||||
// straight, in `u` and in `v`.
|
||||
let mut path = kurbo::BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.curve_to((30.0, -20.0), (70.0, 10.0), (100.0, 0.0));
|
||||
path.line_to((100.0, 50.0));
|
||||
path.line_to((40.0, 50.0));
|
||||
path.line_to((0.0, 50.0));
|
||||
path.close_path();
|
||||
let c = Coons::from_top_object(&PathData::from_bezpath(&path), Some([0, 1, 2, 4])).unwrap();
|
||||
let [u, v] = c.places();
|
||||
assert!(u.first() == Some(&0.0) && u.last() == Some(&1.0) && u.windows(2).all(|w| w[0] < w[1]), "{u:?}");
|
||||
assert!(v.first() == Some(&0.0) && v.last() == Some(&1.0) && v.windows(2).all(|w| w[0] < w[1]), "{v:?}");
|
||||
for w in u.windows(2) {
|
||||
for y in [0.0, 0.3, 1.0] {
|
||||
let mid = c.eval((w[0] + w[1]) / 2.0, y);
|
||||
let half = c.eval(w[0], y).midpoint(c.eval(w[1], y));
|
||||
assert!(mid.distance(half) < 1e-9, "{w:?} {y}: {mid:?} {half:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_go_on_past_the_content_box() {
|
||||
// A stroke outline reaches past the box: a flat mesh and a rectangle stay the identity there.
|
||||
|
||||
@@ -338,6 +338,31 @@ impl Coons {
|
||||
let end = |side: &[Point]| side.last().copied().unwrap_or_default();
|
||||
[self.top[0], end(&self.top), end(&self.bottom), self.bottom[0]]
|
||||
}
|
||||
|
||||
/// The places in `u` where the top or bottom side has a point, and the places in `v` where the
|
||||
/// left or right side has one, rising from 0 to 1. Between two places next to each other, each
|
||||
/// side runs straight, so [`Self::eval`] changes linearly in `u` and in `v` inside each cell of
|
||||
/// the grid they make.
|
||||
pub fn places(&self) -> [Vec<f64>; 2] {
|
||||
let sides = [&self.top, &self.right, &self.bottom, &self.left];
|
||||
let at = |which: usize| -> Vec<f64> {
|
||||
match self.knots.as_deref().and_then(|k| k.get(which)) {
|
||||
Some(knots) => knots.clone(),
|
||||
None => {
|
||||
let last = sides[which].len().saturating_sub(1).max(1);
|
||||
(0..=last).map(|i| i as f64 / last as f64).collect()
|
||||
}
|
||||
}
|
||||
};
|
||||
let merge = |a: usize, b: usize| {
|
||||
let mut v = at(a);
|
||||
v.extend(at(b));
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
[merge(0, 2), merge(1, 3)]
|
||||
}
|
||||
}
|
||||
|
||||
/// The most points one side of [`Coons::from_top_object`] holds while it adds points between its
|
||||
|
||||
@@ -828,6 +828,7 @@ pub(crate) use edit::{settle_interaction, typing_in_progress};
|
||||
pub(crate) use object::bounds_rewrite;
|
||||
pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned};
|
||||
pub use plugin_call::{Location, ParamsPlace, key_location, location_in, location_keys};
|
||||
pub use select::SIMILAR;
|
||||
pub(crate) use vectorcraft_doc::range::parse_range;
|
||||
|
||||
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
|
||||
|
||||
@@ -422,6 +422,15 @@ pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result<Opti
|
||||
Ok(ids.first().copied().filter(|id| super::graph::is_design_art(d, *id)))
|
||||
}
|
||||
|
||||
/// The [`design_reference`] of an edit aimed at `item`: none for the Appearance panel's active row, which is a row of the
|
||||
/// first selected object's stack.
|
||||
pub(crate) fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result<Option<NodeId>> {
|
||||
if matches!(item, ItemTarget::Item { explicit: false, .. }) {
|
||||
return Ok(None);
|
||||
}
|
||||
design_reference(s, p, cmd)
|
||||
}
|
||||
|
||||
/// `ids` after `of` (see [`design_reference`]), when there is one.
|
||||
pub(crate) fn with_reference(of: Option<NodeId>, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
of.into_iter().chain(ids.iter().copied()).collect()
|
||||
@@ -610,16 +619,23 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
/// ([`capture_series_changes`], [`super::graph::capture_label_changes`]). Nothing targeted is not
|
||||
/// an edit, and when `f` leaves every target as it was, the document and its undo history stay as
|
||||
/// they are.
|
||||
///
|
||||
/// With `of` (see [`reference_of`]), an explicit aligned `item` is a row of the stack of `of`, which
|
||||
/// is not among `ids`: it must be there and of the edited kind, each target edits the row equal to
|
||||
/// it, a target without one is left as it is, and no target with one is an error. Returns the
|
||||
/// targets `f` was aimed at, in the order of `ids`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn edit_items(
|
||||
s: &mut Session,
|
||||
ids: &[NodeId],
|
||||
item: ItemTarget,
|
||||
of: Option<NodeId>,
|
||||
cmd: &str,
|
||||
label: &str,
|
||||
fill: bool,
|
||||
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| {
|
||||
) -> Result<Vec<NodeId>> {
|
||||
edit_items_then(s, ids, item, of, cmd, label, fill, f, |before, d, edited| {
|
||||
let series = capture_series_changes(before, d, edited);
|
||||
// The rows `f` edited are kept by `edit_items_then`.
|
||||
let labels = super::graph::capture_label_changes(before, d, edited, false);
|
||||
@@ -636,17 +652,29 @@ pub(crate) fn edit_items_then(
|
||||
s: &mut Session,
|
||||
ids: &[NodeId],
|
||||
item: ItemTarget,
|
||||
of: Option<NodeId>,
|
||||
cmd: &str,
|
||||
label: &str,
|
||||
fill: bool,
|
||||
mut f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
|
||||
mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result<bool>,
|
||||
) -> Result<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
) -> Result<Vec<NodeId>> {
|
||||
let art = of.and_then(|of| s.doc().ok()?.doc.node(of).map(|n| n.appearance.clone()));
|
||||
let per = match (item, art) {
|
||||
// Checked even when the art is the only object given, which leaves `ids` empty.
|
||||
(ItemTarget::Item { index, explicit: true, aligned: true }, Some(art)) => {
|
||||
item.resolve(&art, fill, cmd)?;
|
||||
let per = item.per_target_of(s, of, ids);
|
||||
if per.iter().all(Option::is_none) {
|
||||
return Err(bad(cmd, format!("no appearance item {index}")));
|
||||
}
|
||||
per
|
||||
}
|
||||
_ if ids.is_empty() => return Ok(Vec::new()),
|
||||
_ => item.per_target(s, ids),
|
||||
};
|
||||
let aimed: Vec<NodeId> = ids.iter().zip(&per).filter(|(_, item)| item.is_some()).map(|(id, _)| *id).collect();
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = item.per_target(s, ids);
|
||||
super::overprint::edit_counted(s, label, |d| {
|
||||
let mut edited = Vec::new();
|
||||
// The objects whose rows `f` edited: on type, no row means its characters.
|
||||
@@ -665,7 +693,13 @@ pub(crate) fn edit_items_then(
|
||||
let labels = super::graph::capture_label_rows(d, &rows);
|
||||
Ok(count_changed(d, &before, &edited) + usize::from(more || labels))
|
||||
})?;
|
||||
Ok(())
|
||||
Ok(aimed)
|
||||
}
|
||||
|
||||
/// The first of `ids` that [`edit_items`] aimed at `item` with `of` changes, and the item it changes there. Read it
|
||||
/// before the edit: once edited, a row that was equal to a row of the art of `of` no longer is.
|
||||
pub(crate) fn first_edited(s: &Session, item: ItemTarget, of: Option<NodeId>, ids: &[NodeId]) -> Option<(NodeId, ItemTarget)> {
|
||||
ids.iter().zip(item.per_target_of(s, of, ids)).find_map(|(id, it)| Some((*id, it?)))
|
||||
}
|
||||
|
||||
/// How many of `ids` differ in `d` from `before`.
|
||||
@@ -674,8 +708,8 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_
|
||||
}
|
||||
|
||||
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
|
||||
/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or
|
||||
/// null is an error of command `cmd`.
|
||||
/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`] and [`reference_of`]), else `default`. A `stroke` other than
|
||||
/// true, false or null is an error of command `cmd`.
|
||||
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result<bool> {
|
||||
check_switches(p, &["stroke"], cmd)?;
|
||||
if let Some(b) = p.get("stroke").and_then(Value::as_bool) {
|
||||
@@ -683,8 +717,14 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
|
||||
}
|
||||
let ItemTarget::Item { index, .. } = item else { return Ok(default) };
|
||||
let d = &s.doc()?.doc;
|
||||
let ids = drop_design_art(d, appearance_targets(s, p)?);
|
||||
Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
|
||||
let holder = match reference_of(s, p, item, cmd)? {
|
||||
Some(of) => Some(of),
|
||||
None => {
|
||||
let ids = drop_design_art(d, appearance_targets(s, p)?);
|
||||
ids.get(item.anchor(s, &ids)).copied()
|
||||
}
|
||||
};
|
||||
Ok(holder.and_then(|id| d.node(id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`].
|
||||
|
||||
@@ -32,7 +32,7 @@ use crate::DocState;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = vec![
|
||||
cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty)", always, export_svg),
|
||||
cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty). Under automation read roots it is refused while the SVG would name a linked image's file outside them (an image whose pixels the clipboard doesn't hold; a relative path is followed from the document the objects came from while it is open)", always, export_svg),
|
||||
cmd!(
|
||||
query "clipboard.importSvg",
|
||||
"Load SVG into Clipboard",
|
||||
@@ -435,14 +435,37 @@ impl Clipboard {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The internal clipboard as standalone SVG (`None` when it is empty).
|
||||
/// The internal clipboard as standalone SVG (`None` when it is empty, and when the SVG would name
|
||||
/// a linked image's file the caller asking now may not be shown: [`Self::clipboard_svg_refused`]).
|
||||
pub fn clipboard_svg(&self) -> Option<String> {
|
||||
if self.clipboard_svg_refused() {
|
||||
return None;
|
||||
}
|
||||
self.clipboard.svg()
|
||||
}
|
||||
|
||||
/// Would the internal clipboard's SVG name a linked image's file outside the read roots in
|
||||
/// force? It names the file of an image whose pixels the clipboard doesn't hold. A relative
|
||||
/// path is followed from the document the objects came from while it is open; otherwise it
|
||||
/// counts as outside.
|
||||
pub fn clipboard_svg_refused(&self) -> bool {
|
||||
self.check_clipboard_svg("clipboard.exportSvg").is_err()
|
||||
}
|
||||
|
||||
/// [`Self::clipboard_svg_refused`] as `cmd`'s error, which names no file or folder.
|
||||
fn check_clipboard_svg(&self, cmd: &str) -> Result<()> {
|
||||
if crate::file_access::current().is_none() || !self.clipboard.has_art() {
|
||||
return Ok(());
|
||||
}
|
||||
let from = self.docs.iter().find(|d| Some(d.uid) == self.clipboard.source_doc);
|
||||
let base = from.map_or_else(super::links::LinkBase::default, super::links::LinkBase::of);
|
||||
super::fileio::check_images_in(cmd, "svg", &self.clipboard.to_document(), base, &json!({}))
|
||||
}
|
||||
}
|
||||
|
||||
fn export_svg(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "svg": s.clipboard_svg() }))
|
||||
s.check_clipboard_svg("clipboard.exportSvg")?;
|
||||
Ok(json!({ "svg": s.clipboard.svg() }))
|
||||
}
|
||||
|
||||
fn import_svg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -14,7 +14,8 @@ use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::appearance::{
|
||||
ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, with_reference,
|
||||
ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, reference_of,
|
||||
with_reference,
|
||||
};
|
||||
use super::expand::has_seeded;
|
||||
use super::rasterfx;
|
||||
@@ -336,15 +337,6 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us
|
||||
/// have.
|
||||
const APPENDED: usize = usize::MAX;
|
||||
|
||||
/// The [`design_reference`] of an effect edit aimed at `item`: none for the Appearance panel's active row, which is a
|
||||
/// row of the first selected object's stack.
|
||||
fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result<Option<NodeId>> {
|
||||
if matches!(item, ItemTarget::Item { explicit: false, .. }) {
|
||||
return Ok(None);
|
||||
}
|
||||
design_reference(s, p, cmd)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn edit_effects(
|
||||
s: &mut Session,
|
||||
|
||||
@@ -125,9 +125,10 @@ mod commands;
|
||||
mod export_names;
|
||||
mod formats;
|
||||
|
||||
pub(crate) use access::check_images_in;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::list_dir;
|
||||
pub use access::{check_linked_scripts, write_file_with, write_save_file, write_save_file_with};
|
||||
pub use access::{check_linked_images, check_linked_scripts, write_file_with, write_save_file, write_save_file_with};
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests_readonly;
|
||||
|
||||
@@ -139,6 +139,49 @@ pub fn check_linked_scripts(cmd: &str, format: &str, st: &crate::DocState) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The error for an SVG that would name a linked image's file outside the read roots.
|
||||
pub(crate) const IMAGE_OUTSIDE: &str = "a linked image file is outside the read roots";
|
||||
|
||||
/// `cmd` may write `d`, the document an output of `st` writes (all of it, or the objects it
|
||||
/// exports), in format `format` (a format id) with options `p`: any format but SVG and SVGZ, and
|
||||
/// those too unless read roots are in force and the file would name a linked image's file outside
|
||||
/// them (a relative path taken from `st`'s folder; a URL is not checked). An `<image>` names the
|
||||
/// file with SVG Options › Images set to Link, and with Embed when the document holds no pixels for
|
||||
/// the image, for the images of the art each file draws (with chosen artboards, the art over each
|
||||
/// one; hidden layers and objects only with `hiddenLayers`, template layers and the opacity-mask
|
||||
/// and symbol editing layers never) and of the symbols, patterns and brushes that art uses; with
|
||||
/// Preserve Editing Capabilities, the native document the file carries names every linked file, the relative paths kept of them
|
||||
/// and each data set's image. The error names no file or folder.
|
||||
pub fn check_linked_images(cmd: &str, format: &str, st: &crate::DocState, d: &vectorcraft_doc::Document, p: &Value) -> Result<()> {
|
||||
check_images_in(cmd, format, d, crate::cmd::links::LinkBase::of(st), p)
|
||||
}
|
||||
|
||||
/// [`check_linked_images`] for `d`, a document whose links are looked for from `base` (the
|
||||
/// objects on the clipboard, looked for from the document they came from).
|
||||
pub(crate) fn check_images_in(cmd: &str, format: &str, d: &vectorcraft_doc::Document, base: crate::cmd::links::LinkBase, p: &Value) -> Result<()> {
|
||||
if !matches!(format, "svg" | "svgz") || crate::file_access::current().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
// Options that don't read as the encoder's choices count as Link, Preserve Editing and hidden
|
||||
// layers kept: the encoder refuses them anyway.
|
||||
let (link, preserve, hidden) = match super::svg::options_map(p) {
|
||||
Ok(m) => {
|
||||
let on = |k: &str| m.get(k).is_some_and(|v| v.as_bool() != Some(false));
|
||||
(m.get("images").is_some_and(|v| v.as_str() != Some("embed")), on("preserveEditing"), on("hiddenLayers"))
|
||||
}
|
||||
Err(_) => (true, true, true),
|
||||
};
|
||||
// The opacity-mask and symbol editing layers are never written.
|
||||
let d = &*d.without_edit_modes();
|
||||
let names = |d: &vectorcraft_doc::Document| crate::cmd::links::svg_names_hidden_image(d, base, link, hidden);
|
||||
if preserve && crate::cmd::links::names_hidden_file(d, base)
|
||||
|| names(d) && super::svg::drawn(d, p).is_none_or(|files| files.iter().any(|f| names(f)))
|
||||
{
|
||||
return Err(bad(cmd, IMAGE_OUTSIDE));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn may_write(path: &str) -> Result<()> {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@ use crate::DocState;
|
||||
use super::super::{Result, Session, bad, check_switches, edit, number_or, switch_or, text_param};
|
||||
use super::encode::single_artboard;
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, Format, check_linked_scripts, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded,
|
||||
write_or_return,
|
||||
ARTBOARD_PARAMS, Format, check_images_in, check_linked_scripts, default_name, encode_all, encode_with_warnings, merge, with_black_output,
|
||||
writable, write_encoded, write_or_return,
|
||||
};
|
||||
|
||||
/// The keys the export commands take for every format, beside the path, the format and the
|
||||
@@ -287,6 +287,8 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let doc = export_doc(st, f, p)?;
|
||||
// The objects written (all, or with selectedOnly the selected ones) are checked.
|
||||
check_images_in(cmd, f.id, &doc, crate::cmd::links::LinkBase::of(st), p)?;
|
||||
let display_doc_title = super::has_own_title(&doc);
|
||||
Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title))
|
||||
}
|
||||
@@ -372,6 +374,8 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let expanded = expand_presets(s, C, p)?;
|
||||
let p = with_black_output(s, &expanded);
|
||||
let (d, bounds) = selection(s, C, f.id == "txt")?;
|
||||
// The selected objects' links are looked for from the document they are in.
|
||||
check_images_in(C, f.id, &d, crate::cmd::links::LinkBase::of(s.doc()?), &without_artboards(&p))?;
|
||||
// The encoder's warnings (options accepted but not applied, features approximated or left
|
||||
// out), as document.export reports them.
|
||||
let (bytes, mut warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
|
||||
|
||||
@@ -891,6 +891,13 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
|
||||
let cmd = plan.mode.command();
|
||||
super::check_linked_scripts(cmd, plan.format.id, st)?;
|
||||
let mut own = doc_to_save(st, plan.format);
|
||||
let mut params = plan.options.clone();
|
||||
if is_svg(plan.format) {
|
||||
// A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out.
|
||||
params.entry("hiddenLayers").or_insert(Value::Bool(true));
|
||||
}
|
||||
let params = Value::Object(params);
|
||||
super::check_linked_images(cmd, plan.format.id, st, &own, ¶ms)?;
|
||||
// The file the document is saved as (else its own) titles it if New's name is all it has.
|
||||
let file = plan.path.as_deref().filter(|_| plan.retargets()).or(st.path.as_deref());
|
||||
if let Some(d) = super::titled_for_export(&own, file, plan.format.id) {
|
||||
@@ -905,12 +912,7 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
|
||||
let base = crate::cmd::links::LinkBase::of(st);
|
||||
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, base, p));
|
||||
let doc = relative.map_or(own, Arc::new);
|
||||
let mut params = plan.options.clone();
|
||||
if is_svg(plan.format) {
|
||||
// A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out.
|
||||
params.entry("hiddenLayers").or_insert(Value::Bool(true));
|
||||
}
|
||||
let mut params = Value::Object(params);
|
||||
let mut params = params;
|
||||
if matches!(plan.format.id, "vectorcraft" | "template") {
|
||||
// Not remembered with the options: the preference decides each time it isn't given.
|
||||
params = with_compression_pref(&s.prefs, ¶ms);
|
||||
@@ -1307,7 +1309,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 name an option given flat or in options that the file doesn't read: a legacy .ai reads only its own options, and other .ai files don't read outlineText, hiddenObjects, transparentImages or preserveEditing), 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?: true (native: also carry a PDF of every artboard; false omits it) | 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 and EPS (below): a .ai file is 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 takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; 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. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), also by file.saveAs and file.saveCopy. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
|
||||
"{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 name an option given flat or in options that the file doesn't read: a legacy .ai reads only its own options, and other .ai files don't read outlineText, hiddenObjects, transparentImages or preserveEditing), 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?: true (native: also carry a PDF of every artboard; false omits it) | 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 and EPS (below): a .ai file is 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 takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; 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. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), or while the file would name a linked image's file outside them (images: link, preserveEditing, or an image whose pixels the document doesn't hold; a URL is not checked), also by file.saveAs and file.saveCopy. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -1125,6 +1125,13 @@ pub(crate) fn export(s: &mut Session, p: &Value, asked: &Value) -> Result<Value>
|
||||
};
|
||||
let crop = rows.iter().any(|r| r.format.id != "pdf");
|
||||
let items = items(&doc, &o, selection, &selected, &naming.filename, crop, true)?;
|
||||
// The file each item writes for each SVG row, before the folder is made or any row is written.
|
||||
let base = crate::cmd::links::LinkBase::of(s.doc()?);
|
||||
for r in rows.iter().filter(|r| matches!(r.format.id, "svg" | "svgz")) {
|
||||
for item in &items {
|
||||
super::check_images_in(C, r.format.id, &item.doc, base, &r.options_for(item.region, item.artboard)?)?;
|
||||
}
|
||||
}
|
||||
// Appearance of Black › Printing / Exporting for RGB raster rows.
|
||||
// Include Artboard Fill off: the encoders leave the background colors out.
|
||||
let fill = if o.include_artboard_fill == Some(false) { json!({ "includeArtboardFill": false }) } else { json!({}) };
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! params' top level or from an `svg` object (which wins), and the files an SVG export writes
|
||||
//! (one per artboard, plus the images it links to).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use vectorcraft_doc::Document;
|
||||
@@ -155,6 +157,18 @@ fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>,
|
||||
Ok((opts, named.unwrap_or_else(|| vec![0]).into_iter().map(Some).collect(), chosen))
|
||||
}
|
||||
|
||||
/// The art the SVG files of `doc` with options `p` draw, as [`encode`] picks it: with chosen
|
||||
/// artboards, the art over each one; else all of `doc`. `None` when the options don't read.
|
||||
pub(super) fn drawn<'a>(doc: &'a Document, p: &Value) -> Option<Vec<Cow<'a, Document>>> {
|
||||
let (_, boards, chosen) = plan(doc, p).ok()?;
|
||||
if !chosen {
|
||||
return Some(vec![Cow::Borrowed(doc)]);
|
||||
}
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let rects: Vec<_> = boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect();
|
||||
Some(rects.into_iter().map(|r| Cow::Owned(super::export::art_over(doc, r, &mut renderer))).collect())
|
||||
}
|
||||
|
||||
/// What a save of `doc` as SVG (format `label`) with options `p` loses of several artboards: the
|
||||
/// file is one artboard and reopens as one, unless it carries the native document (Preserve
|
||||
/// Editing Capabilities).
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind; the points on a label's characters are not stored → {index}",
|
||||
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind; the points on a label's characters are not stored → {index}",
|
||||
has_doc,
|
||||
add_point
|
||||
),
|
||||
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{index? (default: the selected point), at?: [x,y] (document coordinates), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} move or recolour a point of the freeform gradient → {index}",
|
||||
"{index? (default: the selected point), at?: [x,y] (document coordinates), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} move or recolour a point of the freeform gradient → {index}",
|
||||
has_doc,
|
||||
set_point
|
||||
),
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{index? (default: the selected point), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} remove a point (never the last one); lines through it close up around it and lines left with one point go → {index}",
|
||||
"{index? (default: the selected point), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} remove a point (never the last one); lines through it close up around it and lines left with one point go → {index}",
|
||||
has_doc,
|
||||
delete_point
|
||||
),
|
||||
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Freeform Line",
|
||||
[],
|
||||
None,
|
||||
"{points: [index, …] (at least two), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} join points with a smooth line through them, in order → {line}",
|
||||
"{points: [index, …] (at least two), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} join points with a smooth line through them, in order → {line}",
|
||||
has_doc,
|
||||
add_line
|
||||
),
|
||||
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Freeform Line",
|
||||
[],
|
||||
None,
|
||||
"{line: line index, segment: the segment from the line's point `segment` to the next, t?: 0..1 along it (default 0.5), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point on a line there (colour, opacity and spread between the segment's ends) and select it → {index}",
|
||||
"{line: line index, segment: the segment from the line's point `segment` to the next, t?: 0..1 along it (default 0.5), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} add a point on a line there (colour, opacity and spread between the segment's ends) and select it → {index}",
|
||||
has_doc,
|
||||
split_line
|
||||
),
|
||||
@@ -201,7 +201,9 @@ fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor
|
||||
first.get_or_insert(r);
|
||||
Ok(())
|
||||
};
|
||||
edit_items(s, &ids, item, cmd, "Gradient", !stroke, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, cmd)?;
|
||||
let shown_at = super::appearance::first_edited(s, item, of, &ids);
|
||||
edit_items(s, &ids, item, of, cmd, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -222,7 +224,9 @@ fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor
|
||||
})?;
|
||||
let r = first.ok_or_else(|| bad(cmd, NOT_FREEFORM))?;
|
||||
// The edited gradient is the last one used (`paint.lastGradient`).
|
||||
let shown = s.doc()?.doc.node(ids[0]).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten()));
|
||||
let shown = shown_at.and_then(|(id, item)| {
|
||||
s.doc().ok()?.doc.node(id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten()))
|
||||
});
|
||||
if let Some(p) = shown {
|
||||
s.remember_paint(&p);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without its placement, fitted to each label",
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.setFill; without stroke, the kind of the row edited), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without its placement, fitted to each label",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient Vector",
|
||||
[],
|
||||
None,
|
||||
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without the placement this command sets, fitted to each label",
|
||||
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without the placement this command sets, fitted to each label",
|
||||
has_doc,
|
||||
set_gradient_geom
|
||||
),
|
||||
@@ -580,7 +580,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model)
|
||||
.map_err(|e| bad(C, e))?;
|
||||
let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform);
|
||||
edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, C)?;
|
||||
let shown_at = super::appearance::first_edited(s, item, of, &ids);
|
||||
// The objects returned: with a design's art, those with a row equal to the art's.
|
||||
let edited = edit_items(s, &ids, item, of, C, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -616,9 +619,12 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.gradient_mode = m;
|
||||
}
|
||||
// The last gradient is the one the first object now shows (the defaults' without objects).
|
||||
let shown = match ids.first() {
|
||||
Some(id) => s.doc()?.doc.node(*id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten())),
|
||||
None => Some(new_default.clone()),
|
||||
let shown = match shown_at {
|
||||
Some((id, item)) => {
|
||||
s.doc()?.doc.node(id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten()))
|
||||
}
|
||||
None if ids.is_empty() => Some(new_default.clone()),
|
||||
None => None,
|
||||
};
|
||||
if let Some(shown) = shown {
|
||||
s.remember_paint(&shown);
|
||||
@@ -628,7 +634,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
s.paint.fill = new_default;
|
||||
}
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
Ok(json!({"ids": edited.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// A painted stroke on a type run without a weight gets 1 pt.
|
||||
@@ -766,7 +772,9 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let targets = super::appearance::drop_design_art(&s.doc()?.doc, targets);
|
||||
let model = super::paint::new_color_model(s, &Value::Null);
|
||||
edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, C)?;
|
||||
// The objects returned: with a design's art, those with a row equal to the art's.
|
||||
let targets = edit_items(s, &targets, item, of, C, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
|
||||
@@ -869,6 +869,188 @@ fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() {
|
||||
assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]);
|
||||
}
|
||||
|
||||
/// An `item` given with `aligned: true` and the art of a graph design in a series first is a row of the art's stack in the
|
||||
/// paint and stroke commands: each other object edits the row equal to it, and a row the art does not have as a fill or
|
||||
/// stroke as edited, or that no other object has, is an error that leaves the document and its undo history as they are.
|
||||
#[test]
|
||||
fn aligned_paint_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
let green = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0));
|
||||
// The art's row 1 is the blue fill the mark has as row 0, and its row 2 the black stroke the mark has as row 1.
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
let black = rows(&s, mark).items[1].clone();
|
||||
s.edit("Stroke", |d, _| {
|
||||
d.node_mut(art).unwrap().appearance.items.push(black.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
|
||||
let fill = |s: &mut Session, cmd: &str, p: serde_json::Value| {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let now = rows(s, mark);
|
||||
assert_eq!(rows(s, art), art_was, "{cmd}");
|
||||
assert_ne!(now.items[0], mark_was.items[0], "{cmd} edits the mark's blue fill");
|
||||
assert_eq!(now.items[1], mark_was.items[1], "{cmd} leaves the mark's stroke");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(rows(s, mark), mark_was, "{cmd} is one undo step");
|
||||
now
|
||||
};
|
||||
let ids = [art.0, mark.0];
|
||||
let now = fill(&mut s, "paint.setFill", json!({"ids": ids, "item": 1, "aligned": true, "color": "#00ff00"}));
|
||||
assert_eq!(now.items[0].paint(), &green);
|
||||
let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 1, "aligned": true}));
|
||||
assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the inverted color is the last color, not the art's");
|
||||
let now = fill(&mut s, "paint.lastColor", json!({"ids": ids, "item": 1, "aligned": true, "stroke": false}));
|
||||
assert_eq!(Some(s.last_solid), now.items[0].paint().color());
|
||||
// Without `aligned`, the art is left out and the item is a row of the mark.
|
||||
let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 0}));
|
||||
assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the mark's inverted color is the last color, not the art's");
|
||||
s.last_solid = vectorcraft_color::Color::rgb(0.5, 0.5, 0.5);
|
||||
let now = fill(&mut s, "paint.complement", json!({"ids": ids, "item": 0}));
|
||||
assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the mark's complement is the last color, not the art's");
|
||||
let now = fill(&mut s, "paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"}));
|
||||
let Paint::Gradient(g) = now.items[0].paint() else { panic!("{:?}", now.items[0].paint()) };
|
||||
assert_eq!(&s.last_gradient, &**g, "the mark's new gradient is the last gradient");
|
||||
let now = fill(&mut s, "paint.lastGradient", json!({"ids": ids, "item": 1, "aligned": true, "stroke": false}));
|
||||
assert!(matches!(now.items[0].paint(), Paint::Gradient(_)));
|
||||
|
||||
let stroke = |s: &mut Session, cmd: &str, p: serde_json::Value| {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let now = rows(s, mark);
|
||||
assert_eq!(rows(s, art), art_was, "{cmd}");
|
||||
assert_eq!(now.items[0], mark_was.items[0], "{cmd} leaves the mark's fill");
|
||||
assert_ne!(now.items[1], mark_was.items[1], "{cmd} edits the mark's stroke");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(rows(s, mark), mark_was, "{cmd} is one undo step");
|
||||
now
|
||||
};
|
||||
let now = stroke(&mut s, "paint.setStroke", json!({"ids": ids, "item": 2, "aligned": true, "color": "#00ff00"}));
|
||||
assert_eq!(now.items[1].paint(), &green);
|
||||
let now = stroke(&mut s, "stroke.set", json!({"ids": ids, "item": 2, "aligned": true, "weight": 5}));
|
||||
assert_eq!(now.stroke_at(Some(1)).unwrap().width, 5.0);
|
||||
let now = stroke(&mut s, "stroke.setAdvanced", json!({"ids": ids, "item": 2, "aligned": true, "arrowScale": [200, 200]}));
|
||||
assert_eq!(now.stroke_at(Some(1)).unwrap().arrow_scale, (200.0, 200.0));
|
||||
// Without `stroke`, the kind of the art's row is edited.
|
||||
let now = stroke(&mut s, "paint.editGradient", json!({"ids": ids, "item": 2, "aligned": true, "kind": "linear"}));
|
||||
assert!(matches!(now.items[1].paint(), Paint::Gradient(_)));
|
||||
fill(&mut s, "paint.complement", json!({"ids": ids, "item": 1, "aligned": true, "stroke": false}));
|
||||
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
for (cmd, p, said) in [
|
||||
("stroke.set", json!({"ids": ids, "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"),
|
||||
("paint.setFill", json!({"ids": ids, "item": 2, "aligned": true, "color": "#00ff00"}), "appearance item 2 is not a fill"),
|
||||
("paint.setFill", json!({"ids": ids, "item": 0, "aligned": true, "color": "#00ff00"}), "no appearance item 0"),
|
||||
("paint.setFill", json!({"ids": ids, "item": 9, "aligned": true, "color": "#00ff00"}), "no appearance item 9"),
|
||||
// The art alone: no other object has the row.
|
||||
("paint.setFill", json!({"ids": [art.0], "item": 9, "aligned": true, "color": "#00ff00"}), "no appearance item 9"),
|
||||
("paint.setFill", json!({"ids": [art.0], "item": 1, "aligned": true, "color": "#00ff00"}), "no appearance item 1"),
|
||||
("stroke.set", json!({"ids": [art.0], "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"),
|
||||
("paint.editGradient", json!({"ids": [art.0], "item": 1, "aligned": true, "kind": "linear"}), "no appearance item 1"),
|
||||
// The weight steps take no row: they step each object's active or top stroke.
|
||||
("stroke.weightUp", json!({"ids": ids, "item": 2}), "unknown parameter `item`"),
|
||||
("stroke.weightUp", json!({"ids": ids, "aligned": true}), "unknown parameter `aligned`"),
|
||||
("stroke.weightDown", json!({"ids": ids, "item": 2}), "unknown parameter `item`"),
|
||||
("stroke.weightDown", json!({"ids": ids, "aligned": true}), "unknown parameter `aligned`"),
|
||||
] {
|
||||
let defaults = format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient));
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(said), "{cmd}: {e}");
|
||||
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was.clone()), "{cmd}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}");
|
||||
assert_eq!(format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient)), defaults, "{cmd}");
|
||||
}
|
||||
|
||||
// An object with no row equal to the art's is left out of what the commands report as edited.
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let rect = *s.doc().unwrap().selection.subjects().first().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [rect.0], "color": "#ff00ff"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [rect.0], "color": "#ff00ff"})).unwrap();
|
||||
let rect_was = rows(&s, rect);
|
||||
let ids = [art.0, mark.0, rect.0];
|
||||
for (cmd, p, key, said) in [
|
||||
("paint.setFill", json!({"ids": ids, "item": 1, "aligned": true, "color": "#00ff00"}), "objects", json!(1)),
|
||||
("stroke.set", json!({"ids": ids, "item": 2, "aligned": true, "weight": 5}), "objects", json!(1)),
|
||||
("paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"}), "ids", json!([mark.0])),
|
||||
("paint.setGradientGeom", json!({"ids": ids, "item": 1, "aligned": true, "start": [0, 0], "end": [10, 40]}), "ids", json!([mark.0])),
|
||||
] {
|
||||
if cmd == "paint.setGradientGeom" {
|
||||
s.execute("paint.editGradient", &json!({"ids": [mark.0], "item": 0, "kind": "linear"})).unwrap();
|
||||
let gradient = rows(&s, mark).items[0].clone();
|
||||
s.edit("Gradient", |d, _| {
|
||||
d.node_mut(art).unwrap().appearance.items[1] = gradient.clone();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let r = s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(r[key], said, "{cmd}: {r}");
|
||||
assert_eq!(rows(&s, rect), rect_was, "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
// A gradient spanning the selection spans only the objects that take it: a distant object without an equal row does
|
||||
// not move it.
|
||||
s.execute("shape.rectangle", &json!({"x": 5000, "y": 5000, "width": 10, "height": 10})).unwrap();
|
||||
let far = *s.doc().unwrap().selection.subjects().first().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [far.0], "color": "#ff00ff"})).unwrap();
|
||||
let far_was = rows(&s, far);
|
||||
let gradient = json!({"stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]});
|
||||
let mut placed = Vec::new();
|
||||
for ids in [vec![art.0, mark.0], vec![art.0, mark.0, far.0]] {
|
||||
s.execute("paint.setFill", &json!({"ids": ids, "item": 1, "aligned": true, "gradient": gradient, "span": "selection"})).unwrap();
|
||||
placed.push(rows(&s, mark).items[0].clone());
|
||||
assert_eq!(rows(&s, far), far_was);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert!(matches!(placed[0].paint(), Paint::Gradient(_)));
|
||||
assert_eq!(placed[1], placed[0]);
|
||||
}
|
||||
|
||||
/// The gradient and freeform gradient commands read an `item` given with `aligned: true` and the art of a graph design in
|
||||
/// a series first as a row of the art's stack, as the paint commands do.
|
||||
#[test]
|
||||
fn aligned_gradient_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
for kind in ["linear", "freeform"] {
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
// The mark's fill and the art's equal row become the same gradient.
|
||||
s.execute("paint.editGradient", &json!({"ids": [mark.0], "item": 0, "kind": kind})).unwrap();
|
||||
let gradient = rows(&s, mark).items[0].clone();
|
||||
// A yellow fill above the mark's gradient is the mark's row 1, as the gradient is the art's row 1.
|
||||
let mut yellow = gradient.clone();
|
||||
*yellow.paint_mut() = Paint::solid(vectorcraft_color::Color::rgb(1.0, 1.0, 0.0));
|
||||
s.edit("Gradient", |d, _| {
|
||||
d.node_mut(art).unwrap().appearance.items[1] = gradient.clone();
|
||||
d.node_mut(mark).unwrap().appearance.items.insert(1, yellow.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let ids = [art.0, mark.0];
|
||||
let edits = if kind == "linear" {
|
||||
vec![
|
||||
("paint.setGradientGeom", json!({"ids": ids, "item": 1, "aligned": true, "start": [0, 0], "end": [10, 40]})),
|
||||
("paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "radial"})),
|
||||
]
|
||||
} else {
|
||||
vec![("paint.freeform.addPoint", json!({"ids": ids, "item": 1, "aligned": true, "at": [5, 5]}))]
|
||||
};
|
||||
for (cmd, p) in edits {
|
||||
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let now = rows(&s, mark);
|
||||
assert_eq!(rows(&s, art), art_was, "{cmd}");
|
||||
assert_ne!(now.items[0], mark_was.items[0], "{cmd} edits the mark's gradient fill");
|
||||
assert_eq!(now.items[1..], mark_was.items[1..], "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// The edited gradient is the last gradient (paint.setGradientGeom does not set it).
|
||||
if cmd != "paint.setGradientGeom" {
|
||||
let Paint::Gradient(g) = now.items[0].paint() else { panic!("{cmd}") };
|
||||
assert_eq!(&s.last_gradient, &**g, "{cmd}: the mark's edited gradient is the last gradient");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Contents row of a graph design's copy and of a group whose stack differs is not moved with `aligned: true`, as
|
||||
/// the rows are not.
|
||||
#[test]
|
||||
|
||||
@@ -1298,7 +1298,112 @@ pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
|
||||
/// alone in a reply to the caller asking now? Only a path [`path_shown`] does not allow; a URL is
|
||||
/// always shown as it is.
|
||||
pub(crate) fn script_hidden(f: &str, doc_path: Option<&str>) -> bool {
|
||||
!path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url))
|
||||
file_hidden(f, doc_path)
|
||||
}
|
||||
|
||||
/// Is the file `path` (a link of the document saved at `doc_path`) one the caller asking now may
|
||||
/// not be shown? Only a path [`path_shown`] does not allow; a URL is not a file and is shown as it is.
|
||||
pub(crate) fn file_hidden(path: &str, doc_path: Option<&str>) -> bool {
|
||||
!path_shown(path, doc_path) && !matches!(fileio::document_path(path, doc_path), Err(craft_docpath::Refused::Url))
|
||||
}
|
||||
|
||||
/// Does an SVG of `d` (a document whose links are looked for from `base`) written for the caller
|
||||
/// asking now name the file of a linked image that [`file_hidden`] keeps from it? An `<image>`
|
||||
/// names its image's file with SVG Options › Images set to Link (`link`), and with Embed when the
|
||||
/// document holds no pixels for the image. An image with a solid colorize paint whose pixels are
|
||||
/// gray or missing names none: the SVG writes gray pixels colorized and leaves out an image
|
||||
/// without pixels. The images of the art the SVG writes (opacity masks included) and of the
|
||||
/// symbols, patterns and brushes that art uses, as Copy follows them, are checked: the SVG writes
|
||||
/// no other resource. Hidden layers and objects are written only with
|
||||
/// SVG Options › hidden layers kept (`hidden`), and template layers never ([`svg_written`]).
|
||||
/// Always `false` without read roots in force.
|
||||
pub(crate) fn svg_names_hidden_image(d: &Document, base: LinkBase, link: bool, hidden: bool) -> bool {
|
||||
if crate::file_access::current().is_none() || !names_hidden_image(d, base, link) {
|
||||
return false;
|
||||
}
|
||||
let art = d.layers.iter().filter_map(|l| svg_written(l, hidden)).collect();
|
||||
names_hidden_image(&super::clipboard::Clipboard::gather(d, art).to_document(), base, link)
|
||||
}
|
||||
|
||||
/// `n` as the SVG writer writes it, or `None` when it writes none of it: a hidden layer or object
|
||||
/// is left out unless `hidden`, a template layer's art always, and a disabled opacity mask's art.
|
||||
/// Only layers and groups lose members; other objects keep theirs as they are.
|
||||
fn svg_written(n: &Node, hidden: bool) -> Option<Node> {
|
||||
if !(n.visible || hidden) {
|
||||
return None;
|
||||
}
|
||||
let mut n = n.clone();
|
||||
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled).and_then(|m| {
|
||||
let art = svg_written(&m.art, hidden)?;
|
||||
Some(Box::new(vectorcraft_doc::OpacityMask { art: std::sync::Arc::new(art), ..m.clone() }))
|
||||
});
|
||||
if n.extras().mask.is_some() {
|
||||
n.extras_mut().mask = mask;
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Layer { template: true, children, .. } => children.clear(),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
*children = children.iter().filter_map(|c| svg_written(c, hidden)).map(std::sync::Arc::new).collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// [`svg_names_hidden_image`] for every image of `d`: in the layers, symbols, graph designs,
|
||||
/// patterns, opacity masks and the art of the stored brushes, used or not.
|
||||
fn names_hidden_image(d: &Document, base: LinkBase, link: bool) -> bool {
|
||||
let d = with_brush_art(d);
|
||||
let mut hidden = false;
|
||||
d.visit_images(|n, im| {
|
||||
if hidden {
|
||||
return;
|
||||
}
|
||||
let shown = d.shown_image(n, im);
|
||||
let Some(l) = &shown.link else { return };
|
||||
let blob = d.image_blob(&shown).filter(|b| !b.bytes.is_empty());
|
||||
// The SVG writes an image with a solid colorize paint as colorized pixels when they are
|
||||
// gray, and leaves it out when the document holds none of them.
|
||||
if matches!(n.extras().colorize.as_deref(), Some(vectorcraft_color::Paint::Solid { .. })) && blob.is_none_or(|b| b.gray_source().is_some()) {
|
||||
return;
|
||||
}
|
||||
let named = link || blob.is_none();
|
||||
hidden = named && file_hidden(&l.path, base.doc);
|
||||
});
|
||||
hidden
|
||||
}
|
||||
|
||||
/// Does the native document an SVG of `d` (a document whose links are looked for from `base`)
|
||||
/// carries with Preserve Editing Capabilities name a file that the caller asking now may not be
|
||||
/// shown: the file of a linked image or placed document ([`file_hidden`]), the relative path kept
|
||||
/// of one, which is followed from the file the document was read from and from the document's own
|
||||
/// folder, or a data set's image? Links in the layers, symbols, graph designs, patterns, the pattern
|
||||
/// being edited as it was before, opacity masks and the art of the stored brushes are all checked,
|
||||
/// as [`with_hidden_paths_named`] checks them. A stored brush library that is not a list, or holds
|
||||
/// an entry that does not read as a brush, counts as naming one: the native document carries it as
|
||||
/// it is, and its links cannot be checked. Always `false` without read roots in force.
|
||||
pub(crate) fn names_hidden_file(d: &Document, base: LinkBase) -> bool {
|
||||
if crate::file_access::current().is_none() {
|
||||
return false;
|
||||
}
|
||||
let unread = |list: &Vec<Value>| list.iter().any(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).is_err());
|
||||
if d.unknown.get(vectorcraft_brush::DOC_KEY).is_some_and(|v| v.as_array().is_none_or(unread)) {
|
||||
return true;
|
||||
}
|
||||
let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from));
|
||||
let hidden = |l: &LinkInfo| file_hidden(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden);
|
||||
let image_hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if file_hidden(path, base.doc));
|
||||
if d.variables.datasets.iter().any(|set| set.values.values().any(image_hidden)) {
|
||||
return true;
|
||||
}
|
||||
let mut d = with_brush_art(d);
|
||||
if let Some(original) = d.pattern_edit.as_ref().and_then(|e| e.original.clone()) {
|
||||
d.to_mut().patterns.push(original);
|
||||
}
|
||||
let mut found = false;
|
||||
d.visit_images(|_, im| found |= im.link.as_ref().is_some_and(hidden));
|
||||
d.visit_placed(|_, p| found |= hidden(&p.link));
|
||||
found
|
||||
}
|
||||
|
||||
/// The linked JavaScript file `f` of the open document `st` as a reply to the caller asking now
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke",
|
||||
[],
|
||||
None,
|
||||
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, as paint.setFill",
|
||||
"{…as paint.setFill, item?: the stroke item to set, aligned?: bool (as paint.setFill)} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, as paint.setFill",
|
||||
has_doc,
|
||||
|s, p| { set_paint(s, p, false) }
|
||||
),
|
||||
@@ -96,7 +96,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Invert",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md); no undo step when no color changes → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, aligned?: bool (as paint.setFill), target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md); no undo step when no color changes → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
|
||||
),
|
||||
@@ -105,7 +105,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Complement",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default; no undo step when no color changes → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, aligned?: bool (as paint.setFill), target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default; no undo step when no color changes → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model())
|
||||
),
|
||||
@@ -114,7 +114,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Apply Last Color",
|
||||
[],
|
||||
Some(","),
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, aligned?: bool (as paint.setFill), target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let paint = Paint::solid(s.last_solid);
|
||||
@@ -126,7 +126,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Apply Last Gradient",
|
||||
[],
|
||||
Some("."),
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, aligned?: bool (as paint.setFill), target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
|
||||
@@ -553,7 +553,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, fill);
|
||||
let selected = item.targets(s, p)?;
|
||||
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
|
||||
let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint };
|
||||
let of = super::appearance::reference_of(s, p, item, cmd)?;
|
||||
let paint = if span {
|
||||
// With a design's art, the gradient spans only the objects with a row equal to the art's, which take it.
|
||||
let spanned: Vec<NodeId> = ids.iter().zip(item.per_target_of(s, of, &ids)).filter_map(|(id, it)| it.map(|_| *id)).collect();
|
||||
placed_across(&s.doc()?.doc, &spanned, paint, p, fill)
|
||||
} else {
|
||||
paint
|
||||
};
|
||||
// The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso: a solid
|
||||
// fill recolors just those points of their meshes (the proxies show them: `fill_mesh_points`).
|
||||
let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] };
|
||||
@@ -570,10 +577,12 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
// The type objects whose characters took the paint (no row targeted): only those change what a graph's labels keep
|
||||
// for their characters; a row edit is kept with the label's rows.
|
||||
let characters = std::cell::RefCell::new(Vec::new());
|
||||
edit_items_then(
|
||||
// The objects counted: with a design's art, those with a row equal to the art's.
|
||||
let ids = edit_items_then(
|
||||
s,
|
||||
&ids,
|
||||
item,
|
||||
of,
|
||||
cmd,
|
||||
label,
|
||||
fill,
|
||||
@@ -1070,30 +1079,32 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(
|
||||
return Ok(json!({ "changed": changed as usize }));
|
||||
}
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, !stroke);
|
||||
let (r, shown_item, edited) = match item {
|
||||
ItemTarget::Item { index, .. } => {
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?);
|
||||
let (r, shown_at) = match item {
|
||||
ItemTarget::Item { .. } => {
|
||||
let targets = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?);
|
||||
let of = super::appearance::reference_of(s, p, item, cmd)?;
|
||||
// The row shown is the one edited in the first object that has it: an aligned row maps to each object's equal row.
|
||||
let shown_at = super::appearance::first_edited(s, item, of, &targets).and_then(|(id, it)| match it {
|
||||
ItemTarget::Item { index, .. } => Some((id, Some(index))),
|
||||
ItemTarget::Top => None,
|
||||
});
|
||||
let mut changed = 0;
|
||||
edit_items(s, &ids, item, cmd, label, !stroke, |n, i| {
|
||||
edit_items(s, &targets, item, of, cmd, label, !stroke, |n, i| {
|
||||
let paint =
|
||||
if stroke { n.appearance.stroke_at_mut(i).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(i).map(|l| &mut l.paint) };
|
||||
changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize;
|
||||
Ok(())
|
||||
})?;
|
||||
(json!({ "changed": changed }), Some(index), Some(ids))
|
||||
(json!({ "changed": changed }), shown_at)
|
||||
}
|
||||
// The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles.
|
||||
ItemTarget::Top => {
|
||||
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||||
(super::colorcmds::recolor(s, &q, cmd, label, f)?, None, None)
|
||||
(super::colorcmds::recolor(s, &q, cmd, label, f)?, leaf_targets(s, &ids)?.first().map(|id| (*id, None)))
|
||||
}
|
||||
};
|
||||
// The paint remembered is one the edit recolored: a graph design's art that the item edit leaves out gives none.
|
||||
let shown_ids = match edited {
|
||||
Some(ids) => ids,
|
||||
None => leaf_targets(s, &ids)?,
|
||||
};
|
||||
if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) {
|
||||
if let Some(shown) = shown_at.and_then(|(id, at)| s.doc().ok()?.doc.node(id).map(|n| proxy_paint(n, stroke, at))) {
|
||||
s.remember_paint(&shown);
|
||||
}
|
||||
Ok(r)
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file or PSD/PSB file placed in a clip group, which the Links commands take}. A PSD or PSB file that names a clipping path places in a clip group of that path (even-odd), as does its merged image in document.open. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at), showImportOptions?: true (in the app, a text file without text, a DXF drawing without dxf and a PDF with several pages or a password and no page ask for those options first; false places them with the options last confirmed, a PDF on page 1 (or page), and asks only an encrypted PDF's password)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file or PSD/PSB file placed in a clip group, which the Links commands take}. A PSD or PSB file that names a clipping path places in a clip group of that path (even-odd), as does its merged image in document.open. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
|
||||
@@ -540,6 +540,7 @@ struct Saved {
|
||||
recent_colors: Vec<vectorcraft_color::Color>,
|
||||
recent_urls: Vec<String>,
|
||||
global_edit_options: super::globaledit::Options,
|
||||
select_similar: &'static str,
|
||||
envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>,
|
||||
}
|
||||
|
||||
@@ -564,6 +565,7 @@ impl Saved {
|
||||
recent_colors: s.recent_colors.clone(),
|
||||
recent_urls: s.recent_urls.clone(),
|
||||
global_edit_options: s.global_edit_options.clone(),
|
||||
select_similar: s.select_similar,
|
||||
envelope_defaults: s.envelope_defaults,
|
||||
}
|
||||
}
|
||||
@@ -587,6 +589,7 @@ impl Saved {
|
||||
s.recent_colors = self.recent_colors;
|
||||
s.recent_urls = self.recent_urls;
|
||||
s.global_edit_options = self.global_edit_options;
|
||||
s.select_similar = self.select_similar;
|
||||
s.envelope_defaults = self.envelope_defaults;
|
||||
s.pending_paint = None;
|
||||
}
|
||||
|
||||
@@ -536,6 +536,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"select.same.textStrokeColor",
|
||||
"select.segments",
|
||||
"select.set",
|
||||
"select.similar",
|
||||
"select.storedPath",
|
||||
"select.toggle",
|
||||
"select.update",
|
||||
|
||||
@@ -225,9 +225,69 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
same_attribute
|
||||
),
|
||||
cmd!(
|
||||
"select.similar",
|
||||
"Select Similar Objects",
|
||||
[],
|
||||
None,
|
||||
"{by?: \"all\"|\"fillColor\"|\"fillAndStroke\"|\"strokeColor\"|\"strokeWeight\"|\"opacity\"|\"blendingMode\"|\"appearance\"|\"graphicStyle\"|\"shapeType\"|\"symbolInstance\"} the Control bar's Select Similar Objects button and its menu: selects the objects like the first selected object by `by`, running the select.same command of that name; `all` selects the objects with its fill and stroke colours, stroke weight, opacity and blending mode (tints, compound paths and Live Paint faces as select.same.fillColor). A `by` given is kept for the session once the selection is made; without it, the kept one (`all` until one is given). Any other `by` is an error that keeps the selection and the kept attribute → {count, by}",
|
||||
has_selection,
|
||||
similar
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The attributes Select Similar Objects (`select.similar`) selects by, in its menu's order, each
|
||||
/// with the Select › Same command it runs; `all` compares the fill and stroke colours, the stroke
|
||||
/// weight, the opacity and the blending mode at once, with no command of its own.
|
||||
pub const SIMILAR: [(&str, Option<&str>); 11] = [
|
||||
("all", None),
|
||||
("fillColor", Some("select.same.fillColor")),
|
||||
("fillAndStroke", Some("select.same.fillAndStroke")),
|
||||
("strokeColor", Some("select.same.strokeColor")),
|
||||
("strokeWeight", Some("select.same.strokeWeight")),
|
||||
("opacity", Some("select.same.opacity")),
|
||||
("blendingMode", Some("select.same.blendingMode")),
|
||||
("appearance", Some("select.same.appearance")),
|
||||
("graphicStyle", Some("select.same.graphicStyle")),
|
||||
("shapeType", Some("select.same.shapeType")),
|
||||
("symbolInstance", Some("select.same.symbolInstance")),
|
||||
];
|
||||
|
||||
fn similar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.similar";
|
||||
let by = match text_param(p, "by", C)? {
|
||||
Some(name) => *SIMILAR.iter().find(|(n, _)| *n == name).ok_or_else(|| {
|
||||
let names: Vec<&str> = SIMILAR.iter().map(|(n, _)| *n).collect();
|
||||
bad(C, format!("unknown `by` \"{name}\": expected one of {}", names.join(", ")))
|
||||
})?,
|
||||
None => *SIMILAR.iter().find(|(n, _)| *n == s.select_similar).unwrap_or(&SIMILAR[0]),
|
||||
};
|
||||
let mut r = match by.1 {
|
||||
Some(same_cmd) => s.execute(same_cmd, &json!({}))?,
|
||||
None => {
|
||||
let tint = s.prefs.select_same_tint_percent;
|
||||
let r = same_fill(s, C, |a, b| {
|
||||
same_paint(&fill_of(a), &fill_of(b), tint)
|
||||
&& same_paint(&stroke_of(a).0, &stroke_of(b).0, tint)
|
||||
&& (stroke_weight(a) - stroke_weight(b)).abs() < 1e-9
|
||||
&& (a.opacity - b.opacity).abs() < 1e-6
|
||||
&& a.blend == b.blend
|
||||
})?;
|
||||
// Select › Reselect repeats `all`, whichever attribute is kept by then.
|
||||
if let Some(last) = s.doc_mut()?.last_selection_cmd.as_mut() {
|
||||
last.1 = json!({ "by": by.0 });
|
||||
}
|
||||
r
|
||||
}
|
||||
};
|
||||
s.select_similar = by.0;
|
||||
// A recorded action or the journal repeats the attribute used, not the one kept by then.
|
||||
s.note_journal("by", json!(by.0));
|
||||
r["by"] = json!(by.0);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Selectable objects: children of visible, unlocked layers (or of the isolation container).
|
||||
/// The objects Select All takes: those of the isolated container, else of every layer (looking
|
||||
/// through sublayers, which are not objects). In isolation mode there are none while the isolated
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters the stroke options they take → {objects: how many objects took the stroke options}",
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters the stroke options they take → {objects: how many objects took the stroke options}",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
@@ -49,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
[],
|
||||
None,
|
||||
"{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, item?: stroke item index|null, ids?|id?, target?: \"object\"|\"contents\"} Stroke panel extras (with nothing selected, all but brush set up the next object drawn)",
|
||||
"{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, item?: stroke item index|null, aligned?: bool (as stroke.set), ids?|id?, target?: \"object\"|\"contents\"} Stroke panel extras (with nothing selected, all but brush set up the next object drawn)",
|
||||
has_doc,
|
||||
stroke_advanced
|
||||
),
|
||||
@@ -303,7 +303,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.profile = pr.clone();
|
||||
}
|
||||
};
|
||||
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, C)?;
|
||||
// The objects counted: with a design's art, those with a row equal to the art's.
|
||||
let ids = edit_items(s, &ids, item, of, C, "Stroke", false, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -372,7 +374,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
|
||||
}
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
|
||||
let label = if up { "Stroke Weight Up" } else { "Stroke Weight Down" };
|
||||
edit_items(s, &ids, item, c, label, false, |n, index| {
|
||||
edit_items(s, &ids, item, None, c, label, false, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -535,7 +537,8 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
arrows_and_profile(st);
|
||||
}
|
||||
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, C)?;
|
||||
edit_items(s, &ids, item, of, C, "Stroke", false, |n, index| {
|
||||
let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) };
|
||||
arrows_and_profile(st);
|
||||
if let Some(b) = &brush {
|
||||
|
||||
@@ -344,6 +344,9 @@ pub struct Session {
|
||||
/// The Global Edit Options Start Global Edit matches with (`select.globalEditOptions`); not
|
||||
/// saved.
|
||||
pub global_edit_options: cmd::globaledit::Options,
|
||||
/// The attribute Select Similar Objects selects by (`select.similar`), one of
|
||||
/// [`cmd::SIMILAR`]; not saved.
|
||||
pub select_similar: &'static str,
|
||||
/// Set while [`Session::tool_typed_text`] passes keyboard input to the active tool
|
||||
/// ([`vectorcraft_tools::ToolContext::typed_from_keyboard`]).
|
||||
keyboard_text: bool,
|
||||
|
||||
@@ -432,7 +432,7 @@ struct World {
|
||||
}
|
||||
|
||||
/// The choices outside the documents that a script's commands can change: the preferences, the
|
||||
/// global edit options, the recent URLs, the chosen gradient stop, freeform point and appearance
|
||||
/// global edit options, the Select Similar Objects attribute, the recent URLs, the chosen gradient stop, freeform point and appearance
|
||||
/// item, the Eyedropper's sample, the envelope defaults and the Caps Lock state.
|
||||
#[derive(Clone)]
|
||||
struct Choices {
|
||||
@@ -440,6 +440,7 @@ struct Choices {
|
||||
caps_lock: bool,
|
||||
eyedropper_sample: Option<Arc<vectorcraft_doc::Node>>,
|
||||
global_edit_options: cmd::globaledit::Options,
|
||||
select_similar: &'static str,
|
||||
recent_urls: Vec<String>,
|
||||
gradient_stop: Option<(usize, cmd::gradient::StopOwner)>,
|
||||
freeform_point: Option<(usize, cmd::gradient::StopOwner)>,
|
||||
@@ -454,6 +455,7 @@ impl Choices {
|
||||
caps_lock: s.caps_lock,
|
||||
eyedropper_sample: s.eyedropper_sample.clone(),
|
||||
global_edit_options: s.global_edit_options.clone(),
|
||||
select_similar: s.select_similar,
|
||||
recent_urls: s.recent_urls.clone(),
|
||||
gradient_stop: s.gradient_stop,
|
||||
freeform_point: s.freeform_point,
|
||||
@@ -467,6 +469,7 @@ impl Choices {
|
||||
s.caps_lock = self.caps_lock;
|
||||
s.eyedropper_sample = self.eyedropper_sample;
|
||||
s.global_edit_options = self.global_edit_options;
|
||||
s.select_similar = self.select_similar;
|
||||
s.recent_urls = self.recent_urls;
|
||||
s.gradient_stop = self.gradient_stop;
|
||||
s.freeform_point = self.freeform_point;
|
||||
@@ -480,6 +483,7 @@ impl Choices {
|
||||
swap(&mut self.caps_lock, &mut s.caps_lock);
|
||||
swap(&mut self.eyedropper_sample, &mut s.eyedropper_sample);
|
||||
swap(&mut self.global_edit_options, &mut s.global_edit_options);
|
||||
swap(&mut self.select_similar, &mut s.select_similar);
|
||||
swap(&mut self.recent_urls, &mut s.recent_urls);
|
||||
swap(&mut self.gradient_stop, &mut s.gradient_stop);
|
||||
swap(&mut self.freeform_point, &mut s.freeform_point);
|
||||
|
||||
@@ -141,6 +141,7 @@ impl Session {
|
||||
link_stamps: Default::default(),
|
||||
link_scan: None,
|
||||
global_edit_options: Default::default(),
|
||||
select_similar: crate::cmd::SIMILAR[0].0,
|
||||
keyboard_text: false,
|
||||
script_key: None,
|
||||
script_engines: crate::script::EnginesKey::new(),
|
||||
|
||||
@@ -1791,3 +1791,350 @@ fn a_recovered_copy_of_an_imported_file_keeps_its_links_folder() {
|
||||
let mut saved: Value = serde_json::from_slice(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("src/photo.png"));
|
||||
}
|
||||
|
||||
/// Image `id` of the active document without pixels in the document, as a linked file that could
|
||||
/// not be read leaves it.
|
||||
fn without_pixels(s: &mut Session, id: NodeId) {
|
||||
let key = image(s, id).key;
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).images.remove(&key);
|
||||
}
|
||||
|
||||
/// The text of `document.serialize`'s reply `r` (an SVG, or the SVGZ it holds).
|
||||
fn svg_text(r: &Value) -> String {
|
||||
match r["text"].as_str() {
|
||||
Some(text) => text.to_string(),
|
||||
None => {
|
||||
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
vectorcraft_svg::text_of(&bytes).unwrap().into_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A caller confined to read roots: every SVG and SVGZ output that would name a linked image's file
|
||||
/// outside them (Images: Link, Preserve Editing Capabilities, or Embed for an image without pixels
|
||||
/// in the document) is refused before a file or folder is written, with an error that names no
|
||||
/// file or folder, and the document keeps the link. Embed with the pixels names no file.
|
||||
#[test]
|
||||
fn confined_svg_output_refuses_a_linked_image_file_outside_the_read_roots() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("svg-image-away");
|
||||
let home = Folder::new("svg-image-home");
|
||||
let photo = away.file("photo.png");
|
||||
write(&photo, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let a = place(&mut s, &photo);
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("assets.add", &json!({"ids": [a.0]})).unwrap();
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let hidden = [away.0.to_string_lossy().into_owned(), home.0.to_string_lossy().into_owned(), "photo.png".to_string()];
|
||||
let refused = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let e = confine(roots.as_ref(), || s.execute(cmd, &p)).expect_err(cmd).to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{cmd} {p}: {e}");
|
||||
for h in &hidden {
|
||||
assert!(!e.contains(h.as_str()), "{cmd} {p} names {h}: {e}");
|
||||
}
|
||||
};
|
||||
let out = home.file("out");
|
||||
// Every SVG output, with the options `o` given flat.
|
||||
let every = |s: &mut Session, o: &Value| {
|
||||
for format in ["svg", "svgz"] {
|
||||
let file = |name: &str| home.file(&format!("{name}.{format}"));
|
||||
let with = |p: Value| {
|
||||
let mut p = p;
|
||||
if let (Some(m), Some(o)) = (p.as_object_mut(), o.as_object()) {
|
||||
m.extend(o.clone());
|
||||
}
|
||||
p
|
||||
};
|
||||
refused(s, "document.serialize", with(json!({"format": format})));
|
||||
refused(s, "document.export", with(json!({"format": format})));
|
||||
refused(s, "document.export", with(json!({"format": format, "path": file("export")})));
|
||||
refused(s, "document.export", with(json!({"format": format, "selectedOnly": true})));
|
||||
refused(s, "document.exportSelection", with(json!({"format": format, "path": file("selection")})));
|
||||
refused(s, "document.save", with(json!({"path": file("save")})));
|
||||
refused(s, "file.saveAs", with(json!({"format": format, "path": file("save-as")})));
|
||||
refused(s, "file.saveCopy", with(json!({"format": format, "path": file("copy")})));
|
||||
let row = with(json!({"format": format}));
|
||||
refused(s, "document.exportForScreens", json!({"folder": out, "formats": [{"format": "png"}, row]}));
|
||||
refused(s, "document.exportForScreens", json!({"zip": true, "formats": [row]}));
|
||||
refused(s, "assets.export", json!({"folder": out, "formats": [row]}));
|
||||
}
|
||||
};
|
||||
every(&mut s, &json!({"images": "link"}));
|
||||
every(&mut s, &json!({"preserveEditing": true}));
|
||||
refused(&mut s, "document.serialize", json!({"format": "svg", "svg": {"images": "link"}}));
|
||||
// Embedded pixels name no file.
|
||||
for format in ["svg", "svgz"] {
|
||||
let r = confine(roots.as_ref(), || s.execute("document.serialize", &json!({"format": format}))).unwrap();
|
||||
let text = svg_text(&r);
|
||||
assert!(text.contains("data:image/png;base64,") && !text.contains(&hidden[0]), "{format}: {text}");
|
||||
}
|
||||
// Without its pixels, an embedded image names its file.
|
||||
without_pixels(&mut s, a);
|
||||
let r = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
assert!(svg_text(&r).contains(&format!("xlink:href=\"{photo}\"")), "{r}");
|
||||
every(&mut s, &json!({}));
|
||||
let written: Vec<_> = std::fs::read_dir(&home.0).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert!(written.is_empty(), "{written:?}");
|
||||
assert_eq!(image(&s, a).link.map(|l| l.path), Some(photo));
|
||||
}
|
||||
|
||||
/// A confined caller's SVG leaves out hidden layers and objects, unless hidden layers are kept (as
|
||||
/// a save keeps them by default), and template layers always: a linked image file outside the read
|
||||
/// roots in art the file leaves out does not refuse it, and in art the file writes does.
|
||||
#[test]
|
||||
fn confined_svg_output_checks_only_the_linked_images_it_writes() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("svg-image-unwritten-away");
|
||||
let home = Folder::new("svg-image-unwritten-home");
|
||||
let photo = away.file("photo.png");
|
||||
write(&photo, &png(60, 30, RED));
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
// A visible rectangle, and the linked image in a layer of its own.
|
||||
let setup = || {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
let layer = NodeId(s.execute("layer.new", &json!({"top": true})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("layer.setCurrent", &json!({"id": layer.0})).unwrap();
|
||||
let a = place(&mut s, &photo);
|
||||
assert!(s.doc().unwrap().doc.node(layer).unwrap().children().unwrap().iter().any(|c| c.id == a), "the image is in the new layer");
|
||||
(s, layer, a)
|
||||
};
|
||||
let written = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let r = confine(roots.as_ref(), || s.execute(cmd, &p)).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
if cmd == "document.serialize" {
|
||||
let text = svg_text(&r);
|
||||
assert!(!text.contains(&photo) && !text.contains("photo.png"), "{cmd} {p}: {text}");
|
||||
}
|
||||
};
|
||||
let refused = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let e = confine(roots.as_ref(), || s.execute(cmd, &p)).expect_err(cmd).to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{cmd} {p}: {e}");
|
||||
};
|
||||
// Exports leave hidden art out; hidden layers kept, and a save, write it.
|
||||
let hidden_art = |s: &mut Session, name: &str| {
|
||||
for format in ["svg", "svgz"] {
|
||||
written(s, "document.serialize", json!({"format": format, "images": "link"}));
|
||||
written(s, "document.export", json!({"format": format, "images": "link", "path": home.file(&format!("{name}.{format}"))}));
|
||||
written(s, "document.export", json!({"format": format, "images": "link", "svg": {"hiddenLayers": false}}));
|
||||
refused(s, "document.serialize", json!({"format": format, "images": "link", "hiddenLayers": true}));
|
||||
refused(s, "document.export", json!({"format": format, "svg": {"images": "link", "hiddenLayers": true}}));
|
||||
refused(s, "document.save", json!({"path": home.file(&format!("{name}-save.{format}")), "images": "link"}));
|
||||
refused(s, "file.saveCopy", json!({"format": format, "path": home.file(&format!("{name}-copy.{format}")), "images": "link"}));
|
||||
}
|
||||
// A save told to leave hidden layers out leaves the image out too.
|
||||
let copy = home.file(&format!("{name}-copy.svg"));
|
||||
written(s, "file.saveCopy", json!({"format": "svg", "path": copy, "images": "link", "hiddenLayers": false}));
|
||||
};
|
||||
let (mut s, layer, _) = setup();
|
||||
s.execute("layer.setProps", &json!({"ids": [layer.0], "visible": false})).unwrap();
|
||||
hidden_art(&mut s, "hidden-layer");
|
||||
let (mut s, _, a) = setup();
|
||||
s.execute("object.hide", &json!({"ids": [a.0]})).unwrap();
|
||||
hidden_art(&mut s, "hidden-object");
|
||||
// A template layer is never written, not even with hidden layers kept.
|
||||
let (mut s, layer, _) = setup();
|
||||
s.execute("layer.setProps", &json!({"ids": [layer.0], "template": true})).unwrap();
|
||||
for format in ["svg", "svgz"] {
|
||||
written(&mut s, "document.serialize", json!({"format": format, "images": "link"}));
|
||||
written(&mut s, "document.serialize", json!({"format": format, "images": "link", "hiddenLayers": true}));
|
||||
written(&mut s, "file.saveCopy", json!({"format": format, "path": home.file(&format!("template-copy.{format}")), "images": "link"}));
|
||||
}
|
||||
// Back to an ordinary layer, the image is written and refuses the file.
|
||||
s.execute("layer.setProps", &json!({"ids": [layer.0], "template": false})).unwrap();
|
||||
refused(&mut s, "document.serialize", json!({"format": "svg", "images": "link"}));
|
||||
// The layer of symbol editing mode is never written: editing a symbol no art uses leaves its
|
||||
// image out of the file.
|
||||
let (mut s, _, a) = setup();
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let instance = s.execute("symbol.new", &json!({"name": "Away"})).unwrap()["id"].clone();
|
||||
s.execute("edit.clear", &json!({"ids": [instance]})).unwrap();
|
||||
s.execute("symbol.edit", &json!({"name": "Away"})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.symbol_edit.is_some(), "in symbol editing mode");
|
||||
for format in ["svg", "svgz"] {
|
||||
written(&mut s, "document.serialize", json!({"format": format, "images": "link", "hiddenLayers": true}));
|
||||
written(&mut s, "file.saveCopy", json!({"format": format, "path": home.file(&format!("symbol-edit-copy.{format}")), "images": "link"}));
|
||||
}
|
||||
s.execute("symbol.exitEditing", &json!({})).unwrap();
|
||||
s.execute("symbol.place", &json!({"name": "Away"})).unwrap();
|
||||
refused(&mut s, "document.serialize", json!({"format": "svg", "images": "link"}));
|
||||
}
|
||||
|
||||
/// A confined caller's SVG of a grayscale linked image outside the read roots that a solid fill
|
||||
/// colorizes: the file gets the colorized pixels, or leaves the image out without pixels, and names
|
||||
/// no file with Link or Embed. With Preserve Editing Capabilities the document the SVG carries
|
||||
/// keeps the link, and the SVG is refused.
|
||||
#[test]
|
||||
fn confined_svg_output_writes_a_colorized_gray_image_without_its_file() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("svg-image-colorized-away");
|
||||
let home = Folder::new("svg-image-colorized-home");
|
||||
let photo = away.file("gray.png");
|
||||
let mut bytes = vec![];
|
||||
image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap().write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
write(&photo, &bytes);
|
||||
let mut s = session();
|
||||
let a = place(&mut s, &photo);
|
||||
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap();
|
||||
let colorized = s.doc().unwrap().doc.node(a).unwrap().extras().colorize.clone();
|
||||
assert!(matches!(colorized.as_deref(), Some(vectorcraft_color::Paint::Solid { .. })), "{colorized:?}");
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let written = |s: &mut Session, cmd: &str, p: Value| {
|
||||
let r = confine(roots.as_ref(), || s.execute(cmd, &p)).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
if cmd == "document.serialize" {
|
||||
let text = svg_text(&r);
|
||||
assert!(!text.contains(&away.0.to_string_lossy().into_owned()) && !text.contains("href=\"gray.png"), "{cmd} {p}: {text}");
|
||||
}
|
||||
};
|
||||
let refused = |s: &mut Session, p: Value| {
|
||||
let e = confine(roots.as_ref(), || s.execute("document.serialize", &p)).expect_err("preserveEditing").to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{p}: {e}");
|
||||
};
|
||||
let every = |s: &mut Session, name: &str| {
|
||||
for format in ["svg", "svgz"] {
|
||||
for images in ["link", "embed"] {
|
||||
written(s, "document.serialize", json!({"format": format, "images": images}));
|
||||
written(s, "document.export", json!({"format": format, "images": images, "path": home.file(&format!("{name}-{images}.{format}"))}));
|
||||
let copy = home.file(&format!("{name}-{images}-copy.{format}"));
|
||||
written(s, "file.saveCopy", json!({"format": format, "images": images, "path": copy}));
|
||||
}
|
||||
refused(s, json!({"format": format, "preserveEditing": true}));
|
||||
}
|
||||
};
|
||||
let text = svg_text(&confine(roots.as_ref(), || s.execute("document.serialize", &json!({"format": "svg"}))).unwrap());
|
||||
assert!(text.contains("data:image/png;base64,"), "{text}");
|
||||
every(&mut s, "pixels");
|
||||
without_pixels(&mut s, a);
|
||||
every(&mut s, "missing");
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let svg = confine(roots.as_ref(), || s.execute("clipboard.exportSvg", &json!({}))).unwrap()["svg"].as_str().unwrap().to_string();
|
||||
assert!(!svg.contains(&photo), "{svg}");
|
||||
assert_eq!(image(&s, a).link.map(|l| l.path), Some(photo));
|
||||
}
|
||||
|
||||
/// A confined caller's SVG names each linked image file it may read, as stored: a file inside the
|
||||
/// read roots, a path relative to the document's folder that leads inside them, and a URL. The
|
||||
/// selected objects alone are exported when the image outside them is not selected, and a caller
|
||||
/// without roots gets a file outside them named too.
|
||||
#[test]
|
||||
fn svg_output_names_linked_image_files_a_caller_may_read() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("svg-image-kept-away");
|
||||
let home = Folder::new("svg-image-kept-home");
|
||||
let inside = home.file("photo.png");
|
||||
let (nested, web) = (home.file("lib/photo.png"), home.file("web.png"));
|
||||
for f in [&inside, &nested, &web] {
|
||||
write(f, &png(60, 30, RED));
|
||||
}
|
||||
let url = "https://example.com/images/photo.png";
|
||||
let mut s = session();
|
||||
place(&mut s, &inside);
|
||||
place(&mut s, &nested);
|
||||
place(&mut s, &web);
|
||||
save(&mut s, &home.file("doc.vectorcraft"));
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
doc.update_links_where(|l| l.path == nested, |l| l.path = "lib/photo.png".into());
|
||||
doc.update_links_where(|l| l.path == web, |l| l.path = url.into());
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let link = json!({"format": "svg", "images": "link"});
|
||||
for format in ["svg", "svgz"] {
|
||||
let p = json!({"format": format, "images": "link"});
|
||||
let text = svg_text(&confine(roots.as_ref(), || s.execute("document.serialize", &p)).unwrap());
|
||||
for file in [inside.as_str(), "lib/photo.png", url] {
|
||||
assert!(text.contains(&format!("xlink:href=\"{file}\"")), "{format} {file}: {text}");
|
||||
}
|
||||
}
|
||||
// An image outside them in a symbol no art uses is not written.
|
||||
let unused = away.file("unused.png");
|
||||
write(&unused, &png(60, 30, BLUE));
|
||||
let u = place(&mut s, &unused);
|
||||
s.execute("select.set", &json!({"ids": [u.0]})).unwrap();
|
||||
let instance = s.execute("symbol.new", &json!({"name": "Away"})).unwrap()["id"].clone();
|
||||
s.execute("edit.clear", &json!({"ids": [instance]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.symbols.len(), 1);
|
||||
for format in ["svg", "svgz"] {
|
||||
let p = json!({"format": format, "images": "link"});
|
||||
let text = svg_text(&confine(roots.as_ref(), || s.execute("document.serialize", &p)).unwrap());
|
||||
assert!(!text.contains(&unused), "{format}: {text}");
|
||||
}
|
||||
let outside = away.file("photo.png");
|
||||
write(&outside, &png(60, 30, BLUE));
|
||||
place(&mut s, &outside);
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r["id"].clone()]})).unwrap();
|
||||
let p = json!({"format": "svg", "images": "link"});
|
||||
let r = confine(roots.as_ref(), || s.execute("document.exportSelection", &p)).unwrap();
|
||||
assert!(!r.to_string().contains(&outside), "{r}");
|
||||
let p = json!({"format": "svg", "images": "link", "selectedOnly": true});
|
||||
for cmd in ["document.serialize", "document.export"] {
|
||||
let r = confine(roots.as_ref(), || s.execute(cmd, &p)).unwrap();
|
||||
assert!(!r.to_string().contains(&outside), "{cmd}: {r}");
|
||||
}
|
||||
// The file of an artboard the image is not over.
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap();
|
||||
let p = json!({"format": "svg", "images": "link", "range": "2"});
|
||||
let r = confine(roots.as_ref(), || s.execute("document.serialize", &p)).unwrap();
|
||||
assert!(!r.to_string().contains(&outside), "{r}");
|
||||
let p = json!({"folder": home.file("screens"), "formats": [{"format": "svg", "images": "link"}], "range": "2"});
|
||||
confine(roots.as_ref(), || s.execute("document.exportForScreens", &p)).unwrap();
|
||||
// Without roots, a file outside them is named as stored.
|
||||
let text = svg_text(&s.execute("document.serialize", &link).unwrap());
|
||||
assert!(text.contains(&format!("xlink:href=\"{outside}\"")), "{text}");
|
||||
}
|
||||
|
||||
/// The clipboard's SVG for a confined caller: embedded pixels name no file; an image whose pixels
|
||||
/// the clipboard doesn't hold makes `clipboard.exportSvg` refuse with an error that names no file
|
||||
/// or folder, and the system clipboard gets no SVG. A caller without roots gets the file named.
|
||||
#[test]
|
||||
fn confined_clipboard_svg_refuses_a_linked_image_file_outside_the_read_roots() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let away = Folder::new("clip-image-away");
|
||||
let home = Folder::new("clip-image-home");
|
||||
let photo = away.file("photo.png");
|
||||
write(&photo, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let a = place(&mut s, &photo);
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let r = confine(roots.as_ref(), || s.execute("clipboard.exportSvg", &json!({}))).unwrap();
|
||||
let svg = r["svg"].as_str().unwrap();
|
||||
assert!(svg.contains("data:image/png;base64,") && !svg.contains(&photo), "{svg}");
|
||||
|
||||
without_pixels(&mut s, a);
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let e = confine(roots.as_ref(), || s.execute("clipboard.exportSvg", &json!({}))).unwrap_err().to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{e}");
|
||||
assert!(!e.contains(&away.0.to_string_lossy().into_owned()) && !e.contains("photo.png"), "{e}");
|
||||
assert!(confine(roots.as_ref(), || s.clipboard_svg()).is_none());
|
||||
let svg = s.execute("clipboard.exportSvg", &json!({})).unwrap()["svg"].as_str().unwrap().to_string();
|
||||
assert!(svg.contains(&format!("xlink:href=\"{photo}\"")), "{svg}");
|
||||
}
|
||||
|
||||
/// A confined caller's SVG with Preserve Editing Capabilities is refused while the stored brush
|
||||
/// library holds an entry that does not read as a brush, or is not a list: the native document
|
||||
/// carries it as it is, and its links cannot be checked. Without Preserve Editing the SVG is written.
|
||||
#[test]
|
||||
fn confined_preserve_editing_svg_refuses_a_brush_library_entry_it_cannot_read() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let home = Folder::new("svg-brush-entry-home");
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
let roots = AutomationRoots::new(&[home.0.as_path()], &[home.0.as_path()]).unwrap();
|
||||
let preserve = json!({"format": "svg", "preserveEditing": true});
|
||||
confine(roots.as_ref(), || s.execute("document.serialize", &preserve)).unwrap();
|
||||
for stored in [json!(["not a brush"]), json!({"link": "/away/photo.png"})] {
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
doc.unknown.insert(vectorcraft_brush::DOC_KEY.into(), stored.clone());
|
||||
let e = confine(roots.as_ref(), || s.execute("document.serialize", &preserve)).unwrap_err().to_string();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{stored}: {e}");
|
||||
confine(roots.as_ref(), || s.execute("document.serialize", &json!({"format": "svg"}))).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2789,6 +2789,73 @@ fn select_same_fill_keeps_live_paint_faces_to_their_group() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Select Similar Objects (`select.similar`) runs the Select > Same command of the attribute it is
|
||||
/// given and keeps that attribute for its next run without one; `all`, the attribute until one is
|
||||
/// given, matches the fill and stroke colours, the stroke weight, the opacity and the blending mode
|
||||
/// together. An unknown attribute is an error that keeps the selection and the kept attribute.
|
||||
#[test]
|
||||
fn select_similar_selects_by_the_kept_attribute() {
|
||||
let mut s = session();
|
||||
let [a, b, faded, heavy, multiplied, blue] = [0.0, 20.0, 40.0, 60.0, 80.0, 100.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0));
|
||||
for id in [a, b, faded, heavy, multiplied] {
|
||||
fill(&mut s, id, "#ff0000");
|
||||
}
|
||||
fill(&mut s, blue, "#0000ff");
|
||||
s.execute("transparency.set", &json!({"ids": [faded.0], "opacity": 50, "item": null})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [multiplied.0], "blend": "multiply", "item": null})).unwrap();
|
||||
sel(&mut s, &[heavy]);
|
||||
s.execute("stroke.set", &json!({"weight": 3})).unwrap();
|
||||
let similar = |s: &mut Session, p: Value| {
|
||||
sel(s, &[a]);
|
||||
let r = s.execute("select.similar", &p).unwrap();
|
||||
let mut v = selected(s);
|
||||
v.sort();
|
||||
assert_eq!(r["count"], v.len(), "{p}");
|
||||
(r["by"].as_str().unwrap().to_string(), v)
|
||||
};
|
||||
assert_eq!(s.select_similar, "all");
|
||||
assert_eq!(similar(&mut s, json!({})), ("all".into(), vec![a, b]));
|
||||
assert_eq!(similar(&mut s, json!({"by": "fillColor"})), ("fillColor".into(), vec![a, b, faded, heavy, multiplied]));
|
||||
assert_eq!(similar(&mut s, json!({})), ("fillColor".into(), vec![a, b, faded, heavy, multiplied]));
|
||||
// The journal, and an action recorded around it, keep the attribute used.
|
||||
let last = s.journal.last().unwrap();
|
||||
assert_eq!((last.0.as_str(), &last.1), ("select.similar", &json!({"by": "fillColor"})));
|
||||
assert_eq!(similar(&mut s, json!({"by": "opacity"})), ("opacity".into(), vec![a, b, heavy, multiplied, blue]));
|
||||
assert_eq!(similar(&mut s, json!({"by": "blendingMode"})), ("blendingMode".into(), vec![a, b, faded, heavy, blue]));
|
||||
assert_eq!(similar(&mut s, json!({"by": "strokeWeight"})), ("strokeWeight".into(), vec![a, b, faded, multiplied, blue]));
|
||||
assert_eq!(similar(&mut s, json!({"by": "all"})).1, vec![a, b]);
|
||||
// Select > Reselect repeats `all`, whichever attribute is kept by then.
|
||||
s.select_similar = "fillColor";
|
||||
s.execute("select.reselect", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a, b]);
|
||||
// Every attribute names a command that runs. A path with no graphic style has none to match,
|
||||
// which is an error that keeps the attribute as it was.
|
||||
for (by, _) in crate::cmd::SIMILAR {
|
||||
sel(&mut s, &[a]);
|
||||
let was = s.select_similar;
|
||||
let r = s.execute("select.similar", &json!({ "by": by }));
|
||||
if by == "graphicStyle" {
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
assert_eq!(s.select_similar, was);
|
||||
} else {
|
||||
r.unwrap_or_else(|e| panic!("{by}: {e:?}"));
|
||||
assert_eq!(s.select_similar, by);
|
||||
}
|
||||
}
|
||||
// An unknown or malformed attribute changes nothing.
|
||||
s.select_similar = "opacity";
|
||||
for bad in [json!("color"), json!(""), json!("FillColor"), json!(3), json!(true), json!(["all"])] {
|
||||
sel(&mut s, &[a]);
|
||||
let r = s.execute("select.similar", &json!({ "by": bad }));
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}");
|
||||
assert_eq!((selected(&s), s.select_similar), (vec![a], "opacity"), "{bad}");
|
||||
}
|
||||
// With nothing selected it does not run, and keeps the attribute.
|
||||
sel(&mut s, &[]);
|
||||
assert!(s.execute("select.similar", &json!({"by": "fillColor"})).is_err());
|
||||
assert_eq!(s.select_similar, "opacity");
|
||||
}
|
||||
|
||||
/// Select by Color needs no object to start from: it matches fills and strokes as
|
||||
/// `document.inspect` reports them, `#rrggbb`.
|
||||
#[test]
|
||||
|
||||
@@ -52,6 +52,39 @@ fn invert_and_complement_recolour_the_active_item() {
|
||||
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#ff0000".into(), "#0000ff".into()));
|
||||
}
|
||||
|
||||
/// With a mixed selection, Invert on the Appearance panel's active row remembers the color of the row it recolors in the
|
||||
/// first object, the row equal to the active one, which need not have the active row's index.
|
||||
#[test]
|
||||
fn invert_of_the_active_row_remembers_the_equal_row_it_recolors() {
|
||||
let mut s = session();
|
||||
let rect = |s: &mut Session, x: f64| {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
};
|
||||
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 100.0));
|
||||
// `a` paints first: [red Fill, Stroke, blue Fill]. `b`, selected first: [green Fill, red Fill, Stroke].
|
||||
let look = Appearance::basic(Paint::solid(Color::from_hex("#ff0000").unwrap()), Paint::solid(Color::BLACK), 1.0);
|
||||
let (mut a_look, mut b_look) = (look.clone(), look.clone());
|
||||
let (mut green, mut blue) = (look.items[0].clone(), look.items[0].clone());
|
||||
*green.paint_mut() = Paint::solid(Color::from_hex("#00ff00").unwrap());
|
||||
*blue.paint_mut() = Paint::solid(Color::from_hex("#0000ff").unwrap());
|
||||
b_look.items.insert(0, green);
|
||||
a_look.items.push(blue);
|
||||
s.edit("Looks", |d, _| {
|
||||
d.node_mut(a).unwrap().appearance = vectorcraft_doc::Shared::new(a_look.clone());
|
||||
d.node_mut(b).unwrap().appearance = vectorcraft_doc::Shared::new(b_look.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap();
|
||||
s.execute("appearance.setActiveItem", &json!({"index": 1})).unwrap();
|
||||
s.last_solid = Color::from_hex("#808080").unwrap();
|
||||
s.execute("paint.invert", &json!({})).unwrap();
|
||||
let hexes = (item_hex(&s, a, 0), item_hex(&s, a, 2), item_hex(&s, b, 0), item_hex(&s, b, 1));
|
||||
assert_eq!(hexes, ("#00ffff".into(), "#0000ff".into(), "#00ff00".into(), "#00ffff".into()));
|
||||
assert_eq!(s.last_solid.to_hex(), "#00ffff", "the last color is the inverted red, not row 1 of the first object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mixed_proxy_compares_the_active_item() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -290,6 +290,11 @@ fn a_failed_run_puts_back_the_choices_its_commands_made() {
|
||||
run(&mut s, source).unwrap();
|
||||
assert_eq!(s.global_edit_options, before, "the failed command's choice went back");
|
||||
|
||||
let source = "vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10}); \
|
||||
if (vc.run('select.similar', {by: 'opacity'}).by !== 'opacity') throw new Error('not run'); throw new Error('x');";
|
||||
assert!(matches!(run(&mut s, source), Err(e) if !format!("{e:?}").contains("not run")));
|
||||
assert_eq!(s.select_similar, "all", "the Select Similar Objects attribute went back");
|
||||
|
||||
assert!(!s.caps_lock);
|
||||
assert!(run(&mut s, "vc.run('view.capsLock', {on: true}); throw new Error('x');").is_err());
|
||||
assert!(!s.caps_lock, "the Caps Lock state went back");
|
||||
|
||||
@@ -4601,6 +4601,10 @@ proptest! {
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
|
||||
survive("mutated .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
survive("mutated .ai symbols, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
|
||||
// The same symbols as files of versions 10 to 13 place them, by a `TransformMatrix`.
|
||||
let older = mutate_text(&older_symbols_text(), cut, &edits);
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&older)].concat();
|
||||
survive("mutated older .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
// A symbol library: the same symbols and list, no layers.
|
||||
let library = mutate_text(&symbol_library_text(), cut, &edits);
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&library)].concat();
|
||||
@@ -4855,6 +4859,53 @@ fn the_symbols_fuzz_data_opens_live() {
|
||||
assert_eq!(instances, 3);
|
||||
}
|
||||
|
||||
/// [`symbols_text`] with its instances placed by a `TransformMatrix`, as files of versions 10 to
|
||||
/// 13 write them. Art space's origin is at canvas (8091, 8242), from the template box: on the page
|
||||
/// the matrix takes the symbol's art to the canvas (y down), and in S2's art to S2's space.
|
||||
fn older_symbols_text() -> String {
|
||||
let mut text = symbols_text();
|
||||
for (newer, older) in [
|
||||
("1 0 0 -1 -8081 8237 /RTransformMatrix", "1 0 0 1 10 5 /TransformMatrix"),
|
||||
("1 0 0 -1 20 50 /RTransformMatrix", "1 0 0 1 8111 8192 /TransformMatrix"),
|
||||
("2 0 0 -2 100 50 /RTransformMatrix", "2 0 0 2 8191 8192 /TransformMatrix"),
|
||||
("1 0 0 -1 20 20 /RTransformMatrix", "1 0 0 1 8111 8222 /TransformMatrix"),
|
||||
] {
|
||||
assert!(text.contains(newer), "{newer}");
|
||||
text = text.replace(newer, older);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// The older data [`ai_symbols_never_panic`] damages, as it is, opens with its instances live and
|
||||
/// placed as [`symbols_text`] places them, on the page and in S2's art.
|
||||
#[test]
|
||||
fn the_older_symbols_fuzz_data_opens_live() {
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
fn xfs(n: &Node, out: &mut Vec<[f64; 6]>) {
|
||||
match &n.kind {
|
||||
NodeKind::SymbolInstance { xf, .. } => out.push(xf.as_coeffs()),
|
||||
_ => n.children().into_iter().flatten().for_each(|c| xfs(c, out)),
|
||||
}
|
||||
}
|
||||
let open = |text: &str| {
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat();
|
||||
let (doc, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0);
|
||||
assert!(layered, "{warnings:?}");
|
||||
let (mut page, mut nested) = (vec![], vec![]);
|
||||
doc.layers.iter().for_each(|l| xfs(l, &mut page));
|
||||
let s2 = doc.symbols.iter().find(|s| s.name == "S2").expect("S2");
|
||||
xfs(&s2.art, &mut nested);
|
||||
(page, nested)
|
||||
};
|
||||
let text = older_symbols_text();
|
||||
assert!(!text.contains("/RTransformMatrix"));
|
||||
let (older, newer) = (open(&text), open(&symbols_text()));
|
||||
assert_eq!((older.0.len(), older.1.len()), (3, 1));
|
||||
let same = |a: &[[f64; 6]], b: &[[f64; 6]]| a.len() == b.len() && a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() < 1e-9);
|
||||
assert!(same(&older.0, &newer.0), "{older:?} {newer:?}");
|
||||
assert!(same(&older.1, &newer.1), "{older:?} {newer:?}");
|
||||
}
|
||||
|
||||
/// Symbols the reader must stop at: sections that don't end, references and matrices it can't
|
||||
/// use, definitions nested deeper than it reads, and symbols that hold each other.
|
||||
#[test]
|
||||
|
||||
@@ -10457,7 +10457,7 @@ fn an_envelope_is_written_in_the_order_the_reader_reads_it() {
|
||||
/// Envelopes the reader would open in another shape, or not at all, are written as their distorted
|
||||
/// art with a warning, and open as that art: an envelope of type, one in a turned frame, a warp drawn
|
||||
/// without its style's net, a mesh the reader would lay out the other way round, a top object
|
||||
/// whose sides don't pair up, a Fidelity that isn't a whole number and bounds margins kept with
|
||||
/// with a side of more segments than the grid has columns, a Fidelity that isn't a whole number and bounds margins kept with
|
||||
/// Distort Appearance off. Other live objects keep their own warning.
|
||||
#[test]
|
||||
fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() {
|
||||
@@ -10494,15 +10494,10 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() {
|
||||
**kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: transpose(points, 3, 2), handles: vec![] };
|
||||
}
|
||||
});
|
||||
let mut uneven = kurbo::BezPath::new();
|
||||
uneven.move_to((30.0, 10.0));
|
||||
for p in [(100.0, 0.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] {
|
||||
uneven.line_to(p);
|
||||
}
|
||||
uneven.close_path();
|
||||
let lopsided = change(&|k| {
|
||||
// A top side of 51 segments: more grid columns than the reader reads.
|
||||
let crowded = change(&|k| {
|
||||
if let NodeKind::Envelope { kind, .. } = k {
|
||||
**kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven), corners: None };
|
||||
**kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven_top_object(51)), corners: None };
|
||||
}
|
||||
});
|
||||
let fractional = change(&|k| {
|
||||
@@ -10521,7 +10516,7 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() {
|
||||
("turned", turned),
|
||||
("old warp", old_warp),
|
||||
("sideways mesh", sideways),
|
||||
("lopsided top object", lopsided),
|
||||
("top object past the grid's limit", crowded),
|
||||
("fractional fidelity", fractional),
|
||||
("margin kept", margin_kept),
|
||||
];
|
||||
@@ -10542,6 +10537,131 @@ fn transpose(points: Vec<kurbo::Point>, rows: usize, cols: usize) -> Vec<kurbo::
|
||||
(0..=cols).flat_map(|c| (0..=rows).map(move |r| (r, c))).map(|(r, c)| points[r * (cols + 1) + c]).collect()
|
||||
}
|
||||
|
||||
/// A top object over (30, 10)-(170, 90) whose top side is `top` straight segments, falling and
|
||||
/// rising by 10 pt between its anchors, and whose other sides are one straight segment each.
|
||||
fn uneven_top_object(top: usize) -> kurbo::BezPath {
|
||||
let mut path = kurbo::BezPath::new();
|
||||
path.move_to((30.0, 10.0));
|
||||
for k in 1..top {
|
||||
path.line_to((30.0 + 140.0 * k as f64 / top as f64, if k % 2 == 1 { 20.0 } else { 10.0 }));
|
||||
}
|
||||
for p in [(170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] {
|
||||
path.line_to(p);
|
||||
}
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
||||
/// Do the patches of top objects `a` and `b` lie within 0.001 pt of each other at every place of a
|
||||
/// 32 × 32 grid?
|
||||
fn same_top_object_patch(a: &vectorcraft_doc::live::EnvelopeKind, b: &vectorcraft_doc::live::EnvelopeKind) -> bool {
|
||||
use vectorcraft_doc::live::{Coons, EnvelopeKind};
|
||||
let (EnvelopeKind::TopObject { path, corners }, EnvelopeKind::TopObject { path: p, corners: c }) = (a, b) else { return false };
|
||||
let (Some(a), Some(b)) = (Coons::from_top_object(path, *corners), Coons::from_top_object(p, *c)) else { return false };
|
||||
let at = |i: u32| f64::from(i) / 32.0;
|
||||
(0..=32).all(|j| (0..=32).all(|i| a.eval(at(i), at(j)).distance(b.eval(at(i), at(j))) < 1e-3))
|
||||
}
|
||||
|
||||
/// A top object whose top and bottom sides have different numbers of segments is written live, its
|
||||
/// side with fewer segments split into as many as the side across from it has, with Edit Contents off
|
||||
/// and on: two straight segments over one straight one, three over one, and two over one curve.
|
||||
/// VectorCraft's reader opens it as a top object with its corners on the same four points, the same
|
||||
/// patch, contents, Fidelity, Edit Contents and Envelope Options, and saved again it opens the same.
|
||||
#[test]
|
||||
fn a_top_object_with_uneven_sides_is_read_back_live() {
|
||||
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions};
|
||||
let mut curved = kurbo::BezPath::new();
|
||||
curved.move_to((30.0, 10.0));
|
||||
curved.line_to((100.0, 0.0));
|
||||
curved.line_to((170.0, 10.0));
|
||||
curved.line_to((170.0, 90.0));
|
||||
curved.curve_to((130.0, 105.0), (70.0, 105.0), (30.0, 90.0));
|
||||
curved.close_path();
|
||||
let shapes = [("two over one", uneven_top_object(2), 6), ("three over one", uneven_top_object(3), 8), ("two over a curve", curved, 6)];
|
||||
for (name, path, anchors) in shapes {
|
||||
let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None };
|
||||
for editing in [false, true] {
|
||||
let what = format!("{name}, editing {editing}");
|
||||
let doc = envelope_doc(kind.clone(), 50.0, editing, EnvelopeOptions::NEW);
|
||||
let text = program(&doc);
|
||||
assert!(text.contains("AIDeformOptionsKey") && text.contains("%_3 /Int (AIDeformStyle) ,\n"), "{what}: {text}");
|
||||
let (back, warnings, notes) = envelope_back(&doc);
|
||||
assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}: {warnings:?}");
|
||||
assert!(!notes.iter().any(|w| w == crate::import::ENVELOPE_DRAWN), "{what}: {notes:?}");
|
||||
let NodeKind::Envelope { content, kind: got, fidelity, editing: e, options, frame } = &back.kind else { panic!("{what}: {back:#?}") };
|
||||
assert_eq!((*fidelity, *e, *options, **frame), (50.0, editing, EnvelopeOptions::NEW, Affine::IDENTITY), "{what}");
|
||||
let EnvelopeKind::TopObject { path: outline, corners: Some(corners) } = &**got else { panic!("{what}: {got:?}") };
|
||||
let sp = &outline.subpaths[0];
|
||||
assert_eq!(sp.anchors.len(), anchors, "{what}: {outline:?}");
|
||||
let points = corners.map(|i| sp.anchors[i as usize].p);
|
||||
let want = [(30.0, 10.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)].map(kurbo::Point::from);
|
||||
assert!(points.iter().zip(want).all(|(a, b)| a.distance(b) < 1e-6), "{what}: {points:?}");
|
||||
assert!(same_top_object_patch(&kind, got), "{what}: {got:?}");
|
||||
let colors: Vec<Option<Color>> = content.iter().map(|c| c.appearance.fill_paint().color()).collect();
|
||||
assert_eq!(colors, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{what}");
|
||||
let mut again = Document::new(200.0, 100.0);
|
||||
let layer = again.layers[0].id;
|
||||
again.insert(Some(layer), usize::MAX, back.clone()).unwrap();
|
||||
let (twice, warnings, notes) = envelope_back(&again);
|
||||
assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}, saved again: {warnings:?}");
|
||||
let NodeKind::Envelope { kind: twice, .. } = &twice.kind else { panic!("{what}, saved again: {twice:#?} {notes:?}") };
|
||||
assert!(same_top_object_patch(&kind, twice), "{what}, saved again: {twice:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A top object with a top side of 50 segments over a bottom side of one is written live with a grid
|
||||
/// of 50 columns, the most the reader reads.
|
||||
#[test]
|
||||
fn a_top_object_split_to_the_grids_limit_is_read_back_live() {
|
||||
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions};
|
||||
let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven_top_object(50)), corners: None };
|
||||
let doc = envelope_doc(kind.clone(), 50.0, false, EnvelopeOptions::NEW);
|
||||
let text = program(&doc);
|
||||
assert!(text.contains("[50 1] /Size X#"), "{text}");
|
||||
let (back, warnings, notes) = envelope_back(&doc);
|
||||
assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}");
|
||||
let NodeKind::Envelope { kind: got, .. } = &back.kind else { panic!("{back:#?} {notes:?}") };
|
||||
assert!(same_top_object_patch(&kind, got), "{got:?}");
|
||||
}
|
||||
|
||||
/// A top object whose side with fewer segments would share out the content another way once split
|
||||
/// (two curves that aren't one curve, under three segments) is written as its art, with a warning.
|
||||
#[test]
|
||||
fn a_top_object_whose_split_side_would_map_another_way_is_written_as_its_art() {
|
||||
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions};
|
||||
let mut path = uneven_top_object(3);
|
||||
path.truncate(5);
|
||||
path.curve_to((150.0, 110.0), (120.0, 110.0), (100.0, 90.0));
|
||||
path.curve_to((80.0, 95.0), (40.0, 80.0), (30.0, 90.0));
|
||||
path.close_path();
|
||||
let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None };
|
||||
let doc = envelope_doc(kind, 50.0, false, EnvelopeOptions::NEW);
|
||||
assert!(!program(&doc).contains("AIDeformOptionsKey"));
|
||||
let (back, warnings, _) = envelope_back(&doc);
|
||||
assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}");
|
||||
assert!(matches!(back.kind, NodeKind::Group { .. }), "{back:#?}");
|
||||
}
|
||||
|
||||
/// A top object whose split side would map the content another way only between the places of an
|
||||
/// even grid of eighths is written as its art too: a bottom side of a long straight segment, which is
|
||||
/// split, and a short curve in its last eighth, which the reader then samples at fewer points.
|
||||
#[test]
|
||||
fn a_top_object_whose_split_side_differs_between_eighths_is_written_as_its_art() {
|
||||
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions};
|
||||
let mut path = uneven_top_object(3);
|
||||
path.truncate(5);
|
||||
path.curve_to((168.0, 85.0), (164.0, 85.0), (162.0, 90.0));
|
||||
path.line_to((30.0, 90.0));
|
||||
path.close_path();
|
||||
let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None };
|
||||
let doc = envelope_doc(kind, 50.0, false, EnvelopeOptions::NEW);
|
||||
assert!(!program(&doc).contains("AIDeformOptionsKey"));
|
||||
let (back, warnings, _) = envelope_back(&doc);
|
||||
assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}");
|
||||
assert!(matches!(back.kind, NodeKind::Group { .. }), "{back:#?}");
|
||||
}
|
||||
|
||||
/// An envelope with its contents in an opacity mask's art is written as its art there, and an
|
||||
/// envelope in a group stays live.
|
||||
#[test]
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
//! contents in an `X=` … `X+` block (while they are edited, the grid) as `AIDeformArtKey`, and the
|
||||
//! kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object); then the distorted art on plain
|
||||
//! lines, and the plugin's `XP` after it. A top object's outline is the edge of the grid written
|
||||
//! for it, one grid edge to each segment of its sides. An envelope the reader would open in another
|
||||
//! shape is written as its distorted art.
|
||||
//! for it, one grid edge to each segment of its sides, a side with fewer segments than the side
|
||||
//! across from it split into more. An envelope the reader would open in another shape is written as
|
||||
//! its distorted art.
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Point, Vec2};
|
||||
use kurbo::{CubicBez, ParamCurve, Point, Vec2};
|
||||
use vectorcraft_doc::live::{
|
||||
Coons, EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, PreserveShape, WARP_NET, envelope_content_boxes,
|
||||
envelope_grid, envelope_stroke_margin, warp_grid,
|
||||
@@ -173,11 +174,44 @@ fn reads_as_written(rows: usize, cols: usize, pts: &[GridPoint]) -> bool {
|
||||
envelope_upright(&mesh).is_some_and(|(r, c, read)| (r as usize, c as usize) == (rows, cols) && read == pts)
|
||||
}
|
||||
|
||||
/// `pieces`, one side of a top object, split into `n` pieces: each piece is split into equal spans
|
||||
/// of its curve parameter, the extra pieces going one at a time to the piece with the longest
|
||||
/// control polygon for each of its spans. `None` where `n` is fewer pieces than it has, more than
|
||||
/// [`MAX_GRID`], or a split leaves a piece of no length.
|
||||
fn split_to(pieces: &[CubicBez], n: usize) -> Option<Vec<CubicBez>> {
|
||||
if n < pieces.len() || n > MAX_GRID {
|
||||
return None;
|
||||
}
|
||||
if n == pieces.len() {
|
||||
return Some(pieces.to_vec());
|
||||
}
|
||||
let polygon = |c: &CubicBez| c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3);
|
||||
let lengths: Vec<f64> = pieces.iter().map(polygon).collect();
|
||||
let mut parts = vec![1usize; pieces.len()];
|
||||
for _ in pieces.len()..n {
|
||||
let most = (0..pieces.len()).max_by(|a, b| (lengths[*a] / parts[*a] as f64).total_cmp(&(lengths[*b] / parts[*b] as f64)))?;
|
||||
parts[most] += 1;
|
||||
}
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for (c, p) in pieces.iter().zip(parts) {
|
||||
for j in 0..p {
|
||||
let piece = c.subsegment(j as f64 / p as f64..(j + 1) as f64 / p as f64);
|
||||
if [piece.p1, piece.p2, piece.p3].iter().all(|q| q.distance(piece.p0) <= 1e-9) {
|
||||
return None;
|
||||
}
|
||||
out.push(piece);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The grid a top object with outline `path` and corners `corners` is written with: its edge runs
|
||||
/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment, so
|
||||
/// the top and bottom sides need as many segments as each other, as do the left and right sides;
|
||||
/// the points inside blend the sides. `None` where that grid's edge, read back as the top object's
|
||||
/// outline, doesn't give the same patch ([`Coons::from_top_object`]) at every place checked.
|
||||
/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment,
|
||||
/// the points inside blending the sides. The top and bottom sides need as many grid edges as each
|
||||
/// other, as do the left and right sides, so where one side of a pair has fewer segments than the
|
||||
/// other, its segments are split ([`split_to`]) without changing its shape. `None` where that
|
||||
/// grid's edge, read back as the top object's outline, doesn't give the same patch
|
||||
/// ([`Coons::from_top_object`]) at every place checked.
|
||||
fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, usize, Vec<GridPoint>)> {
|
||||
let coons = Coons::from_top_object(path, corners)?;
|
||||
// The outline, as the patch takes it: the largest subpath.
|
||||
@@ -214,10 +248,35 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize,
|
||||
}
|
||||
};
|
||||
let gap = |k: usize| (at[(k + 1) % 4] + count - at[k]) % count;
|
||||
let (cols, rows) = (gap(0), gap(1));
|
||||
if gap(2) != cols || gap(3) != rows || cols > MAX_GRID || rows > MAX_GRID {
|
||||
if (0..4).any(|k| gap(k) > MAX_GRID) {
|
||||
return None;
|
||||
}
|
||||
// Each side's segments from its corner on, a straight one as a cubic with its handles a third of
|
||||
// the way along, which the patch spreads evenly along, as it does a straight segment.
|
||||
let side = |k: usize| -> Vec<CubicBez> {
|
||||
(0..gap(k))
|
||||
.map(|j| {
|
||||
let i = (at[k] + j) % count;
|
||||
let seg = sp.segment(i);
|
||||
if sp.segment_is_line(i) {
|
||||
CubicBez::new(seg.p0, seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0), seg.p3)
|
||||
} else {
|
||||
seg
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut sides = [side(0), side(1), side(2), side(3)];
|
||||
// Opposite sides take as many segments as each other: the one with fewer has some split.
|
||||
for k in 0..2 {
|
||||
let n = sides[k].len().max(sides[k + 2].len());
|
||||
for s in [k, k + 2] {
|
||||
sides[s] = split_to(&sides[s], n)?;
|
||||
}
|
||||
}
|
||||
let (cols, rows) = (sides[0].len(), sides[1].len());
|
||||
let edge: Vec<CubicBez> = sides.concat();
|
||||
let total = edge.len();
|
||||
let width = cols + 1;
|
||||
let mut pts: Vec<GridPoint> = vec![(Point::ZERO, [Vec2::ZERO; 4]); (rows + 1) * width];
|
||||
// Where the `k`th anchor from the top left corner goes, and the slots of the handles of the
|
||||
@@ -233,20 +292,15 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize,
|
||||
(rows - (k - 2 * cols - rows), 0, H_UP, H_DOWN)
|
||||
}
|
||||
};
|
||||
for k in 0..count {
|
||||
let i = (at[0] + k) % count;
|
||||
let seg = sp.segment(i);
|
||||
// A straight segment's patch spreads evenly along it, as a cubic's with its handles a third
|
||||
// of the way does.
|
||||
let (p1, p2) = if sp.segment_is_line(i) { (seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0)) } else { (seg.p1, seg.p2) };
|
||||
for (k, seg) in edge.iter().enumerate() {
|
||||
let (r, c, out, into) = place(k);
|
||||
let (nr, nc, _, _) = place((k + 1) % count);
|
||||
let (nr, nc, _, _) = place((k + 1) % total);
|
||||
if let Some(q) = pts.get_mut(r * width + c) {
|
||||
q.0 = seg.p0;
|
||||
q.1[out] = p1 - seg.p0;
|
||||
q.1[out] = seg.p1 - seg.p0;
|
||||
}
|
||||
if let Some(q) = pts.get_mut(nr * width + nc) {
|
||||
q.1[into] = p2 - seg.p3;
|
||||
q.1[into] = seg.p2 - seg.p3;
|
||||
}
|
||||
}
|
||||
// The points inside, blended from the sides.
|
||||
@@ -274,9 +328,20 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize,
|
||||
let read = Coons::from_top_object(&outline, Some(corners))?;
|
||||
let [tl, tr, br, bl] = coons.corners();
|
||||
let reach = tl.distance(br).max(tr.distance(bl)).max(1.0) * TOP_OBJECT_TOLERANCE;
|
||||
const STEPS: usize = 8;
|
||||
let at = |i: usize| i as f64 / STEPS as f64;
|
||||
let same = (0..=STEPS).all(|j| (0..=STEPS).all(|i| coons.eval(at(i), at(j)).distance(read.eval(at(i), at(j))) <= reach));
|
||||
// Each side of both patches runs straight between the places the sides have points
|
||||
// ([`Coons::places`]), so two matching sides lie furthest apart at one of those places. A point
|
||||
// inside a patch blends its four sides and corners, so where every side lies within a third of
|
||||
// `reach` of the other patch's, the patches lie within `reach` of each other everywhere.
|
||||
let merge = |a: Vec<f64>, b: Vec<f64>| {
|
||||
let mut v = [a, b].concat();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
let ([cu, cv], [ru, rv]) = (coons.places(), read.places());
|
||||
let (us, vs) = (merge(cu, ru), merge(cv, rv));
|
||||
let near = |u: f64, v: f64| coons.eval(u, v).distance(read.eval(u, v)) <= reach / 3.0;
|
||||
let same = us.iter().all(|&u| near(u, 0.0) && near(u, 1.0)) && vs.iter().all(|&v| near(0.0, v) && near(1.0, v));
|
||||
same.then_some((rows, cols, pts))
|
||||
}
|
||||
|
||||
|
||||
@@ -102,8 +102,10 @@
|
||||
//! in the Symbols panel's order. An instance: `/SymbolInstance : (name) /SymbolRef , a b c d tx
|
||||
//! ty /RTransformMatrix ;`, the matrix taking the symbol's art to art space; in a symbol's art,
|
||||
//! it takes it to art space about the canvas point of art space's origin, as a pattern's
|
||||
//! gradients are. Its hide and lock come before it (`Xw`, `A`) and its transparency (`Xy`) after
|
||||
//! it.
|
||||
//! gradients are. Files of versions 10 to 13 write `a b c d tx ty /TransformMatrix` instead,
|
||||
//! the matrix taking the symbol's art to the canvas (y down, art space's origin at the canvas
|
||||
//! point the template box gives); in a symbol's art, it takes the art to that symbol's space.
|
||||
//! Its hide and lock come before it (`Xw`, `A`) and its transparency (`Xy`) after it.
|
||||
//!
|
||||
//! Sections the reader doesn't need (styles, filters) are skipped; type, instances of symbols it
|
||||
//! can't read, fills with a pattern it can't read, placed files without a preview and operators
|
||||
|
||||
@@ -636,17 +636,22 @@ impl<'a> Reader<'a> {
|
||||
/// A symbol instance (`/SymbolInstance`): the symbol its `SymbolRef` names, placed by its
|
||||
/// `RTransformMatrix`, hidden and locked as the state says. In a symbol's art the matrix is
|
||||
/// written about the canvas point of art space's origin, and the name is the file's until the
|
||||
/// symbols are checked ([`Self::check_symbols`]). An instance of a symbol that wasn't read, one
|
||||
/// whose appearance differs from its symbol's, or one placed only by a `Matrix` or `Transform`,
|
||||
/// comes in as the page draws it, within the symbol's box placed by the matrix when the box is
|
||||
/// known.
|
||||
/// symbols are checked ([`Self::check_symbols`]). Files of versions 10 to 13 place an instance
|
||||
/// by a `TransformMatrix` instead, which takes the symbol's art to the canvas (y down); in a
|
||||
/// symbol's art, it takes the art to that symbol's space. The reader converts it with the canvas
|
||||
/// point of art space's origin, from the template box. An instance of a symbol that wasn't
|
||||
/// read, one whose appearance differs from its symbol's, or one placed only by a `Matrix` or
|
||||
/// `Transform`, comes in as the page draws it, within the symbol's box placed by the matrix
|
||||
/// when the box is known.
|
||||
fn symbol_instance(&mut self, o: &Obj) -> Result<(), String> {
|
||||
let name = self.names.entry(o, "SymbolRef");
|
||||
let affine = |n: &[f64]| match n {
|
||||
[a, b, c, d, e, f] if [a, b, c, d, e, f].iter().all(|v| v.is_finite()) => Some(super::Affine::new([*a, *b, *c, *d, *e, *f])),
|
||||
_ => None,
|
||||
};
|
||||
let live = affine(&o.nums("RTransformMatrix"));
|
||||
// The canvas → art space: y turned up about the canvas point of art space's origin.
|
||||
let from_canvas = self.canvas_origin().map(|o| super::Affine::new([1.0, 0.0, 0.0, -1.0, -o.x, o.y]));
|
||||
let live = affine(&o.nums("RTransformMatrix")).or_else(|| Some(from_canvas? * affine(&o.nums("TransformMatrix"))?));
|
||||
let boxed_only = live.is_none();
|
||||
let matrix = live.or_else(|| {
|
||||
o.get("Matrix")
|
||||
|
||||
@@ -5115,6 +5115,73 @@ fn an_instance_in_a_symbols_art_is_placed_from_the_canvas() {
|
||||
assert_eq!(instance_box(&doc, &a[0]), Rect::new(58.0, 53.0, 62.0, 57.0));
|
||||
}
|
||||
|
||||
/// Files of versions 10 to 13 place an instance by a `TransformMatrix` that takes the symbol's art
|
||||
/// to the canvas (y down): the instance stays live and lands where the `RTransformMatrix` of a
|
||||
/// newer file puts it, on the page and in a symbol's art.
|
||||
#[test]
|
||||
fn an_instance_placed_on_the_canvas_by_an_older_file_stays_live() {
|
||||
// The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242).
|
||||
let older = |name: &str, m: [f64; 6]| {
|
||||
let [a, b, c, d, e, f] = m;
|
||||
format!("/SymbolInstance :\n({name}) /SymbolRef ,\n{a} {b} {c} {d} {e} {f} /TransformMatrix\n;\n")
|
||||
};
|
||||
let page = older("Dot", [1.0, 0.0, 0.0, 1.0, 8111.0, 8192.0]) + &older("Dot", [2.0, 0.0, 0.0, 2.0, 8191.0, 8192.0]);
|
||||
let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{}", symbols(&symbol("Dot", &dot(), 5)), layer(&page));
|
||||
let doc = read_ok(&body).doc;
|
||||
let a = art(&doc);
|
||||
assert_eq!(a.len(), 2);
|
||||
assert!(a.iter().all(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })), "{a:#?}");
|
||||
assert_eq!(instance_box(&doc, &a[0]), Rect::new(18.0, 48.0, 22.0, 52.0));
|
||||
assert_eq!(instance_box(&doc, &a[1]), Rect::new(96.0, 46.0, 104.0, 54.0));
|
||||
// Turned and scaled, as the newer file writes the same instance: canvas x is art x + 8091 and
|
||||
// canvas y is 8242 - art y, so the second row of the matrix changes sign.
|
||||
let turned = [1.299, -0.4, 0.75, 0.6928, 8111.0, 8192.0];
|
||||
let newer = [1.299, 0.4, 0.75, -0.6928, 20.0, 50.0];
|
||||
let older_doc =
|
||||
read_ok(&format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{}", symbols(&symbol("Dot", &dot(), 5)), layer(&older("Dot", turned)))).doc;
|
||||
let newer_doc =
|
||||
read_ok(&format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{}", symbols(&symbol("Dot", &dot(), 5)), layer(&instance("Dot", newer)))).doc;
|
||||
let xf = |d: &Document| match &art(d)[0].kind {
|
||||
NodeKind::SymbolInstance { xf, .. } => xf.as_coeffs(),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let (o, n) = (xf(&older_doc), xf(&newer_doc));
|
||||
assert!(o.iter().zip(n).all(|(a, b)| (a - b).abs() < 1e-9), "{o:?} {n:?}");
|
||||
// In a symbol's art the matrix takes the art to the symbol's own space: Dot at (10, 5) in Pair's
|
||||
// art, which the page instance puts at (50, 50).
|
||||
let nested = older("Dot", [1.0, 0.0, 0.0, 1.0, 10.0, 5.0]);
|
||||
let defs = symbols(&(symbol("Dot", &dot(), 5) + &symbol("Pair", &nested, 1)));
|
||||
let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{defs}{}", layer(&older("Pair", [1.0, 0.0, 0.0, 1.0, 8141.0, 8192.0])));
|
||||
let doc = read_ok(&body).doc;
|
||||
let a = art(&doc);
|
||||
assert_eq!(instance_box(&doc, &a[0]), Rect::new(58.0, 53.0, 62.0, 57.0));
|
||||
// One document saved in both forms: the template box's center is (100.5, 99.5), so art space's
|
||||
// origin is at canvas (8091, 8291). Dot sits at (5, 2.5) in Pair's art, and the page instance
|
||||
// puts Pair at (57, 145.5). Both instances get the newer file's matrices, coefficient by
|
||||
// coefficient.
|
||||
let saved = |inner: String, pair: String| {
|
||||
let defs = symbols(&(symbol("Dot", &dot(), 5) + &symbol("Pair", &inner, 1)));
|
||||
read_ok(&format!("%AI3_TemplateBox: 100.5 99.5 100.5 99.5\n{defs}{}", layer(&pair))).doc
|
||||
};
|
||||
let older_doc = saved(older("Dot", [1.0, 0.0, 0.0, 1.0, 5.0, 2.5]), older("Pair", [1.0, 0.0, 0.0, 1.0, 8148.0, 8145.5]));
|
||||
let newer_doc = saved(instance("Dot", [1.0, 0.0, 0.0, -1.0, -8086.0, 8288.5]), instance("Pair", [1.0, 0.0, 0.0, -1.0, 57.0, 145.5]));
|
||||
fn first_xf(n: &Node) -> Option<[f64; 6]> {
|
||||
match &n.kind {
|
||||
NodeKind::SymbolInstance { xf, .. } => Some(xf.as_coeffs()),
|
||||
_ => n.children()?.iter().find_map(|c| first_xf(c)),
|
||||
}
|
||||
}
|
||||
let both = |d: &Document| (xf(d), first_xf(&d.symbols[1].art).expect("Dot's instance in Pair's art"));
|
||||
let ((op, on), (np, nn)) = (both(&older_doc), both(&newer_doc));
|
||||
assert!(op.iter().zip(np).all(|(a, b)| (a - b).abs() < 1e-9), "{op:?} {np:?}");
|
||||
assert!(on.iter().zip(nn).all(|(a, b)| (a - b).abs() < 1e-9), "{on:?} {nn:?}");
|
||||
// Without a template box the canvas's place isn't known: the instance isn't read, though Dot is
|
||||
// defined and the same instance with a template box is read above.
|
||||
let defs = symbols(&symbol("Dot", &dot(), 5));
|
||||
let e = read(stream(&format!("{defs}{}", layer(&older("Dot", [1.0, 0.0, 0.0, 1.0, 8111.0, 8192.0])))).as_bytes());
|
||||
assert!(e.is_err_and(|e| e.contains("symbols")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_in_a_symbols_art_is_placed_from_the_canvas() {
|
||||
// The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242). The
|
||||
|
||||
@@ -2197,6 +2197,34 @@ fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
|
||||
assert!(!d.layers[2].visible);
|
||||
}
|
||||
|
||||
/// A `.ai` of versions 10 to 13, whose symbol instances are placed by a `TransformMatrix` on the
|
||||
/// canvas, opens from its layers: the art outside its artboard, an instance among it, comes in,
|
||||
/// rather than the PDF part alone, which has none of it.
|
||||
#[test]
|
||||
fn an_older_ai_files_symbols_keep_its_layers_and_the_art_outside_its_artboard() {
|
||||
// The template box's center is (50.5, 50.5): art space's origin is at canvas (8141, 8242). The
|
||||
// instance puts Box at (150, 50) in art space, right of the artboard (x 0 to 100).
|
||||
let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n1 0 0 1 8291 8192 /TransformMatrix\n;\n9 () XW";
|
||||
let layers = format!(
|
||||
"{}{}{}",
|
||||
stroked_box_symbol(),
|
||||
layer("Jig", true, &square(10, 10)),
|
||||
layer("Parts", true, &format!("{}\n{instance}", square(30, 110)))
|
||||
);
|
||||
let art = native(&layers).replacen("%AI5_NumLayers", "%AI3_TemplateBox: 50.5 50.5 50.5 50.5\n%AI5_NumLayers", 1);
|
||||
let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
|
||||
assert!(read, "{warnings:?}");
|
||||
assert_eq!(names(&d), ["Jig", "Parts"], "{warnings:?}");
|
||||
let parts = d.layers[1].children().unwrap();
|
||||
let above = parts[0].visual_bounds().unwrap();
|
||||
assert!(above.y1 <= 1e-6, "the square above the artboard is kept, outside it: {above:?}");
|
||||
let NodeKind::SymbolInstance { symbol, .. } = &parts[1].kind else { panic!("an instance: {:?} {warnings:?}", parts[1].kind) };
|
||||
assert_eq!(symbol, "Box");
|
||||
let b = parts[1].visual_bounds_with(&d.symbol_boxes()).unwrap();
|
||||
let near = [b.x0 - 149.0, b.y0 - 49.5, b.x1 - 151.0, b.y1 - 50.5].iter().all(|v| v.abs() < 0.3);
|
||||
assert!(near, "the instance right of the artboard: {b:?}");
|
||||
}
|
||||
|
||||
/// A `.ai` whose PDF part was read in points from a page of 10 points a unit (its `UserUnit`), and
|
||||
/// whose editing data has the page in those units: its layers come in at the page's size, ten
|
||||
/// times as large as the editing data has them, with stroke weights and dashes ten times as large.
|
||||
|
||||
@@ -1366,7 +1366,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"file.exportAs",
|
||||
"Export As…",
|
||||
"",
|
||||
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it",
|
||||
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, or while the file would name a linked image's file outside them, as document.export refuses it",
|
||||
),
|
||||
(
|
||||
"ui.fileInfoDialog",
|
||||
|
||||
@@ -556,10 +556,12 @@ impl Styles {
|
||||
}
|
||||
|
||||
/// The properties rules with a `:not()` set on `n`, each with its value after the cascade.
|
||||
/// Only the rules [`Index::candidates`] gives for `n` are tested, as in [`Styles::matched_bytes`].
|
||||
pub(super) fn set_by_not_rules(&self, n: XNode) -> Vec<(String, String)> {
|
||||
let declared = self.declared(n);
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for r in self.rules.iter().filter(|r| r.selector.has_not() && r.selector.matches(n)) {
|
||||
let rules = self.index.candidates(n).into_iter().filter_map(|i| self.rules.get(i));
|
||||
for r in rules.filter(|r| r.selector.has_not() && r.selector.matches(n)) {
|
||||
for (k, ..) in r.decls.iter() {
|
||||
if !out.iter().any(|(o, _)| o == k)
|
||||
&& let Some(v) = declared.get(k)
|
||||
|
||||
@@ -334,6 +334,26 @@ fn not_selectors() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The `:not()` rules filed under classes an element lacks are not tested against it, so
|
||||
/// [`Styles::set_by_not_rules`] tests no more rules than [`Styles::matched_bytes`] counts.
|
||||
#[test]
|
||||
fn not_rules_test_only_the_indexed_candidates() {
|
||||
const N: usize = 2_000;
|
||||
let rules: String = (0..N).map(|i| format!(".c{i}:not(.skip) {{ fill: #{i:06x} }}\n")).collect();
|
||||
let rects: String = (0..N).map(|i| format!(r#"<rect id="r{i}" class="c{i}"/>"#)).collect();
|
||||
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><style>{rules}</style>{rects}<rect id="skip" class="c0 skip"/></svg>"#);
|
||||
with_doc(&svg, |doc, st| {
|
||||
let before = selector_tests();
|
||||
for i in 0..N {
|
||||
assert_eq!(st.set_by_not_rules(by_id(doc, &format!("r{i}"))), [("fill".to_string(), format!("#{i:06x}"))]);
|
||||
}
|
||||
assert!(st.set_by_not_rules(by_id(doc, "skip")).is_empty());
|
||||
// Testing every `:not()` rule against every element would test N × N selectors.
|
||||
let tests = selector_tests() - before;
|
||||
assert!(tests <= 3 * N as u64, "finding the :not() rules of {N} elements tested {tests} selectors");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decl_comments_are_stripped() {
|
||||
let d = |k: &str, v: &str| (k.to_string(), v.to_string(), false);
|
||||
|
||||
@@ -18,13 +18,13 @@ mod hints;
|
||||
mod status_bar;
|
||||
mod tabs;
|
||||
|
||||
pub(crate) use control_bar::group_separator;
|
||||
pub use control_bar::{
|
||||
AnchorControls, CONTROL_BAR_EDGES, CONTROL_BAR_GROUPS, anchor_buttons, anchor_controls, control_bar, control_bar_moving, control_bar_placement,
|
||||
read_hidden_groups,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use control_bar::{CONTROL_BAR_FLOAT_AT, ISOLATION_BUTTON};
|
||||
pub(crate) use control_bar::{group_separator, left_out};
|
||||
#[cfg(test)]
|
||||
use hints::hint_segments;
|
||||
pub use hints::{hint_bar, hint_strings};
|
||||
|
||||
@@ -61,6 +61,7 @@ pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorCon
|
||||
for (label, buttons) in groups.into_iter().take(shown) {
|
||||
let text = egui::RichText::new(label).size(12.0).color(t.text);
|
||||
if in_row && ui.available_width() < row_width(ui, &[widgets::text_width(ui, text.clone())]) + buttons_width(ui, buttons.len()) {
|
||||
left_out(ui);
|
||||
break;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
@@ -164,9 +165,13 @@ const GLOBAL_EDIT_OPTIONS_WIDTH: f32 = 24.0;
|
||||
/// It opens the Properties panel's Global Edit Options, which set the same session options, and is
|
||||
/// off and closed while Global Edit is on, as that panel's dropdown is. It sits after Recolor
|
||||
/// Artwork, ahead of the X/Y/W/H fields that the bar leaves out when it runs short of room, and is
|
||||
/// left out when the room left, less `reserve` ([`transform_reserve`]), cannot hold it.
|
||||
/// left out when the room left, less `reserve` ([`transform_reserve`]), cannot hold it ([`left_out`]).
|
||||
fn global_edit_options(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
|
||||
if !crate::menus::enabled(app, "select.globalEdit") || ui.available_width() < row_width(ui, &[GLOBAL_EDIT_OPTIONS_WIDTH]) + reserve {
|
||||
if !crate::menus::enabled(app, "select.globalEdit") {
|
||||
return;
|
||||
}
|
||||
if ui.available_width() < row_width(ui, &[GLOBAL_EDIT_OPTIONS_WIDTH]) + reserve {
|
||||
left_out(ui);
|
||||
return;
|
||||
}
|
||||
let on = app.session.active().is_some_and(|d| d.global_edit.is_some());
|
||||
@@ -287,6 +292,24 @@ fn boundary(ui: &mut Ui, draw: impl FnOnce(&mut Ui)) {
|
||||
ui.data_mut(|d| d.insert_temp(key, b));
|
||||
}
|
||||
|
||||
/// The key of the pass in which a control of the row `ui` was last left out for room.
|
||||
fn left_out_key(ui: &Ui) -> egui::Id {
|
||||
ui.unique_id().with("cb-left-out")
|
||||
}
|
||||
|
||||
/// Notes that a control of the Control bar's row `ui` was left out in this pass for want of room,
|
||||
/// so the controls the bar leaves out first stay out too ([`any_left_out`]).
|
||||
pub(crate) fn left_out(ui: &mut Ui) {
|
||||
let (key, pass) = (left_out_key(ui), ui.ctx().cumulative_pass_nr());
|
||||
ui.data_mut(|d| d.insert_temp(key, pass));
|
||||
}
|
||||
|
||||
/// Whether a control of the row `ui` was left out in this pass for want of room ([`left_out`]).
|
||||
fn any_left_out(ui: &Ui) -> bool {
|
||||
let (key, pass) = (left_out_key(ui), ui.ctx().cumulative_pass_nr());
|
||||
ui.data(|d| d.get_temp::<u64>(key)) == Some(pass)
|
||||
}
|
||||
|
||||
/// The separator that ends a group of the Control bar's controls, drawn as `ui.separator()` draws
|
||||
/// one, and left out when no control follows it ([`boundary`]) or the row has no room left for it.
|
||||
pub(crate) fn group_separator(ui: &mut Ui) {
|
||||
@@ -348,20 +371,26 @@ fn transform_reserve(app: &VectorcraftApp, ui: &Ui, selected: bool) -> f32 {
|
||||
/// Align To menu and, for two or more objects, the Distribute buttons (a single object keeps the bar
|
||||
/// as it was); each shows only while the end of the bar keeps its room. Groups the panel menu has
|
||||
/// off are left out, and take no room. Without `room` (the Character group had none), all of these
|
||||
/// groups are left out, so the bar trims them from its right end before the Character group.
|
||||
/// groups are left out, so the bar trims them from its right end before the Character group. The
|
||||
/// Character group without its room, and a control left out for room here, are noted with
|
||||
/// [`left_out`].
|
||||
fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize, room: bool) -> bool {
|
||||
if !room {
|
||||
left_out(ui);
|
||||
}
|
||||
let nothing = objects == 0;
|
||||
let end = end_width(app, ui, nothing);
|
||||
if room && group_on(app, "paragraph") && crate::panels::paragraph::control_bar(app, ui, BUTTON, |ui| group_end_width(ui) + end) {
|
||||
group_end(ui);
|
||||
}
|
||||
if room
|
||||
&& group_on(app, "verticalAlignment")
|
||||
&& crate::panels::properties::vertical_align::shown(app).is_some()
|
||||
&& ui.available_width() >= buttons_width(ui, 4) + group_end_width(ui) + end
|
||||
&& crate::panels::properties::vertical_align::buttons(app, ui, BUTTON)
|
||||
{
|
||||
group_end(ui);
|
||||
if room && group_on(app, "verticalAlignment") && crate::panels::properties::vertical_align::shown(app).is_some() {
|
||||
if ui.available_width() >= buttons_width(ui, 4) + group_end_width(ui) + end {
|
||||
if crate::panels::properties::vertical_align::buttons(app, ui, BUTTON) {
|
||||
group_end(ui);
|
||||
}
|
||||
} else {
|
||||
left_out(ui);
|
||||
}
|
||||
}
|
||||
if nothing {
|
||||
// Each button shows only while the bar has room left for it, so Preferences can show
|
||||
@@ -378,14 +407,24 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize
|
||||
}
|
||||
let transform = group_on(app, "transform");
|
||||
let end = if transform { separator_width(ui) + end } else { 0.0 };
|
||||
if room && group_on(app, "alignDistribute") && ui.available_width() >= buttons_width(ui, 7) + end {
|
||||
if room && group_on(app, "alignDistribute") {
|
||||
if ui.available_width() < buttons_width(ui, 7) + end {
|
||||
left_out(ui);
|
||||
return transform;
|
||||
}
|
||||
crate::panels::align::align_buttons(app, ui, BUTTON);
|
||||
if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x + end {
|
||||
crate::panels::align::align_to_menu(app, ui, BUTTON);
|
||||
if objects > 1 && ui.available_width() >= GROUP_GAP + buttons_width(ui, 6) + end {
|
||||
ui.add_space(GROUP_GAP);
|
||||
crate::panels::align::distribute_buttons(app, ui, BUTTON);
|
||||
if objects > 1 {
|
||||
if ui.available_width() >= GROUP_GAP + buttons_width(ui, 6) + end {
|
||||
ui.add_space(GROUP_GAP);
|
||||
crate::panels::align::distribute_buttons(app, ui, BUTTON);
|
||||
} else {
|
||||
left_out(ui);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
left_out(ui);
|
||||
}
|
||||
if transform {
|
||||
boundary(ui, separator);
|
||||
@@ -594,9 +633,10 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
|
||||
/// and the width profile, and the separator after them while a control follows ([`group_separator`]).
|
||||
/// Short of room, the bar leaves out the width profile first, then the Stroke link with the weight,
|
||||
/// then the two chips; each shows only while the bar has room for all of it and `reserve` after it
|
||||
/// ([`transform_reserve`]).
|
||||
/// ([`transform_reserve`]), and a control left out is noted with [`left_out`].
|
||||
fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
|
||||
if ui.available_width() < row_width(ui, &[CHIP_WIDTH, CHIP_WIDTH]) + reserve {
|
||||
left_out(ui);
|
||||
return;
|
||||
}
|
||||
let shown_stroke = crate::panels::current_stroke(app);
|
||||
@@ -610,11 +650,13 @@ fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
|
||||
if ui.available_width() >= row_width(ui, &[link, WEIGHT_FIELD_WIDTH]) + reserve {
|
||||
stroke_panel::link(app, ui, tl!("Stroke:"));
|
||||
stroke_panel::weight_field(app, ui, "cb-stroke", weight, WEIGHT_FIELD_WIDTH);
|
||||
if ui.available_width() >= row_width(ui, &[stroke_panel::PROFILE_DROPDOWN_WIDTH]) + reserve
|
||||
&& let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref()))
|
||||
{
|
||||
if ui.available_width() < row_width(ui, &[stroke_panel::PROFILE_DROPDOWN_WIDTH]) + reserve {
|
||||
left_out(ui);
|
||||
} else if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) {
|
||||
app.run("stroke.set", json!({"profile": id})).ok();
|
||||
}
|
||||
} else {
|
||||
left_out(ui);
|
||||
}
|
||||
group_end(ui);
|
||||
}
|
||||
@@ -625,7 +667,7 @@ fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
|
||||
/// the bar leaves out Global Edit Options first, then Recolor Artwork, then the Style link with its
|
||||
/// chip. The Opacity link with its field and the Style link with its chip each show only while the
|
||||
/// bar has room for all of it and `reserve` after it ([`transform_reserve`]), so the narrower Style
|
||||
/// part can show without the Opacity part.
|
||||
/// part can show without the Opacity part. A control left out is noted with [`left_out`].
|
||||
fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: f32, selected: bool, reserve: f32) {
|
||||
let (opacity_on, style_on) = (group_on(app, "opacity"), group_on(app, "graphicStyle") && selected);
|
||||
let mut drawn = false;
|
||||
@@ -642,10 +684,15 @@ fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity:
|
||||
app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
|
||||
}
|
||||
drawn = true;
|
||||
} else {
|
||||
left_out(ui);
|
||||
}
|
||||
}
|
||||
let text = egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline();
|
||||
if style_on && ui.available_width() >= GROUP_GAP + row_width(ui, &[widgets::text_width(ui, text.clone()), CHIP_WIDTH]) + reserve {
|
||||
let style_room = GROUP_GAP + row_width(ui, &[widgets::text_width(ui, text.clone()), CHIP_WIDTH]) + reserve;
|
||||
if style_on && ui.available_width() < style_room {
|
||||
left_out(ui);
|
||||
} else if style_on {
|
||||
// Style picker: the selection's graphic style; its menu applies another.
|
||||
ui.add_space(GROUP_GAP);
|
||||
if ui.link(text).clicked() {
|
||||
@@ -656,10 +703,9 @@ fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity:
|
||||
egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui));
|
||||
// Recolor Artwork, when the selection has colours to recolour.
|
||||
let colored = app.session.active().is_some_and(|st| crate::canvas::has_colors(&st.doc, &st.selection.objects));
|
||||
if colored
|
||||
&& ui.available_width() >= buttons_width(ui, 1) + reserve
|
||||
&& widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, BUTTON).clicked()
|
||||
{
|
||||
if colored && ui.available_width() < buttons_width(ui, 1) + reserve {
|
||||
left_out(ui);
|
||||
} else if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, BUTTON).clicked() {
|
||||
crate::menus::invoke(app, "ui.recolorDialog", json!({}));
|
||||
}
|
||||
global_edit_options(app, ui, reserve);
|
||||
@@ -670,6 +716,58 @@ fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity:
|
||||
}
|
||||
}
|
||||
|
||||
/// The width of the chevron that opens the Select Similar Options menu.
|
||||
const SELECT_SIMILAR_MENU_WIDTH: f32 = 16.0;
|
||||
|
||||
/// The label of each attribute of [`vectorcraft_engine::cmd::SIMILAR`] in the Select Similar Options
|
||||
/// menu, translated.
|
||||
fn similar_label(by: &str) -> &'static str {
|
||||
match by {
|
||||
"all" => tl!("All"),
|
||||
"fillColor" => tl!("Fill Color"),
|
||||
"fillAndStroke" => tl!("Fill & Stroke"),
|
||||
"strokeColor" => tl!("Stroke Color"),
|
||||
"strokeWeight" => tl!("Stroke Weight"),
|
||||
"opacity" => tl!("Opacity"),
|
||||
"blendingMode" => tl!("Blending Mode"),
|
||||
"appearance" => tl!("Appearance"),
|
||||
"graphicStyle" => tl!("Graphic Style"),
|
||||
"shapeType" => tl!("Shape"),
|
||||
_ => tl!("Symbol Instance"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Control bar's Select Similar Objects button and, beside it, the Select Similar Options menu
|
||||
/// (<https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=165>). The
|
||||
/// button runs `select.similar`, which selects the objects like the first selected one by the
|
||||
/// attribute kept for the session; a menu item runs it by that item's attribute, which is kept from
|
||||
/// then on, and the kept one is checked. Both are off while `select.similar` cannot run. They show
|
||||
/// together at the right end of the bar, after a separator, while something is selected and the
|
||||
/// panel menu has Object Options on, and only in the room every control ahead of them left
|
||||
/// ([`contents`]): the bar leaves them out before any other control, and while a control ahead of
|
||||
/// them was left out for room ([`any_left_out`]). Groups the panel menu has off take no room.
|
||||
fn trailing_select_similar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty());
|
||||
let width = separator_width(ui) + row_width(ui, &[BUTTON, SELECT_SIMILAR_MENU_WIDTH]);
|
||||
if any_left_out(ui) || !selected || !group_on(app, "objectOptions") || ui.available_width() < width {
|
||||
return;
|
||||
}
|
||||
boundary(ui, separator);
|
||||
let enabled = crate::menus::enabled(app, "select.similar");
|
||||
if widgets::icon_button_enabled(ui, "copy", tl!("Select Similar Objects"), false, enabled, BUTTON).clicked() {
|
||||
crate::menus::invoke(app, "select.similar", json!({}));
|
||||
}
|
||||
let options = widgets::icon_button_enabled(ui, "chevron-down", tl!("Select Similar Options"), false, enabled, SELECT_SIMILAR_MENU_WIDTH);
|
||||
egui::Popup::menu(&options).show(|ui| {
|
||||
ui.set_min_width(140.0);
|
||||
for (by, _) in vectorcraft_engine::cmd::SIMILAR {
|
||||
if widgets::menu_item(ui, similar_label(by), true, app.session.select_similar == by) {
|
||||
crate::menus::invoke(app, "select.similar", json!({ "by": by }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Control bar's gripper, at the left end of the bar `bar`. Dragging it shows where a release
|
||||
/// puts the bar ([`release_at`] in `dock_room`, the window below the menu bar): a band along the
|
||||
/// edge it docks to, or the outline of the bar floating under the pointer. The bar stays where it is
|
||||
@@ -819,6 +917,8 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
|
||||
let label = egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text);
|
||||
if ui.available_width() >= widgets::text_width(ui, label.clone()) {
|
||||
ui.label(label);
|
||||
} else {
|
||||
left_out(ui);
|
||||
}
|
||||
// With the Artboard tool in use, the bar shows the active artboard's controls in place
|
||||
// of the object controls.
|
||||
@@ -862,6 +962,8 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
|
||||
// all; left out, it leaves out the groups between it and the link too.
|
||||
let room = !group_on(app, "character") || crate::panels::character::control_bar(app, ui, reserve);
|
||||
if !groups_before_transform(app, ui, sel.len(), room) {
|
||||
// With the Transform group off, its link and X/Y/W/H fields take no room.
|
||||
trailing_select_similar(app, ui);
|
||||
erase_trailing(ui);
|
||||
return;
|
||||
}
|
||||
@@ -875,9 +977,10 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
|
||||
}
|
||||
crate::panels::transform::link(app, ui);
|
||||
// The bounding box, rotated with rotated objects: its centre and its own sides.
|
||||
if let Some(b) = app.selection_box()
|
||||
&& ui.available_width() >= inline_transform_width(ui, t)
|
||||
{
|
||||
let selection_box = app.selection_box();
|
||||
if selection_box.is_some() && ui.available_width() < inline_transform_width(ui, t) {
|
||||
left_out(ui);
|
||||
} else if let Some(b) = selection_box {
|
||||
let c = b.center();
|
||||
let origin = app.ruler_origin();
|
||||
let link = crate::panels::transform::proportional(app, ui);
|
||||
@@ -906,6 +1009,8 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
|
||||
crate::panels::transform::constrain_link_at(app, ui, ui.interact(room, id, Sense::click()));
|
||||
}
|
||||
}
|
||||
// Select Similar Objects comes last: it takes only the room every control ahead of it left.
|
||||
trailing_select_similar(app, ui);
|
||||
erase_trailing(ui);
|
||||
});
|
||||
// Controls that keep their size can still run into the button's room in a narrow window;
|
||||
|
||||
@@ -3502,6 +3502,162 @@ fn the_control_bar_shows_the_global_edit_options() {
|
||||
assert!(seen, "a wide bar has room for it");
|
||||
}
|
||||
|
||||
/// The Control bar's Select Similar Objects button selects the objects like the first selected one
|
||||
/// by the attribute kept for the session (All at first), and the Select Similar Options menu beside
|
||||
/// it selects by the item chosen, which is kept and checked from then on. Both show with something
|
||||
/// selected while the Object Options group is on, and fit inside the bar or are left out.
|
||||
#[test]
|
||||
fn the_control_bar_selects_similar_objects() {
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let rect = |app: &mut crate::VectorcraftApp, x: f64, color: &str| {
|
||||
let id = app.run("shape.rectangle", json!({"x": x, "y": 20, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "color": color})).unwrap();
|
||||
id
|
||||
};
|
||||
let a = rect(&mut app, 20.0, "#ff0000");
|
||||
let b = rect(&mut app, 100.0, "#ff0000");
|
||||
let c = rect(&mut app, 200.0, "#0000ff");
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(2400.0, 300.0));
|
||||
let frame = |app: &mut crate::VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default()
|
||||
};
|
||||
type Nodes = Vec<(egui::accesskit::NodeId, egui::accesskit::Node)>;
|
||||
let find = |nodes: &Nodes, name: &str| nodes.iter().find(|(_, n)| n.label() == Some(name) || n.value() == Some(name)).map(|(_, n)| n.clone());
|
||||
// A menu item's label carries its check mark or the room for it.
|
||||
let item = |nodes: &Nodes, name: &str| {
|
||||
nodes.iter().find(|(_, n)| n.label().or(n.value()).is_some_and(|t| t.trim_start_matches(['✓', ' ']) == name)).map(|(_, n)| n.clone())
|
||||
};
|
||||
let center = |n: &egui::accesskit::Node| {
|
||||
let b = n.bounds().unwrap_or_else(|| panic!("{n:?}"));
|
||||
egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
|
||||
};
|
||||
let click = |app: &mut crate::VectorcraftApp, at: egui::Pos2| {
|
||||
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, vec![egui::Event::PointerMoved(at), press(true)]);
|
||||
frame(app, vec![press(false)]);
|
||||
frame(app, vec![]);
|
||||
frame(app, vec![])
|
||||
};
|
||||
let selected = |app: &crate::VectorcraftApp| {
|
||||
let mut ids: Vec<u64> = app.session.active().unwrap().selection.objects.iter().map(|i| i.0).collect();
|
||||
ids.sort();
|
||||
ids
|
||||
};
|
||||
app.run("select.set", json!({"ids": []})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
let nodes = frame(&mut app, vec![]);
|
||||
assert!(find(&nodes, "Select Similar Objects").is_none() && find(&nodes, "Select Similar Options").is_none(), "nothing selected");
|
||||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
let nodes = frame(&mut app, vec![]);
|
||||
let button = find(&nodes, "Select Similar Objects").unwrap_or_else(|| panic!("one rectangle: the button shows"));
|
||||
click(&mut app, center(&button));
|
||||
assert_eq!(selected(&app), vec![a, b], "All: the red rectangles");
|
||||
// The menu selects by the item chosen and keeps it.
|
||||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
let options = find(&frame(&mut app, vec![]), "Select Similar Options").unwrap_or_else(|| panic!("the options menu shows"));
|
||||
let nodes = click(&mut app, center(&options));
|
||||
for label in [
|
||||
"All",
|
||||
"Fill Color",
|
||||
"Fill & Stroke",
|
||||
"Stroke Color",
|
||||
"Stroke Weight",
|
||||
"Opacity",
|
||||
"Blending Mode",
|
||||
"Appearance",
|
||||
"Graphic Style",
|
||||
"Shape",
|
||||
"Symbol Instance",
|
||||
] {
|
||||
assert!(item(&nodes, label).is_some(), "{label} in the menu");
|
||||
}
|
||||
assert!(find(&nodes, "✓ All").is_some(), "All is checked at first");
|
||||
let stroke = item(&nodes, "Stroke Color").unwrap_or_else(|| panic!("Stroke Color"));
|
||||
click(&mut app, center(&stroke));
|
||||
assert_eq!(app.session.select_similar, "strokeColor");
|
||||
assert_eq!(selected(&app), vec![a, b, c], "Stroke Color: all three share the default stroke");
|
||||
app.run("select.set", json!({"ids": [c]})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
let nodes = frame(&mut app, vec![]);
|
||||
click(&mut app, center(&find(&nodes, "Select Similar Objects").unwrap_or_else(|| panic!("the button"))));
|
||||
assert_eq!(selected(&app), vec![a, b, c], "the button keeps Stroke Color");
|
||||
let options = find(&frame(&mut app, vec![]), "Select Similar Options").unwrap_or_else(|| panic!("the options menu"));
|
||||
let nodes = click(&mut app, center(&options));
|
||||
assert!(find(&nodes, "✓ Stroke Color").is_some(), "the kept item is checked");
|
||||
// The panel menu's Object Options group turns them off.
|
||||
app.run("window.control.options", json!({"group": "objectOptions"})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
assert!(find(&frame(&mut app, vec![]), "Select Similar Objects").is_none(), "Object Options off");
|
||||
app.run("window.control.options", json!({"group": "objectOptions"})).unwrap();
|
||||
// At any width the button and its menu fit inside the bar together or are left out. They take
|
||||
// only the room left at the end of the bar, so they show only while the Align buttons and the
|
||||
// inline X field do too. A control ahead of them that appears in a wider bar (the Distribute
|
||||
// buttons) can take that room again, so the widest bar is the one that must show them.
|
||||
let mut seen = false;
|
||||
for width in (600..=2400).step_by(10) {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width as f32, 300.0));
|
||||
let mut nodes = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
nodes = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default();
|
||||
}
|
||||
let shown = find(&nodes, "Select Similar Objects").and_then(|n| n.bounds());
|
||||
let menu = find(&nodes, "Select Similar Options").and_then(|n| n.bounds());
|
||||
assert_eq!(shown.is_some(), menu.is_some(), "{width}: the button and its menu show together");
|
||||
if let Some(b) = menu {
|
||||
assert!(b.x1 <= f64::from(width), "{width}: {b:?}");
|
||||
assert!(find(&nodes, "Horizontal Align Left").is_some(), "{width}: shown without the Align buttons");
|
||||
let x = nodes.iter().find(|(_, n)| n.label() == Some("X:") || n.value() == Some("X:")).and_then(|(_, n)| n.bounds());
|
||||
assert!(x.is_some_and(|x| x.x1 <= b.x0), "{width}: shown without the X field ahead of it: {x:?}");
|
||||
}
|
||||
seen = shown.is_some();
|
||||
}
|
||||
assert!(seen, "the widest bar has room for it");
|
||||
// With the Transform group off they still show at the end of a wide bar, after the Align
|
||||
// buttons.
|
||||
app.run("window.control.options", json!({"group": "transform", "visible": false})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
let nodes = frame(&mut app, vec![]);
|
||||
let (button, align) = (find(&nodes, "Select Similar Objects"), find(&nodes, "Horizontal Align Left"));
|
||||
let (Some(b), Some(a)) = (button.and_then(|n| n.bounds()), align.and_then(|n| n.bounds())) else {
|
||||
panic!("Transform off: the button and the Align buttons show")
|
||||
};
|
||||
assert!(a.x1 <= b.x0, "after the Align buttons: {a:?} {b:?}");
|
||||
// With the Transform group off, its link and X/Y/W/H fields take no room: the button and its
|
||||
// menu show in the narrowest bar that holds them after every control ahead of them, and never
|
||||
// while a control ahead of them is left out.
|
||||
let mut first = None;
|
||||
for width in (600..=2400).step_by(10) {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width as f32, 300.0));
|
||||
let mut nodes = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
nodes = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default();
|
||||
}
|
||||
let Some(b) = find(&nodes, "Select Similar Options").and_then(|n| n.bounds()) else {
|
||||
continue;
|
||||
};
|
||||
for name in ["Stroke:", "Opacity:", "Horizontal Align Left", "Recolor Artwork"] {
|
||||
assert!(find(&nodes, name).is_some(), "{width}: shown without {name} ahead of it");
|
||||
}
|
||||
first.get_or_insert((width, b.x1));
|
||||
}
|
||||
let Some((width, end)) = first else { panic!("Transform off: a wide bar has room for them") };
|
||||
let free = f64::from(width) - end;
|
||||
assert!(free < 120.0, "Transform off: the narrowest bar that shows them, {width} pt wide, leaves {free} pt after them");
|
||||
}
|
||||
|
||||
/// At any width the Control bar shows the inline X, Y, W and H fields whole inside its right margin,
|
||||
/// or leaves all four out and keeps the Transform link; a wider bar never leaves them out once a
|
||||
/// narrower one showed them. The Align buttons, and for two objects the Distribute buttons, are
|
||||
|
||||
@@ -84,6 +84,24 @@ pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool {
|
||||
offer(app, &name, &bytes, path, Some(p))
|
||||
}
|
||||
|
||||
/// The `dxf` options a drawing `file.place` params `p` name is placed with when Show Import Options
|
||||
/// is off: the choices confirmed last (fit, lineweights, centring, layers), the drawing at 1:1 in
|
||||
/// its own unit. `None` when `p` names no `.dxf` drawing or already gives `dxf` options.
|
||||
pub fn last_options(app: &VectorcraftApp, p: &Value) -> Option<Value> {
|
||||
let named = p.get("path").or_else(|| p.get("name")).and_then(Value::as_str).map(fileio::extension);
|
||||
if p.get("dxf").is_some() || named.as_deref() != Some("dxf") {
|
||||
return None;
|
||||
}
|
||||
Some(remembered(app))
|
||||
}
|
||||
|
||||
/// The `dxf` options of the choices confirmed last (fit, lineweights, centring, layers; `{}` when
|
||||
/// none was).
|
||||
pub fn remembered(app: &VectorcraftApp) -> Value {
|
||||
let last = app.ui.dxf_import.as_object();
|
||||
Value::Object(REMEMBERED.iter().filter_map(|k| last?.get(*k).filter(|v| v.is_boolean()).map(|v| (k.to_string(), v.clone()))).collect())
|
||||
}
|
||||
|
||||
/// The `dxf` options of `document.open` and `file.place` the fields set.
|
||||
fn options(d: &Dialog) -> Value {
|
||||
let fit = d.bool("fit");
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! Fields: `mode` (`open` | `place`), `name`, `path?`, `pageCount`, `page` (the page shown, and the
|
||||
//! one placed; 1-based), `allPages`, `range` and `linkPages` (open: Import PDF Pages as Links, for
|
||||
//! a file read from a path and opened without a password), `cropTo` and `password`. `__locked` is set
|
||||
//! until the password is accepted: OK then unlocks the file. `__place` keeps the other `file.place`
|
||||
//! until the password is accepted: OK then unlocks the file, and with `__passwordOnly` (Place with
|
||||
//! Show Import Options off) places its first page at once. `__place` keeps the other `file.place`
|
||||
//! params (Link, Template, `at`…). The file's bytes stay out of the fields, in
|
||||
//! [`crate::state::UiState::dialog_file`].
|
||||
|
||||
@@ -168,6 +169,33 @@ pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool {
|
||||
offer(app, &name, &bytes, path, Some(p))
|
||||
}
|
||||
|
||||
/// [`offer_place`] when Show Import Options is off: asks (true) only for the password of an
|
||||
/// encrypted PDF given no `password`, which nothing else opens, and places its first page (or
|
||||
/// `page`) and crop box once it is unlocked; any other PDF is placed that way without asking.
|
||||
pub fn offer_place_password(app: &mut VectorcraftApp, p: &Value) -> bool {
|
||||
// A `page` that isn't a page number is left for `file.place` to refuse.
|
||||
if p.get("password").is_some() || p.get("page").is_some_and(|v| v.as_u64().is_none_or(|n| n == 0)) {
|
||||
return false;
|
||||
}
|
||||
let Some((name, bytes, path)) = place_source(app, p, |ext| matches!(ext, "pdf" | "ai" | "ait")) else { return false };
|
||||
if !matches!(vectorcraft_pdf::open_pdf(&bytes, None).map(|_| ()), Err(PdfError::NeedsPassword)) {
|
||||
return false;
|
||||
}
|
||||
let offered = offer(app, &name, &bytes, path, Some(p));
|
||||
if let (true, Some(d)) = (offered, app.ui.dialog.as_mut()) {
|
||||
d.fields.insert("__passwordOnly".into(), json!(true));
|
||||
// A `page` and `crop` given are placed as given, for `file.place` to check.
|
||||
if let Some(place) = d.fields.get_mut("__place").and_then(Value::as_object_mut) {
|
||||
for k in ["page", "crop"] {
|
||||
if let Some(v) = p.get(k) {
|
||||
place.insert(k.into(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
offered
|
||||
}
|
||||
|
||||
fn page_count(d: &Dialog) -> usize {
|
||||
d.fields.get("pageCount").and_then(Value::as_u64).unwrap_or(0) as usize
|
||||
}
|
||||
@@ -204,7 +232,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let info = vectorcraft_pdf::info(&file.bytes, password(&d).as_deref()).map_err(|e| e.to_string())?;
|
||||
d.fields.insert("__locked".into(), json!(false));
|
||||
d.fields.insert("pageCount".into(), json!(info.pages.len()));
|
||||
if info.pages.len() > 1 {
|
||||
if info.pages.len() > 1 && !d.bool("__passwordOnly") {
|
||||
// Unlocked: now pick the pages.
|
||||
app.ui.dialog = Some(d);
|
||||
return Ok(Value::Null);
|
||||
@@ -214,8 +242,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string);
|
||||
let r = if d.str("mode") == "place" {
|
||||
let mut p = with_file(d.fields.get("__place"), &d, &file);
|
||||
p["page"] = json!(page(&d));
|
||||
p["crop"] = json!(crop(&d).id());
|
||||
// Asked only for the password: a `page` and `crop` given with the file stand.
|
||||
let [page_given, crop_given] = ["page", "crop"].map(|k| d.bool("__passwordOnly") && p.get(k).is_some());
|
||||
if !page_given {
|
||||
p["page"] = json!(page(&d));
|
||||
}
|
||||
if !crop_given {
|
||||
p["crop"] = json!(crop(&d).id());
|
||||
}
|
||||
if let Some(pw) = password(&d) {
|
||||
p["password"] = json!(pw);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! File → Place: the files picked, one line each about what they are, and the Place options row
|
||||
//! (Link, Template, Replace). OK places one file centred in the view (or in place of the selected
|
||||
//! object) or loads the place cursor with several ([`crate::place::confirm`]).
|
||||
//! (Link, Template, Replace, Show Import Options). OK places one file centred in the view (or in
|
||||
//! place of the selected object) or loads the place cursor with several ([`crate::place::confirm`]).
|
||||
//!
|
||||
//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one file
|
||||
//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, `showImportOptions` (each
|
||||
//! PDF, DXF or text file asks for its import options before it is placed), and `__replace` (one file
|
||||
//! and one selected object: Replace applies), `__documents` and `__others` (VectorCraft documents
|
||||
//! from files are picked, other files are: what Link's tooltip explains), `__info` (a line per
|
||||
//! file; on the desktop the lines are read off the UI thread and are empty until they are read,
|
||||
@@ -29,10 +30,15 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
};
|
||||
|
||||
/// The options row: (field, label, tooltip).
|
||||
const OPTIONS: [(&str, &str, &str); 3] = [
|
||||
const OPTIONS: [(&str, &str, &str); 4] = [
|
||||
("link", "Link", "Keep a link to an image file instead of only embedding it"),
|
||||
("template", "Template", "Place onto a new locked, dimmed template layer below the current layer"),
|
||||
("replace", "Replace", "Swap the selected object for the file, keeping its place and transform"),
|
||||
(
|
||||
"showImportOptions",
|
||||
"Show Import Options",
|
||||
"Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last",
|
||||
),
|
||||
];
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
@@ -73,7 +79,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
// The options row.
|
||||
ui.horizontal(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 18.0;
|
||||
for (key, label, tip) in OPTIONS {
|
||||
let on = d.bool(key);
|
||||
|
||||
@@ -299,14 +299,17 @@ fn code_view(app: &VectorcraftApp, ui: &mut egui::Ui, d: &Dialog) {
|
||||
Some((_, c)) => c,
|
||||
None => {
|
||||
const MAX: usize = 200_000;
|
||||
let params = json!({ "svg": opts });
|
||||
let encoded = vectorcraft_engine::cmd::fileio::check_linked_scripts("document.export", "svg", st).and_then(|()| {
|
||||
let params = json!({ "svg": opts });
|
||||
if selection {
|
||||
let (doc, _) = vectorcraft_engine::cmd::fileio::selection_doc(st, "document.exportSelection", false)?;
|
||||
vectorcraft_engine::cmd::fileio::encode_all(&doc, "svg", ¶ms)
|
||||
let selected;
|
||||
let doc = if selection {
|
||||
selected = vectorcraft_engine::cmd::fileio::selection_doc(st, "document.exportSelection", false)?.0;
|
||||
&selected
|
||||
} else {
|
||||
vectorcraft_engine::cmd::fileio::encode_all(&st.doc, "svg", ¶ms)
|
||||
}
|
||||
&st.doc
|
||||
};
|
||||
vectorcraft_engine::cmd::fileio::check_linked_images("document.export", "svg", st, doc, ¶ms)?;
|
||||
vectorcraft_engine::cmd::fileio::encode_all(doc, "svg", ¶ms)
|
||||
});
|
||||
let mut text = match encoded {
|
||||
// Shown as text whatever the file's encoding.
|
||||
@@ -446,6 +449,52 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
/// Show Code under read roots draws no SVG that would name a linked image's file outside them
|
||||
/// (Images: Link): it shows the error, which names no file or folder. Embedded pixels are
|
||||
/// shown, and without roots the link is shown.
|
||||
#[test]
|
||||
fn show_code_leaves_out_a_linked_image_file_outside_the_read_roots() {
|
||||
let tmp = std::env::temp_dir();
|
||||
let root = tmp.join(format!("vc-svg-image-root-{}", std::process::id()));
|
||||
let away = tmp.join(format!("vc-svg-image-away-{}", std::process::id()));
|
||||
for dir in [&root, &away] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let photo = away.join("photo.png").to_string_lossy().into_owned();
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 40, 40, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
std::fs::write(&photo, png).unwrap();
|
||||
let (mut app, _) = app("/tmp/code.svg");
|
||||
app.run("file.place", json!({"path": photo, "link": true, "at": [50, 50]})).unwrap();
|
||||
app.run("file.export.svg", Value::Null).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("showCode".into(), json!(true));
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap().unwrap();
|
||||
// The text the code view shows after two frames in one context, with `roots` in force.
|
||||
let code = |app: &mut VectorcraftApp, roots: Option<&Arc<vectorcraft_engine::file_access::AutomationRoots>>| {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
for _ in 0..2 {
|
||||
let mut out =
|
||||
vectorcraft_engine::file_access::confine(roots, || ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
ctx.data(|m| m.get_temp::<(u64, Arc<String>)>(egui::Id::unique("svg-code"))).map(|(_, c)| c.to_string()).unwrap_or_default()
|
||||
};
|
||||
let embedded = code(&mut app, Some(&roots));
|
||||
assert!(embedded.contains("data:image/png;base64,") && !embedded.contains(&photo), "{embedded}");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("images".into(), json!("link"));
|
||||
let confined = code(&mut app, Some(&roots));
|
||||
assert!(confined.contains("a linked image file is outside the read roots"), "{confined}");
|
||||
assert!(!confined.contains("photo.png") && !confined.contains("vc-svg-image"), "{confined}");
|
||||
let shown = code(&mut app, None);
|
||||
assert!(shown.contains(&format!("xlink:href=\"{photo}\"")), "{shown}");
|
||||
for dir in [root, away] {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show Code under Export Selection shows the SVG that OK writes: the selection alone, cropped
|
||||
/// to it with what its effects draw, not the whole document.
|
||||
#[test]
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//!
|
||||
//! Fields: `conflicts` (as `clipboard.conflicts` lists them: `[{name, document, clipboard, spot}]`),
|
||||
//! `index` (the one asked about), `choice` (`merge` or `add`), `applyToAll`, `choices` (the
|
||||
//! answers so far, by swatch name), `__command` and `__params` (the paste to run).
|
||||
//! answers so far, by swatch name), `__command` and `__params` (the paste to run), and `__doc`
|
||||
//! (the uid of the document it asks about).
|
||||
|
||||
use egui::vec2;
|
||||
use serde_json::{Map, Value, json};
|
||||
@@ -39,7 +40,7 @@ pub fn ask(app: &mut VectorcraftApp, command: &str, params: &Value) -> Option<Di
|
||||
let conflicts = found["swatches"].as_array().filter(|a| !a.is_empty())?.clone();
|
||||
let fields = json!({
|
||||
"conflicts": conflicts, "index": 0, "choice": "merge", "applyToAll": false, "choices": {},
|
||||
"__command": command, "__params": params,
|
||||
"__command": command, "__params": params, DOC: app.session.active().map(|doc| doc.uid),
|
||||
});
|
||||
if app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND) {
|
||||
crate::placing::cancel_swatch_paste(app);
|
||||
@@ -48,6 +49,9 @@ pub fn ask(app: &mut VectorcraftApp, command: &str, params: &Value) -> Option<Di
|
||||
Some(Ok(json!({ "dialog": KIND })))
|
||||
}
|
||||
|
||||
/// The field that holds the uid of the document the question is about.
|
||||
const DOC: &str = "__doc";
|
||||
|
||||
fn conflicts(d: &Dialog) -> &[Value] {
|
||||
d.fields.get("conflicts").and_then(Value::as_array).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
@@ -102,8 +106,14 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Record the answer; ask about the next conflict, or paste once every one is answered.
|
||||
/// Record the answer; ask about the next conflict, or paste once every one is answered. When
|
||||
/// another document has become the active one since an immediate paste asked, it closes as Cancel
|
||||
/// does and pastes nothing (a deferred paste goes back to its own document).
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
|
||||
if d.fields.get("__deferredPaste").is_none() && d.fields.get(DOC).and_then(Value::as_u64) != app.session.active().map(|doc| doc.uid) {
|
||||
app.ui.dialog = None;
|
||||
return Ok(json!({}));
|
||||
}
|
||||
let choice = d.str("choice");
|
||||
if !matches!(choice.as_str(), "merge" | "add") {
|
||||
return Err(format!("choice must be \"merge\" or \"add\", not `{choice}`"));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Text Import Options: placing a text file asks how to read it (platform and character set) and
|
||||
//! what to clean up (line breaks inside paragraphs, blank lines between them, runs of spaces). OK
|
||||
//! places it as area type (`file.place` with `text`).
|
||||
//! places it as area type (`file.place` with `text`) and remembers the options: the next text file
|
||||
//! starts from them, and is placed with them when Show Import Options is off.
|
||||
//!
|
||||
//! Fields: `platform` (`windows` | `mac`), `characterSet` (`unicode` | `ansi`),
|
||||
//! `removeLineReturns`, `removeParagraphReturns`, `replaceSpaces` (on or off), `spaces` (how many
|
||||
@@ -27,9 +28,10 @@ pub fn is_text(p: &Value) -> bool {
|
||||
vectorcraft_engine::cmd::fileio::TEXT_EXTS.contains(&vectorcraft_engine::cmd::fileio::extension(name).as_str())
|
||||
}
|
||||
|
||||
/// Ask how to place the text file `params` (`file.place` params) names.
|
||||
/// Ask how to place the text file `params` (`file.place` params) names, starting from the options
|
||||
/// confirmed last.
|
||||
pub fn open(app: &mut VectorcraftApp, params: &Value) -> Value {
|
||||
let fields = json!({
|
||||
let mut fields = json!({
|
||||
"platform": if cfg!(target_os = "macos") { "mac" } else { "windows" },
|
||||
"characterSet": "unicode",
|
||||
"removeLineReturns": false,
|
||||
@@ -38,10 +40,31 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) -> Value {
|
||||
"spaces": 3,
|
||||
"__params": params,
|
||||
});
|
||||
let last = &app.ui.text_import;
|
||||
for (k, wanted) in [("platform", ["windows", "mac"]), ("characterSet", ["unicode", "ansi"])] {
|
||||
if let Some(v) = last.get(k).and_then(Value::as_str).filter(|v| wanted.contains(v)) {
|
||||
fields[k] = json!(v);
|
||||
}
|
||||
}
|
||||
for k in ["removeLineReturns", "removeParagraphReturns"] {
|
||||
if let Some(v) = last.get(k).and_then(Value::as_bool) {
|
||||
fields[k] = json!(v);
|
||||
}
|
||||
}
|
||||
if let Some(n) = last.get("replaceSpaces").and_then(Value::as_u64).filter(|n| (2..=100).contains(n)) {
|
||||
fields["replaceSpaces"] = json!(true);
|
||||
fields["spaces"] = json!(n);
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
json!({ "dialog": KIND })
|
||||
}
|
||||
|
||||
/// The `text` options a text file is placed with when Show Import Options is off: those confirmed
|
||||
/// last, else `file.place`'s defaults.
|
||||
pub fn last_options(app: &VectorcraftApp) -> Value {
|
||||
if app.ui.text_import.is_object() { app.ui.text_import.clone() } else { json!({}) }
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
widgets::subheader(ui, tl!("Encoding"));
|
||||
form::choice(ui, d, "platform", tl!("Platform:"), (LABEL, 180.0), &[("windows", "Windows"), ("mac", "Mac")]);
|
||||
@@ -87,10 +110,12 @@ fn options(d: &Dialog) -> Value {
|
||||
o
|
||||
}
|
||||
|
||||
/// Places the file with the options; closes first, so an error shows in the status bar.
|
||||
/// Places the file with the options and remembers them for the next text file; closes first, so an
|
||||
/// error shows in the status bar.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut params = d.fields.get("__params").cloned().unwrap_or_else(|| json!({}));
|
||||
params["text"] = options(d);
|
||||
app.ui.text_import = params["text"].clone();
|
||||
app.ui.dialog = None;
|
||||
crate::place::run_for_user(app, ¶ms)
|
||||
}
|
||||
|
||||
@@ -3520,6 +3520,8 @@ digits Malayalam Малаялам
|
||||
Swap Fill and Stroke (Shift+X) Размяна на запълването и щриха (Shift+X)
|
||||
Swap start and end arrowheads Размяна на началната и крайната стрелка
|
||||
Swap the selected object for the file, keeping its place and transform Замяна на избрания обект с файла, като се запазват мястото и трансформациите му
|
||||
Show Import Options Показване на опциите за импортиране
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Питане за опциите за импортиране на всеки PDF, DXF или текстов файл преди поставянето му; изключено: използват се последно избраните опции
|
||||
Stylistic Alternates Стилистични алтернативи
|
||||
Set {n} Набор {n}
|
||||
Swash Декоративен елемент
|
||||
@@ -6150,6 +6152,8 @@ axis Y Axis Y ос
|
||||
Create New Set Създаване на нов набор
|
||||
Stop Global Edit Край на глобалното редактиране
|
||||
Global Edit Options Опции за глобално редактиране
|
||||
Select Similar Objects Избор на подобни обекти
|
||||
Select Similar Options Опции за избор на подобни
|
||||
Leave Out of Global Edit Изключване от глобалното редактиране
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them Глобалното редактиране работи с пътища, съставни пътища, екземпляри на символи и групи от тях
|
||||
Match Съвпадение
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2757,6 +2757,8 @@ digits Malayalam Malajalam
|
||||
Keep a link to an image file instead of only embedding it Zadrži vezu na slikovnu datoteku umjesto samo ugrađivanja
|
||||
Place onto a new locked, dimmed template layer below the current layer Postavi na novi zaključani, zatamnjeni sloj predloška ispod trenutnog sloja
|
||||
Swap the selected object for the file, keeping its place and transform Zamijeni odabrani objekat datotekom, zadržavajući njegovo mjesto i transformaciju
|
||||
Show Import Options Prikaži opcije uvoza
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Pitaj za opcije uvoza svake PDF, DXF ili tekstualne datoteke prije postavljanja; isključeno: koriste se posljednje odabrane opcije
|
||||
On: one locked object showing the document, updated when it changes. Uključeno: jedan zaključani objekat koji prikazuje dokument, ažuriran kad se on promijeni.
|
||||
Off: an editable copy of its art, embedded in this document. Isključeno: kopija njegovog crteža koja se može uređivati, ugrađena u ovaj dokument.
|
||||
Images: link to the file instead of only embedding it. Slike: poveži s datotekom umjesto samo ugrađivanja.
|
||||
@@ -6192,6 +6194,8 @@ axis Y Axis Y osa
|
||||
Create New Set Napravi novi skup
|
||||
Stop Global Edit Zaustavi globalno uređivanje
|
||||
Global Edit Options Opcije globalnog uređivanja
|
||||
Select Similar Objects Odaberi slične objekte
|
||||
Select Similar Options Opcije odabira sličnih
|
||||
Leave Out of Global Edit Isključi iz globalnog uređivanja
|
||||
Match Podudaranje
|
||||
On Artboards Na radnim pločama
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1854,6 +1854,8 @@ digits Malayalam Malaiàlam
|
||||
Global Adjust Ajust global
|
||||
Global Brush Dimensions Dimensions globals del pinzell
|
||||
Global Edit Options Opcions globals d’edició
|
||||
Select Similar Objects Selecciona objectes similars
|
||||
Select Similar Options Opcions de selecció de similars
|
||||
Leave Out of Global Edit Exclou de l’edició global
|
||||
Global Process Color Color de procés global
|
||||
Global Process Color, CMYK Color de procés global, CMYK
|
||||
@@ -4602,6 +4604,8 @@ digits Malayalam Malaiàlam
|
||||
Swap Fill and Stroke (Shift+X) Intercanvia el farciment i el traç (Maj+X)
|
||||
Swap start and end arrowheads Intercanvia les puntes de fletxa inicial i final
|
||||
Swap the selected object for the file, keeping its place and transform Substitueix l’objecte seleccionat pel fitxer, mantenint-ne la posició i la transformació
|
||||
Show Import Options Mostra les opcions d’importació
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Demana les opcions d’importació de cada fitxer PDF, DXF o de text abans de col·locar-lo; desactivat: s’usen les últimes opcions triades
|
||||
Stylistic Alternates Alternatives estilístiques
|
||||
Swash Volada
|
||||
Swatch Conflict Conflicte de mostres
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2792,6 +2792,8 @@ digits Malayalam Malajálamské
|
||||
Keep a link to an image file instead of only embedding it Ponechat propojení na soubor obrázku, ne jen ho vložit
|
||||
Place onto a new locked, dimmed template layer below the current layer Umístit do nové zamčené, ztlumené vrstvy šablony pod aktuální vrstvou
|
||||
Swap the selected object for the file, keeping its place and transform Nahradit vybraný objekt souborem se zachováním jeho polohy a transformace
|
||||
Show Import Options Zobrazit možnosti importu
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Před umístěním se zeptat na možnosti importu každého souboru PDF, DXF nebo textového souboru; vypnuto: použijí se naposledy zvolené možnosti
|
||||
On: one locked object showing the document, updated when it changes. Zapnuto: jeden zamčený objekt zobrazující dokument, aktualizovaný při jeho změně.
|
||||
Off: an editable copy of its art, embedded in this document. Vypnuto: upravitelná kopie jeho kresby vložená do tohoto dokumentu.
|
||||
Images: link to the file instead of only embedding it. Obrázky: propojit se souborem místo pouhého vložení.
|
||||
@@ -6698,6 +6700,8 @@ axis Y Axis osa Y
|
||||
Create New Set Vytvořit novou sadu
|
||||
Stop Global Edit Ukončit globální úpravy
|
||||
Global Edit Options Možnosti globálních úprav
|
||||
Select Similar Objects Vybrat podobné objekty
|
||||
Select Similar Options Možnosti výběru podobných
|
||||
Leave Out of Global Edit Vyřadit z globálních úprav
|
||||
Match Shoda
|
||||
On Artboards Na pracovních plochách
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -3183,6 +3183,8 @@ digits Malayalam Malayalam
|
||||
Keep a link to an image file instead of only embedding it Behold et link til en billedfil i stedet for kun at indlejre den
|
||||
Place onto a new locked, dimmed template layer below the current layer Placér på et nyt låst, nedtonet skabelonlag under det aktuelle lag
|
||||
Swap the selected object for the file, keeping its place and transform Udskift det markerede objekt med filen, mens placering og transformering bevares
|
||||
Show Import Options Vis importindstillinger
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Spørg om importindstillinger for hver PDF-, DXF- eller tekstfil, før den placeres; fra: brug de senest valgte indstillinger
|
||||
On: one locked object showing the document, updated when it changes. Til: ét låst objekt, der viser dokumentet, og som opdateres, når det ændres.
|
||||
Off: an editable copy of its art, embedded in this document. Fra: en redigerbar kopi af dens grafik, indlejret i dette dokument.
|
||||
Images: link to the file instead of only embedding it. Billeder: link til filen i stedet for kun at indlejre den.
|
||||
@@ -6776,6 +6778,8 @@ axis Y Axis Y-akse
|
||||
Create New Set Opret nyt sæt
|
||||
Stop Global Edit Stop global redigering
|
||||
Global Edit Options Indstillinger for global redigering
|
||||
Select Similar Objects Vælg lignende objekter
|
||||
Select Similar Options Indstillinger for valg af lignende
|
||||
Leave Out of Global Edit Udelad fra global redigering
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them Global redigering virker på stier, sammensatte stier, symbolforekomster og grupper af dem
|
||||
Match Match
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3583,6 +3583,8 @@ effect Points Punkte
|
||||
Swap Fill and Stroke (Shift+X) Fläche und Kontur vertauschen (Shift+X)
|
||||
Swap start and end arrowheads Pfeilspitzen an Anfang und Ende vertauschen
|
||||
Swap the selected object for the file, keeping its place and transform Das ausgewählte Objekt durch die Datei ersetzen; Position und Transformation bleiben erhalten
|
||||
Show Import Options Importoptionen anzeigen
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Vor dem Platzieren nach den Importoptionen jeder PDF-, DXF- oder Textdatei fragen; aus: die zuletzt gewählten Optionen verwenden
|
||||
Stylistic Alternates Stilistische Varianten
|
||||
Swash Schwungschrift
|
||||
Swatch Conflict Farbfeldkonflikt
|
||||
@@ -6288,6 +6290,8 @@ axis Y Axis Y-Achse
|
||||
Create New Set Neuen Satz erstellen
|
||||
Stop Global Edit Globale Bearbeitung beenden
|
||||
Global Edit Options Optionen für globale Bearbeitung
|
||||
Select Similar Objects Ähnliche Objekte auswählen
|
||||
Select Similar Options Optionen für ähnliche Objekte
|
||||
Leave Out of Global Edit Von globaler Bearbeitung ausschließen
|
||||
Match Übereinstimmung
|
||||
On Artboards Auf Zeichenflächen
|
||||
@@ -8995,6 +8999,7 @@ breakText Glyphs Glyphen
|
||||
@msg a line can't run from a point to itself eine Linie kann nicht von einem Punkt zu sich selbst verlaufen
|
||||
@msg a line needs at least two points eine Linie braucht mindestens zwei Punkte
|
||||
@msg a linked JavaScript file is outside the read roots eine verknüpfte JavaScript-Datei liegt außerhalb der Lesestammordner
|
||||
@msg a linked image file is outside the read roots eine verknüpfte Bilddatei liegt außerhalb der Lesestammordner
|
||||
@msg a number cannot be read eine Zahl kann nicht gelesen werden
|
||||
@msg a swatch exchange block is too long ein Block der Farbfeld-Austauschdatei ist zu lang
|
||||
@msg a swatch exchange name is too long ein Name in der Farbfeld-Austauschdatei ist zu lang
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -4108,6 +4108,8 @@ digits Malayalam Μαλαγιάλαμ
|
||||
Swap Fill and Stroke (Shift+X) Εναλλαγή γεμίσματος και περιγράμματος (Shift+X)
|
||||
Swap start and end arrowheads Εναλλαγή αρχικής και τελικής αιχμής βέλους
|
||||
Swap the selected object for the file, keeping its place and transform Αντικατάσταση επιλεγμένου αντικειμένου με το αρχείο, διατηρώντας θέση και μετασχηματισμό
|
||||
Show Import Options Εμφάνιση επιλογών εισαγωγής
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Ερώτηση για τις επιλογές εισαγωγής κάθε αρχείου PDF, DXF ή κειμένου πριν από την τοποθέτηση· απενεργοποιημένο: χρήση των τελευταίων επιλογών
|
||||
Swatch Conflict Διένεξη δείγματος χρώματος
|
||||
Swatch Conflicts Διενέξεις δειγμάτων χρωμάτων
|
||||
Swatch exchange files keep solid colors only. Τα αρχεία ανταλλαγής δειγμάτων διατηρούν μόνο συμπαγή χρώματα.
|
||||
@@ -6654,6 +6656,8 @@ axis Y Axis Άξονας Y
|
||||
Create New Set Δημιουργία νέου συνόλου
|
||||
Stop Global Edit Τερματισμός καθολικής επεξεργασίας
|
||||
Global Edit Options Επιλογές καθολικής επεξεργασίας
|
||||
Select Similar Objects Επιλογή παρόμοιων αντικειμένων
|
||||
Select Similar Options Επιλογές επιλογής παρόμοιων
|
||||
Leave Out of Global Edit Εξαίρεση από την καθολική επεξεργασία
|
||||
Match Αντιστοίχιση
|
||||
On Artboards Σε πίνακες σχεδίασης
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -3612,6 +3612,8 @@ digits Malayalam Malayálam
|
||||
Swap Fill and Stroke (Shift+X) Intercambiar relleno y trazo (Shift+X)
|
||||
Swap start and end arrowheads Intercambiar puntas de flecha inicial y final
|
||||
Swap the selected object for the file, keeping its place and transform Sustituir el objeto seleccionado por el archivo, conservando su posición y transformación
|
||||
Show Import Options Mostrar opciones de importación
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Preguntar las opciones de importación de cada archivo PDF, DXF o de texto antes de colocarlo; desactivado: usar las últimas opciones elegidas
|
||||
Stylistic Alternates Alternativas estilísticas
|
||||
Swash Decorativa
|
||||
Swatch Conflict Conflicto de muestras
|
||||
@@ -6643,6 +6645,8 @@ axis Y Axis Eje Y
|
||||
Create New Set Crear nuevo conjunto
|
||||
Stop Global Edit Detener edición global
|
||||
Global Edit Options Opciones de edición global
|
||||
Select Similar Objects Seleccionar objetos similares
|
||||
Select Similar Options Opciones de selección de similares
|
||||
Leave Out of Global Edit Excluir de la edición global
|
||||
Match Coincidir
|
||||
On Artboards En mesas de trabajo
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2736,6 +2736,8 @@ digits Malayalam Malajalam
|
||||
Keep a link to an image file instead of only embedding it Säilytä linkki kuvatiedostoon sen sijaan, että kuva vain upotetaan
|
||||
Place onto a new locked, dimmed template layer below the current layer Sijoita uudelle lukitulle, himmennetylle mallitasolle nykyisen tason alapuolelle
|
||||
Swap the selected object for the file, keeping its place and transform Korvaa valittu objekti tiedostolla säilyttäen sen paikan ja muunnoksen
|
||||
Show Import Options Näytä tuontiasetukset
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Kysy kunkin PDF-, DXF- tai tekstitiedoston tuontiasetukset ennen sijoittamista; pois: käytä viimeksi valittuja asetuksia
|
||||
On: one locked object showing the document, updated when it changes. Päällä: yksi lukittu objekti, joka näyttää asiakirjan ja päivittyy sen muuttuessa.
|
||||
Off: an editable copy of its art, embedded in this document. Pois: muokattava kopio sen kuvituksesta upotettuna tähän asiakirjaan.
|
||||
Images: link to the file instead of only embedding it. Kuvat: linkitä tiedostoon sen sijaan, että se vain upotetaan.
|
||||
@@ -6732,6 +6734,8 @@ axis Y Axis Y-akseli
|
||||
Create New Set Luo uusi sarja
|
||||
Stop Global Edit Lopeta yleismuokkaus
|
||||
Global Edit Options Yleismuokkauksen asetukset
|
||||
Select Similar Objects Valitse samankaltaiset objektit
|
||||
Select Similar Options Samankaltaisten valinnan asetukset
|
||||
Leave Out of Global Edit Jätä pois yleismuokkauksesta
|
||||
Match Vastaavuus
|
||||
On Artboards Piirtoalustoilla
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3604,6 +3604,8 @@ digits Malayalam Malayalam
|
||||
Swap Fill and Stroke (Shift+X) Permuter le fond et le contour (Maj+X)
|
||||
Swap start and end arrowheads Permuter les pointes de flèche de début et de fin
|
||||
Swap the selected object for the file, keeping its place and transform Remplacer l’objet sélectionné par le fichier, en conservant sa position et sa transformation
|
||||
Show Import Options Afficher les options d’importation
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Demander les options d’importation de chaque fichier PDF, DXF ou texte avant de le placer, sinon utiliser les dernières options choisies
|
||||
Stylistic Alternates Variantes stylistiques
|
||||
Swash Lettres ornées
|
||||
Swatch Conflict Conflit de nuances
|
||||
@@ -6693,6 +6695,8 @@ axis Y Axis Axe Y
|
||||
Create New Set Créer un jeu
|
||||
Stop Global Edit Arrêter la modification globale
|
||||
Global Edit Options Options de modification globale
|
||||
Select Similar Objects Sélectionner les objets similaires
|
||||
Select Similar Options Options de sélection des objets similaires
|
||||
Leave Out of Global Edit Exclure de la modification globale
|
||||
Match Correspondance
|
||||
On Artboards Sur les plans de travail
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -1510,6 +1510,8 @@
|
||||
Global ગ્લોબલ
|
||||
Global Brush Dimensions ગ્લોબલ બ્રશ પરિમાણો
|
||||
Global Edit Options ગ્લોબલ એડિટ વિકલ્પો
|
||||
Select Similar Objects સમાન ઑબ્જેક્ટ પસંદ કરો
|
||||
Select Similar Options સમાન પસંદગીના વિકલ્પો
|
||||
Global Process Color ગ્લોબલ પ્રોસેસ રંગ
|
||||
Glowing Edges… ગ્લોઇંગ એજિસ…
|
||||
Glyph Bounds ગ્લિફ બાઉન્ડ્સ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 72 and column 5.
|
@@ -2070,6 +2070,8 @@ antiAlias Sharp शार्प
|
||||
Global Adjust वैश्विक समायोजन
|
||||
Global Brush Dimensions ग्लोबल ब्रश आयाम
|
||||
Global Edit Options ग्लोबल एडिट ऑप्शन्स
|
||||
Select Similar Objects समान ऑब्जेक्ट चुनें
|
||||
Select Similar Options समान चयन विकल्प
|
||||
Global Process Color ग्लोबल प्रोसेस कलर
|
||||
Global Process Color, CMYK ग्लोबल प्रोसेस कलर, CMYK
|
||||
Glow Amount ग्लो मात्रा
|
||||
@@ -4983,6 +4985,8 @@ antiAlias Sharp शार्प
|
||||
Swap Objects and Sizes स्वैप ऑब्जेक्ट्स और साइज
|
||||
Swap start and end arrowheads स्वैप शुरू और अंत तीरहेड्स
|
||||
Swap the selected object for the file, keeping its place and transform फ़ाइल के लिए सिलेक्टेड ऑब्जेक्ट को स्वैप करें, अपनी जगह को बनाए रखें और परिवर्तित करें
|
||||
Show Import Options आयात विकल्प दिखाएँ
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last रखने से पहले हर PDF, DXF या टेक्स्ट फ़ाइल के आयात विकल्प पूछें; बंद होने पर पिछली बार चुने गए विकल्प इस्तेमाल करें
|
||||
Swash स्वॉश
|
||||
Swatch Conflict स्वॉच विरोध
|
||||
Swatch Conflicts स्वॉच विरोध
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -4300,6 +4300,8 @@ mark-style Japanese Japán
|
||||
Swap Fill and Stroke (Shift+X) Kitöltés és körvonal cseréje (Shift+X)
|
||||
Swap start and end arrowheads Kezdő és záró nyílhegy cseréje
|
||||
Swap the selected object for the file, keeping its place and transform A kijelölt objektum cseréje a fájlra, a helye és az átalakítása megtartásával
|
||||
Show Import Options Importálási beállítások megjelenítése
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Elhelyezés előtt rákérdez minden PDF-, DXF- vagy szövegfájl importálási beállításaira; kikapcsolva a legutóbb választott beállításokat használja
|
||||
Stylistic Alternates Stilisztikai változatok
|
||||
Swash Cifra változatok
|
||||
Swatch Conflict Színminta-ütközés
|
||||
@@ -6681,6 +6683,8 @@ axis Y Axis Y tengely
|
||||
Create New Set Új készlet létrehozása
|
||||
Stop Global Edit Globális szerkesztés befejezése
|
||||
Global Edit Options Globális szerkesztés beállításai
|
||||
Select Similar Objects Hasonló objektumok kijelölése
|
||||
Select Similar Options Hasonlók kijelölésének beállításai
|
||||
Leave Out of Global Edit Kizárás a globális szerkesztésből
|
||||
Match Egyezés
|
||||
On Artboards Rajztáblákon
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2574,6 +2574,8 @@ digits Malayalam Malayalam
|
||||
Keep a link to an image file instead of only embedding it Pertahankan tautan ke file gambar alih-alih hanya menyematkannya
|
||||
Place onto a new locked, dimmed template layer below the current layer Tempatkan pada lapisan templat baru yang terkunci dan diredupkan di bawah lapisan saat ini
|
||||
Swap the selected object for the file, keeping its place and transform Tukar objek terpilih dengan file, mempertahankan tempat dan transformasinya
|
||||
Show Import Options Tampilkan Opsi Impor
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Tanyakan opsi impor setiap file PDF, DXF, atau teks sebelum menempatkannya; nonaktif: gunakan opsi yang terakhir dipilih
|
||||
On: one locked object showing the document, updated when it changes. Aktif: satu objek terkunci yang menampilkan dokumen, diperbarui saat dokumen berubah.
|
||||
Off: an editable copy of its art, embedded in this document. Nonaktif: salinan karya seninya yang dapat diedit, tersemat dalam dokumen ini.
|
||||
Images: link to the file instead of only embedding it. Gambar: tautkan ke file alih-alih hanya menyematkannya.
|
||||
@@ -6166,6 +6168,8 @@ axis Y Axis Sumbu Y
|
||||
Create New Set Buat set baru
|
||||
Stop Global Edit Hentikan edit global
|
||||
Global Edit Options Opsi edit global
|
||||
Select Similar Objects Pilih objek serupa
|
||||
Select Similar Options Opsi pilih serupa
|
||||
Leave Out of Global Edit Keluarkan dari edit global
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them Edit global berfungsi pada jalur, jalur gabungan, instans simbol, dan grupnya
|
||||
Match Cocokkan
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2458 and column 5.
|
@@ -3658,6 +3658,8 @@ digits Malayalam Malayalam
|
||||
Swap Fill and Stroke (Shift+X) Inverti riempimento e traccia (Shift+X)
|
||||
Swap start and end arrowheads Inverti punte di freccia iniziale e finale
|
||||
Swap the selected object for the file, keeping its place and transform Sostituisci l'oggetto selezionato con il file, mantenendone posizione e trasformazione
|
||||
Show Import Options Mostra opzioni di importazione
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Chiedi le opzioni di importazione di ogni file PDF, DXF o di testo prima di inserirlo; disattivato: usa le ultime opzioni scelte
|
||||
Stylistic Alternates Alternative stilistiche
|
||||
Swash Ornamentale
|
||||
Swatch Conflict Conflitto campioni
|
||||
@@ -6324,6 +6326,8 @@ axis Y Axis Asse Y
|
||||
Create New Set Crea nuovo set
|
||||
Stop Global Edit Interrompi modifica globale
|
||||
Global Edit Options Opzioni modifica globale
|
||||
Select Similar Objects Seleziona oggetti simili
|
||||
Select Similar Options Opzioni selezione oggetti simili
|
||||
Leave Out of Global Edit Escludi dalla modifica globale
|
||||
Match Corrispondenza
|
||||
On Artboards Sulle tavole da disegno
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -4058,6 +4058,8 @@ file-filter Templates テンプレート
|
||||
Superscript 上付き文字
|
||||
Swap Fill and Stroke (Shift+X) 塗りと線を入れ替え (Shift+X)
|
||||
Swap the selected object for the file, keeping its place and transform 選択したオブジェクトを、位置と変形を保ったままファイルに置き換え
|
||||
Show Import Options 読み込みオプションを表示
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last 配置する前に PDF、DXF、テキストファイルごとの読み込みオプションを確認。オフのときは前回選んだオプションを使用
|
||||
Swatch Libraries Menu スウォッチライブラリメニュー
|
||||
System Layout システムレイアウト
|
||||
Tabular Lining 等幅ライニング
|
||||
@@ -8197,6 +8199,8 @@ axis Y Axis Y 軸
|
||||
Create New Set 新規セットを作成
|
||||
Stop Global Edit グローバル編集を終了
|
||||
Global Edit Options グローバル編集オプション
|
||||
Select Similar Objects 類似オブジェクトを選択
|
||||
Select Similar Options 類似オブジェクトの選択オプション
|
||||
Leave Out of Global Edit グローバル編集から除外
|
||||
Match 一致
|
||||
On Artboards アートボード上
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2496,6 +2496,8 @@ digits Malayalam მალაიალამური
|
||||
Keep a link to an image file instead of only embedding it გამოსახულების ფაილის ბმულის შენარჩუნება მხოლოდ ჩაშენების ნაცვლად
|
||||
Place onto a new locked, dimmed template layer below the current layer ახალ ჩაკეტილ, ჩამქრალ შაბლონის შრეზე განთავსება მიმდინარე შრის ქვემოთ
|
||||
Swap the selected object for the file, keeping its place and transform მონიშნული ობიექტის ფაილით ჩანაცვლება მისი ადგილისა და გარდაქმნის შენარჩუნებით
|
||||
Show Import Options იმპორტის პარამეტრების ჩვენება
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last განთავსებამდე თითოეული PDF, DXF ან ტექსტური ფაილის იმპორტის პარამეტრების მოთხოვნა; გამორთულისას ბოლოს არჩეული პარამეტრები გამოიყენება
|
||||
On: one locked object showing the document, updated when it changes. ჩართული: ერთი ჩაკეტილი ობიექტი, რომელიც დოკუმენტს აჩვენებს და მისი ცვლილებისას ახლდება.
|
||||
Off: an editable copy of its art, embedded in this document. გამორთული: მისი ნახატის რედაქტირებადი ასლი, ჩაშენებული ამ დოკუმენტში.
|
||||
Images: link to the file instead of only embedding it. გამოსახულებები: ფაილის ბმული მხოლოდ ჩაშენების ნაცვლად.
|
||||
@@ -6653,6 +6655,8 @@ axis Y Axis Y ღერძი
|
||||
Create New Set ახალი ნაკრების შექმნა
|
||||
Stop Global Edit გლობალური რედაქტირების დასრულება
|
||||
Global Edit Options გლობალური რედაქტირების პარამეტრები
|
||||
Select Similar Objects მსგავსი ობიექტების მონიშვნა
|
||||
Select Similar Options მსგავსის მონიშვნის პარამეტრები
|
||||
Leave Out of Global Edit გლობალური რედაქტირებიდან გამორიცხვა
|
||||
Match დამთხვევა
|
||||
On Artboards სამუშაო დაფებზე
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -4046,6 +4046,8 @@ file-filter Templates 템플릿
|
||||
Superscript 위 첨자
|
||||
Swap Fill and Stroke (Shift+X) 칠과 선 바꾸기 (Shift+X)
|
||||
Swap the selected object for the file, keeping its place and transform 선택한 개체를 파일로 바꾸되 위치와 변형은 유지합니다
|
||||
Show Import Options 가져오기 옵션 표시
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last 배치하기 전에 각 PDF, DXF 또는 텍스트 파일의 가져오기 옵션을 묻습니다. 끄면 마지막으로 선택한 옵션을 사용합니다
|
||||
Swatch Libraries Menu 견본 라이브러리 메뉴
|
||||
System Layout 시스템 레이아웃
|
||||
Tabular Lining 고정폭 라이닝
|
||||
@@ -6285,6 +6287,8 @@ axis Y Axis Y축
|
||||
Create New Set 새 세트 만들기
|
||||
Stop Global Edit 전체 편집 중지
|
||||
Global Edit Options 전체 편집 옵션
|
||||
Select Similar Objects 유사한 개체 선택
|
||||
Select Similar Options 유사 개체 선택 옵션
|
||||
Leave Out of Global Edit 전체 편집에서 제외
|
||||
Match 일치
|
||||
On Artboards 아트보드에서
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -3092,6 +3092,8 @@ digits Malayalam Malayalam
|
||||
Keep a link to an image file instead of only embedding it Een koppeling met een afbeeldingsbestand behouden in plaats van het alleen in te sluiten
|
||||
Place onto a new locked, dimmed template layer below the current layer Plaatsen op een nieuwe, vergrendelde, gedimde sjabloonlaag onder de huidige laag
|
||||
Swap the selected object for the file, keeping its place and transform Het geselecteerde object vervangen door het bestand, met behoud van positie en transformatie
|
||||
Show Import Options Importopties tonen
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Vóór het plaatsen naar de importopties van elk PDF-, DXF- of tekstbestand vragen; uit: de laatst gekozen opties gebruiken
|
||||
On: one locked object showing the document, updated when it changes. Aan: één vergrendeld object dat het document weergeeft, bijgewerkt wanneer het verandert.
|
||||
Off: an editable copy of its art, embedded in this document. Uit: een bewerkbare kopie van de artwork, ingesloten in dit document.
|
||||
Images: link to the file instead of only embedding it. Afbeeldingen: koppel aan het bestand in plaats van het alleen in te sluiten.
|
||||
@@ -6028,6 +6030,7 @@ imageTrace Outline Contour
|
||||
@msg a layer's form has no dictionary het formulier van een laag heeft geen woordenboek
|
||||
@msg a legend draws at most {MAX_CHIPS} swatches een legenda tekent maximaal {MAX_CHIPS} stalen
|
||||
@msg a linked JavaScript file is outside the read roots een gekoppeld JavaScript-bestand staat buiten de leesroots
|
||||
@msg a linked image file is outside the read roots een gekoppeld afbeeldingsbestand staat buiten de leesroots
|
||||
@msg a name names one asset: give multiple: false, or one object een naam noemt één asset: geef multiple: false, of één object
|
||||
@msg a namespace declaration uses a reserved prefix or URI een naamruimtedeclaratie gebruikt een gereserveerd voorvoegsel of een gereserveerde URI
|
||||
@msg a namespace qualifier is undefined een naamruimtekwalificatie is niet gedefinieerd
|
||||
@@ -8612,6 +8615,8 @@ axis Y Axis Y-as
|
||||
Create New Set Nieuwe set maken
|
||||
Stop Global Edit Globale bewerking stoppen
|
||||
Global Edit Options Opties voor globale bewerking
|
||||
Select Similar Objects Vergelijkbare objecten selecteren
|
||||
Select Similar Options Opties voor vergelijkbare objecten selecteren
|
||||
Leave Out of Global Edit Uitsluiten van globale bewerking
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them Globale bewerking werkt op paden, samengestelde paden, symboolinstanties en groepen daarvan
|
||||
Match Overeenkomst
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2705,6 +2705,8 @@ digits Malayalam Malajalam
|
||||
Keep a link to an image file instead of only embedding it Zachowaj łącze do pliku obrazu zamiast tylko go osadzać
|
||||
Place onto a new locked, dimmed template layer below the current layer Umieść na nowej zablokowanej, przyciemnionej warstwie szablonu pod bieżącą warstwą
|
||||
Swap the selected object for the file, keeping its place and transform Zamień zaznaczony obiekt na plik, zachowując jego miejsce i przekształcenie
|
||||
Show Import Options Pokaż opcje importu
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Pytaj o opcje importu każdego pliku PDF, DXF lub tekstowego przed umieszczeniem; wył.: używaj ostatnio wybranych opcji
|
||||
On: one locked object showing the document, updated when it changes. Wł.: jeden zablokowany obiekt pokazujący dokument, aktualizowany przy jego zmianie.
|
||||
Off: an editable copy of its art, embedded in this document. Wył.: edytowalna kopia jego kompozycji, osadzona w tym dokumencie.
|
||||
Images: link to the file instead of only embedding it. Obrazy: połącz z plikiem zamiast tylko go osadzać.
|
||||
@@ -6553,6 +6555,8 @@ axis Y Axis Oś Y
|
||||
Create New Set Utwórz nowy zestaw
|
||||
Stop Global Edit Zakończ edycję globalną
|
||||
Global Edit Options Opcje edycji globalnej
|
||||
Select Similar Objects Zaznacz podobne obiekty
|
||||
Select Similar Options Opcje zaznaczania podobnych
|
||||
Leave Out of Global Edit Wyklucz z edycji globalnej
|
||||
Match Dopasowanie
|
||||
On Artboards Na obszarach kompozycji
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5242,6 +5242,8 @@ axis Both Ambos
|
||||
Summary Resumo
|
||||
Superscript Sobrescrito
|
||||
Swap the selected object for the file, keeping its place and transform Troca o objeto selecionado pelo arquivo, mantendo sua posição e transformação
|
||||
Show Import Options Mostrar Opções de Importação
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Pergunta as opções de importação de cada arquivo PDF, DXF ou de texto antes de colocá-lo; desligado: usa as últimas opções escolhidas
|
||||
Swatch Libraries Menu Menu de Bibliotecas de Amostras
|
||||
Create New Library… Criar Nova Biblioteca…
|
||||
Rename Library… Renomear Biblioteca…
|
||||
@@ -6613,6 +6615,8 @@ axis Y Axis Eixo Y
|
||||
Create New Set Criar Novo Conjunto
|
||||
Stop Global Edit Parar Edição Global
|
||||
Global Edit Options Opções de Edição Global
|
||||
Select Similar Objects Selecionar Objetos Semelhantes
|
||||
Select Similar Options Opções de Seleção de Semelhantes
|
||||
Leave Out of Global Edit Deixar Fora da Edição Global
|
||||
Match Corresponder
|
||||
On Artboards Nas Mesas de Trabalho
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -3644,6 +3644,8 @@ digits Malayalam Малаялам
|
||||
Swap Fill and Stroke (Shift+X) Поменять заливку и обводку (Shift+X)
|
||||
Swap start and end arrowheads Поменять начальную и конечную стрелки
|
||||
Swap the selected object for the file, keeping its place and transform Заменить выделенный объект файлом, сохранив его положение и преобразование
|
||||
Show Import Options Показывать параметры импорта
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Запрашивать параметры импорта каждого файла PDF, DXF или текстового файла перед помещением; если выключено, используются последние выбранные параметры
|
||||
Stylistic Alternates Стилистические альтернативы
|
||||
Swash Росчерк
|
||||
Swatch Conflict Конфликт образцов
|
||||
@@ -6311,6 +6313,8 @@ axis Y Axis Ось Y
|
||||
Create New Set Создать новый набор
|
||||
Stop Global Edit Завершить глобальное редактирование
|
||||
Global Edit Options Параметры глобального редактирования
|
||||
Select Similar Objects Выделить похожие объекты
|
||||
Select Similar Options Параметры выделения похожих
|
||||
Leave Out of Global Edit Исключить из глобального редактирования
|
||||
Match Совпадение
|
||||
On Artboards На монтажных областях
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -3534,6 +3534,8 @@ digits Malayalam Malayalam
|
||||
Swap Fill and Stroke (Shift+X) Byt plats på fyllning och kontur (Shift+X)
|
||||
Swap start and end arrowheads Byt plats på start- och slutpilspets
|
||||
Swap the selected object for the file, keeping its place and transform Ersätt det markerade objektet med filen och behåll dess placering och transformering
|
||||
Show Import Options Visa importalternativ
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Fråga efter importalternativ för varje PDF-, DXF- eller textfil innan den placeras; av: använd de senast valda alternativen
|
||||
Stylistic Alternates Stilistiska alternativ
|
||||
Swash Swash
|
||||
Swatch Conflict Färgrutekonflikt
|
||||
@@ -6676,6 +6678,8 @@ axis Y Axis Y-axel
|
||||
Create New Set Skapa ny uppsättning
|
||||
Stop Global Edit Avsluta global redigering
|
||||
Global Edit Options Alternativ för global redigering
|
||||
Select Similar Objects Markera liknande objekt
|
||||
Select Similar Options Alternativ för att markera liknande
|
||||
Leave Out of Global Edit Uteslut från global redigering
|
||||
Match Matcha
|
||||
On Artboards På ritytor
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -332,6 +332,28 @@ fn languages_resolve_from_their_tags_and_name_themselves() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turkish `Draw` labels use the drawing verb of the catalog's other drawing modes, and the
|
||||
/// accepted control names in its messages keep the spelling the command reads. The dimension
|
||||
/// scale rows keep the `drawing:real` form and say that the ratio labels a page unit.
|
||||
#[test]
|
||||
fn turkish_draw_labels_use_the_drawing_verb() {
|
||||
let tr_lang = Lang::from_code("tr").expect("tr registered");
|
||||
assert_eq!(tr(tr_lang, "Draw"), "Çiz");
|
||||
assert_eq!(tr(tr_lang, "Draw:"), "Çiz:");
|
||||
assert_eq!(tr(tr_lang, "Draw Behind"), "Arkaya Çiz");
|
||||
let (entries, _) = parse_entries(tr_lang.0.source);
|
||||
let tie: Vec<_> =
|
||||
entries.iter().filter(|(_, _, t)| t.to_lowercase().contains("beraber")).map(|(_, src, t)| format!("{src:?} -> {t:?}")).collect();
|
||||
assert!(tie.is_empty(), "Turkish rows read as a tied game:\n{}", tie.join("\n"));
|
||||
let unknown = message(tr_lang, "unknown control `x`: fillStroke, coloring, drawMode or screenMode");
|
||||
assert!(unknown.contains("fillStroke, coloring, drawMode veya screenMode"), "{unknown}");
|
||||
assert_eq!(
|
||||
tr(tr_lang, "Drawing : real size — 1:10 labels 1 unit on the page as 10."),
|
||||
"Çizim : gerçek boyut — 1:10, sayfadaki 1 birimi 10 olarak etiketler."
|
||||
);
|
||||
assert_eq!(message(tr_lang, "Scale must be drawing:real, for example 1:10"), "Ölçek çizim:gerçek biçiminde olmalıdır, örneğin 1:10");
|
||||
}
|
||||
|
||||
/// The Catalan catalog registers under `ca`, claims complete menus and messages, and passes
|
||||
/// locale resolution, vocabulary and count formatting checks.
|
||||
#[test]
|
||||
|
||||
@@ -910,12 +910,12 @@ font-technology Variable Değişken
|
||||
Drag art here, or select it and click +. Hold Alt for one asset of several objects. Resmi buraya sürükleyin veya seçip + işaretine tıklayın. Birkaç nesneden oluşan bir varlık için Alt tuşunu basılı tutun.
|
||||
Drag onto art to apply this appearance Bu görünümü uygulamak için resmin üzerine sürükleyin
|
||||
Drag the art to move it on the paper Resmi kağıt üzerinde taşımak için sürükleyin
|
||||
Draw Beraberlik
|
||||
Draw Çiz
|
||||
Draw Behind Arkaya Çiz
|
||||
Draw in Perspective Perspektifte Çiz
|
||||
Draw Inside İçini Çiz
|
||||
Draw Normal Normal Çiz
|
||||
Draw: Beraberlik:
|
||||
Draw: Çiz:
|
||||
Drawing Mode Çizim Modu
|
||||
Drop Shadow Alt Gölge
|
||||
Drop Shadow… Alt Gölge…
|
||||
@@ -3398,6 +3398,8 @@ digits Malayalam Malayalam
|
||||
Swap Fill and Stroke (Shift+X) Dolgu ve Konturu Değiştir (Shift+X)
|
||||
Swap start and end arrowheads Başlangıç ve bitiş ok uçlarını değiştirin
|
||||
Swap the selected object for the file, keeping its place and transform Seçilen nesneyi, yerini koruyarak dosyayla değiştirin ve dönüştürün
|
||||
Show Import Options İçe Aktarma Seçeneklerini Göster
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Her PDF, DXF veya metin dosyasını yerleştirmeden önce içe aktarma seçeneklerini sor; kapalıyken son seçilen seçenekleri kullan
|
||||
Stylistic Alternates Stilistik Alternatifler
|
||||
Swash çalkalama
|
||||
Swatch Conflict Renk Örneği Çatışması
|
||||
@@ -5868,7 +5870,7 @@ theme Appearance Görünüm
|
||||
Drawer menu Çekmece menüsü
|
||||
Toolbar: Show Controls Araç Çubuğu: Kontrolleri Göster
|
||||
@msg control must be fillStroke, coloring, drawMode or screenMode control fillStroke, coloring, drawMode veya screenMode olmalıdır
|
||||
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode bilinmeyen kontrol `{name}`: fillStroke, colouring,drawMode veya screenMode
|
||||
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode bilinmeyen kontrol `{name}`: fillStroke, coloring, drawMode veya screenMode
|
||||
@msg give a fraction `t` from 0 to 1 0'dan 1'e kadar bir `t` kesri verin
|
||||
@msg give the path `id` `id` yolunu ver
|
||||
@msg invalid stroke widths geçersiz kontur genişlikleri
|
||||
@@ -5891,7 +5893,7 @@ theme Appearance Görünüm
|
||||
Below Object Nesnenin altında
|
||||
Registration: Kayıt:
|
||||
Dimension Boyut
|
||||
Drawing : real size — 1:10 labels 1 unit on the page as 10. Çizim : Gerçek boyut — 1:10 etiketler sayfada 10 adet olarak 1 adettir.
|
||||
Drawing : real size — 1:10 labels 1 unit on the page as 10. Çizim : gerçek boyut — 1:10, sayfadaki 1 birimi 10 olarak etiketler.
|
||||
Preview (1 inch on the page) Önizleme (sayfada 1 inç)
|
||||
Set as Default Varsayılan Olarak Ayarla
|
||||
out from a highlighted edge to place its dimension | boyutunu yerleştirmek için vurgulanan kenardan dışarı |
|
||||
@@ -5929,7 +5931,7 @@ theme Appearance Görünüm
|
||||
@msg Measured segment no longer exists Ölçülen segment artık mevcut değil
|
||||
@msg Non-finite dimension Sonlu olmayan boyut
|
||||
@msg Only linear dimensions are horizontal or vertical Yalnızca doğrusal boyutlar yatay veya dikey olabilir
|
||||
@msg Scale must be drawing:real, for example 1:10 Ölçek çizim olmalıdır:gerçek, örneğin 1:10
|
||||
@msg Scale must be drawing:real, for example 1:10 Ölçek çizim:gerçek biçiminde olmalıdır, örneğin 1:10
|
||||
@msg Scale ratio must be between 1:1000000 and 1000000:1 Ölçek oranı 1:1000000 ile 1000000:1 arasında olmalıdır
|
||||
@msg Second measured segment no longer exists Ölçülen ikinci segment artık mevcut değil
|
||||
@msg Unknown dimension mode Bilinmeyen boyut modu
|
||||
@@ -8575,6 +8577,8 @@ axis Y Axis Y ekseni
|
||||
Create New Set Yeni Set Oluştur
|
||||
Stop Global Edit Genel Düzenlemeyi Durdur
|
||||
Global Edit Options Genel Düzenleme Seçenekleri
|
||||
Select Similar Objects Benzer Nesneleri Seç
|
||||
Select Similar Options Benzer Seçimi Seçenekleri
|
||||
Leave Out of Global Edit Genel Düzenlemenin Dışında Bırak
|
||||
Match Eşleştir
|
||||
On Artboards Çalışma Yüzeylerinde
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -3651,6 +3651,8 @@ effect Points Точки
|
||||
Swap Fill and Stroke (Shift+X) Змінити заливку та обведення (Shift+X)
|
||||
Swap start and end arrowheads Поміняти початкову та кінцеву стрілки
|
||||
Swap the selected object for the file, keeping its place and transform Замінити виділений об’єкт файлом, зберігши його положення та перетворення
|
||||
Show Import Options Показувати параметри імпорту
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Запитувати параметри імпорту кожного файлу PDF, DXF чи текстового файлу перед розміщенням; якщо вимкнено, використовуються останні вибрані параметри
|
||||
Stylistic Alternates Стилістичні альтернативи
|
||||
Swash Розчерк
|
||||
Swatch Conflict Конфлікт зразків
|
||||
@@ -6304,6 +6306,8 @@ axis Y Axis Вісь Y
|
||||
Create New Set Створити новий набір
|
||||
Stop Global Edit Завершити глобальне редагування
|
||||
Global Edit Options Параметри глобального редагування
|
||||
Select Similar Objects Виділити схожі об’єкти
|
||||
Select Similar Options Параметри виділення схожих
|
||||
Leave Out of Global Edit Виключити з глобального редагування
|
||||
Match Збіг
|
||||
On Artboards На монтажних областях
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -4025,6 +4025,8 @@ mark-style Japanese Kiểu Nhật
|
||||
Swap Fill and Stroke (Shift+X) Đổi màu tô và nét (Shift+X)
|
||||
Swap start and end arrowheads Đổi đầu mũi tên bắt đầu và kết thúc
|
||||
Swap the selected object for the file, keeping its place and transform Thay đối tượng đã chọn bằng tệp, giữ nguyên vị trí và biến đổi
|
||||
Show Import Options Hiện tùy chọn nhập
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last Hỏi tùy chọn nhập của từng tệp PDF, DXF hoặc văn bản trước khi đặt; khi tắt, dùng các tùy chọn đã chọn gần nhất
|
||||
Stylistic Alternates Ký tự thay thế theo phong cách
|
||||
Swash Swash
|
||||
Swatch Conflict Xung đột mẫu màu
|
||||
@@ -6309,6 +6311,8 @@ axis Y Axis Trục Y
|
||||
Create New Set Tạo bộ mới
|
||||
Stop Global Edit Dừng chỉnh sửa toàn cục
|
||||
Global Edit Options Tùy chọn chỉnh sửa toàn cục
|
||||
Select Similar Objects Chọn các đối tượng tương tự
|
||||
Select Similar Options Tùy chọn chọn đối tượng tương tự
|
||||
Leave Out of Global Edit Loại khỏi chỉnh sửa toàn cục
|
||||
Match Khớp
|
||||
On Artboards Trên các bảng vẽ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -3472,6 +3472,8 @@ digits Malayalam 马拉雅拉姆数字
|
||||
Swap Fill and Stroke (Shift+X) 切换填色和描边 (Shift+X)
|
||||
Swap start and end arrowheads 对调起点与终点箭头
|
||||
Swap the selected object for the file, keeping its place and transform 以文件替换选择的对象,并保留其位置与变形
|
||||
Show Import Options 显示导入选项
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last 置入前询问每个 PDF、DXF 或文本文件的导入选项;关闭时使用上次选择的选项
|
||||
Stylistic Alternates 风格替代字
|
||||
Swash 花饰字
|
||||
Swatch Conflict 色板冲突
|
||||
@@ -6537,6 +6539,8 @@ axis Y Axis Y 轴
|
||||
Create New Set 创建新组
|
||||
Stop Global Edit 停止全局编辑
|
||||
Global Edit Options 全局编辑选项
|
||||
Select Similar Objects 选择相似对象
|
||||
Select Similar Options 选择相似对象选项
|
||||
Leave Out of Global Edit 从全局编辑中排除
|
||||
Match 匹配
|
||||
On Artboards 在画板上
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -4733,6 +4733,8 @@ digits Malayalam 馬來亞拉姆數字
|
||||
Swap Fill and Stroke (Shift+X) 對調填色與筆畫 (Shift+X)
|
||||
Swap start and end arrowheads 對調起點與終點箭頭
|
||||
Swap the selected object for the file, keeping its place and transform 以檔案取代選取的物件,並保留其位置與變形
|
||||
Show Import Options 顯示讀入選項
|
||||
Ask for each PDF, DXF or text file's import options before placing it; off, use the options chosen last 置入前詢問每個 PDF、DXF 或文字檔案的讀入選項;關閉時使用上次選擇的選項
|
||||
Stylistic Alternates 風格替代字
|
||||
Swash 花體字
|
||||
Swatch Conflict 色票衝突
|
||||
@@ -6344,6 +6346,8 @@ axis Y Axis Y 軸
|
||||
Create New Set 建立新組
|
||||
Stop Global Edit 停止整體編輯
|
||||
Global Edit Options 整體編輯選項
|
||||
Select Similar Objects 選取相似物件
|
||||
Select Similar Options 選取相似物件選項
|
||||
Leave Out of Global Edit 從整體編輯中排除
|
||||
Match 符合
|
||||
On Artboards 在工作區域上
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -82,6 +82,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
|
||||
std::borrow::Cow::Owned(d) => std::sync::Arc::new(d),
|
||||
}
|
||||
};
|
||||
fileio::check_linked_images("file.exportAs", f.id, st, &doc, params).map_err(|e| e.to_string())?;
|
||||
// A PDF shows its title in the viewer's title bar only when it is the document's own, not the
|
||||
// file name it was titled after, as `document.export` writes it.
|
||||
let own_title = fileio::has_own_title(&doc);
|
||||
|
||||
@@ -1367,7 +1367,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"file.exportAs",
|
||||
"Export As…",
|
||||
"",
|
||||
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it",
|
||||
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, or while the file would name a linked image's file outside them, as document.export refuses it",
|
||||
),
|
||||
(
|
||||
"ui.fileInfoDialog",
|
||||
|
||||
@@ -399,7 +399,7 @@ fn control_bar_link_text(ui: &Ui) -> egui::RichText {
|
||||
/// Character controls: the Paragraph link, which opens the Paragraph panel in a popover under it,
|
||||
/// and the Align left, Align center and Align right buttons, `size` points square as the bar's others. `reserve` gives the room the controls
|
||||
/// after them keep, asked only when type is selected: short of room, the buttons are left out,
|
||||
/// then the link too. Returns whether the link shows.
|
||||
/// then the link too, each noted with [`crate::chrome::left_out`]. Returns whether the link shows.
|
||||
pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, size: f32, reserve: impl FnOnce(&Ui) -> f32) -> bool {
|
||||
let Some((_, para)) = text_style(app) else { return false };
|
||||
let text = control_bar_link_text(ui);
|
||||
@@ -407,6 +407,7 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, size: f32, rese
|
||||
let room = ui.available_width() - reserve(ui);
|
||||
let link_room = link_width + ui.spacing().item_spacing.x;
|
||||
if room < link_room {
|
||||
crate::chrome::left_out(ui);
|
||||
return false;
|
||||
}
|
||||
let link = ui.link(text).on_hover_text(tl!("Paragraph options"));
|
||||
@@ -419,6 +420,8 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, size: f32, rese
|
||||
ui.spacing_mut().item_spacing.x = CONTROL_BAR_BUTTON_SPACING;
|
||||
first_alignment_buttons(app, ui, ¶, size, CONTROL_BAR_ALIGNMENTS);
|
||||
ui.spacing_mut().item_spacing.x = spacing;
|
||||
} else {
|
||||
crate::chrome::left_out(ui);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -792,9 +792,12 @@ fn control_bar_nodes(ctx: &egui::Context, app: &mut VectorcraftApp, width: f32,
|
||||
nodes
|
||||
}
|
||||
|
||||
/// The control named `name` (a button's or link's label, a label's text).
|
||||
/// The control named `name`: a button's or link's label, else a label's text. A link's text run
|
||||
/// carries the same text as its value, and the update lists the nodes in no set order, so the
|
||||
/// label is looked for first.
|
||||
fn control_bar_node(nodes: &Nodes, name: &str) -> Option<egui::accesskit::Node> {
|
||||
nodes.iter().find(|(_, n)| n.label() == Some(name) || n.value() == Some(name)).map(|(_, n)| n.clone())
|
||||
let labeled = nodes.iter().find(|(_, n)| n.label() == Some(name));
|
||||
labeled.or_else(|| nodes.iter().find(|(_, n)| n.value() == Some(name))).map(|(_, n)| n.clone())
|
||||
}
|
||||
|
||||
/// Where the control named `name` ends, if it shows.
|
||||
|
||||
+99
-20
@@ -69,6 +69,11 @@ pub struct PlaceState {
|
||||
/// The Place dialog's lines about its files while they are read off the UI thread: the
|
||||
/// dialog's files, and the lines once they are read.
|
||||
reading_lines: Option<(Value, std::sync::mpsc::Receiver<Vec<String>>)>,
|
||||
/// The files of the loaded place cursor ask for their import options when clicked or dragged
|
||||
/// (the Place dialog loaded it with Show Import Options on).
|
||||
queue_asks: bool,
|
||||
/// The loaded cursor's files by name, with the size a click places them at.
|
||||
queue_sizes: Vec<(String, f64, f64)>,
|
||||
}
|
||||
|
||||
/// A place cursor thumbnail: decoded when the cursor is loaded, uploaded when first drawn (and
|
||||
@@ -107,9 +112,11 @@ fn engine(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result<Value, String
|
||||
// ---------- File → Place… ----------
|
||||
|
||||
/// File → Place… (`file.place`): with a file (`path` or `name` + `dataBase64`), place it, centred
|
||||
/// in the view unless `at`, `rect` or `replace` say otherwise (a text file without `text` options
|
||||
/// asks for them first); with `paths`, the Place dialog for them; with neither, pick the files
|
||||
/// first. The file is read and placed at once (MCP, scripts).
|
||||
/// in the view unless `at`, `rect` or `replace` say otherwise (a text file without `text` options,
|
||||
/// a DXF drawing without `dxf` options and a PDF with several pages or a password and no `page`
|
||||
/// ask for them first, unless `showImportOptions: false` places them with the options confirmed
|
||||
/// last); with `paths`, the Place dialog for them; with neither, pick the files first. The file is
|
||||
/// read and placed at once (MCP, scripts).
|
||||
pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
place_file(app, p, Read::AtOnce)
|
||||
}
|
||||
@@ -130,8 +137,27 @@ enum Read {
|
||||
}
|
||||
|
||||
fn place_file(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result<Value, String> {
|
||||
if p.get("text").is_none() && crate::dialogs::text_import::is_text(p) {
|
||||
return Ok(crate::dialogs::text_import::open(app, p));
|
||||
let show = match p.get("showImportOptions") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Bool(b)) => Some(*b),
|
||||
Some(_) => return Err("`showImportOptions` must be true or false".into()),
|
||||
};
|
||||
let ask = show.unwrap_or(true);
|
||||
let mut p = p.clone();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.remove("showImportOptions");
|
||||
}
|
||||
if !ask {
|
||||
// Placed with the options confirmed last for the format.
|
||||
if p.get("text").is_none() && crate::dialogs::text_import::is_text(&p) {
|
||||
p["text"] = crate::dialogs::text_import::last_options(app);
|
||||
}
|
||||
if let Some(dxf) = crate::dialogs::dxf_import::last_options(app, &p) {
|
||||
p["dxf"] = dxf;
|
||||
}
|
||||
}
|
||||
if p.get("text").is_none() && crate::dialogs::text_import::is_text(&p) {
|
||||
return Ok(crate::dialogs::text_import::open(app, &p));
|
||||
}
|
||||
if p.get("path").is_none() && p.get("dataBase64").is_none() {
|
||||
let files: Vec<Value> = match p.get("paths").and_then(Value::as_array) {
|
||||
@@ -142,17 +168,21 @@ fn place_file(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result<Value,
|
||||
return Err("paths: give at least one file".into());
|
||||
}
|
||||
open_dialog(app, files);
|
||||
if let (Some(show), Some(d)) = (show, app.ui.dialog.as_mut()) {
|
||||
d.fields.insert("showImportOptions".into(), json!(show));
|
||||
}
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// A PDF with several pages or a password asks for the page, box and password first.
|
||||
if crate::dialogs::import_pdf::offer_place(app, p) {
|
||||
// A PDF with several pages or a password asks for the page, box and password first; with
|
||||
// Show Import Options off, only an encrypted one asks, for its password.
|
||||
let offered = if ask { crate::dialogs::import_pdf::offer_place(app, &p) } else { crate::dialogs::import_pdf::offer_place_password(app, &p) };
|
||||
if offered {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// A DXF drawing asks for its import options.
|
||||
if crate::dialogs::dxf_import::offer_place(app, p) {
|
||||
if ask && crate::dialogs::dxf_import::offer_place(app, &p) {
|
||||
return Ok(json!({ "dialog": crate::dialogs::dxf_import::KIND }));
|
||||
}
|
||||
let mut p = p.clone();
|
||||
if ["at", "rect", "replace"].iter().all(|k| p.get(k).is_none())
|
||||
&& let Some(v) = app.view()
|
||||
{
|
||||
@@ -203,6 +233,7 @@ fn open_dialog(app: &mut VectorcraftApp, files: Vec<Value>) {
|
||||
"link": app.ui.place_link,
|
||||
"template": false,
|
||||
"replace": false,
|
||||
"showImportOptions": app.ui.place_import_options,
|
||||
"__replace": files.len() == 1 && one_object,
|
||||
"__documents": linked.contains(&true),
|
||||
"__others": linked.contains(&false),
|
||||
@@ -319,15 +350,18 @@ fn fmt_ppi(v: f64) -> String {
|
||||
}
|
||||
|
||||
/// Place (the dialog's OK): one file is placed centred in the view (or replaces the selection);
|
||||
/// several load the place cursor.
|
||||
/// several load the place cursor. With Show Import Options on, each file that has import options
|
||||
/// asks for them before it is placed (the cursor's files when clicked or dragged); off, each is
|
||||
/// placed with the options confirmed last for its format.
|
||||
pub fn confirm(app: &mut VectorcraftApp, d: &crate::state::Dialog) -> Result<Value, String> {
|
||||
let files: Vec<Value> = d.fields.get("files").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let sources: Vec<Value> = files.iter().filter_map(|f| source(app, f)).collect();
|
||||
if sources.is_empty() {
|
||||
return Err("no files to place".into());
|
||||
}
|
||||
let (link, template) = (d.bool("link"), d.bool("template"));
|
||||
let (link, template, ask) = (d.bool("link"), d.bool("template"), d.bool("showImportOptions"));
|
||||
app.ui.place_link = link;
|
||||
app.ui.place_import_options = ask;
|
||||
app.ui.dialog = None;
|
||||
app.place.reading_lines = None;
|
||||
let r = match &sources[..] {
|
||||
@@ -335,6 +369,7 @@ pub fn confirm(app: &mut VectorcraftApp, d: &crate::state::Dialog) -> Result<Val
|
||||
let mut p = one.clone();
|
||||
p["link"] = json!(link);
|
||||
p["template"] = json!(template);
|
||||
p["showImportOptions"] = json!(ask);
|
||||
if d.bool("replace") && d.bool("__replace") {
|
||||
p["replace"] = json!(true);
|
||||
}
|
||||
@@ -343,7 +378,13 @@ pub fn confirm(app: &mut VectorcraftApp, d: &crate::state::Dialog) -> Result<Val
|
||||
many => {
|
||||
let paths: Vec<&Value> = many.iter().filter_map(|s| s.get("path")).collect();
|
||||
let files: Vec<&Value> = many.iter().filter(|s| s.get("path").is_none()).collect();
|
||||
queue_files(app, &json!({ "paths": paths, "files": files, "link": link, "template": template }), Read::ForUser { dropped: false })
|
||||
let mut q = json!({ "paths": paths, "files": files, "link": link, "template": template });
|
||||
if !ask {
|
||||
// Each text file and DXF drawing in the cursor takes the options confirmed last.
|
||||
q["text"] = crate::dialogs::text_import::last_options(app);
|
||||
q["dxf"] = crate::dialogs::dxf_import::remembered(app);
|
||||
}
|
||||
queue_files(app, &q, Read::ForUser { dropped: false }, ask)
|
||||
}
|
||||
};
|
||||
app.place.picked.clear();
|
||||
@@ -355,35 +396,41 @@ pub fn confirm(app: &mut VectorcraftApp, d: &crate::state::Dialog) -> Result<Val
|
||||
/// `file.place.queue` with thumbnails for the cursor (unless `thumbnail` is given), the files read
|
||||
/// at once (MCP, scripts).
|
||||
pub fn queue(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
queue_files(app, p, Read::AtOnce)
|
||||
queue_files(app, p, Read::AtOnce, false)
|
||||
}
|
||||
|
||||
fn queue_files(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result<Value, String> {
|
||||
/// `file.place.queue` with `p`; `asks`: the cursor's files ask for their import options when placed.
|
||||
fn queue_files(app: &mut VectorcraftApp, p: &Value, read: Read, asks: bool) -> Result<Value, String> {
|
||||
let mut p = p.clone();
|
||||
if p.get("thumbnail").is_none() {
|
||||
p["thumbnail"] = json!(THUMB);
|
||||
}
|
||||
if matches!(read, Read::ForUser { .. }) && crate::placing::available(app) {
|
||||
let r = crate::placing::start_queue(app, &p);
|
||||
let r = crate::placing::start_queue(app, &p, asks);
|
||||
if let Err(e) = &r {
|
||||
app.status(e.clone());
|
||||
}
|
||||
return r;
|
||||
}
|
||||
let r = engine(app, "file.place.queue", &p)?;
|
||||
show_thumbnails(app, &r);
|
||||
show_thumbnails(app, &r, asks);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The file a click or drag of the place cursor places (`file.place` with `p`, its `rect` set):
|
||||
/// read off the UI thread where the files the user places are ([`crate::placing::start`]), else at
|
||||
/// once. An error shows in the status bar.
|
||||
/// once. When the Place dialog loaded the cursor with Show Import Options on, a file that has
|
||||
/// import options asks for them first (Cancel leaves it unplaced). An error shows in the status bar.
|
||||
pub(crate) fn place_from_cursor(app: &mut VectorcraftApp, p: &Value) {
|
||||
// Read within the automation roots of whoever loaded the cursor, even on a click of the person
|
||||
// at the keyboard: an agent's queue reaches no file it wasn't given.
|
||||
let roots = app.session.place_queue_roots();
|
||||
vectorcraft_engine::file_access::confine(roots.as_ref(), || {
|
||||
if crate::placing::available(app) {
|
||||
if app.place.queue_asks {
|
||||
// Placing shows its own errors in the status bar.
|
||||
let p = clicked_at_full_size(app, p);
|
||||
let _ = place_file(app, &p, Read::ForUser { dropped: false });
|
||||
} else if crate::placing::available(app) {
|
||||
if let Err(e) = crate::placing::start(app, p, false) {
|
||||
app.status(e);
|
||||
}
|
||||
@@ -393,8 +440,40 @@ pub(crate) fn place_from_cursor(app: &mut VectorcraftApp, p: &Value) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The cursor's thumbnails from `file.place.queue`'s reply `r`.
|
||||
pub(crate) fn show_thumbnails(app: &mut VectorcraftApp, r: &Value) {
|
||||
/// The params of a cursor click (`rect` at the file's queued size) on a PDF or DXF file, whose
|
||||
/// import options may change its size (another page, another scale): placed as its options place
|
||||
/// it at the click (`at`, the center of the box the click gave), as File › Place of that one file
|
||||
/// places it, instead of fitted into the box of the first page or the default scale. A drag keeps
|
||||
/// its `rect`; one to exactly the queued size gives the same `rect` as a click, and is taken as one.
|
||||
fn clicked_at_full_size(app: &VectorcraftApp, p: &Value) -> Value {
|
||||
let mut p = p.clone();
|
||||
let Some(name) = p.get("path").or_else(|| p.get("name")).and_then(Value::as_str).map(fileio::file_name) else { return p };
|
||||
if !matches!(fileio::extension(&name).as_str(), "pdf" | "ai" | "ait" | "dxf") {
|
||||
return p;
|
||||
}
|
||||
let r: Vec<f64> = p.get("rect").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let [x, y, w, h] = r[..] else { return p };
|
||||
// Files of the same name in the cursor must all have that size: the place tool's request names
|
||||
// the file, not its place in the cursor.
|
||||
let mut same_name = app.place.queue_sizes.iter().filter(|(n, ..)| *n == name).peekable();
|
||||
let queued = same_name.peek().is_some() && same_name.all(|(_, qw, qh)| (qw - w).abs() < 1e-6 && (qh - h).abs() < 1e-6);
|
||||
if queued && let Some(o) = p.as_object_mut() {
|
||||
o.remove("rect");
|
||||
o.insert("at".into(), json!([x + w / 2.0, y + h / 2.0]));
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// The cursor's thumbnails and its files' sizes from `file.place.queue`'s reply `r` once the cursor
|
||||
/// is loaded; `asks`: its files ask for their import options when placed.
|
||||
pub(crate) fn show_thumbnails(app: &mut VectorcraftApp, r: &Value, asks: bool) {
|
||||
app.place.queue_asks = asks;
|
||||
app.place.queue_sizes = r["files"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|f| Some((f["name"].as_str()?.to_string(), f["width"].as_f64()?, f["height"].as_f64()?)))
|
||||
.collect();
|
||||
app.place.thumbs = r["files"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
|
||||
@@ -21,10 +21,11 @@ use vectorcraft_engine::file_access::ScopeHandle;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// What was read: one file to place, the files to load the place cursor with, or a bitmap to paste.
|
||||
/// What was read: one file to place, the files to load the place cursor with (and whether they ask
|
||||
/// for their import options when placed), or a bitmap to paste.
|
||||
enum Read {
|
||||
Place(Box<PlaceRead>),
|
||||
Queue(QueueRead),
|
||||
Queue(QueueRead, bool),
|
||||
Paste(Box<Paste>),
|
||||
}
|
||||
|
||||
@@ -236,17 +237,18 @@ pub fn start(app: &mut VectorcraftApp, p: &Value, dropped: bool) -> Result<Value
|
||||
|
||||
/// `file.place.queue` with `p` for the active document, its files read and their thumbnails made off
|
||||
/// the UI thread → `{background: true, status}`; the place cursor is loaded once they are ready. When no thread could start, the cursor is loaded at once → `file.place.queue`'s reply.
|
||||
pub fn start_queue(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
/// `asks`: the cursor's files ask for their import options when placed ([`crate::place::show_thumbnails`]).
|
||||
pub fn start_queue(app: &mut VectorcraftApp, p: &Value, asks: bool) -> Result<Value, String> {
|
||||
let job = app.session.prepare_place_queue(p).map_err(|e| e.to_string())?;
|
||||
let names = job.names().join(", ");
|
||||
let started = spawn(app, names.clone(), names, false, move || job.read().map(Read::Queue).map_err(|e| e.to_string()))?;
|
||||
let started = spawn(app, names.clone(), names, false, move || job.read().map(|r| Read::Queue(r, asks)).map_err(|e| e.to_string()))?;
|
||||
match started {
|
||||
Some(r) => Ok(r),
|
||||
None => {
|
||||
let r = app.session.execute("file.place.queue", p).map_err(|e| e.to_string());
|
||||
app.sync_views();
|
||||
let r = r?;
|
||||
crate::place::show_thumbnails(app, &r);
|
||||
crate::place::show_thumbnails(app, &r, asks);
|
||||
Ok(r)
|
||||
}
|
||||
}
|
||||
@@ -485,7 +487,9 @@ fn finish(app: &mut VectorcraftApp, p: &Pending, r: Result<Read, String>) {
|
||||
app.session.set_active(i);
|
||||
let r = match r {
|
||||
Ok(Read::Place(read)) => app.session.place_read(*read).map(|_| ()).map_err(|e| e.to_string()),
|
||||
Ok(Read::Queue(read)) => app.session.place_queue_read(read).map(|r| crate::place::show_thumbnails(app, &r)).map_err(|e| e.to_string()),
|
||||
Ok(Read::Queue(read, asks)) => {
|
||||
app.session.place_queue_read(read).map(|r| crate::place::show_thumbnails(app, &r, asks)).map_err(|e| e.to_string())
|
||||
}
|
||||
Ok(Read::Paste(paste)) => return finish_paste(app, *paste),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
|
||||
@@ -1173,7 +1173,7 @@ fn a_file_whose_document_was_reverted_meanwhile_is_not_placed() {
|
||||
for kind in ["place", "drop", "queue"] {
|
||||
let (mut app, _) = saved_and_changed(app(true), &format!("reverted-{kind}"));
|
||||
let read = |app: &mut VectorcraftApp| match kind {
|
||||
"queue" => start_queue(app, &json!({"paths": [tif]})),
|
||||
"queue" => start_queue(app, &json!({"paths": [tif]}), false),
|
||||
_ => start(app, &json!({"path": tif}), kind == "drop"),
|
||||
};
|
||||
read(&mut app).unwrap();
|
||||
|
||||
@@ -749,6 +749,14 @@ pub struct UiState {
|
||||
/// File → Place: Link is on (the Place dialog remembers it).
|
||||
#[serde(default = "yes")]
|
||||
pub place_link: bool,
|
||||
/// File → Place: Show Import Options is on (the Place dialog remembers it). Each placed file
|
||||
/// with import options (a PDF's page and box, a DXF drawing, a text file) asks for them first;
|
||||
/// off, it is placed with the options last confirmed for its format.
|
||||
#[serde(default = "yes")]
|
||||
pub place_import_options: bool,
|
||||
/// The Text Import Options confirmed last (`file.place`'s `text` options; null: never used).
|
||||
#[serde(default)]
|
||||
pub text_import: Value,
|
||||
/// The file the open dialog reads, kept out of its JSON fields (Import PDF); dropped when no
|
||||
/// dialog is open.
|
||||
#[serde(skip)]
|
||||
@@ -1227,6 +1235,8 @@ impl Default for UiState {
|
||||
swatches_show_recent_colors: true,
|
||||
svg_options: Value::Null,
|
||||
place_link: true,
|
||||
place_import_options: true,
|
||||
text_import: Value::Null,
|
||||
dialog_file: None,
|
||||
made_text_style: vec![],
|
||||
pending_fonts: vec![],
|
||||
|
||||
@@ -189,7 +189,10 @@ impl VectorcraftApp {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (clip, svg, pdf) = (self.session.clipboard.clone(), self.session.prefs.copy_as_svg, self.session.prefs.copy_as_pdf);
|
||||
// An SVG that would name a linked image's file outside the read roots of the automation
|
||||
// that asked for the Copy is left out (decided now: the formats are made later).
|
||||
let svg = self.session.prefs.copy_as_svg && !self.session.clipboard_svg_refused();
|
||||
let (clip, pdf) = (self.session.clipboard.clone(), self.session.prefs.copy_as_pdf);
|
||||
let latest = self.clipboard_publishing.latest.clone();
|
||||
let n = latest.fetch_add(1, Ordering::SeqCst).wrapping_add(1);
|
||||
self.clipboard_publishing.pending = true;
|
||||
|
||||
@@ -272,6 +272,46 @@ fn control_svg_export_refuses_a_linked_javascript_file_outside_the_read_root() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `app.export`, and `file.exportAs` run as a command, write no SVG that would name a linked
|
||||
/// image's file outside the read root (Images: Link, Preserve Editing Capabilities), and the error
|
||||
/// names no file or folder. Embedded pixels are written; the person at the keyboard exports the
|
||||
/// link as stored.
|
||||
#[test]
|
||||
fn control_svg_export_refuses_a_linked_image_file_outside_the_read_root() {
|
||||
let (mut app, tx, base) = confined("svg-image");
|
||||
app.run("file.new", json!({"width": 100, "height": 50})).unwrap();
|
||||
let photo = s(base.join("outside/photo.png"));
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 40, 40, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
std::fs::write(&photo, png).unwrap();
|
||||
app.run("file.place", json!({"path": photo, "link": true, "at": [50, 25]})).unwrap();
|
||||
let path = s(base.join("out/a.svg"));
|
||||
for (method, params) in [
|
||||
("app.export", json!({"path": path, "format": "svg", "images": "link", "wait": true})),
|
||||
("app.export", json!({"path": s(base.join("out/a.svgz")), "images": "link", "wait": true})),
|
||||
("app.export", json!({"format": "svg", "images": "link"})),
|
||||
("app.export", json!({"format": "svg", "preserveEditing": true})),
|
||||
("engine.execute", json!({"command": "file.exportAs", "params": {"path": path, "format": "svg", "images": "link"}})),
|
||||
] {
|
||||
let r = request(&mut app, &tx, method, params.clone());
|
||||
let e = r["error"].as_str().unwrap_or_default();
|
||||
assert!(e.contains("a linked image file is outside the read roots"), "{method} {params}: {r}");
|
||||
assert!(!e.contains("photo.png") && !e.contains(&s(base.clone())), "{method} {params}: {r}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(base.join("out")).unwrap().count(), 0, "nothing written");
|
||||
let r = request(&mut app, &tx, "app.export", json!({"path": path, "format": "svg", "wait": true}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let svg = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(svg.contains("data:image/png;base64,") && !svg.contains(&photo), "{svg}");
|
||||
// The person at the keyboard isn't confined.
|
||||
app.run("file.exportAs", json!({"path": path, "format": "svg", "images": "link"})).unwrap();
|
||||
let svg = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(svg.contains(&format!("xlink:href=\"{photo}\"")), "{svg}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `app.export` with a path writes the file when given an option only another format reads, and
|
||||
/// its warnings name that option, as `document.export`'s do without a path. `file.exportAs` run
|
||||
/// as a command does the same; an export that automation did not ask for gets no such note.
|
||||
|
||||
@@ -104,6 +104,22 @@ fn apply_to_all_answers_the_rest_and_cancel_pastes_nothing() {
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_pastes_nothing_after_another_document_became_active() {
|
||||
let mut app = conflicting();
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
set(&mut app, "applyToAll", json!(true));
|
||||
app.run("document.activate", json!({"index": 0})).unwrap();
|
||||
let before = objects(&app);
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "it closes as Cancel does");
|
||||
assert_eq!(objects(&app), before, "the document made active pastes nothing");
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||||
app.run("document.activate", json!({"index": 1})).unwrap();
|
||||
assert!(objects(&app).is_empty(), "the document it asked in pastes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_goes_to_the_centre_of_the_view() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -7,6 +7,7 @@ use egui::{Pos2, Shape, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::canvas::Xf;
|
||||
@@ -897,6 +898,179 @@ fn idle(app: &mut VectorcraftApp, ctx: &egui::Context, time: &mut f64, secs: f64
|
||||
/// while the window sits idle in the background, follows Update Links: Automatically: the app
|
||||
/// wakes itself to see the new link before the change, so the change isn't taken for the file as
|
||||
/// first seen.
|
||||
/// The text of the one selected type object.
|
||||
fn selected_text(app: &VectorcraftApp) -> String {
|
||||
let st = app.session.active().unwrap();
|
||||
let NodeKind::Text(t) = &st.doc.node(st.selection.objects[0]).unwrap().kind else { panic!("not type") };
|
||||
t.plain_text()
|
||||
}
|
||||
|
||||
/// The kind of the open dialog, if any.
|
||||
fn dialog_kind(app: &VectorcraftApp) -> Option<&str> {
|
||||
app.ui.dialog.as_ref().map(|d| d.kind.as_str())
|
||||
}
|
||||
|
||||
const NOTES: &[u8] = b"one\r\ntwo\r\n\r\nthree\r\n";
|
||||
|
||||
/// A DXF drawing of one 100 × 50 line.
|
||||
const LINE_DXF: &[u8] = b"0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n100\n21\n50\n0\nENDSEC\n0\nEOF\n";
|
||||
|
||||
#[test]
|
||||
fn show_import_options_is_a_place_option_saved_with_the_preferences() {
|
||||
let mut app = app();
|
||||
let dir = Folder::new("ui-place-show-import-options");
|
||||
let notes = dir.file("notes.txt", NOTES);
|
||||
app.run("file.place", json!({"paths": [notes]})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().expect("the Place dialog");
|
||||
assert!(d.bool("showImportOptions"), "on by default");
|
||||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
|
||||
assert!(text.contains("Show Import Options"), "{text}");
|
||||
// On: the text file asks for its import options first.
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND));
|
||||
app.ui.dialog = None;
|
||||
// Off: it is placed at once, and the next Place dialog starts off.
|
||||
app.run("file.place", json!({"paths": [notes]})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("showImportOptions".into(), json!(false));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(dialog_kind(&app), None);
|
||||
assert!(selected_text(&app).contains("three"));
|
||||
assert!(!app.ui.place_import_options);
|
||||
app.run("file.place", json!({"paths": [notes]})).unwrap();
|
||||
assert!(!app.ui.dialog.as_ref().unwrap().bool("showImportOptions"), "remembered");
|
||||
// `showImportOptions` given with `paths` sets the dialog's checkbox.
|
||||
app.run("file.place", json!({"paths": [notes], "showImportOptions": true})).unwrap();
|
||||
assert!(app.ui.dialog.as_ref().unwrap().bool("showImportOptions"));
|
||||
// Saved with the preferences; older preferences without it start on.
|
||||
let saved = serde_json::to_value(&app.ui).unwrap();
|
||||
assert_eq!(saved["place_import_options"], false);
|
||||
let old: crate::UiState = serde_json::from_value(json!({"place_link": false})).unwrap();
|
||||
assert!(old.place_import_options && old.text_import.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_show_import_options_off_a_file_takes_the_options_confirmed_last() {
|
||||
let mut app = app();
|
||||
let b64 = vectorcraft_format::base64_encode;
|
||||
// Nothing confirmed yet: file.place's defaults.
|
||||
app.run("file.place", json!({"name": "a.txt", "dataBase64": b64(NOTES), "showImportOptions": false})).unwrap();
|
||||
assert_eq!(dialog_kind(&app), None);
|
||||
let plain = selected_text(&app);
|
||||
// Text Import Options remembers what it confirmed, and the next text file starts from it.
|
||||
app.run("file.place", json!({"name": "b.txt", "dataBase64": b64(NOTES)})).unwrap();
|
||||
assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND));
|
||||
for k in ["removeLineReturns", "removeParagraphReturns", "replaceSpaces"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), json!(true));
|
||||
}
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("spaces".into(), json!(4));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(selected_text(&app), "one two\nthree");
|
||||
assert_ne!(plain, "one two\nthree");
|
||||
app.run("file.place", json!({"name": "c.txt", "dataBase64": b64(NOTES)})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert!(d.bool("removeLineReturns") && d.bool("replaceSpaces"));
|
||||
assert_eq!(d.f64("spaces", 0.0), 4.0);
|
||||
app.ui.dialog = None;
|
||||
app.run("file.place", json!({"name": "d.txt", "dataBase64": b64(NOTES), "showImportOptions": false})).unwrap();
|
||||
assert_eq!((dialog_kind(&app), selected_text(&app).as_str()), (None, "one two\nthree"));
|
||||
// A DXF drawing: placed without asking, with the fit chosen last.
|
||||
let width = |app: &VectorcraftApp| {
|
||||
let st = app.session.active().unwrap();
|
||||
st.doc.node(st.selection.objects[0]).unwrap().geometric_bounds().unwrap().width()
|
||||
};
|
||||
app.run("file.place", json!({"name": "line.dxf", "dataBase64": b64(LINE_DXF), "showImportOptions": false})).unwrap();
|
||||
assert_eq!(dialog_kind(&app), None);
|
||||
let unfitted = width(&app);
|
||||
app.ui.dxf_import = json!({"fit": true});
|
||||
app.run("file.place", json!({"name": "line.dxf", "dataBase64": b64(LINE_DXF), "showImportOptions": false})).unwrap();
|
||||
assert_eq!(dialog_kind(&app), None);
|
||||
assert!(width(&app) > unfitted + 1.0, "fitted: {} vs {unfitted}", width(&app));
|
||||
app.run("file.place", json!({"name": "line.dxf", "dataBase64": b64(LINE_DXF)})).unwrap();
|
||||
assert_eq!(dialog_kind(&app), Some(crate::dialogs::dxf_import::KIND), "on: it asks");
|
||||
app.ui.dialog = None;
|
||||
// A PDF with several pages: its first page, without asking.
|
||||
let pages = [PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"), PdfPage::new(300.0, 100.0, "1 0 0 rg 0 0 30 30 re f")];
|
||||
let two = pdf(&pages, None);
|
||||
app.run("file.place", json!({"name": "two.pdf", "dataBase64": b64(&two), "showImportOptions": false})).unwrap();
|
||||
assert_eq!(dialog_kind(&app), None);
|
||||
assert!((width(&app) - 100.0).abs() < 0.01, "page 1: {}", width(&app));
|
||||
// An encrypted PDF still asks for its password, and then places its first page.
|
||||
let locked = pdf(&pages, Some("pw"));
|
||||
app.run("file.place", json!({"name": "locked.pdf", "dataBase64": b64(&locked), "showImportOptions": false})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.bool("__locked")), Some(true));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("password".into(), json!("pw"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(dialog_kind(&app), None, "no page to pick");
|
||||
assert!((width(&app) - 100.0).abs() < 0.01, "page 1: {}", width(&app));
|
||||
// Given `page`, it asks for the password and places that page.
|
||||
app.run("file.place", json!({"name": "locked.pdf", "dataBase64": b64(&locked), "page": 2, "showImportOptions": false})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("password".into(), json!("pw"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!((width(&app) - 300.0).abs() < 0.01, "page 2: {}", width(&app));
|
||||
// A page past the last one is refused once the file is unlocked, not placed as another page.
|
||||
app.run("file.place", json!({"name": "locked.pdf", "dataBase64": b64(&locked), "page": 9, "showImportOptions": false})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("password".into(), json!("pw"));
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
app.ui.dialog = None;
|
||||
// A value that is not true or false is refused, and nothing is placed.
|
||||
let before = app.session.active().unwrap().history.undo.len();
|
||||
for bad in [json!("no"), json!(0)] {
|
||||
let e = app.run("file.place", json!({"name": "e.txt", "dataBase64": b64(NOTES), "showImportOptions": bad})).unwrap_err();
|
||||
assert!(e.contains("`showImportOptions` must be true or false"), "{e}");
|
||||
}
|
||||
assert_eq!((dialog_kind(&app), app.session.active().unwrap().history.undo.len()), (None, before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_place_cursors_files_ask_for_their_import_options_when_show_import_options_is_on() {
|
||||
let mut app = app();
|
||||
let dir = Folder::new("ui-place-cursor-import-options");
|
||||
let a = dir.file("a.png", &png(10, 10, 72.0));
|
||||
let notes = dir.file("notes.txt", NOTES);
|
||||
let click = json!({"path": notes, "rect": [10, 10, 200, 100]});
|
||||
// On: a click on the text file asks for its import options, and OK places it in the rect.
|
||||
app.run("file.place", json!({"paths": [a, notes]})).unwrap();
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.tool_options()["count"], 2);
|
||||
crate::place::place_from_cursor(&mut app, &click);
|
||||
assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__params"]["rect"], json!([10, 10, 200, 100]));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(selected_text(&app).contains("three"));
|
||||
// A click on a PDF places the page chosen at its own size, centered where the click put the
|
||||
// first page; a drag keeps its box.
|
||||
let pages = [PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"), PdfPage::new(300.0, 100.0, "1 0 0 rg 0 0 30 30 re f")];
|
||||
let two = dir.file("two.pdf", &pdf(&pages, None));
|
||||
app.run("file.place", json!({"paths": [a, two]})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
crate::place::place_from_cursor(&mut app, &json!({"path": two, "link": false, "rect": [10, 10, 100, 100]}));
|
||||
assert_eq!(dialog_kind(&app), Some("importPdf"));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__place"], json!({"link": false, "at": [60.0, 60.0]}));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!(2));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let st = app.session.active().unwrap();
|
||||
let b = st.doc.node(st.selection.objects[0]).unwrap().geometric_bounds().unwrap();
|
||||
assert_eq!((b.width().round(), b.center()), (300.0, vectorcraft_geom::Point::new(60.0, 60.0)));
|
||||
crate::place::place_from_cursor(&mut app, &json!({"path": two, "link": false, "rect": [10, 10, 50, 50]}));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__place"]["rect"], json!([10, 10, 50, 50]));
|
||||
app.ui.dialog = None;
|
||||
// Off: the cursor's files are placed at once, with the options confirmed last.
|
||||
app.run("file.place", json!({"paths": [a, notes]})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("showImportOptions".into(), json!(false));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.tool_options()["count"], 2);
|
||||
crate::place::place_from_cursor(&mut app, &click);
|
||||
assert_eq!(dialog_kind(&app), None);
|
||||
assert!(selected_text(&app).contains("three"));
|
||||
// A cursor loaded by file.place.queue places at once, as before.
|
||||
app.run("file.place", json!({"paths": [a, notes]})).unwrap();
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
app.run("file.place.queue", json!({"paths": [a, notes]})).unwrap();
|
||||
crate::place::place_from_cursor(&mut app, &click);
|
||||
assert_eq!(dialog_kind(&app), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_placed_while_idle_follows_its_changes() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -190,7 +190,8 @@ private editing data. VectorCraft:
|
||||
14; #868: round trip with Illustrator 2018 loses layers and groups), some files 4× slower to open since the
|
||||
editing data is read whole (#758), live effects that are not read or have settings that are not read, and
|
||||
brushes come in as their drawn look (#637; pattern fills are read since #1025, and symbols and their instances
|
||||
open live, as do the symbols of a symbol library without layers); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides
|
||||
open live, those that files of versions 10 to 13 place with a `TransformMatrix` included, as do the symbols of a
|
||||
symbol library without layers); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides
|
||||
open as ruler guides and guide paths since #5057 (#779), hidden, locked and named ruler guides with their state.
|
||||
Type under a Warp effect is compared with the art saved with it: type that draws as that art stays editable with
|
||||
the effect live, and type that draws differently, or that the file's limit of checks has no check left for, opens
|
||||
|
||||
+57
-4
@@ -246,6 +246,21 @@ the suite) keep the agent to the folders you give it:
|
||||
without a path of a document whose own format is SVG or SVGZ), `file.exportAs`, and the SVG Options dialog's
|
||||
Show Code. A file given as another URL (`https://…`) is not checked. The document keeps the link, and the
|
||||
person at the keyboard exports it as stored.
|
||||
- The same SVG and SVGZ outputs are refused with "a linked image file is outside the read roots", which names no
|
||||
file or folder, when the file would name a linked image's file outside the read roots (a path, a `file:` URL,
|
||||
or a relative path taken from the document's folder). An `<image>` names the file with `images: "link"`, and
|
||||
with `embed` when the document holds no pixels for the image. An image colorized with a solid fill names
|
||||
no file when its pixels are gray (the file gets the colorized pixels) or missing (the file leaves it out).
|
||||
With `preserveEditing`, the document the SVG carries names every linked image and placed file, the relative
|
||||
paths kept of them and each data set's image, in every symbol, pattern, graph design and brush, used or not,
|
||||
colorized or not, and a stored brush entry that does not read as a brush is refused too, since its links
|
||||
cannot be checked. Otherwise the images of the art the file writes,
|
||||
in opacity masks and in the symbols, patterns and brushes that art uses, are checked: hidden layers and
|
||||
objects only with `hiddenLayers` (a save keeps them unless it is `false`), and template layers and the
|
||||
layer of opacity-mask or symbol editing mode never. `selectedOnly` and `document.exportSelection` check the
|
||||
selected objects only, and `clipboard.exportSvg` checks the copied objects, following a relative path from
|
||||
the document they came from while it is open; in a frame a request drives, Copy then puts no SVG on the
|
||||
system clipboard. An image whose pixels the document holds exports embedded, and a URL is not checked.
|
||||
- `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths
|
||||
outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in
|
||||
folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source`
|
||||
@@ -846,9 +861,13 @@ given), `threeD.mapGraphic`,
|
||||
do not use the Appearance panel's active row. When the first object given is design art, rows and effects given with
|
||||
`aligned: true` are rows of its stack in `appearance.setItem`, `appearance.removeItem`, `appearance.duplicateItem`,
|
||||
`transparency.set` with an `item`, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit,
|
||||
move or remove an effect: each other object edits the row or effect equal to it, and an object without one is left as
|
||||
it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they do
|
||||
for a row no object has, and the other commands are an error. `appearance.moveItem`, `effect.move` and
|
||||
move or remove an effect, `paint.setFill`, `paint.setStroke`, `paint.invert`, `paint.complement`, `paint.lastColor` and
|
||||
`paint.lastGradient` with an `item`, `stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and the `paint.freeform.*` commands
|
||||
that edit points and lines: each other object edits the row or effect equal to it, and an object without one is left
|
||||
as it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they
|
||||
do for a row no object has, and the other commands are an error. The paint, stroke and gradient commands are also an
|
||||
error when the art's row is not the kind of row they edit, and `paint.editGradient`, `paint.setGradientGeom` and the
|
||||
`paint.freeform.*` commands without `stroke` edit the kind of the art's row. `appearance.moveItem`, `effect.move` and
|
||||
`appearance.duplicateItem` with `to` are an error when the art's stack differs from the other objects', and
|
||||
`appearance.duplicateItem` with `to` is also an error when two or more other objects are given, as it is without the
|
||||
art. To change the art, edit the design with `graph.design`.
|
||||
@@ -2815,6 +2834,22 @@ template layer, on a locked artboard while nothing is isolated, or outside the i
|
||||
such as a symbol instance just placed, is not passed over for being on a locked artboard while it stays selected. When
|
||||
the selection holds no other object, they return the error "nothing selected".
|
||||
|
||||
`select.similar {by?}` is the Control bar's Select Similar Objects button and the Select Similar Options menu beside it
|
||||
([help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=165)). It selects the
|
||||
objects like the first selected object by `by` and returns `{count, by}`. `by` is `fillColor`, `fillAndStroke`,
|
||||
`strokeColor`, `strokeWeight`, `opacity`, `blendingMode`, `appearance`, `graphicStyle`, `shapeType` or
|
||||
`symbolInstance`, which runs the `select.same.*` command of that name, or `all`, which selects the objects whose fill
|
||||
and stroke colors, stroke weight, opacity and blending mode all match (tints, compound paths and Live Paint faces as
|
||||
`select.same.fillColor`); Select › Reselect repeats `all` as `all`. The menu's items, their order and what `all`
|
||||
compares are VectorCraft's own. A `by` given is kept for the session once the selection is made, and the menu checks
|
||||
it; without `by` the command uses the kept one, `all` until one is given. Any other `by` is an error that keeps the
|
||||
selection and the kept attribute, and an error of the `select.same.*` command it runs (for example "the selection has
|
||||
no graphic style") keeps the kept attribute too. It needs a selection and records no undo step; the journal records the
|
||||
attribute used, so a recorded action repeats it, and a script run that fails puts the kept attribute back. The button
|
||||
and the menu show while something is selected and the
|
||||
Object Options group is on, at the right end of the bar after the X, Y, W and H fields. They take only the room the
|
||||
other controls leave, so the bar leaves them out, together, before any other control.
|
||||
|
||||
`select.object.allOnSameLayers` selects the objects on the layers and sublayers of the selected objects that can be
|
||||
selected. A selected object that is hidden or locked, inside a hidden or locked group or layer, on a template layer, on
|
||||
a locked artboard while nothing is isolated, or outside the isolated group adds no layer. When the selection holds no
|
||||
@@ -6858,7 +6893,8 @@ where it is.
|
||||
`window.control.options {group, visible?}` (desktop app) turns a group of the Control bar's options on (`visible:
|
||||
true`), off (`false`) or toggles it (omitted), and returns `{group, visible}`. The groups are `snapping` (the Snapping
|
||||
menu), `anchors` (Convert and Anchors), `toolOptions` (the active tool's options), `objectOptions` (Isolate Selected
|
||||
Object or Exit Isolation Mode, the envelope controls and the symbol instance controls), `imageOptions` (a selected
|
||||
Object or Exit Isolation Mode, the envelope controls, the symbol instance controls and Select Similar Objects),
|
||||
`imageOptions` (a selected
|
||||
image's or Image Trace object's controls), `fillStroke` (the fill and stroke chips, Stroke and its weight and width
|
||||
profile), `opacity`, `graphicStyle` (Style, Recolor Artwork and Global Edit Options), `character`, `paragraph`,
|
||||
`verticalAlignment` (area type's vertical alignment), `alignDistribute`, `transform` (the Transform link and the X/Y/W/H
|
||||
@@ -6986,6 +7022,23 @@ with `pointer_gesture` (a click places at 100% with the top-left corner there, a
|
||||
`file.place.info` refuse, of the keys each one takes, a `link`, `replace` or `template` that is not true or false and a `thumbnail` that is not a
|
||||
number, naming the key, and place nothing; null is the same as leaving the key out.
|
||||
|
||||
In the app, `file.place` asks for a file's import options before placing it: a text file without `text` (Text Import
|
||||
Options), a DXF drawing without `dxf` (DXF Import Options), and a PDF, `.ai` or `.ait` file with several pages or a
|
||||
password and no `page` (Place PDF). `showImportOptions: false` places such a file without asking, with the options
|
||||
last confirmed in its dialog: the text options, and the DXF fit, lineweight, centering and layer choices with the
|
||||
drawing at 1:1 in its own unit. A PDF is placed on page 1 (or `page`) and its crop box, and an encrypted PDF given no
|
||||
`password` still asks for the password. A `showImportOptions` that is not true or false is refused, and nothing is placed. The
|
||||
Place dialog (File › Place, or `file.place {paths}`) has a Show Import Options checkbox after Link, Template and
|
||||
Replace ([Place multiple files in a document](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/place-multiple-files-in-a-document.html)).
|
||||
It is on by default and saved with the preferences. On, each file that has import options asks for them before it is
|
||||
placed, also each file of the place cursor when it is clicked or dragged (Cancel leaves that file unplaced). A click on
|
||||
a PDF or DXF file places it as its options place it with `at` at the center of the box the click gave (a PDF's page at
|
||||
its own size, a DXF drawing at its scale, or on the artboard there when fitted or not centered), and a drag to any other
|
||||
size fits it into the dragged box (a drag to exactly the file's queued size gives the place tool the same box as a click and
|
||||
is placed as one). Off, each
|
||||
file is placed with the options last confirmed for its format. The default, and the options a file is placed with
|
||||
when the checkbox is off, are VectorCraft's own choices.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/photo.jpg","at":[300,200]}}}
|
||||
{"name":"run_command","arguments":{"command":"file.place","params":{"name":"logo.svg","dataBase64":"PHN2Zy…","rect":[0,0,100,100]}}}
|
||||
|
||||
@@ -653,6 +653,7 @@ since 2026-10-01.
|
||||
- **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`.
|
||||
- **Global Edit leaves out groups holding type or images:** Select › Start Global Edit on a group that holds type or an image, at any depth, refuses to start, and such a group is not taken as a match, as Global Edit does not take images or text ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test for a group holding type: `global_edit_needs_one_supported_object` in `crates/engine/src/tests/globaledit.rs`.
|
||||
- **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`.
|
||||
- **Select Similar Objects in the Control bar:** with something selected, the Control bar's Select Similar Objects button selects the objects like the first selected one, and the Select Similar Options menu beside it chooses what that is based on: All, Fill Color, Fill & Stroke, Stroke Color, Stroke Weight, Opacity, Blending Mode, Appearance, Graphic Style, Shape or Symbol Instance (`select.similar {by}`). The choice is kept for the session and checked in the menu ([archived help guide, select objects with the same attributes](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=165)). The list and what All compares are VectorCraft's own. Regression tests: `the_control_bar_selects_similar_objects` in `crates/ui-egui/src/chrome/tests.rs`, `select_similar_selects_by_the_kept_attribute` in `crates/engine/src/tests/menucmds.rs`.
|
||||
- **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`.
|
||||
- **Start Global Edit is offered only for a group it can start on:** with a group selected, Select › Start Global Edit is disabled, and the Properties panel's Quick Actions and the Control bar do not offer it or its options, when the group holds type, an image or nothing, at any depth; the check walks the group's objects the same way, stopping at the first one Global Edit does not take. This is VectorCraft's own rule. Regression test: `start_global_edit_is_offered_only_for_a_group_of_supported_objects` in `crates/engine/src/tests/globaledit.rs`.
|
||||
- **Global Edit takes rotated copies:** Select › Start Global Edit on an object or group also takes its rotated copies, with the highlights before the start and the options' Results count, and an edit reaches each copy turned by its angle, as copies of a group rotated around a point are edited at once ([Design a modern geometric logo](https://helpx.adobe.com/vn_vi/illustrator/how-to/modern-logo-design.html)). Under VectorCraft's own rules, a copy's angle is the one that lays the selected object's anchors and handles on the copy's in order, each within a millionth of the larger side of the copy's box; a reflected copy is not taken; and with Size, a rotated copy has the same size when it is the object rotated without scaling. Regression tests: `start_global_edit_takes_rotated_copies_*`, `a_rotated_copy_leads_*`, `size_takes_a_rotated_copy_*`, `a_square_whose_anchors_were_turned_*`, `rotated_groups_are_like_the_group_*` and `a_rotated_symbol_instance_*` in `crates/engine/src/tests/globaledit.rs`.
|
||||
@@ -855,6 +856,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`).
|
||||
- Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, brush art included, leaves out a relative path that leads outside them and a stored brush entry that does not read as a brush, names a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`, `document_json_checks_brush_art_and_relative_paths_against_the_read_roots`).
|
||||
- SVG output under `--automation-read-root`: `document.serialize`, `document.export`, `document.exportSelection`, `document.exportForScreens`, `assets.export`, the SVG and SVGZ saves, `file.exportAs` and the SVG Options dialog's Show Code refuse an SVG or SVGZ of a document that links a JavaScript file outside the read roots, before any file or folder is written, with an error that names no file or folder; a URL, a file inside the roots and a relative path that leads inside them are written as stored, and the person at the keyboard exports every link as stored. `crates/engine/src/tests/interactivity.rs` (`confined_svg_output_refuses_a_linked_javascript_file_outside_the_read_roots`, `svg_output_keeps_linked_javascript_files_a_caller_may_read`), `crates/ui-egui/src/tests/automation.rs` (`control_svg_export_refuses_a_linked_javascript_file_outside_the_read_root`), `crates/ui-egui/src/dialogs/svg_options.rs` (`show_code_leaves_out_a_linked_javascript_file_outside_the_read_roots`).
|
||||
- Linked images in SVG output under `--automation-read-root`: the same outputs, and `clipboard.exportSvg`, refuse an SVG or SVGZ that would name a linked image's file outside the read roots (`images: link`, an image whose pixels the document doesn't hold, or `preserveEditing`), before any file or folder is written, with an error that names no file or folder; Copy in a frame a request drives then puts no SVG on the system clipboard. Embedded pixels, a file inside the roots, a relative path that leads inside them and a URL are written as before, and the person at the keyboard exports every link as stored. `crates/engine/src/tests/links.rs` (`confined_svg_output_refuses_a_linked_image_file_outside_the_read_roots`, `svg_output_names_linked_image_files_a_caller_may_read`, `confined_clipboard_svg_refuses_a_linked_image_file_outside_the_read_roots`, `confined_preserve_editing_svg_refuses_a_brush_library_entry_it_cannot_read`, `confined_svg_output_checks_only_the_linked_images_it_writes`, `confined_svg_output_writes_a_colorized_gray_image_without_its_file`), `crates/ui-egui/src/tests/automation.rs` (`control_svg_export_refuses_a_linked_image_file_outside_the_read_root`), `crates/ui-egui/src/dialogs/svg_options.rs` (`show_code_leaves_out_a_linked_image_file_outside_the_read_roots`).
|
||||
- Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`.
|
||||
- Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/<file name>` and keeps that id for the session unless a library loaded later without a file shows that id itself, which moves it to the next free `loaded/<file name> (N)`, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`.
|
||||
- Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`.
|
||||
@@ -1185,6 +1187,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete.
|
||||
Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out.
|
||||
The transparency, paint, stroke, gradient and overprint commands and Expand Appearance also leave the art of a column or marker design inside a series unchanged. The graph draws that art again from the design (`docs/mcp.md`).
|
||||
When that art is given first with `aligned: true`, the paint, stroke and gradient commands read the `item` as a row of the art's stack, and each other object edits its equal row.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
While a drag rotates the view, a dial at the center of the canvas shows the angle.
|
||||
- **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked; pressing Escape finishes editing and switches back to the selection tool used before double-clicking it (when in isolation mode, the first Escape exits text editing while preserving isolation mode, and a second Escape exits isolation).
|
||||
@@ -1755,6 +1758,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`).
|
||||
A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art).
|
||||
A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes of type, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 envelopes and images with a Warp in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`).
|
||||
A top object whose top and bottom sides, or left and right sides, have different numbers of segments is also saved live: the side with fewer segments is split into as many as the side across from it has (each segment into equal spans of its curve, a straight one at even steps), up to the 50 a side the grid holds, so the file opens with the same corners and the same mapping of the contents, its outline holding the added anchors. Where the split side would share out the contents another way, as a side of two curves that aren't one curve can, or a side has more than 50 segments, the envelope is written as its distorted art, with the warning (`a_top_object_with_uneven_sides_is_read_back_live`, `a_top_object_split_to_the_grids_limit_is_read_back_live` and `a_top_object_whose_split_side_would_map_another_way_is_written_as_its_art` in `crates/eps/src/ai_editing/tests.rs`).
|
||||
A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose).
|
||||
The same holds for the objects of an opacity mask's art, a mask inside another mask's art and a hidden art root included, so a mask whose art has Offset Path opens drawing the image it masks as it did, with the effect live; geometry effects that no art style holds on a mask's art, such as Roughen, are written applied to the art, which opens drawing what it drew (`a_masked_image_with_live_offset_path_round_trips`, `hidden_and_nested_mask_art_keep_live_offset_path` and `a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws` in `crates/eps/src/ai_editing/tests.rs`).
|
||||
A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines).
|
||||
@@ -1817,6 +1821,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
In `.ai` and EPS editing-data import, a clipping group, layer or opacity mask's art with several clipping paths (`W`) clips to their intersection: each clipping path after the first clips a clipping group nested inside the one before it, and each keeps its own stroke and transparency. A compound path of clipping paths (`*u` … `*U`) is still one clipping path, and clipping paths that would nest the art past the document's 32 levels stop the editing data from being read (`crates/eps/src/import/ai/tests.rs`).
|
||||
`.ai` and EPS editing-data import keeps template layers, and opens canvas and artboard ruler guides as ruler guides on their layers; guide objects stay editable guide paths (#779; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
A hidden (`1 Xw`) ruler guide path in `.ai` and EPS editing data opens as a hidden ruler guide rather than a guide path, and every ruler guide keeps its path's hide, lock and name, so its Layers row shows them (`hidden_locked_and_named_guide_paths_are_ruler_guides`).
|
||||
`.ai` editing-data import reads symbol instances that files of versions 10 to 13 place with a `TransformMatrix` on the canvas, on the page and in a symbol's art, so such a file opens from its layers with the art outside its artboards instead of from its PDF part alone (`an_instance_placed_on_the_canvas_by_an_older_file_stays_live`, `an_older_ai_files_symbols_keep_its_layers_and_the_art_outside_its_artboard`).
|
||||
`.ai` and EPS editing-data import keeps each path's Attributes panel note (`%AI3_Note:`, `%%Note:`, more lines in `%AI5_NoteMore:`, up to 1,024 characters) and Show Center (`1 Ap`); a note or Show Center applies to every path after it until the next one, and a note before a group or compound path is not theirs (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`).
|
||||
`.ai` and EPS editing-data import reads object tags (`/identifier (string) XT`) on the object before them, whatever kind of object it is: `AdobeURL` becomes the object's URL, and every other tag is kept in the object's data in order, up to 64 per object; a tag longer than 64 KiB, and tags in a pattern's or brush's art, are ignored. A tag on art that shows no longer stops the editing data from being read, and brush art that holds tags is read (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`).
|
||||
A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`).
|
||||
@@ -1859,6 +1864,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Placed documents' bitmaps on screen: when a bitmap made in the background is ready, the desktop app renders the canvas again only where the objects that show its file are, when the frame shown was rendered on the UI thread and that part is at most half the canvas. Bitmaps drawn or made since a frame began are kept for that frame past the 512 MiB budget, up to 2 GiB, so a view showing more than the budget does not make them again in every frame. A bitmap that would take them past 2 GiB is not kept, and its file draws exactly at that zoom. A bitmap that can't be made (the file can't be read and there is no preview) shows the light placeholder and is not asked for again. Each ready bitmap used to render the whole canvas again, and bitmaps past the budget and bitmaps that could not be made were asked for again in every frame (`crates/render/src/tests/placed_document.rs`).
|
||||
- Linked PDF and `.ai` pages: File › Place with Link (the default) places a page of a PDF, `.ai` or `.ait` file read as PDF pages as a placed document, as it links an image, the page and its box (`crop`) kept with the link: Links check, update and relink it, Break Link and Object › Expand give the group placing it without Link gives, every output draws the page's vectors, and Package copies the file. Without Link, or with a password or `textAs: "outlines"`, the page is embedded. EPS and PostScript `.ai` files are still embedded (`crates/engine/src/tests/placed_document.rs`).
|
||||
- File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests/place.rs`).
|
||||
- The Place dialog has a Show Import Options checkbox after Link, Template and Replace ([Place multiple files in a document](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/place-multiple-files-in-a-document.html)). On (VectorCraft's default, saved with the preferences), each PDF, DXF or text file asks for its import options before it is placed, also in the place cursor; off (`file.place {showImportOptions: false}`), it is placed with the options last confirmed for its format, and only an encrypted PDF asks, for its password. Text Import Options now starts from the options confirmed last (`crates/ui-egui/src/tests/place.rs`).
|
||||
- Separations Preview shows placed and embedded raster images on the visible plates, as it shows vector art (#4153): an RGB image's pixels are separated into process inks with the proof setup's intent, a CMYK image shows its own inks, a hidden plate's ink leaves the image, and with one plate visible the image shows that plate's ink as a grey. Raster images carry no spot ink, so a spot plate shown alone shows them as paper; a colorized image shows its color's plates. Emoji and other bitmap glyphs follow the same rule. The plate-filtered pixels and their reduced-size copies are kept apart from the image's own pixels, for each set of visible plates, proof setup and color settings, and with the preview off nothing changes. Evidence: `tests::colorize::separations_preview_shows_an_rgb_images_process_inks`, `separations_preview_shows_a_cmyk_images_own_inks`, `separations_preview_keeps_an_images_pixels_per_plate_set`. Print preview fidelity remains the open gap, so scores and hours are unchanged.
|
||||
- Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests/print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged.
|
||||
- A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root `<title>`. The title is File Info's alone (`DocMetadata::title`): the document's name stays the file's name with its extension, a file that gives no title leaves File Info with that name, and exports (PDF, SVG, PNG, EPS, `.ai`) and native saves carry it; changing the title in File Info sets the document's name as before (#3626).
|
||||
|
||||
Reference in New Issue
Block a user