M2.1: merge main again and keep path-deletion evidence (#3380)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ruchirguitar
2026-11-09 11:25:37 +05:30
co-authored by Cursor
163 changed files with 8595 additions and 792 deletions
Generated
+4 -1
View File
@@ -1059,7 +1059,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e#c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
@@ -6511,6 +6511,7 @@ name = "vectorcraft-eps"
version = "0.21.0"
dependencies = [
"craft-pdf",
"encoding_rs",
"fax",
"flate2",
"hayro-ccitt",
@@ -6651,6 +6652,8 @@ dependencies = [
name = "vectorcraft-plugins"
version = "0.21.0"
dependencies = [
"craft-vectorcraft-format",
"image",
"log",
"serde",
"serde_json",
+4 -1
View File
@@ -22,7 +22,7 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63
# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
@@ -99,6 +99,9 @@ ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
hayro-ccitt = "0.3"
# Substring search for the EPS reader's PostScript `search`.
memchr = "2"
# The WHATWG Encoding Standard's legacy encodings: names in legacy .ai files read in the code page
# the user picks (Mac OS Roman, Korean, Japanese, Chinese).
encoding_rs = "0.8.42"
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
+34 -7
View File
@@ -5,7 +5,7 @@
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] [--ppi 300]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--ppi 300] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli convert IN OUT [--scale 2] [--ppi 300] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--legacy-name-encoding ENC] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli info FILE
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000] [--deep]
@@ -101,7 +101,7 @@ USAGE:
vectorcraft-cli commands
Print the command catalogue as JSON.
vectorcraft-cli convert IN OUT [--scale N] [--ppi N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
vectorcraft-cli convert IN OUT [--scale N] [--ppi N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--legacy-name-encoding ENC] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). Raster resolution: --ppi, else 72 times --scale, else the document's raster effects
resolution; other formats leave them out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
@@ -114,7 +114,10 @@ USAGE:
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
--replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy
even where that draws differently from its PDF part or page (which can be older), as long as
it can be read. A PDF OUT omits the embedded native document unless --preserve-editing is
it can be read. --legacy-name-encoding reads the names in a .ai's or an EPS's editing data
that aren't UTF-8 (layers, objects, artboards, colors, gradients, patterns, graphic styles,
swatches, brushes, symbols, variables and data sets, and data sets' text values) in
windows-1252 (the default), macintosh, windows-949, shift_jis, gbk or big5. A PDF OUT omits the embedded native document unless --preserve-editing is
given (the app's Save As PDF still defaults to keeping it). With --preserve-editing, when the
attachment is written the result's warnings say so (it is left out, with its own warning, for
--artboard or a --range without every artboard).
@@ -302,8 +305,20 @@ fn commands() -> Result<(), String> {
}
fn convert(args: &[String]) -> Result<(), String> {
let valued =
["--scale", "-s", "--ppi", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"];
let valued = [
"--scale",
"-s",
"--ppi",
"--artboard",
"-a",
"--range",
"-r",
"--decimals",
"--ai-version",
"--hidden-objects",
"--transparent-images",
"--legacy-name-encoding",
];
if help_requested(args, &valued) {
return help();
}
@@ -312,6 +327,9 @@ fn convert(args: &[String]) -> Result<(), String> {
(None::<f64>, None::<u64>, None::<String>, false, false, false, false);
let mut ppi = None::<f64>;
let mut decimals = None::<u64>;
// The encoding of a legacy `.ai`'s names that aren't UTF-8, passed on as `document.open`'s
// `legacyNameEncoding`; the engine checks it.
let mut legacy_names = None::<String>;
// Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`,
// `transparentImages`); the engine checks their values.
let mut legacy: Vec<(&str, String)> = vec![];
@@ -334,6 +352,9 @@ fn convert(args: &[String]) -> Result<(), String> {
"--replace-lossy" => replace_lossy = true,
"--editing-data" => editing_data = true,
"--preserve-editing" => preserve_editing = true,
"--legacy-name-encoding" => {
legacy_names = Some(it.next().cloned().ok_or("--legacy-name-encoding needs an encoding such as windows-949")?);
}
"--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)),
"--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)),
"--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)),
@@ -346,8 +367,14 @@ fn convert(args: &[String]) -> Result<(), String> {
return Err("a .ait template holds every artboard: --artboard and --range don't apply".into());
}
let mut h = Headless::new();
let opened = if editing_data {
let params = json!({"path": input, "editingData": "always"});
let opened = if editing_data || legacy_names.is_some() {
let mut params = json!({"path": input});
if editing_data {
params["editingData"] = json!("always");
}
if let Some(e) = &legacy_names {
params["legacyNameEncoding"] = json!(e);
}
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?
} else {
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?
+2 -1
View File
@@ -193,7 +193,8 @@ fn the_system_title_bar_preference_keeps_the_window_decorations() {
/// Preferences › User Interface › VectorCraft Title Bar on Every Desktop gives the app frame where
/// the probe chose the desktop's (KDE, X11, a window opened through XWayland); without it the probe
/// decides, and `VECTORCRAFT_TITLEBAR` takes precedence over both.
/// decides, and `VECTORCRAFT_TITLEBAR` set to `app`, `system` or `desktop` takes precedence over
/// both.
#[test]
fn the_app_title_bar_preference_gives_the_app_frame_on_desktop_frames() {
assert!(!super::opted_into_app_title_bar(None), "no preferences: the default");
+11 -9
View File
@@ -42,7 +42,7 @@ pub(crate) const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether the user asked for the system title bar in Preferences › User Interface › System Title
/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is
/// `false` (the default). This preference forces the desktop/native frame back on unless
/// `VECTORCRAFT_TITLEBAR` is set ([`system_title_bar_applies`]).
/// `VECTORCRAFT_TITLEBAR` is set to `app`, `system` or `desktop` ([`system_title_bar_applies`]).
pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
}
@@ -56,8 +56,8 @@ pub(crate) fn opted_into_app_title_bar(saved: Option<&vectorcraft_ui_egui::UiSta
/// Whether the app draws the frame on this start, before the System Title Bar preference is
/// applied ([`custom_titlebar_from`]). `probed_app` is the `craft-window` probe's answer and
/// `overridden` its `VECTORCRAFT_TITLEBAR` override. In order: the override decides when it is
/// set; VectorCraft Title Bar on Every Desktop (`opted_into_app`) gives the app frame; a window
/// `overridden` its `VECTORCRAFT_TITLEBAR` override. In order: the override decides when the
/// variable is set to `app`, `system` or `desktop`;VectorCraft Title Bar on Every Desktop (`opted_into_app`) gives the app frame; a window
/// that opens through XWayland (`forced_to_x11`) gets the desktop frame; otherwise the probe
/// decides.
pub(crate) fn app_draws_frame(probed_app: bool, overridden: Option<bool>, forced_to_x11: bool, opted_into_app: bool) -> bool {
@@ -89,13 +89,14 @@ fn probed_choice() -> &'static craft_window::Choice {
/// Whether `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences ›
/// User Interface › System Title Bar and VectorCraft Title Bar on Every Desktop do not apply while
/// it is set. Always `false` on macOS and Windows.
/// it is set to `app`, `system` or `desktop`. Always `false` on macOS and Windows.
pub(crate) fn titlebar_from_env() -> bool {
cfg!(not(any(target_os = "macos", target_os = "windows"))) && probed_choice().overridden().is_some()
}
/// Whether System Title Bar decides this start: `opted_into_system` unless `VECTORCRAFT_TITLEBAR`
/// is set (`from_env`), which chooses the frame for the run it is set in.
/// is set to `app`, `system` or `desktop` (`from_env`), which chooses the frame for the run it is
/// set in.
pub(crate) fn system_title_bar_applies(opted_into_system: bool, from_env: bool) -> bool {
opted_into_system && !from_env
}
@@ -107,10 +108,10 @@ pub(crate) fn system_title_bar_applies(opted_into_system: bool, from_env: bool)
/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with
/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it
/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter),
/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly
/// or when `VECTORCRAFT_TITLEBAR` set to `app`, `system` or `desktop` overrides the probe. VectorCraft draws its own title bar exactly
/// when `craft-window` says the app owns the frame **and** the user has not opted into the system
/// title bar; the preference forces the native frame back on unless `VECTORCRAFT_TITLEBAR` is set
/// ([`system_title_bar_applies`]).
/// to one of those values ([`system_title_bar_applies`]).
///
/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the
/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but
@@ -132,13 +133,14 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst
/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its
/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System
/// Title Bar preference, which does not apply while the variable is set. Call once at startup,
/// Title Bar preference, which does not apply while the variable is set to `app`, `system` or
/// `desktop`. Call once at startup,
/// before [`native_options`].
///
/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through
/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs
/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native
/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins,
/// frame, the same as a real X11 session. A recognized `VECTORCRAFT_TITLEBAR` override still wins,
/// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]).
pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
let choice = probed_choice();
+42 -30
View File
@@ -143,38 +143,11 @@ impl Color {
}
/// HSB with hue in degrees 0..360, saturation and brightness 0..1.
pub fn to_hsb(&self) -> [f32; 3] {
let [r, g, b] = self.to_rgb();
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let d = max - min;
let h = if d <= 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / d).rem_euclid(6.0))
} else if max == g {
60.0 * ((b - r) / d + 2.0)
} else {
60.0 * ((r - g) / d + 4.0)
};
// f32 rounding in rem_euclid can yield exactly 360; wrap back to 0.
let h = if h >= 360.0 { 0.0 } else { h };
let s = if max <= 0.0 { 0.0 } else { d / max };
[h, s, max]
rgb_to_hsb(self.to_rgb())
}
pub fn from_hsb(h: f32, s: f32, v: f32) -> Self {
let h = h.rem_euclid(360.0) / 60.0;
let c = v * s;
let x = c * (1.0 - (h % 2.0 - 1.0).abs());
let (r, g, b) = match h as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = v - c;
Color::Rgb { r: r + m, g: g + m, b: b + m }
let [r, g, b] = hsb_to_rgb([h, s, v]);
Color::Rgb { r, g, b }
}
/// `#rrggbb` (display RGB).
pub fn to_hex(&self) -> String {
@@ -327,6 +300,45 @@ impl Color {
}
}
/// HSB (hue in degrees 0..360, saturation and brightness 0..1) of RGB numbers 0..1, with no color
/// conversion ([`Color::to_hsb`] reads the display color first).
pub fn rgb_to_hsb([r, g, b]: [f32; 3]) -> [f32; 3] {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let d = max - min;
let h = if d <= 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / d).rem_euclid(6.0))
} else if max == g {
60.0 * ((b - r) / d + 2.0)
} else {
60.0 * ((r - g) / d + 4.0)
};
// f32 rounding in rem_euclid can yield exactly 360; wrap back to 0.
let h = if h >= 360.0 { 0.0 } else { h };
let s = if max <= 0.0 { 0.0 } else { d / max };
[h, s, max]
}
/// RGB numbers 0..1 of HSB `[h, s, v]` (hue in degrees, saturation and brightness 0..1): the inverse
/// of [`rgb_to_hsb`].
pub fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
let h = h.rem_euclid(360.0) / 60.0;
let c = v * s;
let x = c * (1.0 - (h % 2.0 - 1.0).abs());
let (r, g, b) = match h as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = v - c;
[r + m, g + m, b + m]
}
/// `new` expressed in the colour model of `orig` ([`Color::in_model`]): results of colour operations
/// (harmonies, blends, inversions, recolouring) keep the model of the colour they came from.
pub fn keep_model(orig: Color, new: Color) -> Color {
+18 -4
View File
@@ -128,11 +128,14 @@ fn live_inputs() -> u64 {
}
/// A live object evaluated by [`live_art`] on this thread: the object, its document's symbols (by
/// name and art) and patterns and the [`LiveInputs`] as they were, the art and its [`art_size`].
/// name and art), patterns, swatches and color groups and the [`LiveInputs`] as they were, the art
/// and its [`art_size`].
struct EvaluatedLive {
node: Node,
symbols: Vec<(String, Arc<Node>)>,
patterns: Vec<crate::PatternDef>,
swatches: crate::Shared<Vec<vectorcraft_color::Swatch>>,
swatch_groups: crate::Shared<Vec<vectorcraft_color::SwatchGroup>>,
inputs: u64,
art: Rc<Vec<Node>>,
size: usize,
@@ -146,6 +149,8 @@ impl EvaluatedLive {
&& self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && Arc::ptr_eq(art, &s.art))
&& self.node == *n
&& self.patterns == doc.patterns
&& self.swatches == doc.swatches
&& self.swatch_groups == doc.swatch_groups
}
}
@@ -184,8 +189,8 @@ thread_local! {
/// The art live object `n` of `doc` draws ([`set_live_art`]); [`crate::live::expand_live`] while
/// nothing is registered. The art is evaluated again when `n`, a symbol of `doc`, a pattern of
/// `doc` or the [`LiveInputs`] have changed since, and on every click while it or the document's
/// symbols or patterns hold a placed document ([`holds_placed`]).
/// `doc`, its swatches or color groups, or the [`LiveInputs`] have changed since, and on every
/// click while it or the document's symbols or patterns hold a placed document ([`holds_placed`]).
fn live_art(doc: &Document, n: &Node) -> Rc<Vec<Node>> {
let inputs = live_inputs();
let kept = EVALUATED.with_borrow_mut(|kept| {
@@ -206,7 +211,16 @@ fn live_art(doc: &Document, n: &Node) -> Rc<Vec<Node>> {
return art;
}
let symbols = doc.symbols.iter().map(|s| (s.name.clone(), s.art.clone())).collect();
let e = EvaluatedLive { node: n.clone(), symbols, patterns: doc.patterns.clone(), inputs, art: art.clone(), size };
let e = EvaluatedLive {
node: n.clone(),
symbols,
patterns: doc.patterns.clone(),
swatches: doc.swatches.clone(),
swatch_groups: doc.swatch_groups.clone(),
inputs,
art: art.clone(),
size,
};
EVALUATED.with_borrow_mut(|kept| {
kept.insert(0, e);
kept.truncate(LIVE_KEPT);
+31 -1
View File
@@ -605,11 +605,41 @@ fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() {
fn a_kept_evaluation_is_stale_after_the_live_inputs_change() {
let d = Document::new(100.0, 100.0);
let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let e = EvaluatedLive { node: n.clone(), symbols: vec![], patterns: vec![], inputs: 7, art: std::rc::Rc::new(vec![]), size: 0 };
let e = kept_evaluation(&d, &n);
assert!(e.is_of(&d, &n, 7));
assert!(!e.is_of(&d, &n, 8));
}
/// An evaluation of `n` in `d`, with inputs 7, holding no symbols, patterns or art.
fn kept_evaluation(d: &Document, n: &Node) -> EvaluatedLive {
EvaluatedLive {
node: n.clone(),
symbols: vec![],
patterns: vec![],
swatches: d.swatches.clone(),
swatch_groups: d.swatch_groups.clone(),
inputs: 7,
art: std::rc::Rc::new(vec![]),
size: 0,
}
}
/// A kept evaluation is stale once a swatch changes, in a color group too: a painted live effect's
/// pieces name swatches.
#[test]
fn a_kept_evaluation_is_stale_after_a_swatch_changes() {
let mut d = Document::new(100.0, 100.0);
let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let e = kept_evaluation(&d, &n);
assert!(e.is_of(&d, &n, 7));
d.swatches.first_mut().unwrap().name = "Renamed".into();
assert!(!e.is_of(&d, &n, 7));
let mut d = Document::new(100.0, 100.0);
let e = kept_evaluation(&d, &n);
d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Added".into(), swatches: vec![] });
assert!(!e.is_of(&d, &n, 7));
}
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
/// clipping path and the art.
@@ -1,5 +1,5 @@
use super::{
Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, SubPath, Touched, Value, b, bad, check_numbers,
Arc, Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, Touched, Value, b, bad, check_numbers,
f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_leaves, replace_with_pieces, root_ids,
shape_node, switch_or,
};
@@ -41,6 +41,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
let gap = gap_param(p);
// The map the Shape Builder tool already has, which a click or drag of it is made over.
let shared = s.tool.shape_builder_map().filter(|(_, g)| *g == gap);
let prepared = s.tool.shape_builder_merge();
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
let (ids, merged, touched, split) = s.edit(label, |d, sel| {
let (shapes, leaves) = b::shape_builder_shapes(d, &roots, open_filled_as_closed);
@@ -89,16 +90,28 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
touched.add(map.hit(q, Some(tol)));
return Ok((ids, None, touched, Some(split)));
}
let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() };
touched.extend(&map.builder_hits_along(&pts, piece_tol));
touched.edges = map.erased_edges(&touched);
let touched = map.merge_touched(&pts, marquee, piece_tol);
if touched.is_empty() {
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
}
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
let inputs = b::merge_inputs(&leaves.iter().collect::<Vec<_>>(), open_filled_as_closed);
// The tool computed this merge on another thread when it would come out the same.
let ready = prepared.as_ref().filter(|m| {
shared.as_ref().is_some_and(|(s, _)| Arc::ptr_eq(s, &m.map) && std::ptr::eq(s.as_ref(), map))
&& m.gap == gap
&& m.touched == touched
&& m.inputs == inputs
});
let computed;
let geometry = match ready {
Some(m) => &m.geometry,
None => {
computed = b::merge_geometry(map, &touched, gap, &inputs);
&computed
}
};
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p));
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
let union = &geometry.union;
// Pick Color From Artwork: the merged shape takes the look of the front-most object under the
// drag's first point, else under its last point, else of the front-most object. Color
// Swatches: the current fill, with the rest of the look of the front-most object over the
@@ -115,7 +128,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
// What goes in place of each object the map was made from (none: it stays as it is).
let mut outs: Vec<Option<Vec<Node>>> = vec![];
let mut merged_id = None;
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
for (i, leaf) in leaves.iter().take(map.shapes.len()).enumerate() {
let mut out = None;
let pieces: Vec<usize> = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect();
if !pieces.is_empty() {
@@ -131,41 +144,15 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
}
out = Some(kept);
}
} else {
} else if let Some(Some(rest)) = geometry.rests.get(i) {
// A shape loses the regions deleted or merged, then the edges deleted (each one on
// its outline, shared edges from every shape along them).
let in_union = picked.iter().any(|r| r.sources.contains(&i));
let mut rest = if in_union {
// What is left of it: its faces not merged or deleted, united. They share their
// edges with the merged faces exactly, where subtracting the merged shape left
// slivers of the outline along them (#569). A sliver goes in only where the
// shape covers it and the merged shape does not take it in.
let kept: Vec<&po::Region> = map
.regions
.iter()
.enumerate()
.filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k))
.map(|(_, (r, ..))| r)
.collect();
if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) }
// its outline, shared edges from every shape along them). What is left of its own
// outline keeps its fill rule; united faces fill with non-zero.
out = Some(if rest.path.is_empty() {
vec![]
} else {
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
};
let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none();
// Edges come back from the planar map within a few hundredths of the outlines they
// follow, whatever else is selected.
let cut_tol = b::outline_snap(&sh.path);
let mut cut = false;
for piece in &cuts {
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
rest = r;
cut = true;
}
}
if in_union || cut {
// What is left of its own outline keeps its fill rule; united faces fill with non-zero.
out = Some(if rest.is_empty() { vec![] } else { vec![remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))] });
}
vec![remnant(d, leaf, rest.path.clone(), rest.united.then_some(FillRule::NonZero))]
});
}
// The merged shape goes in place of the object it takes its look from, so it stays in
// that object's group (a clip group too).
+5 -4
View File
@@ -93,16 +93,17 @@ pub use save::{
remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder,
templates_folder,
};
pub(crate) use screens::{
PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS,
check_settings as check_screen_settings, export as export_screens, safe_file_name, store_settings as store_screen_settings,
with_export_preset as with_screen_export_preset,
};
pub use screens::{
PRESET_FILE_EXTS as SCREEN_EXPORT_PRESET_EXTS, PRESET_FILE_FORMAT as SCREEN_EXPORT_PRESET_FORMAT, PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS,
PRESETS as SCREEN_PRESETS, SHARED_KEYS as SCREEN_SETTINGS_KEYS, ScreenExport, ScreenExportPreset, ScreenSize,
builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_list as screen_export_preset_list,
preset_rows as screen_preset_rows,
};
pub(crate) use screens::{
check_settings as check_screen_settings, export as export_screens, safe_file_name, store_settings as store_screen_settings,
with_export_preset as with_screen_export_preset,
};
pub use svg::options_map as svg_options;
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
/// file it replaces), for the apps' own writers too.
File diff suppressed because one or more lines are too long
+13 -10
View File
@@ -184,7 +184,8 @@ fn through_editing_data(
) -> (Document, Vec<String>, bool) {
let Some(layers) = layers else { return (doc, warnings, false) };
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai_from(layers, doc, warnings, outlined, opts.trust_editing_data, unit);
let (doc, mut warnings, read) =
vectorcraft_eps::layered_ai_from(layers, doc, warnings, outlined, opts.trust_editing_data, unit, opts.legacy_names);
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
if !read {
return (doc, warnings, false);
@@ -209,12 +210,12 @@ fn from_editing_data_alone(opened: &vectorcraft_pdf::Opened, opts: &LoadOptions)
return None;
}
let private = opened.illustrator_data()?;
Some(editing_copy_alone(&private))
Some(editing_copy_alone(&private, opts))
}
/// [`from_editing_data_alone`] of `private`, the editor's copy already read.
fn editing_copy_alone(private: &[u8]) -> Result<(Document, Vec<String>)> {
vectorcraft_eps::ai_alone(private)
fn editing_copy_alone(private: &[u8], opts: &LoadOptions) -> Result<(Document, Vec<String>)> {
vectorcraft_eps::ai_alone_with(private, opts.legacy_names)
.map_err(|why| err(format!("this file was saved without PDF compatibility, and its editing data can't be read ({why})")))
}
@@ -247,7 +248,7 @@ fn without_pdf_part(bytes: &[u8], opts: &LoadOptions, e: &EngineError) -> Option
return None;
}
let private = vectorcraft_pdf::illustrator_data_without_pdf_part(bytes, opts.password.as_deref())?;
let (doc, warnings) = vectorcraft_eps::ai_alone_on_page(&private.data, private.trim, private.user_unit).ok()?;
let (doc, warnings) = vectorcraft_eps::ai_alone_on_page(&private.data, private.trim, private.user_unit, opts.legacy_names).ok()?;
let notes = std::iter::once(format!("the PDF part couldn't be read ({e}): the file opened from its editing data")).chain(warnings).collect();
Some(outlined_if_asked(doc, notes, opts))
}
@@ -323,7 +324,7 @@ pub(crate) fn page_or_editing_data(
match read {
Ok((doc, notes)) => Ok((doc, notes, false)),
Err(e) if is_placeholder(&e) => {
let l = load_with(name, bytes, &LoadOptions { password: opts.password.clone(), ..Default::default() })?;
let l = load_with(name, bytes, &LoadOptions { password: opts.password.clone(), legacy_names: opts.legacy_names, ..Default::default() })?;
Ok((l.doc, l.warnings, true))
}
Err(e) if page_index == 0 => match load_with(name, bytes, &LoadOptions { pages: None, ..opts.clone() }) {
@@ -352,7 +353,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
restore_or_import(editing, || {
let r = vectorcraft_eps::import_trusting(bytes, opts.editing_data, opts.trust_editing_data)
let r = vectorcraft_eps::import_names(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names)
.map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
Ok((r.document, r.warnings))
})?
@@ -391,7 +392,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
if let Some((o, private)) = opened.as_ref().ok().filter(|_| reads_layers_alone(opts)).and_then(|o| Some((o, editing_copy(o, opts)?)))
{
let pages = o.trim_pages();
match vectorcraft_eps::ai_layers_alone(&private, &pages.sizes, pages.user_unit) {
match vectorcraft_eps::ai_layers_alone(&private, &pages.sizes, pages.user_unit, opts.legacy_names) {
Ok((mut doc, warnings)) => {
doc.setup.bleed = pages.bleed;
editing_data = true;
@@ -413,8 +414,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
// decoded draws nothing either): its notes are at most the page's own.
let empty = draws_nothing(&r.document) && r.warnings.iter().all(|w| is_page_note(w));
let copy_alone = || match &private {
Some(private) => Some(editing_copy_alone(private)),
None if layers.is_some() => opened.as_ref().ok().and_then(|o| editing_copy(o, opts)).map(|p| editing_copy_alone(&p)),
Some(private) => Some(editing_copy_alone(private, opts)),
None if layers.is_some() => {
opened.as_ref().ok().and_then(|o| editing_copy(o, opts)).map(|p| editing_copy_alone(&p, opts))
}
None => None,
};
let alone = (r.only_text || empty)
+15 -3
View File
@@ -39,6 +39,9 @@ pub struct LoadOptions {
/// A `.ai` opens from its editing copy even where that draws differently from its PDF part
/// (which can be older than it), with a warning, instead of from the PDF part.
pub trust_editing_data: bool,
/// The encoding names in a `.ai` or EPS's editing data are read in when they aren't UTF-8
/// (`legacyNameEncoding`).
pub legacy_names: vectorcraft_eps::NameEncoding,
/// A PDF or `.ai` saved with Preserve Editing opens from the VectorCraft document it carries;
/// off, it opens as another app would open it, from the editing copy of its art or its pages.
pub native: bool,
@@ -57,6 +60,7 @@ impl Default for LoadOptions {
dxf: Default::default(),
editing_data: true,
trust_editing_data: false,
legacy_names: vectorcraft_eps::NameEncoding::default(),
native: true,
}
}
@@ -65,8 +69,9 @@ impl Default for LoadOptions {
impl LoadOptions {
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`,
/// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false | `always`) and `dxf`
/// (the DXF import options).
/// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false | `always`),
/// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]) and `dxf` (the DXF
/// import options).
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
None => None,
@@ -95,8 +100,15 @@ impl LoadOptions {
Some(Value::String(s)) if s.eq_ignore_ascii_case("always") => (true, true),
Some(v) => (v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true, false or \"always\""))?, false),
};
let legacy_names = match p.get("legacyNameEncoding").filter(|v| !v.is_null()) {
None => vectorcraft_eps::NameEncoding::default(),
Some(v) => v
.as_str()
.and_then(vectorcraft_eps::NameEncoding::from_label)
.ok_or_else(|| bad(cmd, format!("legacyNameEncoding must be one of {}", vectorcraft_eps::NameEncoding::LABELS.join(", "))))?,
};
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data, native: true })
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data, legacy_names, native: true })
}
/// Does the document read only part of the file, or read it differently (a page range, another
+3 -1
View File
@@ -752,6 +752,8 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
/// file becomes the document's own; the live document gets them in [`SaveJob::finish`]). Bad
/// options fail here, before anything is encoded.
pub fn save_job(s: &mut Session, plan: SavePlan) -> Result<SaveJob> {
// A Shape Builder merge still being computed for a click is saved with the document.
s.finish_tool_edit_later()?;
// The web build, without a clock, leaves the dates unless given one.
let at = if plan.path.is_some() && plan.retargets() { clock_date(s, "modified", plan.modified) } else { None };
job_for_stamped(s, s.doc()?, plan, at)
@@ -1102,7 +1104,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: 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: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: 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: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. 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",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
+35 -2
View File
@@ -193,7 +193,10 @@ pub(super) fn name_without_device(name: &str) -> String {
}
/// Keys of a format row that aren't encoder options.
const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"];
pub(crate) const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"];
/// The writable formats a format row can't name.
pub(crate) const UNWRITTEN_FORMATS: [&str; 5] = ["template", "txt", "dxf", "emf", "wmf"];
/// One format row: what to write, at which size, with which suffix, into which sub-folder.
struct Row {
@@ -214,7 +217,7 @@ impl Row {
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
}
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
if matches!(format.id, "template" | "txt" | "dxf" | "emf" | "wmf") {
if UNWRITTEN_FORMATS.contains(&format.id) {
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft"));
}
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
@@ -285,6 +288,36 @@ impl Row {
/// or where to.
pub const SHARED_KEYS: [&str; 6] = ["formats", "preset", "settings", "prefix", "subfolders", "antiAlias"];
/// The keys `document.exportForScreens` reads: those of [`ScreenParams`], the formats and their
/// settings, `antiAlias`, `exportPreset` and `openLocation`, which the app reads.
pub(crate) const PARAM_KEYS: [&str; 25] = [
"artboard",
"artboards",
"range",
"folder",
"zip",
"fullDocument",
"includeBleed",
"includeArtboardFill",
"subfolders",
"preset",
"prefix",
"assets",
"layers",
"selection",
"name",
"counterStart",
"counterPer",
"date",
"folders",
"pdfs",
"formats",
"settings",
"antiAlias",
"exportPreset",
"openLocation",
];
/// What `document.exportForScreens` reads besides the formats.
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -1022,6 +1022,21 @@ fn an_ai_file_saved_without_its_pdf_part_pastes_from_its_editing_data() {
assert!(e.contains("placeholder"), "{e}");
}
/// `file.place` reads a `.ai` saved without its PDF part in the `legacyNameEncoding` it is given.
#[test]
fn an_ai_file_saved_without_its_pdf_part_places_in_the_encoding_picked() {
let mut data = ai::sample_data();
let at = data.windows(4).position(|w| w == b"Pair").unwrap();
// 빨강 in code page 949.
data.splice(at..at + 4, br"\273\241\260\255".iter().copied());
let file = vectorcraft_format::base64_encode(&ai::ai(&compressed(&data), PLACEHOLDER));
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("file.place", &json!({"name": "art.ai", "dataBase64": file, "link": false, "legacyNameEncoding": "windows-949"})).unwrap();
let placed = s.doc().unwrap().doc.layers[0].children().unwrap()[0].clone();
assert_eq!(names(&placed.children().unwrap()[1..]), ["빨강", "Ring", "Window"], "{r}");
}
#[test]
fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() {
let [sample, damaged, plain] = placeholder_files();
@@ -184,6 +184,41 @@ showpage
assert!(e.contains("junk.eps"), "{e}");
}
/// `legacyNameEncoding` picks the code page of a legacy `.ai`'s layer names that aren't UTF-8; by
/// default they come in as Windows-1252, with a warning naming them. An encoding it doesn't know is
/// refused.
#[test]
fn legacy_ai_layer_names_open_in_the_encoding_picked() {
let mut s = Session::new();
// A layer named 레이어 1 in Windows code page 949.
let ai = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%DocumentNeededResources: procset Adobe_IllustratorA_AI3 1.0 0\n%AI3_TemplateBox: 50 50 50 50\n%%EndComments\n\
%%BeginProlog\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\
%AI5_BeginLayer\n1 1 1 1 0 0 2 0 0 0 Lb\n(\\267\\271\\300\\314\\276\\356 1) Ln\n0 0 0 1 K\n0 i 1 w\n10 10 m\n90 90 L\nS\nLB\n%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n";
let open_as = |s: &mut Session, encoding: Option<&str>| {
let mut p = json!({"name": "korean.ai", "dataBase64": vectorcraft_format::base64_encode(ai)});
if let Some(e) = encoding {
p["legacyNameEncoding"] = json!(e);
}
s.execute("document.open", &p)
};
let r = open_as(&mut s, None).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].name.as_deref(), Some("·¹À̾î 1"));
assert_eq!(
r["warnings"],
json!(["the name `·¹À̾î 1` isn't UTF-8 and was read as windows-1252: if it looks wrong, open the file with another legacyNameEncoding"])
);
for encoding in ["windows-949", "EUC-KR"] {
let r = open_as(&mut s, Some(encoding)).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].name.as_deref(), Some("레이어 1"), "{encoding}");
assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("`레이어 1`") && w.contains("windows-949")), "{r}");
}
let e = open_as(&mut s, Some("utf-16")).unwrap_err();
assert!(
matches!(&e, EngineError::BadParams { msg, .. } if msg == "legacyNameEncoding must be one of windows-1252, macintosh, windows-949, shift_jis, gbk, big5"),
"{e}"
);
}
#[test]
fn eps_files_place_as_one_group_clipped_to_their_bounding_box() {
let mut s = rich();
+178 -34
View File
@@ -6,9 +6,9 @@
//! by `|`; a `?` marks an optional one, and a `:`, `=` or `(` starts what the entry says about it. An entry that starts
//! with `…` stands for keys listed elsewhere: those of each command it names after `as` or `of`
//! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't
//! described key by key, so only the top level is checked, except in an export's options: those
//! are checked against the options of the formats the command can write, in `marks`, `bleed` and
//! the other objects too ([`check_export_options`]).
//! described key by key, so only the top level is checked, except in the options of an export, a
//! save or Export for Screens: those are checked against the options of the formats the command
//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]).
//!
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
@@ -531,25 +531,183 @@ fn check_option_tree(id: &str, within: &str, given: &serde_json::Map<String, Val
Ok(())
}
/// The options of an export command `id`. `document.export`, `document.exportSelection` and
/// `document.serialize` take a key that some format reads (one object can carry several formats'
/// options; `document.formats` lists them). `document.exportPdf`, `document.pdfSettings` and
/// `pdf.preset.save` take a key that PDF reads, `document.exportEps` one that EPS reads and
/// `document.exportDxf` one that DXF reads. Where a command takes `marks`, `bleed`, `flattener` or
/// another object option of PDF or EPS, only the object's own keys pass. An SVG export's `svg`
/// object is checked when it is read. Saved presets are not checked: one with a key that is no
/// longer read keeps loading.
/// `given`, the options object at `within` (`""` for the params themselves, `options.`,
/// `formats[0].`), has only `keys`, and only their own keys in the PDF and EPS object options
/// (`trees`). `why_not` says why a key that is not near any of `keys` is refused, and where the
/// keys are listed.
fn check_option_keys(id: &str, within: &str, given: &serde_json::Map<String, Value>, keys: &[&str], why_not: &str, trees: &Value) -> Result<()> {
for (key, value) in given {
if !keys.contains(&key.as_str()) {
return Err(bad(
id,
match nearest(key, keys) {
Some(near) => format!("unknown parameter `{within}{key}`; did you mean `{within}{near}`?"),
None => format!("unknown parameter `{within}{key}`; {why_not}"),
},
));
}
if let (Some(Value::Object(schema)), Value::Object(value)) = (trees.get(key), value) {
check_option_tree(id, &format!("{within}{key}."), value, schema)?;
}
}
Ok(())
}
/// Why an export option that no format reads is refused.
const NO_FORMAT: &str = "no format takes it (see document.formats)";
/// Why a save option that no save format reads is refused.
const NO_SAVE_FORMAT: &str = "no save format takes it (see file.formatOptions)";
/// The format ids Export for Screens writes: the writable formats, those exporter plug-ins add
/// included, but those it doesn't write.
pub fn screen_formats() -> Vec<&'static str> {
use super::fileio::{FORMATS, SCREEN_UNWRITTEN_FORMATS, plugin_formats};
let added = plugin_formats();
FORMATS.iter().chain(added.iter().copied()).filter(|f| f.write && !SCREEN_UNWRITTEN_FORMATS.contains(&f.id)).map(|f| f.id).collect()
}
/// The keys a format row of Export for Screens, and the settings of a format, take: the row's own
/// (`format`, `scale`…), `includeArtboardFill` and `richBlack`, which the encoders read from a
/// row's options (`with_artboard_fills`, the render options), `params`, an exporter plug-in's own,
/// and a key some format reads, as `document.export` takes them.
fn screen_option_keys() -> Vec<&'static str> {
use super::fileio::{FORMATS, SCREEN_ROW_KEYS};
let mut keys: Vec<&str> = SCREEN_ROW_KEYS.to_vec();
keys.extend(["includeArtboardFill", "richBlack", "params"]);
keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name));
keys
}
/// A format row of Export for Screens, or the settings of a format, without the keys the export
/// refuses in it ([`check_screen_rows`]), inside `marks`, `svg` and the other object options too:
/// a saved row or preset that holds a key that is no longer read loads without it.
pub fn drop_unread_row_options(row: &mut serde_json::Map<String, Value>) {
fn retain_tree(o: &mut serde_json::Map<String, Value>, schema: &serde_json::Map<String, Value>) {
o.retain(|k, _| schema.contains_key(k));
for (k, v) in o.iter_mut() {
if let (Some(Value::Object(inner)), Value::Object(v)) = (schema.get(k), v) {
retain_tree(v, inner);
}
}
}
let keys = screen_option_keys();
row.retain(|k, _| keys.contains(&k.as_str()));
let trees = screen_option_trees();
for (k, v) in row.iter_mut() {
if let (Some(Value::Object(schema)), Value::Object(v)) = (trees.get(k), v) {
retain_tree(v, schema);
}
}
}
/// [`option_trees`] of Export for Screens: its remembered settings mark PDF security whose
/// passwords were left out with `security.passwordRequired`, which the export reads.
fn screen_option_trees() -> Value {
let mut trees = option_trees();
if let Some(security) = trees.get_mut("security").and_then(Value::as_object_mut) {
security.insert("passwordRequired".into(), Value::Null);
}
trees
}
/// The object options an export or save checks key by key: PDF's `marks`, `bleed` and the others,
/// the flattener's, and SVG's `svg` with the artboard keys it may carry.
fn option_trees() -> Value {
let mut trees = pdf_option_tree();
if let Some(tree) = trees.as_object_mut() {
tree.insert("flattener".into(), serde_json::to_value(super::flatten::FlattenOptions::default()).unwrap_or_default());
let mut svg = serde_json::to_value(vectorcraft_svg::ExportOptions::default()).unwrap_or_default();
if let Some(svg) = svg.as_object_mut() {
svg.extend(super::fileio::ARTBOARD_PARAMS.iter().chain(&["useArtboards"]).map(|k| (k.to_string(), Value::Null)));
}
tree.insert("svg".into(), svg);
}
trees
}
/// The format rows (`formats`) and the per-format `settings` of Export for Screens params `p`, at
/// `within` (`settings.` in `document.exportPresets.save`). A row, and the settings of a format,
/// take [`screen_option_keys`]; `settings` takes the formats Export for Screens writes.
fn check_screen_rows(id: &str, within: &str, p: &serde_json::Map<String, Value>) -> Result<()> {
let keys = screen_option_keys();
let trees = &screen_option_trees();
if let Some(Value::Array(rows)) = p.get("formats") {
for (i, row) in rows.iter().enumerate() {
if let Value::Object(row) = row {
check_option_keys(id, &format!("{within}formats[{i}]."), row, &keys, NO_FORMAT, trees)?;
}
}
}
if let Some(Value::Object(settings)) = p.get("settings") {
let formats = screen_formats();
for (format, options) in settings {
if !formats.contains(&format.as_str()) {
let why = match nearest(format, &formats) {
Some(near) => format!("did you mean `{within}settings.{near}`?"),
None => format!("`{within}settings` takes: {}", formats.join(", ")),
};
return Err(bad(id, format!("unknown parameter `{within}settings.{format}`; {why}")));
}
if let Value::Object(options) = options {
check_option_keys(id, &format!("{within}settings.{format}."), options, &keys, NO_FORMAT, trees)?;
}
}
}
Ok(())
}
/// The options of an export or save command `id`. `document.export`, `document.exportSelection`
/// and `document.serialize` take a key that some format reads (one object can carry several
/// formats' options; `document.formats` lists them). `document.exportPdf`, `document.pdfSettings`
/// and `pdf.preset.save` take a key that PDF reads, `document.exportEps` one that EPS reads and
/// `document.exportDxf` one that DXF reads. `document.save`, `file.saveAs`, `file.saveCopy` and
/// `file.saveAsTemplate` take, beside the path and in `options`, a key that some save format
/// reads. `document.exportForScreens` takes the keys it reads, and checks its format rows and
/// settings ([`check_screen_rows`]), as `document.exportPresets.save` does in its `settings` and
/// `assets.export` and `assets.settings.set` do in theirs.
/// Where a command takes `marks`, `bleed`, `flattener`, `svg` or another object option of PDF,
/// EPS or SVG, only the object's own keys pass ([`option_trees`]). Saved presets are not checked:
/// one with a key that is no longer read keeps loading.
fn check_export_options(id: &str, params: &Value) -> Result<()> {
use super::fileio::{FORMATS, dxf, eps, pdf};
use super::fileio::{FORMATS, SAVE_FORMATS, SCREEN_PARAM_KEYS, TEMPLATE_FORMATS, dxf, eps, pdf};
let Value::Object(given) = params else { return Ok(()) };
match (id, given.get("settings")) {
("document.exportForScreens", _) => {
if let Some(key) = given.keys().find(|k| !SCREEN_PARAM_KEYS.contains(&k.as_str())) {
return Err(unknown(id, key, &SCREEN_PARAM_KEYS));
}
return check_screen_rows(id, "", given);
}
("document.exportPresets.save", Some(Value::Object(settings))) => {
let why = format!("`settings` takes: {}", SCREEN_PARAM_KEYS.join(", "));
check_option_keys(id, "settings.", settings, &SCREEN_PARAM_KEYS, &why, &Value::Null)?;
return check_screen_rows(id, "settings.", settings);
}
// Export Assets exports with, and stores, Export for Screens' format rows and settings.
("assets.export" | "assets.settings.set", _) => return check_screen_rows(id, "", given),
_ => {}
}
let mut keys: Vec<&str> = vec!["path", "format", "acknowledgeLoss"];
// A save's `options` and the keys they take.
let mut inner: Option<(&serde_json::Map<String, Value>, Vec<&str>)> = None;
let why_not = match id {
"document.save" | "file.saveAs" | "file.saveCopy" | "file.saveAsTemplate" => {
let saved = FORMATS.iter().filter(|f| SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id));
let options: Vec<&str> = saved.flat_map(|f| f.options).map(|o| o.name).collect();
if let Some(Value::Object(o)) = given.get("options") {
inner = Some((o, options.clone()));
}
keys.extend(["options", "modified"]);
keys.extend(options);
NO_SAVE_FORMAT
}
"document.export" | "document.exportSelection" | "document.serialize" => {
// `richBlack` is Appearance of Black's setting for a raster export, and `params` an
// exporter plug-in's own.
keys.extend(["includeArtboardFill", "selectedOnly", "richBlack", "params"]);
keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name));
"no format takes it"
NO_FORMAT
}
"document.exportPdf" | "document.pdfSettings" | "pdf.preset.save" => {
keys.push("includeArtboardFill");
@@ -559,38 +717,24 @@ fn check_export_options(id: &str, params: &Value) -> Result<()> {
"pdf.preset.save" => keys.extend(["name", "newName", "description"]),
_ => {}
}
"PDF doesn't take it"
"PDF doesn't take it (see document.formats)"
}
"document.exportEps" => {
keys.push("includeArtboardFill");
keys.extend(eps::OPTIONS.iter().map(|o| o.name));
"EPS doesn't take it"
"EPS doesn't take it (see document.formats)"
}
"document.exportDxf" => {
keys.extend(dxf::OPTIONS.iter().map(|o| o.name));
"DXF doesn't take it"
"DXF doesn't take it (see document.formats)"
}
_ => return Ok(()),
};
let mut trees = pdf_option_tree();
if let Some(tree) = trees.as_object_mut() {
tree.insert("flattener".into(), serde_json::to_value(super::flatten::FlattenOptions::default()).unwrap_or_default());
let trees = option_trees();
if let Some((o, options)) = inner {
check_option_keys(id, "options.", o, &options, NO_SAVE_FORMAT, &trees)?;
}
for (key, value) in given {
if !keys.contains(&key.as_str()) {
return Err(bad(
id,
match nearest(key, &keys) {
Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"),
None => format!("unknown parameter `{key}`; {why_not} (see document.formats)"),
},
));
}
if let (Some(Value::Object(schema)), Value::Object(value)) = (trees.get(key), value) {
check_option_tree(id, &format!("{key}."), value, schema)?;
}
}
Ok(())
check_option_keys(id, "", given, &keys, why_not, &trees)
}
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included,
+25 -9
View File
@@ -36,7 +36,12 @@
//! (`vc_settings_*`, `vc_cache_*`), in the plug-in store under its id ([`crate::store`]):
//! `plugin.clearCache` empties the cache, `plugin.list` and `plugin.info` give its size
//! (`cacheBytes`), replacing the plug-in keeps both and `plugin.remove` deletes them.
//!
//! A filter whose manifest lists `inputs` (plug-in API level 2) is also given the selected type,
//! images or symbol instances of those kinds, to read only: its output must return each of them
//! unchanged ([`objects::decode_output_with`]).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use serde_json::{Map, Value, json};
@@ -75,6 +80,9 @@ pub fn describe(p: &Plugin) -> Value {
if m.budget == Budget::Heavy {
v["budget"] = json!("heavy");
}
if !m.inputs.is_empty() {
v["inputs"] = json!(m.inputs);
}
if m.paint_output {
v["paintOutput"] = json!(true);
}
@@ -126,6 +134,9 @@ pub struct FilterRun {
params: Vec<u8>,
/// The objects the plug-in is given, and the container new objects go in.
inputs: Vec<NodeId>,
/// The type, images and symbol instances among them (a filter that asks for them), as given:
/// its output must return each one unchanged.
read_only: HashMap<NodeId, Value>,
parent: Option<NodeId>,
/// The document it was made from, and that document's revision then.
document: (u64, u64),
@@ -192,8 +203,10 @@ impl FilterRun {
st.selection.objects.clone()
}
};
// Without `doc.read` a filter is given no objects: it can only add new ones.
let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() };
// Without `doc.read` a filter is given no objects: it can only add new ones. Type, images
// and symbol instances only when its manifest asks for them (`inputs`).
let kinds = m.input_kinds();
let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets_with(&st.doc, &selected, &kinds) } else { Vec::new() };
let parent = st.insertion_parent();
let bounds = st.doc.bounds_of(&inputs, false);
let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first());
@@ -206,10 +219,12 @@ impl FilterRun {
params["_context"]["mode"] = json!("command");
params["_context"]["command"] = json!(cmd.id);
}
let input = objects::encode_input(&st.doc, &inputs);
let params = serde_json::to_vec(&params).map_err(|e| bad(c, e.to_string()))?;
// The input and the parameters may use half the plug-in's memory together.
let capacity = (plugin.limits().max_memory_bytes / 2).min(i32::MAX as usize).saturating_sub(params.len());
let (input, read_only) = objects::encode_input_with(&st.doc, &inputs, &kinds, capacity).map_err(err)?;
let document = (st.uid, st.revision);
Ok(FilterRun { plugin, command, input, params, inputs, parent, document })
Ok(FilterRun { plugin, command, input, params, inputs, read_only, parent, document })
}
/// The plug-in, for its name and budget.
@@ -249,12 +264,13 @@ impl FilterRun {
}
/// Applies the plug-in's `output` to the active document as one undo step: its objects, and what
/// it keeps with the document from now on. An output its permissions don't allow is refused,
/// and the document stays as it was.
/// it keeps with the document from now on. An output its permissions don't allow, and one that
/// changes or leaves out a read-only object it was given, is refused, and the document stays as
/// it was.
fn finish(self, s: &mut Session, output: &[u8]) -> Result<Value> {
let m = self.plugin.manifest();
let may = m.permissions();
let out = objects::decode_output(output).map_err(err)?;
let out = objects::decode_output_with(output, &self.read_only).map_err(err)?;
if out.document_data.is_some() && !may.contains(&Permission::State) {
return Err(not_granted(&m.name, Permission::State));
}
@@ -874,7 +890,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Plug-ins",
[],
None,
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
always,
list
),
@@ -892,7 +908,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Plug-in",
[],
None,
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, compound paths that clip a clip group and paths inside a group that clips one are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, with at most 16 MiB of pixels and 16 MiB of type runs per run (later ones come with pixelsOmitted or truncated); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size (a path or compound path fails it with the memory-budget error for a selection too large to hand over), and more than 100,000 of these objects fail it with an object-budget error: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
has_doc,
run
),
+6
View File
@@ -678,6 +678,12 @@ impl Session {
for e in self.prefs.keep_folders(&mut p) {
log::warn!("{e}");
}
// The edit the active tool computes on another thread is applied first (the Preferences
// dialog applies its settings here, outside a command). A failure to apply it was the
// edit's own, and the preferences apply all the same.
if self.depth == 0 {
let _ = self.finish_tool_edit_later();
}
let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions;
let history_changed = p.history_states != self.prefs.history_states;
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
+29 -1
View File
@@ -1,4 +1,4 @@
use super::{NodeId, Value, cmd};
use super::{NodeId, Value, cmd, find_command};
/// The commands that act on the active artboard (#693), which the app passes as their `artboard`:
/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, Distribute
@@ -78,6 +78,34 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) {
}
}
/// Queries that apply the edit the active tool is computing before they run, as edits do
/// ([`Session::finish_tool_edit`]): a batch runs edits, Find Objects with `select`, Select All
/// Instances and Lock Guides change the selection, and the proof views, the preferences and added
/// fonts give the documents new revisions (they draw again, and type lays out again).
const WAIT_FOR_TOOL_EDIT: &[&str] = &[
"command.batch",
"document.find",
"symbol.selectInstances",
"view.guides.lock",
"view.proofSetup",
"view.proofColors",
"view.overprintPreview",
"view.separationsPreview",
"prefs.set",
"prefs.reset",
"text.addFontFiles",
];
/// Does command `id`, run on its own, first apply the edit the active tool is computing on
/// another thread and the input held for it ([`Session::finish_tool_edit`])? Every command that
/// can edit, select, save or give a document a new revision does; queries that only read do not
/// (`commands_that_do_not_wait_for_a_tool_edit_change_no_document` in the command sweep checks
/// them). A frontend that adds params from
/// its own state (the active artboard) applies them first itself.
pub fn waits_for_tool_edit(id: &str) -> bool {
find_command(id).is_some_and(|spec| spec.journal || WAIT_FOR_TOOL_EDIT.contains(&id))
}
/// Journaled commands that write a file and change nothing in the document: with an object of a
/// dynamic symbol instance's art selected, they read the instance as queries do
/// ([`crate::Session::execute`]).
+14 -1
View File
@@ -62,6 +62,19 @@ pub struct Session {
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
/// back as it was at the release ([`Session::pointer`]).
pub(crate) lender: Option<Box<dyn Tool>>,
/// Pointer and key input that came while the tool computed an edit on another thread, each with
/// its number and view, made in order once the edit is applied.
held_input: std::collections::VecDeque<(u64, tooling::HeldInput, ViewInfo)>,
/// The number the next pointer or key event handed to the session gets.
input_count: u64,
/// The number of the event whose handling started the edit the tool is computing.
edit_started_by: Option<u64>,
/// Set while the tool's edit and the input held for it are applied: the commands that run then
/// do not start applying them again.
settling_tool_edit: bool,
/// Requests for the UI from making held input where nothing could take them (a command, a tool
/// switch), which the next [`Session::tool_poll`] returns.
held_ui: Vec<UiRequest>,
/// The active document (its uid) and its [`History::step`] when the tool last handled a pointer
/// event or drew its overlays: when they differ at the next one, the tool is told first
/// ([`vectorcraft_tools::Tool::history_moved`]). None until the first.
@@ -308,7 +321,7 @@ mod session;
pub(crate) use command_sets::SELECTS_GIVEN_IDS;
#[cfg(test)]
use command_sets::given_ids;
pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD};
pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit};
pub use doc_state::{DocState, OpenRows, RecalledSelection};
pub use error::{EngineError, Result};
pub use history::{History, HistoryEntry, NamedStep, default_history_budget};
+1 -1
View File
@@ -176,7 +176,7 @@ pub struct Prefs {
pub system_title_bar: bool,
/// Linux and the BSDs: draw the app bar as the title bar in sessions where the desktop draws
/// the frame (X11, Wayland compositors with server-side decorations, XWayland), as on GNOME.
/// Read when the app starts; System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it.
/// Read when the app starts; System Title Bar and `VECTORCRAFT_TITLEBAR` set to `app`, `system` or `desktop` take precedence over it.
pub app_title_bar: bool,
// Performance
pub gpu_performance: bool,
+1 -1
View File
@@ -1,4 +1,4 @@
use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only};
use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only, waits_for_tool_edit};
use super::doc_state::DocState;
use super::error::{EngineError, Result};
use super::history::NamedStep;
+8
View File
@@ -46,6 +46,11 @@ impl Session {
journal: Journal::default(),
tool: vectorcraft_tools::create("selection"),
lender: None,
held_input: Default::default(),
input_count: 0,
edit_started_by: None,
settling_tool_edit: false,
held_ui: vec![],
tool_step: None,
selection_at_lend: None,
lent_selection: None,
@@ -160,6 +165,9 @@ impl Session {
}
self.press_type_selection = None;
let view = self.last_view;
// The edit the tool computes on another thread and the input held for it belong to the
// document being left. The switch goes on whatever they did, as for the deactivation.
let _ = self.finish_tool_edit_later();
// The switch goes on whatever the lent tool's last actions did, as for the deactivation.
let _ = self.give_back_tool(view);
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
+5
View File
@@ -230,6 +230,11 @@ impl Session {
if label != "Typing" && cmd::typing_in_progress(self) {
cmd::settle_interaction(self)?;
}
// An edit the tool computes on another thread (a Shape Builder merge) is applied first too,
// so the snapshot the interaction previews from and goes back to holds it.
if !self.in_interaction() {
self.finish_tool_edit_later()?;
}
self.clear_deferred_overlay_cache();
let paint = self.paint.clone();
let st = self.doc_mut()?;
+7 -1
View File
@@ -1,6 +1,6 @@
use super::{
CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard,
strip_replay_only,
strip_replay_only, waits_for_tool_edit,
};
impl Session {
@@ -39,6 +39,12 @@ impl Session {
/// Execute a command by id. This is THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
// An edit the active tool is computing on another thread (a Shape Builder merge) is
// applied before a command that may edit, select, save or give a document a new revision,
// as the click came first. Queries that only read do not wait for it.
if self.depth == 0 && waits_for_tool_edit(id) {
self.finish_tool_edit_later()?;
}
let outer = std::mem::replace(&mut self.reading, !spec.journal || JOURNALED_READS.contains(&id));
let r = self.execute_spec(spec, id, params);
self.reading = outer;
+4
View File
@@ -207,6 +207,10 @@ impl Session {
/// [`Session::end_undo_scope`]: open an undo group there or, when one is open already, join it,
/// so that the edits are part of its step. `None` without a document.
pub fn begin_undo_scope(&mut self) -> Option<UndoScope> {
// An edit the tool computes on another thread is a step before the scope, so the scope's
// snapshot holds it. A failure to apply it was the edit's own, and the scope begins all the
// same.
let _ = self.finish_tool_edit_later();
let journal = self.journal.len();
let st = self.active()?;
let uid = st.uid;
+480
View File
@@ -607,6 +607,486 @@ fn shape_builder_merge_uses_the_map_the_tool_has() {
assert_eq!(maps_built(), built + 1, "another gap");
}
/// 40 squares 20 pt wide in a row, each 10 pt right of the one before (160 segments, which the
/// Shape Builder merges on another thread), all selected.
fn square_row(s: &mut Session) -> Vec<NodeId> {
let ids: Vec<NodeId> = (0..40).map(|k| rect(s, f64::from(k) * 10.0, 0.0, 20.0, 20.0)).collect();
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
ids
}
/// The active document of `s` without its dates, so documents made in two sessions at different
/// times compare equal when their art is.
fn undated(s: &Session) -> vectorcraft_doc::Document {
let mut d = (*s.doc().unwrap().doc).clone();
(d.metadata.created, d.metadata.modified) = (None, None);
d
}
/// The Shape Builder over [`square_row`], its map built, after a click at (15, 10): the merge is
/// being computed on another thread, and the document is as it was.
fn click_row(s: &mut Session, v: ViewInfo) {
s.select_tool("shapeBuilder", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 15.0, 10.0), v).unwrap();
s.tool_poll(v, true).unwrap();
let revision = s.doc().unwrap().revision;
s.pointer(&PointerEvent::new(PointerKind::Down, 15.0, 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 15.0, 10.0), v).unwrap();
assert!(s.tool_editing() && s.tool_waiting());
assert_eq!(s.doc().unwrap().revision, revision, "the release returns before the merge is applied");
}
/// A click of the Shape Builder on a large selection merges on another thread, and the merge the
/// next poll applies is the command's own, as one undo step.
#[test]
fn shape_builder_merges_a_large_selection_on_another_thread() {
use vectorcraft_tools::builder::merges_computed;
let v = ViewInfo::default();
let mut s = session();
square_row(&mut s);
click_row(&mut s, v);
let computed = merges_computed();
s.tool_poll(v, true).unwrap();
assert!(!s.tool_editing());
assert_eq!(merges_computed(), computed, "the command takes the merge computed on another thread");
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
assert_eq!(layer_children(&s), 41, "the overlap and what is left of the two squares over it");
let mut direct = session();
square_row(&mut direct);
direct.execute("shapeBuilder.merge", &json!({"points": [[15, 10]]})).unwrap();
assert!(undated(&s) == undated(&direct), "the same document as the command gives");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layer_children(&s), 40, "one undo step");
}
/// What comes after a click whose merge is being computed on another thread waits for the merge
/// and runs on the merged art, as when the merge ran on the calling thread: a command (an edit,
/// an undo, a selection, a save, a batch, Lock Guides, the proof views, a preference, Add Font
/// Files), an option change, another tool, a save made outside a command, preferences applied
/// outside a command, a dialog's preview, a scrubbed field's undo scope, a Cmd-click or Cmd-drag
/// with the selection tool. The merge is the one computed on the other thread.
#[test]
fn shape_builder_applies_a_merge_before_what_comes_next() {
use vectorcraft_tools::builder::merges_computed;
type Next = (&'static str, fn(&mut Session, &[NodeId], &std::path::Path));
let v = ViewInfo::default();
let next: Vec<Next> = vec![
("Select All Instances", |s, _, _| {
s.execute("symbol.selectInstances", &json!({"name": "S"})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects.len(), 1);
}),
("an edit", |s, _, _| {
s.execute("object.move", &json!({"dx": 0, "dy": 100})).unwrap();
}),
("an undo", |s, _, _| {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layer_children(s), 40, "the undo takes the merge back");
s.execute("edit.redo", &json!({})).unwrap();
}),
("another selection", |s, _, _| {
s.execute("select.inverse", &json!({})).unwrap();
}),
("Select All", |s, _, _| {
s.execute("select.all", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects.len(), 41);
}),
("a save", |s, _, dir| {
s.execute("file.saveAs", &json!({"path": dir.join("a.vectorcraft").to_string_lossy()})).unwrap();
assert!(!s.doc().unwrap().is_dirty(), "the file holds the merge");
}),
("a save outside a command", |s, _, dir| {
let p = json!({"path": dir.join("b.vectorcraft").to_string_lossy()});
let plan = cmd::fileio::save_plan(s, cmd::fileio::SaveMode::SaveAs, &p).unwrap();
assert_eq!(layer_children(s), 40, "planning a save leaves the document");
let job = cmd::fileio::save_job(s, plan).unwrap();
assert_eq!(layer_children(s), 41, "the snapshot is taken after the merge");
drop(job);
}),
("a batch", |s, _, _| {
s.execute("command.batch", &json!({"commands": [{"command": "object.move", "params": {"dx": 0, "dy": 50}}]})).unwrap();
}),
("an option change", |s, _, _| {
s.set_tool_option_cmd(&json!({"key": "gapDetection", "value": true})).unwrap();
}),
("another tool", |s, _, _| {
s.select_tool("selection", ViewInfo::default()).unwrap();
}),
("a closed document", |s, _, _| {
s.execute("file.close", &json!({})).unwrap();
}),
("Lock Guides", |s, _, _| {
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
}),
("an option set from a panel", |s, _, _| {
s.set_tool_option("gapDetection", &json!(true));
}),
("a dialog's preview, canceled", |s, _, _| {
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 7, "dy": 0})).unwrap();
s.preview("object.move", &json!({"dx": 9, "dy": 0})).unwrap();
s.cancel_interaction().unwrap();
assert_eq!(layer_children(s), 41, "canceling the preview keeps the merge");
}),
("a dialog's preview, kept", |s, _, _| {
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 7, "dy": 0})).unwrap();
s.commit_interaction().unwrap();
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Move", "Shape Builder"]);
}),
("a scrubbed field, canceled", |s, _, _| {
let scope = s.begin_undo_scope().unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.end_undo_scope(scope, true);
assert_eq!(
s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()),
Some("Shape Builder"),
"the scrub's step is undone, not the merge"
);
}),
("a held click, then another tool", |s, _, _| {
let v = ViewInfo::default();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
assert!(s.tool_editing(), "the click is held");
s.select_tool("selection", v).unwrap();
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Shape Builder", "Shape Builder"], "the held click merges with the Shape Builder first");
}),
("a held click, then another document", |s, _, _| {
let v = ViewInfo::default();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
let first = s.active_index().unwrap();
s.add_document(vectorcraft_doc::Document::new(100.0, 100.0), None);
assert!(!s.tool_editing());
let labels: Vec<&str> = s.documents()[first].history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Shape Builder", "Shape Builder"], "the held click merges in its own document first");
s.set_active(first);
}),
("a Cmd-click", |s, _, _| {
let v = ViewInfo::default();
let cmd = Mods { cmd: true, ..Default::default() };
assert_eq!(s.cursor(Point::new(500.0, 300.0), cmd, v), vectorcraft_tools::Cursor::Busy, "Cmd shows the merge is computed");
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 500.0, 300.0).with_mods(cmd), v).unwrap();
}
assert!(s.tool_editing(), "the click is held");
while s.tool_waiting() {
s.tool_poll(v, true).unwrap();
}
assert!(s.doc().unwrap().selection.is_empty(), "the lent selection tool deselects after the merge");
assert_eq!(s.tool_id(), "shapeBuilder");
}),
("a Cmd-drag", |s, _, _| {
let v = ViewInfo::default();
let cmd = Mods { cmd: true, ..Default::default() };
for (kind, y) in [(PointerKind::Down, 10.0), (PointerKind::Drag, 60.0), (PointerKind::Drag, 110.0), (PointerKind::Up, 110.0)] {
s.pointer(&PointerEvent::new(kind, 305.0, y).with_mods(cmd), v).unwrap();
}
assert!(s.tool_editing(), "the drag is held");
while s.tool_waiting() {
s.tool_poll(v, true).unwrap();
}
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Move", "Shape Builder"]);
}),
// The proof views redraw every document (a new revision); they are left off, as the
// other tests of the process-wide proof view expect.
("Separations Preview", |s, _, _| {
s.execute("view.separationsPreview", &json!({"on": false})).unwrap();
}),
("Overprint Preview", |s, _, _| {
s.execute("view.overprintPreview", &json!({"on": false})).unwrap();
}),
("Proof Colors", |s, _, _| {
s.execute("view.proofColors", &json!({"on": false})).unwrap();
}),
("Proof Setup", |s, _, _| {
s.execute("view.proofSetup", &json!({})).unwrap();
}),
("a preference", |s, _, _| {
let every = s.prefs.gridline_every;
s.execute("prefs.set", &json!({"key": "gridlineEvery", "value": every + 10.0})).unwrap();
}),
("Reset Preferences", |s, _, _| {
s.execute("prefs.reset", &json!({})).unwrap();
}),
("preferences applied outside a command", |s, _, _| {
let mut p = s.prefs.clone();
p.gridline_every += 10.0;
s.apply_prefs(p);
}),
("Add Font Files", |s, _, _| {
// No files: the command fails after the merge.
assert!(s.execute("text.addFontFiles", &json!({"files": []})).is_err());
}),
];
let _proof = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = vectorcraft_testkit::temp_dir("shape-builder-merge-first");
for (what, then) in next {
let mut s = session();
if what == "Select All Instances" {
let far = rect(&mut s, 500.0, 500.0, 20.0, 20.0);
s.execute("symbol.new", &json!({"name": "S", "ids": [far.0]})).unwrap();
}
let ids = square_row(&mut s);
click_row(&mut s, v);
let computed = merges_computed();
let journal = s.journal.len();
then(&mut s, &ids, &dir);
assert!(!s.tool_editing(), "{what}");
assert_eq!(merges_computed(), computed, "{what}: the merge computed on another thread");
assert_eq!(s.journal.since(journal).first().map(|j| j.0.as_str()), Some("shapeBuilder.merge"), "{what}: the merge comes first");
if what != "a closed document" {
assert!(layer_children(&s) >= 41, "{what}: the merge stays");
}
}
}
/// Gestures and arrow keys that come while a merge is computed on another thread are held and
/// made after it, in order: the document is the one they make one after the other, and the
/// events count as made once they are and the merge they start is applied.
#[test]
fn shape_builder_holds_gestures_made_during_a_merge() {
type Gesture = fn(&mut Session, ViewInfo);
let cases: [(&str, bool, Gesture); 6] = [
("a second click", false, |s, v| {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
}),
("a drag", false, |s, v| {
for (kind, x) in [(PointerKind::Down, 75.0), (PointerKind::Drag, 85.0), (PointerKind::Drag, 95.0), (PointerKind::Up, 95.0)] {
s.pointer(&PointerEvent::new(kind, x, 10.0), v).unwrap();
}
}),
("two clicks", false, |s, v| {
for x in [55.0, 135.0] {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, x, 10.0), v).unwrap();
}
}
}),
("a Cmd-click", false, |s, v| {
let cmd = Mods { cmd: true, ..Default::default() };
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 500.0, 300.0).with_mods(cmd), v).unwrap();
}
}),
("a Cmd-drag and a click", false, |s, v| {
let cmd = Mods { cmd: true, ..Default::default() };
for (kind, y) in [(PointerKind::Down, 10.0), (PointerKind::Drag, 110.0), (PointerKind::Up, 110.0)] {
s.pointer(&PointerEvent::new(kind, 305.0, y).with_mods(cmd), v).unwrap();
}
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
}),
("an arrow key", true, |s, v| {
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
}),
];
let v = ViewInfo::default();
let start = |swatches: bool| {
let mut s = session();
if swatches {
s.select_tool("shapeBuilder", v).unwrap();
s.set_tool_option_cmd(&json!({"key": "pickColorFrom", "value": "swatches"})).unwrap();
}
square_row(&mut s);
click_row(&mut s, v);
s
};
for (what, swatches, send) in cases {
// One after the other: the merge applied before the gesture, and each merge after its own.
let mut one = start(swatches);
while one.tool_waiting() {
one.tool_poll(v, true).unwrap();
}
send(&mut one, v);
while one.tool_waiting() {
one.tool_poll(v, true).unwrap();
}
// While the merge is computed.
let mut s = start(swatches);
let revision = s.doc().unwrap().revision;
let from = s.input_count();
send(&mut s, v);
let to = s.input_count();
assert!(s.tool_editing() && !s.input_applied(from, to), "{what}: held");
assert_eq!(s.doc().unwrap().revision, revision, "{what}: nothing made before the merge");
while s.tool_waiting() {
s.tool_poll(v, true).unwrap();
}
assert!(!s.tool_editing() && s.input_applied(from, to), "{what}");
assert!(undated(&s) == undated(&one), "{what}: the document the gestures make one after the other");
assert_eq!(s.doc().unwrap().selection, one.doc().unwrap().selection, "{what}");
assert_eq!(s.paint.fill, one.paint.fill, "{what}");
let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>();
assert_eq!(labels(&s), labels(&one), "{what}");
// The click's merge alone.
let mut merged = start(swatches);
while merged.tool_waiting() {
merged.tool_poll(v, true).unwrap();
}
let changed = labels(&s).len() > labels(&merged).len()
|| s.paint.fill != merged.paint.fill
|| s.doc().unwrap().selection != merged.doc().unwrap().selection;
assert!(changed, "{what}: the gesture did something");
}
}
/// A press held while a merge is computed on another thread, then dropped (a long touch, a
/// canceled touch, a second finger), is never made: once the merge is applied, the tool is at
/// rest and the document is the one the same input makes, dropped the same way, after the merge.
/// A held click before the press is still made, and so is a perspective plane key held after it.
#[test]
fn shape_builder_drops_a_press_held_during_a_merge() {
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
type Gesture = fn(&mut Session, ViewInfo);
fn press(s: &mut Session, v: ViewInfo, x: f64, mods: Mods) {
s.pointer(&PointerEvent::new(PointerKind::Down, x, 10.0).with_mods(mods), v).unwrap();
}
fn click(s: &mut Session, v: ViewInfo) {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
}
// What a case sends, and whether the perspective grid is shown.
let cases: [(&str, bool, Gesture); 7] = [
("a press", false, |s, v| press(s, v, 75.0, Mods::default())),
("a drag", false, |s, v| {
press(s, v, 75.0, Mods::default());
s.pointer(&PointerEvent::new(PointerKind::Drag, 95.0, 10.0), v).unwrap();
}),
("a Cmd-press", false, |s, v| press(s, v, 305.0, Mods { cmd: true, ..Default::default() })),
("a press and a key", false, |s, v| {
press(s, v, 75.0, Mods::default());
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
}),
("a press and an arrow key", false, |s, v| {
press(s, v, 75.0, Mods::default());
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
}),
("a press and a plane key", true, |s, v| {
press(s, v, 75.0, Mods::default());
s.tool_key(ToolKey::Digit(3), Mods::default(), v).unwrap();
}),
("a click, then a press", false, |s, v| {
click(s, v);
press(s, v, 135.0, Mods::default());
}),
];
let v = ViewInfo::default();
let start = |grid: bool| {
let mut s = session();
s.select_tool("shapeBuilder", v).unwrap();
s.set_tool_option_cmd(&json!({"key": "pickColorFrom", "value": "swatches"})).unwrap();
if grid {
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
}
square_row(&mut s);
click_row(&mut s, v);
s
};
// A tool left in the middle of a gesture keeps waiting: the polls stop after a few.
let settle = |s: &mut Session| {
for _ in 0..8 {
if !s.tool_waiting() {
return;
}
s.tool_poll(v, true).unwrap();
}
};
let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>();
let plane = |s: &Session| PerspectiveGrid::from_doc(&s.doc().unwrap().doc).unwrap().plane;
for (what, grid, send) in cases {
// The same input after the merge, dropped the same way.
let mut one = start(grid);
settle(&mut one);
send(&mut one, v);
one.drop_tool_press(v).unwrap();
settle(&mut one);
let mut s = start(grid);
if grid {
assert_ne!(plane(&s), Plane::Right, "{what}: the plane before the key");
}
let from = s.input_count();
send(&mut s, v);
let to = s.input_count();
assert!(s.tool_editing() && !s.input_applied(from, to), "{what}: held");
s.drop_tool_press(v).unwrap();
settle(&mut s);
assert!(!s.tool_waiting() && !s.tool_editing() && s.input_applied(from, to), "{what}");
assert!(!s.tool_busy(), "{what}: the tool is at rest");
assert_eq!(s.tool_id(), "shapeBuilder", "{what}: no tool is lent");
assert!(undated(&s) == undated(&one), "{what}: the document the input makes after the merge");
assert_eq!(labels(&s), labels(&one), "{what}");
assert_eq!(s.doc().unwrap().selection, one.doc().unwrap().selection, "{what}");
assert_eq!(s.paint.fill, one.paint.fill, "{what}");
if grid {
assert_eq!(plane(&one), Plane::Right, "{what}: the plane key is made after the merge");
assert_eq!(plane(&s), Plane::Right, "{what}: the plane key is made");
}
}
}
/// A new revision of the document made outside a command while a merge is computed on another
/// thread keeps the merge when the selected art is as it was (the next poll applies it, as the
/// command computes it), and drops it when the art changed.
#[test]
fn shape_builder_keeps_a_merge_over_a_new_revision_of_the_same_art() {
use vectorcraft_tools::builder::merges_computed;
let v = ViewInfo::default();
let mut s = session();
square_row(&mut s);
click_row(&mut s, v);
let computed = merges_computed();
s.active_mut().unwrap().revision += 1;
s.tool_poll(v, true).unwrap();
assert!(!s.tool_editing());
assert_eq!(merges_computed(), computed, "the merge computed on another thread");
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
let mut direct = session();
square_row(&mut direct);
direct.execute("shapeBuilder.merge", &json!({"points": [[15, 10]]})).unwrap();
assert!(undated(&s) == undated(&direct), "the same document as the command gives");
// The art changed: the merge is dropped.
let mut s = session();
let ids = square_row(&mut s);
click_row(&mut s, v);
let journal = s.journal.len();
let st = s.active_mut().unwrap();
Arc::make_mut(&mut st.doc).node_mut(ids[1]).unwrap().transform(Affine::translate((0.0, 50.0)), false);
st.revision += 1;
s.tool_poll(v, true).unwrap();
assert!(!s.tool_editing());
assert_eq!(s.journal.len(), journal, "nothing merged");
assert_eq!(layer_children(&s), 40);
}
/// A merge computed for the tool Cmd lent the selection tool's drag away from is applied before
/// a command too.
#[test]
fn shape_builder_merge_of_the_lending_tool_comes_first() {
let v = ViewInfo::default();
let mut s = session();
square_row(&mut s);
click_row(&mut s, v);
let lent = s.make_tool("selection");
s.lender = Some(std::mem::replace(&mut s.tool, lent));
assert!(s.tool_editing());
let journal = s.journal.len();
s.execute("object.move", &json!({"dx": 0, "dy": 100})).unwrap();
assert!(!s.tool_editing());
assert_eq!(s.journal.since(journal).first().map(|j| j.0.as_str()), Some("shapeBuilder.merge"));
assert_eq!(s.tool_id(), "selection", "the lent tool stays active until the release");
assert_eq!(s.lender.as_ref().map(|t| t.id()), Some("shapeBuilder"));
}
#[test]
fn shape_builder_tool_merges_the_narrow_shape_it_highlights_in_a_wide_selection() {
let mut s = session();
+202
View File
@@ -177,6 +177,208 @@ fn export_options_pass_every_listed_key() {
}
}
const SAVES: [&str; 4] = ["document.save", "file.saveAs", "file.saveCopy", "file.saveAsTemplate"];
/// A save refuses a key no save format reads, beside the path, in `options` and in the PDF and
/// SVG objects of either, under each save command.
#[test]
fn save_options_refuse_a_key_no_save_format_reads() {
let why = "no save format takes it (see file.formatOptions)";
for id in SAVES {
assert_eq!(refusal(id, json!({"path": "a.pdf", "noSuchOption": true})), format!("unknown parameter `noSuchOption`; {why}"));
assert_eq!(
refusal(id, json!({"path": "a.pdf", "options": {"noSuchOption": true}})),
format!("unknown parameter `options.noSuchOption`; {why}")
);
assert_eq!(refusal(id, json!({"compres": true})), "unknown parameter `compres`; did you mean `compress`?");
assert_eq!(refusal(id, json!({"options": {"compres": true}})), "unknown parameter `options.compres`; did you mean `options.compress`?");
// An export's own key is not a save's.
assert_eq!(refusal(id, json!({"path": "a.png", "quality": 80})), format!("unknown parameter `quality`; {why}"));
for (given, message) in [
(json!({"path": "a.pdf", "marks": {"useDocumentBleed": true}}), "unknown parameter `marks.useDocumentBleed`; `marks` takes:"),
(json!({"path": "a.pdf", "options": {"marks": {"x": 1}}}), "unknown parameter `options.marks.x`; `options.marks` takes:"),
(json!({"path": "a.ai", "bleed": {"useDocumen": true}}), "unknown parameter `bleed.useDocumen`; did you mean `bleed.useDocument`?"),
// The `svg` object is checked when the save writes no SVG too.
(json!({"path": "a.pdf", "svg": {"noSuchOption": true}}), "unknown parameter `svg.noSuchOption`; `svg` takes:"),
(
json!({"path": "a.pdf", "options": {"svg": {"minfy": true}}}),
"unknown parameter `options.svg.minfy`; did you mean `options.svg.minify`?",
),
] {
let got = refusal(id, given.clone());
assert!(got.starts_with(message), "{id} {given}: {got}");
}
// A key that another format a save writes reads still passes: the save leaves it out.
for given in [
json!({"path": "a.svg", "compress": true}),
json!({"path": "a.pdf", "options": {"preview": true, "styling": "style"}}),
json!({"path": "a.vectorcraft", "standard": "pdfX4", "marks": {"trim": true}}),
json!({"path": "a.ai", "acknowledgeLoss": true, "modified": null, "svg": {"minify": true}}),
json!({"path": "a.svg", "svg": {"styling": "css", "artboard": 0, "useArtboards": true}}),
] {
check_params(id, &given).unwrap_or_else(|e| panic!("{id} {given}: {e}"));
}
}
}
/// A save passes each option of each format it writes, beside the path and in `options`, and
/// each key its description names.
#[test]
fn save_options_pass_every_listed_key() {
use crate::cmd::fileio::{FORMATS, SAVE_FORMATS, TEMPLATE_FORMATS};
for f in FORMATS.iter().filter(|f| SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id)) {
for o in f.options {
for params in [json!({"format": f.id, o.name: null}), json!({"format": f.id, "options": {o.name: null}})] {
for id in SAVES {
check_params(id, &params).unwrap_or_else(|e| panic!("{id} {params}: {e}"));
}
}
}
}
for id in SAVES {
let keys = doc_keys(find_command(id).unwrap().params).unwrap().keys;
let refused: Vec<&str> = keys.into_iter().filter(|key| check_params(id, &json!({*key: null})).is_err()).collect();
assert_eq!(refused, Vec::<&str>::new(), "{id} names and refuses these");
}
}
/// Export for Screens refuses a key it doesn't take, and in its rows and settings a key no format
/// reads, by the row's path; `document.exportPresets.save` checks the rows of its settings.
#[test]
fn export_for_screens_refuses_a_key_no_format_reads() {
let id = "document.exportForScreens";
assert!(
refusal(id, json!({"folder": "out", "noSuchOption": true})).starts_with("unknown parameter `noSuchOption`; the command takes: artboard, ")
);
assert_eq!(refusal(id, json!({"subfolder": true})), "unknown parameter `subfolder`; did you mean `subfolders`?");
let no_format = "no format takes it (see document.formats)";
for (given, message) in [
(json!({"formats": [{"format": "png", "noSuchOption": 1}]}), format!("unknown parameter `formats[0].noSuchOption`; {no_format}")),
(
json!({"formats": [{"format": "png"}, {"format": "pdf", "marks": {"useDocumentBleed": true}}]}),
"unknown parameter `formats[1].marks.useDocumentBleed`; `formats[1].marks` takes:".into(),
),
(
json!({"formats": [{"format": "pdf", "security": {"openPasswrd": "x"}}]}),
"unknown parameter `formats[0].security.openPasswrd`; did you mean `formats[0].security.openPassword`?".into(),
),
(
json!({"formats": [{"format": "eps", "flattener": {"balanse": 50}}]}),
"unknown parameter `formats[0].flattener.balanse`; did you mean `formats[0].flattener.balance`?".into(),
),
(json!({"formats": [{"format": "jpg", "sufix": "@2x"}]}), "unknown parameter `formats[0].sufix`; did you mean `formats[0].suffix`?".into()),
(json!({"settings": {"pnj": {}}}), "unknown parameter `settings.pnj`; did you mean `settings.png`?".into()),
(json!({"settings": {"dxf": {}}}), "unknown parameter `settings.dxf`; `settings` takes: ".into()),
(
json!({"settings": {"png": {"bckground": "white"}}}),
"unknown parameter `settings.png.bckground`; did you mean `settings.png.background`?".into(),
),
(json!({"settings": {"pdf": {"bleed": {"top": 9, "inner": 1}}}}), "unknown parameter `settings.pdf.bleed.inner`;".into()),
] {
let got = refusal(id, given.clone());
assert!(got.starts_with(&message), "{given}: {got}");
}
let save = "document.exportPresets.save";
assert_eq!(
refusal(save, json!({"name": "a", "settings": {"formats": [{"format": "png", "noSuchOption": 1}]}})),
format!("unknown parameter `settings.formats[0].noSuchOption`; {no_format}")
);
assert!(
refusal(save, json!({"name": "a", "settings": {"settings": {"pdf": {"marks": {"x": 1}}}}}))
.starts_with("unknown parameter `settings.settings.pdf.marks.x`;")
);
assert_eq!(
refusal(save, json!({"name": "a", "settings": {"formtas": [{"format": "png"}]}})),
"unknown parameter `settings.formtas`; did you mean `settings.formats`?"
);
assert!(
refusal(save, json!({"name": "a", "settings": {"noSuchOption": true}}))
.starts_with("unknown parameter `settings.noSuchOption`; `settings` takes: artboard, ")
);
// Export Assets takes the same rows and settings, and stores them.
for asset in ["assets.export", "assets.settings.set"] {
assert_eq!(
refusal(asset, json!({"formats": [{"format": "png", "noSuchOption": 1}]})),
format!("unknown parameter `formats[0].noSuchOption`; {no_format}")
);
assert!(refusal(asset, json!({"settings": {"pdf": {"marks": {"x": 1}}}})).starts_with("unknown parameter `settings.pdf.marks.x`;"));
check_params(asset, &json!({"formats": [{"format": "jpg", "scale": "2x", "quality": 80}]})).unwrap();
}
// An exporter plug-in's `params` in a row (its format is a plug-in's: tests/plugin_export.rs).
check_params(id, &json!({"formats": [{"format": "png", "params": {}}]})).unwrap();
// Keys the preset leaves out are still Export for Screens params.
check_params(save, &json!({"name": "a", "settings": {"zip": true, "formats": [{"format": "jpg", "quality": 80}]}})).unwrap();
// The encoders read Include Artboard Fill and Appearance of Black from a row or a format's
// settings.
for given in [
json!({"formats": [{"format": "png", "includeArtboardFill": false, "richBlack": true}]}),
json!({"settings": {"png": {"includeArtboardFill": false, "richBlack": true}}}),
] {
check_params(id, &given).unwrap_or_else(|e| panic!("{given}: {e}"));
}
}
/// Export for Screens passes each key its description names, each option of a format in a row or
/// in settings, and the built-in presets; `settings` takes the formats a row can write.
#[test]
fn export_for_screens_passes_every_listed_key() {
use crate::cmd::fileio::{FORMATS, check_screen_settings, screen_export_builtin_presets};
let id = "document.exportForScreens";
let keys = doc_keys(find_command(id).unwrap().params).unwrap().keys;
assert!(keys.len() > 20, "{keys:?}");
// The keys the check takes are the ones the description names.
let (mut named, mut taken) = (keys.clone(), crate::cmd::fileio::SCREEN_PARAM_KEYS.to_vec());
named.sort_unstable();
taken.sort_unstable();
assert_eq!(named, taken);
let refused: Vec<&str> = keys.into_iter().filter(|key| check_params(id, &json!({*key: null})).is_err()).collect();
assert_eq!(refused, Vec::<&str>::new(), "{id} names and refuses these");
check_params(id, &json!({"artboard": 0, "openLocation": true})).unwrap();
let s = Session::new();
for f in FORMATS.iter().filter(|f| f.write) {
for o in f.options {
let row = json!({"formats": [{"format": "png", "scale": 2, "suffix": "", "folder": "x", o.name: null}]});
check_params(id, &row).unwrap_or_else(|e| panic!("{row}: {e}"));
}
let written = check_screen_settings(&s, &json!({"formats": [{"format": f.id}]})).is_ok();
let settings = json!({"settings": {f.id: {}}});
assert_eq!(check_params(id, &settings).is_ok(), written, "{settings}");
}
for preset in screen_export_builtin_presets() {
let settings = serde_json::Value::Object(preset.settings);
check_params(id, &settings).unwrap_or_else(|e| panic!("{}: {e}", preset.name));
check_params("document.exportPresets.save", &json!({"name": "a", "settings": settings})).unwrap();
}
}
/// A saved row loses the keys Export for Screens refuses in it, inside its object options too, and
/// keeps the others.
#[test]
fn a_saved_row_drops_the_keys_no_format_reads() {
use crate::cmd::params::drop_unread_row_options;
let serde_json::Value::Object(mut row) = json!({
"format": "pdf", "scale": "1x", "noSuchOption": 1, "params": {"x": 1},
"marks": {"useDocumentBleed": true, "trim": true}, "preset": "High Quality Print",
"security": {"passwordRequired": true, "noSuchLock": 1}
}) else {
panic!("an object")
};
drop_unread_row_options(&mut row);
assert_eq!(
serde_json::Value::Object(row.clone()),
json!({
"format": "pdf", "scale": "1x", "params": {"x": 1}, "marks": {"trim": true}, "preset": "High Quality Print",
"security": {"passwordRequired": true}
})
);
check_params("document.exportForScreens", &json!({"formats": [row]})).unwrap();
// Remembered security whose passwords were left out, with one given again.
let security = json!({"passwordRequired": true, "openPassword": "x"});
for id in ["document.exportForScreens", "assets.export", "assets.settings.set"] {
check_params(id, &json!({"formats": [{"format": "pdf", "security": security}], "settings": {"pdf": {"security": security}}})).unwrap();
}
}
/// `stroke.set` names every key it reads (its own, `item`, and `ids`/`id`/`target`), so a
/// misnamed one is refused instead of answering `objects: 1` with nothing changed.
#[test]
+97
View File
@@ -439,6 +439,42 @@ fn a_painted_effect_on_a_fill_and_refused_results() {
}
}
/// A result refused for a swatch the document doesn't have stays cached: once the document has the
/// swatch, the next check of that result clears the error.
#[test]
fn a_refused_swatch_s_error_clears_once_the_document_has_the_swatch() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let mut later = solid("#00ff00");
later["swatch"] = json!("Later");
let output = json!({"objects": [{"path": square_at(40.0), "fills": [later]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.laterswatch", true), &output));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.laterswatch"}));
drawn(&s);
assert!(run(&mut s, "plugin.info", json!({"id": "test.engine.laterswatch"}))["lastError"].is_string());
run(&mut s, "swatch.new", json!({"name": "Later", "color": "#00ff00", "global": true}));
drawn(&s);
assert_eq!(run(&mut s, "plugin.info", json!({"id": "test.engine.laterswatch"}))["lastError"], Value::Null);
}
/// An effect that paints nothing has no references to check, so replacing a refused painted effect
/// with one clears the refusal's error once it runs.
#[test]
fn a_replacement_that_paints_nothing_clears_a_refused_swatch_s_error() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let mut gone = solid("#00ff00");
gone["swatch"] = json!("Absent");
let output = json!({"objects": [{"path": square_at(40.0), "fills": [gone]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.replaced", true), &output));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.replaced"}));
drawn(&s);
assert!(run(&mut s, "plugin.info", json!({"id": "test.engine.replaced"}))["lastError"].is_string());
install_wat(&mut s, &tpl::returning(&painting("test.engine.replaced", false), &output));
drawn(&s);
assert_eq!(run(&mut s, "plugin.info", json!({"id": "test.engine.replaced"}))["lastError"], Value::Null);
}
/// A painted effect on a group may link its pieces to the document's swatches.
#[test]
fn a_painted_group_effect_links_to_the_document_s_swatches() {
@@ -1542,6 +1578,67 @@ fn a_filter_does_only_what_its_permissions_allow() {
assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1})));
}
/// A filter whose manifest lists `inputs` is also given the selected type, read-only: an echo
/// changes nothing, and an output that leaves the type out or changes it is refused with the
/// document and undo history as they were, handed in from a run made elsewhere too. A filter
/// without `inputs` is not given the type and so never deletes it. Installing one that lists
/// `inputs` needs the user's grant.
#[test]
fn read_only_kind_field_and_omission_refuse_without_undo() {
use cmd::plugin::FilterRun;
let mut s = session();
let r = rect(&mut s, 10.0, "#ff0000");
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Hi"}))["id"].as_u64().unwrap());
let reads =
|id: &str| format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type"]}}"#);
// Each case installs another module under the same id, in place of the last (`replace: true`).
let install_wat = |s: &mut Session, src: &str| {
let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap());
run(s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write"], "replace": true}))
};
let echo = "test.engine.inputs.echo";
let module = tpl::module(&reads(echo), tpl::ECHO, "", 1);
let data = vectorcraft_format::base64_encode(&wat::parse_str(&module).unwrap());
let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string();
assert!(e.contains("asks for the permissions doc.read, doc.write"), "{e}");
let listed = install_wat(&mut s, &module);
assert_eq!(listed["inputs"], json!(["type"]));
let p = |id: &str| json!({"id": id, "ids": [r.0, t.0]});
let before = s.doc().unwrap().doc.clone();
assert_eq!(run(&mut s, "plugin.run", p(echo))["ids"], json!([r.0, t.0]));
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "an echo changes nothing");
for (output, says) in [
(json!({"objects": [{"id": r.0}]}), format!("objects (id {}): a read-only object was left out", t.0)),
(json!({"objects": [{"id": r.0}, {"id": t.0, "name": "Renamed"}]}), format!("objects[1] (id {}): read-only field 'name' changed", t.0)),
(json!({"objects": [{"id": r.0}, {"id": t.0}, {"type": "type", "path": triangle()}]}), "`type` must be \"path\" or \"compound\"".into()),
] {
let id = "test.engine.inputs.bad";
install_wat(&mut s, &tpl::returning(&reads(id), &output.to_string()));
let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let e = s.execute("plugin.run", &p(id)).unwrap_err().to_string();
assert!(e.contains(&says), "{says}: {e}");
assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&doc, undo), "{output}");
// Handed in from a run made away from plugin.run, it is refused the same way.
let made = FilterRun::prepare(&s, &p(id)).unwrap();
let out = made.run().unwrap();
made.hand_in(&mut s, out);
let e = s.execute("plugin.run", &p(id)).unwrap_err().to_string();
assert!(e.contains(&says), "handed in: {e}");
assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&doc, undo), "handed in: {output}");
}
// Without `inputs` the type isn't given, so leaving it out keeps it.
let legacy = "test.engine.inputs.legacy";
install_wat(&mut s, &tpl::returning(&tpl::manifest(legacy, "filter", "{}"), &json!({"objects": [{"id": r.0, "name": "Kept"}]}).to_string()));
assert_eq!(run(&mut s, "plugin.run", p(legacy))["ids"], json!([r.0]));
assert_eq!(node(&s, r).name.as_deref(), Some("Kept"));
assert!(matches!(node(&s, t).kind, vectorcraft_doc::NodeKind::Text(_)), "the type stays");
// A manifest that lists `inputs` below `api` 2 doesn't install.
let old = format!(r#"{{"id":"{echo}","name":"Old","kind":"filter","inputs":["type"]}}"#);
let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::module(&old, tpl::ECHO, "", 1)).unwrap());
let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string();
assert!(e.contains("`inputs` needs `api` 2"), "{e}");
}
/// What the user granted is kept in the plug-in store with the module: the next session installs
/// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is
/// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder
+1 -1
View File
@@ -72,7 +72,7 @@ fn save_as_takes_on_path_format_and_options() {
let d = dir("saveas");
let mut s = session();
let pdf = path(&d, "doc.pdf");
s.execute("file.saveAs", &json!({"path": pdf, "options": {"range": "1", "bogus": 1}})).unwrap();
s.execute("file.saveAs", &json!({"path": pdf, "options": {"range": "1", "preview": true}})).unwrap();
let st = s.doc().unwrap();
assert_eq!((st.format, st.title()), ("pdf", "doc.pdf".to_string()));
assert_eq!(Value::Object(st.save_options.clone()), json!({"range": "1"}), "only the format's own options are kept");
+22
View File
@@ -268,6 +268,28 @@ fn escape_on_a_shape_drag_saves_the_option_it_put_back() {
assert_eq!(s.prefs.corner_radius, radius);
}
/// Setting another tool's option during a polygon drag leaves the drag's options alone: Escape
/// still puts back the side count the arrow changed.
#[test]
fn escape_puts_back_the_sides_after_another_tool_s_option_is_set() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
select(&mut s, "polygon");
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap();
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
// Saves every option, the one the arrow changed included.
s.set_tool_option("length", &json!(50));
let values = json!({"points": 9});
s.set_tool_options(Some("star"), values.as_object().unwrap());
assert_eq!(s.tool_options()["sides"], json!(7), "the drag keeps its side count");
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(s.tool_options()["sides"], json!(6));
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(6));
assert_eq!(s.prefs.tool_settings["star"]["points"], json!(9));
}
/// The Fidelity an Alt smoothing drag uses on a selected path.
fn alt_smooth_fidelity(s: &mut Session) -> Option<f64> {
use vectorcraft_tools::{Action, Mods, PointerEvent, PointerKind};
+286 -10
View File
@@ -83,6 +83,23 @@ pub enum UiRequest {
/// Place a file with these `file.place` params: a click or drag of the place cursor, when
/// [`Session::place_cursor_requests`] is set.
Place(Value),
/// A press held while the tool computed an edit on another thread was made now, at `pos` in the
/// document with [`crate::DocState::uid`] `doc`, with tool `tool` active (the selection tool Cmd
/// lent): the frontend does what it does after a press it hands over itself.
HeldPress {
doc: Option<u64>,
pos: Point,
tool: &'static str,
},
}
/// Pointer or key input that came while the active tool computed an edit on another thread
/// ([`Tool::editing`]), made in order once the edit is applied ([`Session::tool_poll`],
/// [`Session::finish_tool_edit`]).
#[derive(Clone, Copy, Debug)]
pub(crate) enum HeldInput {
Pointer(PointerEvent),
Key(ToolKey, Mods),
}
impl Session {
@@ -106,6 +123,7 @@ impl Session {
/// Choose a tool (the toolbar, its shortcut, MCP), finishing any pending tool work first.
pub fn select_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
self.finish_tool_edit_later()?;
// Choosing a perspective tool shows the document's perspective grid; Hide Grid hides it
// again, the tool staying chosen.
if matches!(id, "perspectiveGrid" | "perspectiveSelection")
@@ -129,6 +147,8 @@ impl Session {
/// Switch to tool `id` without choosing it afresh (back from a temporary tool: a hidden
/// perspective grid stays hidden), finishing any pending tool work first.
pub fn switch_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
// The edit the tool computes on another thread and the input held for it come first.
self.finish_tool_edit_later()?;
self.give_back_tool(view)?;
if self.tool.id() == id {
return Ok(());
@@ -257,7 +277,15 @@ impl Session {
/// Give `t` the persistent options stored for it in `prefs`.
fn restore_tool_settings(prefs: &Prefs, t: &mut dyn Tool) {
Self::restore_stores(prefs, t, |_| true);
}
/// Give `t` the persistent options stored for it in `prefs`, from the stores `wanted` accepts.
fn restore_stores(prefs: &Prefs, t: &mut dyn Tool, wanted: impl Fn(&str) -> bool) {
for (store, keys) in settings::stores(t.id()) {
if !wanted(store) {
continue;
}
let Some(saved) = prefs.tool_settings.get(store) else { continue };
for k in keys {
if let Some(v) = saved.get(*k) {
@@ -267,6 +295,7 @@ impl Session {
}
// Alt switches the Pencil, Paintbrush or Blob Brush to Smooth, using Smooth Tool Options' Fidelity.
if matches!(t.id(), "pencil" | "paintbrush" | "blobBrush")
&& wanted("smooth")
&& let Some(v) = prefs.tool_settings.get("smooth").and_then(|s| s.get("fidelity"))
{
t.set_option("smoothFidelity", v);
@@ -421,8 +450,85 @@ impl Session {
self.tool_step = now;
}
/// Feed a pointer event to the active tool. Returns requests for the UI.
/// Feed a pointer event to the active tool. Returns requests for the UI. While the tool computes
/// an edit on another thread, a press and what follows it are held and made once the edit is
/// applied, in order ([`Session::tool_poll`]); moves of the pointer with no button held go to
/// the tool at once.
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
let n = self.next_input();
if ev.kind != PointerKind::Move && (!self.held_input.is_empty() || self.tool_edit_pending()) {
self.held_input.push_back((n, HeldInput::Pointer(*ev), view));
return Ok(vec![]);
}
let r = self.pointer_now(ev, view);
self.note_edit_start(n);
r
}
/// The number of the next pointer or key event handed to the session.
fn next_input(&mut self) -> u64 {
let n = self.input_count;
self.input_count = n.wrapping_add(1);
n
}
/// How many pointer and key events have been handed to the session ([`Session::pointer`],
/// [`Session::tool_key`]); with [`Session::input_applied`], a frontend tells when the events it
/// handed over have been made.
pub fn input_count(&self) -> u64 {
self.input_count
}
/// Have the events numbered `from..to` ([`Session::input_count`] before and after handing them
/// over) been made, and the edit they started on another thread applied?
pub fn input_applied(&self, from: u64, to: u64) -> bool {
let ours = |n: &u64| (from..to).contains(n);
!self.held_input.iter().any(|(n, ..)| ours(n)) && !(self.tool_edit_pending() && self.edit_started_by.as_ref().is_some_and(ours))
}
/// After event `n` was made: the event that started the edit being computed, if one is.
fn note_edit_start(&mut self, n: u64) {
if !self.tool_edit_pending() {
self.edit_started_by = None;
} else if self.edit_started_by.is_none() {
self.edit_started_by = Some(n);
}
}
/// Is the active tool, or the tool Cmd lent the selection tool's drag away from, computing an
/// edit on another thread?
fn tool_edit_pending(&self) -> bool {
self.tool.editing() || self.lender.as_ref().is_some_and(|t| t.editing())
}
/// Makes the input held while the tool computed an edit, in order, until a held gesture starts
/// another such edit.
fn replay_held_input(&mut self) -> Result<Vec<UiRequest>> {
let mut ui = vec![];
let mut failed = None;
while !self.tool_edit_pending()
&& let Some((n, input, view)) = self.held_input.pop_front()
{
let r = match input {
HeldInput::Pointer(ev) => self.pointer_now(&ev, view).map(|mut r| {
if ev.kind == PointerKind::Down {
r.push(UiRequest::HeldPress { doc: self.active().map(|d| d.uid), pos: ev.pos, tool: self.tool.id() });
}
r
}),
HeldInput::Key(key, mods) => self.tool_key_now(key, mods, view),
};
self.note_edit_start(n);
match r {
Ok(r) => ui.extend(r),
// The input after it is still made, as it was when each event came on its own.
Err(e) => failed = failed.or(Some(e)),
}
}
failed.map_or(Ok(ui), Err)
}
fn pointer_now(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
self.pointer_event(ev, view, true)
}
@@ -433,8 +539,25 @@ impl Session {
/// nothing and a click opens no size dialog. The tool gets back the persistent options it had
/// when the press began, and none of the press's changes to them are stored. A dropped Type
/// press restores the prior selection. The freeform gradient point selected once the tool
/// took the press is selected again.
/// took the press is selected again. A press held while the tool computes an edit on another
/// thread has not reached the tool: it is removed from the held input with the pointer events
/// and tool keys held after it, and the tool is left as it is. A perspective plane key held
/// after it stays held, since the plane it picks outlasts a dropped press. Pointer events held
/// after the last held release belong to the press the tool has, and are removed with it.
pub fn drop_tool_press(&mut self, view: ViewInfo) -> Result<()> {
let (from, held) = self.held_press();
let keep: Vec<bool> = (self.held_input.iter().enumerate())
.map(|(j, (_, input, _))| match input {
_ if j < from => true,
HeldInput::Pointer(_) => false,
HeldInput::Key(key, _) => !held || self.plane_key(*key).is_some(),
})
.collect();
let mut keep = keep.into_iter();
self.held_input.retain(|_| keep.next().unwrap_or(true));
if held {
return Ok(());
}
// Nothing to roll back without a document; the tool still drops the press and gets the release.
let _ = self.cancel_interaction();
let before = self.press_options.take();
@@ -476,6 +599,19 @@ impl Session {
r.map(drop)
}
/// Where the input of the press in progress starts in the held input, and whether the press
/// itself is held: the last held press when no release is held after it; otherwise the input
/// after the last held release (all of it when none is held), which follows a press the tool
/// already has.
fn held_press(&self) -> (usize, bool) {
let ends = |kind: PointerKind| matches!(kind, PointerKind::Down | PointerKind::Up);
match self.held_input.iter().rposition(|(_, input, _)| matches!(input, HeldInput::Pointer(ev) if ends(ev.kind))) {
Some(i) if matches!(self.held_input.get(i), Some((_, HeldInput::Pointer(ev), _)) if ev.kind == PointerKind::Down) => (i, true),
Some(i) => (i + 1, false),
None => (0, false),
}
}
/// The options of `t` that [`Self::store_tool_settings`] stores.
fn persistent_options(t: &dyn Tool) -> Map<String, Value> {
let opts = t.options();
@@ -610,19 +746,148 @@ impl Session {
self.tool.wants_samples()
}
/// Is the active tool waiting for work it runs on another thread ([`Tool::waiting`])?
/// Is the active tool waiting for work it runs on another thread ([`Tool::waiting`]), is input
/// held until an edit it computes is applied, or are requests for the UI left from making it?
pub fn tool_waiting(&self) -> bool {
self.tool.waiting()
self.tool.waiting() || !self.held_input.is_empty() || !self.held_ui.is_empty()
}
/// Lets the active tool check on the work [`Session::tool_waiting`] reports, and with `wait`
/// wait for it to finish ([`Tool::poll`]).
pub fn tool_poll(&mut self, view: ViewInfo, wait: bool) -> Result<()> {
self.with_tool_cx(view, |t, cx| t.poll(cx, wait));
self.take_tool_panic()
/// wait for it to finish ([`Tool::poll`]); an edit the work made is applied, and then the input
/// held for it is made. Returns requests for the UI, those left from making held input where
/// nothing could take them first ([`Session::finish_tool_edit_later`]).
pub fn tool_poll(&mut self, view: ViewInfo, wait: bool) -> Result<Vec<UiRequest>> {
if self.settling_tool_edit {
return Ok(vec![]);
}
self.settling_tool_edit = true;
let r = self.tool_poll_now(view, wait);
self.settling_tool_edit = false;
// On an error the requests left wait for the next poll.
let r = r?;
let mut ui = std::mem::take(&mut self.held_ui);
ui.extend(r);
Ok(ui)
}
fn tool_poll_now(&mut self, view: ViewInfo, wait: bool) -> Result<Vec<UiRequest>> {
let acts = self.with_tool_cx(view, |t, cx| t.poll(cx, wait));
self.take_tool_panic()?;
let applied = !acts.is_empty();
let mut ui = self.apply_actions(acts)?;
if !self.tool_edit_pending() {
self.edit_started_by = None;
// The input held for an edit applied now is made at the next poll, so what waits for the
// edit alone (a `ui.pointer` reply) sees the document as the edit left it.
if !applied {
ui.extend(self.replay_held_input()?);
}
}
Ok(ui)
}
/// Is the active tool computing an edit on another thread ([`Tool::editing`]), or is input held
/// until it is applied?
pub fn tool_editing(&self) -> bool {
self.tool_edit_pending() || !self.held_input.is_empty()
}
/// Waits for the edit the active tool computes on another thread and applies it
/// ([`Tool::finish_edit`]), then makes the input held for it, waiting in turn for an edit that
/// starts, so what runs next sees the document as the gestures left it. Commands, saves, option
/// changes, interactions, undo scopes and tool or document switches call it first. Returns
/// requests for the UI, with those left from earlier ([`Session::finish_tool_edit_later`]); on
/// an error, the next [`Session::tool_poll`] returns them.
pub fn finish_tool_edit(&mut self) -> Result<Vec<UiRequest>> {
if self.settling_tool_edit {
return Ok(vec![]);
}
let mut ui = std::mem::take(&mut self.held_ui);
if self.tool_editing() {
self.settling_tool_edit = true;
let r = self.finish_tool_edit_now();
self.settling_tool_edit = false;
match r {
Ok(r) => ui.extend(r),
Err(e) => {
ui.append(&mut self.held_ui);
self.held_ui = ui;
return Err(e);
}
}
}
Ok(ui)
}
/// [`Session::finish_tool_edit`] for a caller that cannot hand requests to the UI (a command, a
/// tool switch): the next [`Session::tool_poll`] returns them, and [`Session::tool_waiting`]
/// holds until then.
pub(crate) fn finish_tool_edit_later(&mut self) -> Result<()> {
let ui = self.finish_tool_edit()?;
self.held_ui.extend(ui);
Ok(())
}
fn finish_tool_edit_now(&mut self) -> Result<Vec<UiRequest>> {
let mut ui = vec![];
let mut failed = None;
loop {
match self.finish_pending_edit() {
Ok(r) => ui.extend(r),
Err(e) => failed = failed.or(Some(e)),
}
// A tool that cannot finish its edit keeps the input held.
if self.tool_edit_pending() {
break;
}
self.edit_started_by = None;
if self.held_input.is_empty() {
break;
}
match self.replay_held_input() {
Ok(r) => ui.extend(r),
Err(e) => failed = failed.or(Some(e)),
}
}
match failed {
None => Ok(ui),
Some(e) => {
self.held_ui.extend(ui);
Err(e)
}
}
}
/// Waits for the edit being computed and applies it: the active tool's, or that of the tool
/// Cmd lent the selection tool's drag away from, which is active meanwhile.
fn finish_pending_edit(&mut self) -> Result<Vec<UiRequest>> {
if self.tool.editing() {
let acts = self.with_tool_cx(self.last_view, |t, cx| t.finish_edit(cx));
self.take_tool_panic()?;
return self.apply_actions(acts);
}
let Some(lender) = self.lender.take_if(|t| t.editing()) else { return Ok(vec![]) };
let lent = std::mem::replace(&mut self.tool, lender);
let acts = self.with_tool_cx(self.last_view, |t, cx| t.finish_edit(cx));
let r = self.take_tool_panic().and_then(|()| self.apply_actions(acts));
self.lender = Some(std::mem::replace(&mut self.tool, lent));
r
}
/// A key for the active tool. While the tool computes an edit on another thread, the key is held
/// and made once the edit is applied, after the input held before it.
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
let n = self.next_input();
if self.tool_editing() {
self.held_input.push_back((n, HeldInput::Key(key, mods), view));
return Ok(vec![]);
}
let r = self.tool_key_now(key, mods, view);
self.note_edit_start(n);
r
}
fn tool_key_now(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
// 1–4 pick the active perspective plane while the grid is shown.
if let Some(plane) = self.plane_key(key) {
self.execute("perspective.plane.set", &serde_json::json!({ "plane": plane.id() }))?;
@@ -828,7 +1093,8 @@ impl Session {
/// it is on, makes the drawing tools' a crosshair, [`Cursor::precise`]). With Cmd held, the pointer of the selection tool a
/// press would borrow ([`Self::lend_selection_tool`]).
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
let lent = if mods.cmd && self.lender.is_none() { self.lent_tool(self.tool.id(), &mods) } else { None };
// While an edit is computed, a Cmd press is held for the active tool, which shows it is busy.
let lent = if mods.cmd && self.lender.is_none() && !self.tool_editing() { self.lent_tool(self.tool.id(), &mods) } else { None };
let c = match lent {
Some(id) => {
let lent = self.make_tool(id);
@@ -851,6 +1117,9 @@ impl Session {
self.tool.options()
}
pub fn set_tool_option(&mut self, key: &str, v: &Value) {
// The active tool's edit being computed is applied with the options it was started with. A
// failure to apply it was the edit's own, and the option is set all the same.
let _ = self.finish_tool_edit_later();
if key == "queue" && self.tool.id() == "place" {
self.note_place_queue_roots();
}
@@ -907,7 +1176,10 @@ impl Session {
}
Self::restore_tool_settings(&self.prefs, lender);
}
Self::restore_tool_settings(&self.prefs, self.tool.as_mut());
// Only the stores it shares with the tool set: the active tool's own options stay as they
// are, also in the middle of a drag that Escape can cancel.
let set = other.id();
Self::restore_stores(&self.prefs, self.tool.as_mut(), |store| settings::stores(set).any(|(s, _)| s == store));
}
out
}
@@ -922,6 +1194,10 @@ impl Session {
{
return Err(format!("unknown tool `{t}`"));
}
// The active tool's merge being computed is applied with the options it was started with.
if tool.is_none_or(|t| t == self.tool.id()) {
self.finish_tool_edit_later().map_err(|e| e.to_string())?;
}
let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
if let Some(k) = p.get("key").and_then(Value::as_str) {
values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null));
+50
View File
@@ -219,6 +219,56 @@ fn every_command_with_junk_params() {
);
}
/// Each document's uid, revision and selected ids, the active document and the current paint.
type ToolEditState = (Vec<(u64, u64, Vec<u64>)>, Option<usize>, vectorcraft_tools::PaintDefaults);
/// What a command that runs while the active tool computes an edit on another thread must leave
/// as it was: every document's revision and selection, which document is active, and the current
/// paint.
fn tool_edit_state(s: &Session) -> ToolEditState {
let docs = s.documents().iter().map(|d| (d.uid, d.revision, d.selection.objects.iter().map(|i| i.0).collect())).collect();
(docs, s.active_index(), s.paint.clone())
}
/// Every command that runs at once while the active tool computes an edit on another thread (a
/// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the
/// documents' revisions and selections, the active document and the current paint as they were,
/// with `{}`, junk params and params that turn its options on and off: the edit, computed for the
/// document as it was, still applies after it.
#[test]
fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() {
let path = safe_path();
let mut failures = vec![];
for fx in [Fixture::Single, Fixture::Multi] {
for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) {
let mut cases = junk_params(c.params, &path);
if heavy(c.id) {
cases.truncate(12);
}
for k in param_keys(c.params) {
for v in [json!(true), json!(false), json!(1), json!("on")] {
cases.push(json!({ k.as_str(): v }));
}
}
// Find Objects selects only with a filter, which one key at a time does not give it.
if c.id == "document.find" {
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
}
for p in &cases {
let mut s = fx.session();
let before = tool_edit_state(&s);
let _ = catch_quiet(|| no_panic(s.execute(c.id, p)));
if tool_edit_state(&s) != before {
failures.push(format!("{} {p} [{fx:?}]", c.id));
}
}
}
}
failures.sort();
failures.dedup_by(|a, b| a.split(' ').next() == b.split(' ').next());
assert!(failures.is_empty(), "{} commands change a document without waiting for a tool edit:\n{}", failures.len(), failures.join("\n"));
}
/// Junk inside the obvious structured params: ids arrays, anchors, matrices, colours, stops.
#[test]
fn structured_junk() {
+96
View File
@@ -3239,6 +3239,50 @@ fn text_editing_of(document: &str, objects: &str) -> String {
/// Area Type Options as an area frame's data may keep them: rows, columns, gutters and the first
/// baseline, and keys besides, each with a value of any kind, in or out of range.
/// A paragraph's attributes as a text document might keep them: an alignment (key 0), auto leading
/// (7), hyphenation options (10 to 15) and tab stops (30) of any value or type, the stops a list of
/// any length whose entries may be anything.
fn arb_paragraph_attributes() -> impl Strategy<Value = String> {
let values = prop::sample::select(vec![
"0",
"1",
"2",
"3",
"6",
"7",
"-1",
"2.5",
"1e400",
"-1e400",
"NaN",
"inf",
"1e9",
"(x)",
"true",
"false",
"/nil",
"[ 1 ]",
"<< /0 1 >>",
]);
let keys = prop::sample::select(vec!["0", "7", "10", "11", "12", "13", "14", "15"]);
let stop = (values.clone(), values.clone(), 0usize..3).prop_map(|(at, align, shape)| match shape {
0 => format!("<< /0 {at} /1 {align} >>"),
1 => format!("<< /1 {align} >>"),
_ => at.to_string(),
});
let tabs = (prop::collection::vec(stop, 0..40), 0usize..4).prop_map(|(stops, shape)| match shape {
0 => format!("/30 << /0 [ {} ] >>", stops.join(" ")),
1 => "/30 << /0 7 >>".to_string(),
2 => "/30 [ 1 2 ]".to_string(),
_ => String::new(),
});
(prop::collection::vec((keys, values), 0..8), tabs).prop_map(|(kv, tabs)| {
let mut attrs: Vec<String> = kv.iter().map(|(k, v)| format!("/{k} {v}")).collect();
attrs.push(tabs);
attrs.join(" ")
})
}
fn arb_area_options() -> impl Strategy<Value = String> {
let keys = prop::sample::select(vec!["3", "4", "5", "7", "8", "10", "13", "14"]);
let values = prop::sample::select(vec![
@@ -3399,6 +3443,23 @@ fn the_shown_stories_fixture_opens_with_its_layers() {
assert!(texts.iter().all(|t| t.contains('\u{2028}') && t.contains('\n')), "{texts:?}");
}
/// The fuzzed editing data with its layer, pattern and global colour named in code page 949 opens
/// with those names, read in that encoding, from its layers.
#[test]
fn the_editing_fixture_reads_names_in_a_legacy_encoding() {
// 레이어 1, then 빨강.
let text = editing_text()
.replace("(One) Ln", r"(\267\271\300\314\276\356 1) Ln")
.replace("(Dots)", r"(\273\241\260\255)")
.replace("(Black global)", r"(\273\241\260\255 global)");
let bytes = editing_eps(&text);
let r = vectorcraft_eps::import_names(&bytes, true, true, vectorcraft_eps::NameEncoding::Korean).unwrap();
let names: Vec<_> = r.document.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["레이어 1", "Two"], "{:?}", r.warnings);
assert!(r.document.pattern("빨강").is_some(), "{:?}", r.warnings);
assert!(r.document.swatch("빨강 global").is_some_and(|s| s.global), "{:?}", r.warnings);
}
proptest! {
#![proptest_config(config())]
@@ -3446,6 +3507,18 @@ proptest! {
survive(".ai text frames with hostile area options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
}
/// The same text document with hostile paragraph attributes: an alignment, auto leading,
/// hyphenation options and tab stops of any value, type or size.
#[test]
fn text_frames_with_hostile_paragraph_attributes_never_panic(attrs in arb_paragraph_attributes()) {
let text = frames_document_text().replace("/5 << /0 2 /1 4 /2 6 >>", &format!("/5 << /1 4 /2 6 {attrs} >>"));
let editing = text_editing_of(&text, FRAME_OBJECTS);
let bytes = editing_eps(&editing);
survive("EPS text frames with hostile paragraph attributes", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?;
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&editing)].concat();
survive(".ai text frames with hostile paragraph attributes", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
}
/// The type on a path of the same text document with hostile Type on a Path Options, opened as
/// an EPS document and as a `.ai`'s editing data.
#[test]
@@ -3475,6 +3548,26 @@ proptest! {
survive("mutated EPS editing data", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
}
/// Names of any bytes (a layer's, a pattern's, a global colour's) read in any legacy name
/// encoding: each byte written as an octal escape, so every code page sees bytes it doesn't
/// define, cut sequences and UTF-8 alike.
#[test]
fn eps_editing_data_names_in_any_encoding_never_panic(
name in prop::collection::vec(any::<u8>(), 0..24),
encoding in prop::sample::select(vectorcraft_eps::NameEncoding::LABELS.to_vec()),
) {
let escaped: String = name.iter().map(|b| format!("\\{b:03o}")).collect();
let text = editing_text()
.replace("(One) Ln", &format!("({escaped}) Ln"))
.replace("(Dots)", &format!("({escaped})"))
.replace("(Black global)", &format!("({escaped} global)"));
let bytes = editing_eps(&text);
let encoding = vectorcraft_eps::NameEncoding::from_label(encoding).unwrap();
survive("EPS editing data names in a legacy encoding", || {
vectorcraft_eps::import_names(&bytes, true, false, encoding).ok().map(|r| r.document)
})?;
}
/// A `.ai` whose envelopes are damaged or hostile: any kind, warp style, options word,
/// Fidelity and grid size, corners out of range or repeated, the contents block cut short.
#[test]
@@ -5082,6 +5175,9 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"-9 /Real (CornerRadius) ,",
"1 /Bool (Absolute) ,",
"-0.5 /Real ,",
"0.5 /Real ,",
"1 /Real ,",
"1e308 /Real ,",
"1.5 /Real ,",
"-2 /Real ,",
"; (BSLeftWidthsArray) ,",
+21
View File
@@ -125,6 +125,27 @@ fn an_export_step_or_preset_with_a_key_no_longer_read_still_runs() {
s.execute("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap();
}
/// So does a save, or an Export for Screens, with a key that is no longer read, and an Export for
/// Screens preset file that holds one in a row still loads and exports. `Session::execute`
/// refuses the same options.
#[test]
fn a_save_or_screens_step_with_a_key_no_longer_read_still_runs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let save = json!({"format": "pdf", "noSuchOption": true, "options": {"retired": 1, "marks": {"useDocumentBleed": true}}});
assert!(check_params("file.saveCopy", &save).is_err());
assert!(s.execute("file.saveCopy", &save).is_err());
s.replay("file.saveCopy", &save).unwrap();
let screens = json!({"noSuchOption": true, "formats": [{"format": "png", "retired": 1}], "settings": {"pdf": {"marks": {"retired": true}}}});
assert!(check_params("document.exportForScreens", &screens).is_err());
assert!(s.execute("document.exportForScreens", &screens).is_err());
s.replay("document.exportForScreens", &screens).unwrap();
let file = json!({"format": "vcexportpresets", "presets": [{"name": "Old", "settings": {"formats": [{"format": "png", "noLongerRead": 1}]}}]});
s.execute("document.exportPresets.import", &json!({"data": file.to_string()})).unwrap();
check_params("document.exportForScreens", &json!({"exportPreset": "Old"})).unwrap();
s.execute("document.exportForScreens", &json!({"exportPreset": "Old"})).unwrap();
}
/// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't
/// take, or a value of another type, replays and opens; the effect reads what it knows, clamped.
#[test]
+16
View File
@@ -157,6 +157,22 @@ fn the_exporter_is_given_its_parameters_and_the_file_it_writes() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Export for Screens writes an exporter's format from a row, with the row's `params`, and takes
/// settings for that format.
#[test]
fn export_for_screens_takes_an_exporter_s_format_and_params() {
let mut s = session();
let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#;
install(&mut s, &tpl::echo_exporter(&tpl::exporter("test.screens", r#"["tss"]"#, params), Echo::Params));
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "plugin.test.screens", "params": {"density": 7}}]})).unwrap();
let file = &r["files"][0];
assert!(file["name"].as_str().unwrap().ends_with(".tss"), "{r}");
let got: Value = serde_json::from_slice(&vectorcraft_format::base64_decode(file["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!(got["density"], json!(7.0));
let settings = json!({"formats": [{"format": "plugin.test.screens"}], "settings": {"plugin.test.screens": {"params": {"density": 2}}}});
s.execute("document.exportForScreens", &settings).unwrap();
}
/// Only the selected objects, with `selectedOnly`, as for the built-in formats.
#[test]
fn selected_only_hands_over_the_selection() {
+58
View File
@@ -824,6 +824,64 @@ proptest! {
}
}
/// Manifests with hostile `inputs` (unknown, repeated and oddly cased kinds, long lists, junk of
/// every type) with any `api`, kind and permissions parse to an error or to a filter at `api` 2
/// or later asking for each kind once, whose `permissions` list `doc.read` so that installing
/// it asks for approval. One that installs and echoes its input on a document
/// with type, images and symbols leaves the document as it was, and it still renders.
#[test]
fn hostile_manifest_inputs_never_crash(
kind in prop::sample::select(vec!["filter", "effect", "exporter"]),
names in prop::collection::vec(prop::sample::select(vec!["type", "image", "symbolInstance", "Type", "text", "path", ""]), 0..6),
long in prop::option::weighted(0.1, 4usize..300),
inputs in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]),
api in prop_oneof![2 => Just(json!(2)), 1 => Just(json!(1)), 1 => Just(json!(3)), 1 => arb_junk()],
permissions in prop_oneof![Just(Value::Null), Just(json!([])), Just(json!(["doc.read", "doc.write"])), Just(json!(["doc.write"]))],
) {
let mut m = json!({"id": "fuzz.inputs", "name": "Inputs", "kind": kind, "api": api});
if kind == "exporter" {
m["extensions"] = json!(["fz"]);
}
if !permissions.is_null() {
m["permissions"] = permissions;
}
m["inputs"] = match (inputs, long) {
(_, Some(n)) => json!(vec!["image"; n]),
(Some(Value::Null) | None, None) => json!(names),
(Some(junk), None) => junk,
};
let text = m.to_string();
let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes()));
let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?;
if let Ok(man) = &parsed {
let kinds = man.input_kinds();
prop_assert_eq!(kinds.len(), man.inputs.len(), "{}", text);
prop_assert!(kinds.iter().enumerate().all(|(i, k)| !kinds[..i].contains(k)), "{}", text);
prop_assert!(kinds.is_empty() || (man.kind == vectorcraft_plugins::Kind::Filter && man.api >= 2), "{}", text);
let reads = man.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == "doc.read"));
prop_assert!(kinds.is_empty() || (man.asks_permissions() && reads), "{}", text);
}
let mut s = rich_session();
let module = wat::parse_str(tpl::module(&text, tpl::ECHO, "", 1)).unwrap();
let data = vectorcraft_format::base64_encode(&module);
let grant = json!(["doc.read", "doc.write", "state", "effect.render", "export.write"]);
let before = s.doc().unwrap().doc.clone();
let r = catch_quiet(|| {
let installed = s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant})).is_ok();
let _ = s.execute("select.all", &json!({}));
let ran = installed && s.execute("plugin.run", &json!({"id": "fuzz.inputs"})).is_ok();
(installed, ran)
});
let (installed, ran) = r.map_err(|msg| TestCaseError::fail(format!("panicked running {text}: {msg}")))?;
prop_assert!(!installed || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text);
if ran {
prop_assert!(s.doc().unwrap().doc.layers == before.layers, "an echo changed the document: {}", text);
}
renders(&s, &text)?;
vectorcraft_plugins::registry::remove("fuzz.inputs");
vectorcraft_plugins::registry::revoke("fuzz.inputs");
}
/// A plug-in that calls the storage imports with hostile keys (`..`, separators, device names,
/// control characters, too long), values, lengths and pointers in and out of its memory runs to
/// an output, never a panic; every call returns a length or a storage code, and the settings and
+2
View File
@@ -37,6 +37,8 @@ hayro-ccitt = { workspace = true }
serde_json = { workspace = true }
# Substring search for PostScript `search`.
memchr = { workspace = true }
# Names in legacy .ai files that aren't UTF-8, read in the encoding the user picks.
encoding_rs = { workspace = true }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
+1 -1
View File
@@ -462,7 +462,7 @@ fn an_artboard_larger_than_the_canvas_is_written_on_a_large_canvas() {
let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap();
assert!((bounds.x0 - 1000.0).abs() < 1e-6 && (bounds.y1 - 2000.0).abs() < 1e-6 && (bounds.width() - 2000.0).abs() < 1e-6, "{bounds:?}");
// The same without a readable PDF part, on its first page's size.
let (alone, notes) = crate::ai_alone_on_page(&p.data, (20_000.0, 20_000.0), 1.389).unwrap();
let (alone, notes) = crate::ai_alone_on_page(&p.data, (20_000.0, 20_000.0), 1.389, crate::NameEncoding::default()).unwrap();
assert_eq!(alone.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)], "{notes:?}");
// And from the editing data alone, as a file saved without its PDF part opens.
let alone = read_back(&doc);
+12 -4
View File
@@ -73,6 +73,7 @@ use graphics::{GState, Out};
use interp::{Fault, Interp};
use obj::PsError;
pub use ai::NameEncoding;
#[cfg(test)]
pub(crate) use ai::objects_read;
pub(crate) use ai::{
@@ -80,7 +81,7 @@ pub(crate) use ai::{
symbol_art_member, warp_number,
};
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES};
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from};
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from};
pub(crate) use native::{MAX_TEXT, scale_document};
pub use text::family_style;
@@ -251,6 +252,13 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
/// [`import_with`]; `trust`: an Illustrator EPS's layers are used even where they draw differently
/// from its page, with a note (`editingData: "always"`, as for a `.ai`).
pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result<Imported, String> {
import_names(bytes, editing_data, trust, NameEncoding::default())
}
/// [`import_trusting`], with the names in the editing copy that aren't UTF-8 (layers, objects,
/// artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables
/// and data sets) read in `names`.
pub fn import_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result<Imported, String> {
let ps = program(bytes)?;
let dsc = Dsc::read(ps);
// A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no
@@ -258,7 +266,7 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result<
// printed).
if editing_data
&& dsc.own_legacy
&& let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator)
&& let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator, names)
{
return Ok(Imported { document, warnings, preview: false });
}
@@ -278,7 +286,7 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result<
}
let drew = !out.drawn.is_empty();
let why = match (&result, drew) {
(Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, dsc.illustrator)),
(Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, dsc.illustrator, names)),
(Ok(()), true) => return Ok(finish(out)),
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
(Err(e), _) => reason(e, fault.as_ref()),
@@ -286,7 +294,7 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result<
// A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the
// prolog defining its operators without including it): its layers, which the program has, or
// its art on one layer when it has none (Illustrator 3).
let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator)).flatten() {
let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator, names)).flatten() {
Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }),
Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"),
None => why,
+4 -1
View File
@@ -39,14 +39,17 @@ pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN;
pub(crate) use envelope::MAX_CHECKED;
pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType};
pub(crate) use frames::text_frames;
pub use obj::{NameEncoding, Names};
pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS;
#[cfg(test)]
pub(crate) use read::objects_read;
#[cfg(test)]
pub use read::read;
#[cfg(test)]
pub(crate) use read::with_image_limits;
pub use read::{
BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, Symbols, active_artboard, artboards, bindings_note, brushes, graphic_styles,
palette, read, read_beside, symbols, variables,
palette, read_beside, read_in, symbols, variables,
};
pub(crate) use read::{
MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with,
+8 -7
View File
@@ -57,7 +57,7 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use super::lex::{Lexer, Tok};
use super::obj;
use super::obj::Names;
/// The brush manager's order: the plug-in name of its object.
pub(super) const MANAGER: &str = "Adobe Brush Manager Order"; // brand-ok: the plug-in name the files use
@@ -157,16 +157,17 @@ pub(super) enum BrushError {
}
/// The strings of a plug-in object's section (`section`: what follows `%AI8_BeginPluginObject`):
/// its plug-in name, its name and its parameters, joined. The name is `None` with one string, and
/// the whole is `None` with none.
pub(super) fn plugin_strings(section: &[u8]) -> Option<(String, Option<String>, String)> {
/// its plug-in name, the bytes of its name and its parameters, joined, read by `names` without
/// notes. The name is `None` with one string, and the whole is `None` with none. A brush's name has
/// its note when the brush is read, and the names of its art when their art is read.
pub(super) fn plugin_strings(section: &[u8], names: &Names) -> Option<(String, Option<Vec<u8>>, String)> {
let mut lex = Lexer::new(section);
let mut strings = vec![];
let mut params = vec![];
while let Some(t) = lex.next_token() {
if let Tok::Str(s) | Tok::Hex(s) = t.tok {
if strings.len() < 2 {
strings.push(obj::text(&s));
strings.push(s);
} else {
// Joined before they are text: a string may end inside a character.
let room = MAX_PARAMS.saturating_sub(params.len());
@@ -175,8 +176,8 @@ pub(super) fn plugin_strings(section: &[u8]) -> Option<(String, Option<String>,
}
}
let mut strings = strings.into_iter();
let kind = strings.next()?;
Some((kind, strings.next(), obj::text(&params)))
let kind = names.quiet(&strings.next()?);
Some((kind, strings.next(), names.quiet(&params)))
}
/// The brush manager's order (`params`: its parameters): the kind and name of each brush. Pairs
+334
View File
@@ -47,6 +47,237 @@ pub(super) fn text(bytes: &[u8]) -> String {
}
}
/// The encoding names in the editing data that aren't UTF-8 are read in (layers, objects,
/// artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables
/// and data sets, and data sets' text values). Legacy files store them in the code page of the
/// system that wrote them and don't say which.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NameEncoding {
/// Windows-1252, Latin-1 for its gaps.
#[default]
Windows1252,
/// Mac OS Roman.
Macintosh,
/// Korean: Windows code page 949 (EUC-KR and Unified Hangul Code).
Korean,
/// Japanese: Shift_JIS (Windows code page 932).
ShiftJis,
/// Simplified Chinese: GBK (Windows code page 936).
Gbk,
/// Traditional Chinese: Big5 (Windows code page 950).
Big5,
}
impl NameEncoding {
/// The labels [`NameEncoding::from_label`] reads, one for each encoding.
pub const LABELS: [&str; 6] = ["windows-1252", "macintosh", "windows-949", "shift_jis", "gbk", "big5"];
/// The encoding with label `label` (any case): one of [`Self::LABELS`], or `euc-kr` for
/// [`NameEncoding::Korean`].
pub fn from_label(label: &str) -> Option<Self> {
let label = label.trim().to_ascii_lowercase();
Some(match label.as_str() {
"windows-1252" => Self::Windows1252,
"macintosh" => Self::Macintosh,
"windows-949" | "euc-kr" => Self::Korean,
"shift_jis" => Self::ShiftJis,
"gbk" => Self::Gbk,
"big5" => Self::Big5,
_ => return None,
})
}
/// The encoding's label, as [`Self::LABELS`] gives it.
pub fn label(self) -> &'static str {
match self {
Self::Windows1252 => "windows-1252",
Self::Macintosh => "macintosh",
Self::Korean => "windows-949",
Self::ShiftJis => "shift_jis",
Self::Gbk => "gbk",
Self::Big5 => "big5",
}
}
/// `bytes` as text: UTF-8 as it is, else read in this encoding, with each sequence it doesn't
/// define as U+FFFD (Windows-1252 defines every byte).
fn decode(self, bytes: &[u8]) -> Decoded {
if let Ok(s) = std::str::from_utf8(bytes) {
return Decoded { text: s.to_string(), fallback: false, replaced: false };
}
let other = match self {
Self::Windows1252 => return Decoded { text: bytes.iter().map(|b| cp1252(*b)).collect(), fallback: true, replaced: false },
Self::Macintosh => encoding_rs::MACINTOSH,
Self::Korean => encoding_rs::EUC_KR,
Self::ShiftJis => encoding_rs::SHIFT_JIS,
Self::Gbk => encoding_rs::GBK,
Self::Big5 => encoding_rs::BIG5,
};
let (text, replaced) = other.decode_without_bom_handling(bytes);
Decoded { text: text.into_owned(), fallback: true, replaced }
}
/// [`NameEncoding::decode`] as the text, whether the bytes weren't UTF-8, and whether some
/// weren't valid in the encoding either.
pub(super) fn decoded(self, bytes: &[u8]) -> (String, bool, bool) {
let d = self.decode(bytes);
(d.text, d.fallback, d.replaced)
}
}
/// A name's hash, for counting the names past the notes.
fn hash(name: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
name.hash(&mut h);
h.finish()
}
/// Bytes read as text by [`NameEncoding::decode`].
struct Decoded {
text: String,
/// The bytes weren't UTF-8.
fallback: bool,
/// Some weren't valid in the encoding either, and became U+FFFD.
replaced: bool,
}
/// Most names that weren't UTF-8 each named in a note; the rest are counted in one.
const MAX_NAME_NOTES: usize = 3;
/// Most other names counted (the count stops there).
const MAX_NAMES_COUNTED: usize = 10_000;
/// The names read in a [`NameEncoding`], and the notes on those that weren't UTF-8: one for each
/// name (the first [`MAX_NAME_NOTES`]), then one counting the others (up to
/// [`MAX_NAMES_COUNTED`]).
#[derive(Debug, Default)]
pub struct Names {
pub encoding: NameEncoding,
noted: Vec<(String, bool)>,
/// Hashes of the other names.
more: std::collections::HashSet<u64>,
}
impl Names {
pub fn new(encoding: NameEncoding) -> Self {
Self { encoding, ..Self::default() }
}
/// `bytes` read in the encoding, without a note (a list or a parameter string that repeats
/// names noted where they are defined).
pub fn quiet(&self, bytes: &[u8]) -> String {
self.encoding.decode(bytes).text
}
/// A string's or name's text as a name: a string that isn't UTF-8 read in the encoding,
/// with a note.
pub(super) fn text(&mut self, v: &V) -> Option<String> {
match v {
V::Str(s) => Some(self.bytes(s)),
V::Name(n) => Some(n.clone()),
_ => None,
}
}
/// `bytes` read as a name (see [`Names::text`]).
pub fn bytes(&mut self, bytes: &[u8]) -> String {
let d = self.encoding.decode(bytes);
if d.fallback {
self.note(&d.text, d.replaced);
}
d.text
}
/// The first text value of `o`'s entry `key`, as a name.
pub(super) fn entry(&mut self, o: &Obj, key: &str) -> Option<String> {
let v = o.get(key)?.iter().find(|v| matches!(v, V::Str(_)))?;
self.text(v)
}
/// The name the XML id in `id` (an `AI10_ArtUID`) gives its object ([`xml_name`]): a string that
/// isn't UTF-8 is read in the encoding, with a note on the name.
pub(super) fn xml_id_name(&mut self, id: &Obj) -> Option<String> {
match id.content.iter().find(|v| matches!(v, V::Str(_) | V::Name(_)))? {
V::Str(bytes) => {
let d = self.encoding.decode(bytes);
let name = xml_name(&d.text);
if d.fallback {
self.note(&name, d.replaced);
}
Some(name)
}
v => v.text().map(|t| xml_name(&t)),
}
}
/// A note on `name`, which wasn't UTF-8 (`replaced`: parts of it weren't valid in the encoding
/// either).
pub(super) fn note(&mut self, name: &str, replaced: bool) {
if self.noted.iter().any(|(n, _)| n == name) {
return;
}
if self.noted.len() < MAX_NAME_NOTES {
self.noted.push((name.to_string(), replaced));
} else {
self.count(hash(name));
}
}
fn count(&mut self, h: u64) {
if self.more.len() < MAX_NAMES_COUNTED {
self.more.insert(h);
}
}
/// Take the notes of `other`, a reader's for part of the same data.
pub fn absorb(&mut self, other: Names) {
for (name, replaced) in other.noted {
self.note(&name, replaced);
}
for h in other.more {
self.count(h);
}
}
/// Take the notes of `other` on the names in `used` (names read but then not used, such as the
/// layers of data whose layers aren't used, left out).
pub fn absorb_among(&mut self, other: Names, used: &[&str]) {
for (name, replaced) in other.noted {
if used.contains(&name.as_str()) {
self.note(&name, replaced);
}
}
for n in used {
let h = hash(n);
if other.more.contains(&h) {
self.count(h);
}
}
}
/// The notes on the names that weren't UTF-8.
pub fn notes(&self) -> Vec<String> {
let label = self.encoding.label();
let mut out: Vec<String> = self
.noted
.iter()
.map(|(name, replaced)| {
if *replaced {
format!(
"the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding"
)
} else {
format!("the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding")
}
})
.collect();
if !self.more.is_empty() {
out.push(format!("more names that aren't UTF-8 were read as {label} ({count})", count = self.more.len()));
}
out
}
}
fn cp1252(b: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
@@ -172,4 +403,107 @@ mod tests {
assert_eq!(text(b"caf\xe9 \x80"), "café €");
assert_eq!(text("ü".as_bytes()), "ü");
}
/// The default reads every byte as Windows-1252 does, the five bytes it leaves undefined as the
/// C1 controls of Latin-1, as `text` always has.
#[test]
fn names_default_to_windows_1252_with_latin_1_in_its_gaps() {
let all: Vec<u8> = (0x80..=0xff).collect();
let mut names = Names::default();
assert_eq!(names.bytes(&all), text(&all));
let gaps: String = [0x81u8, 0x8d, 0x8f, 0x90, 0x9d].iter().map(|b| names.bytes(&[*b])).collect();
assert_eq!(gaps, "\u{81}\u{8d}\u{8f}\u{90}\u{9d}");
assert_eq!(names.bytes(b"\x80\x9f"), "€Ÿ");
}
/// A name that is UTF-8 is read as it is in every encoding, without a note.
#[test]
fn utf8_names_need_no_encoding() {
for label in NameEncoding::LABELS {
let mut names = Names::new(NameEncoding::from_label(label).unwrap());
assert_eq!(names.bytes("레이어 1".as_bytes()), "레이어 1");
assert_eq!(names.text(&V::Str(b"Layer 1".to_vec())).as_deref(), Some("Layer 1"));
assert!(names.notes().is_empty(), "{label}");
}
}
/// Each encoding reads the bytes of a name written in it.
#[test]
fn names_in_each_encoding() {
for (label, bytes, want) in [
("windows-1252", &b"Caf\xe9"[..], "Café"),
("macintosh", b"Caf\x8e", "Café"),
("windows-949", b"\xb7\xb9\xc0\xcc\xbe\xee 1", "레이어 1"),
("euc-kr", b"\xb7\xb9\xc0\xcc\xbe\xee 1", "레이어 1"),
("shift_jis", b"\x83\x8c\x83\x43\x83\x84\x81\x5b 1", "レイヤー 1"),
("gbk", b"\xcd\xbc\xb2\xe3 1", "图层 1"),
("big5", b"\xb9\xcf\xbch 1", "圖層 1"),
] {
let mut names = Names::new(NameEncoding::from_label(label).unwrap());
assert_eq!(names.bytes(bytes), want, "{label}");
assert_eq!(
names.notes(),
vec![format!(
"the name `{want}` isn't UTF-8 and was read as {}: if it looks wrong, open the file with another legacyNameEncoding",
names.encoding.label()
)]
);
}
assert_eq!(NameEncoding::from_label(" Windows-949 "), Some(NameEncoding::Korean));
assert_eq!(NameEncoding::from_label("utf-8"), None);
for label in NameEncoding::LABELS {
assert_eq!(NameEncoding::from_label(label).map(NameEncoding::label), Some(label));
}
}
/// The Korean bytes read by default come in as Windows-1252, with a note naming what they
/// became.
#[test]
fn korean_bytes_by_default_come_in_as_windows_1252_with_a_note() {
let mut names = Names::default();
let name = names.bytes(b"\xb7\xb9\xc0\xcc\xbe\xee 1");
assert_eq!(name, "·¹À̾î 1");
assert_eq!(
names.notes(),
vec!["the name `·¹À̾î 1` isn't UTF-8 and was read as windows-1252: if it looks wrong, open the file with another legacyNameEncoding"]
);
}
/// Bytes the encoding doesn't define come in as U+FFFD, and the note says so.
#[test]
fn malformed_names_keep_a_replacement_character() {
let mut names = Names::new(NameEncoding::Korean);
let name = names.bytes(b"\xb7\xb9\xc0 1");
assert!(name.contains('\u{fffd}'), "{name:?}");
assert!(name.starts_with('레'), "{name:?}");
let notes = names.notes();
assert_eq!(notes.len(), 1);
assert!(notes[0].contains("isn't UTF-8, and parts of it aren't valid windows-949 either:"), "{notes:?}");
}
/// The notes name the first names once each, and count the others.
#[test]
fn name_notes_are_capped() {
let mut names = Names::new(NameEncoding::Macintosh);
for i in 0..10u8 {
let bytes = [b'a' + i, 0x8e];
names.bytes(&bytes);
names.bytes(&bytes);
}
let notes = names.notes();
assert_eq!(notes.len(), MAX_NAME_NOTES + 1);
assert!(notes[0].contains("`aé`"), "{notes:?}");
assert_eq!(notes.last().map(String::as_str), Some("more names that aren't UTF-8 were read as macintosh (7)"));
let mut all = Names::new(NameEncoding::Macintosh);
all.absorb(names);
assert_eq!(all.notes(), notes);
// The count stops at its limit, whatever the file holds.
let mut many = Names::new(NameEncoding::Macintosh);
for i in 0..MAX_NAME_NOTES + MAX_NAMES_COUNTED + 50 {
let mut bytes = i.to_string().into_bytes();
bytes.push(0x8e);
many.bytes(&bytes);
}
assert_eq!(many.notes().last().map(String::as_str), Some("more names that aren't UTF-8 were read as macintosh (10000)"));
}
}
+77 -36
View File
@@ -32,7 +32,7 @@ use vectorcraft_color::gradient::{Gradient, GradientGeom, GradientKind, Gradient
use vectorcraft_color::{Color, Paint};
use super::lex::{Lexer, Tok};
use super::obj::V;
use super::obj::{NameEncoding, Names, V};
/// Largest number of stops read in one gradient.
pub(crate) const MAX_STOPS: usize = 256;
@@ -49,8 +49,8 @@ pub(super) struct Named {
}
/// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named
/// colour it uses. `None` when the operands don't fit.
pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
/// colour it uses, its name read by `names`. `None` when the operands don't fit.
pub(super) fn color_op_in(op: &str, vals: &[V], names: &mut Names) -> Option<(Paint, Option<Named>)> {
let nums: Vec<f32> = vals.iter().filter_map(V::num).map(|v| v as f32).collect();
let unit = |v: f32| v.clamp(0.0, 1.0);
let last = |n: usize| nums.get(nums.len().checked_sub(n)?..);
@@ -64,9 +64,9 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
let [r, g, b] = last(3)? else { return None };
Some((Paint::solid(Color::rgb(unit(*r), unit(*g), unit(*b))), None))
}
"x" => named(vals, Some(false)),
"xx" => named(vals, None),
"xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))),
"x" => named(vals, Some(false), names),
"xx" => named(vals, None, names),
"xk" => named(vals, None, names).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))),
"xs" | "xz" => registration(op, vals),
_ => None,
}
@@ -78,14 +78,14 @@ fn is_registration(name: &str) -> bool {
/// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab);
/// `rgb` forces RGB or CMYK.
fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
fn named(vals: &[V], rgb: Option<bool>, names: &mut Names) -> Option<(Paint, Option<Named>)> {
let at = vals.iter().rposition(|v| matches!(v, V::Str(_) | V::Name(_)))?;
let name = vals.get(at)?.text()?;
let name = vals.get(at)?;
let before: Vec<f32> = vals.get(..at)?.iter().filter_map(V::num).map(|v| v as f32).collect();
let after: Vec<f32> = vals.get(at + 1..)?.iter().filter_map(V::num).map(|v| v as f32).collect();
let unit = |v: f32| v.clamp(0.0, 1.0);
let tint = 1.0 - unit(*after.first()?);
if is_registration(&name) {
if is_registration(&name.text()?) {
let full = Color::cmyk(1.0, 1.0, 1.0, 1.0);
let paint = Paint::Solid { color: full.tinted(tint), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint };
return Some((paint, None));
@@ -105,6 +105,7 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
let [c, m, y, k] = before.get(from..from + 4)? else { return None };
Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k))
};
let name = names.text(name)?;
let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint };
Some((paint, Some(Named { name, color, tint, spot: true })))
}
@@ -130,7 +131,7 @@ fn registration(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
}
/// The colour of a gradient stop's colour values and style.
fn stop_color(style: f64, comps: &[V]) -> Option<(Color, Option<Named>)> {
fn stop_color(style: f64, comps: &[V], names: &mut Names) -> Option<(Color, Option<Named>)> {
let nums: Vec<f32> = comps.iter().filter_map(V::num).map(|v| v as f32).collect();
let unit = |v: f32| v.clamp(0.0, 1.0);
let tail = |n: usize| nums.get(nums.len().checked_sub(n)?..);
@@ -145,7 +146,7 @@ fn stop_color(style: f64, comps: &[V]) -> Option<(Color, Option<Named>)> {
(Color::rgb(unit(*r), unit(*g), unit(*b)), None)
}
_ => {
let (paint, named) = named(comps, Some(style as i64 == 4))?;
let (paint, named) = named(comps, Some(style as i64 == 4), names)?;
(paint.color()?, named)
}
})
@@ -159,16 +160,16 @@ pub(super) struct GradientDef {
}
impl GradientDef {
/// `(name) type stops Bd`.
pub fn begin(vals: &[V]) -> Option<Self> {
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None })?;
/// `(name) type stops Bd`, the name read by `names`.
pub fn begin(vals: &[V], names: &mut Names) -> Option<Self> {
let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v))?;
let nums: Vec<f64> = vals.iter().filter_map(V::num).collect();
let kind = if nums.first().copied() == Some(1.0) { GradientKind::Radial } else { GradientKind::Linear };
Some(Self { name, gradient: Gradient::new(kind, vec![]) })
}
/// A stop (`Bs`).
pub fn stop(&mut self, vals: &[V], spots: &mut Vec<Named>) {
/// A stop (`Bs`), the name of its colour read by `names`.
pub fn stop(&mut self, vals: &[V], spots: &mut Vec<Named>, names: &mut Names) {
if self.gradient.stops.len() >= MAX_STOPS {
return;
}
@@ -179,7 +180,7 @@ impl GradientDef {
let newer = n >= 5 && n.checked_sub(3).and_then(num) == Some(6.0);
let (style_at, opacity) = if newer { (n.checked_sub(5), n.checked_sub(4).and_then(num).unwrap_or(1.0)) } else { (n.checked_sub(3), 1.0) };
let (Some(style_at), Some(style)) = (style_at, style_at.and_then(num)) else { return };
let Some((color, named)) = stop_color(style, vals.get(..style_at).unwrap_or_default()) else { return };
let Some((color, named)) = stop_color(style, vals.get(..style_at).unwrap_or_default(), names) else { return };
let mut stop = GradientStop::new((ramp / 100.0).clamp(0.0, 1.0) as f32, color);
stop.opacity = opacity.clamp(0.0, 1.0) as f32;
stop.midpoint = (mid / 100.0).clamp(0.13, 0.87) as f32;
@@ -225,9 +226,16 @@ pub(super) struct ColorNames {
vals: Vec<V>,
def: Option<GradientDef>,
spots: Vec<Named>,
/// Reads the names found (the reader reads them again, and keeps the notes on them).
decoder: Names,
}
impl ColorNames {
/// The names found to be read in `encoding`.
pub fn new(encoding: NameEncoding) -> Self {
Self { decoder: Names::new(encoding), ..Self::default() }
}
pub fn token(&mut self, tok: Tok<'_>) {
self.take(tok, true);
}
@@ -252,7 +260,7 @@ impl ColorNames {
};
let vals = std::mem::take(&mut self.vals);
match w {
b"Bd" => self.def = GradientDef::begin(&vals),
b"Bd" => self.def = GradientDef::begin(&vals, &mut self.decoder),
b"BD" => {
if let Some((name, _)) = self.def.take().and_then(GradientDef::finish) {
self.gradients.push(name);
@@ -260,18 +268,18 @@ impl ColorNames {
}
b"Bs" => {
if let Some(d) = self.def.as_mut() {
d.stop(&vals, &mut self.spots);
d.stop(&vals, &mut self.spots, &mut self.decoder);
}
}
b"x" | b"X" | b"Xx" | b"XX" | b"Xk" | b"XK" => {
if let Some((_, Some(n))) = std::str::from_utf8(w).ok().and_then(|op| color_op(op, &vals)) {
if let Some((_, Some(n))) = std::str::from_utf8(w).ok().and_then(|op| color_op_in(op, &vals, &mut self.decoder)) {
self.spots.push(n);
}
}
b"@" | b"&" if outer => {
for v in &vals {
if let V::Str(s) = v {
let mut inner = ColorNames::default();
let mut inner = ColorNames::new(self.decoder.encoding);
let mut lex = Lexer::new(s);
while let Some(t) = lex.next_token() {
inner.take(t.tok, false);
@@ -396,10 +404,10 @@ impl Instance {
Self { stroke, length: 1.0, ..Self::default() }
}
/// `flag (name) x y angle length a b c d tx ty Bg`.
pub fn bg(&mut self, vals: &[V]) {
/// `flag (name) x y angle length a b c d tx ty Bg`, the name read by `names`.
pub fn bg(&mut self, vals: &[V], names: &mut Names) {
let Some(at) = vals.iter().rposition(|v| matches!(v, V::Str(_))) else { return };
self.name = vals.get(at).and_then(V::text);
self.name = vals.get(at).and_then(|v| names.text(v));
let n: Vec<f64> = vals.get(at + 1..).unwrap_or_default().iter().filter_map(V::num).collect();
if let [x, y, angle, length, m @ ..] = n.as_slice() {
self.origin = Point::new(*x, *y);
@@ -520,6 +528,35 @@ mod tests {
v.iter().map(|n| V::Num(*n)).collect()
}
fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
color_op_in(op, vals, &mut Names::default())
}
/// A named colour, a gradient and its stop's colour, and the gradient an object names, read in
/// the encoding picked, so that names written alike match.
#[test]
fn colour_and_gradient_names_read_in_the_encoding() {
let mut names = Names::new(super::super::obj::NameEncoding::Korean);
let mut v = nums(&[0.0, 0.5, 1.0, 0.0]);
v.extend([V::Str(b"\xbb\xa1\xb0\xad".to_vec()), V::Num(0.0)]);
let (_, named) = color_op_in("x", &v, &mut names).unwrap();
assert_eq!(named.unwrap().name, "빨강");
let mut d = GradientDef::begin(&[V::Str(b"\xb1\xd7\xb6\xf3".to_vec()), V::Num(0.0)], &mut names).unwrap();
let mut stop = nums(&[0.0, 0.5, 1.0, 0.0]);
stop.extend([V::Str(b"\xbb\xa1\xb0\xad".to_vec()), V::Num(0.0), V::Num(3.0), V::Num(50.0), V::Num(0.0)]);
let mut spots = vec![];
d.stop(&stop, &mut spots, &mut names);
assert_eq!(spots.first().map(|s| s.name.as_str()), Some("빨강"));
let (name, g) = d.finish().unwrap();
assert_eq!(name, "그라");
let mut i = Instance::begin(&[]);
let mut bg = vec![V::Num(1.0), V::Str(b"\xb1\xd7\xb6\xf3".to_vec())];
bg.extend(nums(&[0.0, 0.0, 0.0, 10.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
i.bg(&bg, &mut names);
assert!(i.paint(&HashMap::from([(name, g)]), &ShownNames::default(), Affine::IDENTITY).is_some());
assert_eq!(names.notes().len(), 2);
}
#[test]
fn registration_paints_with_the_registration_swatch_at_its_tint() {
let mut v = nums(&[0.75, 0.68, 0.67, 0.9, 0.0, 0.0, 0.0]);
@@ -608,10 +645,11 @@ mod tests {
#[test]
fn gradient_definition_and_instance() {
let mut d = GradientDef::begin(&[V::Str(b"G".to_vec()), V::Num(1.0), V::Num(2.0)]).unwrap();
let names = &mut Names::default();
let mut d = GradientDef::begin(&[V::Str(b"G".to_vec()), V::Num(1.0), V::Num(2.0)], names).unwrap();
let mut spots = vec![];
d.stop(&nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.5, 6.0, 30.0, 100.0]), &mut spots);
d.stop(&nums(&[0.81, 0.0, 1.0, 6.0, 50.0, 0.0]), &mut spots);
d.stop(&nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.5, 6.0, 30.0, 100.0]), &mut spots, names);
d.stop(&nums(&[0.81, 0.0, 1.0, 6.0, 50.0, 0.0]), &mut spots, names);
let (name, g) = d.finish().unwrap();
assert_eq!(name, "G");
assert_eq!(g.kind, GradientKind::Radial);
@@ -622,7 +660,7 @@ mod tests {
let mut i = Instance::begin(&[]);
let mut bg = vec![V::Num(1.0), V::Str(b"G".to_vec())];
bg.extend(nums(&[100.0, 100.0, 0.0, 50.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
i.bg(&bg);
i.bg(&bg, names);
i.bm(&nums(&[50.0, 0.0, 0.0, -50.0, 100.0, 100.0]));
let shown = ShownNames::default();
let Some(Paint::Gradient(gp)) = i.paint(&defs, &shown, Affine::IDENTITY) else { panic!() };
@@ -634,7 +672,7 @@ mod tests {
assert!(i.paint(&HashMap::new(), &shown, Affine::IDENTITY).is_none());
// Newer files give the matrix with `Xm`; the `Bm`s after it are bands of a cached ramp.
let mut i = Instance::begin(&[]);
i.bg(&bg);
i.bg(&bg, names);
i.xm(&nums(&[40.0, 0.0, 0.0, -40.0, 10.0, 10.0]));
i.bm(&nums(&[5.0, 0.0, 0.0, -40.0, 30.0, 10.0]));
let Some(Paint::Gradient(gp)) = i.paint(&defs, &shown, Affine::IDENTITY) else { panic!() };
@@ -679,11 +717,12 @@ mod tests {
#[test]
fn gradient_stops_beyond_the_cap_are_ignored() {
let mut d = GradientDef::begin(&[V::Str(b"H".to_vec()), V::Num(0.0)]).unwrap();
let names = &mut Names::default();
let mut d = GradientDef::begin(&[V::Str(b"H".to_vec()), V::Num(0.0)], names).unwrap();
let mut spots = vec![];
for i in 0..257 {
let ramp = i as f64 / 256.0 * 100.0;
d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut spots);
d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut spots, names);
}
let (_, g) = d.finish().unwrap();
assert_eq!(g.stops.len(), MAX_STOPS);
@@ -694,9 +733,10 @@ mod tests {
#[test]
fn stops_at_one_ramp_point_keep_their_sides() {
for (kind, listed) in [(0.0, [0.75, 0.25]), (1.0, [0.25, 0.75])] {
let mut d = GradientDef::begin(&[V::Str(b"E".to_vec()), V::Num(kind), V::Num(2.0)]).unwrap();
let names = &mut Names::default();
let mut d = GradientDef::begin(&[V::Str(b"E".to_vec()), V::Num(kind), V::Num(2.0)], names).unwrap();
for (gray, mid) in listed.into_iter().zip([40.0, 70.0]) {
d.stop(&nums(&[gray, 0.0, 1.0, 6.0, mid, 30.0]), &mut vec![]);
d.stop(&nums(&[gray, 0.0, 1.0, 6.0, mid, 30.0]), &mut vec![], names);
}
let (_, g) = d.finish().unwrap();
let lightness: Vec<f32> = g.stops.iter().map(|s| (s.color.to_rgb()[0] * 100.0).round() / 100.0).collect();
@@ -711,16 +751,17 @@ mod tests {
/// art space, `Xm` for the whole ramp and a `Bm` for each pair of stops.
#[test]
fn a_linear_gradient_runs_its_whole_ramp_not_its_last_part() {
let mut d = GradientDef::begin(&[V::Str(b"L".to_vec()), V::Num(0.0), V::Num(3.0)]).unwrap();
let names = &mut Names::default();
let mut d = GradientDef::begin(&[V::Str(b"L".to_vec()), V::Num(0.0), V::Num(3.0)], names).unwrap();
for ramp in [100.0, 60.0, 20.0] {
d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut vec![]);
d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut vec![], names);
}
let defs = HashMap::from([d.finish().unwrap()]);
let instance = |xm: bool| {
let mut i = Instance::begin(&[]);
let mut bg = vec![V::Num(1.0), V::Str(b"L".to_vec())];
bg.extend(nums(&[0.3, -0.2, -90.0, 0.5, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]));
i.bg(&bg);
i.bg(&bg, &mut Names::default());
if xm {
i.xm(&nums(&[0.0, -200.0, -30.0, 0.0, 10.0, 300.0]));
}
+28 -12
View File
@@ -26,7 +26,7 @@ use vectorcraft_color::Color;
use vectorcraft_doc::swatches::color_name;
use super::lex::{Lexer, Tok};
use super::obj::V;
use super::obj::{Names, V};
use super::paint::{self, Named};
/// Largest number of swatches read from one palette.
@@ -73,8 +73,9 @@ impl Palette {
}
}
/// The palette of `data`: what follows `%AI5_BeginPalette`, up to its end.
pub(super) fn palette(data: &[u8]) -> Palette {
/// The palette of `data`: what follows `%AI5_BeginPalette`, up to its end, its names read by
/// `names`.
pub(super) fn palette(data: &[u8], names: &mut Names) -> Palette {
let mut lex = Lexer::new(data);
let mut out = Palette::default();
let mut vals: Vec<V> = Vec::new();
@@ -125,15 +126,15 @@ pub(super) fn palette(data: &[u8]) -> Palette {
cell = None;
}
"Bg" if in_gradient => {
if let Some(name) = last_text(&vals) {
if let Some(name) = last_text(&vals, names) {
cell = Some(Entry { cell: Cell::Gradient(name.clone()), name });
}
}
"BB" => in_gradient = false,
"p" => cell = first_text(&vals).map(|name| Entry { cell: Cell::Pattern(name.clone()), name }),
"p" => cell = first_text(&vals, names).map(|name| Entry { cell: Cell::Pattern(name.clone()), name }),
"Xs" | "Xz" | "XS" | "XZ" => cell = None,
"g" | "k" | "x" | "Xa" | "Xx" | "Xk" => {
cell = paint::color_op(w, &vals).and_then(|(p, named)| match named {
cell = paint::color_op_in(w, &vals, names).and_then(|(p, named)| match named {
Some(Named { name, color, spot, .. }) => Some(Entry { name, cell: Cell::Color { color, global: true, spot } }),
None => p.color().map(|color| Entry { name: color_name(color), cell: Cell::Color { color, global: false, spot: false } }),
});
@@ -141,7 +142,7 @@ pub(super) fn palette(data: &[u8]) -> Palette {
"Pg" => {
cell = None;
// Capped as swatches are: a file of many `Pg` would make as many empty groups.
if let Some(name) = first_text(&vals)
if let Some(name) = first_text(&vals, names)
&& out.groups.len() < MAX_SWATCHES
{
out.groups.push((name, Vec::new()));
@@ -150,7 +151,7 @@ pub(super) fn palette(data: &[u8]) -> Palette {
"Pc" => {
// The name before `Pc`, or the one the paint gives; `[Registration]` and the other
// built-in swatches every document has are left out.
let name = last_text(&vals);
let name = last_text(&vals, names);
if let Some(mut e) = cell.take()
&& !name.as_ref().is_some_and(|n| n.starts_with('['))
&& count < MAX_SWATCHES
@@ -181,12 +182,12 @@ fn push(vals: &mut Vec<V>, v: V) {
}
}
fn first_text(vals: &[V]) -> Option<String> {
vals.iter().find(|v| matches!(v, V::Str(_))).and_then(V::text)
fn first_text(vals: &[V], names: &mut Names) -> Option<String> {
vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v))
}
fn last_text(vals: &[V]) -> Option<String> {
vals.iter().rev().find(|v| matches!(v, V::Str(_))).and_then(V::text)
fn last_text(vals: &[V], names: &mut Names) -> Option<String> {
vals.iter().rev().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v))
}
#[cfg(test)]
@@ -197,6 +198,21 @@ mod tests {
Entry { name: name.into(), cell: Cell::Color { color, global, spot } }
}
fn palette(data: &[u8]) -> Palette {
super::palette(data, &mut Names::default())
}
/// Swatch, group and spot colour names that aren't UTF-8 are read in the encoding picked.
#[test]
fn palette_names_read_in_the_encoding() {
let mut names = Names::new(super::super::obj::NameEncoding::ShiftJis);
// 赤 (red) and 色 (color) in Shift_JIS.
let p = super::palette(b"0 0 Pb\n0 (\x90\x46) 1 Pg\n0 1 1 0 (\x90\xd4) 0 x\n(\x90\xd4)\nPc\nPB\n", &mut names);
assert_eq!(p.groups.first().map(|(n, s)| (n.as_str(), s.len())), Some(("色", 1)));
assert_eq!(p.groups.first().and_then(|(_, s)| s.first()), Some(&color("赤", Color::cmyk(0.0, 1.0, 1.0, 0.0), true, true)));
assert_eq!(names.notes().len(), 2);
}
#[test]
fn a_palette_gives_its_swatches_in_order() {
let p = palette(
+39 -24
View File
@@ -125,7 +125,7 @@ use vectorcraft_geom::{AnchorKind, FillRule, PathData};
use super::super::ate::CANVAS_CENTRE;
use super::super::graphics::HAIRLINE;
use super::lex::{Lexer, Tok};
use super::obj::{self, Obj, V};
use super::obj::{self, NameEncoding, Obj, V};
use super::paint::{self, Global, GradientDef, Instance, Named, ShownNames};
use crate::ps;
@@ -238,22 +238,30 @@ pub(super) fn remember_xml_id(xml_ids: &mut HashMap<String, Option<NodeId>>, byt
}
}
/// [`read_in`], names that aren't UTF-8 read as Windows-1252.
#[cfg(test)]
pub fn read(data: &[u8], large_canvas: bool) -> Result<Structure, String> {
read_in(data, large_canvas, NameEncoding::default())
}
/// Read the structure of the editing data `data`, applying the file's `%AI24_LargeCanvasScale` only
/// when `large_canvas` is set: an EPS's page is drawn at the full size, but a `.ai`'s PDF part may
/// keep the reduced one, which the PDF import reads as it is (#2031).
pub fn read(data: &[u8], large_canvas: bool) -> Result<Structure, String> {
read_with(data, large_canvas, false)
/// keep the reduced one, which the PDF import reads as it is (#2031). Names that aren't UTF-8
/// (layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes,
/// symbols, variables and data sets) are read in `names`, each with a warning.
pub fn read_in(data: &[u8], large_canvas: bool, names: NameEncoding) -> Result<Structure, String> {
read_with(data, large_canvas, false, names)
}
/// [`read`] for a file whose page art replaces the objects the reader doesn't read (see
/// [`read_in`] for a file whose page art replaces the objects the reader doesn't read (see
/// [`Reader::unread_box`]): an image past the hard limits ([`raster::Limits`]) is then such an
/// object, where [`read`] leaves it out with a note.
pub(crate) fn read_over_page(data: &[u8], large_canvas: bool) -> Result<Structure, String> {
read_with(data, large_canvas, true)
/// object, where [`read_in`] leaves it out with a note.
pub(crate) fn read_over_page(data: &[u8], large_canvas: bool, names: NameEncoding) -> Result<Structure, String> {
read_with(data, large_canvas, true, names)
}
fn read_with(data: &[u8], large_canvas: bool, page_art: bool) -> Result<Structure, String> {
let mut r = Reader::with_names(data);
fn read_with(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result<Structure, String> {
let mut r = Reader::with_names(data, names);
r.large_canvas = large_canvas.then_some(1.0);
r.page_art = page_art;
r.run()?;
@@ -263,17 +271,18 @@ fn read_with(data: &[u8], large_canvas: bool, page_art: bool) -> Result<Structur
/// How many objects [`read`] counts against [`MAX_NODES`] in the editing data `data`.
#[cfg(test)]
pub(crate) fn objects_read(data: &[u8]) -> Result<usize, String> {
let mut r = Reader::with_names(data);
let mut r = Reader::with_names(data, NameEncoding::default());
r.run()?;
Ok(r.nodes)
}
/// [`read`] for editing data kept apart from the program `ps` it came with (an EPS's private
/// data): the program's header gives the author and the custom colors the editing data's header
/// doesn't. `page_art`: as [`read_over_page`] reads a file.
pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8], page_art: bool) -> Result<Structure, String> {
let header = Reader::header_of(ps);
let mut r = Reader::with_names(data);
/// doesn't. `page_art`: as [`read_over_page`] reads a file. `names`: the encoding of names that
/// aren't UTF-8.
pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8], page_art: bool, names: NameEncoding) -> Result<Structure, String> {
let header = Reader::header_of(ps, names);
let mut r = Reader::with_names(data, names);
r.large_canvas = large_canvas.then_some(1.0);
r.page_art = page_art;
// The program's custom colors have swatches of their names, which no gradient or pattern takes.
@@ -292,8 +301,8 @@ pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8], page_art: bool) -
/// and patterns. Names come only from what the reader reads: not from the alternates of versioned
/// content, the prolog, resources, sections it skips (such as symbols and the text document) or
/// what follows the trailer. The palette the reader keeps is the last one outside those and the
/// sections it reads on their own.
fn swatch_names(data: &[u8]) -> (ShownNames, Option<&[u8]>) {
/// sections it reads on their own. Names that aren't UTF-8 are read in `encoding`.
fn swatch_names(data: &[u8], encoding: NameEncoding) -> (ShownNames, Option<&[u8]>) {
/// The sections the reader reads on their own whose definitions and colors it keeps.
const READ_APART: &[&str] = &["BeginPattern", "BeginBrushPattern", "BeginPluginObject", "BeginArtStyles", "BeginArtStyleList"];
/// Whether the marker `m` ends what began with `begin` and ends with `end`, `depth` of its kind
@@ -311,7 +320,9 @@ fn swatch_names(data: &[u8]) -> (ShownNames, Option<&[u8]>) {
}
let mut names = ShownNames::default();
names.reserve(vectorcraft_color::swatch::REGISTRATION);
let mut colors = paint::ColorNames::default();
// The reader reads these names again, and keeps the notes on them.
let mut decoder = obj::Names::new(encoding);
let mut colors = paint::ColorNames::new(encoding);
let mut lex = Lexer::new(data);
// What the reader skips wherever it is (the marker of one of its kind nested in it, its end
// marker, how many are open inside it, and where reading goes on when it doesn't end), the
@@ -386,7 +397,7 @@ fn swatch_names(data: &[u8]) -> (ShownNames, Option<&[u8]>) {
// A palette's names are those of the palette kept, as the palette reads them.
_ if skip.is_some() || palette_at.is_some() || section.is_some_and(|s| !s.3) => continue,
Tok::Str(s) | Tok::Hex(s) if std::mem::take(&mut head) => {
let text = obj::text(s);
let text = decoder.bytes(s);
if !text.contains('\n') {
names.reserve(&text);
}
@@ -396,7 +407,7 @@ fn swatch_names(data: &[u8]) -> (ShownNames, Option<&[u8]>) {
colors.token(t.tok);
}
colors.gradients.iter().filter(|n| !n.contains('\n')).for_each(|n| names.reserve(n));
let palette = palette.map(super::palette::palette).unwrap_or_default();
let palette = palette.map(|p| super::palette::palette(p, &mut decoder)).unwrap_or_default();
if palette.is_empty() {
for mode in [ColorMode::Rgb, ColorMode::Cmyk] {
let (swatches, groups) = vectorcraft_color::default_swatches(mode.model());
@@ -631,6 +642,8 @@ struct Reader<'a> {
/// The legacy text object being read (`To` … `TO`), and the modal type attributes.
text: Option<legacy_text::LegacyText>,
type_state: legacy_text::TypeState,
/// How names that aren't UTF-8 are read, and the notes on them.
names: obj::Names,
/// The file has brush objects (the brush manager's order or a brush).
brush_objects: bool,
/// The brush manager's order.
@@ -668,13 +681,14 @@ pub(crate) fn is_look_slot(name: Option<&str>) -> bool {
impl<'a> Reader<'a> {
/// A reader of the editing data `data` as a whole, with the names its gradients and patterns
/// get in the document and its art styles found first ([`swatch_names`]): the symbols defined
/// before the art styles section use them too.
fn with_names(data: &'a [u8]) -> Self {
/// before the art styles section use them too. Names that aren't UTF-8 are read in `encoding`.
fn with_names(data: &'a [u8], encoding: NameEncoding) -> Self {
let mut r = Self::new(data);
let (names, styles) = swatch_names(data);
r.names = obj::Names::new(encoding);
let (names, styles) = swatch_names(data, encoding);
r.global_names = names;
if let Some(section) = styles {
r.styles = super::style::art_styles(section);
r.styles = super::style::art_styles_in(section, &mut r.names);
}
r
}
@@ -794,6 +808,7 @@ impl<'a> Reader<'a> {
v7_patterns: HashSet::new(),
text: None,
type_state: legacy_text::TypeState::default(),
names: obj::Names::default(),
brush_objects: false,
brush_order: None,
brush_drafts: Vec::new(),
+1 -1
View File
@@ -640,7 +640,7 @@ impl<'a> Reader<'a> {
/// 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 = o.text("SymbolRef");
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,
+33 -14
View File
@@ -123,7 +123,7 @@ impl<'a> Reader<'a> {
// GUARDS: a definition's art takes the file's art styles and hands them back, so a
// section inside it would replace the file's own.
if self.subreader_depth == 0 {
self.styles = crate::import::ai::style::art_styles(section);
self.styles = crate::import::ai::style::art_styles_in(section, &mut self.names);
}
return Ok(());
}
@@ -134,7 +134,7 @@ impl<'a> Reader<'a> {
}
if m == "BeginPalette" {
let section = self.section("EndPalette")?;
self.palette = crate::import::ai::palette::palette(section);
self.palette = crate::import::ai::palette::palette(section, &mut self.names);
return Ok(());
}
if m == "BeginEncoding" {
@@ -309,7 +309,7 @@ impl<'a> Reader<'a> {
return;
}
let Some(open) = v.iter().position(|&b| b == b'(') else { return };
let (Some(head), Some(name)) = (v.get(..open), v.get(open..).and_then(ps_string)) else { return };
let (Some(head), Some(name)) = (v.get(..open), v.get(open..).and_then(ps_bytes)) else { return };
// One more than a color has, so that too many are seen.
let parts: Vec<Option<f32>> = head
.split(u8::is_ascii_whitespace)
@@ -322,6 +322,7 @@ impl<'a> Reader<'a> {
[Some(r), Some(g), Some(b)] if !cmyk => vectorcraft_color::Color::rgb(*r, *g, *b),
_ => return,
};
let name = self.names.bytes(&name);
if !name.trim().is_empty() && !self.custom_colors.iter().any(|n| n.name == name) {
// A gradient or pattern defined after it does not take its swatch's name.
self.global_names.reserve(&name);
@@ -378,6 +379,7 @@ impl<'a> Reader<'a> {
Legacy::Yes(def) => return self.define_legacy_pattern(&def),
}
let mut sub = Reader::new(section);
sub.names = super::obj::Names::new(self.names.encoding);
sub.subreader_depth = self.subreader_depth + 1;
sub.pattern = Some(PatternHead::default());
// The gradients and art styles defined so far and the names given, lent (and the gradients
@@ -392,6 +394,7 @@ impl<'a> Reader<'a> {
self.take_back_budgets(&mut sub);
self.nodes = sub.nodes;
self.gradients = std::mem::take(&mut sub.gradients);
self.names.absorb(std::mem::take(&mut sub.names));
self.global_names = std::mem::take(&mut sub.global_names);
self.styles = std::mem::take(&mut sub.styles);
// Unread art in the definition, hidden or not, leaves the tile incomplete.
@@ -409,8 +412,9 @@ impl<'a> Reader<'a> {
/// tile's box (`_ &`). Each layer starts from the state the definition starts with.
fn define_legacy_pattern(&mut self, def: &LegacyPattern) {
let mut sub = Reader::new(b"");
sub.names = super::obj::Names::new(self.names.encoding);
sub.subreader_depth = self.subreader_depth + 1;
sub.pattern = Some(PatternHead { taken: true, name: def.name.clone(), tile: Some(def.tile) });
sub.pattern = Some(PatternHead { taken: true, name: self.names.bytes(&def.name), tile: Some(def.tile) });
sub.gradients = std::mem::take(&mut self.gradients);
sub.global_names = std::mem::take(&mut self.global_names);
sub.styles = std::mem::take(&mut self.styles);
@@ -429,6 +433,7 @@ impl<'a> Reader<'a> {
self.gradients = std::mem::take(&mut sub.gradients);
self.global_names = std::mem::take(&mut sub.global_names);
self.styles = std::mem::take(&mut sub.styles);
self.names.absorb(std::mem::take(&mut sub.names));
let whole = read.is_ok()
&& sub.unsupported.is_empty()
&& sub.unknown.is_empty()
@@ -569,10 +574,11 @@ impl<'a> Reader<'a> {
return;
}
let name = match Lexer::new(section).next_token().map(|t| t.tok) {
Some(Tok::Str(bytes)) => obj::text(&bytes),
Some(Tok::Str(bytes)) => self.names.bytes(&bytes),
_ => return,
};
let mut sub = Reader::new(section);
sub.names = super::obj::Names::new(self.names.encoding);
sub.subreader_depth = self.subreader_depth + 1;
sub.symbol = Some(SymbolHead::default());
sub.template = self.template;
@@ -591,6 +597,7 @@ impl<'a> Reader<'a> {
self.global_names = std::mem::take(&mut sub.global_names);
self.styles = std::mem::take(&mut sub.styles);
self.pattern_tiles = std::mem::take(&mut sub.pattern_tiles);
self.names.absorb(std::mem::take(&mut sub.names));
// Art stopped by a trailer, or with an array, type, image, placed file, gradient mesh or
// gradient still open at the end (as [`Reader::run_string`] checks), isn't whole.
let open = sub.done
@@ -709,7 +716,7 @@ impl<'a> Reader<'a> {
if names.len() >= MAX_LISTED {
break;
}
names.push(obj::text(&bytes));
names.push(self.names.quiet(&bytes));
}
}
self.symbol_list = Some(names);
@@ -719,7 +726,7 @@ impl<'a> Reader<'a> {
/// order or a brush ([`brush`]); other objects are left alone. A brush without a name is counted
/// as not read.
fn plugin_object(&mut self, section: &[u8]) {
let Some((kind, name, params)) = brush::plugin_strings(section) else { return };
let Some((kind, name, params)) = brush::plugin_strings(section, &self.names) else { return };
if kind == brush::MANAGER {
if name.is_some() {
self.brush_objects = true;
@@ -737,8 +744,12 @@ impl<'a> Reader<'a> {
self.brushes_unread += 1;
return;
}
match brush::parse_tool_object(tool, &name, &params) {
Ok(d) => self.brush_drafts.push(d),
match brush::parse_tool_object(tool, &self.names.quiet(&name), &params) {
Ok(d) => {
// Only the name of a brush that is read gets a note.
self.names.bytes(&name);
self.brush_drafts.push(d);
}
Err(_) => self.brushes_unread += 1,
}
}
@@ -756,8 +767,9 @@ impl<'a> Reader<'a> {
self.brush_art_sections += 1;
let mut lex = Lexer::new(section);
let Some(Tok::Str(name) | Tok::Hex(name)) = lex.next_token().map(|t| t.tok) else { return };
let name = obj::text(&name);
let name = self.names.bytes(&name);
let mut sub = Reader::new(lex.slice(lex.offset(), section.len()));
sub.names = obj::Names::new(self.names.encoding);
// In a space of its own (y down), with no head to take.
sub.pattern = Some(PatternHead { taken: true, ..PatternHead::default() });
sub.subreader_depth = self.subreader_depth + 1;
@@ -779,6 +791,7 @@ impl<'a> Reader<'a> {
self.pattern_tiles = std::mem::take(&mut sub.pattern_tiles);
self.v7_patterns = std::mem::take(&mut sub.v7_patterns);
self.global_names = std::mem::take(&mut sub.global_names);
self.names.absorb(std::mem::take(&mut sub.names));
// Images aren't kept: the document's images don't count a brush's art among their users. Art
// read with a note (something left out of it) isn't kept either.
let whole = read.is_ok()
@@ -834,7 +847,8 @@ impl<'a> Reader<'a> {
head.taken = true;
let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals };
let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default());
let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals };
let [x0, y0, x1, y1] = tile.as_slice() else { return vals };
let Some(name) = vals.get(at).and_then(|v| self.names.text(v)) else { return vals };
head.name = name;
head.tile = Some(Rect::new(*x0, *y0, *x1, *y1));
vals.split_off(at + 5)
@@ -853,7 +867,7 @@ const MAX_LAYERS: usize = 65_536;
/// A pattern's definition in the version 7 form: its head, then a list of layers painted in order,
/// the first the lowest.
struct LegacyPattern {
name: String,
name: Vec<u8>,
/// The tile in the pattern's space (y up).
tile: Rect,
layers: Vec<LegacyLayer>,
@@ -892,7 +906,7 @@ fn legacy_pattern(section: &[u8]) -> Legacy {
let mut lex = super::Lexer::new(section);
let mut next = || next_operand(&mut lex).ok().flatten().map(|t| (t.tok, t.hidden));
let name = match next() {
Some((Tok::Str(s), _)) => super::obj::text(&s),
Some((Tok::Str(s), _)) => s,
_ => return Legacy::No,
};
let mut tile = [0.0; 4];
@@ -961,6 +975,11 @@ const MAX_STRING: usize = 4096;
/// The PostScript string `v` starts with (after spaces), its escapes read, when it ends on the line
/// and holds at most [`MAX_STRING`] bytes.
fn ps_string(v: &[u8]) -> Option<String> {
ps_bytes(v).map(|bytes| obj::text(&bytes))
}
/// [`ps_string`] as the string's bytes.
fn ps_bytes(v: &[u8]) -> Option<Vec<u8>> {
let mut bytes = v.trim_ascii_start().iter().copied().peekable();
if bytes.next()? != b'(' {
return None;
@@ -997,7 +1016,7 @@ fn ps_string(v: &[u8]) -> Option<String> {
out.push(b);
}
b')' => {
let Some(d) = depth.checked_sub(1) else { return Some(super::obj::text(&out)) };
let Some(d) = depth.checked_sub(1) else { return Some(out) };
depth = d;
out.push(b);
}
+167 -37
View File
@@ -7,6 +7,7 @@ use super::{
Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, Global, HashMap, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect,
Structure, Swatch, Unit, V, obj,
};
use crate::import::ai::NameEncoding;
use vectorcraft_doc::{DataSet, DataValue, GraphicStyle, Guide, ImageBlob, PatternDef, Symbol, TextWrap, Variable, VariableKind, Variables};
/// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files).
@@ -141,21 +142,31 @@ pub(crate) const LOOK_SLOT: &str = "\u{0}text:look";
/// The artboards (names, and rects in art space) of the editing data `data` that [`read`] can't
/// read as a whole, as far as it gets: where to put the pages of its PDF part instead, and what to
/// call them (#1068); and the index among them of the active artboard (`CropAreaActive`).
pub fn artboards(data: &[u8]) -> (Vec<(String, Rect)>, Option<usize>) {
let found = artboards_read(data);
/// call them (#1068); and the index among them of the active artboard (`CropAreaActive`). `names`
/// reads their names and keeps the notes on them.
pub fn artboards(data: &[u8], names: &mut obj::Names) -> (Vec<(String, Rect)>, Option<usize>) {
let mut from_start = obj::Names::new(names.encoding);
let found = artboards_read(data, &mut from_start);
if !found.0.is_empty() && found.1.is_some() {
names.absorb(from_start);
return found;
}
// Reading can stop before the artboards (art kept in the document's data comes first), or
// between them and the active artboard's entry: read their array on its own.
let Some((boards, active)) = artboard_array_read(data) else { return found };
let mut from_array = obj::Names::new(names.encoding);
let Some((boards, active)) = artboard_array_read(data, &mut from_array) else {
names.absorb(from_start);
return found;
};
if found.0.is_empty() {
names.absorb(from_array);
(boards, active)
} else if boards == found.0 {
// The same array: its names and rects read alike.
names.absorb(from_start);
(found.0, active)
} else {
names.absorb(from_start);
found
}
}
@@ -163,7 +174,7 @@ pub fn artboards(data: &[u8]) -> (Vec<(String, Rect)>, Option<usize>) {
/// The index of the active artboard (`CropAreaActive`) in the `ArtboardArray` of the editing data
/// `data`, as [`artboards`] reads it, from that array and the entry beside it alone.
pub fn active_artboard(data: &[u8]) -> Option<usize> {
artboard_array_read(data)?.1
artboard_array_read(data, &mut obj::Names::default())?.1
}
/// Artboards as [`artboards`] gives them: names and rects in art space, and the active one's index.
@@ -171,7 +182,7 @@ type ArtboardsRead = (Vec<(String, Rect)>, Option<usize>);
/// The `ArtboardArray` entry of the editing data `data` ([`artboard_array`]), with the
/// `CropAreaActive` entry beside it, read on their own as [`artboards_read`] reads them.
fn artboard_array_read(data: &[u8]) -> Option<ArtboardsRead> {
fn artboard_array_read(data: &[u8], names: &mut obj::Names) -> Option<ArtboardsRead> {
let (array, active) = artboard_array(data)?;
let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec();
alone.extend_from_slice(array);
@@ -180,7 +191,7 @@ fn artboard_array_read(data: &[u8]) -> Option<ArtboardsRead> {
alone.extend_from_slice(active);
}
alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n");
Some(artboards_read(&alone))
Some(artboards_read(&alone, names))
}
/// The key that ends the `ArtboardArray` entry of the editing data.
@@ -278,6 +289,26 @@ fn beside(mut defined: Vec<PatternDef>, have: &[PatternDef]) -> (Vec<PatternDef>
(defined, renamed)
}
/// The names of the objects in `art` and what they hold, masks included.
fn art_names<'a>(art: impl IntoIterator<Item = &'a Node>) -> Vec<&'a str> {
let mut stack: Vec<&Node> = art.into_iter().collect();
let mut names = vec![];
while let Some(n) = stack.pop() {
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| &**c));
names.extend(n.name.as_deref());
}
names
}
/// The names read for the patterns `added` that the patterns don't show as their own: those
/// `renamed` replaced, and the names of the objects of their art.
fn pattern_names_read<'a>(added: &'a [PatternDef], renamed: &'a std::collections::BTreeMap<String, String>) -> Vec<&'a str> {
let shown: std::collections::HashSet<&str> = added.iter().map(|p| p.name.as_str()).collect();
let mut names: Vec<&str> = renamed.iter().filter(|(_, new)| shown.contains(new.as_str())).map(|(old, _)| old.as_str()).collect();
names.extend(art_names(added.iter().flat_map(|p| p.art.iter().map(|a| &**a))));
names
}
/// `n` and what it holds painting with the patterns `renamed` renames (old to new) by their new names.
fn repoint(n: &mut Node, renamed: &std::collections::BTreeMap<String, String>) {
for p in vectorcraft_doc::swatches::node_paints_mut(n) {
@@ -301,9 +332,10 @@ fn repoint(n: &mut Node, renamed: &std::collections::BTreeMap<String, String>) {
/// layers aren't read: the swatches its PDF part has are the defaults (#4036). They come as
/// [`read`] makes them, with pattern swatches only for the patterns in `patterns` and those the
/// editing data defines, which are named apart from `patterns` ([`beside`]); `None` when the
/// palette is empty or reading stops before it.
pub fn palette(data: &[u8], patterns: &[PatternDef]) -> Option<PaletteSwatches> {
let mut r = Reader::with_names(data);
/// palette is empty or reading stops before it. `names` reads their names and keeps the notes on
/// those the swatches take.
pub fn palette(data: &[u8], patterns: &[PatternDef], names: &mut obj::Names) -> Option<PaletteSwatches> {
let mut r = Reader::with_names(data, names.encoding);
// The palette, the gradients and the patterns it names come before the art, so an error in the
// art leaves them read.
let _ = r.run();
@@ -331,6 +363,15 @@ pub fn palette(data: &[u8], patterns: &[PatternDef]) -> Option<PaletteSwatches>
images.insert(im.key.clone(), blob.clone());
}
});
let used: Vec<&str> = swatches
.iter()
.chain(groups.iter().flat_map(|g| &g.swatches))
.map(|s| s.name.as_str())
.chain(groups.iter().map(|g| g.name.as_str()))
.chain(added.iter().map(|p| p.name.as_str()))
.chain(pattern_names_read(&added, &renamed))
.collect();
names.absorb_among(std::mem::take(&mut r.names), &used);
Some(PaletteSwatches { swatches, groups, patterns: added, images })
}
@@ -349,11 +390,12 @@ pub struct Brushes {
/// The brushes of the editing data `data`, for a file whose layers aren't read (a brush library
/// has none), as [`read`] makes them, with the patterns of the editing data their art paints with,
/// named apart from `patterns` ([`beside`]) as [`palette`] names them. `None` when the file has no
/// brush objects or reading stops before them.
pub fn brushes(data: &[u8], patterns: &[PatternDef]) -> Option<Brushes> {
/// brush objects or reading stops before them. `names` reads their names and keeps the notes on
/// those it brings in.
pub fn brushes(data: &[u8], patterns: &[PatternDef], names: &mut obj::Names) -> Option<Brushes> {
// Brush objects are plug-in objects: data without one isn't read again.
memchr::memmem::find(data, b"BeginPluginObject")?;
let mut r = Reader::with_names(data);
let mut r = Reader::with_names(data, names.encoding);
// The brushes come before the art, so an error in the art leaves them read.
let _ = r.run();
let (mut lib, unread) = r.brush_library()?;
@@ -386,6 +428,14 @@ pub fn brushes(data: &[u8], patterns: &[PatternDef]) -> Option<Brushes> {
images.insert(im.key.clone(), blob.clone());
}
});
let used: Vec<&str> = lib
.iter()
.map(|b| b.name.as_str())
.chain(added.iter().map(|p| p.name.as_str()))
.chain(pattern_names_read(&added, &renamed))
.chain(art_names(lib.iter().flat_map(|b| b.art())))
.collect();
names.absorb_among(std::mem::take(&mut r.names), &used);
Some(Brushes { brushes: lib, patterns: added, images, note: (unread > 0).then(|| brush::left_out_note(unread)) })
}
@@ -393,16 +443,19 @@ pub fn brushes(data: &[u8], patterns: &[PatternDef]) -> Option<Brushes> {
/// style library has none), as [`read`] makes them, with ids from 1, and the data's declared large
/// canvas scale (1 when it declares none). `large_canvas`: as [`read`] takes it. The styles are
/// those the list names before reading stops, and none when the data has no style list or lists no
/// style this reads.
pub fn graphic_styles(data: &[u8], large_canvas: bool) -> (Vec<GraphicStyle>, f64) {
/// style this reads. `names` reads their names and keeps the notes on them.
pub fn graphic_styles(data: &[u8], large_canvas: bool, names: &mut obj::Names) -> (Vec<GraphicStyle>, f64) {
if crate::import::ai::find(data, b"BeginArtStyleList").is_none() {
return (vec![], 1.0);
}
let mut r = Reader::with_names(data);
let mut r = Reader::with_names(data, names.encoding);
r.large_canvas = large_canvas.then_some(1.0);
// The styles come before the art, so an error in the art leaves them read.
let _ = r.run();
(r.graphic_styles().0, r.canvas_scale)
let styles = r.graphic_styles().0;
let used: Vec<&str> = styles.iter().map(|s| s.name.as_str()).collect();
names.absorb_among(std::mem::take(&mut r.names), &used);
(styles, r.canvas_scale)
}
/// The symbols of a `.ai` file's editing data, as [`symbols`] reads them.
@@ -421,10 +474,10 @@ pub struct Symbols {
/// instance of a symbol the data doesn't define, and with each object of their art that is linked
/// to a listed graphic style linked to it by the id [`graphic_styles`] gives that style. The
/// symbols are those defined before reading stops and before the first layer; `None` when there
/// are none.
pub fn symbols(data: &[u8]) -> Option<Symbols> {
/// are none. `names` reads their names and keeps the notes on those it brings in.
pub fn symbols(data: &[u8], names: &mut obj::Names) -> Option<Symbols> {
crate::import::ai::find(data, b"BeginSymbol")?;
let mut r = Reader::with_names(data);
let mut r = Reader::with_names(data, names.encoding);
// The symbols come before the art, so an error in the art leaves them read.
let _ = r.run();
r.check_symbols();
@@ -468,14 +521,22 @@ pub fn symbols(data: &[u8]) -> Option<Symbols> {
let reg = r.doc.symbol_registration(&s.name);
(s, reg)
})
.collect::<Vec<_>>();
let used: Vec<&str> = symbols
.iter()
.map(|(s, _)| s.name.as_str())
.chain(swatches.iter().map(|s| s.name.as_str()))
.chain(art_names(symbols.iter().map(|(s, _)| &*s.art)))
.collect();
names.absorb_among(std::mem::take(&mut r.names), &used);
Some(Symbols { symbols, images, swatches })
}
/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and
/// those of a `/Document` still open when it stopped (its art can be what stopped it).
fn artboards_read(data: &[u8]) -> (Vec<(String, Rect)>, Option<usize>) {
fn artboards_read(data: &[u8], names: &mut obj::Names) -> (Vec<(String, Rect)>, Option<usize>) {
let mut r = Reader::new(data);
r.names = obj::Names::new(names.encoding);
let _ = r.run();
let open: Vec<Obj> = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect();
for o in &open {
@@ -484,6 +545,8 @@ fn artboards_read(data: &[u8]) -> (Vec<(String, Rect)>, Option<usize>) {
}
}
let active = r.active_board.filter(|i| *i < r.artboards.len());
let used: Vec<&str> = r.artboards.iter().map(|b| b.name.as_str()).collect();
names.absorb_among(std::mem::take(&mut r.names), &used);
(r.artboards.into_iter().map(|b| (b.name, b.rect)).collect(), active)
}
@@ -535,20 +598,27 @@ impl VariableRead {
}
/// The variable library of the editing data `data` ([`VariableRead`]), for a file whose layers
/// [`read`] can't read. Data without a `variableSets` element isn't read.
pub fn variables(data: &[u8]) -> VariableRead {
/// [`read`] can't read. Data without a `variableSets` element isn't read. `names` reads the
/// library's names and keeps the notes on them.
pub fn variables(data: &[u8], names: &mut obj::Names) -> VariableRead {
if crate::import::ai::find(data, b"variableSets").is_none() {
return VariableRead::default();
}
let mut r = Reader::new(data);
r.names = obj::Names::new(names.encoding);
let _ = r.run();
if r.variables.present {
absorb_library_names(&r.variables, r.names, names);
return r.variables;
}
// A `/Document` still open when reading stopped.
let open: Vec<Obj> = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect();
if let Some(found) = open.iter().filter(|o| o.ty == "Document").map(variable_library).find(|v| v.present) {
return found;
for o in open.iter().filter(|o| o.ty == "Document") {
let found = variable_library(o, &mut r.names);
if found.present {
absorb_library_names(&found, r.names, names);
return found;
}
}
// Reading can stop before the library: read its node on its own.
variable_sets_node(data).map_or(r.variables, |node| {
@@ -556,11 +626,20 @@ pub fn variables(data: &[u8]) -> VariableRead {
alone.extend_from_slice(node);
alone.extend_from_slice(b"\n%_; (xmlnode-children) ,\n%_; /Recorded ,\n%_;\n");
let mut r = Reader::new(&alone);
r.names = obj::Names::new(names.encoding);
let _ = r.run();
absorb_library_names(&r.variables, r.names, names);
r.variables
})
}
/// Take into `names` the notes of `read` on the names of the variables and data sets of `library`.
fn absorb_library_names(library: &VariableRead, read: obj::Names, names: &mut obj::Names) {
let used: Vec<&str> =
library.variables.variables.iter().map(|v| v.name.as_str()).chain(library.variables.datasets.iter().map(|d| d.name.as_str())).collect();
names.absorb_among(read, &used);
}
impl<'a> Reader<'a> {
/// Take the author and custom colors that the header of a program gives (`outer`, from
/// [`Self::header_of`]), where the editing data's own header gives none: the program of an EPS
@@ -569,6 +648,7 @@ impl<'a> Reader<'a> {
if self.author.is_none() {
self.author = outer.author;
}
self.names.absorb(outer.names);
for n in outer.custom_colors {
if self.custom_colors.len() < super::directives::MAX_CUSTOM_COLORS && !self.custom_colors.iter().any(|c| c.name == n.name) {
self.custom_colors.push(n);
@@ -577,8 +657,8 @@ impl<'a> Reader<'a> {
}
/// A reader that has read the header of the program `ps` (its comment lines up to
/// `%%EndComments`).
pub(super) fn header_of(ps: &[u8]) -> Reader<'_> {
/// `%%EndComments`), its names that aren't UTF-8 read in `names`.
pub(super) fn header_of(ps: &[u8], names: NameEncoding) -> Reader<'_> {
let mut end = 0;
for line in ps.split_inclusive(|&b| b == b'\n' || b == b'\r') {
if !line.starts_with(b"%") && !line.trim_ascii().is_empty() {
@@ -590,6 +670,7 @@ impl<'a> Reader<'a> {
}
}
let mut outer = Reader::new(ps.get(..end).unwrap_or_default());
outer.names = obj::Names::new(names);
let _ = outer.run();
outer
}
@@ -732,10 +813,10 @@ impl<'a> Reader<'a> {
{
super::remember_xml_id(&mut self.xml_ids, &mut self.xml_id_bytes, uid, id);
}
let name = match o.text("AIArtName") {
let name = match self.names.entry(o, "AIArtName") {
Some(name) => name,
None => match o.obj("AI10_ArtUID").and_then(Obj::content_text) {
Some(id) => obj::xml_name(&id),
None => match o.obj("AI10_ArtUID").and_then(|id| self.names.xml_id_name(id)) {
Some(name) => name,
None => return,
},
};
@@ -753,7 +834,7 @@ impl<'a> Reader<'a> {
/// Effects Settings, its variable library (the first one read), and its Objects on Path records.
pub(super) fn document(&mut self, o: &Obj) {
if !self.variables.present {
self.variables = variable_library(o);
self.variables = variable_library(o, &mut self.names);
}
self.constraint_data(o);
if let Some(r) = find_entry(o, "AI Auto Rasterize", 0) {
@@ -769,7 +850,7 @@ impl<'a> Reader<'a> {
if !sane(r) || r.area() <= 0.0 {
continue;
}
let name = ab.text("Name").unwrap_or_else(|| format!("Artboard {}", i + 1));
let name = self.names.entry(ab, "Name").unwrap_or_else(|| format!("Artboard {}", i + 1));
let mut board = AiArtboard::new(name, r);
display_options(ab, &mut board);
if active == Some(i as f64) {
@@ -1057,7 +1138,7 @@ impl<'a> Reader<'a> {
let mut styles: Vec<GraphicStyle> = vec![];
let mut ids = HashMap::new();
for raw in &self.style_list {
let name = obj::text(raw);
let name = self.names.quiet(raw);
if name.is_empty() || ids.contains_key(&name) {
continue;
}
@@ -1227,7 +1308,7 @@ impl<'a> Reader<'a> {
self.drawn_looks += self.pattern_looks.values().sum::<usize>();
if self.drawn_looks > 0 {
warnings.push(format!(
"objects with several fills or strokes, effects or brushes came in as their drawn look ({}): their appearance isn't editable yet",
"objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({}): their appearance isn't editable yet",
self.drawn_looks
));
}
@@ -1242,6 +1323,7 @@ impl<'a> Reader<'a> {
if !doc.symbols.is_empty() {
doc.limit_depth_noting(&mut warnings);
}
warnings.extend(self.names.notes());
Ok(Structure {
doc,
to_doc,
@@ -1399,9 +1481,57 @@ const MAX_LIBRARY_BYTES: usize = 8 << 20;
/// variable holds its value: `p` elements of text, one paragraph each; a `/Bool` text, whether its
/// objects show; a linked file's URL (`file://`) or path. Graph values aren't read. What is
/// malformed, repeated or past the caps (a declaration, a data set, a value, a setting, a second
/// `variableSet`) is left out and counted; a library past [`MAX_LIBRARY_NODES`] isn't read.
fn variable_library(o: &Obj) -> VariableRead {
find_variable_sets(o, 0).map_or_else(VariableRead::default, read_variable_sets)
/// `variableSet`) is left out and counted; a library past [`MAX_LIBRARY_NODES`] isn't read. Text
/// that isn't UTF-8 is read in the encoding of `names`, which gets a note on each such name of a
/// variable or a data set.
fn variable_library(o: &Obj, names: &mut obj::Names) -> VariableRead {
let Some(sets) = find_variable_sets(o, 0) else { return VariableRead::default() };
let mut legacy = HashMap::new();
let read = read_variable_sets(&library_in_utf8(sets, names.encoding, &mut legacy, 0));
for name in read.variables.variables.iter().map(|v| &v.name).chain(read.variables.datasets.iter().map(|d| &d.name)) {
if let Some(replaced) = legacy.get(name) {
names.note(name, *replaced);
}
}
read
}
/// The XML nodes of a variable library with their text (a `/String` or `/UnicodeString` entry, whose
/// strings a long one is written across are joined first: a string may end inside a character) in
/// UTF-8, so that an element named after a variable and that variable's name are alike whatever
/// the encoding. `legacy` gets the text that wasn't UTF-8, trimmed as [`library_name`] trims it, and
/// whether parts of it weren't valid in the encoding either.
fn library_in_utf8(o: &Obj, encoding: NameEncoding, legacy: &mut HashMap<String, bool>, depth: usize) -> Obj {
let mut out = o.clone();
if depth > MAX_DEPTH {
return out;
}
for (_, values) in &mut out.entries {
match values.split_last_mut() {
Some((V::Name(ty), strings)) if matches!(ty.as_str(), "String" | "UnicodeString") && strings.iter().all(|v| matches!(v, V::Str(_))) => {
let bytes: Vec<u8> = strings.iter().flat_map(|v| if let V::Str(s) = v { s.as_slice() } else { &[] }).copied().collect();
let (text, not_utf8, replaced) = encoding.decoded(&bytes);
if not_utf8 {
legacy.insert(text.trim().to_string(), replaced);
}
let ty = V::Name(ty.clone());
*values = vec![V::Str(text.into_bytes()), ty];
}
_ => {
for v in values.iter_mut() {
if let V::Obj(child) = v {
*child = std::rc::Rc::new(library_in_utf8(child, encoding, legacy, depth + 1));
}
}
}
}
}
for v in &mut out.content {
if let V::Obj(child) = v {
*child = std::rc::Rc::new(library_in_utf8(child, encoding, legacy, depth + 1));
}
}
out
}
/// The `variableSets` XML node in `o` or the dictionaries it holds.
+60 -10
View File
@@ -169,7 +169,7 @@ impl<'a> Reader<'a> {
}
if let Some(d) = self.gradient_def.as_mut() {
match w {
"Bs" => d.stop(&vals, &mut self.stop_spots),
"Bs" => d.stop(&vals, &mut self.stop_spots, &mut self.names),
"BD" => {
if let Some((name, g)) = self.gradient_def.take().and_then(GradientDef::finish) {
self.global_names.define(Global::Gradient, &name);
@@ -227,7 +227,7 @@ impl<'a> Reader<'a> {
"Lb" => self.layer_attrs(&vals),
"Ln" => {
if let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() {
a.name = vals.iter().rev().find_map(V::text).unwrap_or_default();
a.name = vals.iter().rev().find_map(|v| self.names.text(v)).unwrap_or_default();
}
}
"LB" => self.layer_end()?,
@@ -319,12 +319,12 @@ impl<'a> Reader<'a> {
"A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0),
"XW" => self.state_marker(&vals, hidden),
// Gradients.
"Bd" => self.gradient_def = GradientDef::begin(&vals),
"Bd" => self.gradient_def = GradientDef::begin(&vals, &mut self.names),
"Bs" | "BD" => {}
"Bb" => self.instance = Some(Instance::begin(&vals)),
"Bg" => {
if let Some(i) = self.instance.as_mut() {
i.bg(&vals);
i.bg(&vals, &mut self.names);
}
}
"Bm" => {
@@ -479,7 +479,7 @@ impl<'a> Reader<'a> {
if self.frames.is_empty() {
return;
}
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None });
let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| self.names.text(v));
let Some(tile) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).copied() else {
self.unread_pattern(w);
return;
@@ -523,7 +523,7 @@ impl<'a> Reader<'a> {
}
fn color(&mut self, w: &str, vals: &[V]) {
let Some((p, named)) = paint::color_op(w, vals) else { return };
let Some((p, named)) = paint::color_op_in(w, vals, &mut self.names) else { return };
// Registration (`Xs`, `Xz`) says nothing of the document's colour model.
let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ") || p.is_registration();
match p.color().filter(|_| !registration) {
@@ -706,7 +706,7 @@ impl<'a> Reader<'a> {
/// follows an object.
fn style_marker(&mut self, vals: &[V], hidden: bool) {
let code = Self::nums(vals).last().copied().unwrap_or(0.0);
let style = vals.iter().rev().find_map(V::text).unwrap_or_default();
let style = vals.iter().rev().find_map(|v| self.names.text(v)).unwrap_or_default();
let after = std::mem::take(&mut self.after_object);
// An object's own state block ends `6 () XW` (a layer's `7 () XW`): an `Xy` before it isn't the layer's.
if code == 6.0 {
@@ -752,6 +752,24 @@ impl<'a> Reader<'a> {
{
return;
}
// A variable-width stroke's look that draws nothing but copies read from `%_` lines: an
// empty group, which [`styled`] takes the stroke for.
let drawn;
let look = if !hidden
&& slot_of(object.name.as_deref()).is_none()
&& matches!(look.kind, NodeKind::Group { .. })
&& self.styles.get(&style).is_some_and(|st| st.uneven)
&& !draws(look, &self.hidden_ids)
{
let mut copy = look.clone();
if let Some(children) = copy.children_mut() {
children.clear();
}
drawn = copy;
&drawn
} else {
look
};
// A style read here: the object, painted as it says.
if let Some(mut appearance) = self.styles.get(&style).and_then(|st| {
styled(
@@ -1062,10 +1080,13 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
use crate::import::ai::style::StyleItem;
// A text object's slot (an empty group standing for it) takes the style as the type will.
let slot = slot_of(object.name.as_deref()).is_some();
// Several fills and strokes, on a path or a compound path.
let stacked = !slot && !st.simple && st.stacked && matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
// A symbol instance draws its symbol's art: it keeps its drawn look.
if !st.plain
|| matches!(object.kind, NodeKind::SymbolInstance { .. })
|| (!slot && !st.simple)
// A look with no outline in it to keep takes a variable-width stroke as the object's own.
|| (!slot && !st.simple && !stacked && (!st.uneven || draws(look, &HashSet::new())))
|| (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty())
|| (st.pathfinder
&& (slot || !st.items.is_empty() || object.is_compound_shape() || !matches!(object.kind, NodeKind::Group { clip: false, .. })))
@@ -1080,6 +1101,19 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
// drawn look has each where the app placed it.
let mut placed = HashMap::new();
placed_paints(look, &mut placed, 0);
// A stack's gradients are each its own swatch, placed where the drawn look has it: one fitted
// to the object's bounds instead could lie elsewhere, so such a stack keeps its drawn look.
if stacked {
let mut seen = HashSet::new();
let placed_apart = st.items.iter().all(|i| match i {
StyleItem::Gradient { name: Some(n), .. } => seen.insert(n) && placed.contains_key(paints.names.get(Global::Gradient, n)),
StyleItem::Gradient { name: None, .. } => false,
_ => true,
});
if !placed_apart {
return None;
}
}
let mut items = vec![];
for (n, item) in st.items.iter().enumerate() {
let mut layer = match item {
@@ -1087,8 +1121,9 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
StyleItem::Gradient { name, angle } => {
// The drawn look's gradients are keyed by the names they got in the document.
let paint = match (name.as_ref().and_then(|n| placed.get(paints.names.get(Global::Gradient, n))), gradients, own_gradient) {
// Other objects than type: the object's own, as they always were.
(_, 1, Some(own)) if !slot => own.paint.clone(),
// Other objects than type: the object's own, as they always were (a stack's
// where its drawn look places it).
(_, 1, Some(own)) if !slot && !stacked => own.paint.clone(),
(Some(p), ..) => p.clone(),
(None, 1, Some(own)) => own.paint.clone(),
_ => {
@@ -1259,6 +1294,21 @@ fn strip(n: &mut Node, hidden: &HashSet<NodeId>) {
}
}
/// Whether `n` holds anything that draws, apart from the children read from `%_` lines (`hidden`):
/// a path or a compound path with a paint of its own, or a node other than a group or those.
fn draws(n: &Node, hidden: &HashSet<NodeId>) -> bool {
if hidden.contains(&n.id) {
return false;
}
let paints = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none());
match (&n.kind, n.children()) {
(NodeKind::Compound { .. }, Some(children)) => paints && children.iter().any(|c| c.visible && !hidden.contains(&c.id)),
(NodeKind::Group { .. }, Some(children)) => children.iter().any(|c| c.visible && draws(c, hidden)),
(NodeKind::Path { .. }, _) => paints,
_ => true,
}
}
/// Whether ASCII85 data in comment lines, up to its `~>`, follows the entry keyed `key` of a
/// dictionary of type `ty`: the `/ASCII85Decode` of a `/Binary` dictionary or a text document, or a
/// non-native art object's (`/ForeignObject`'s) `/Data`.
+50 -14
View File
@@ -47,7 +47,8 @@
//! with a pattern or gradient too; strokes with a Calligraphic or Art brush or aligned inside or outside too), each with its own
//! transparency and effects, and the effects of
//! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and
//! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the
//! effects (a path's stack of several fills and strokes when its drawn look places each of its
//! gradients); others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the
//! object's chain) are read on a group with no fills or strokes of its own, which keeps its members.
use std::collections::HashMap;
@@ -59,7 +60,7 @@ use vectorcraft_effects::is_pathfinder;
use super::brush::PathBrush;
use super::lex::{Lexer, Tok};
use super::obj::{self, V};
use super::obj::{self, Names, V};
use super::paint;
const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files
@@ -173,13 +174,21 @@ const PATHFINDER_OPTIONS: [(&str, Kind, f64); 12] = [
pub(super) struct ArtStyle {
/// The style reads as [`Self::items`] and [`Self::effects`].
pub plain: bool,
/// It is one fill and one stroke at most, in solid colours or the object's own gradient, with
/// It is one fill and one stroke at most, none of them [`Self::uneven`], in solid colours or the object's own gradient, with
/// no transparency or effects of their own, and only Drop Shadow, Inner Glow, Outer Glow,
/// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke),
/// Bas Relief read from its settings block and some Distort & Transform effects ([`any_object_takes`]) on the object
/// (and Pathfinder effects when it has no fills or strokes): what any object is read with. Type takes the other plain styles too (its drawn look
/// can't be retyped); other objects keep their drawn look, which is the app's own rendering.
/// can't be retyped), paths and compound paths [`Self::stacked`] ones; other objects keep their drawn look, which is the app's own rendering.
pub simple: bool,
/// It would be [`Self::simple`] but for a Stroke Style filter's variable width written as one
/// centered array ([`centered_widths`]): an object whose drawn look is empty takes it with its profile.
pub uneven: bool,
/// It is several fills and strokes in solid colours or gradients (strokes without a brush),
/// each with its own visibility, opacity and blend mode but no effects of its own, under only
/// the effects any object takes that don't move the outline: what a path or compound path is
/// read with when each gradient is placed as its drawn look has it (`read::ops`' `styled`).
pub stacked: bool,
/// Its fills and strokes, bottom first.
pub items: Vec<StyleItem>,
/// Each item's own visibility, transparency and effects, in the order of [`Self::items`].
@@ -380,8 +389,14 @@ fn closes(data: &[u8]) -> bool {
false
}
/// The styles of the section `data` (between its markers), by name.
/// [`art_styles_in`], names read in the default encoding.
pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
art_styles_in(data, &mut Names::default())
}
/// The styles of the section `data` (between its markers), by name: the names of the styles and of
/// the colours, gradients and patterns they paint with read by `names`.
pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap<String, ArtStyle> {
let mut styles = HashMap::new();
let mut name: Option<String> = None;
let mut filters: Vec<Filter> = vec![];
@@ -437,7 +452,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
}
b"FillStyle" => style_entry = Some(Kind::Fill),
b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other),
b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text),
b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)),
b"All" | b"[Registration]" | b"Registration" => operands.push(V::Name(obj::text(n))),
b"CompoundFilter" => pending = Some(true),
b"BasicFilter" => pending = Some(false),
@@ -464,7 +479,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
}
b"Array" => dict_array = Some(vec![]),
b"PluginObject" if in_basic => {
let mut texts = operands.iter().rev().filter_map(V::text);
let mut texts = operands.iter().rev().filter_map(|v| names.text(v));
if let (Some(plugin), Some(name), Some(f)) = (texts.next(), texts.next(), filters.last_mut()) {
f.plugin_object = Some((name, plugin));
}
@@ -535,7 +550,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
}
}
if let Some(f) = filters.last_mut() {
paint_op(f, op, &operands);
paint_op(f, op, &operands, names);
}
if op == "," || op == ";" || op.starts_with('X') || op.starts_with('B') || op.len() <= 2 {
operands.clear();
@@ -608,13 +623,13 @@ fn block_data(lex: &mut Lexer, encoded: &[u8]) -> Option<Vec<u8>> {
None
}
/// A paint operator of a fill's or stroke's style.
fn paint_op(f: &mut Filter, op: &str, vals: &[V]) {
/// A paint operator of a fill's or stroke's style, the names it gives read by `names`.
fn paint_op(f: &mut Filter, op: &str, vals: &[V], names: &mut Names) {
let last = vals.iter().rev().find_map(V::num);
match op {
"Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" | "xa" | "xx" | "xk" | "xs"
| "xz" => {
let Some((p, _)) = paint::color_op(op, vals) else { return };
let Some((p, _)) = paint::color_op_in(op, vals, names) else { return };
if op.ends_with(|c: char| c.is_ascii_lowercase()) {
f.fill = Some(p);
} else {
@@ -675,13 +690,13 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V]) {
"Bg" => {
f.gradient = true;
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)));
let name = at.and_then(|i| vals.get(i)).and_then(V::text);
let name = at.and_then(|i| vals.get(i)).and_then(|v| names.text(v));
let n: Vec<f64> = at.map_or(&[][..], |i| vals.get(i + 1..).unwrap_or_default()).iter().filter_map(V::num).collect();
let angle = n.get(2).copied().filter(|a| a.is_finite()).unwrap_or(0.0);
f.gradient_of = Some((name, angle));
}
"p" => {
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None });
let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v));
let matrix = vals.iter().rev().find_map(|v| match v {
V::Arr(a) => {
let n: Vec<f64> = a.iter().filter_map(V::num).collect();
@@ -1589,7 +1604,7 @@ fn style(filters: &[Filter]) -> ArtStyle {
st.plain = b.plain;
let bas_relief = bas_relief_read(filters);
let count = |stroke: bool| st.items.iter().filter(|i| matches!(i, StyleItem::Stroke { .. }) == stroke).count();
st.simple = st.plain
let simple = st.plain
&& count(true) <= 1
&& count(false) <= 1
&& st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. }))
@@ -1600,9 +1615,30 @@ fn style(filters: &[Filter]) -> ArtStyle {
&& (!st.moves_outline() || st.effects.len() == 1)
// Pathfinder effects on a group with no fills or strokes of its own (`styled` checks the group).
&& (!st.pathfinder || st.items.is_empty());
// A stroke with one centered width array comes in as the drawn look, which holds the outline as the file draws it.
let centered = filters.iter().any(centered_widths);
(st.simple, st.uneven) = (simple && !centered, simple && centered);
st.stacked = !centered
&& st.plain
&& !st.pathfinder
&& !st.moves_outline()
&& st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Gradient { .. } | StyleItem::Stroke { brush: None, .. }))
&& st.looks.iter().all(|l| l.effects.is_empty())
&& st.effects.iter().all(any_object_takes);
st
}
/// Whether `f` is a painted Stroke Style filter with a variable width written as one centered array,
/// `BSCenteredWidthsArray`. A Stroke Offset filter's profile and one written as each side's widths
/// (`BSLeftWidthsArray` and `BSRightWidthsArray`, as VectorCraft saves it) are not.
fn centered_widths(f: &Filter) -> bool {
let each_side = f
.arrays
.get("BSVariableWidthTValuesArray")
.is_some_and(|at| ["BSLeftWidthsArray", "BSRightWidthsArray"].iter().all(|key| f.arrays.get(*key).is_some_and(|w| w.len() == at.len())));
f.name == STROKE && f.paints.0 && !each_side && width_profile(&f.arrays).is_some()
}
mod adapter;
#[cfg(test)]
mod tests;
+55 -12
View File
@@ -1,20 +1,63 @@
use super::*;
use vectorcraft_color::Color;
#[test]
fn a_variable_width_stroke_keeps_its_profile() {
let data = b"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
fn paint_op(f: &mut Filter, op: &str, vals: &[V]) {
super::paint_op(f, op, vals, &mut Names::default());
}
/// The style `Tapered`: a 30 pt stroke whose beautiful-strokes dictionary holds `arrays`.
fn width_style(arrays: &str) -> ArtStyle {
let data = format!(
"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 30 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
/Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n";
let styles = art_styles(data);
let style = &styles["Tapered"];
assert!(style.plain);
let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) };
assert_eq!(*width, 30.0);
assert_eq!(profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)]));
/Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n{arrays}; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n"
);
art_styles(data.as_bytes()).remove("Tapered").unwrap_or_default()
}
/// A beautiful-strokes array `key` of `values`.
fn bs_array(key: &str, values: &[&str]) -> String {
let items: String = values.iter().map(|v| format!("{v} /Real ,\n")).collect();
format!("/Array :\n{items}; ({key}) ,\n")
}
#[test]
fn a_centered_width_array_reads_as_uneven_with_its_profile() {
// GUARDS: the style counted as `simple`, and any object read with it dropped its drawn look.
let t = |v: &[&str]| bs_array("BSVariableWidthTValuesArray", v);
let w = |v: &[&str]| bs_array("BSCenteredWidthsArray", v);
for (arrays, points) in [
(t(&["0", "0.5", "1"]) + &w(&["0", "1", "0"]), vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]),
(t(&["0", "1"]) + &w(&["1", "0.1"]), vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)]),
] {
let style = width_style(&arrays);
assert!(style.plain && !style.simple && style.uneven, "{arrays:?}: {style:?}");
let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) };
assert_eq!((*width, profile.as_ref().map(|p| p.points.clone())), (30.0, Some(points)), "{arrays:?}");
}
}
#[test]
fn a_uniform_or_unreadable_width_profile_stays_a_live_stroke() {
let t = |v: &[&str]| bs_array("BSVariableWidthTValuesArray", v);
let w = |v: &[&str]| bs_array("BSCenteredWidthsArray", v);
for arrays in [
String::new(),
t(&["0", "1"]) + &w(&["1", "1"]),
// No widths, widths of another length, widths that aren't numbers.
t(&["0", "0.5", "1"]),
t(&["0", "0.5", "1"]) + &w(&["0", "1"]),
t(&["0", "1"]) + "/Array :\n(x) /String ,\n(y) /String ,\n; (BSCenteredWidthsArray) ,\n",
// Positions that aren't numbers.
t(&["nan", "nan"]) + &w(&["0", "1"]),
] {
let style = width_style(&arrays);
assert!(style.plain && style.simple, "{arrays:?}: {style:?}");
let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{arrays:?}: {:?}", style.items) };
assert_eq!((*width, profile), (30.0, &None), "{arrays:?}");
}
}
#[test]
+482 -4
View File
@@ -783,7 +783,7 @@ fn pattern_names_are_shown_up_to_their_newline() {
// Nor the name of a custom color the program of an EPS gives, or None's.
let ps = b"%!PS-Adobe-3.0\n%%CMYKCustomColor: 0 0 1 0 (Outer Ink)\n%%EndComments\n";
let data = stream(&format!("{}{}{}", def("Outer Ink\\n1", 8), def("[None]\\n1", 8), layer(&filled("Outer Ink\\n1"))));
let doc = super::read_beside(data.as_bytes(), true, ps, false).unwrap().doc;
let doc = super::read_beside(data.as_bytes(), true, ps, false, NameEncoding::default()).unwrap().doc;
assert_eq!(doc.patterns.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Outer Ink 2", "[None] 2"]);
assert!(matches!(doc.swatch("Outer Ink").unwrap().paint, Paint::Solid { .. }));
assert!(matches!(fill(&art(&doc)[0]), Paint::Pattern { pattern, .. } if pattern == "Outer Ink 2"));
@@ -2510,6 +2510,7 @@ fn ai_variable_library_without_the_layers() {
let library = variable_library_data(&sample_variable_sets("variableAndTypes", &[]));
let unread = stream(&format!("{library}{}", layer("1 Zz\n")));
assert!(read(unread.as_bytes()).is_err());
let variables = |data: &[u8]| variables(data, &mut Names::default());
let found = variables(unread.as_bytes());
assert!(found.present && found.variables.locked && found.skipped == 0);
assert_eq!((found.variables.variables.len(), found.variables.datasets.len()), (5, 3));
@@ -5724,7 +5725,7 @@ fn artboards_after_what_stops_the_reading() {
let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board("one", 0), board("two", 200));
// Art in the document's own data that the reader can't follow comes before the artboards.
let data = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n%_/Dictionary :\nLB\n{array}%_; /NotRecorded ,\n%_;\n");
let boards: Vec<_> = super::artboards(data.as_bytes()).0.into_iter().map(|(n, r)| (n, r.x0)).collect();
let boards: Vec<_> = super::artboards(data.as_bytes(), &mut Names::default()).0.into_iter().map(|(n, r)| (n, r.x0)).collect();
assert_eq!(boards, [("one".to_string(), 0.0), ("two".to_string(), 200.0)]);
}
@@ -5925,6 +5926,115 @@ fn a_symbol_defined_before_the_art_styles_reads_its_styled_objects() {
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
/// An art style named `Glow`: a path filled with gradient swatch `under`, then gradient swatch
/// `over` at 50 % Screen above it.
fn two_gradient_style(under: &str, over: &str) -> String {
let fill = |name: &str| {
format!(
"/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
/FillStyle : 0 O\n1 ({name}) 0 0 0 1 1 0 0 1 0 0 Bg\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n"
)
};
format!(
"/KnownStyle :\n(Glow) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n{}\
/BasicFilter :\n(Blend Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
/BlendStyle : 2 0.5 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n\
{}/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n",
fill(over),
fill(under)
)
}
#[test]
fn a_path_with_several_gradient_fills_comes_in_as_itself_where_its_look_places_them() {
// GUARDS: a path whose style stacks fills with their own opacity and blend mode came in as its drawn look.
let defs = "%AI5_BeginGradient: (A)\n(A) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n\
%AI5_BeginGradient: (B)\n(B) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 0 1 0 2 1 6 50 0 Bs\n%_0 0 0 0 1 1 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let rect = rect_path(0.0, 0.0, 100.0, 50.0);
let hidden_rect = rect.replace('\n', "\n%_");
// The drawn look: the path filled with each gradient where the app placed it (its transparency
// left out: only where the gradients lie is read from it).
let look = |name: &str, x0: f64| format!("{rect}Bb\n1 ({name}) {x0} 25 0 50 1 0 0 1 0 0 Bg\nf\n0 BB\n");
let square = |under: &str, over: &str| format!("0 Ae\nu\n{}{}U\n%_{hidden_rect}f\n1 (Glow) XW\n", look(under, 0.0), look(over, 50.0));
let r = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "B"), layer(&square("A", "B"))));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}");
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
let fills: Vec<_> = a[0]
.appearance
.items
.iter()
.map(|i| match (i, i.paint()) {
(AppearanceItem::Fill(f), Paint::Gradient(g)) => (g.swatch.clone(), g.geom.map(|g| g.start.x), f.opacity, f.blend),
_ => panic!("{i:?}"),
})
.collect();
assert_eq!(fills, [(Some("A".into()), Some(0.0), 1.0, BlendMode::Normal), (Some("B".into()), Some(50.0), 0.5, BlendMode::Screen)]);
// Two fills of one swatch: which placement is whose isn't known, so the drawn look stays.
let same = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "A"), layer(&square("A", "A"))));
assert!(same.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", same.warnings);
// One gradient over a solid fill, on a path filled with that gradient placed elsewhere: the
// gradient lies where the drawn look has it, not where the path's own fill does.
let one = two_gradient_style("A", "B").replace("1 (A) 0 0 0 1 1 0 0 1 0 0 Bg\n", "0 0 0 1 0 0 0 Xa\n");
let own = format!(
"0 Ae\nu\n0 0 0 1 0 0 0 Xa\n{rect}f\n{}U\n%_{hidden_rect}Bb\n%_1 (B) 0 25 0 100 1 0 0 1 0 0 Bg\n%_f\n%_0 BB\n1 (Glow) XW\n",
look("B", 50.0)
);
let one = read_ok(&format!("%AI9_BeginArtStyles\n{one}%AI9_EndArtStyles\n{defs}{}", layer(&own)));
let a = art(&one.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let placed: Vec<_> = a[0]
.appearance
.items
.iter()
.map(|i| match i.paint() {
Paint::Gradient(g) => g.geom.map(|g| g.start.x),
_ => None,
})
.collect();
assert_eq!(placed, [None, Some(50.0)], "{:?}", one.warnings);
// A look that doesn't paint the gradient (a raster, say): the drawn look stays.
let flat = format!("0 Ae\nu\n{RED}{rect}f\nU\n%_{hidden_rect}f\n1 (Glow) XW\n");
let flat = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "B"), layer(&flat)));
assert!(flat.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", flat.warnings);
}
/// A stack of solid colours comes in live too: a path whose style is a solid fill under a stroke
/// without a brush at 50 % Screen comes in as itself with both, each with its own transparency.
#[test]
fn a_path_with_a_solid_fill_under_a_screened_stroke_comes_in_as_itself() {
let stroke = "/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\
/StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n";
let over = "/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
/FillStyle : 0 O\n1 (B) 0 0 0 1 1 0 0 1 0 0 Bg\n0 1 Xd\n/Def ;\n (FillStyle) ,\n";
let style = two_gradient_style("A", "B");
assert!(style.contains(over));
let style = style.replace(over, stroke).replace("1 (A) 0 0 0 1 1 0 0 1 0 0 Bg\n", "0 0 0 1 0 0 0 Xa\n");
let rect = rect_path(0.0, 0.0, 100.0, 50.0);
let hidden_rect = rect.replace('\n', "\n%_");
let square = format!("0 Ae\nu\n0 0 0 1 0 0 0 Xa\n{rect}f\n0 1 1 0 1 0 0 XA\n2 w\n{rect}S\nU\n%_{hidden_rect}f\n1 (Glow) XW\n");
let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&square)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}");
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
let items: Vec<_> = a[0]
.appearance
.items
.iter()
.map(|i| match i {
AppearanceItem::Fill(f) => ("fill", matches!(f.paint, Paint::Solid { .. }), f.opacity, f.blend),
AppearanceItem::Stroke(s) => {
("stroke", matches!(s.paint, Paint::Solid { .. }) && s.brush.is_none() && s.width == 2.0, s.opacity, s.blend)
}
})
.collect();
assert_eq!(items, [("fill", true, 1.0, BlendMode::Normal), ("stroke", true, 0.5, BlendMode::Screen)]);
}
/// `node` is the red square of [`styled_square`] read as itself with [`ART_STYLES`]' `Blue`: a blue
/// fill and a drop shadow, not the look it draws.
fn assert_blue_square(node: &Node) {
@@ -6239,7 +6349,7 @@ fn a_symbol_library_without_layers_gives_its_symbols() {
);
let list = "%AI24_BeginSymbolList\n(Tile)\n(Pair)\n(Lost)\n(Dot)\n%AI24_EndSymbolList\n";
let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{styles}{defs}{list}");
let read = super::symbols(stream(&body).as_bytes()).expect("the library's symbols");
let read = super::symbols(stream(&body).as_bytes(), &mut Names::default()).expect("the library's symbols");
let names: Vec<&str> = read.symbols.iter().map(|(s, _)| s.name.as_str()).collect();
assert_eq!(names, ["Tile", "Pair", "Dot", "Pic"]);
let registrations: Vec<u8> = read.symbols.iter().map(|(_, r)| r.0).collect();
@@ -6256,7 +6366,7 @@ fn a_symbol_library_without_layers_gives_its_symbols() {
assert_eq!(keys.len(), 1);
assert_eq!(read.images.keys().cloned().collect::<Vec<_>>(), keys);
// Without symbols there are none to give.
assert!(super::symbols(stream(&styles).as_bytes()).is_none());
assert!(super::symbols(stream(&styles).as_bytes(), &mut Names::default()).is_none());
}
#[test]
@@ -6342,6 +6452,31 @@ fn an_object_with_a_feather_style_comes_in_with_a_live_effect() {
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
/// The art style `Tapered`: a 4 pt stroke with width points `widths` (position, width) along it,
/// their widths written as one centered array.
fn width_profile_style(widths: &[(f64, f64)]) -> String {
width_arrays_style(widths, &["BSCenteredWidthsArray"])
}
/// The art style `Tapered`: a 4 pt stroke with width points `widths` (position, width) along it,
/// their widths written under each of `keys`.
fn width_arrays_style(widths: &[(f64, f64)], keys: &[&str]) -> String {
let array = |values: Vec<f64>| values.iter().map(|v| format!("{v} /Real ,\n")).collect::<String>();
let width_arrays: String = keys.iter().map(|key| format!("/Array :\n{}; ({key}) ,\n", array(widths.iter().map(|w| w.1).collect()))).collect();
format!(
"%AI9_BeginArtStyles\n\
/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\
/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 4 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
/Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n{}; (BSVariableWidthTValuesArray) ,\n{width_arrays}\
; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n\
%AI9_EndArtStyles\n",
array(widths.iter().map(|w| w.0).collect()),
)
}
/// An art style section with style `PF`: an Effect › Pathfinder filter with `Command` `command`
/// and `Mix` `mix` in the object's chain, over a fill and stroke that paint nothing.
fn pathfinder_styles(command: u32, mix: f64) -> String {
@@ -6372,6 +6507,73 @@ fn pathfinder_group() -> String {
)
}
#[test]
fn a_variable_width_stroke_comes_in_as_its_drawn_outline() {
// GUARDS: the object came in as a live stroke drawn from its profile, and the outline in the file was dropped.
let hidden_line = "0 5 m\n10 5 L\n".replace('\n', "\n%_");
let fills = format!("{RED}{}f\n", rect_path(30.0, 0.0, 40.0, 10.0));
let art_ = format!("{fills}0 Ae\nu\n{RED}{}f\nU\n%_{hidden_line}S\n1 (Tapered) XW\n{fills}", rect_path(0.0, 3.0, 10.0, 7.0));
let lens = width_profile_style(&[(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)]);
let r = read_ok(&format!("{lens}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 3, "{a:#?}");
let look = &a[1];
assert!(matches!(look.kind, NodeKind::Group { .. }), "{look:#?}");
let [outline] = look.children().unwrap().as_slice() else { panic!("{look:#?}") };
assert!(matches!(outline.kind, NodeKind::Path { .. }) && outline.appearance.fill().is_some(), "{outline:#?}");
assert!(outline.appearance.stroke().is_none_or(|s| s.profile.is_none()), "{outline:#?}");
assert!([&a[0], &a[2]].iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the objects beside it stay in place");
assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings);
// A uniform profile is a plain stroke: the object comes in as itself.
let uniform = width_profile_style(&[(0.0, 1.0), (1.0, 1.0)]);
let r = read_ok(&format!("{uniform}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 3, "{a:#?}");
assert!(matches!(a[1].kind, NodeKind::Path { .. }), "{:#?}", a[1]);
assert_eq!(a[1].appearance.stroke().map(|s| (s.width, s.profile.is_none())), Some((4.0, true)), "{:#?}", a[1]);
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
/// Only a Stroke Style filter's widths written as one centered array come in as the drawn look.
/// A Stroke Offset stroke and widths written for each side, as VectorCraft saves them, stay live.
#[test]
fn only_centered_widths_on_a_plain_stroke_come_in_as_the_drawn_outline() {
// GUARDS: every stroke with a profile went to its drawn look, so a Stroke Offset stroke and the
// app's own files lost their live profile.
let hidden_line = "0 5 m\n10 5 L\n".replace('\n', "\n%_");
let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_{hidden_line}S\n1 (Tapered) XW\n", rect_path(0.0, 3.0, 10.0, 7.0));
let lens = [(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)];
let r = read_ok(&format!("{}{}", width_profile_style(&lens), layer(&art_)));
let a = art(&r.doc);
let [look] = a.as_slice() else { panic!("{a:#?}") };
assert!(matches!(look.kind, NodeKind::Group { .. }), "{look:#?}");
assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings);
// One side's array alone doesn't replace the centered one, so the widths are still centered.
let r = read_ok(&format!("{}{}", width_arrays_style(&lens, &["BSCenteredWidthsArray", "BSLeftWidthsArray"]), layer(&art_)));
let a = art(&r.doc);
let [look] = a.as_slice() else { panic!("{a:#?}") };
assert!(matches!(look.kind, NodeKind::Group { .. }), "{look:#?}");
let sides = width_arrays_style(&lens, &["BSLeftWidthsArray", "BSRightWidthsArray"]);
let profile = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n0.5 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n0 /Real ,\n1 /Real ,\n0 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n";
let offset = format!(
"%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n",
offset_stroke_style_with("Tapered", &format!("0 /Bool (StrokeOffsetInside) ,\n{profile}"), OFFSET_STROKE)
);
for (why, style) in [("each side's widths", sides), ("a Stroke Offset stroke", offset)] {
let r = read_ok(&format!("{style}{}", layer(&art_)));
let a = art(&r.doc);
let [object] = a.as_slice() else { panic!("{why}: {a:#?}") };
assert!(matches!(object.kind, NodeKind::Path { .. }), "{why}: {object:#?}");
assert!(object.appearance.stroke().is_some_and(|s| s.profile.is_some()), "{why}: {object:#?}");
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings);
}
}
#[test]
fn a_group_with_a_pathfinder_effect_keeps_its_members_and_the_effect() {
// GUARDS: the style was not read, and the group came in as its drawn look: one path, with no effect.
@@ -6388,6 +6590,27 @@ fn a_group_with_a_pathfinder_effect_keeps_its_members_and_the_effect() {
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
#[test]
fn a_variable_width_stroke_with_an_empty_look_comes_in_as_itself() {
// GUARDS: with no outline in its look to keep, the object was dropped.
let hidden_line = "0 5 m\n10 5 L\n".replace('\n', "\n%_");
let hidden_fill = format!("{}f\n", rect_path(0.0, 3.0, 10.0, 7.0)).replace('\n', "\n%_");
let lens = width_profile_style(&[(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)]);
// An empty look, ones whose group or compound path holds only a copy read from `%_` lines, and
// ones that hold only a path painted with nothing or a hidden painted path.
let unpainted = format!("u\n{}n\nU\n", rect_path(0.0, 3.0, 10.0, 7.0));
let invisible = format!("u\n1 Xw\n{RED}{}f\n0 Xw\nU\n", rect_path(0.0, 3.0, 10.0, 7.0));
for look in ["u\nU\n".to_string(), format!("u\nu\n%_{hidden_fill}\nU\nU\n"), format!("u\n*u\n%_{hidden_fill}\n*U\nU\n"), unpainted, invisible] {
let art_ = format!("{look}%_{hidden_line}S\n1 (Tapered) XW\n");
let r = read_ok(&format!("{lens}{}", layer(&art_)));
let a = art(&r.doc);
let [object] = a.as_slice() else { panic!("{look:?}: {a:#?}") };
assert!(matches!(object.kind, NodeKind::Path { .. }), "{look:?}: {object:#?}");
assert_eq!(object.appearance.stroke().map(|s| (s.width, s.profile.is_some())), Some((4.0, true)), "{look:?}: {object:#?}");
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{look:?}: {:?}", r.warnings);
}
}
#[test]
fn each_pathfinder_operation_on_a_group_draws_as_vectorcraft_draws_it() {
// GUARDS: only Add (`Command` 0) was read; the other operations came in as the drawn look's one path.
@@ -10119,3 +10342,258 @@ fn copies_of_a_multi_layer_mask_count_toward_the_limit() {
let e = read(mask_stream7(&filled).as_bytes()).unwrap_err();
assert!(e.contains("objects"), "{e}");
}
// ---- names that aren't UTF-8 ----------------------------------------------------------------
/// `body` read with its names that aren't UTF-8 in `encoding`.
fn read_names(body: &str, encoding: &str) -> Structure {
let encoding = NameEncoding::from_label(encoding).unwrap();
match super::read_in(stream(body).as_bytes(), true, encoding) {
Ok(s) => s,
Err(e) => panic!("{e}"),
}
}
/// The notes on names that weren't UTF-8.
fn name_notes(s: &Structure) -> Vec<&str> {
s.warnings.iter().map(String::as_str).filter(|w| w.contains("isn't UTF-8") || w.contains("aren't UTF-8")).collect()
}
/// A layer's name in a legacy code page (`(name) Ln`, the bytes as octal escapes) is read in the
/// encoding picked, with a note that names it and the encoding; one in UTF-8 is read as it is in
/// every encoding, without a note.
#[test]
fn layer_names_are_read_in_the_legacy_encoding_picked() {
let layer_named = |name: &str| {
format!(
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{RED}{}f\nLB\n%AI5_EndLayer--\n",
rect_path(0.0, 0.0, 10.0, 10.0)
)
};
for (encoding, bytes, want) in [
("windows-949", r"\267\271\300\314\276\356 1", "레이어 1"),
("shift_jis", r"\203\214\203C\203\204\201[ 1", "レイヤー 1"),
("gbk", r"\315\274\262\343 1", "图层 1"),
("big5", r"\271\317\274h 1", "圖層 1"),
("macintosh", r"Caf\216", "Café"),
("windows-1252", r"Caf\351", "Café"),
] {
let s = read_names(&layer_named(bytes), encoding);
assert_eq!(s.doc.layers[0].name.as_deref(), Some(want), "{encoding}");
assert_eq!(
name_notes(&s),
[format!("the name `{want}` isn't UTF-8 and was read as {encoding}: if it looks wrong, open the file with another legacyNameEncoding")],
"{encoding}"
);
let utf8 = read_names(&layer_named(r"\353\240\210\354\235\264\354\226\264 1"), encoding);
assert_eq!(utf8.doc.layers[0].name.as_deref(), Some("레이어 1"), "{encoding}");
assert!(name_notes(&utf8).is_empty(), "{encoding}: {:?}", utf8.warnings);
}
// By default the Korean bytes come in as Windows-1252, as before, now with the note. A comment
// in UTF-8 doesn't change how the names are read.
let s = read_names(&format!("%%Title: 레이어\n{}", layer_named(r"\267\271\300\314\276\356 1")), "windows-1252");
assert_eq!(s.doc.layers[0].name.as_deref(), Some("·¹À̾î 1"));
assert_eq!(
name_notes(&s),
["the name `·¹À̾î 1` isn't UTF-8 and was read as windows-1252: if it looks wrong, open the file with another legacyNameEncoding"]
);
// Bytes the encoding doesn't define come in as U+FFFD, and the note says so.
let s = read_names(&layer_named(r"\267\271\300 1"), "windows-949");
assert_eq!(s.doc.layers[0].name.as_deref(), Some("레\u{fffd} 1"));
assert_eq!(
name_notes(&s),
["the name `레\u{fffd} 1` isn't UTF-8, and parts of it aren't valid windows-949 either: open the file with another legacyNameEncoding"]
);
}
/// An object's name, an artboard's, a spot colour's and a pattern's are read in the encoding
/// picked too, and a fill names the pattern as its definition does, whichever encoding is picked.
#[test]
fn object_artboard_colour_and_pattern_names_are_read_in_the_encoding_picked() {
// 그라 (a pattern and a group), 빨강 (an artboard and a spot colour), 레이어 1 (the layer).
let (pattern, red, layer_name) = (r"\261\327\266\363", r"\273\241\260\255", r"\267\271\300\314\276\356 1");
let boards = format!(
"%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_200 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_({red}) /UnicodeString (Name) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"
);
let tile = DOTS.replace("(Dots)", &format!("({pattern})"));
let art_ = format!(
"0 Ae\nu\n0 1 1 0 ({red}) 0 x\n{}f\n({pattern}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\nU\n%_/ArtDictionary :\n%_({pattern}) /String (AIArtName) ,\n%_;\n9 () XW\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
);
let body = format!("{boards}{tile}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({layer_name}) Ln\n{art_}LB\n%AI5_EndLayer--\n");
for (encoding, [pattern, red, layer_name]) in [("windows-949", ["그라", "빨강", "레이어 1"]), ("windows-1252", ["±×¶ó", "»¡°\u{ad}", "·¹À̾î 1"])]
{
let s = read_names(&body, encoding);
let doc = &s.doc;
assert_eq!(doc.layers[0].name.as_deref(), Some(layer_name), "{encoding}");
assert_eq!(doc.artboards.first().map(|a| a.name.as_str()), Some(red), "{encoding}");
let group = &art(doc)[0];
assert_eq!(group.name.as_deref(), Some(pattern), "{encoding}");
let kids = group.children().unwrap();
assert!(matches!(fill(&kids[0]), Paint::Solid { swatch: Some(s), .. } if s == red), "{encoding}: {:?}", fill(&kids[0]));
assert!(doc.swatch(red).is_some_and(|s| s.spot), "{encoding}");
assert!(matches!(fill(&kids[1]), Paint::Pattern { pattern: p, .. } if p == pattern), "{encoding}: {:?}", fill(&kids[1]));
assert!(doc.pattern(pattern).is_some(), "{encoding}");
assert!(s.unread.is_empty(), "{encoding}: {:?}", s.unread);
assert_eq!(name_notes(&s).len(), 3, "{encoding}: {:?}", s.warnings);
}
}
/// Past three names, the notes count the others in one.
#[test]
fn notes_on_names_that_arent_utf8_are_capped() {
let layers: String = (0..6)
.map(|i| {
format!(
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L{i} Caf\\216) Ln\n{RED}{}f\nLB\n%AI5_EndLayer--\n",
rect_path(0.0, 0.0, 10.0, 10.0)
)
})
.collect();
let s = read_names(&layers, "macintosh");
let names: Vec<_> = s.doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert!(names.iter().all(|n| n.ends_with(" Café")), "{names:?}");
let notes = name_notes(&s);
assert_eq!(notes.len(), 4, "{notes:?}");
assert_eq!(notes[3], "more names that aren't UTF-8 were read as macintosh (3)");
}
/// 빨강 and 그라 in code page 949, as octal escapes.
const KO_RED: &str = r"\273\241\260\255";
const KO_GRA: &str = r"\261\327\266\363";
/// A brush's name, the art it names and the brush manager's order are read in the encoding picked:
/// the brush finds its art and the order finds the brush, with a note on the brush and on its art.
#[test]
fn brush_names_are_read_in_the_encoding_picked() {
let params = SPRAY.replace("Dot art", KO_GRA);
let scatter = format!("%AI8_BeginPluginObject\n({SCATTER})\n({KO_RED})\n({params}) .\n%AI8_EndPluginObject\n");
let manager = brush::MANAGER;
let order = format!("%AI8_BeginPluginObject\n({manager})\n({manager})\n( {SCATTER}/ {KO_RED}/) .\n%AI8_EndPluginObject\n");
let body = format!("{}{order}{scatter}{}", brush_art(KO_GRA, 4.0), layer(""));
for (encoding, name) in [("windows-949", "빨강"), ("windows-1252", "»¡°\u{ad}")] {
let s = read_names(&body, encoding);
let lib = vectorcraft_brush::library(&s.doc);
assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), [name], "{encoding}: {:?}", s.warnings);
assert!(matches!(brush_named(&lib, name), vectorcraft_brush::BrushKind::Scatter(_)), "{encoding}");
assert_eq!(name_notes(&s).len(), 2, "{encoding}: {:?}", s.warnings);
}
}
/// A symbol's name, the instances that name it and the symbol list are read in the encoding
/// picked, with a note on each symbol.
#[test]
fn symbol_names_are_read_in_the_encoding_picked() {
let list = format!("%AI24_BeginSymbolList\n({KO_GRA})\n({KO_RED})\n%AI24_EndSymbolList\n");
let defs = symbols(&format!("{}{}{list}", symbol(KO_RED, &dot(), 5), symbol(KO_GRA, &dot(), 5)));
let s = read_names(&(defs + &layer(&instance(KO_RED, [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]))), "windows-949");
let names: Vec<&str> = s.doc.symbols.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, ["그라", "빨강"], "{:?}", s.warnings);
assert!(matches!(&art(&s.doc)[0].kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "빨강"), "{:?}", art(&s.doc)[0].kind);
assert_eq!(name_notes(&s).len(), 2, "{:?}", s.warnings);
}
/// A symbol library read without its layers has notes on the names of the objects in its art.
#[test]
fn a_symbol_librarys_art_has_notes_on_its_object_names() {
let inner = format!("{}{}", dot(), vectorcraft_testkit::ai::named(KO_GRA));
let data = stream(&symbols(&symbol(KO_RED, &inner, 5)));
let mut names = obj::Names::new(NameEncoding::Korean);
assert!(super::symbols(data.as_bytes(), &mut names).is_some());
let notes = names.notes();
assert_eq!(notes.len(), 2, "{notes:?}");
assert!(notes.iter().any(|n| n.contains("`그라`")), "{notes:?}");
}
/// An object without a name of its own takes the one its XML id gives it, read in the encoding
/// picked, with a note on that name.
#[test]
fn an_object_named_by_its_xml_id_is_read_in_the_encoding_picked() {
let square = format!("{RED}{}f\n{}", rect_path(0.0, 0.0, 10.0, 10.0), vectorcraft_testkit::ai::named(&format!("{KO_RED}_1")));
let s = read_names(&layer(&square), "windows-949");
assert_eq!(art(&s.doc)[0].name.as_deref(), Some("빨강 1"), "{:?}", s.warnings);
let notes = name_notes(&s);
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("`빨강 1`"), "{notes:?}");
}
/// The strings of a plug-in object that isn't a brush, and the name of a brush that isn't read,
/// have no notes.
#[test]
fn plug_in_objects_that_are_not_read_have_no_name_notes() {
let other = format!("%AI8_BeginPluginObject\n(Other Tool)\n({KO_RED})\n(x) .\n%AI8_EndPluginObject\n");
let broken = format!("%AI8_BeginPluginObject\n({SCATTER})\n({KO_GRA})\n(not a brush) .\n%AI8_EndPluginObject\n");
let s = read_names(&format!("{other}{broken}{}", layer("")), "windows-949");
assert!(name_notes(&s).is_empty(), "{:?}", s.warnings);
}
/// A graphic style's name, in the art styles and in the style list, and an object's link to it are
/// read in the encoding picked, with a note on the style.
#[test]
fn graphic_style_names_are_read_in_the_encoding_picked() {
let defs = [fill_style(KO_RED, "0 0 0 0 1 0 0 Xa\n")];
let s = read_names(&format!("{}{}", styles_and_list(&defs, &[KO_RED]), layer(&styled_square(KO_RED))), "windows-949");
assert_eq!(style_names(&s.doc), [("빨강".to_string(), 1)], "{:?}", s.warnings);
assert_eq!(art(&s.doc)[0].extras().graphic_style, Some(1), "{:?}", s.warnings);
assert_eq!(name_notes(&s).len(), 1, "{:?}", s.warnings);
}
/// A variable's and a data set's names are read in the encoding picked, each with a note, a name
/// written across two strings that end inside a character included. The element named after the
/// variable still names it, and the data set's text is read in the encoding too, without a note.
#[test]
fn variable_and_data_set_names_are_read_in_the_encoding_picked() {
let split_red = "\\273)\n%_(\\241\\260\\255";
let library = vectorcraft_testkit::ai::variable_library(&["QVARQ"], &["QSETQ"]).replace("QVARQ", split_red).replace("QSETQ", KO_GRA);
let s = read_names(&format!("{library}{}", layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)))), "windows-949");
let vars = &s.doc.variables;
assert_eq!(vars.variables.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["빨강"], "{:?}", s.warnings);
assert_eq!(vars.datasets.iter().map(|d| d.name.as_str()).collect::<Vec<_>>(), ["그라"], "{:?}", s.warnings);
assert_eq!(vars.dataset("그라").and_then(|d| d.values.get("빨강")), Some(&vectorcraft_doc::DataValue::Text("그라 빨강".into())));
assert_eq!(name_notes(&s).len(), 2, "{:?}", s.warnings);
}
/// A binding names its variable by the name read in the encoding picked.
#[test]
fn a_bound_variables_name_is_read_in_the_encoding_picked() {
use vectorcraft_testkit::ai::{XmlValue, named, variable_library_data, xml_element, xml_node};
let attributes =
[("trait", XmlValue::Str("textcontent")), ("varName", XmlValue::Unicode("QVARQ")), ("docRef", XmlValue::Raw("/XMLUIDREF : (one) ;"))];
let variable = xml_node(1, "variable", XmlValue::Str(""), &attributes, &[]);
let set = xml_element("variableSet", &[("locked", "none")], &[xml_element("variables", &[], &[variable])]);
let library = variable_library_data(&xml_element("variableSets", &[], &[set])).replace("QVARQ", KO_RED);
let square = layer(&format!("{RED}{}f\n{}", rect_path(0.0, 0.0, 10.0, 10.0), named("one")));
let s = read_names(&format!("{library}{square}"), "windows-949");
assert_eq!(s.bindings, [BindingRef { variable: "빨강".into(), target: Some("one".into()) }], "{:?}", s.warnings);
assert_eq!(s.doc.variables.variables.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["빨강"]);
assert_eq!(name_notes(&s).len(), 1, "{:?}", s.warnings);
}
/// A custom color of the header is read in the encoding picked, so it is the color an object
/// names: one swatch comes in, with a note.
#[test]
fn a_custom_color_of_the_header_is_read_in_the_encoding_picked() {
let body = format!(
"%!PS-Adobe-3.0\n%%CMYKCustomColor: 0 1 1 0 ({KO_RED})\n%%EndComments\n{}",
layer(&format!("0 1 1 0 ({KO_RED}) 0 x\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)))
);
let s = super::read_in(body.as_bytes(), true, NameEncoding::Korean).unwrap();
assert_eq!(s.doc.swatches.iter().filter(|w| w.spot).map(|w| w.name.as_str()).collect::<Vec<_>>(), ["빨강"], "{:?}", s.warnings);
assert_eq!(name_notes(&s).len(), 1, "{:?}", s.warnings);
}
/// A pattern's name in the version 7 form is read in the encoding picked, with a note, and a fill
/// that names it finds it.
#[test]
fn a_version_7_pattern_name_is_read_in_the_encoding_picked() {
let layers = "(0 g) @ (2 2 m 9 2 L 9 9 L f) &\n";
let fill_it = format!("({KO_RED}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0));
let stream = pattern_stream7(&pattern7(KO_RED, layers), &pattern_layer7("Art", &fill_it));
let s = super::read_in(stream.as_bytes(), true, NameEncoding::Korean).unwrap();
assert!(s.doc.pattern("빨강").is_some(), "{:?}", s.doc.patterns.iter().map(|p| &p.name).collect::<Vec<_>>());
assert!(matches!(fill(&art(&s.doc)[0]), Paint::Pattern { pattern, .. } if pattern == "빨강"), "{:?}", s.warnings);
assert_eq!(name_notes(&s).len(), 1, "{:?}", s.warnings);
}
+322 -61
View File
@@ -25,7 +25,10 @@ use std::collections::BTreeMap;
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject, TextRun};
use vectorcraft_doc::{
CharStyle, HyphenationOptions, JustificationOptions, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TabAlign,
TabStop, TextKind, TextObject, TextRun,
};
use vectorcraft_geom::{Affine, PathData, Point, Rect};
/// Longest story read (bytes).
@@ -383,8 +386,33 @@ struct Para {
space_before: f64,
space_after: f64,
hyphenate: bool,
/// Key 7: the share of its type size a line takes as its auto leading (1.2: 120%, the default;
/// Justification… sets 0 to 500%:
/// https://helpx.adobe.com/illustrator/using/line-character-spacing.html). `None`: the default.
auto_leading: Option<f64>,
/// Keys 10 to 15, in the order of Paragraph panel menu › Hyphenation…: Words Longer Than,
/// After First, Before Last, Hyphen Limit, Hyphenation Zone (pt) and Hyphenate Capitalized
/// Words. A value that can't be read, or is out of its range, keeps the default.
hyphenation: HyphenationOptions,
/// Key 30, its list (key 0): the tab stops, each its position from the frame's left edge (key
/// 0) and alignment (key 1: 0 left, the default, 1 center, 2 right, 3 decimal, in the order of
/// the Tabs panel's buttons), sorted. A stop that can't be read is left out, and so are those
/// past the first [`MAX_TABS`]. Leaders aren't read.
tabs: Vec<TabStop>,
/// The paragraph has more than [`MAX_TABS`] stops: those past them were left out.
tabs_truncated: bool,
}
/// Most tab stops read for one paragraph.
const MAX_TABS: usize = 1024;
/// Largest tab stop position read (in the text document's units), as `text.tabs.set` takes it.
const MAX_TAB_POSITION: f64 = 100_000.0;
/// Most tab stops a story's paragraphs take in all: a paragraph style's stops are copied into
/// every paragraph that has them. The paragraphs past it take the default stops, with a warning.
const MAX_STORY_TABS: usize = 1 << 20;
/// The warning when [`MAX_STORY_TABS`] leaves tab stops out.
pub(crate) const TABS_LEFT_OUT: &str = "tab stops past VectorCraft's limits were left out";
/// The text of one story, in the units of the text document.
#[derive(Debug)]
pub(super) struct Story {
@@ -405,6 +433,20 @@ pub(super) struct Story {
/// Vertical type (Type › Type Orientation › Vertical): its lines are columns, from right to
/// left. Its layout's lines run down from the anchor, each column further left.
vertical: bool,
/// Tab stops past [`MAX_STORY_TABS`] were left out of its paragraphs.
tabs_left_out: std::cell::Cell<bool>,
}
/// Spend `budget` on the tab stops `tabs`: when there aren't enough left, they're left out (the
/// default stops apply). Whether they were.
fn spend_tabs(tabs: &mut Vec<TabStop>, budget: &mut usize) -> bool {
if tabs.len() <= *budget {
*budget -= tabs.len();
false
} else {
tabs.clear();
true
}
}
#[derive(Clone, Debug)]
@@ -607,7 +649,12 @@ impl Texts {
if runs.is_empty() {
runs.push((text.chars().count(), self.style(None)));
}
Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical })
let mut budget = MAX_STORY_TABS;
let mut tabs_left_out = false;
for (_, p) in &mut paras {
tabs_left_out |= p.tabs_truncated | spend_tabs(&mut p.tabs, &mut budget);
}
Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical, tabs_left_out: tabs_left_out.into() })
}
/// A paragraph's attributes (`run`: its attributes, key 5, and the paragraph style it is based
@@ -633,6 +680,46 @@ impl Texts {
space_before: num("4"),
space_after: num("5"),
hyphenate: !matches!(pick("9"), Some(Val::Bool(false))),
auto_leading: pick("7").and_then(Val::num).filter(|v| v.is_finite() && *v > 0.0 && *v <= 5.0),
hyphenation: {
let d = HyphenationOptions::default();
let count = |k: &str, lo: f64, hi: f64, or: u32| {
pick(k).and_then(Val::num).filter(|v| v.is_finite() && v.fract() == 0.0 && (lo..=hi).contains(v)).map_or(or, |v| v as u32)
};
HyphenationOptions {
words_longer_than: count("10", 1.0, 25.0, d.words_longer_than),
after_first: count("11", 1.0, 15.0, d.after_first),
before_last: count("12", 1.0, 15.0, d.before_last),
hyphen_limit: count("13", 0.0, 25.0, d.hyphen_limit),
zone: pick("14").and_then(Val::num).filter(|v| v.is_finite() && (0.0..=1296.0).contains(v)).unwrap_or(d.zone),
capitalized: match pick("15") {
Some(Val::Bool(b)) => *b,
_ => d.capitalized,
},
}
},
tabs_truncated: pick("30").and_then(|t| t.get("0")).map(Val::list).is_some_and(|list| list.len() > MAX_TABS),
tabs: {
let list = pick("30").and_then(|t| t.get("0")).map(Val::list).unwrap_or_default();
let mut tabs: Vec<TabStop> = list
.iter()
.take(MAX_TABS)
.filter_map(|t| {
let position = t.get("0").and_then(Val::num).filter(|p| (0.0..=MAX_TAB_POSITION).contains(p))?;
let align = match t.get("1").map(Val::num) {
None | Some(Some(0.0)) => TabAlign::Left,
Some(Some(1.0)) => TabAlign::Center,
Some(Some(2.0)) => TabAlign::Right,
Some(Some(3.0)) => TabAlign::Decimal,
_ => return None,
};
Some(TabStop { position, align, leader: String::new(), align_on: '.' })
})
.collect();
tabs.sort_by(|a, b| a.position.total_cmp(&b.position));
tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6);
tabs
},
}
}
@@ -1001,7 +1088,6 @@ impl Story {
// The text document measures in the art's units too: the document's scale carries its
// sizes and spacings onto the page (a Large Canvas copy is written at 1:10; #2031).
let scale = scale * art_scale(to_doc);
let leading = self.line_leading(scale);
let chars: Vec<char> = text.chars().collect();
let mut runs = vec![];
let mut from = 0usize;
@@ -1012,13 +1098,13 @@ impl Story {
if piece.is_empty() {
continue;
}
runs.push(TextRun::new(piece, char_style(st, scale, leading)));
runs.push(TextRun::new(piece, char_style(st, scale)));
}
if from < chars.len() {
let last = self.runs.last().map(|r| &r.1)?;
runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::<String>(), char_style(last, scale, leading)));
runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::<String>(), char_style(last, scale)));
}
let first = self.runs.first().map(|r| char_style(&r.1, scale, leading))?;
let first = self.runs.first().map(|r| char_style(&r.1, scale))?;
let mut t = TextObject::point(Point::ZERO, "", first);
if !runs.is_empty() {
t.runs = runs;
@@ -1065,6 +1151,21 @@ impl Story {
!self.lines.is_empty() && vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), t)
}
/// Area type of one frame that the file lays more lines out in than fit here: in the fallback
/// font, or a last line whose descent the file lets pass the frame's bottom. The frame is made
/// taller from its bottom (`vectorcraft_text::fit_source_lines`) so the file's lines show.
/// Whether it was.
pub(super) fn fit_last_lines(&self, t: &mut TextObject) -> bool {
matches!(self.frames.as_slice(), [FrameKind::Area(..)])
&& vectorcraft_text::fit_source_lines(vectorcraft_text::FontDb::global(), t, self.lines.len())
}
/// Were tab stops past [`MAX_STORY_TABS`] left out of its paragraphs (so far: making its text
/// object counts them again)?
pub(super) fn tabs_left_out(&self) -> bool {
self.tabs_left_out.get()
}
/// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and
/// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its
/// page draws it: its first baseline is fixed where the file has it. The font's ascent below the
@@ -1103,38 +1204,6 @@ impl Story {
true
}
/// The leading the file's own line positions imply: the distance from one line to the next, less
/// the space between paragraphs that is part of it (a paragraph's space after and the next one's
/// space before; a layout's lines are where that space has put them, and the text object adds it
/// again).
fn line_leading(&self, scale: f64) -> Option<f64> {
let para_at = |c: usize| {
let mut end = 0usize;
for (n, p) in &self.paras {
end = end.saturating_add(*n);
if c < end {
return Some(p);
}
}
self.paras.last().map(|(_, p)| p)
};
let chars: Vec<char> = self.text.chars().collect();
let breaks: Vec<usize> = chars.iter().enumerate().filter(|(_, c)| matches!(c, '\r' | '\x03')).map(|(i, _)| i).collect();
// More lines than breaks: area type wraps, and which break ends which line is unknown.
let wraps = self.lines.len() > breaks.len() + 1;
self.lines.windows(2).enumerate().find_map(|(i, w)| {
let pitch = w[1].1 - w[0].1;
let between = match breaks.get(i) {
Some(&at) if !wraps && chars.get(at) == Some(&'\r') => {
para_at(at).map_or(0.0, |p| p.space_after) + para_at(at + 1).map_or(0.0, |p| p.space_before)
}
_ => 0.0,
};
let l = (pitch - between) * scale;
(l.is_finite() && l > 0.0).then_some(l)
})
}
/// The style of each paragraph of `text` (paragraphs split at `\n`).
fn paragraph_styles(&self, text: &str, scale: f64) -> Vec<ParaStyle> {
let count = text.split('\n').count();
@@ -1142,9 +1211,13 @@ impl Story {
let mut runs = self.paras.iter();
let mut current = runs.next();
let mut left = current.map_or(usize::MAX, |r| r.0);
let mut budget = MAX_STORY_TABS;
for p in text.split('\n') {
let para = current.map(|r| r.1.clone()).unwrap_or_default();
out.push(para_style(&para, scale));
let mut style = current.map_or_else(|| para_style(&Para::default(), scale), |r| para_style(&r.1, scale));
if spend_tabs(&mut style.tabs, &mut budget) {
self.tabs_left_out.set(true);
}
out.push(style);
// Each paragraph and its ending `\r` count.
let mut used = p.chars().count() + 1;
while used > 0 {
@@ -1165,6 +1238,8 @@ impl Story {
}
}
/// The paragraph style of paragraph attributes `p`, its lengths (indents, spacing, the
/// hyphenation zone) scaled by `scale`, as sizes are.
fn para_style(p: &Para, scale: f64) -> ParaStyle {
ParaStyle {
justify: match p.justify {
@@ -1184,21 +1259,33 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle {
space_before: p.space_before * scale,
space_after: p.space_after * scale,
hyphenate: p.hyphenate,
hyphenation: HyphenationOptions { zone: p.hyphenation.zone * scale, ..p.hyphenation.clone() },
tabs: p.tabs.iter().map(|t| TabStop { position: t.position * scale, ..t.clone() }).collect(),
// A right-to-left paragraph lays its text out from the right whatever letters it has
// (" 120.5 mm " reads "mm 120.5"); without the key, from its first strong letter.
direction: p.rtl.then_some(ParaDirection::RightToLeft),
justification: JustificationOptions {
// Rounded, so the file's 1.2 is the default 120% exactly.
auto_leading: p.auto_leading.map_or(JustificationOptions::default().auto_leading, |a| (a * 1e6).round() / 1e4),
..JustificationOptions::default()
},
..ParaStyle::default()
}
}
fn char_style(s: &Style, scale: f64, leading: Option<f64>) -> CharStyle {
/// The character style of character attributes `s`: its font from the fonts the app has, its
/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`.
fn char_style(s: &Style, scale: f64) -> CharStyle {
let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style);
let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
CharStyle {
font_family: family,
font_style: style,
size: s.size * scale,
leading: s.leading.map(|l| l * scale).or(leading),
// Auto leading stays auto: the layout gives each line the paragraph's share of the largest
// type on it, as the file's own layout does ("The largest leading value in a line of type
// determines the leading for that line": https://helpx.adobe.com/illustrator/using/line-character-spacing.html).
leading: s.leading.map(|l| l * scale),
tracking: s.tracking,
baseline_shift: s.baseline_shift * scale,
h_scale: s.h_scale * 100.0,
@@ -1247,6 +1334,130 @@ mod tests {
assert_eq!(justify(b"/0 1 /33 0"), Justify::Right);
}
/// Key 7 is the paragraph's auto leading, a share of the type size; one that can't be read
/// leaves the default, 120%.
#[test]
fn a_paragraph_keeps_its_auto_leading() {
let texts = Texts {
stories: vec![],
fonts: vec![],
defaults: Val::Dict(BTreeMap::new()),
para_defaults: read(b"/7 1.2").unwrap(),
char_sheets: vec![],
para_sheets: vec![],
frames: vec![],
};
let auto = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justification.auto_leading;
assert_eq!(auto(b""), JustificationOptions::default().auto_leading);
assert_eq!(auto(b"/7 1.5"), 150.0);
assert_eq!(auto(b"/7 1.15"), 115.0);
for unreadable in [b"/7 0".as_slice(), b"/7 -1", b"/7 1e9", b"/7 /nil"] {
assert!((auto(unreadable) - 120.0).abs() < 1e-9, "{}", String::from_utf8_lossy(unreadable));
}
}
/// Keys 10 to 15 are the paragraph's hyphenation options; one that can't be read, or is out of
/// its range, keeps the default. The zone is in points of the text document, scaled as sizes are.
#[test]
fn a_paragraph_keeps_its_hyphenation_options() {
let texts = Texts {
stories: vec![],
fonts: vec![],
defaults: Val::Dict(BTreeMap::new()),
para_defaults: read(b"/10 6 /11 2 /12 2 /13 0 /14 36.0 /15 true").unwrap(),
char_sheets: vec![],
para_sheets: vec![],
frames: vec![],
};
let options = |own: &[u8], scale: f64| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale).hyphenation;
let want = HyphenationOptions { words_longer_than: 6, after_first: 2, before_last: 2, hyphen_limit: 0, zone: 36.0, capitalized: true };
assert_eq!(options(b"", 1.0), want);
assert_eq!(
options(b"/10 8 /13 3 /15 false", 0.5),
HyphenationOptions { words_longer_than: 8, hyphen_limit: 3, zone: 18.0, capitalized: false, ..want.clone() }
);
let d = HyphenationOptions::default();
let bare = Texts {
stories: vec![],
fonts: vec![],
defaults: Val::Dict(BTreeMap::new()),
para_defaults: Val::Dict(BTreeMap::new()),
char_sheets: vec![],
para_sheets: vec![],
frames: vec![],
};
let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation;
assert_eq!(own(b""), d);
assert_eq!(own(b"/10 0 /11 99 /12 2.5 /13 -1 /14 -3 /15 1"), d);
}
/// Key 30 holds the paragraph's tab stops: their positions (key 0), sorted, and alignments (key
/// 1: 0 left, 1 center, 2 right, 3 decimal), scaled as sizes are. A stop that can't be read is
/// left out. A user's price list keeps a right stop at 269.29 and a left one at 272.13.
#[test]
fn a_paragraph_keeps_its_tab_stops() {
let texts = Texts {
stories: vec![],
fonts: vec![],
defaults: Val::Dict(BTreeMap::new()),
para_defaults: Val::Dict(BTreeMap::new()),
char_sheets: vec![],
para_sheets: vec![],
frames: vec![],
};
let tabs = |own: &[u8], scale: f64| {
para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale)
.tabs
.iter()
.map(|t| (t.position, t.align))
.collect::<Vec<_>>()
};
assert_eq!(tabs(b"", 1.0), []);
assert_eq!(
tabs(b"/30 << /0 [ << /0 272.12598 /1 0 >> << /0 269.29135 /1 2 >> ] >>", 1.0),
[(269.29135, TabAlign::Right), (272.12598, TabAlign::Left)]
);
assert_eq!(
tabs(b"/30 << /0 [ << /0 10 /1 1 >> << /0 20 /1 3 >> << /0 30 >> ] >>", 0.5),
[(5.0, TabAlign::Center), (10.0, TabAlign::Decimal), (15.0, TabAlign::Left)]
);
// Past MAX_TABS stops, the rest are left out, and the paragraph says so (the story's warning).
let many = |n: usize| {
let stops: String = (0..n).map(|i| format!("<< /0 {i} >> ")).collect();
texts.para(Some(&read(format!("/5 << /30 << /0 [ {stops}] >> >>").as_bytes()).unwrap()))
};
let p = many(MAX_TABS + 6);
assert!(p.tabs_truncated && p.tabs.len() == MAX_TABS);
let p = many(MAX_TABS);
assert!(!p.tabs_truncated && p.tabs.len() == MAX_TABS);
// Unreadable: a position out of range or not a number, an unknown alignment.
assert_eq!(
tabs(b"/30 << /0 [ << /0 -1 >> << /0 1e9 >> << /0 /nil >> << /0 5 /1 7 >> << /0 6 /1 /x >> << /0 7 /1 2 >> ] >>", 1.0),
[(7.0, TabAlign::Right)]
);
}
/// A story's paragraphs take at most [`MAX_STORY_TABS`] tab stops in all (a paragraph style's
/// are copied into each paragraph): those past it take the default stops, and the story says so.
#[test]
fn a_story_s_tab_stops_are_bounded() {
let mut budget = 3;
let mut tabs = vec![TabStop { position: 1.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }; 2];
assert!(!spend_tabs(&mut tabs, &mut budget) && tabs.len() == 2 && budget == 1);
assert!(spend_tabs(&mut tabs, &mut budget) && tabs.is_empty() && budget == 1);
// Paragraphs of a style with MAX_TABS stops each, more than the story takes.
let stop = TabStop { position: 1.0, align: TabAlign::Left, leader: String::new(), align_on: '.' };
let para = Para { tabs: vec![stop; MAX_TABS], ..Para::default() };
let count = MAX_STORY_TABS / MAX_TABS + 2;
let s = story(&"a\r".repeat(count), &[0.0], &para);
assert!(!s.tabs_left_out());
let styles = s.paragraph_styles(&format!("{}a", "a\n".repeat(count - 1)), 1.0);
assert_eq!(styles.len(), count);
assert!(s.tabs_left_out());
assert_eq!(styles.iter().map(|p| p.tabs.len()).sum::<usize>(), MAX_STORY_TABS);
assert!(styles.last().is_some_and(|p| p.tabs.is_empty()));
}
/// Where point type lies runs along its baseline: a label turned a quarter (as a vertical
/// dimension's is) lies in a tall box, not a wide one.
#[test]
@@ -1636,6 +1847,7 @@ mod tests {
)],
path_spacing: 0.0,
vertical: false,
tabs_left_out: false.into(),
}
}
@@ -1882,25 +2094,54 @@ mod tests {
}
}
/// The baselines of the text object `story` comes in as, laid out.
fn baselines_of(story: &Story) -> Vec<f64> {
let Some(node) = story.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY) else { return vec![] };
let NodeKind::Text(t) = &node.kind else { return vec![] };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).baselines().iter().map(|b| b.0.y).collect()
}
/// Auto leading stays auto: each line takes the paragraph's share of the largest type on it, as
/// the file's own layout does, not the distance between the first two lines for every line. A
/// 15 pt heading over 10 pt type at 120%: 18 pt between the heading's lines, then 12 pt.
#[test]
fn auto_leading_follows_the_size_of_each_line() {
let mut s = story("Big\x03Bigger\rsmall\r", &[0.0, 18.0, 30.0], &Para::default());
let base = s.runs[0].1.clone();
s.runs = vec![(11, Style { size: 15.0, ..base.clone() }), (6, Style { size: 10.0, ..base })];
assert_eq!(leading_of(&s), None);
let ys = baselines_of(&s);
assert_eq!(ys.len(), 3, "{ys:?}");
assert!(((ys[1] - ys[0]).abs() - 18.0).abs() < 1e-6, "{ys:?}");
assert!(((ys[2] - ys[1]).abs() - 12.0).abs() < 1e-6, "{ys:?}");
// At a share the paragraph sets (key 7) of 150%: 22.5 pt, then 15 pt.
let share = Para { auto_leading: Some(1.5), ..Para::default() };
s.paras = s.paras.iter().map(|(n, _)| (*n, share.clone())).collect();
let ys = baselines_of(&s);
assert!(((ys[1] - ys[0]).abs() - 22.5).abs() < 1e-6, "{ys:?}");
assert!(((ys[2] - ys[1]).abs() - 15.0).abs() < 1e-6, "{ys:?}");
}
#[test]
fn leading_leaves_out_the_space_between_paragraphs_that_the_lines_already_have() {
// Lines 10.6 apart that are three paragraphs with 5 less after each: auto leading (15.6) less 5.
let para = Para { space_after: -5.0, ..Para::default() };
// The text object keeps auto leading and the spacing, so its lines are where the file's are.
let s = story("one\rtwo\rthree\r", &[0.0, 10.6, 21.2], &para);
assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9);
assert!((leading_of(&s).unwrap() - 15.6).abs() < 1e-9);
// Space after and space before both count; the scale of the frame carries onto the leading.
assert_eq!(leading_of(&s), None);
let ys = baselines_of(&s);
assert!(ys.len() == 3 && ys.windows(2).all(|w| ((w[1] - w[0]).abs() - 10.6).abs() < 1e-6), "{ys:?}");
// Space after and space before both count.
let para = Para { space_after: 4.0, space_before: 2.0, ..Para::default() };
let s = story("one\rtwo\r", &[0.0, 20.0], &para);
assert!((s.line_leading(1.0).unwrap() - 14.0).abs() < 1e-9);
assert!((s.line_leading(0.5).unwrap() - 7.0).abs() < 1e-9);
let ys = baselines_of(&story("one\rtwo\r", &[0.0, 21.6], &para));
assert!(((ys[1] - ys[0]).abs() - 21.6).abs() < 1e-6, "{ys:?}");
}
/// #2031: a Large Canvas editing copy measures its text document in its art's units, a tenth
/// of the page's: the story's size and leading reach the page through `to_doc`'s scale.
#[test]
fn a_story_in_a_scaled_art_space_keeps_its_size_and_leading() {
let s = story("one\rtwo\r", &[0.0, 20.0], &Para::default());
let s = story("one\rtwo\r", &[0.0, 15.6], &Para::default());
// A copy written at 1:10 (see `ai::read::ensure_space`): its art's units reach the
// document, 100 points to the page, through `to_doc`'s scale.
let to_doc = Affine::scale(10.0) * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]);
@@ -1909,24 +2150,18 @@ mod tests {
_ => panic!("not text"),
};
assert_eq!(t.runs[0].style.size, 130.0, "13 pt at the page's scale");
assert_eq!(t.runs[0].style.leading, Some(200.0), "a 20-unit line pitch at the page's scale");
assert_eq!(t.runs[0].style.leading, None, "auto leading: 120% of its size at the page's scale");
assert_eq!(t.para.justification.auto_leading, 120.0);
}
#[test]
fn leading_between_lines_of_a_paragraph_is_their_distance() {
// A line break inside a paragraph has no paragraph spacing between its lines.
let para = Para { space_after: -5.0, space_before: 3.0, ..Para::default() };
let s = story("one\x03two\x03three\r", &[0.0, 15.6, 31.2], &para);
assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9);
// Paragraphs without spacing: the distance as it was.
let s = story("one\rtwo\r", &[0.0, 15.6], &Para::default());
assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9);
// Area type whose paragraph wraps: lines and breaks don't pair up, so nothing is taken off.
let para = Para { space_after: -5.0, ..Para::default() };
let s = story("one two three\rfour\r", &[0.0, 15.6, 31.2, 41.8], &para);
assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9);
// One line has no distance to read.
assert_eq!(story("one\r", &[0.0], &Para::default()).line_leading(1.0), None);
let ys = baselines_of(&story("one\x03two\x03three\r", &[0.0, 15.6, 31.2], &para));
assert!(ys.len() == 3 && ys.windows(2).all(|w| ((w[1] - w[0]).abs() - 15.6).abs() < 1e-6), "{ys:?}");
// One line.
assert_eq!(baselines_of(&story("one\r", &[0.0], &Para::default())).len(), 1);
}
/// A `\x03` ends a line inside its paragraph: it comes in as a forced line break, and the
@@ -1970,6 +2205,32 @@ mod tests {
assert!(!story("Heading\r", &[], &Para::default()).fit_first_line(&mut t) && height(&t).unwrap().3 == 13.0);
}
/// The file sets three lines in a frame whose third line's descent passes its bottom: the frame
/// grows from its bottom so the third shows. Only when the story's one frame is area type.
#[test]
fn the_last_lines_the_file_sets_in_its_frame_show() {
let db = vectorcraft_text::FontDb::global();
let area = |height: f64| {
let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle { size: 13.0, ..CharStyle::default() });
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 20.0 + height)) };
t
};
let tall = vectorcraft_text::layout(db, &area(400.0));
let third = &tall.lines[2];
let mut t = area(third.baseline - 20.0 + third.descent / 2.0);
assert_eq!(vectorcraft_text::layout(db, &t).lines.len(), 2);
let mut s = story("one\rtwo\rthree\r", &[0.0, 15.6, 31.2], &Para::default());
// A point-type frame (the helper's) isn't fitted.
assert!(!s.fit_last_lines(&mut t));
s.frames = vec![FrameKind::Area(vec![], AreaFrame::default())];
assert!(s.fit_last_lines(&mut t));
assert_eq!(vectorcraft_text::layout(db, &t).lines.len(), 3);
// A story threaded through two frames: its lines are the first frame's alone.
let mut t = area(third.baseline - 20.0 + third.descent / 2.0);
s.frames.push(FrameKind::Area(vec![], AreaFrame::default()));
assert!(!s.fit_last_lines(&mut t));
}
#[test]
fn area_type_s_first_line_goes_where_the_file_lays_it_out() {
// GUARDS: area type's first baseline was the font's ascent below the frame's top, where the
+1 -1
View File
@@ -54,7 +54,7 @@ use build::{Frame, MADE, prune_unseen};
use compare::{
MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour,
};
pub use layers::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, layered_ai, layered_ai_from};
pub use layers::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, layered_ai, layered_ai_from};
pub(super) use layers::{layered, program_alone};
pub(crate) use slots::MAX_TEXT;
pub use slots::is_loss;
+7 -5
View File
@@ -49,7 +49,7 @@ pub(super) struct Frame<'a> {
/// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and
/// nothing is compared. `outlined`: the page's type was read as outlines, so it has none to put in
/// the text objects. `unit`: the size in points of a unit of the page's user space (see
/// [`layered_ai`]).
/// [`layered_ai`]). `names`: the encoding of names that aren't UTF-8.
pub(super) fn build(
data: &[u8],
visible: Option<&Document>,
@@ -57,11 +57,12 @@ pub(super) fn build(
outlined: bool,
trust: bool,
unit: f64,
names: ai::NameEncoding,
) -> Result<(Document, Vec<String>), String> {
// An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art
// space, as its PDF part may (#2031).
let large_canvas = matches!(page, Page::Art);
let structure = if visible.is_some() { ai::read_over_page(data, large_canvas)? } else { ai::read(data, large_canvas)? };
let structure = if visible.is_some() { ai::read_over_page(data, large_canvas, names)? } else { ai::read_in(data, large_canvas, names)? };
build_from(structure, data, visible, page, outlined, trust, unit)
}
@@ -69,9 +70,10 @@ pub(super) fn build(
/// (width and height, either way round) points large in units of `unit` points: the document is
/// drawn `unit` times as large when its first artboard, or its art's box, has the page's size in
/// those units and not in points, as [`build_from`] decides with a page; else as many times as
/// large as its declared large canvas scale when it has the page's size at that scale.
pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result<(Document, Vec<String>), String> {
let structure = ai::read(data, false)?;
/// large as its declared large canvas scale when it has the page's size at that scale. `names`: the
/// encoding of names that aren't UTF-8.
pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64, names: ai::NameEncoding) -> Result<(Document, Vec<String>), String> {
let structure = ai::read_in(data, false, names)?;
let board = structure.artboard.map(|a| structure.to_doc.transform_rect_bbox(a));
let art = structure.bbox.map(|[a, b, c, d]| structure.to_doc.transform_rect_bbox(Rect::new(a, b, c, d)));
let (w, h) = page;
+91 -56
View File
@@ -11,10 +11,12 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect
/// ([`use_symbols`]) and its variable library ([`use_variables`]) when it could be read, and with
/// its layer tree, empty, below the page's layer when that could be read (#2033). A program in the
/// legacy format without layers (`illustrator`, see [`program_alone`]) is read with its art on one
/// layer; when that can't be used, the file is `visible` as it was.
pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool) -> Imported {
/// layer; when that can't be used, the file is `visible` as it was. `names`: the encoding of names
/// that aren't UTF-8.
pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool, names: ai::NameEncoding) -> Imported {
let Some(data) = editing_copy(ps) else {
let read = illustrator.then(|| one_layer(ps)).flatten().and_then(|p| build(&p, Some(&visible.document), Page::Art, false, trust, 1.0).ok());
let read =
illustrator.then(|| one_layer(ps)).flatten().and_then(|p| build(&p, Some(&visible.document), Page::Art, false, trust, 1.0, names).ok());
return match read {
Some((document, warnings)) => with_page_notes(document, warnings, &visible),
None => visible,
@@ -24,7 +26,8 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus
// can't be used.
// Private data kept apart from the program: the program's own header gives the author and
// custom colors the private data's header may lack.
let read = |d: &[u8]| if matches!(data, Ok(Cow::Owned(_))) { ai::read_beside(d, true, ps, true) } else { ai::read_over_page(d, true) };
let read =
|d: &[u8]| if matches!(data, Ok(Cow::Owned(_))) { ai::read_beside(d, true, ps, true, names) } else { ai::read_over_page(d, true, names) };
let (tree, result) = match data.as_deref() {
Ok(d) => match read(d) {
Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)),
@@ -37,12 +40,14 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus
Err(why) => {
let mut v = visible;
if let Ok(data) = &data {
let mut read = ai::Names::new(names);
let have = v.document.patterns.len();
let own_palette = use_palette(data, &mut v.document);
use_brushes(data, &mut v.document, have, &mut v.warnings);
use_styles(data, &mut v.document, true, 1.0, None);
use_symbols(data, &mut v.document, own_palette, &mut v.warnings);
use_variables(data, &mut v.document, &mut v.warnings);
let own_palette = use_palette(data, &mut v.document, &mut read);
use_brushes(data, &mut v.document, have, &mut v.warnings, &mut read);
use_styles(data, &mut v.document, true, 1.0, None, &mut read);
use_symbols(data, &mut v.document, own_palette, &mut v.warnings, &mut read);
use_variables(data, &mut v.document, &mut v.warnings, &mut read);
add_notes(&mut v.warnings, read.notes());
}
if tree.is_empty() {
v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer"));
@@ -112,12 +117,13 @@ fn layer_tree(nodes: &[Arc<Node>]) -> Vec<Arc<Node>> {
/// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`,
/// by which its art came in scaled. Editing data whose artboard matches the page only in those
/// units comes in `unit` times as large, and so does the artboard layout `place_pages` takes from
/// it.
/// it. Names that aren't UTF-8 are read as Windows-1252 ([`layered_ai_from`]).
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool, trust: bool, unit: f64) -> (Document, Vec<String>, bool) {
layered_ai_from(AiLayers::decode(private), visible, warnings, outlined, trust, unit)
layered_ai_from(AiLayers::decode(private), visible, warnings, outlined, trust, unit, ai::NameEncoding::default())
}
/// [`layered_ai`] of the editing copy `layers`, as far as it was already decoded and read.
/// [`layered_ai`] of the editing copy `layers`, as far as it was already decoded and read, with the
/// names that aren't UTF-8 read in `names`.
pub fn layered_ai_from(
layers: AiLayers,
visible: Document,
@@ -125,6 +131,7 @@ pub fn layered_ai_from(
outlined: bool,
trust: bool,
unit: f64,
names: ai::NameEncoding,
) -> (Document, Vec<String>, bool) {
let unread = |mut all: Vec<String>, why: &str| {
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
@@ -137,34 +144,41 @@ pub fn layered_ai_from(
};
if !has_layers(&data) {
let (mut visible, mut warnings) = (visible, warnings);
let (boards, active) = ai::artboards(&data);
let (boards, active) = ai::artboards(&data, &mut ai::Names::default());
if board_scale(&boards, &visible, unit).is_some() {
visible.opened_artboard = active_page(active, &visible);
}
let mut read = ai::Names::new(names);
let have = visible.patterns.len();
let own_palette = use_palette(&data, &mut visible);
use_brushes(&data, &mut visible, have, &mut warnings);
use_styles(&data, &mut visible, false, unit, Some(&boards));
use_symbols(&data, &mut visible, own_palette, &mut warnings);
use_variables(&data, &mut visible, &mut warnings);
let own_palette = use_palette(&data, &mut visible, &mut read);
use_brushes(&data, &mut visible, have, &mut warnings, &mut read);
use_styles(&data, &mut visible, false, unit, Some(&boards), &mut read);
use_symbols(&data, &mut visible, own_palette, &mut warnings, &mut read);
use_variables(&data, &mut visible, &mut warnings, &mut read);
add_notes(&mut warnings, read.notes());
return (visible, warnings, false);
}
// The PDF part, its pages placed and named as the editing data's artboards are, when the
// layers can't be used (`why`).
let from_pdf_part = |mut visible: Document, mut warnings: Vec<String>, why: &str| {
let (boards, active) = ai::artboards(&data);
place_pages(&boards, active, &mut visible, unit);
let mut read = ai::Names::new(names);
let mut board_names = ai::Names::new(names);
let (boards, active) = ai::artboards(&data, &mut board_names);
if place_pages(&boards, active, &mut visible, unit) {
read.absorb(board_names);
}
let have = visible.patterns.len();
let own_palette = use_palette(&data, &mut visible);
use_brushes(&data, &mut visible, have, &mut warnings);
use_styles(&data, &mut visible, false, unit, Some(&boards));
let own_palette = use_palette(&data, &mut visible, &mut read);
use_brushes(&data, &mut visible, have, &mut warnings, &mut read);
use_styles(&data, &mut visible, false, unit, Some(&boards), &mut read);
let mut all = unread(warnings, why);
use_symbols(&data, &mut visible, own_palette, &mut all);
use_variables(&data, &mut visible, &mut all);
use_symbols(&data, &mut visible, own_palette, &mut all, &mut read);
use_variables(&data, &mut visible, &mut all, &mut read);
add_notes(&mut all, read.notes());
(visible, all, false)
};
let built = structure
.unwrap_or_else(|| ai::read_over_page(&data, false).map(Box::new))
.unwrap_or_else(|| ai::read_over_page(&data, false, names).map(Box::new))
.and_then(|structure| build_from(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit));
match built {
Ok((mut done, notes)) => {
@@ -183,8 +197,8 @@ pub fn layered_ai_from(
/// reading it, when the file's text document makes all of its type and nothing in its layers needs
/// the PDF part (see `build_without_page`). Nothing is compared with the PDF part. Else, as the
/// error, what was decoded and read of the editing copy, for [`layered_ai_from`] once the PDF part
/// is imported.
pub fn ai_layers_alone(private: &[u8], pages: &[(f64, f64)], unit: f64) -> Result<(Document, Vec<String>), AiLayers> {
/// is imported. `names`: the encoding of names that aren't UTF-8.
pub fn ai_layers_alone(private: &[u8], pages: &[(f64, f64)], unit: f64, names: ai::NameEncoding) -> Result<(Document, Vec<String>), AiLayers> {
let data = match ai::decode_private(private) {
Ok(data) => data,
Err(why) => return Err(AiLayers { data: Err(why), structure: None }),
@@ -193,7 +207,7 @@ pub fn ai_layers_alone(private: &[u8], pages: &[(f64, f64)], unit: f64) -> Resul
return Err(AiLayers { data: Ok(data), structure: None });
}
// An image past the hard limits is read as an unread box, which the PDF part's art replaces.
let structure = match ai::read_over_page(&data, false) {
let structure = match ai::read_over_page(&data, false, names) {
Ok(structure) => structure,
Err(why) => return Err(AiLayers { data: Ok(data), structure: Some(Err(why)) }),
};
@@ -234,9 +248,10 @@ pub fn ai_opened_artboard(private: &[u8], doc: &Document) -> Option<u32> {
/// sets, active data set and lock of the variable library of its editing data `data`, as [`build`]
/// gives a document read from the layers, and the note about what of the library wasn't read
/// added to `warnings`: its bindings aren't, as the objects they bind aren't the layers' objects.
/// Left as it is when the editing data has no library.
fn use_variables(data: &[u8], doc: &mut Document, warnings: &mut Vec<String>) {
let read = ai::variables(data);
/// Left as it is when the editing data has no library. `names` reads the library's names and keeps
/// the notes on them.
fn use_variables(data: &[u8], doc: &mut Document, warnings: &mut Vec<String>, names: &mut ai::Names) {
let read = ai::variables(data, names);
warnings.extend(read.note());
warnings.extend(ai::bindings_note(read.bindings.len() + read.bindings_skipped));
if read.present {
@@ -250,9 +265,10 @@ fn use_variables(data: &[u8], doc: &mut Document, warnings: &mut Vec<String>) {
/// palette doesn't have follow the palette's swatches. The patterns that the editing data defines come
/// in for the palette's pattern swatches, with the images they show; one whose name a pattern of
/// the PDF part already has takes a free variant of it ([`ai::palette`]). Left as
/// it is when the palette is empty. Whether the palette's swatches came in.
fn use_palette(data: &[u8], doc: &mut Document) -> bool {
let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns) else { return false };
/// it is when the palette is empty. Whether the palette's swatches came in. `names` reads the
/// palette's names and keeps the notes on those the swatches take.
fn use_palette(data: &[u8], doc: &mut Document, names: &mut ai::Names) -> bool {
let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns, names) else { return false };
for (key, blob) in images {
doc.images.entry(key).or_insert(blob);
}
@@ -269,15 +285,25 @@ fn use_palette(data: &[u8], doc: &mut Document) -> bool {
true
}
/// `notes` added to `warnings`, those it already has left out.
fn add_notes(warnings: &mut Vec<String>, notes: Vec<String>) {
for n in notes {
if !warnings.contains(&n) {
warnings.push(n);
}
}
}
/// `doc` (a file read as its page or PDF part, in place of its layers) with the brushes of its
/// editing data `data` in place of the default ones, as [`build`] gives a document read from the
/// layers, the patterns of the editing data their art paints with (named apart from the first
/// `have` patterns of `doc`, those of the page or PDF part, as [`use_palette`] names them, which
/// may have added some of them already), and the note for the brushes left out added to
/// `warnings`. Left as it is when the editing data has no brushes.
fn use_brushes(data: &[u8], doc: &mut Document, have: usize, warnings: &mut Vec<String>) {
/// `warnings`. Left as it is when the editing data has no brushes. `names` reads the brushes' names
/// and keeps the notes on them.
fn use_brushes(data: &[u8], doc: &mut Document, have: usize, warnings: &mut Vec<String>, names: &mut ai::Names) {
let page = doc.patterns.get(..have).unwrap_or(&doc.patterns);
let Some(ai::Brushes { brushes, patterns, images, note }) = ai::brushes(data, page) else { return };
let Some(ai::Brushes { brushes, patterns, images, note }) = ai::brushes(data, page, names) else { return };
for (key, blob) in images {
doc.images.entry(key).or_insert(blob);
}
@@ -299,9 +325,10 @@ fn use_brushes(data: &[u8], doc: &mut Document, have: usize, warnings: &mut Vec<
/// the size of `doc`'s pages in units of `unit` points, else as many times as large as the data's
/// declared large canvas scale when they are the pages' size at that scale ([`board_scale`]).
/// `boards`: the editing data's artboards when they were already read. Left as it is when the
/// editing data has no style this reads.
fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, boards: Option<&[(String, Rect)]>) {
let (mut styles, canvas) = ai::graphic_styles(data, large_canvas);
/// editing data has no style this reads. `names` reads the styles' names and keeps the notes on
/// them.
fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, boards: Option<&[(String, Rect)]>, names: &mut ai::Names) {
let (mut styles, canvas) = ai::graphic_styles(data, large_canvas, names);
if styles.is_empty() {
return;
}
@@ -310,7 +337,7 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo
let boards = match boards {
Some(b) => b,
None => {
read = ai::artboards(data).0;
read = ai::artboards(data, &mut ai::Names::default()).0;
&read
}
};
@@ -336,12 +363,12 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo
/// symbols' art keeps the editing data's units, as in a document read from the layers: when
/// [`use_styles`] draws the styles' strokes and effects larger (a file in larger units), linked art
/// isn't scaled with them. Left as it is when `doc` has symbols already or the editing data has
/// none this reads.
fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mut Vec<String>) {
/// none this reads. `names` reads the symbols' names and keeps the notes on them.
fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mut Vec<String>, names: &mut ai::Names) {
if !doc.symbols.is_empty() {
return;
}
let Some(ai::Symbols { symbols, images, swatches }) = ai::symbols(data) else { return };
let Some(ai::Symbols { symbols, images, swatches }) = ai::symbols(data, names) else { return };
for (key, blob) in images {
doc.images.entry(key).or_insert(blob);
}
@@ -414,14 +441,14 @@ fn board_scale(boards: &[(String, Rect)], doc: &Document, unit: f64) -> Option<f
/// read (#1068); the page of the artboard at `active` (the active one) is the active artboard. Left
/// as they are unless there are as many artboards as pages, each the size of its page in points,
/// or, when `unit` is above 1, each the size of its page in units of `unit` points: then the layout
/// is drawn `unit` times as large.
fn place_pages(boards: &[(String, Rect)], active: Option<usize>, doc: &mut Document, unit: f64) {
let Some(k) = board_scale(boards, doc, unit) else { return };
/// is drawn `unit` times as large. Whether the pages took the artboards' names.
fn place_pages(boards: &[(String, Rect)], active: Option<usize>, doc: &mut Document, unit: f64) -> bool {
let Some(k) = board_scale(boards, doc, unit) else { return false };
for ((name, _), a) in boards.iter().zip(&mut doc.artboards) {
a.name.clone_from(name);
}
doc.opened_artboard = active_page(active, doc);
let [(_, first), _, ..] = boards else { return };
let [(_, first), _, ..] = boards else { return true };
// Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it,
// `k` points a unit.
let to_doc = Affine::new([k, 0.0, 0.0, -k, -first.x0 * k, first.y1 * k]);
@@ -433,6 +460,7 @@ fn place_pages(boards: &[(String, Rect)], active: Option<usize>, doc: &mut Docum
for (a, v) in doc.artboards.iter_mut().zip(&moves) {
a.rect = a.rect + *v;
}
true
}
/// The id of the page of `doc` (a `.ai` read as its PDF part: a page per artboard) for the artboard
@@ -464,19 +492,25 @@ fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) {
/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
/// Editing data written on a large canvas comes in at its declared scale: no page holds the size.
/// Names that aren't UTF-8 are read as Windows-1252 ([`ai_alone_with`]).
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
ai_alone_with(private, ai::NameEncoding::default())
}
/// [`ai_alone`], with the names that aren't UTF-8 read in `names`.
pub fn ai_alone_with(private: &[u8], names: ai::NameEncoding) -> Result<(Document, Vec<String>), String> {
let data = ai::decode_private(private)?;
build_from(ai::read(&data, true)?, &data, None, Page::Artboard, false, false, 1.0)
build_from(ai::read_in(&data, true, names)?, &data, None, Page::Artboard, false, false, 1.0)
}
/// [`ai_alone`] for a `.ai` whose PDF part can't be read, its first page `page` (width and height)
/// points large in units of `unit` points (its `UserUnit`): editing data whose first artboard, or
/// whose art's box, has the page's size in those units, and not in points, comes in `unit` times as
/// large, as [`layered_ai`] reads it. A page turned a quarter (`/Rotate 90`) is measured either
/// way round.
pub fn ai_alone_on_page(private: &[u8], page: (f64, f64), unit: f64) -> Result<(Document, Vec<String>), String> {
/// way round. `names`: the encoding of names that aren't UTF-8.
pub fn ai_alone_on_page(private: &[u8], page: (f64, f64), unit: f64, names: ai::NameEncoding) -> Result<(Document, Vec<String>), String> {
let data = ai::decode_private(private)?;
build_on_page(&data, page, unit)
build_on_page(&data, page, unit, names)
}
/// Does the editing data (or a program in the legacy format) have layers?
@@ -499,15 +533,16 @@ fn editing_copy(ps: &[u8]) -> Option<Result<Cow<'_, [u8]>, String>> {
/// but whose program has no layers (Illustrator 3 had none; image editors still export their
/// paths so, storytold/photocraft#6036) is read with its art on one layer. `None` when the program
/// has no layers and isn't in the format: other PostScript isn't read as the format's operators.
pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool) -> Option<Result<(Document, Vec<String>), String>> {
/// `names`: the encoding of names that aren't UTF-8.
pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool, names: ai::NameEncoding) -> Option<Result<(Document, Vec<String>), String>> {
if ai::has_eps_data(ps) {
return None;
}
if has_layers(ps) {
return Some(build(ps, None, Page::Art, false, false, 1.0));
return Some(build(ps, None, Page::Art, false, false, 1.0, names));
}
let program = illustrator.then(|| one_layer(ps)).flatten()?;
Some(build(&program, None, Page::Art, false, false, 1.0))
Some(build(&program, None, Page::Art, false, false, 1.0, names))
}
/// `ps` with its art (from its `%%EndSetup` to its `%%PageTrailer`, else its `%%Trailer`, else its
+15 -1
View File
@@ -59,7 +59,7 @@ pub(super) fn make_slots(
to_doc: Affine,
warn: &mut Vec<String>,
) -> Vec<Option<Made>> {
let mut taller = 0;
let (mut taller, mut deeper, mut tabs_left_out) = (0, 0, false);
let made = infos
.iter()
.enumerate()
@@ -67,6 +67,7 @@ pub(super) fn make_slots(
let t = template?;
let story = stories.get(k)?.as_ref()?;
let mut node = story.node(NodeId(0), info.frame, t, to_doc)?;
tabs_left_out |= story.tabs_left_out();
let mut region = story.region(t, to_doc);
// The first line where the file has it, in the text document's frame (that of a
// copied object is its original's, as its lines are).
@@ -80,6 +81,11 @@ pub(super) fn make_slots(
{
taller += 1;
}
if let NodeKind::Text(text) = &mut node.kind
&& story.fit_last_lines(text)
{
deeper += 1;
}
Some((Box::new(node), region))
})
.collect();
@@ -88,6 +94,14 @@ pub(super) fn make_slots(
"area type in frames too short for a line of the font it shows in got taller frames ({taller}): their type wouldn't show otherwise"
));
}
if deeper > 0 {
warn.push(format!(
"area type whose last lines the file sets in frames too short for them here got taller frames ({deeper}): those lines wouldn't show otherwise"
));
}
if tabs_left_out {
warn.push(crate::import::ate::TABS_LEFT_OUT.into());
}
made
}
+113
View File
@@ -1411,6 +1411,89 @@ fn an_ai_file_without_layers_keeps_the_gradients_of_its_palette_unlinked() {
assert_eq!(g.gradient.stops.len(), 2);
}
/// A file that comes in as its PDF part reads the palette's names that aren't UTF-8 in the encoding
/// picked, with the notes on them; a layer name that isn't UTF-8, in layers that aren't used, gets
/// none.
#[test]
fn an_ai_file_that_comes_in_as_its_pdf_part_notes_its_palette_names_in_a_legacy_encoding() {
let image = "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 1 0 0 1 0 100 ] 6000 6000 0 Xh\n\
[ 1 0 0 1 0 100 ] 0 0 6000 6000 6000 6000 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN";
// 빨강 (red) in code page 949, for the group and its ink; 그라 for the layer.
let palette = PALETTE.replace("Inks", r"\273\241\260\255").replace("Red ink", r"\273\241\260\255 ink");
let art = native(&format!("{palette}{}", layer(r"\261\327\266\363", true, &format!("{}\n{image}", square(10, 10)))));
// An image past 2^25 pixels isn't read here, and the page has no art of its own in its box.
let (d, warnings, read) = crate::import::ai::with_image_limits(1 << 26, 1 << 25, 512 << 20, || {
crate::layered_ai_from(crate::AiLayers::decode(&private(&art)), pdf_part(10.0, 86.0), vec![], false, true, 1.0, crate::NameEncoding::Korean)
});
assert!(!read, "the PDF part is used: {warnings:?}");
let groups: Vec<&str> = d.swatch_groups.iter().map(|g| g.name.as_str()).collect();
assert_eq!(groups, ["빨강"]);
assert!(d.swatch("빨강 ink").is_some_and(|s| s.spot));
let notes: Vec<&String> = warnings.iter().filter(|w| w.contains("isn't UTF-8")).collect();
assert_eq!(notes.len(), 2, "{warnings:?}");
assert!(notes.iter().all(|n| n.contains("windows-949") && !n.contains("그라")), "{notes:?}");
}
/// A library without layers reads the names of its brushes, graphic styles, symbols, variables and
/// data sets that aren't UTF-8 in the encoding picked, with a note on each name it brings in.
#[test]
fn an_ai_library_without_layers_reads_its_names_in_a_legacy_encoding() {
// 빨강 and 그라 in code page 949.
let (red, gra) = (r"\273\241\260\255", r"\261\327\266\363");
let art = format!("%AI8_BeginBrushPattern\n({gra})\n0 0 m\n4 0 L\n4 4 L\nf\n%AI8_EndBrushPattern\n");
let spray = format!(
"%AI8_BeginPluginObject\n(Adobe Scatter Brush Tool)\n({red})\n(2 /{gra}/ 5 1 0 0 / 0 0.5 1.5 1 1.2 2 0 -0.25 0.25 2 -45 30 1 1 0) .\n%AI8_EndPluginObject\n"
);
let order = format!(
"%AI8_BeginPluginObject\n(Adobe Brush Manager Order)\n(Adobe Brush Manager Order)\n( Adobe Scatter Brush Tool/ {red}/) .\n%AI8_EndPluginObject\n"
);
let styles =
format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n({red})\n%AI9_EndArtStyleList\n", stroke_art_style(red, 1.0, ""));
let symbols = format!("%AI5_Begin_NonPrinting\n{}%AI5_End_NonPrinting--\n", symbol_def(gra, &symbol_square(-2.0, -2.0, 2.0, 2.0), 1));
let variables = vectorcraft_testkit::ai::variable_library(&["QVARQ"], &["QSETQ"]).replace("QVARQ", red).replace("QSETQ", gra);
let library = format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{art}{order}{spray}{styles}{symbols}{variables}%%EndSetup\n%%PageTrailer\n%%Trailer\n"
);
let (d, warnings, read) = crate::layered_ai_from(
crate::AiLayers::decode(&private(&library)),
pdf_part(10.0, 86.0),
vec![],
false,
true,
1.0,
crate::NameEncoding::Korean,
);
assert!(!read, "{warnings:?}");
assert_eq!(vectorcraft_brush::library(&d).iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["빨강"], "{warnings:?}");
assert_eq!(d.graphic_styles.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["빨강"], "{warnings:?}");
assert_eq!(d.symbols.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["그라"], "{warnings:?}");
assert_eq!(d.variables.variables.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["빨강"], "{warnings:?}");
assert_eq!(d.variables.datasets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["그라"], "{warnings:?}");
let notes: Vec<&String> = warnings.iter().filter(|w| w.contains("isn't UTF-8") || w.contains("aren't UTF-8")).collect();
assert_eq!(notes.len(), 2, "{warnings:?}");
assert!(notes.iter().all(|n| n.contains("windows-949")), "{notes:?}");
}
/// A pattern of a file without layers that takes a free name beside a pattern of the PDF part has
/// a note on the name it was read under.
#[test]
fn a_renamed_pattern_of_a_file_without_layers_has_a_note_on_its_legacy_name() {
// 빨강 in code page 949.
let red = r"\273\241\260\255";
let tile = format!("%AI3_BeginPattern: ({red})\n({red}) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n");
let palette = format!("%AI5_BeginPalette\n0 0 Pb\n({red}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Tile)\nPc\nPB\n%AI5_EndPalette\n");
let art = native(&format!("{tile}{palette}"));
let mut part = pdf_part(10.0, 86.0);
part.patterns.push(vectorcraft_doc::PatternDef::new("빨강", vec![]));
let (d, warnings, read) =
crate::layered_ai_from(crate::AiLayers::decode(&private(&art)), part, vec![], false, true, 1.0, crate::NameEncoding::Korean);
assert!(!read, "{warnings:?}");
assert!(d.pattern("빨강 2").is_some(), "{:?}", d.patterns.iter().map(|p| &p.name).collect::<Vec<_>>());
let notes: Vec<&String> = warnings.iter().filter(|w| w.contains("isn't UTF-8")).collect();
assert_eq!(notes.len(), 1, "{warnings:?}");
assert!(notes[0].contains("`빨강`"), "{notes:?}");
}
/// A `.ai` pattern library (a palette of pattern swatches and no layers) whose PDF part defines no
/// pattern gets the patterns its editing data defines with the palette's pattern swatches.
#[test]
@@ -5691,6 +5774,36 @@ fn type_with_convert_to_shape_comes_in_as_type_with_it_live() {
);
}
#[test]
fn type_with_a_variable_width_stroke_keeps_it_live() {
// GUARDS: other objects with a variable-width stroke come in as their drawn look; type keeps the
// stroke, since its drawn look can't be retyped.
use vectorcraft_doc::AppearanceItem;
let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let style = "%AI9_BeginArtStyles\n/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 1 0 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n0.5 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n0 /Real ,\n1 /Real ,\n0 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n";
let drawing = "0 0 0 1 k\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf";
let look = format!("u\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n{drawing}\nU\n9 () XW\n");
let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect();
let art = native_with_text(&(style.to_string() + &layer("Art", true, &format!("{look}{own}1 (Tapered) XW\n"))), &doc);
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
let mut texts = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
texts.push((t.plain_text(), n.appearance.clone()));
}
});
let [(text, a)] = texts.as_slice() else { panic!("{texts:?} {notes:?}") };
assert_eq!(text, "Bang");
let [AppearanceItem::Stroke(stroke)] = a.items.as_slice() else { panic!("{a:?}") };
assert_eq!(stroke.profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]));
assert!(!notes.iter().any(|w| w.contains("drawn look")), "{notes:?}");
}
#[test]
fn point_type_without_a_matrix_is_placed_by_its_layout() {
// GUARDS: Illustrator 24 leaves an unturned, unscaled point type's matrix out of its frame; such
+2 -2
View File
@@ -44,8 +44,8 @@ mod tiff;
pub use ai_editing::{AiEditingOptions, AiEditingOutput, CREATOR_VERSION, export_ai_editing, private_data as ai_private_data};
pub use import::{
AiLayers, Imported, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, family_style, import, import_trusting,
import_with, is_loss, layered_ai, layered_ai_from, page,
AiLayers, Imported, NameEncoding, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard,
family_style, import, import_names, import_trusting, import_with, is_loss, layered_ai, layered_ai_from, page,
};
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai, writes_image_mask};
pub use print::{PrintJob, PrintPage, print};
+7 -4
View File
@@ -173,6 +173,8 @@ impl Headless {
UiRequest::Place(p) => {
self.session.execute("file.place", &p).map_err(|e| e.to_string())?;
}
// The host keeps its view's artboard on a press, as for the presses it hands over.
UiRequest::HeldPress { .. } => {}
}
}
Ok(())
@@ -204,10 +206,11 @@ impl Headless {
};
let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut requests)?;
// With no frames to show the highlight in later, a hover waits for the work the tool
// runs on another thread.
if kind == PointerKind::Move && self.session.tool_waiting() {
self.session.tool_poll(self.view, true).map_err(|e| e.to_string())?;
// With no frames to show the highlight or apply an edit in later, each event waits for
// the work the tool runs on another thread (a hover's map, a release's merge).
if self.session.tool_waiting() {
let reqs = self.session.tool_poll(self.view, true).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut requests)?;
}
let hold = PointerEvent::json_hold(e);
if hold > 0.0 {
+9
View File
@@ -498,6 +498,15 @@ fn headless_hover_waits_for_the_shape_builder_map() {
assert!(!r.is_error, "{r:?}");
assert!(!h.session.tool_waiting());
assert!(!h.session.overlays(h.view).is_empty(), "the region under the pointer is highlighted");
// A click's reply comes once the merge the tool computes on another thread is applied, with
// the selection after it.
let click = json!({"events": [{"kind": "down", "x": 115, "y": 110}, {"kind": "up", "x": 115, "y": 110}]});
let r = crate::call_tool(&mut h, "pointer_gesture", &click);
assert!(!r.is_error, "{r:?}");
assert!(!h.session.tool_waiting() && !h.session.tool_editing());
let text: String = r.content.iter().filter_map(|c| c["text"].as_str()).collect();
let reply: Value = serde_json::from_str(&text).unwrap();
assert_eq!(reply["selection"].as_array().map(Vec::len), Some(41), "the overlap and the 40 squares, two of them cut: {reply}");
}
/// inspect_document reads back the paint type's characters show, as add_text and set_paint give it.
+32
View File
@@ -44,6 +44,38 @@ fn an_option_no_format_takes_is_an_error() {
assert!(!r.is_error, "{r:?}");
}
/// `app.save`, `file.save` and `file.exportForScreens` refuse a key no format takes before they
/// write anything.
#[test]
fn a_save_or_screens_option_no_format_takes_is_an_error() {
let dir = std::env::temp_dir().join(format!("vc-mcp-save-options-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 50, "height": 30}})).unwrap();
let pdf = dir.join("a.pdf").to_string_lossy().into_owned();
for (params, named) in [
(json!({"path": pdf, "noSuchOption": true}), "noSuchOption"),
(json!({"path": pdf, "options": {"marks": {"useDocumentBleed": true}}}), "options.marks.useDocumentBleed"),
] {
let e = h.call("app.save", params.clone()).unwrap_err();
assert!(e.contains(named), "app.save: {e}");
let e = h.call("engine.execute", json!({"command": "file.save", "params": params})).unwrap_err();
assert!(e.contains(named), "file.save: {e}");
}
let folder = dir.join("screens").to_string_lossy().into_owned();
for (params, named) in [
(json!({"folder": folder, "noSuchOption": true}), "noSuchOption"),
(json!({"folder": folder, "formats": [{"format": "pdf", "marks": {"useDocumentBleed": true}}]}), "formats[0].marks.useDocumentBleed"),
] {
let e = h.call("engine.execute", json!({"command": "file.exportForScreens", "params": params})).unwrap_err();
assert!(e.contains(named), "file.exportForScreens: {e}");
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "nothing was written");
h.call("app.save", json!({"path": pdf})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn raster_export_uses_document_resolution_unless_overridden() {
let mut h = Headless::new();
+3
View File
@@ -15,6 +15,9 @@ vectorcraft-doc = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
# The pixels of a small image a filter asks to read, as RGBA in base64.
image = { workspace = true }
craft-vectorcraft-format = { workspace = true }
# The SHA-256 of a module, which the install prompt shows and a grant records.
sha2 = { version = "0.10", default-features = false }
thiserror = { workspace = true }
+110 -13
View File
@@ -174,7 +174,25 @@ fn weight(key: &Key, out: Option<&Out>) -> usize {
};
anchors(&key.path) + out + (key.params.len() + key.paints.len()) / 50 + 1
}
static ERRORS: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
static ERRORS: Mutex<Option<HashMap<String, Failures>>> = Mutex::new(None);
/// The errors recorded for a live-effect plug-in, one for each kind of outcome that can clear it.
#[derive(Default)]
struct Failures {
/// From a run of the plug-in.
run: Option<String>,
/// From checking the swatches and patterns a painted result names against the document.
references: Option<String>,
/// Was the latest failure a refused reference?
references_latest: bool,
}
/// Which of a plug-in's [`Failures`] an outcome replaces.
#[derive(Clone, Copy)]
enum Origin {
Run,
References,
}
/// Where an installation ([`crate::Plugin::serial`]) stands as a live effect in this process.
enum Runs {
@@ -227,21 +245,42 @@ fn end_run(serial: u64, cancelled: bool) {
}
}
/// The last error of live-effect plug-in `id` (cleared when it next succeeds).
/// The last error of live-effect plug-in `id`: the latest failure that still stands. A run that
/// succeeds clears a failed run's error, and a reference check that passes clears a refused
/// reference's.
pub fn last_error(id: &str) -> Option<String> {
ERRORS.lock().unwrap_or_else(|e| e.into_inner()).as_ref()?.get(id).cloned()
let errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner());
let failures = errors.as_ref()?.get(id)?;
let (latest, other) = if failures.references_latest { (&failures.references, &failures.run) } else { (&failures.run, &failures.references) };
latest.clone().or_else(|| other.clone())
}
/// Records how a run of plug-in `id` went: a failure as its last error (a painted effect's named
/// after the plug-in, [`painted::named`]), a success clearing it.
fn note(id: &str, plugin: &crate::Plugin, r: std::result::Result<(), &Error>) {
/// Records how a run of plug-in `id`, or a check of what its result names, went: a failure as that
/// origin's error (a painted effect's named after the plug-in, [`painted::named`]), a success
/// clearing that origin's error. A run of an effect that paints nothing clears a refused
/// reference's error too.
fn note(id: &str, plugin: &crate::Plugin, origin: Origin, r: std::result::Result<(), &Error>) {
let message = r.err().map(|e| if plugin.manifest().paint_output { painted::named(&plugin.manifest().name, e) } else { e.to_string() });
let mut errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner());
let errors = errors.get_or_insert_with(HashMap::new);
match r {
Ok(()) => errors.remove(id),
Err(e) if plugin.manifest().paint_output => errors.insert(id.to_string(), painted::named(&plugin.manifest().name, e)),
Err(e) => errors.insert(id.to_string(), e.to_string()),
};
let failures = errors.entry(id.to_string()).or_default();
if message.is_some() {
failures.references_latest = matches!(origin, Origin::References);
}
match origin {
Origin::Run => {
failures.run = message;
// Nothing checks the references of an effect that paints nothing, such as one that
// replaced a painted effect under this id.
if !plugin.manifest().paint_output {
failures.references = None;
}
}
Origin::References => failures.references = message,
}
if failures.run.is_none() && failures.references.is_none() {
errors.remove(id);
}
}
/// Does live-effect plug-in `id` return painted pieces (its manifest declares `paintOutput`)?
@@ -294,7 +333,7 @@ pub fn apply_painted(
fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>) -> Option<()> {
let checked = painted::check_references(pieces, doc);
if doc.is_some() {
note(id, plugin, checked.as_ref().map(|_| ()));
note(id, plugin, Origin::References, checked.as_ref().map(|_| ()));
}
checked.ok()
}
@@ -334,7 +373,7 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR
if cancelled {
return None;
}
note(id, &plugin, r.as_ref().map(|_| ()));
note(id, &plugin, Origin::Run, r.as_ref().map(|_| ()));
let out = r.ok();
let size = weight(&key, out.as_ref());
if size > CACHE_ANCHORS / 4 {
@@ -377,6 +416,7 @@ fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect,
mod tests {
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{FillLayer, StrokeLayer};
use vectorcraft_geom::{Point, SubPath};
use super::*;
@@ -400,6 +440,63 @@ mod tests {
assert_eq!((none.fills, none.strokes, none.item), (vec![], vec![], json!("object")));
}
/// A result taken from the cache passes its reference check without clearing the error of a
/// failed run, and the next run that succeeds clears that error.
#[test]
fn a_passed_reference_check_keeps_a_failed_run_s_error() {
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::safe_mode::set_active(None);
const ID: &str = "test.effect.lasterror";
let manifest = format!(r#"{{"id":"{ID}","name":"Test","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{{}}}}"#);
// Traps on a long input.
let first = "(if (i32.gt_u (local.get $il) (i32.const 4096)) (then unreachable))";
let src = crate::wat::with_imports(&manifest, "", first, r#"{"objects":[]}"#);
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
let doc = Document::new(100.0, 100.0);
let line = |n: usize| PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::<Vec<_>>(), false));
let draw =
|path: &PathData| apply_painted(ID, &json!({}), path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints::default(), Some(&doc));
let short = line(2);
assert!(draw(&short).is_some());
assert_eq!(last_error(ID), None);
assert!(draw(&line(1000)).is_none());
let failed = last_error(ID).unwrap();
assert!(draw(&short).is_some(), "from the cache");
assert_eq!(last_error(ID), Some(failed));
assert!(draw(&line(3)).is_some(), "a new run");
assert_eq!(last_error(ID), None);
registry::remove(ID);
}
/// With a refused reference and a failed run both standing, the latest of the two is reported.
#[test]
fn the_latest_of_a_refused_reference_and_a_failed_run_is_reported() {
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::safe_mode::set_active(None);
const ID: &str = "test.effect.latesterror";
let manifest = format!(r#"{{"id":"{ID}","name":"Test","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{{}}}}"#);
let mut fill = serde_json::to_value(Paint::solid(Color::WHITE)).unwrap();
fill["swatch"] = json!("Missing");
let square = PathData::single(SubPath::polyline(&[(0.0, 0.0), (9.0, 0.0), (9.0, 9.0)].map(|(x, y)| Point::new(x, y)), true));
let output = json!({"objects": [{"path": square, "fills": [fill]}]}).to_string();
// Traps on a long input.
let first = "(if (i32.gt_u (local.get $il) (i32.const 4096)) (then unreachable))";
let src = crate::wat::with_imports(&manifest, "", first, &output);
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
let doc = Document::new(100.0, 100.0);
let line = |n: usize| PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::<Vec<_>>(), false));
let draw =
|path: &PathData| apply_painted(ID, &json!({}), path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints::default(), Some(&doc));
let short = line(2);
assert!(draw(&short).is_none());
assert!(last_error(ID).unwrap().contains("swatch"));
assert!(draw(&line(1000)).is_none());
assert!(last_error(ID).unwrap().contains("trapped"));
assert!(draw(&short).is_none(), "from the cache, refused again");
assert!(last_error(ID).unwrap().contains("swatch"));
registry::remove(ID);
}
/// A first run that is cancelled leaves the next run to be the first: the launch marker names
/// the plug-in during that one.
#[test]
+3 -2
View File
@@ -18,7 +18,8 @@
//! a module does can panic or hang the host.
//!
//! Plug-ins see the document as JSON ([`objects`]): the selected paths and compound paths with
//! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns
//! their geometry, fills, strokes (gradients included) and opacity, and, for a filter that asks
//! for them ([`InputKind`]), type, images and symbol instances to read. An **object filter** returns
//! the objects to keep, change or add, applied to the document as one edit
//! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each
//! time it is drawn, from an appearance stack, and at `api` 2 may paint what it returns
@@ -49,7 +50,7 @@ pub mod wat;
pub use dialog::ParamUi;
pub use host::{LogLevel, LogLine, Report};
pub use manifest::{Budget, Command, Kind, Manifest, Param, ParamSpec};
pub use manifest::{Budget, Command, InputKind, Kind, Manifest, Param, ParamSpec};
pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms};
/// What the host puts before a plug-in's id to name what it adds: its live effect
+132 -1
View File
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::dialog::ParamUi;
use crate::permissions::{self, API_LEVEL, PROGRESS_API, Permission};
use crate::permissions::{self, API_LEVEL, INPUTS_API, PROGRESS_API, Permission};
use crate::{Error, Result};
/// Longest manifest the host reads.
@@ -54,6 +54,39 @@ impl Kind {
}
}
/// A kind of object a filter may ask to be given besides paths and compound paths (its manifest's
/// `inputs`, plug-in API level 2). The filter reads these objects and returns each one as it was
/// given: it can't change or delete them ([`crate::objects::decode_output_with`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum InputKind {
/// Point, area and path type: its text runs with their character attributes, and its
/// paragraph attributes.
Type,
/// An embedded or linked image: its size, color mode and, when small, its pixels.
Image,
/// A symbol instance: the symbol it shows and its transform.
SymbolInstance,
}
impl InputKind {
/// Every kind, in the order the docs list them.
pub const ALL: [InputKind; 3] = [InputKind::Type, InputKind::Image, InputKind::SymbolInstance];
/// Its name in `inputs`, which is also the `type` of such an object in a filter's input.
pub fn name(self) -> &'static str {
match self {
InputKind::Type => "type",
InputKind::Image => "image",
InputKind::SymbolInstance => "symbolInstance",
}
}
/// The kind named `name`.
pub fn parse(name: &str) -> Option<InputKind> {
InputKind::ALL.into_iter().find(|k| k.name() == name)
}
}
/// How much one run of a plug-in may compute.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -282,6 +315,11 @@ pub struct Manifest {
/// installed. Left out, it gets the ones its kind implies ([`Permission::implied`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<Vec<String>>,
/// The kinds of object a filter asks to be given besides paths and compound paths, by
/// [`InputKind::name`]: only a filter at `api` 2 or later whose `permissions` list `doc.read`
/// lists them. Left out or empty, it is given paths and compound paths alone.
#[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "input_names")]
pub inputs: Vec<String>,
/// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest.
#[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")]
pub params: Vec<(String, Param)>,
@@ -327,6 +365,7 @@ impl Manifest {
author: String::new(),
api: 1,
permissions: None,
inputs: Vec::new(),
params: Vec::new(),
extensions: Vec::new(),
budget: Budget::Light,
@@ -370,6 +409,7 @@ impl Manifest {
let permission = host.name();
return Err(Error::Manifest(format!("permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}")));
}
self.validate_inputs()?;
if self.paint_output && (self.kind != Kind::Effect || self.api < 2) {
return Err(Error::Manifest("`paintOutput` is only for a live effect (`kind` \"effect\") at `api` 2".into()));
}
@@ -405,6 +445,34 @@ impl Manifest {
Ok(())
}
/// `inputs` names each [`InputKind`] at most once, and only a filter at `api` 2 or later lists
/// any, with a `permissions` list that holds `doc.read`: installing it then asks the user to
/// grant them ([`Manifest::asks_permissions`]).
fn validate_inputs(&self) -> Result<()> {
if self.inputs.is_empty() {
return Ok(());
}
if self.kind != Kind::Filter {
return Err(Error::Manifest("only a filter declares `inputs`".into()));
}
for (i, name) in self.inputs.iter().enumerate() {
let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD);
if InputKind::parse(name).is_none() {
return Err(Error::Manifest(format!("`inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance")));
}
if self.inputs.get(..i).is_some_and(|before| before.contains(name)) {
return Err(Error::Manifest(format!("`inputs` lists `{shown}` twice")));
}
}
if self.api < INPUTS_API {
return Err(Error::Manifest("`inputs` needs `api` 2 or later".into()));
}
if !self.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == Permission::DocRead.name())) {
return Err(Error::Manifest("`inputs` needs the permission `doc.read`".into()));
}
Ok(())
}
/// A filter or a live effect lists at most [`MAX_COMMANDS`] commands, each with a distinct
/// id, a printable label and parameters declared as the manifest's own are. A heavy filter
/// lists none. The error names the command: its id, or its place in the list.
@@ -446,6 +514,11 @@ impl Manifest {
Ok(())
}
/// The kinds of object besides paths and compound paths it asks to be given (`inputs`).
pub fn input_kinds(&self) -> Vec<InputKind> {
self.inputs.iter().filter_map(|n| InputKind::parse(n)).collect()
}
/// An exporter or an importer lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16
/// lower-case letters and digits; other kinds list none.
fn validate_extensions(&self) -> Result<()> {
@@ -604,6 +677,17 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result
d.deserialize_any(V)
}
/// Reads `inputs`: a list of names (`null`: none). Anything else is an error that names the key, as
/// [`Manifest::validate_inputs`] names a wrong entry.
fn input_names<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<String>, D::Error> {
let wrong = || serde::de::Error::custom("`inputs` must be a list of object kinds: type, image or symbolInstance");
match Value::deserialize(d)? {
Value::Null => Ok(Vec::new()),
Value::Array(names) => names.into_iter().map(|n| if let Value::String(s) = n { Ok(s) } else { Err(wrong()) }).collect(),
_ => Err(wrong()),
}
}
/// Reads the `commands` list one entry at a time from the manifest itself, so each command's
/// `params` keep their declaration order and a repeated parameter is refused. A malformed entry
/// is an error that names it (its id when the id comes before the fault, otherwise its place in
@@ -940,6 +1024,53 @@ mod tests {
}
}
/// A filter at `api` 2 may ask for type, images and symbol instances (`inputs`), each kind once,
/// with a `permissions` list holding `doc.read`, so installing it asks for approval. Left out
/// or empty, it asks for none and isn't written back. Anything else is refused naming `inputs`.
#[test]
fn a_filter_at_api_2_lists_the_inputs_it_reads() {
let m = Manifest::parse(
br#"{"id":"org.example.inspect","name":"Inspect","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type","image","symbolInstance"]}"#,
)
.unwrap();
assert_eq!(m.input_kinds(), InputKind::ALL);
assert!(m.asks_permissions());
assert_eq!(serde_json::to_value(&m).unwrap()["inputs"], json!(["type", "image", "symbolInstance"]));
let some = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.read"],"inputs":["image"]}"#).unwrap();
assert_eq!(some.input_kinds(), [InputKind::Image]);
assert!(some.asks_permissions());
for none in [
r#"{"id":"a","name":"x","kind":"filter"}"#,
r#"{"id":"a","name":"x","kind":"filter","inputs":[]}"#,
r#"{"id":"a","name":"x","kind":"filter","inputs":null}"#,
r#"{"id":"a","name":"x","kind":"effect","inputs":[]}"#,
] {
let m = Manifest::parse(none.as_bytes()).unwrap();
assert!(m.input_kinds().is_empty(), "{none}");
assert!(serde_json::to_value(&m).unwrap().get("inputs").is_none(), "{none}");
}
for (bad, says) in [
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["text"]}"#, "`inputs` names an unknown object kind `text`"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["Type"]}"#, "unknown object kind `Type`"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["image","image"]}"#, "`inputs` lists `image` twice"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":"type"}"#, "`inputs` must be a list"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":[1]}"#, "`inputs` must be a list"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":{"type":true}}"#, "`inputs` must be a list"),
(r#"{"id":"a","name":"x","kind":"filter","inputs":["type"]}"#, "`inputs` needs `api` 2"),
(r#"{"id":"a","name":"x","kind":"filter","api":1,"inputs":["type"]}"#, "`inputs` needs `api` 2"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.write"],"inputs":["type"]}"#, "needs the permission `doc.read`"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["type"]}"#, "`inputs` needs the permission `doc.read`"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":[],"inputs":["image"]}"#, "`inputs` needs the permission `doc.read`"),
(r#"{"id":"a","name":"x","kind":"effect","api":2,"inputs":["type"]}"#, "only a filter declares `inputs`"),
(r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"api":2,"inputs":["image"]}"#, "only a filter declares `inputs`"),
] {
let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string();
assert!(e.contains(says), "{bad}: {e}");
}
let long = format!(r#"{{"id":"a","name":"x","kind":"filter","api":2,"inputs":[{}]}}"#, vec![r#""type""#; 1000].join(","));
assert!(Manifest::parse(long.as_bytes()).unwrap_err().to_string().contains("twice"));
}
/// A live effect at `api` 2 may declare `"paintOutput": true`, and only that value; any other
/// kind or level, and `false`, `null` or another type, is refused naming the key. Without the
/// key, at any level, the effect returns geometry only.
+504 -21
View File
@@ -6,14 +6,19 @@
//! in-range coordinates, capped counts, clamped colours) before it reaches the document.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_color::cms::{self, Cms, CmykLut, lab};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Point, Rect};
use vectorcraft_doc::cmyk::oversized_tiff;
use vectorcraft_doc::{
Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes,
TextKind, TextObject,
};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
use crate::{Error, Result};
use crate::{Error, InputKind, Result};
/// Coordinates a plug-in may return, in points (the document's own canvas limit).
pub const MAX_COORD: f64 = 4.0e6;
@@ -32,6 +37,20 @@ pub const MITER_LIMIT: std::ops::RangeInclusive<f64> = 1.0..=500.0;
pub const MAX_EFFECT_ID: usize = 128;
/// Largest data a plug-in may keep with a document (`documentData`), in bytes of JSON.
pub const MAX_DOCUMENT_DATA: usize = 1 << 20;
/// Most text a filter is given of one type object, in bytes of UTF-8 over all its runs: longer
/// type comes with `truncated` and no runs or paragraphs.
pub const MAX_INPUT_TEXT: usize = 256 * 1024;
/// Most runs, and most paragraphs, a filter is given of one type object (as [`MAX_INPUT_TEXT`]).
pub const MAX_INPUT_RUNS: usize = 4_096;
/// Largest pixels a filter is given of one embedded image, in characters of base64: a larger
/// image comes with `pixelsOmitted`.
pub const MAX_INPUT_PIXELS: usize = 1 << 20;
/// Most pixels a filter is given over all the images of one input, in characters of base64: an
/// image whose pixels don't fit what the images before it left comes with `pixelsOmitted`.
pub const MAX_INPUT_PIXELS_TOTAL: usize = 16 << 20;
/// Most text a filter is given over all the type of one input, in bytes of the JSON of their runs
/// and paragraphs: type that doesn't fit what the type before it left comes with `truncated`.
pub const MAX_INPUT_TEXT_TOTAL: usize = 16 << 20;
/// One object returned by a plug-in. `None` fields keep the object's value (new objects take the
/// defaults: a path, non-zero rule, no fills or strokes, full opacity).
@@ -56,6 +75,9 @@ pub struct OutObject {
pub guide: Option<bool>,
/// Locks or unlocks the object (as Object › Lock and Unlock do).
pub locked: Option<bool>,
/// A read-only input object (type, an image or a symbol instance) returned as it was given:
/// it stays as it is, and new objects listed after it go above it.
pub kept: bool,
}
/// One stroke of an [`OutObject`]. `None` fields keep the stroke's value (a new stroke's
@@ -158,8 +180,350 @@ pub fn encode(n: &Node) -> Option<Value> {
/// The input document of a filter run: `{"objects": [...]}` for `ids` (paths and compound paths;
/// others are left out).
pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec<u8> {
let objects: Vec<Value> = ids.iter().filter_map(|id| doc.node(*id)).filter_map(encode).collect();
serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default()
encode_input_with(doc, ids, &[], usize::MAX).map(|(bytes, _)| bytes).unwrap_or_default()
}
/// [`encode_input`] for a filter that asks for `kinds` too: objects of those kinds among `ids` are
/// in it as [`encode_read_only`] gives them, within [`MAX_INPUT_PIXELS_TOTAL`] and
/// [`MAX_INPUT_TEXT_TOTAL`] over all of them in order, and within `capacity`, the most bytes the
/// input may be. Every object counts toward `capacity` in order, ids, bounds, transforms and names
/// included: pixels or text that don't fit what is left are omitted (`pixelsOmitted`,
/// `truncated`), a read-only object whose other fields alone don't fit is an [`Error::Limit`]
/// naming it and its size, a path or compound path that doesn't fit is the limit the runtime gives
/// an input past its share, and more than [`MAX_OBJECTS`] read-only objects, more than an output
/// may list, is an [`Error::Limit`] too. Also returns those objects by id, which the filter
/// must return as they were ([`decode_output_with`]).
pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind], capacity: usize) -> Result<(Vec<u8>, HashMap<NodeId, Value>)> {
encode_input_within(doc, ids, kinds, Allowance { input: capacity.saturating_sub(EMPTY_INPUT.len()), ..Allowance::FULL })
}
/// The input with no objects; each object adds its JSON and a comma.
const EMPTY_INPUT: &[u8] = b"{\"objects\":[]}";
/// What an input's objects may still be given of its totals, as they are encoded in order:
/// characters of base64 for pixels, bytes of JSON for runs and paragraphs, and bytes of the whole
/// input.
#[derive(Clone, Copy, Debug)]
struct Allowance {
pixels: usize,
text: usize,
input: usize,
}
impl Allowance {
const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL, input: usize::MAX };
}
/// [`encode_input_with`] within `left` of the totals. Each read-only object is kept once, in the map
/// it returns, and written into the input from there.
fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec<u8>, HashMap<NodeId, Value>)> {
let mut read_only = HashMap::new();
let symbols = doc.symbol_boxes();
let mut out = b"{\"objects\":[".to_vec();
let mut first = true;
for n in ids.iter().filter_map(|id| doc.node(*id)) {
let path;
let (o, checked): (&Value, bool) = match input_kind(n).filter(|k| kinds.contains(k)) {
// The output must return every read-only object, and lists at most `MAX_OBJECTS`.
Some(_) if read_only.len() >= MAX_OBJECTS => return Err(too_many_read_only()),
Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left)? {
Some(o) => (read_only.entry(n.id).insert_entry(o).into_mut(), true),
None => continue,
},
None => match encode(n) {
Some(o) => {
path = o;
(&path, false)
}
None => continue,
},
};
let start = out.len();
if !std::mem::take(&mut first) {
out.push(b',');
}
if serde_json::to_writer(&mut out, o).is_err() {
return Ok((Vec::new(), HashMap::new()));
}
let size = out.len() - start;
// A path or compound path past what is left fails the run as the runtime would fail it,
// before the rest of the selection is written.
if size > left.input {
return Err(if checked { too_large(n, size, left.input) } else { Error::Limit(crate::runtime::SELECTION_TOO_LARGE.into()) });
}
left.input -= size;
}
out.extend_from_slice(b"]}");
Ok((out, read_only))
}
/// The error for read-only object `n`, which needs `size` bytes of input where `left` are left.
fn too_large(n: &Node, size: usize, left: usize) -> Error {
Error::Limit(format!("memory budget (object {} needs {size} bytes of input, {left} left)", n.id.0))
}
/// The error for an input with more read-only objects than an output may list.
fn too_many_read_only() -> Error {
Error::Limit(format!("object budget (more than {MAX_OBJECTS} read-only objects)"))
}
/// The kind of read-only object `n` is: type, an image or a symbol instance (`None` for others).
pub fn input_kind(n: &Node) -> Option<InputKind> {
match &n.kind {
NodeKind::Text(_) => Some(InputKind::Type),
NodeKind::Image(_) => Some(InputKind::Image),
NodeKind::SymbolInstance { .. } => Some(InputKind::SymbolInstance),
_ => None,
}
}
fn affine_json(xf: Affine) -> Value {
json!(xf.as_coeffs())
}
/// Type, an image or a symbol instance of `doc` as a filter that asks for its kind reads it (`None`
/// for other objects). Each has `{id, type, name?, bounds, transform}`, `transform` mapping its own
/// space (text space, pixel space, the symbol's art) into the document, and:
/// - type (`"type"`): `text: {kind: "point"|"area"|"path", vertical, runs: [{text, style, inline?}],
/// paragraphs: [style]}` with the native character and paragraph attributes, one paragraph style
/// for each paragraph, and `truncated`: true, with no runs or paragraphs, past [`MAX_INPUT_TEXT`]
/// or [`MAX_INPUT_RUNS`];
/// - an image (`"image"`): `pixelSize: [w, h]`, `colorMode: "rgb"|"cmyk"|"gray"|"unknown"`,
/// `linked`, `pixelsOmitted`, and for an embedded image of at most [`MAX_INPUT_PIXELS`]
/// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top (a CMYK image's inks
/// through the working CMYK profile). A linked image's file and path are never read or given;
/// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when
/// the document has no such symbol, and `overrides: {}`.
pub fn encode_read_only(doc: &Document, n: &Node) -> Option<Value> {
let mut left = Allowance::FULL;
encode_read_only_within(doc, n, &doc.symbol_boxes(), &mut left).ok().flatten()
}
/// The bytes the `pixelsBase64` key adds to an image's JSON besides the base64 itself.
const PIXELS_KEY: usize = ",\"pixelsBase64\":\"\"".len();
/// [`encode_read_only`] measuring symbol instances from `symbols`, a cache shared across the
/// objects of one input, and taking the pixels and text it gives from `left`: pixels or text that
/// don't fit what is left of their totals or of the input are omitted (`pixelsOmitted`,
/// `truncated`), and an image's size is checked against it before anything is decoded. The object's
/// other fields are measured first, with a comma before it: when they alone don't fit what is left
/// of the input, it is an [`Error::Limit`] naming the object and that size.
fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Result<Option<Value>> {
let Some(kind) = input_kind(n) else { return Ok(None) };
let mut o = json!({"id": n.id.0, "type": kind.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))});
// Each flag is measured as `false`, its longer value.
match &n.kind {
NodeKind::Text(t) => {
o["transform"] = affine_json(t.xf);
o["text"] = text_json(t, None).0;
o["truncated"] = json!(false);
}
NodeKind::Image(im) => {
o["transform"] = affine_json(im.xf);
o["pixelSize"] = json!([im.width, im.height]);
o["colorMode"] = json!(doc.image_blob(im).map_or("unknown", color_mode));
o["linked"] = json!(im.link.is_some());
o["pixelsOmitted"] = json!(false);
}
NodeKind::SymbolInstance { symbol, xf } => {
o["transform"] = affine_json(*xf);
o["symbolId"] = json!(symbol);
if !doc.symbols.iter().any(|s| s.name == *symbol) {
o["missing"] = json!(true);
}
o["overrides"] = json!({});
}
_ => return Ok(None),
}
if let Some(name) = &n.name {
o["name"] = json!(name);
}
let fields = serde_json::to_vec(&o).map_or(usize::MAX, |b| b.len()).saturating_add(1);
if fields > left.input {
return Err(too_large(n, fields, left.input));
}
let room = left.input - fields;
match &n.kind {
NodeKind::Text(t) => {
let mut budget = left.text.min(room);
let (text, truncated) = text_json(t, Some(&mut budget));
left.text -= left.text.min(room) - budget;
o["text"] = text;
o["truncated"] = json!(truncated);
}
NodeKind::Image(im) => {
let blob = doc.image_blob(im);
let max = MAX_INPUT_PIXELS.min(left.pixels).min(room.saturating_sub(PIXELS_KEY));
// A linked image's pixels are its file's: never given (nor its path).
let pixels = blob.filter(|_| im.link.is_none()).and_then(|b| rgba_pixels(b, im.width, im.height, max));
o["pixelsOmitted"] = json!(pixels.is_none());
if let Some(p) = pixels {
let b64 = craft_vectorcraft_format::base64::encode(&p);
left.pixels = left.pixels.saturating_sub(b64.len());
o["pixelsBase64"] = json!(b64);
}
}
_ => {}
}
Ok(Some(o))
}
/// Counts the bytes written to it and fails past `left`, so nothing is held to find a size.
struct Measure {
bytes: usize,
left: usize,
}
impl std::io::Write for Measure {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.bytes = self.bytes.saturating_add(buf.len());
if self.bytes > self.left { Err(std::io::ErrorKind::OutOfMemory.into()) } else { Ok(buf.len()) }
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// Adds `item` as JSON to `list` when it fits what is left of `limit` after `used`, counting the
/// comma before it; `false`, with nothing built, when it doesn't.
fn push_within<T: serde::Serialize>(item: &T, list: &mut Vec<Value>, used: &mut usize, limit: usize) -> bool {
let comma = usize::from(!list.is_empty());
let Some(left) = limit.checked_sub(used.saturating_add(comma)) else { return false };
if serde_json::to_writer(Measure { bytes: 0, left }, item).is_err() {
return false;
}
let Ok(value) = serde_json::to_value(item) else { return false };
// A `Value` writes a number in its widest form, so the bytes given are counted from it.
let mut measure = Measure { bytes: 0, left };
if serde_json::to_writer(&mut measure, &value).is_err() {
return false;
}
*used += comma + measure.bytes;
list.push(value);
true
}
/// The text of type `t` as a filter reads it, and whether it was too long to give
/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`], or more bytes of JSON than `left`, which what it gives
/// is taken from, with the commas between them). With `left` `None`, its kind and direction with no
/// runs or paragraphs.
fn text_json(t: &TextObject, left: Option<&mut usize>) -> (Value, bool) {
let kind = match t.kind {
TextKind::Point => "point",
TextKind::Area { .. } => "area",
TextKind::OnPath { .. } => "path",
};
let (mut runs, mut paras): (Vec<Value>, Vec<Value>) = (Vec::new(), Vec::new());
let mut truncated = false;
if let Some(left) = left {
let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len()));
let paragraphs = t.paragraph_count();
// The text alone is never more bytes than its JSON, so type that can't fit isn't built.
truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS;
if !truncated {
// Each run and paragraph is measured before it is built, with the comma before it, so
// no more than `left` is held before the type is found too long.
let (limit, mut used) = (*left, 0usize);
let fits = t.runs.iter().all(|r| push_within(r, &mut runs, &mut used, limit))
&& (0..paragraphs).all(|i| push_within(&t.para_at(i), &mut paras, &mut used, limit));
if fits {
*left -= used;
} else {
(runs, paras, truncated) = (Vec::new(), Vec::new(), true);
}
}
}
(json!({"kind": kind, "vertical": t.vertical, "runs": runs, "paragraphs": paras}), truncated)
}
/// How image `b` stores its colors, read from its header: `"cmyk"`, `"gray"`, `"rgb"`, or
/// `"unknown"` when it can't be read, is a TIFF too large for the decoder to count, or only a
/// linked image's preview is there.
fn color_mode(b: &ImageBlob) -> &'static str {
if oversized_tiff(&b.bytes).is_some() {
return "unknown";
}
if b.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &b.bytes) || **p == *b.bytes) {
return "unknown";
}
if b.is_cmyk() {
return "cmyk";
}
if b.gray_source().is_some() {
return "gray";
}
let readable =
image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok());
if readable.is_some() { "rgb" } else { "unknown" }
}
/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit `max`
/// characters of base64; the size is checked, and read from the header, before anything is decoded.
fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option<Vec<u8>> {
let bytes = u64::from(w).checked_mul(u64::from(h))?.checked_mul(4)?;
if bytes == 0 || bytes.div_ceil(3).saturating_mul(4) > max as u64 || oversized_tiff(&b.bytes).is_some() {
return None;
}
if b.is_cmyk() {
return cmyk_rgba(b, w, h);
}
let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok();
if reader()?.into_dimensions().ok()? != (w, h) {
return None;
}
let mut r = reader()?;
let mut limits = image::Limits::default();
// The decoder's own buffers besides the RGBA it gives.
limits.max_alloc = Some(64 << 20);
r.limits(limits);
let img = r.decode().ok()?.into_rgba8();
(img.dimensions() == (w, h)).then(|| img.into_raw())
}
/// The inks → XYZ table of [`cmyk_rgba`] and the color settings it was built with.
static CMYK_DISPLAY: Mutex<Option<(Arc<Cms>, Arc<CmykLut>)>> = Mutex::new(None);
/// The pixels of CMYK image `b` as straight RGBA, when it is `w` × `h` pixels (checked from its
/// header first): its inks shown through the working CMYK profile, by a table built as
/// `vectorcraft-render`'s `InkLut` builds its own and kept for the active color settings.
fn cmyk_rgba(b: &ImageBlob, w: u32, h: u32) -> Option<Vec<u8>> {
if b.header_size()? != (w, h) {
return None;
}
let inks = b.cmyk().filter(|i| (i.width, i.height) == (w, h))?;
let lut = {
let cms = cms::active();
let mut slot = CMYK_DISPLAY.lock().unwrap_or_else(|e| e.into_inner());
match &*slot {
Some((owner, lut)) if Arc::ptr_eq(owner, &cms) => lut.clone(),
_ => {
let lut = Arc::new(CmykLut::build(|c| {
let [x, y, z] = lab::lab_to_xyz(cms.cmyk_display_lab(c));
[x, y, z, 0.0]
}));
*slot = Some((cms, lut.clone()));
lut
}
}
};
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let mut out = Vec::with_capacity(inks.data.len());
// Runs of one color convert once.
let mut last: Option<([u8; 4], [u8; 4])> = None;
for p in inks.data.as_chunks::<4>().0 {
let rgba = match last {
Some((k, v)) if k == *p => v,
_ => {
let [x, y, z, _] = lut.apply(p.map(|v| f32::from(v) / 255.0));
let [r, g, b] = lab::xyz_to_srgb([x, y, z]).map(q);
last = Some((*p, [r, g, b, 255]));
[r, g, b, 255]
}
};
out.extend_from_slice(&rgba);
}
Some(out)
}
pub(crate) fn out_err(at: &str, msg: impl std::fmt::Display) -> Error {
@@ -397,8 +761,16 @@ pub struct Output {
/// Parses a filter's output: `{"objects": [...], "documentData"?: …}`, or `{"error": "message"}`
/// when the plug-in declined (the message reaches the user).
pub fn decode_output(bytes: &[u8]) -> Result<Output> {
decode_output_with(bytes, &HashMap::new())
}
/// [`decode_output`] for a filter given `read_only` objects ([`encode_input_with`]): it must list
/// each of them exactly once, as `{"id": …}` alone or exactly as it was given (numbers compared by
/// value), and such an entry is [`OutObject::kept`]. Any other entry for one of them, and one left
/// out, is refused naming the object and the first field that differs, before anything changes.
pub fn decode_output_with(bytes: &[u8], read_only: &HashMap<NodeId, Value>) -> Result<Output> {
let mut v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
let objects = decode_objects(&v)?;
let objects = decode_objects(&v, read_only)?;
let document_data = match v.get_mut("documentData").map(Value::take) {
None => None,
Some(Value::Null) => Some(None),
@@ -417,11 +789,12 @@ pub fn decode_output(bytes: &[u8]) -> Result<Output> {
/// declined (the message reaches the user). A live effect's other keys don't matter.
pub fn decode(bytes: &[u8]) -> Result<Vec<OutObject>> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
decode_objects(&v)
decode_objects(&v, &HashMap::new())
}
/// The objects of output `v`, or its decline.
fn decode_objects(v: &Value) -> Result<Vec<OutObject>> {
/// The objects of output `v`, or its decline; entries for `read_only` objects as
/// [`decode_output_with`] reads them.
fn decode_objects(v: &Value, read_only: &HashMap<NodeId, Value>) -> Result<Vec<OutObject>> {
if let Some(e) = declined(v) {
return Err(e);
}
@@ -430,7 +803,91 @@ fn decode_objects(v: &Value) -> Result<Vec<OutObject>> {
return Err(Error::Output(format!("{} objects (limit {MAX_OBJECTS})", objects.len())));
}
let mut anchors = 0;
objects.iter().enumerate().map(|(i, o)| decode_object(o, &format!("objects[{i}]"), &mut anchors)).collect()
let mut returned: HashSet<NodeId> = HashSet::new();
let out = objects
.iter()
.enumerate()
.map(|(i, o)| match output_id(o).and_then(|id| Some((id, read_only.get(&id)?))) {
Some((id, given)) => {
let at = format!("objects[{i}] (id {})", id.0);
if !returned.insert(id) {
return Err(out_err(&at, "is listed twice"));
}
check_unchanged(o, given, &at)?;
Ok(OutObject { id: Some(id), kept: true, ..OutObject::default() })
}
None => decode_object(o, &format!("objects[{i}]"), &mut anchors),
})
.collect::<Result<Vec<_>>>()?;
let mut missing: Vec<NodeId> = read_only.keys().filter(|id| !returned.contains(id)).copied().collect();
missing.sort();
if let Some(id) = missing.first() {
return Err(out_err(
&format!("objects (id {})", id.0),
"a read-only object was left out: return it, as {\"id\": …} alone or as it was given",
));
}
Ok(out)
}
/// The id entry `o` gives, a whole number written either way (`17` or `17.0`).
fn output_id(o: &Value) -> Option<NodeId> {
let id = o.get("id")?;
id.as_u64().or_else(|| id.as_f64().filter(|f| f.fract() == 0.0 && (0.0..=9_007_199_254_740_992.0).contains(f)).map(|f| f as u64)).map(NodeId)
}
/// Checks entry `o` of a filter's output for a read-only object it was `given`: `{"id": …}` alone,
/// or `given` itself, numbers compared by value. Otherwise the error names the first field that
/// differs (`text.runs[0].text`): a field the entry gives before one it leaves out, shown as
/// [`crate::permissions::display_text`] shows a manifest string, since the entry's keys are the
/// plug-in's.
fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> {
if o.as_object().is_some_and(|m| m.len() == 1) {
return Ok(());
}
let changed = match (o, given) {
(Value::Object(x), Value::Object(y)) => {
let mut keys: Vec<&String> = x.keys().collect();
keys.sort();
keys.into_iter().find_map(|k| match y.get(k) {
Some(q) => first_difference(&x[k], q, k),
None => Some(k.clone()),
})
}
_ => None,
};
match changed.or_else(|| first_difference(o, given, "")) {
None => Ok(()),
Some(field) => {
let field = crate::permissions::display_text(&field, crate::permissions::MAX_SHOWN_FIELD);
Err(out_err(at, format!("read-only field '{field}' changed")))
}
}
}
/// The path of the first place `a` and `b` differ (`""`: they differ as a whole), `None` when they
/// are the same JSON with numbers compared by value.
fn first_difference(a: &Value, b: &Value, at: &str) -> Option<String> {
let key = |k: &str| if at.is_empty() { k.to_string() } else { format!("{at}.{k}") };
match (a, b) {
(Value::Number(x), Value::Number(y)) => (x.as_f64() != y.as_f64()).then(|| at.into()),
(Value::Object(x), Value::Object(y)) => {
let mut keys: Vec<&String> = x.keys().chain(y.keys()).collect();
keys.sort();
keys.dedup();
keys.into_iter().find_map(|k| match (x.get(k), y.get(k)) {
(Some(p), Some(q)) => first_difference(p, q, &key(k)),
_ => Some(key(k)),
})
}
(Value::Array(x), Value::Array(y)) => {
if x.len() != y.len() {
return Some(at.into());
}
x.iter().zip(y).enumerate().find_map(|(i, (p, q))| first_difference(p, q, &format!("{at}[{i}]")))
}
(a, b) => (a != b).then(|| at.into()),
}
}
/// The decline in a plug-in's output `v`, `{"error": "…"}`: its message as the user reads it,
@@ -622,25 +1079,39 @@ pub fn selectable(n: &Node) -> bool {
/// The paths and compound paths a filter reads from the selection `selected`, in paint order:
/// selected ones, and those inside selected groups and layers; hidden, locked, guide (compound
/// guides too) and clipping paths are left out (so are paths inside compound paths, blends and
/// other live objects).
/// guides too) and clipping paths are left out, and so are compound paths that clip a clip group
/// and paths inside a group that clips one (and paths inside compound paths, blends and other
/// live objects).
pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec<NodeId> {
fn visit(list: &[Arc<Node>], sel: &HashSet<NodeId>, inside: bool, out: &mut Vec<NodeId>) {
for n in list {
filter_targets_with(doc, selected, &[])
}
/// [`filter_targets`] for a filter that asks for `kinds` too: type, images and symbol instances of
/// those kinds join the paths in paint order, under the same rules (objects that clip a clip
/// group are left out, inside a group that clips one too, and so is anything inside a symbol, a
/// placed document or a live object).
pub fn filter_targets_with(doc: &Document, selected: &[NodeId], kinds: &[InputKind]) -> Vec<NodeId> {
/// `source`: `list` is inside the clipping object of a clip group.
fn visit(list: &[Arc<Node>], sel: &HashSet<NodeId>, inside: bool, clips: bool, source: bool, kinds: &[InputKind], out: &mut Vec<NodeId>) {
for (i, n) in list.iter().enumerate() {
if !n.visible || n.locked {
continue;
}
let picked = inside || sel.contains(&n.id);
let clipping = source || (clips && i == 0);
match &n.kind {
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !is_guide(n) => out.push(n.id),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, out),
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !clipping && !is_guide(n) => {
out.push(n.id)
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, n.clips(), clipping, kinds, out),
_ if picked && !clipping && input_kind(n).is_some_and(|k| kinds.contains(&k)) => out.push(n.id),
_ => {}
}
}
}
let sel: HashSet<NodeId> = selected.iter().copied().collect();
let mut out = vec![];
visit(&doc.layers, &sel, false, &mut out);
visit(&doc.layers, &sel, false, false, false, kinds, &mut out);
out
}
@@ -740,7 +1211,14 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>,
let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" };
out_err(&at, format!("{} {why}", id.0))
})?;
write(doc, &mut n, o, &at, admit)?;
// Type, images and symbol instances are only read: writing one would make it a path.
if input_kind(&n).is_some() && !o.kept {
return Err(out_err(&at, format!("{} is read-only", id.0)));
}
let kept = o.kept;
if !kept {
write(doc, &mut n, o, &at, admit)?;
}
if !run.is_empty() {
let nodes = std::mem::take(&mut run);
match prev {
@@ -748,7 +1226,9 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>,
None => plan.before.insert(id, nodes),
};
}
plan.replace.insert(id, n);
if !kept {
plan.replace.insert(id, n);
}
prev = Some(id);
result.push(id);
}
@@ -774,7 +1254,10 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>,
None => plan.top = Some((parent, run)),
}
}
// Input objects the output left out are deleted.
// Input objects the output left out are deleted, except read-only ones, which it must return.
if let Some(n) = originals.values().filter(|n| input_kind(n).is_some()).min_by_key(|n| n.id) {
return Err(out_err(&format!("objects (id {})", n.id.0), "a read-only object was left out"));
}
plan.delete = originals.into_keys().collect();
if let Some(list) = plan.list(None, &doc.layers) {
doc.layers = list;
+470
View File
@@ -310,6 +310,476 @@ fn bad_stroke_styles_and_effect_records_are_refused() {
}
}
/// A `w` × `h` PNG whose pixels vary with their column, half transparent.
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_fn(w, h, |x, y| image::Rgba([(x * 100) as u8, (y * 7) as u8, 30, 128]));
let mut out = vec![];
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// An image object `w` × `h` pixels showing blob `key`, linked to `link`.
fn image_node(d: &mut Document, key: &str, (w, h): (u32, u32), link: Option<&str>) -> Node {
let im = vectorcraft_doc::ImageObject {
key: key.into(),
width: w,
height: h,
xf: Affine::new([2.0, 0.0, 0.0, 2.0, 40.0, 60.0]),
link: link.map(vectorcraft_doc::LinkInfo::new),
placement: Default::default(),
pixels: None,
};
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
}
/// [`doc`] with, above its paths: point type "T" in two paragraphs, an embedded 2 × 1 image "E", a
/// linked image "L", an instance "S" of symbol "Star" and an instance "G" of a symbol the document
/// doesn't have. Returns those five too.
fn mixed() -> (Document, Vec<NodeId>, NodeId, [NodeId; 5]) {
use vectorcraft_doc::{CharStyle, ParaStyle, Symbol, TextObject};
let (mut d, ids, layer) = doc();
let mut t = TextObject::point(Point::new(5.0, 50.0), "Hi\nthere", CharStyle::default());
t.set_paragraph_styles(vec![ParaStyle::default(), ParaStyle { space_before: 6.0, ..Default::default() }]);
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
d.images.insert("e".into(), ImageBlob::png(png(2, 1)));
d.images.insert("l".into(), ImageBlob::png(png(4, 4)));
let embedded = image_node(&mut d, "e", (2, 1), None);
let linked = image_node(&mut d, "l", (4, 4), Some("C:/private/photo.png"));
let art = Arc::new(Node::path(d.alloc_id(), square(0.0), Appearance::default()));
d.symbols.push(Symbol { name: "Star".into(), art, dynamic: false });
let star = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::translate((100.0, 0.0)) });
let ghost = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY });
let mut extra = [NodeId(0); 5];
for (slot, (mut n, name)) in extra.iter_mut().zip([text, embedded, linked, star, ghost].into_iter().zip(["T", "E", "L", "S", "G"])) {
n.name = Some(name.into());
*slot = d.insert(Some(layer), usize::MAX, n).unwrap();
}
(d, ids, layer, extra)
}
/// A filter that doesn't list `inputs` is given what it was before: the selected paths alone, in
/// the same bytes. One that lists kinds is given those objects too, in paint order, under the same
/// rules (hidden and locked ones left out, selected ones or ones in a selected layer).
#[test]
fn legacy_filter_input_bytes_are_unchanged() {
let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed();
assert_eq!(filter_targets(&d, &[layer]), ids);
assert_eq!(filter_targets_with(&d, &[layer], &[]), ids);
let every: Vec<NodeId> = ids.iter().copied().chain([text, embedded, linked, star, ghost]).collect();
let (bytes, read_only) = encode_input_with(&d, &every, &[], usize::MAX).unwrap();
assert_eq!(bytes, encode_input(&d, &ids), "the other objects are left out");
assert!(read_only.is_empty());
let all = filter_targets_with(&d, &[layer], &InputKind::ALL);
assert_eq!(all, every);
let images: Vec<NodeId> = ids.iter().copied().chain([embedded, linked]).collect();
assert_eq!(filter_targets_with(&d, &[layer], &[InputKind::Image]), images);
assert_eq!(filter_targets_with(&d, &[star, ids[0]], &InputKind::ALL), [ids[0], star], "selected directly");
d.node_mut(text).unwrap().visible = false;
d.node_mut(star).unwrap().locked = true;
assert_eq!(filter_targets_with(&d, &[layer], &InputKind::ALL), [&ids[..], &[embedded, linked, ghost]].concat());
let (bytes, read_only) = encode_input_with(&d, &all, &[InputKind::SymbolInstance], usize::MAX).unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let types: Vec<&str> = v["objects"].as_array().unwrap().iter().map(|o| o["type"].as_str().unwrap()).collect();
assert_eq!(types, ["path", "path", "path", "symbolInstance", "symbolInstance"]);
assert_eq!(read_only.len(), 2);
}
/// Type comes with its runs and their character attributes, one paragraph style per paragraph, its
/// kind and transform; type past [`MAX_INPUT_TEXT`] or [`MAX_INPUT_RUNS`] comes without text.
#[test]
fn declared_type_input_has_bounded_runs_and_paragraph_styles() {
use vectorcraft_doc::{CharStyle, ParaStyle, TextRun};
let (mut d, _, _, [text, ..]) = mixed();
let o = encode_read_only(&d, d.node(text).unwrap()).unwrap();
assert_eq!((&o["type"], &o["name"], &o["truncated"]), (&json!("type"), &json!("T"), &json!(false)));
assert_eq!(o["transform"], json!([1.0, 0.0, 0.0, 1.0, 5.0, 50.0]));
assert_eq!((&o["text"]["kind"], &o["text"]["vertical"]), (&json!("point"), &json!(false)));
assert_eq!(o["text"]["runs"], json!([{"text": "Hi\nthere", "style": serde_json::to_value(CharStyle::default()).unwrap()}]));
let spaced = ParaStyle { space_before: 6.0, ..Default::default() };
assert_eq!(o["text"]["paragraphs"], json!([serde_json::to_value(ParaStyle::default()).unwrap(), serde_json::to_value(spaced).unwrap()]));
assert!(o.get("path").is_none() && o.get("fills").is_none(), "not made into outlines");
let set_runs = |d: &mut Document, runs: Vec<TextRun>| {
if let NodeKind::Text(t) = &mut d.node_mut(text).unwrap().kind {
t.runs = runs;
t.paras.clear();
}
};
let run = |s: String| TextRun::new(s, CharStyle::default());
for (runs, truncated) in [
(vec![run("x".repeat(MAX_INPUT_TEXT))], false),
(vec![run("x".repeat(MAX_INPUT_TEXT - 1)), run("é".into())], true),
(vec![run("\n".repeat(MAX_INPUT_RUNS - 1))], false),
(vec![run("\n".repeat(MAX_INPUT_RUNS))], true),
((0..MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), false),
((0..=MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), true),
] {
let n = runs.len();
set_runs(&mut d, runs);
let o = encode_read_only(&d, d.node(text).unwrap()).unwrap();
assert_eq!(o["truncated"], json!(truncated), "{n} runs");
let given = o["text"]["runs"].as_array().unwrap().len();
assert_eq!((given, o["text"]["paragraphs"].as_array().unwrap().is_empty()), (if truncated { 0 } else { n }, truncated), "{n} runs");
}
}
/// An embedded image comes with its pixels when they are small; a linked image never does, and
/// nothing of its file's path is given. The color mode is read from the image.
#[test]
fn declared_embedded_and_linked_images_have_distinct_metadata_and_pixel_omission() {
let (mut d, _, layer, [_, embedded, linked, ..]) = mixed();
let e = encode_read_only(&d, d.node(embedded).unwrap()).unwrap();
assert_eq!((&e["type"], &e["pixelSize"], &e["colorMode"]), (&json!("image"), &json!([2, 1]), &json!("rgb")));
assert_eq!((&e["linked"], &e["pixelsOmitted"], &e["transform"]), (&json!(false), &json!(false), &json!([2.0, 0.0, 0.0, 2.0, 40.0, 60.0])));
let pixels = craft_vectorcraft_format::base64::decode(e["pixelsBase64"].as_str().unwrap()).unwrap();
assert_eq!(pixels, [0, 0, 30, 128, 100, 0, 30, 128], "straight RGBA, row by row");
let l = encode_read_only(&d, d.node(linked).unwrap()).unwrap();
assert_eq!((&l["linked"], &l["pixelsOmitted"], &l["pixelSize"]), (&json!(true), &json!(true), &json!([4, 4])));
assert!(l.get("pixelsBase64").is_none());
assert!(!l.to_string().contains("private"), "the link's path isn't given: {l}");
// Past the limit (443 × 443 fits it; 444 × 443 doesn't), and pixels of another size, are omitted.
for ((w, h), omitted) in [((443, 443), false), ((444, 443), true)] {
d.images.insert("big".into(), ImageBlob::png(png(w, h)));
let n = image_node(&mut d, "big", (w, h), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
let o = encode_read_only(&d, d.node(id).unwrap()).unwrap();
assert_eq!((&o["pixelsOmitted"], o.get("pixelsBase64").is_some()), (&json!(omitted), !omitted), "{w} × {h}");
}
let n = image_node(&mut d, "e", (3, 1), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["pixelsOmitted"], json!(true), "not the size it says");
// A size whose pixel count overflows is omitted too.
let n = image_node(&mut d, "e", (u32::MAX, u32::MAX), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["pixelsOmitted"], json!(true), "too large to count");
// Gray pixels, and data that isn't an image.
let mut gray = vec![];
image::GrayImage::from_pixel(2, 2, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut gray), image::ImageFormat::Png).unwrap();
for (blob, mode) in [(ImageBlob::png(gray), "gray"), (ImageBlob::png(b"not an image".to_vec()), "unknown")] {
d.images.insert("x".into(), blob);
let n = image_node(&mut d, "x", (2, 2), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["colorMode"], json!(mode));
}
}
/// A CMYK image's pixels are its inks as the working CMYK profile shows them on the canvas: black
/// ink alone is the profile's black, not RGB black.
#[test]
fn cmyk_image_pixels_go_through_the_working_cmyk_profile() {
use vectorcraft_color::cms::{self, lab};
let (mut d, _, layer, _) = mixed();
let inks = vectorcraft_doc::cmyk::Inks::new(2, 1, vec![0, 0, 0, 255, 0, 0, 0, 0]).unwrap();
d.images.insert("k".into(), ImageBlob::cmyk_tiff(&inks).unwrap());
let n = image_node(&mut d, "k", (2, 1), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
let o = encode_read_only(&d, d.node(id).unwrap()).unwrap();
assert_eq!((&o["colorMode"], &o["pixelsOmitted"]), (&json!("cmyk"), &json!(false)));
let pixels = craft_vectorcraft_format::base64::decode(o["pixelsBase64"].as_str().unwrap()).unwrap();
let shown = |c: [f32; 4]| lab::xyz_to_srgb(lab::lab_to_xyz(cms::active().cmyk_display_lab(c))).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
let ([r, g, b], [r2, g2, b2]) = (shown([0.0, 0.0, 0.0, 1.0]), shown([0.0; 4]));
assert_eq!(pixels, [r, g, b, 255, r2, g2, b2, 255]);
assert_ne!(&pixels[..3], &[0, 0, 0], "not converted without the profile");
}
/// A symbol instance comes with the symbol it shows and its transform, never its art; one whose
/// symbol the document lacks says so.
#[test]
fn declared_symbol_instance_keeps_identity_and_transform() {
let (d, _, _, [.., star, ghost]) = mixed();
let s = encode_read_only(&d, d.node(star).unwrap()).unwrap();
assert_eq!((&s["type"], &s["symbolId"], &s["overrides"]), (&json!("symbolInstance"), &json!("Star"), &json!({})));
assert_eq!(s["transform"], json!([1.0, 0.0, 0.0, 1.0, 100.0, 0.0]));
assert!(s.get("missing").is_none() && s.get("path").is_none());
let g = encode_read_only(&d, d.node(ghost).unwrap()).unwrap();
assert_eq!((&g["symbolId"], &g["missing"]), (&json!("Gone"), &json!(true)));
}
/// Pixels and type count against totals over the whole input, in order: an image whose pixels
/// don't fit what the images before it left comes with `pixelsOmitted` (its size checked before it
/// is decoded), and type whose runs and paragraphs don't fit comes with `truncated`. The read-only
/// objects returned are the ones in the input.
#[test]
fn input_totals_cover_images_and_type_in_order() {
let (mut d, _, layer, [text, embedded, ..]) = mixed();
let copy = |d: &mut Document, id: NodeId| {
let mut n = d.node(id).unwrap().clone();
n.id = d.alloc_id();
d.insert(Some(layer), usize::MAX, n).unwrap()
};
let (text2, image2, image3) = (copy(&mut d, text), copy(&mut d, embedded), copy(&mut d, embedded));
let one = encode_read_only(&d, d.node(text).unwrap()).unwrap();
// The runs and paragraphs with the commas between them, without their brackets.
let size: usize = ["runs", "paragraphs"].iter().map(|k| one["text"][k].to_string().len() - 2).sum();
// A 2 × 1 image is 12 characters of base64: two fit 30, the third doesn't.
let ids = [text, text2, embedded, image2, image3];
let (bytes, read_only) = encode_input_within(&d, &ids, &InputKind::ALL, Allowance { pixels: 30, text: size, input: usize::MAX }).unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let objects = v["objects"].as_array().unwrap();
assert_eq!(objects.len(), 5);
let flags: Vec<&Value> = objects.iter().map(|o| if o["type"] == "type" { &o["truncated"] } else { &o["pixelsOmitted"] }).collect();
assert_eq!(flags, [&json!(false), &json!(true), &json!(false), &json!(false), &json!(true)]);
assert!(objects[1]["text"]["runs"].as_array().unwrap().is_empty() && objects[4].get("pixelsBase64").is_none());
assert_eq!(objects[3]["pixelsBase64"], encode_read_only(&d, d.node(embedded).unwrap()).unwrap()["pixelsBase64"]);
for o in objects {
assert_eq!(Some(o), read_only.get(&NodeId(o["id"].as_u64().unwrap())));
}
// Within the full totals every one of them is given.
let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL, usize::MAX).unwrap();
let all = String::from_utf8(bytes).unwrap();
assert!(!all.contains("\"pixelsOmitted\":true") && !all.contains("\"truncated\":true"), "{all}");
}
/// Every object counts toward the input's capacity in order, its id, bounds, transform and name
/// included: a symbol instance before type leaves the type less room, so text that fits the exact
/// size of the whole input comes with `truncated` one byte under it, and an image's pixels are
/// omitted the same way.
#[test]
fn object_fields_count_toward_the_input_capacity() {
let (d, ids, _, [text, embedded, _, star, _]) = mixed();
for (order, flag) in [([ids[0], star, text], "truncated"), ([ids[0], star, embedded], "pixelsOmitted")] {
let (bytes, _) = encode_input_with(&d, &order, &InputKind::ALL, usize::MAX).unwrap();
let (exact, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len()).unwrap();
assert_eq!(exact, bytes, "{flag}: the exact size fits");
let (under, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len() - 1).unwrap();
let v: Value = serde_json::from_slice(&under).unwrap();
assert_eq!(v["objects"][2][flag], json!(true), "{flag}: one byte under");
assert!(under.len() < bytes.len() - 1, "{flag}");
}
}
/// Type with fractional colors counts the bytes the input gives it: one byte under its exact size
/// it comes with `truncated`, not a limit.
#[test]
fn fractional_colors_count_as_the_input_writes_them() {
use vectorcraft_doc::{CharStyle, TextObject};
let (mut d, ids, layer, _) = mixed();
let style = CharStyle { fill: Paint::solid(Color::rgb(0.1, 0.2, 0.3)), ..CharStyle::default() };
let t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(0.0, 0.0), "Hi", style))));
let text = d.insert(Some(layer), usize::MAX, t).unwrap();
let order = [ids[0], text];
let (bytes, _) = encode_input_with(&d, &order, &[InputKind::Type], usize::MAX).unwrap();
let (under, _) = encode_input_with(&d, &order, &[InputKind::Type], bytes.len() - 1).unwrap();
let v: Value = serde_json::from_slice(&under).unwrap();
assert_eq!(v["objects"][1]["truncated"], json!(true));
}
/// A read-only object whose fields other than its pixels and text alone don't fit what is left of
/// the input fails the run with a limit naming it and its size, rather than being left out.
#[test]
fn an_object_too_large_to_give_is_a_limit_naming_it() {
let (mut d, ids, _, [_, _, linked, star, _]) = mixed();
d.node_mut(linked).unwrap().name = Some("n".repeat(4096));
let (fits, _) = encode_input_with(&d, &[ids[0], star], &InputKind::ALL, usize::MAX).unwrap();
let order = [ids[0], star, linked];
let e = encode_input_with(&d, &order, &InputKind::ALL, fits.len() + 4096).unwrap_err();
assert!(matches!(e, Error::Limit(_)), "{e}");
// Measured with `pixelsOmitted` false and a comma before it.
let mut o = encode_read_only(&d, d.node(linked).unwrap()).unwrap();
o["pixelsOmitted"] = json!(false);
let size = o.to_string().len() + 1;
assert_eq!(e.to_string(), format!("plug-in exceeded its memory budget (object {} needs {size} bytes of input, 4096 left)", linked.0));
// A filter that doesn't list its kind is given the rest.
let (bytes, read_only) = encode_input_with(&d, &order, &[InputKind::SymbolInstance], fits.len() + 4096).unwrap();
assert_eq!((bytes, read_only.len()), (fits, 1));
}
/// A path or compound path that doesn't fit what is left of the input fails the run with the limit
/// the runtime gives an input past its share, before anything after it is written; the exact size
/// still fits.
#[test]
fn a_path_past_the_input_capacity_is_the_runtime_limit() {
let (d, ids, _, [text, ..]) = mixed();
let (bytes, _) = encode_input_with(&d, &ids, &[], usize::MAX).unwrap();
assert_eq!(encode_input_with(&d, &ids, &[], bytes.len()).unwrap().0, bytes, "the exact size fits");
let e = encode_input_with(&d, &[ids[0], ids[1], ids[2], text], &[InputKind::Type], bytes.len() - 1).unwrap_err();
assert_eq!(e.to_string(), "plug-in exceeded its memory budget (the selection is too large to hand over)");
}
/// An output lists at most [`MAX_OBJECTS`] objects and must return every read-only one, so an input
/// with more read-only objects than that is a limit; exactly that many are given.
#[test]
fn more_read_only_objects_than_an_output_lists_is_a_limit() {
let (mut d, _, layer, [.., ghost]) = mixed();
let mut ids = vec![ghost];
for _ in 1..MAX_OBJECTS {
let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY });
ids.push(d.insert(Some(layer), usize::MAX, n).unwrap());
}
let (_, read_only) = encode_input_with(&d, &ids, &[InputKind::SymbolInstance], usize::MAX).unwrap();
assert_eq!(read_only.len(), MAX_OBJECTS);
let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY });
ids.push(d.insert(Some(layer), usize::MAX, n).unwrap());
let e = encode_input_with(&d, &ids, &[InputKind::SymbolInstance], usize::MAX).unwrap_err();
assert_eq!(e.to_string(), format!("plug-in exceeded its object budget (more than {MAX_OBJECTS} read-only objects)"));
// A filter that doesn't list the kind is given none of them.
assert!(encode_input_with(&d, &ids, &[], usize::MAX).unwrap().1.is_empty());
}
/// Objects that clip a clip group are left out, and so are those inside a group that clips one;
/// those it clips are given. This covers a filter that lists no `inputs` too.
#[test]
fn objects_that_clip_a_clip_group_are_not_inputs() {
let (mut d, _, layer, [text, embedded, ..]) = mixed();
let (t, e) = (Arc::new(d.node(text).unwrap().clone()), Arc::new(d.node(embedded).unwrap().clone()));
let mut copy = |n: &Arc<Node>| {
let mut c = (**n).clone();
c.id = d.alloc_id();
Arc::new(c)
};
let (t_source, e_source, t_inner, e_inner, t_clipped) = (copy(&t), copy(&e), copy(&t), copy(&e), copy(&t));
let inner = Node::group(d.alloc_id(), vec![t_inner.clone(), e_inner.clone()]);
let mut by_group = Node::group(d.alloc_id(), vec![Arc::new(inner), t_clipped.clone()]);
by_group.set_clips(true);
let mut by_type = Node::group(d.alloc_id(), vec![t_source.clone(), e_source.clone()]);
by_type.set_clips(true);
let by_group = d.insert(Some(layer), usize::MAX, by_group).unwrap();
let by_type = d.insert(Some(layer), usize::MAX, by_type).unwrap();
let path = |d: &mut Document, x: f64| Arc::new(Node::path(d.alloc_id(), square(x), Appearance::default()));
let (part, clipped_by_compound, in_source, clipped_by_paths) = (path(&mut d, 0.0), path(&mut d, 10.0), path(&mut d, 20.0), path(&mut d, 30.0));
let compound = Node::new(d.alloc_id(), NodeKind::Compound { children: vec![part], rule: FillRule::NonZero });
let mut by_compound = Node::group(d.alloc_id(), vec![Arc::new(compound), clipped_by_compound.clone()]);
by_compound.set_clips(true);
let source = Node::group(d.alloc_id(), vec![in_source]);
let mut by_paths = Node::group(d.alloc_id(), vec![Arc::new(source), clipped_by_paths.clone()]);
by_paths.set_clips(true);
let by_compound = d.insert(Some(layer), usize::MAX, by_compound).unwrap();
let by_paths = d.insert(Some(layer), usize::MAX, by_paths).unwrap();
let ids = |list: &[NodeId]| filter_targets_with(&d, list, &InputKind::ALL);
assert_eq!(ids(&[by_type]), [e_source.id], "the type clips; the image is clipped");
assert_eq!(ids(&[by_group]), [t_clipped.id], "the group's type and image clip");
assert_eq!(ids(&[by_compound]), [clipped_by_compound.id], "the compound path clips");
assert_eq!(ids(&[by_paths]), [clipped_by_paths.id], "the group's path clips");
assert_eq!(filter_targets(&d, &[by_compound]), [clipped_by_compound.id], "a filter without inputs");
assert_eq!(filter_targets(&d, &[by_paths]), [clipped_by_paths.id], "a filter without inputs");
}
/// A TIFF with more strips and planes than the decoder can count comes as `"unknown"`, without its
/// pixels, and never reaches the decoder.
#[test]
fn a_tiff_too_large_to_count_is_unknown_and_has_no_pixels() {
let entries: [(u16, u16, u32); 9] =
[(256, 4, 1), (257, 4, 1 << 31 | 1), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)];
let mut tiff = b"II*\0\x08\0\0\0".to_vec();
tiff.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
for (tag, ty, v) in entries {
tiff.extend(tag.to_le_bytes());
tiff.extend(ty.to_le_bytes());
tiff.extend(1u32.to_le_bytes());
tiff.extend(v.to_le_bytes());
}
tiff.extend([0; 4]);
let (mut d, _, layer, _) = mixed();
d.images.insert("big".into(), ImageBlob::new("image/tiff", tiff));
let big = image_node(&mut d, "big", (1, 1), None);
let big = d.insert(Some(layer), usize::MAX, big).unwrap();
let (bytes, _) = encode_input_with(&d, &[big], &[InputKind::Image], usize::MAX).unwrap();
let input: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(input["objects"][0]["colorMode"], "unknown");
assert_eq!(input["objects"][0]["pixelsOmitted"], true);
}
/// A field a plug-in adds to a read-only object is named in the refusal with its control and
/// direction characters escaped, and cut short.
#[test]
fn a_changed_field_is_named_escaped_and_short() {
let (d, _, layer, [text, ..]) = mixed();
let targets = filter_targets_with(&d, &[layer], &[InputKind::Type]);
let (bytes, read_only) = encode_input_with(&d, &targets, &[InputKind::Type], usize::MAX).unwrap();
let mut v: Value = serde_json::from_slice(&bytes).unwrap();
let at = v["objects"].as_array().unwrap().iter().position(|o| o["id"] == json!(text.0)).unwrap();
let key = format!("\u{202E}{}", "k".repeat(500));
v["objects"][at][key.as_str()] = json!(1);
let e = decode_output_with(v.to_string().as_bytes(), &read_only).unwrap_err().to_string();
let shown = e.split('\'').nth(1).unwrap_or_default();
assert!(!e.contains('\u{202E}') && shown.starts_with("\\u{202E}kkk") && shown.ends_with('…'), "{e}");
assert_eq!(shown.chars().count(), crate::permissions::MAX_SHOWN_FIELD, "{e}");
}
/// A filter given read-only objects returns each one as `{"id": …}` or exactly as given (numbers by
/// value), and they stay as they are, new objects listed after one going just above it. Any change,
/// an object left out or listed twice, and a new object of such a kind are refused before anything
/// changes.
#[test]
fn read_only_kind_field_and_omission_refuse() {
let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed();
let targets = filter_targets_with(&d, &[layer], &InputKind::ALL);
let (bytes, read_only) = encode_input_with(&d, &targets, &InputKind::ALL, usize::MAX).unwrap();
assert_eq!(read_only.len(), 5);
let input: Value = serde_json::from_slice(&bytes).unwrap();
let before = d.clone();
let out = decode_output_with(&bytes, &read_only).unwrap();
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5);
apply_output(&mut d, &targets, out.objects, None, &keep).unwrap();
assert_eq!(d, before, "an exact echo changes nothing");
// A filter that doesn't list `inputs` could never hand them back.
assert!(decode_output(&bytes).is_err());
// Numbers written another way are the same numbers.
let mut echo = input.clone();
echo["objects"][3]["transform"] = json!([1, 0, 0, 1, 5, 50]);
assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_ok());
// So is an id written as a float; a fractional one names no object.
let id = input["objects"][3]["id"].as_u64().unwrap();
let mut echo = input.clone();
echo["objects"][3]["id"] = json!(id as f64);
let out = decode_output_with(echo.to_string().as_bytes(), &read_only).unwrap();
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5);
echo["objects"][3]["id"] = json!(id as f64 + 0.5);
assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_err());
// `{"id"}` alone keeps it; the paths left out are deleted and a new path goes above the type.
let ro = |id: NodeId| json!({"id": id.0});
let out = json!({"objects": [ro(text), {"name": "new", "path": square(0.0)}, ro(embedded), ro(linked), ro(star), ro(ghost)]});
let made = apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &read_only).unwrap().objects, None, &keep).unwrap();
assert_eq!(names(&d, layer), ["?", "?", "T", "new", "E", "L", "S", "G"]);
assert_eq!(made.len(), 6);
assert!(ids.iter().all(|id| d.node(*id).is_none()));
assert_eq!(d.node(text), before.node(text), "the type is as it was");
// Refused, naming the object and the field.
let t = text.0;
let changed = |f: &dyn Fn(&mut Value)| {
let mut v = input.clone();
f(&mut v);
v
};
for (out, says) in [
(
changed(&|v| v["objects"][3]["text"]["runs"][0]["text"] = json!("Bye")),
format!("objects[3] (id {t}): read-only field 'text.runs[0].text' changed"),
),
(changed(&|v| v["objects"][3]["type"] = json!("path")), format!("(id {t}): read-only field 'type' changed")),
(changed(&|v| v["objects"][3]["opacity"] = json!(0.5)), format!("(id {t}): read-only field 'opacity' changed")),
(changed(&|v| v["objects"][3]["transform"][4] = json!(6)), format!("(id {t}): read-only field 'transform[4]' changed")),
(
changed(&|v| {
v["objects"][4].as_object_mut().unwrap().remove("pixelsBase64");
}),
format!("(id {}): read-only field 'pixelsBase64' changed", embedded.0),
),
(changed(&|v| v["objects"][6]["symbolId"] = json!("Moon")), format!("(id {}): read-only field 'symbolId' changed", star.0)),
(
changed(&|v| {
v["objects"].as_array_mut().unwrap().remove(3);
}),
format!("objects (id {t}): a read-only object was left out"),
),
(changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"id": ghost.0}))), format!("objects[8] (id {}): is listed twice", ghost.0)),
(
changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"type": "type", "path": square(0.0)}))),
"`type` must be \"path\" or \"compound\"".to_string(),
),
] {
let e = decode_output_with(out.to_string().as_bytes(), &read_only).unwrap_err().to_string();
assert!(e.contains(&says), "{says}: {e}");
}
// Writing one or leaving one out is refused when applying too, whatever decoded the output.
let mut d = before.clone();
let write = vec![OutObject { id: Some(text), name: Some("Renamed".into()), ..OutObject::default() }];
let e = apply_output(&mut d, &[text], write, None, &keep).unwrap_err().to_string();
assert!(e.contains("is read-only"), "{e}");
let e = apply_output(&mut d, &[text], vec![], None, &keep).unwrap_err().to_string();
assert!(e.contains("a read-only object was left out"), "{e}");
assert_eq!(d, before);
}
/// A filter's `documentData`: absent keeps what the plug-in keeps, `null` forgets it, any other JSON
/// of at most [`MAX_DOCUMENT_DATA`] bytes is kept, and more refuses the whole output.
#[test]
+3
View File
@@ -22,6 +22,9 @@ pub const API_LEVEL: u32 = 2;
/// `settings` and `cache` permissions they need.
pub const PROGRESS_API: u32 = 2;
/// The API level that adds a filter's `inputs` ([`crate::InputKind`]).
pub const INPUTS_API: u32 = 2;
/// Most permissions a manifest may list.
pub const MAX_PERMISSIONS: usize = 16;
+3 -1
View File
@@ -90,7 +90,9 @@ pub fn list() -> Vec<Arc<Plugin>> {
/// Uninstalls `id`; false if it wasn't installed.
pub fn remove(id: &str) -> bool {
let gone = PLUGINS.write().unwrap_or_else(|e| e.into_inner()).remove(id).is_some();
// The revision moves while the write lock is held, as in `install`.
let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner());
let gone = plugins.remove(id).is_some();
if gone {
REVISION.fetch_add(1, Ordering::Relaxed);
}
+4 -1
View File
@@ -32,6 +32,9 @@ pub const HEAVY_FUEL: u64 = 1_000_000_000;
/// Wall-clock budget of a heavy filter's run, in milliseconds (native builds).
pub const HEAVY_WALL_TIME_MS: u64 = 300_000;
/// The limit a run reports when its input and parameters together are past half its memory.
pub(crate) const SELECTION_TOO_LARGE: &str = "memory budget (the selection is too large to hand over)";
/// A heavy run's budget as people read it: (minutes it may take, times the instructions a light
/// run gets).
pub fn heavy_terms() -> (u64, u64) {
@@ -311,7 +314,7 @@ impl Plugin {
fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report) -> Result<Vec<u8>> {
let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2);
total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?;
total.ok_or_else(|| Error::Limit(SELECTION_TOO_LARGE.into()))?;
let deadline = Deadline::new(limits.wall_time_ms);
let m = &self.manifest;
let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline).with_storage(crate::registry::storage(&m.id));
+1 -1
View File
@@ -39,7 +39,7 @@ use breaking::tab_advance;
use breaking::{break_line, candidates};
#[cfg(test)]
pub(crate) use driver::layout_faces_per_call;
pub use driver::{decorations, fit_first_line, layout, layout_with, scaled};
pub use driver::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled};
use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths};
#[cfg(test)]
+32
View File
@@ -62,6 +62,38 @@ pub fn fit_first_line(db: &FontDb, t: &mut TextObject) -> bool {
true
}
/// Area type whose source lays out `lines` lines in its frame, more than fit here: a font that isn't
/// available shows in the fallback font, whose lines may reach lower, and the source may set a last
/// line whose descent passes the frame's bottom, which isn't drawn here. For importers: the frame is
/// made taller, from its bottom, by its lines' deepest descent plus 0.05 pt, when that lays out
/// more of the source's lines and no more than it has. Horizontal type only. Whether it was.
pub fn fit_source_lines(db: &FontDb, t: &mut TextObject, lines: usize) -> bool {
let TextKind::Area { frame } = &t.kind else { return false };
if t.vertical {
return false;
}
let Some(b) = frame.bounds().filter(|b| b.height() > 1e-6) else { return false };
let laid = layout(db, t);
let have = laid.lines.len();
if !laid.overflow || have == 0 || have >= lines {
return false;
}
// The deepest descent of its lines (a little more, for the rounding of the layout).
let Some(descent) = laid.lines.iter().map(|l| l.descent).reduce(f64::max).filter(|d| d.is_finite() && *d > 0.0) else { return false };
let by = (b.height() + descent + 0.05) / b.height();
if !(by.is_finite() && by > 1.0) {
return false;
}
let taller = frame.transformed(Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, by) * Affine::translate((0.0, -b.y0)));
let short = std::mem::replace(&mut t.kind, TextKind::Area { frame: taller });
let now = layout(db, t).lines.len();
if now > have && now <= lines {
return true;
}
t.kind = short;
false
}
/// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a
/// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's
/// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift
+58 -7
View File
@@ -410,6 +410,9 @@ struct LineJustification {
start_x: f64,
after: Vec<Option<usize>>,
kashidas: Vec<(usize, f64)>,
/// Right-aligned area type: the line's last tab, which takes the text after it to the right
/// edge (the text before it sits at its stops, from the left edge).
last_tab: Option<usize>,
}
fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool {
@@ -464,6 +467,9 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
true
}
/// The geometry of the line of `paragraph` from glyph `index`, placed at `position`: where it
/// ends with its trailing spaces trimmed, its hyphen, its room and margins, the glyphs that hang or
/// stay out of justification, and whether it is the paragraph's last line.
fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData<'_, '_>, position: LinePosition, index: usize) -> LineGeometry {
let para = paragraph.para;
let sg = &paragraph.sg;
@@ -571,7 +577,11 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
// Justification adds nothing to the list marker's glyphs.
let mut held = held;
held.extend(index..paragraph.marker.min(body));
let last_of_para = position.end >= n || position.end.checked_sub(1).and_then(|i| sg.get(i)).is_some_and(|g| g.ch == '\u{2028}');
// A line a soft return ends is justified as a wrapped one is: only the paragraph's last line
// keeps the alignment of Justify Left, Center or Right. A paragraph that ends in a soft return
// ends with the empty line after it (`run_paragraph_lines`).
let soft_return = position.end > index && sg.get(position.end - 1).is_some_and(|g| g.ch == '\u{2028}');
let last_of_para = position.end >= n && !soft_return;
LineGeometry {
position,
index,
@@ -631,6 +641,9 @@ fn flow_line_flex(glyph: usize, flex: &[Flex], held: &[usize], hung: &[usize]) -
value
}
/// How `line` of `paragraph` is aligned: where it starts, what justification adds to its spaces,
/// gaps and glyphs, its kashidas, and, in right-aligned area type, its last tab, which takes the
/// text after it to the right edge.
fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry) -> LineJustification {
let para = paragraph.para;
let sg = &paragraph.sg;
@@ -718,7 +731,14 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
}
}
let position = &line.position;
let start_x = if justify || pushed_in {
// Right-aligned area type with a tab: the text before the last tab sits at its stops, from the
// left edge, and the text after it against the right edge, as Illustrator's own layout in a
// user's `.ai` sets a price list. Centred and justified lines are aligned as a whole.
let last_tab = (para.justify == Justify::Right && !justify && !paragraph.rtl && paragraph.regions.is_some())
// A tab ending the line counts: its text sits at the left edge, the tab up to the right one.
.then(|| (line.index..line.position.end).rev().find(|&j| sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0)))
.flatten();
let start_x = if justify || pushed_in || last_tab.is_some() {
line.ax0 - line.opt_l
} else if paragraph.regions.is_none() {
match align {
@@ -733,9 +753,43 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
_ => line.ax1 + line.opt_r - width,
}
};
LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas }
LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab }
}
/// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop
/// ([`tab_advance`], measured from `tab_origin` less `tab_shift`), or, for the last tab of
/// right-aligned area type ([`LineJustification::last_tab`]), up to where the rest of the line
/// ends on the right edge. The tab after a list marker keeps the width `prepend_marker` gave it
/// (`advance`).
fn tab_glyph<'p>(
paragraph: &'p ParagraphData<'_, '_>,
line: &LineGeometry,
justification: &LineJustification,
glyph_index: usize,
x: f64,
(tab_origin, tab_shift): (f64, f64),
advance: f64,
) -> (f64, &'p str) {
let sg = &paragraph.sg;
// The tab of a list marker keeps the width `prepend_marker` gave it.
if !sg.get(glyph_index).is_some_and(|g| g.len > 0) {
return (advance, "");
}
let (advance, leader) = tab_advance(&paragraph.para.tabs, tab_origin, x - tab_shift, sg.get(glyph_index + 1..line.trimmed).unwrap_or_default());
if justification.last_tab != Some(glyph_index) {
return (advance, leader);
}
// The width of what follows up to the line's end (nothing, when the tab ends the line), with
// the hyphen a hyphenated line ends with.
let after = sg.get(glyph_index + 1..line.trimmed).unwrap_or_default();
let hyphen = line.hyphen.as_ref().map_or(0.0, |g| g.adv);
let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::<f64>() - line.end_trim + hyphen };
((line.ax1 + line.opt_r - rest - x).max(0.0), leader)
}
/// Lay out `line` of `paragraph` as `justification` aligns it: each glyph placed along the
/// baseline (tabs as [`tab_glyph`] sets them), its hyphen, and the line's record, then the pen
/// moved on past it.
fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) {
let para = paragraph.para;
let sg = &paragraph.sg;
@@ -775,10 +829,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
let mut scale = 1.0;
let mut leader = "";
if glyph.ch == '\t' {
// The tab of a list marker keeps the width `prepend_marker` gave it.
if glyph.len > 0 {
(advance, leader) = tab_advance(&para.tabs, tab_origin, x - tab_shift, &sg[glyph_index + 1..trimmed.max(glyph_index + 1)]);
}
(advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance);
} else if glyph_index < body {
let flex = flow_line_flex(glyph_index, &paragraph.flex, &line.held, &line.hung);
if is_word_space(glyph) {
+1 -1
View File
@@ -47,7 +47,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout};
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
pub use layout::{decorations, fit_first_line, layout, layout_with, scaled};
pub use layout::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled};
pub use vectorcraft_doc::TextObject;
pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign};
+89 -1
View File
@@ -306,6 +306,39 @@ fn area_alignment_and_justify() {
assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6);
}
/// A source that sets a last line whose descent passes its frame's bottom: the frame grows from its
/// bottom by its lines' deepest descent plus 0.05 pt, so that line shows, and only as many lines as
/// the source has.
#[test]
fn fit_source_lines_keeps_the_last_lines_the_source_sets() {
let text = "one\ntwo\nthree";
let tall = layout(db(), &area(text, style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), Justify::Left));
let third = &tall.lines[2];
// The third baseline is in the frame, its descent isn't.
let frame = Rect::new(0.0, 0.0, 200.0, third.baseline + third.descent / 2.0);
let mut t = area(text, style(12.0), frame, Justify::Left);
let short = layout(db(), &t);
assert!(short.overflow && short.lines.len() == 2);
// A source with no more lines than fit leaves the frame as it is.
assert!(!fit_source_lines(db(), &mut t, 2));
assert_eq!(layout(db(), &t).lines.len(), 2);
assert!(fit_source_lines(db(), &mut t, 3));
let fitted = layout(db(), &t);
assert!(!fitted.overflow && fitted.lines.len() == 3);
let TextKind::Area { frame: grown } = &t.kind else { panic!("area type") };
let grown = grown.bounds().unwrap();
assert!(grown.y0 == frame.y0 && grown.y1 > frame.y1 && grown.y1 <= frame.y1 + third.descent + 0.1, "{grown:?}");
// Fitted, nothing more to do.
assert!(!fit_source_lines(db(), &mut t, 3));
// More than a descent short: a line the source can't have set in its frame stays out.
let frame = Rect::new(0.0, 0.0, 200.0, tall.lines[1].baseline + tall.lines[1].descent + 1.0);
let mut t = area(text, style(12.0), frame, Justify::Left);
assert!(!fit_source_lines(db(), &mut t, 3));
// Point type and vertical type aren't fitted.
let mut p = point(text, style(12.0));
assert!(!fit_source_lines(db(), &mut p, 9));
}
#[test]
fn indents_and_paragraph_spacing() {
let frame = Rect::new(0.0, 0.0, 200.0, 400.0);
@@ -880,12 +913,67 @@ fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() {
}
}
/// Right-aligned area type with tabs: the text before the last tab sits at its stops, measured from
/// the frame's left edge, and the text after the last tab against the right edge, as Illustrator's
/// own layout in a user's `.ai` (used locally, not committed) sets a list of an item and two
/// prices: with a right stop at 269.29 and a left one at 272.13, the item starts at 0, the first
/// price ends at 269.29 and the second ends on the edge.
#[test]
fn right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge() {
use vectorcraft_doc::{TabAlign, TabStop};
let stop = |position: f64, align: TabAlign| TabStop { position, align, leader: String::new(), align_on: '.' };
let frame = Rect::new(0.0, 0.0, 336.0, 100.0);
let mut t = area("Item\t12.50\t24.00", style(13.0), frame, Justify::Right);
t.para.tabs = vec![stop(269.29, TabAlign::Right), stop(272.13, TabAlign::Left)];
let lay = layout(db(), &t);
let glyph = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).unwrap();
let end = |byte: usize| glyph(byte).origin.x + glyph(byte).advance;
assert!(glyph(0).origin.x.abs() < 1e-6, "the item starts at the left edge: {}", glyph(0).origin.x);
let first_price_end = "Item\t12.50".len() - 1;
assert!((end(first_price_end) - 269.29).abs() < 1e-6, "the first price ends on its right stop: {}", end(first_price_end));
let last = t.plain_text().len() - 1;
assert!((end(last) - 336.0).abs() < 1e-6, "the last price ends on the edge: {}", end(last));
// One tab: the text after it is against the edge, whatever its stop.
let mut one = area("Another item\t8.00", style(13.0), frame, Justify::Right);
one.para.tabs = t.para.tabs.clone();
let lay = layout(db(), &one);
let first = lay.glyphs.first().unwrap();
let last = lay.glyphs.last().unwrap();
assert!(
first.origin.x.abs() < 1e-6 && (last.origin.x + last.advance - 336.0).abs() < 1e-6,
"{} {}",
first.origin.x,
last.origin.x + last.advance
);
// A tab ending the line (a heading in the user's file): the text before it at the left edge,
// the tab to the right edge.
let lay = layout(db(), &area("Heading\t\nNext", style(13.0), frame, Justify::Right));
assert!(lay.glyphs.first().unwrap().origin.x.abs() < 1e-6, "{}", lay.glyphs.first().unwrap().origin.x);
let tab = lay.glyphs.iter().find(|g| g.byte == 7).unwrap();
assert!((tab.origin.x + tab.advance - 336.0).abs() < 1e-6, "the tab ends at {}", tab.origin.x + tab.advance);
// Text after the last tab that breaks with a hyphen: the hyphen ends on the edge too.
let text = "A\tinternationalization";
let mut hyphenated = area(text, style(13.0), Rect::new(0.0, 0.0, 100.0, 200.0), Justify::Right);
hyphenated.para.hyphenate = true;
hyphenated.para.composer = vectorcraft_doc::Composer::SingleLine;
let lay = layout(db(), &hyphenated);
let line = &lay.lines[0];
assert!(line.end > 2 && line.end < text.len(), "the first line breaks inside the word: {line:?}");
let last = lay.glyphs.get(line.glyph_end - 1).unwrap();
assert!((last.origin.x + last.advance - 100.0).abs() < 1e-6, "the hyphen ends at {}", last.origin.x + last.advance);
// Without a tab, the line is right aligned as a whole.
let lay = layout(db(), &area("Name", style(13.0), frame, Justify::Right));
assert!(lay.glyphs.first().unwrap().origin.x > 200.0);
}
#[test]
fn a_centred_or_right_aligned_line_sets_its_tabs_as_if_it_started_at_the_left() {
use vectorcraft_doc::{TabAlign, TabStop};
// "A<tab>B" with a stop at 100 pt is 100 pt plus B wide whatever the alignment: the whole line
// moves, the gap between A and B stays 100 pt, and a right aligned line ends on the edge (#2457).
for justify in [Justify::Center, Justify::Right, Justify::JustifyCenter, Justify::JustifyRight] {
// (Right aligned area type sets the text after its last tab against the edge instead: see
// `right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`.)
for justify in [Justify::Center, Justify::JustifyCenter, Justify::JustifyRight] {
let mut t = area("A\tB", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), justify);
t.para.tabs = vec![TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
let lay = layout(db(), &t);
+21 -7
View File
@@ -1970,14 +1970,28 @@ fn soft_return_breaks_line_without_new_paragraph() {
assert_eq!(&t.plain_text()[l1.start..l1.end], "Line 2");
}
/// A line ending in a soft return is justified as a wrapped line is: only the paragraph's last
/// line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not
/// committed) spreads such lines to the frame's edge.
#[test]
fn soft_return_in_justified_text_does_not_spread() {
let text = "Short\u{2028}A much longer second line of text inside the frame.";
let t = area(text, style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::JustifyLeft);
let lay = layout(db(), &t);
assert!(lay.lines.len() >= 2);
let l0 = &lay.lines[0];
assert!(l0.x1 - l0.x0 < 100.0, "short line ending with soft return is not justified: {}", l0.x1 - l0.x0);
fn soft_return_in_justified_text_spreads_like_a_wrapped_line() {
let frame = Rect::new(0.0, 0.0, 300.0, 200.0);
let text = "A line of a few words\u{2028}and the last line.";
for justify in [Justify::JustifyLeft, Justify::JustifyCenter, Justify::JustifyRight] {
let lay = layout(db(), &area(text, style(12.0), frame, justify));
assert_eq!(lay.lines.len(), 2);
let (l0, l1) = (&lay.lines[0], &lay.lines[1]);
assert!((l0.x0 - frame.x0).abs() < 1e-6 && (l0.x1 - frame.x1).abs() < 1e-6, "{justify:?}: {l0:?}");
assert!(l1.x1 - l1.x0 < 200.0, "{justify:?}: the last line isn't justified: {l1:?}");
// A soft return ending the paragraph: the empty line after it is the last one, so the line
// it ends is justified too.
let lay = layout(db(), &area("A line of a few words\u{2028}", style(12.0), frame, justify));
let l0 = lay.lines.first().unwrap();
assert!((l0.x0 - frame.x0).abs() < 1e-6 && (l0.x1 - frame.x1).abs() < 1e-6, "{justify:?}, trailing soft return: {l0:?}");
}
// Aligned type stays aligned.
let lay = layout(db(), &area(text, style(12.0), frame, Justify::Left));
assert!(lay.lines[0].x1 < frame.x1 - 100.0);
}
#[test]
+261 -16
View File
@@ -32,7 +32,7 @@ mod live_paint;
mod lookup;
mod map;
use cache::RegionCache;
use cache::{BACKGROUND_SEGMENTS, CacheKey, RegionCache};
#[cfg(test)]
use live_paint::LpHover;
use live_paint::lp_color;
@@ -45,7 +45,10 @@ pub use lookup::{
is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for,
sources, visible_edge_near, visible_face_at,
};
pub use map::{BuilderMap, HATCH, HIGHLIGHT_RED, Hit, Touched, hatch, maps_built, sample_polyline, sorted_roots};
pub use map::{
BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs,
merges_computed, sample_polyline, sorted_roots,
};
/// Small, Medium and Large gap lengths in points.
pub const GAP_LENGTHS: [f64; 3] = [3.0, 6.0, 12.0];
@@ -169,6 +172,52 @@ pub struct ShapeBuilderTool {
/// The pointer went more than [`CLICK_TOL_PX`] from the press during the drag, so the gesture
/// is not a click.
far: bool,
/// The merge of the last gesture over a large selection, being computed on another thread.
merging: Option<PendingMerge>,
/// That merge once computed, for `shapeBuilder.merge` ([`Tool::shape_builder_merge`]).
prepared: Option<Arc<PreparedMerge>>,
/// A gesture made while the map of a large selection is being built on another thread.
deferred: Option<Deferred>,
}
/// A Shape Builder gesture made while the map of the selection was being built on another
/// thread: the map key and the inputs it was made over, its pointer events, which [`Tool::poll`]
/// replays once the map is there, and whether the release has come.
struct Deferred {
key: CacheKey,
shapes: Vec<po::Shape>,
events: Vec<PointerEvent>,
released: bool,
}
/// A Shape Builder merge being computed on another thread: the map key it is for (the document,
/// its revision and the selection), the `shapeBuilder.merge` params that apply it, what it was
/// computed from, and the channel the result arrives on.
struct PendingMerge {
key: CacheKey,
params: Value,
map: Arc<BuilderMap>,
touched: Touched,
gap: f64,
inputs: Vec<MergeInput>,
done: Receiver<MergeGeometry>,
}
/// Computes [`merge_geometry`] on a new thread; None when no thread starts.
#[cfg(not(target_arch = "wasm32"))]
fn spawn_merge(map: Arc<BuilderMap>, touched: Touched, gap: f64, inputs: Vec<MergeInput>) -> Option<Receiver<MergeGeometry>> {
let (tx, done) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("vectorcraft-shape-builder-merge".into()).spawn(move || {
// The tool may have dropped the receiver meanwhile (another tool, another document).
let _ = tx.send(merge_geometry(&map, &touched, gap, &inputs));
});
spawned.is_ok().then_some(done)
}
/// The web build has no threads: the merge runs in the command.
#[cfg(target_arch = "wasm32")]
fn spawn_merge(_map: Arc<BuilderMap>, _touched: Touched, _gap: f64, _inputs: Vec<MergeInput>) -> Option<Receiver<MergeGeometry>> {
None
}
/// How close (screen px) the pointer must come to a line piece or an edge to delete it.
@@ -213,6 +262,132 @@ impl ShapeBuilderTool {
alt.then(|| cx.tol(LINE_TOL_PX))
}
/// Starts computing the merge `params` describe on another thread when the map of the
/// selection is current and has more than [`BACKGROUND_SEGMENTS`] segments; `false` when it
/// did not (small selections, the web build), and the command computes it.
fn start_merge(&mut self, cx: &ToolContext, params: &Value) -> bool {
let key = RegionCache::key_for(cx, self.opts.open_filled_as_closed);
if cfg!(target_arch = "wasm32") || params.get("splitStroke").is_some() || self.cache.key.as_ref() != Some(&key) {
return false;
}
let map = Arc::clone(&self.cache.map);
let segments: usize = map.shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::<usize>()).sum();
if segments <= BACKGROUND_SEGMENTS {
return false;
}
// What the command reads from its params, read the same way.
let num = |v: &Value| v.as_f64().unwrap_or(f64::NAN);
let pts: Vec<Point> = params["points"].as_array().map(|a| a.iter().map(|q| Point::new(num(&q[0]), num(&q[1]))).collect()).unwrap_or_default();
let marquee = params["rect"].as_array().filter(|r| r.len() == 4).map(|r| {
let (x, y) = (num(&r[0]), num(&r[1]));
(Point::new(x, y), Point::new(x + num(&r[2]), y + num(&r[3])))
});
let piece_tol = (params["erase"] == json!(true)).then(|| params["tolerance"].as_f64().unwrap_or(2.0).clamp(0.0, 1000.0));
let touched = map.merge_touched(&pts, marquee, piece_tol);
if touched.is_empty() {
return false;
}
let roots = sorted_roots(cx.doc, &cx.selection.objects);
let (_, leaves) = shape_builder_shapes(cx.doc, &roots, self.opts.open_filled_as_closed);
let inputs = merge_inputs(&leaves, self.opts.open_filled_as_closed);
let gap = self.gap();
let Some(done) = spawn_merge(Arc::clone(&map), touched.clone(), gap, inputs.clone()) else { return false };
self.merging = Some(PendingMerge { key, params: params.clone(), map, touched, gap, inputs, done });
true
}
/// Takes the merge being computed on another thread once it has arrived, or with `wait` once
/// it arrives: the action that applies it, while the document, the selection and its inputs are
/// those it was computed for. When one of them changed (an edit or a selection made other than
/// by a command, which applies the merge first), the merge is dropped at once, without waiting
/// for the thread. A copy of the document (a save stamping its dates) and a new revision that leaves
/// the inputs as they were keep the merge. When the thread failed, the command computes the
/// merge and reports what went wrong.
fn poll_merge(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
let Some(mut m) = self.merging.take() else { return vec![] };
let now = RegionCache::key_for(cx, m.key.3);
if (now.1.0, &now.2) != (m.key.1.0, &m.key.2) {
return vec![];
}
// A new revision that leaves the selection's inputs as they were (a view setting or a
// preference that redraws the document) keeps the merge.
if now.1 != m.key.1 {
if !self.inputs_are(cx, m.key.3, &m.map.shapes) {
return vec![];
}
m.key = now;
}
let got = if wait { m.done.recv().map_err(|_| TryRecvError::Disconnected) } else { m.done.try_recv() };
let geometry = match got {
Ok(g) => Some(g),
Err(TryRecvError::Empty) => {
self.merging = Some(m);
return vec![];
}
Err(TryRecvError::Disconnected) => None,
};
self.prepared = geometry.map(|geometry| Arc::new(PreparedMerge { map: m.map, touched: m.touched, gap: m.gap, inputs: m.inputs, geometry }));
vec![Action::Exec("shapeBuilder.merge".into(), m.params)]
}
/// Is the map of the selection being built on another thread (it is not current yet)?
fn map_building(&mut self, cx: &ToolContext) -> bool {
self.cache.ready(cx, self.opts.open_filled_as_closed).is_none() && self.cache.pending.is_some()
}
/// Are the inputs of the selection in `cx` `shapes`? The map, or the map being built, is filed
/// under the key of `cx` first when they are its inputs ([`RegionCache::rekey`]).
fn inputs_are(&mut self, cx: &ToolContext, open_filled_as_closed: bool, shapes: &[po::Shape]) -> bool {
let _ = self.cache.rekey(cx, open_filled_as_closed);
let key = RegionCache::key_for(cx, open_filled_as_closed);
if self.cache.key.as_ref() == Some(&key) {
return self.cache.shapes == shapes;
}
if let Some(p) = self.cache.pending.as_ref().filter(|p| p.key == key) {
return p.shapes == shapes;
}
let roots = sorted_roots(cx.doc, &cx.selection.objects);
shape_builder_shapes(cx.doc, &roots, open_filled_as_closed).0 == shapes
}
/// Is a gesture made while the map was being built released and waiting for the map?
fn deferred_released(&self) -> bool {
self.deferred.as_ref().is_some_and(|d| d.released)
}
/// Replays the released gesture made while the map was being built once the map is there, or
/// with `wait` once it is built, and with `wait` also waits for the merge it starts: the
/// actions the gesture gives. When the document, the selection or its inputs changed since the
/// gesture, it is dropped; a new revision that leaves them as they were keeps it.
fn replay_deferred(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
if !self.deferred_released() {
return vec![];
}
let ofac = self.opts.open_filled_as_closed;
let now = RegionCache::key_for(cx, ofac);
if let Some(mut d) = self.deferred.take() {
if (d.key.1.0, &d.key.2) != (now.1.0, &now.2) || (d.key.1 != now.1 && !self.inputs_are(cx, ofac, &d.shapes)) {
return vec![];
}
d.key = now;
self.deferred = Some(d);
}
if wait {
self.cache.get(cx, self.opts.open_filled_as_closed);
} else if self.map_building(cx) {
return vec![];
}
let Some(d) = self.deferred.take() else { return vec![] };
let mut acts = vec![];
for ev in &d.events {
acts.extend(self.pointer(cx, ev));
}
if wait {
acts.extend(self.poll_merge(cx, true));
}
acts
}
/// Adds what the drag from `from` to `to` touches, picked as `shapeBuilder.merge` picks it.
fn touch(&mut self, cx: &ToolContext, from: Option<Point>, to: Point) {
let pts: Vec<Point> = from.into_iter().chain([to]).collect();
@@ -274,6 +449,8 @@ impl Tool for ShapeBuilderTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
// The command has taken the merge computed for the last gesture by now.
self.prepared = None;
match ev.kind {
PointerKind::Move => {
self.alt = ev.mods.alt;
@@ -282,7 +459,8 @@ impl Tool for ShapeBuilderTool {
vec![]
}
PointerKind::Down => {
if cx.selection.is_empty() {
// While a merge is being computed, a press starts no gesture.
if cx.selection.is_empty() || self.editing() {
return vec![];
}
self.dragging = true;
@@ -291,11 +469,35 @@ impl Tool for ShapeBuilderTool {
self.points = vec![p];
self.far = false;
self.touched = Touched::default();
// While the map is being built on another thread, the gesture is recorded and
// replayed once the map is there.
if self.map_building(cx) {
let key = RegionCache::key_for(cx, self.opts.open_filled_as_closed);
let shapes = match self.cache.pending.as_ref().filter(|p| p.key == key) {
Some(p) => p.shapes.clone(),
None => self.cache.inputs(cx, self.opts.open_filled_as_closed).0,
};
self.deferred = Some(Deferred { key, shapes, events: vec![*ev], released: false });
return vec![];
}
self.touch(cx, None, p);
vec![]
}
PointerKind::Drag if self.dragging => {
self.at = Some(p);
if let Some(d) = &mut self.deferred {
// The drag's line or marquee shows meanwhile.
d.events.push(*ev);
if let Some((a, _)) = self.marquee {
self.marquee = Some((a, p));
} else if self.opts.straight_line {
self.points.truncate(1);
self.points.push(p);
} else if self.points.last().is_none_or(|q| q.distance(p) >= cx.tol(1.0)) {
self.points.push(p);
}
return vec![];
}
if self.points.first().is_some_and(|s| s.distance(p) > cx.tol(CLICK_TOL_PX)) {
self.far = true;
}
@@ -316,6 +518,13 @@ impl Tool for ShapeBuilderTool {
}
PointerKind::Up if self.dragging => {
self.dragging = false;
if let Some(d) = &mut self.deferred {
d.events.push(*ev);
d.released = true;
self.points.clear();
self.marquee = None;
return vec![];
}
if self.marquee.is_some() {
self.marquee_to(cx, p);
} else if self.opts.straight_line {
@@ -361,6 +570,10 @@ impl Tool for ShapeBuilderTool {
if self.opts.swatches {
params["pickColorFrom"] = json!("swatches");
}
// A large selection's merge is computed on another thread; `poll` applies it.
if self.start_merge(cx, &params) {
return vec![];
}
vec![Action::Exec("shapeBuilder.merge".into(), params)]
}
_ => vec![],
@@ -369,10 +582,13 @@ impl Tool for ShapeBuilderTool {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
let map = &self.cache.map;
let color = if self.alt { HIGHLIGHT_RED } else { HATCH };
let show: Vec<Hit> = if self.dragging {
let t = &self.touched;
// While a merge is being computed, what its gesture touched stays highlighted.
let map = self.merging.as_ref().map_or(&self.cache.map, |m| &m.map);
// The mode of the gesture being merged, else the drag's or the hover's.
let alt = self.merging.as_ref().filter(|_| !self.dragging).map_or(self.alt, |m| m.params["erase"] == json!(true));
let color = if alt { HIGHLIGHT_RED } else { HATCH };
let touched = if self.dragging { Some(&self.touched) } else { self.merging.as_ref().map(|m| &m.touched) };
let show: Vec<Hit> = if let Some(t) = touched {
let regions = t.regions.iter().map(|&i| Hit::Region(i));
regions.chain(t.lines.iter().map(|&i| Hit::Line(i))).chain(map.erased_edges(t).into_iter().map(Hit::Edge)).collect()
} else {
@@ -381,7 +597,7 @@ impl Tool for ShapeBuilderTool {
for hit in show {
match hit {
// Highlight Fill off: merge mode leaves the regions as they are.
Hit::Region(_) if !self.alt && !self.opts.highlight_fill => {}
Hit::Region(_) if !alt && !self.opts.highlight_fill => {}
Hit::Region(i) => {
if let Some((_, bp, _)) = map.regions.get(i) {
out.push(Overlay::Path { path: hatch(bp, cx.tol(4.0)), color, width: 1.0, dashed: false });
@@ -424,7 +640,10 @@ impl Tool for ShapeBuilderTool {
fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
// A drag keeps the mode it started in; hovering follows the key.
let erase = if self.dragging { self.alt } else { m.alt };
if erase {
if self.editing() {
// The merge of the last gesture, or the map it waits for, is being computed.
Cursor::Busy
} else if erase {
Cursor::ShapeBuilderErase
} else if self.stroke.is_some() && !self.dragging {
// Over a stroke a click splits.
@@ -439,30 +658,53 @@ impl Tool for ShapeBuilderTool {
}
fn waiting(&self) -> bool {
self.cache.waiting()
self.cache.waiting() || self.merging.is_some() || self.deferred.is_some()
}
fn poll(&mut self, cx: &ToolContext, wait: bool) {
if !self.cache.waiting() || self.dragging {
return;
fn editing(&self) -> bool {
self.merging.is_some() || self.deferred_released()
}
fn finish_edit(&mut self, cx: &ToolContext) -> Vec<Action> {
let mut acts = self.replay_deferred(cx, true);
acts.extend(self.poll_merge(cx, true));
acts
}
/// Choosing another tool or another document applies the merge being computed first.
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.finish_edit(cx)
}
fn poll(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
let mut acts = self.poll_merge(cx, wait);
if !acts.is_empty() || self.dragging {
return acts;
}
// The map that arrives may be for an earlier revision; the hover then starts the current
// one's, which `wait` waits for too.
loop {
while self.cache.waiting() {
self.cache.settle(wait);
if let Some(p) = self.at {
self.hover_at(cx, p);
}
if !wait || !self.cache.waiting() {
if !wait {
break;
}
}
// A gesture made while the map was being built goes on once it is there.
acts.extend(self.replay_deferred(cx, wait));
acts
}
fn shape_builder_map(&self) -> Option<(Arc<BuilderMap>, f64)> {
self.cache.key.is_some().then(|| (Arc::clone(&self.cache.map), self.cache.gap))
}
fn shape_builder_merge(&self) -> Option<Arc<PreparedMerge>> {
self.prepared.clone()
}
/// With Pick Color From Color Swatches, the Left and Right arrow keys pick the color swatch
/// before or after the current fill. Escape during a drag cancels it: the release after it
/// merges and deletes nothing.
@@ -471,13 +713,16 @@ impl Tool for ShapeBuilderTool {
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if !self.claims_key(cx, key) {
// While a merge is being computed, the arrows leave the current fill it takes as it was.
if !self.claims_key(cx, key) || self.editing() {
return vec![];
}
if key == ToolKey::Escape {
self.dragging = false;
self.points.clear();
self.touched = Touched::default();
// A gesture recorded while the map is being built is canceled with it.
self.deferred = None;
(self.marquee, self.hover, self.far) = (None, None, false);
return vec![];
}
+67 -21
View File
@@ -8,18 +8,21 @@ pub(super) type CacheKey = (usize, (u64, u64), Vec<NodeId>, bool);
/// Hovering builds the map of inputs with more segments than this on another thread
/// ([`RegionCache::ready`]); smaller maps are built on the calling thread.
const BACKGROUND_SEGMENTS: usize = 128;
pub(super) const BACKGROUND_SEGMENTS: usize = 128;
/// A map being built on another thread: the key it is for, the drawn outlines of its inputs, and
/// the channel it arrives on.
struct Pending {
key: CacheKey,
/// A map being built on another thread: the key it is for, its inputs and their drawn outlines,
/// and the channel it arrives on.
pub(super) struct Pending {
pub(super) key: CacheKey,
pub(super) shapes: Vec<po::Shape>,
outlines: Vec<PathData>,
done: Receiver<BuilderMap>,
}
/// The planar map of the current selection, cached by document identity, revision, selection and
/// whether filled open paths count as closed.
/// whether filled open paths count as closed. When only the document's identity or revision
/// changed and the selection's inputs did not (a view setting, a preference, a copy of the
/// document), the map is kept under the new key ([`RegionCache::rekey`]).
#[derive(Default)]
pub(super) struct RegionCache {
pub(super) key: Option<CacheKey>,
@@ -31,8 +34,12 @@ pub(super) struct RegionCache {
pub(super) gap: f64,
/// The outlines of the Shape Builder's inputs as they are drawn (see [`BuilderMap::stroke_at`]).
pub(super) outlines: Vec<PathData>,
/// The inputs the map was built from.
pub(super) shapes: Vec<po::Shape>,
/// The last key whose inputs [`Self::rekey`] compared with the map's.
compared: Option<CacheKey>,
/// The map being built on another thread, if any.
pending: Option<Pending>,
pub(super) pending: Option<Pending>,
/// The key of the last map whose build on another thread failed.
failed: Option<CacheKey>,
/// How many times the map has been built.
@@ -76,13 +83,13 @@ impl RegionCache {
Self { live: true, ..Default::default() }
}
fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey {
pub(super) fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey {
(cx.doc as *const Document as usize, cx.revision, cx.selection.objects.clone(), open_filled_as_closed)
}
/// The inputs of the selection's map, back → front, and their drawn outlines (Shape Builder
/// only).
fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec<po::Shape>, Vec<PathData>) {
pub(super) fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec<po::Shape>, Vec<PathData>) {
let roots = sorted_roots(cx.doc, &cx.selection.objects);
if self.live {
(shapes_for(cx.doc, &roots).0, vec![])
@@ -93,8 +100,9 @@ impl RegionCache {
}
}
fn install(&mut self, key: CacheKey, map: BuilderMap, outlines: Vec<PathData>) {
fn install(&mut self, key: CacheKey, map: BuilderMap, shapes: Vec<po::Shape>, outlines: Vec<PathData>) {
self.map = Arc::new(map);
self.shapes = shapes;
self.outlines = outlines;
self.key = Some(key);
#[cfg(test)]
@@ -122,8 +130,12 @@ impl RegionCache {
pub(super) fn get(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> &BuilderMap {
let key = Self::key_for(cx, open_filled_as_closed);
if self.key.as_ref() != Some(&key) {
let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.outlines)));
let (map, outlines) = match built {
let inputs = self.rekey(cx, open_filled_as_closed);
if self.key.as_ref() == Some(&key) {
return &self.map;
}
let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.shapes, p.outlines)));
let (map, shapes, outlines) = match built {
Some(b) => {
#[cfg(test)]
{
@@ -132,15 +144,45 @@ impl RegionCache {
b
}
None => {
let (shapes, outlines) = self.inputs(cx, open_filled_as_closed);
(build_map(shapes, self.live, self.gap), outlines)
let (shapes, outlines) = inputs.unwrap_or_else(|| self.inputs(cx, open_filled_as_closed));
(build_map(shapes.clone(), self.live, self.gap), shapes, outlines)
}
};
self.install(key, map, outlines);
self.install(key, map, shapes, outlines);
}
&self.map
}
/// When the map, or the map being built, is for the selection of `cx` but for another revision
/// or address of the document, and the selection's inputs and their outlines are those it is
/// made from, files it under the key of `cx`: nothing it is made from changed. Returns the
/// inputs it computed when that did not file a map (they changed), for the caller to build
/// from.
pub(super) fn rekey(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> Option<(Vec<po::Shape>, Vec<PathData>)> {
let key = Self::key_for(cx, open_filled_as_closed);
let other = |k: &CacheKey| *k != key && (&k.2, k.3) == (&key.2, key.3);
if self.pending.as_ref().is_some_and(|p| p.key == key) {
return None;
}
let installed = self.key.as_ref().is_some_and(other);
let building = self.pending.as_ref().is_some_and(|p| other(&p.key));
// The inputs are compared once for each key.
if (!installed && !building) || self.compared.as_ref() == Some(&key) {
return None;
}
self.compared = Some(key.clone());
let (shapes, outlines) = self.inputs(cx, open_filled_as_closed);
if installed && self.shapes == shapes && self.outlines == outlines {
self.key = Some(key);
return None;
}
if let Some(p) = self.pending.as_mut().filter(|p| other(&p.key) && p.shapes == shapes && p.outlines == outlines) {
p.key = key;
return None;
}
Some((shapes, outlines))
}
/// Takes the map being built on another thread once it has arrived, or with `wait` once it
/// arrives, as the map for the key it was built for.
pub(super) fn settle(&mut self, wait: bool) {
@@ -152,7 +194,7 @@ impl RegionCache {
{
self.background += 1;
}
self.install(p.key, map, p.outlines);
self.install(p.key, map, p.shapes, p.outlines);
}
// The build failed (a bug in the planar map): hovering does not build it again, and a
// gesture builds it on the calling thread, which reports the failure.
@@ -171,21 +213,25 @@ impl RegionCache {
if self.key.as_ref() == Some(&key) {
return Some(&self.map);
}
let inputs = self.rekey(cx, open_filled_as_closed);
if self.key.as_ref() == Some(&key) {
return Some(&self.map);
}
if self.pending.is_some() || self.failed.as_ref() == Some(&key) {
return None;
}
let (shapes, outlines) = self.inputs(cx, open_filled_as_closed);
let (shapes, outlines) = inputs.unwrap_or_else(|| self.inputs(cx, open_filled_as_closed));
let segments: usize = shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::<usize>()).sum();
if segments > BACKGROUND_SEGMENTS && cfg!(not(target_arch = "wasm32")) {
if let Some(done) = spawn_build(shapes, self.live, self.gap) {
self.pending = Some(Pending { key, outlines, done });
if let Some(done) = spawn_build(shapes.clone(), self.live, self.gap) {
self.pending = Some(Pending { key, shapes, outlines, done });
return None;
}
// No thread started: build here.
return Some(self.get(cx, open_filled_as_closed));
}
let map = build_map(shapes, self.live, self.gap);
self.install(key, map, outlines);
let map = build_map(shapes.clone(), self.live, self.gap);
self.install(key, map, shapes, outlines);
Some(&self.map)
}
+3 -2
View File
@@ -282,9 +282,9 @@ impl Tool for LivePaintBucketTool {
self.cache.waiting()
}
fn poll(&mut self, cx: &ToolContext, wait: bool) {
fn poll(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
if !self.cache.waiting() {
return;
return vec![];
}
// As the Shape Builder's: the map that arrives may be for an earlier revision. Over a part
// of a Live Paint group the hover takes no map.
@@ -297,6 +297,7 @@ impl Tool for LivePaintBucketTool {
break;
}
}
vec![]
}
}
+2
View File
@@ -284,4 +284,6 @@ pub(super) fn faces_of(regions: Vec<po::Region>) -> Vec<Face> {
thread_local! {
/// The Shape Builder maps built on this thread ([`maps_built`]).
pub(super) static MAPS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// The Shape Builder merges computed on this thread ([`merges_computed`]).
pub(super) static MERGES_COMPUTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
+113 -1
View File
@@ -1,4 +1,4 @@
use super::lookup::{Face, MAPS_BUILT, faces_of, outline_snap, shape_builder_shapes};
use super::lookup::{Face, MAPS_BUILT, MERGES_COMPUTED, faces_of, node_outline, outline_snap, shape_builder_shapes};
use super::*;
/// Shape Builder hover hatch colour (neutral grey) and Live Paint highlight red.
@@ -12,6 +12,12 @@ pub fn maps_built() -> usize {
MAPS_BUILT.with(std::cell::Cell::get)
}
/// How many Shape Builder merges ([`merge_geometry`]) this thread has computed, for tests that
/// count them.
pub fn merges_computed() -> usize {
MERGES_COMPUTED.with(std::cell::Cell::get)
}
/// The Shape Builder's planar map of a selection.
#[derive(Default)]
pub struct BuilderMap {
@@ -345,6 +351,112 @@ impl BuilderMap {
let bounds_one = |(e, _): &(po::Shape, Rect)| self.sides(e).iter().any(|r| r.is_some_and(|r| touched.regions.contains(&r)));
touched.edges.iter().copied().filter(|&k| self.edges.get(k).is_some_and(|e| !bounds_one(e))).collect()
}
/// What `shapeBuilder.merge` touches: the regions the marquee from `marquee.0` to `marquee.1`
/// reaches, then what the drag along `pts` touches ([`Self::builder_hits_along`]), and the
/// edges that deletes ([`Self::erased_edges`]).
pub fn merge_touched(&self, pts: &[Point], marquee: Option<(Point, Point)>, piece_tol: Option<f64>) -> Touched {
let mut touched = Touched { regions: marquee.map(|(a, b)| self.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() };
touched.extend(&self.builder_hits_along(pts, piece_tol));
touched.edges = self.erased_edges(&touched);
touched
}
}
/// What a merge needs of each input besides the map: its outline as drawn ([`node_outline`]) and
/// whether its fill closes its open subpaths (Consider Open Filled Paths as Closed, and a fill).
#[derive(Clone, Debug, PartialEq)]
pub struct MergeInput {
pub outline: Option<PathData>,
pub fill_closes: bool,
}
/// The [`MergeInput`] of each of `leaves`.
pub fn merge_inputs(leaves: &[&Node], open_filled_as_closed: bool) -> Vec<MergeInput> {
leaves
.iter()
.map(|l| MergeInput { outline: node_outline(l).map(|o| o.0), fill_closes: open_filled_as_closed && !l.appearance.fill_paint().is_none() })
.collect()
}
/// What is left of an input a merge or a delete changes: its faces not touched, united
/// (`united`), or its own outline, with the deleted edges cut out of it.
#[derive(Clone, Debug, PartialEq)]
pub struct Rest {
pub path: PathData,
pub united: bool,
}
/// The new shapes of a Shape Builder merge or delete ([`merge_geometry`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MergeGeometry {
/// The union of the touched regions (empty when none is touched).
pub union: PathData,
/// For each closed input, what goes in its place when the gesture changes it.
pub rests: Vec<Option<Rest>>,
}
/// The new shapes of a gesture over `map` that touched `touched`, closing gaps up to `gap`: the
/// union of the touched regions, and for each closed input that has a touched region or a deleted
/// edge, what is left of it. An input a touched region covers keeps its other faces, united
/// (they share their edges with the merged faces exactly, where subtracting the merged shape left
/// slivers of the outline along them, #569; a sliver goes in only where the input covers it and
/// the merged shape does not take it in); then each deleted edge is cut out of it. Inputs with
/// pieces of open paths (`map.lines`) are left to the caller.
pub fn merge_geometry(map: &BuilderMap, touched: &Touched, gap: f64, inputs: &[MergeInput]) -> MergeGeometry {
MERGES_COMPUTED.with(|n| n.set(n.get() + 1));
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
let faces = po::FaceMerger::with_gap(&map.shapes, gap);
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
let rests = map
.shapes
.iter()
.enumerate()
.map(|(i, sh)| {
let input = inputs.get(i)?;
let in_union = picked.iter().any(|r| r.sources.contains(&i));
if map.lines.iter().any(|(l, _)| l.key == i as u64) || (!in_union && cuts.is_empty()) {
return None;
}
let mut rest = if in_union {
let kept: Vec<&po::Region> = map
.regions
.iter()
.enumerate()
.filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k))
.map(|(_, (r, ..))| r)
.collect();
if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) }
} else {
input.outline.clone().unwrap_or_else(|| sh.path.clone())
};
// Edges come back from the planar map within a few hundredths of the outlines they
// follow, whatever else is selected.
let cut_tol = outline_snap(&sh.path);
let mut cut = false;
for piece in &cuts {
if let Some(r) = po::cut_out(&rest, piece, input.fill_closes, cut_tol) {
rest = r;
cut = true;
}
}
(in_union || cut).then_some(Rest { path: rest, united: in_union })
})
.collect();
MergeGeometry { union, rests }
}
/// A merge computed on another thread for a Shape Builder click or drag
/// ([`Tool::shape_builder_merge`]): the map, what the gesture touched and the inputs it was
/// computed from, and the result. `shapeBuilder.merge` takes the result when it would compute the
/// same.
pub struct PreparedMerge {
pub map: Arc<BuilderMap>,
pub touched: Touched,
pub gap: f64,
pub inputs: Vec<MergeInput>,
pub geometry: MergeGeometry,
}
/// The regions, line pieces and edges a Shape Builder gesture has touched, in the order touched.
+269 -12
View File
@@ -132,20 +132,34 @@ fn shape_builder_builds_its_map_once_for_hover_alt_hover_and_a_merge() {
// The merge left the document as it was (it failed, say): the map still fits it.
t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 75.0));
assert_eq!(t.cache.builds, 1, "a hover after a merge that changed nothing");
// A new revision of the same art (a view setting, a preference) keeps the map.
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
t.pointer(&cx2, &ev(PointerKind::Move, 75.0, 75.0));
assert_eq!(t.cache.builds, 2, "a hover on a new revision");
assert_eq!(t.cache.builds, 1, "a hover on a new revision of the same art");
assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
// The same objects, one of them moved.
let mut moved = d.clone();
let second = s.objects[1];
moved.node_mut(second).unwrap().transform(vectorcraft_geom::Affine::translate((10.0, 0.0)), false);
let cx3 = ToolContext { revision: (1, 3), ..cx(&moved, &s, &p) };
t.pointer(&cx3, &ev(PointerKind::Move, 75.0, 75.0));
assert_eq!(t.cache.builds, 2, "a hover on a new revision of other art");
}
/// A row of `n` selected squares 20 pt wide, each overlapping the next by half: 4 segments each.
fn square_row(n: usize) -> (Document, Selection) {
square_row_at(n, 0.0)
}
/// [`square_row`] with the squares' tops at `y`: the same objects, moved.
fn square_row_at(n: usize, y: f64) -> (Document, Selection) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let mut s = Selection::default();
for k in 0..n {
let x = k as f64 * 10.0;
let id = d.alloc_id();
d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 20.0)), Appearance::default_art())).unwrap();
d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art())).unwrap();
s.add(id);
}
(d, s)
@@ -176,9 +190,10 @@ fn shape_builder_hover_builds_a_large_map_on_another_thread() {
assert_eq!((t.cache.builds, t.cache.background), (1, 1));
assert_eq!(t.overlays(&cx1).len(), 2, "the region's hatch and outline");
// A new revision while the map of the one before it is being built: only the newer one's
// map is used.
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
let cx3 = ToolContext { revision: (1, 3), ..cx(&d, &s, &p) };
// map is used. Each revision moves the squares down 1 pt.
let rows: Vec<Document> = (0..7).map(|k| square_row_at(40, f64::from(k)).0).collect();
let cx2 = ToolContext { revision: (1, 2), ..cx(&rows[2], &s, &p) };
let cx3 = ToolContext { revision: (1, 3), ..cx(&rows[3], &s, &p) };
t.pointer(&cx2, &hover);
t.pointer(&cx3, &hover);
poll_until_ready(&mut t, &cx3);
@@ -186,29 +201,265 @@ fn shape_builder_hover_builds_a_large_map_on_another_thread() {
assert_eq!((t.cache.builds, t.cache.background), (3, 3), "the earlier revision's map, then the newer one's");
assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 3)));
// A host without frames waits for the map.
let cx4 = ToolContext { revision: (1, 4), ..cx(&d, &s, &p) };
let cx4 = ToolContext { revision: (1, 4), ..cx(&rows[4], &s, &p) };
t.pointer(&cx4, &hover);
assert!(t.waiting() && t.hover.is_none());
t.poll(&cx4, true);
assert!(!t.waiting());
assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
assert_eq!((t.cache.builds, t.cache.background), (4, 4));
// A press while the map is being built waits for it, and the drag merges.
let cx5 = ToolContext { revision: (1, 5), ..cx(&d, &s, &p) };
// A drag while the map is being built is replayed once it is there, and merges (on
// another thread, see `shape_builder_merges_a_large_selection_on_another_thread` and
// `shape_builder_gesture_waits_for_the_map_without_holding_the_caller`).
let cx5 = ToolContext { revision: (1, 5), ..cx(&rows[5], &s, &p) };
t.pointer(&cx5, &hover);
t.pointer(&cx5, &ev(PointerKind::Down, 15.0, 10.0));
t.pointer(&cx5, &ev(PointerKind::Drag, 35.0, 10.0));
let acts = t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0));
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}");
assert!(t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0)).is_empty());
let acts = t.poll(&cx5, true);
assert_eq!((t.cache.builds, t.cache.background), (5, 5));
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}");
assert!(!t.waiting());
// Changing an option forgets a map being built with the old one.
let cx6 = ToolContext { revision: (1, 6), ..cx(&d, &s, &p) };
let cx6 = ToolContext { revision: (1, 6), ..cx(&rows[6], &s, &p) };
t.pointer(&cx6, &hover);
t.set_option("gapDetection", &json!(true));
assert!(!t.waiting());
}
/// Polls `t` until it stops waiting, as the desktop app does each frame, and returns the
/// actions the polls gave.
fn poll_actions(t: &mut dyn Tool, cx: &ToolContext) -> Vec<Action> {
let start = std::time::Instant::now();
let mut acts = vec![];
while t.waiting() {
assert!(start.elapsed() < std::time::Duration::from_secs(60), "the tool is still waiting");
std::thread::sleep(std::time::Duration::from_millis(2));
acts.extend(t.poll(cx, false));
}
acts
}
/// A Shape Builder over `square_row(40)` (160 segments) that has merged nothing, with its map,
/// after a click at (15, 10) that started a merge on another thread.
fn clicked_row(cx: &ToolContext) -> ShapeBuilderTool {
let mut t = ShapeBuilderTool::default();
t.pointer(cx, &ev(PointerKind::Move, 15.0, 10.0));
poll_until_ready(&mut t, cx);
t.pointer(cx, &ev(PointerKind::Down, 15.0, 10.0));
assert!(t.pointer(cx, &ev(PointerKind::Up, 15.0, 10.0)).is_empty(), "the release returns before the merge is computed");
assert!(t.waiting() && t.editing());
t
}
#[test]
fn shape_builder_merges_a_large_selection_on_another_thread() {
let (d, s) = square_row(40);
let p = paint();
let cx = cx(&d, &s, &p);
let computed = merges_computed();
let mut t = clicked_row(&cx);
assert_eq!(t.cursor(&cx, Point::new(15.0, 10.0), Mods::default()), Cursor::Busy);
assert_eq!(t.overlays(&cx).len(), 2, "the clicked region's hatch and outline stay");
// A press while the merge is being computed starts no gesture.
assert!(t.pointer(&cx, &ev(PointerKind::Down, 35.0, 10.0)).is_empty());
assert!(!t.busy());
assert!(t.pointer(&cx, &ev(PointerKind::Up, 35.0, 10.0)).is_empty());
let acts = poll_actions(&mut t, &cx);
assert_eq!(merges_computed(), computed, "the merge was computed on another thread");
let [Action::Exec(id, params)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!((id.as_str(), &params["points"]), ("shapeBuilder.merge", &json!([[15.0, 10.0]])));
assert!(!t.waiting() && !t.editing());
assert_eq!(t.cursor(&cx, Point::new(15.0, 10.0), Mods::default()), Cursor::ShapeBuilder);
let m = t.shape_builder_merge().expect("the merge the command takes");
assert!(Arc::ptr_eq(&m.map, &t.cache.map));
assert_eq!((m.touched.regions.len(), m.touched.lines.len(), m.touched.edges.len()), (1, 0, 0));
assert!(!m.geometry.union.is_empty());
assert_eq!(m.geometry, merge_geometry(&m.map, &m.touched, m.gap, &m.inputs));
assert_eq!(m.geometry.rests.iter().flatten().count(), 2, "the two squares over the region change");
// The next event lets it go.
t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0));
assert!(t.shape_builder_merge().is_none());
}
#[test]
fn shape_builder_drops_a_merge_the_art_changed_under() {
let (d, s) = square_row(40);
let p = paint();
let mut fewer = Selection::default();
fewer.set(s.objects.iter().skip(1).copied());
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
let (moved, _) = square_row_at(40, 5.0);
let later = [
// An edit or an undo: the squares moved.
ToolContext { revision: (1, 2), ..cx(&moved, &s, &p) },
// Another document.
ToolContext { revision: (2, 1), ..cx(&d, &s, &p) },
// Another selection.
ToolContext { revision: (1, 1), ..cx(&d, &fewer, &p) },
];
for (k, cx2) in later.iter().enumerate() {
let mut t = clicked_row(&cx1);
// Dropped at the first poll, before the thread is done.
assert!(t.poll(cx2, false).is_empty(), "change {k}: the merge is not applied");
assert!(!t.editing() && !t.waiting() && t.shape_builder_merge().is_none(), "change {k}");
}
// Unchanged, the same wait applies it.
let mut t = clicked_row(&cx1);
assert_eq!(t.poll(&cx1, true).len(), 1);
// A copy of the document at the same revision (a save stamps its dates on a copy), and
// finishing the edit before a command, apply it too.
let copy = d.clone();
let cx3 = ToolContext { revision: (1, 1), ..cx(&copy, &s, &p) };
let mut t = clicked_row(&cx1);
assert_eq!(t.finish_edit(&cx3).len(), 1);
assert!(!t.editing() && t.shape_builder_merge().is_some());
// Switching away applies it.
let mut t = clicked_row(&cx1);
assert_eq!(t.deactivate(&cx1).len(), 1);
assert!(!t.editing());
}
/// A new revision of the document that leaves the selection's inputs as they were (a view
/// setting or a preference that redraws it) keeps the merge being computed, the gesture made
/// while the map is being built and the map itself: the merge applies with the map it was
/// computed over, and no map is built again.
#[test]
fn shape_builder_keeps_its_work_over_a_new_revision_of_the_same_art() {
let (d, s) = square_row(40);
let p = paint();
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
// A merge being computed.
let computed = merges_computed();
let mut t = clicked_row(&cx1);
let acts = poll_actions(&mut t, &cx2);
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}");
assert_eq!(merges_computed(), computed, "the merge computed on another thread");
let m = t.shape_builder_merge().expect("the merge the command takes");
assert!(Arc::ptr_eq(&m.map, &t.cache.map), "the map the merge was computed over");
assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 2)));
assert_eq!(t.cache.builds, 1, "no map built again");
// Finishing it before a command too.
let mut t = clicked_row(&cx1);
assert_eq!(t.finish_edit(&cx2).len(), 1);
// A hover over the new revision builds no map.
let mut t = ShapeBuilderTool::default();
let hover = ev(PointerKind::Move, 15.0, 10.0);
t.pointer(&cx1, &hover);
poll_until_ready(&mut t, &cx1);
t.pointer(&cx2, &hover);
assert!(!t.waiting() && matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
assert_eq!(t.cache.builds, 1);
// A map being built, and a gesture made meanwhile.
let drag = [ev(PointerKind::Down, 15.0, 10.0), ev(PointerKind::Drag, 35.0, 10.0), ev(PointerKind::Up, 35.0, 10.0)];
let mut ready = ShapeBuilderTool::default();
ready.pointer(&cx1, &hover);
poll_until_ready(&mut ready, &cx1);
for e in &drag {
ready.pointer(&cx1, e);
}
let want = poll_actions(&mut ready, &cx1);
let mut t = ShapeBuilderTool::default();
t.pointer(&cx1, &hover);
for e in &drag {
assert!(t.pointer(&cx1, e).is_empty());
}
assert!(t.editing() && t.cache.builds == 0, "the gesture waits for the map");
assert_eq!(poll_actions(&mut t, &cx2), want);
assert_eq!((t.cache.builds, t.cache.background), (1, 1), "the map built for the first revision");
// Finishing it before a command waits for the map being built rather than building it again.
let mut t = ShapeBuilderTool::default();
t.pointer(&cx1, &hover);
for e in &drag {
t.pointer(&cx1, e);
}
assert_eq!(t.finish_edit(&cx2), want);
assert_eq!((t.cache.builds, t.cache.background), (1, 1), "the map built on the other thread");
// Another document with the same art drops the merge.
let cx3 = ToolContext { revision: (2, 1), ..cx(&d, &s, &p) };
let mut t = clicked_row(&cx1);
assert!(t.poll(&cx3, false).is_empty());
assert!(!t.editing());
}
/// A press while the map of a large selection is being built on another thread does not wait
/// for it: the gesture is recorded, each event returns at once, and the polls replay it once
/// the map is there, giving the merge the same gesture gives over the map. Finishing the edit
/// waits for the map and the merge; another revision of other art drops the gesture.
#[test]
fn shape_builder_gesture_waits_for_the_map_without_holding_the_caller() {
let (d, s) = square_row(200);
let p = paint();
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
let hover = ev(PointerKind::Move, 15.0, 10.0);
let drag = [ev(PointerKind::Down, 15.0, 10.0), ev(PointerKind::Drag, 35.0, 10.0), ev(PointerKind::Up, 35.0, 10.0)];
let mut ready = ShapeBuilderTool::default();
ready.pointer(&cx1, &hover);
poll_until_ready(&mut ready, &cx1);
for e in &drag {
ready.pointer(&cx1, e);
}
let want = poll_actions(&mut ready, &cx1);
assert!(matches!(want.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{want:?}");
let made_while_building = || {
let mut t = ShapeBuilderTool::default();
t.pointer(&cx1, &hover);
for e in &drag {
assert!(t.pointer(&cx1, e).is_empty());
}
assert_eq!(t.cache.builds, 0, "the gesture did not wait for the map");
assert!(t.editing() && t.waiting());
t
};
let mut t = made_while_building();
assert_eq!(t.cursor(&cx1, Point::new(35.0, 10.0), Mods::default()), Cursor::Busy);
// A press before the gesture is replayed starts nothing.
assert!(t.pointer(&cx1, &ev(PointerKind::Down, 55.0, 10.0)).is_empty());
assert!(t.pointer(&cx1, &ev(PointerKind::Up, 55.0, 10.0)).is_empty());
assert_eq!(poll_actions(&mut t, &cx1), want);
assert!(!t.editing() && t.shape_builder_merge().is_some());
// Finishing the edit before a command waits for both.
let mut t = made_while_building();
assert_eq!(t.finish_edit(&cx1), want);
// Another revision of other art before the map is there drops the gesture.
let mut t = made_while_building();
let (moved, _) = square_row_at(200, 5.0);
let cx2 = ToolContext { revision: (1, 2), ..cx(&moved, &s, &p) };
assert!(t.poll(&cx2, false).is_empty());
assert!(!t.editing());
}
/// Escape during a drag made while the map of a large selection is being built cancels the
/// recorded gesture too: the release and the polls after it merge nothing, the tool stops
/// waiting once the map is there, and the next drag merges on its own.
#[test]
fn shape_builder_escape_cancels_a_gesture_made_while_the_map_is_built() {
let (d, s) = square_row(200);
let p = paint();
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
let mut t = ShapeBuilderTool::default();
t.pointer(&cx1, &ev(PointerKind::Move, 15.0, 10.0));
assert!(t.pointer(&cx1, &ev(PointerKind::Down, 15.0, 10.0)).is_empty());
assert!(t.pointer(&cx1, &ev(PointerKind::Drag, 35.0, 10.0)).is_empty());
assert_eq!(t.cache.builds, 0, "the gesture did not wait for the map");
assert!(t.claims_key(&cx1, ToolKey::Escape));
assert!(t.key(&cx1, ToolKey::Escape, Mods::default()).is_empty());
assert!(t.pointer(&cx1, &ev(PointerKind::Up, 35.0, 10.0)).is_empty());
assert!(poll_actions(&mut t, &cx1).is_empty(), "the canceled gesture merges nothing");
assert!(!t.waiting() && !t.editing());
// The next drag merges what it touches, with no event of the canceled one.
let mut fresh = ShapeBuilderTool::default();
fresh.pointer(&cx1, &ev(PointerKind::Move, 55.0, 10.0));
poll_until_ready(&mut fresh, &cx1);
let next = [ev(PointerKind::Down, 55.0, 10.0), ev(PointerKind::Drag, 75.0, 10.0), ev(PointerKind::Up, 75.0, 10.0)];
for e in &next {
fresh.pointer(&cx1, e);
t.pointer(&cx1, e);
}
assert_eq!(poll_actions(&mut t, &cx1), poll_actions(&mut fresh, &cx1));
}
#[test]
fn shape_builder_hover_builds_a_small_map_at_once() {
let (d, s) = square_row(3);
@@ -1110,9 +1361,15 @@ fn bucket_keeps_its_map_while_the_document_is_unchanged() {
// The fill left the document as it was: the map still fits it.
t.pointer(&cx1, &ev(PointerKind::Move, 25.0, 50.0));
assert_eq!(t.cache.builds, 1, "a hover after a fill that changed nothing");
// A new revision of the same art keeps the map; one with an object moved builds it again.
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
t.pointer(&cx2, &ev(PointerKind::Move, 25.0, 50.0));
assert_eq!(t.cache.builds, 2, "a hover on a new revision");
assert_eq!(t.cache.builds, 1, "a hover on a new revision of the same art");
let mut moved = d.clone();
moved.node_mut(b).unwrap().transform(vectorcraft_geom::Affine::translate((10.0, 0.0)), false);
let cx3 = ToolContext { revision: (1, 3), ..cx(&moved, &s, &p) };
t.pointer(&cx3, &ev(PointerKind::Move, 25.0, 50.0));
assert_eq!(t.cache.builds, 2, "a hover on a new revision of other art");
}
#[test]
+26 -6
View File
@@ -267,21 +267,41 @@ pub trait Tool: Send {
false
}
/// Is the tool waiting for work it runs on another thread (the planar map of a large selection
/// for the Shape Builder and the Live Paint Bucket)? It highlights no region under the pointer
/// until the work is done. While this holds, the desktop app calls [`Tool::poll`] each frame,
/// and hosts without frames call it with `wait` after a [`PointerKind::Move`].
/// for the Shape Builder and the Live Paint Bucket, a Shape Builder merge of one)? It
/// highlights nothing under the pointer until a map is done. While this holds, the desktop app
/// calls [`Tool::poll`] each frame, and hosts without frames call it with `wait` after each
/// pointer event.
fn waiting(&self) -> bool {
false
}
/// Checks on the work [`Tool::waiting`] reports, waiting for it to finish with `wait`. Once it
/// is done, the tool shows the highlight under the pointer where it last moved.
fn poll(&mut self, _cx: &ToolContext, _wait: bool) {}
/// Is the tool computing an edit on another thread, which [`Tool::poll`] applies once it is
/// done (a Shape Builder merge of a large selection)?
fn editing(&self) -> bool {
false
}
/// Checks on the work [`Tool::waiting`] reports, waiting for it to finish with `wait`. Once a
/// map is done, the tool shows the highlight under the pointer where it last moved; once an
/// edit is done, the actions returned apply it.
fn poll(&mut self, _cx: &ToolContext, _wait: bool) -> Vec<Action> {
vec![]
}
/// Waits for the edit [`Tool::editing`] reports and returns the actions that apply it, before
/// a command runs on the document; none when the document's revision or the selection changed
/// since the gesture.
fn finish_edit(&mut self, _cx: &ToolContext) -> Vec<Action> {
vec![]
}
/// The Shape Builder's planar map of the selection, with the outline edges, and the gap it
/// closes, when the tool has one. `shapeBuilder.merge` uses it in place of a map of its own
/// when the map's inputs are the merge's inputs.
fn shape_builder_map(&self) -> Option<(std::sync::Arc<builder::BuilderMap>, f64)> {
None
}
/// The Shape Builder merge the tool computed on another thread for its last gesture, which
/// `shapeBuilder.merge` takes when it would compute the same ([`builder::PreparedMerge`]).
fn shape_builder_merge(&self) -> Option<std::sync::Arc<builder::PreparedMerge>> {
None
}
}
/// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id.
+8 -4
View File
@@ -110,6 +110,7 @@ impl VectorcraftApp {
deferred_control: None,
synthetic_dropped_before: 0,
pending_screenshots: vec![],
pointer_replies: vec![],
queued_screenshots: vec![],
screenshot_token: 0,
screenshot_reserved: 0,
@@ -337,8 +338,9 @@ impl VectorcraftApp {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// The window's close button (or the system quitting the app) asks about unsaved documents,
// once the saves running in the background are done. Not while a file dialog's sheet is on
// the window: what asked for it runs again when it answers.
// once the saves running in the background are done and a Shape Builder merge being
// computed is applied. Not while a file dialog's sheet is on the window: what asked for it
// runs again when it answers.
if ctx.input(|i| i.viewport().close_requested()) {
// This native viewport is going away: release its IME context. No request is
// allocated here: the close prompt or file sheet publishes no custom descriptor, so a
@@ -346,7 +348,7 @@ impl VectorcraftApp {
// fresh generation on the next pass (never inherits a composition across the prompt).
crate::ime::close_viewport(self, ctx);
self.ime_context.end_viewport(ctx.viewport_id());
background::wait_all(self);
unsaved::finish_pending(self);
if self.file_sheet_open() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.status(picks::busy());
@@ -374,6 +376,7 @@ impl VectorcraftApp {
crate::chrome::sync_window_title(self, ctx);
crate::chrome::sync_fullscreen(self, ctx);
self.drain_control(ctx);
self.answer_pointer_replies(ctx);
// An egui `PasteImage` with a rich system clipboard: run the paste command now, once. It
// goes through `run`, whose `edit.paste` branch calls `adopt_system_clipboard` (which clears
// the request) to read the formats and then pastes them. A text paste in the same UI pass
@@ -558,7 +561,8 @@ impl VectorcraftApp {
tools_float::clear_canvas_dock_zone(&ctx);
}
// Beside the canvas, docked Tools draws inside the canvas panel (`toolbar::show_beside_canvas`).
if self.ui.toolbar && self.ui.screen_mode < 3 && (!self.ui.tools_beside_canvas || self.ui.toolbar_pos.is_some()) {
// So does Tools docked on the right, left of the panels.
if self.ui.toolbar && self.ui.screen_mode < 3 && ((!self.ui.tools_beside_canvas && !self.ui.toolbar_right) || self.ui.toolbar_pos.is_some()) {
toolbar::show(self, ui);
} else if !self.ui.toolbar && self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() {
// Window › Tools hides the Tools panel alone; Tab hides the toolbars made with New
+13 -1
View File
@@ -1,5 +1,5 @@
use super::fonts::MAX_RECENT_FONTS;
use super::{Value, VectorcraftApp, dialogs, fileio, io, menus, panels, picks, toolbar};
use super::{Value, VectorcraftApp, canvas, dialogs, fileio, io, menus, panels, picks, toolbar};
use crate::placing;
use crate::state::ReplaceFontWarning;
@@ -94,6 +94,17 @@ impl VectorcraftApp {
result
}
/// A command that waits for the edit the tool computes on another thread and the input held
/// for it waits before the app adds the view's active artboard to its params: a held press
/// may change it. The app's own commands (its dialogs, panels and view) wait too.
fn wait_for_tool_edit(&mut self, id: &str) {
let ui_command = vectorcraft_engine::find_command(id).is_none();
if (ui_command || vectorcraft_engine::waits_for_tool_edit(id)) && self.session.tool_waiting() {
let r = self.session.finish_tool_edit();
canvas::apply_requests(self, r);
}
}
/// [`Self::run`] it, inside what asks for file dialogs.
fn run_now(&mut self, id: &str, params: Value) -> Result<Value, String> {
crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size);
@@ -104,6 +115,7 @@ impl VectorcraftApp {
if cfg!(debug_assertions) && !self.session.replaying() {
menus::check_params(&self.session, id, &params)?;
}
self.wait_for_tool_edit(id);
// A `.ai` file a command writes (a save, an export, a timestamped copy, a batch's save) names
// the view's active artboard.
io::remember_active_artboard(self);
+36 -1
View File
@@ -1,6 +1,6 @@
use super::{
MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot, QueuedScreenshot, SCREENSHOT_TIMEOUT_MS,
VectorcraftApp, file_access, json, now_ms,
VectorcraftApp, canvas, file_access, json, now_ms,
};
use crate::control;
@@ -112,6 +112,9 @@ impl VectorcraftApp {
}
}
}
control::Outcome::AfterToolEdit { from, to, document } => {
self.pointer_replies.push(control::PendingPointerReply { from, to, document, reply });
}
}
}
if handled == control::MAX_CONTROL_REQUESTS_PER_FRAME {
@@ -121,6 +124,38 @@ impl VectorcraftApp {
self.control_rx = Some(rx);
}
/// Sends the `ui.pointer` replies that wait for the tool's edit once their events have been
/// made and the edit is applied. The canvas checks on the tool each frame it is drawn; this
/// checks too, for frames without it.
pub(crate) fn answer_pointer_replies(&mut self, ctx: &egui::Context) {
// Those whose events the canvas's check made go first, before this check makes the input
// held after them.
self.send_pointer_replies();
if self.pointer_replies.is_empty() {
return;
}
if self.session.tool_editing() {
let r = self.session.tool_poll(self.view_info(), false);
canvas::apply_requests(self, r);
}
self.send_pointer_replies();
if !self.pointer_replies.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// Sends the waiting `ui.pointer` replies whose events have been made.
fn send_pointer_replies(&mut self) {
for pending in std::mem::take(&mut self.pointer_replies) {
let control::PendingPointerReply { from, to, document, reply } = pending;
if self.session.input_applied(from, to) {
let _ = reply.send(control::pointer_reply_for(self, document));
} else {
self.pointer_replies.push(control::PendingPointerReply { from, to, document, reply });
}
}
}
/// The active document's identity: its process-unique uid and revision. A capture records it
/// at admission and issuance and refuses to answer with a different document or undo state.
pub(super) fn document_identity(&self) -> Option<(u64, u64)> {
+28 -9
View File
@@ -376,19 +376,29 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e
{
v.artboard = i;
}
// A press with a selection tool inside an artboard makes it the active one (#693), as the
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to
// Artboard aligns to it, and All on Active Artboard selects on it.
if ev.kind == PointerKind::Down
&& vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id())
&& let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos))
&& let Some(v) = app.view_mut()
{
v.artboard = i;
if ev.kind == PointerKind::Down {
pressed_with(app, app.session.active_index(), ev.pos, app.session.tool_id());
}
apply_requests(app, r);
}
/// A press at `pos` in document `index` with tool `tool` (a selection tool) inside an artboard
/// makes it that document's active one (#693), as the Artboard tool's does: Paste in Place and in
/// Front or Back then paste onto it, Align to Artboard aligns to it, and All on Active Artboard
/// selects on it.
fn pressed_with(app: &mut VectorcraftApp, index: Option<usize>, pos: Point, tool: &str) {
if !vectorcraft_tools::catalog::is_selection_tool(tool) {
return;
}
app.sync_views();
if let Some(i) = index
&& let Some(board) = app.session.documents().get(i).and_then(|d| d.doc.artboard_at(pos))
&& let Some(v) = app.views.get_mut(i)
{
v.artboard = board;
}
}
/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error.
pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Vec<vectorcraft_engine::UiRequest>>) {
match r {
@@ -399,6 +409,15 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Ve
vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t),
vectorcraft_engine::UiRequest::Status(msg) => app.status(msg),
vectorcraft_engine::UiRequest::Place(p) => crate::place::place_from_cursor(app, &p),
// In the document the press was made in, which may no longer be the active one.
vectorcraft_engine::UiRequest::HeldPress { doc, pos, tool } => {
let index = doc.and_then(|uid| app.session.documents().iter().position(|d| d.uid == uid));
pressed_with(app, index, pos, tool);
// The selection tool Cmd lent has the press now, so its drag can carry art to another tab.
if let Some(ctx) = app.ui_ctx.clone() {
crate::document_drag::begin(app, &ctx, pos);
}
}
}
}
}
+5 -5
View File
@@ -147,12 +147,12 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
app.session.tool_hover_ended(view);
ui.ctx().request_repaint();
}
// The tool works on another thread (a large selection's planar map): it checks each frame
// whether the work is done, and then shows the highlight under the pointer.
// The tool works on another thread (a large selection's planar map or merge): it checks each
// frame whether the work is done, and then shows the highlight under the pointer or applies
// the edit.
if app.session.tool_waiting() {
if let Err(e) = app.session.tool_poll(view, false) {
app.status(e.to_string());
}
let r = app.session.tool_poll(view, false);
apply_requests(app, r);
ui.ctx().request_repaint_after(std::time::Duration::from_millis(16));
}
if pointer.primary_released()
+48 -5
View File
@@ -96,6 +96,13 @@ fn reserve_synthetic(app: &mut VectorcraftApp, additional: usize) -> Result<(),
pub type ControlResponse = Value;
pub(crate) struct PendingPointerReply {
pub from: u64,
pub to: u64,
pub document: Option<u64>,
pub reply: Sender<ControlResponse>,
}
pub struct ControlRequest {
pub method: String,
pub params: Value,
@@ -167,6 +174,34 @@ pub enum Outcome {
path: Option<String>,
data: bool,
},
/// A `ui.pointer` reply ([`pointer_reply`]), sent once the events numbered `from..to`
/// ([`vectorcraft_engine::Session::input_applied`]) have been made and the edit they started
/// on another thread is applied, so it shows the selection after the edit.
AfterToolEdit {
from: u64,
to: u64,
document: Option<u64>,
},
}
/// The reply to `ui.pointer`: the selection, the key object and the active tool.
pub(crate) fn pointer_reply(app: &mut VectorcraftApp) -> Value {
pointer_reply_for(app, app.session.active().map(|d| d.uid))
}
pub(crate) fn pointer_reply_for(app: &VectorcraftApp, document: Option<u64>) -> Value {
let state = document.and_then(|uid| app.session.documents().iter().find(|d| d.uid == uid));
match state {
Some(state) => json!({
"ok": true,
"result": {
"selection": state.selection.objects.iter().map(|id| id.0).collect::<Vec<_>>(),
"key": state.selection.key.map(|id| id.0),
"tool": app.session.tool_id(),
}
}),
None => json!({"ok": false, "error": "the gesture's document is no longer open"}),
}
}
fn ok(v: Value) -> Outcome {
@@ -470,10 +505,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
o.remove("wait");
}
let method = req.method.as_str();
// The key check of `document.export` runs before the file is written.
if method == "app.export"
&& let Err(e) = crate::menus::check_params(&app.session, "document.export", &p)
{
// The key check of `document.export` or `document.save` runs before the file is written.
let checked = if method == "app.export" { "document.export" } else { "document.save" };
if let Err(e) = crate::menus::check_params(&app.session, checked, &p) {
return err(e);
}
wrap(if wait { crate::background::in_foreground(app, |app| save_or_export(app, method, &p)) } else { save_or_export(app, method, &p) })
@@ -581,6 +615,7 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe
let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default();
let view = app.view_info();
let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v));
let from = app.session.input_count();
let held =
PointerEvent::json_backquote(p, false).and_then(|d| events.iter().map(|e| PointerEvent::json_backquote(e, d)).collect::<Result<Vec<_>, _>>());
let held = match held {
@@ -621,7 +656,15 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe
}
}
ctx.request_repaint();
wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()})))
// A Shape Builder merge of a large selection is computed on another thread, and the
// reply to the events that started it waits for it without holding the frames up. A
// gesture sent while it is computed is made after it, and its reply waits until then;
// a hover is answered at once.
let to = app.session.input_count();
if !app.session.input_applied(from, to) {
return Outcome::AfterToolEdit { from, to, document: app.session.active().map(|d| d.uid) };
}
Outcome::Done(pointer_reply(app))
}
/// `ui.click` and `ui.drag`: pointer input through egui itself.
+279
View File
@@ -1,5 +1,6 @@
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::{ControlRequest, MAX_DIALOG_FIELDS, Outcome, handle};
use crate::state::Dialog;
@@ -11,6 +12,27 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value
r
}
#[test]
fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
let (reply, received) = std::sync::mpsc::channel();
let at = app.session.input_count();
app.pointer_replies.push(super::PendingPointerReply { from: at, to: at, document: app.session.active().map(|d| d.uid), reply });
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("tool.select", json!({"tool": "rectangle"})).unwrap();
app.answer_pointer_replies(&egui::Context::default());
let result = received.try_recv().unwrap();
assert_eq!(result["ok"], true, "{result}");
assert_eq!(result["result"]["selection"], json!([id]));
assert_eq!(result["result"]["key"], Value::Null);
assert_eq!(result["result"]["tool"], "rectangle");
}
#[test]
fn control_lists_and_selects_optional_addon_tools() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
@@ -338,6 +360,263 @@ fn pointer_replies_name_the_key_object() {
assert_eq!(click(&mut app), Some(Value::Null), "a click on the key again lets it go");
}
/// A Shape Builder click on a large selection merges on another thread: the frames go on, and
/// the `ui.pointer` reply comes once the merge is applied, with the selection after it.
#[test]
fn pointer_reply_waits_for_a_merge_on_another_thread() {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx);
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
for k in 0..40 {
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
let ctx = egui::Context::default();
let send = |app: &mut VectorcraftApp, events: Value| {
let (req, reply) = ControlRequest::new("ui.pointer", json!({"events": events}));
tx.send(req).unwrap();
app.drain_control(&ctx);
reply
};
let hover = send(&mut app, json!([{"kind": "move", "x": 15, "y": 10}]));
assert_eq!(hover.try_recv().unwrap()["ok"], true, "a hover is answered at once");
app.session.tool_poll(app.view_info(), true).unwrap();
let revision = app.session.active().unwrap().revision;
let click = send(&mut app, json!([{"kind": "down", "x": 15, "y": 10}, {"kind": "up", "x": 15, "y": 10}]));
assert!(click.try_recv().is_err() && app.session.tool_editing(), "the click is not answered before the merge is applied");
// A hover sent meanwhile is answered at once; a click is made after the merge, and its
// reply waits until then.
let hover = send(&mut app, json!([{"kind": "move", "x": 35, "y": 10}]));
assert_eq!(hover.try_recv().map(|r| r["ok"].clone()), Ok(json!(true)), "a hover during the merge");
let cmd = json!({"cmd": true});
let press = send(&mut app, json!([{"kind": "down", "x": 500, "y": 150, "mods": cmd}, {"kind": "up", "x": 500, "y": 150, "mods": cmd}]));
assert!(press.try_recv().is_err(), "a Cmd-click during the merge waits for it");
let hover = send(&mut app, json!([{"kind": "move", "x": 45, "y": 10}]));
assert_eq!(hover.try_recv().map(|r| r["ok"].clone()), Ok(json!(true)), "a hover after the held Cmd-click");
let start = std::time::Instant::now();
let reply = loop {
if let Ok(r) = click.try_recv() {
break r;
}
assert!(app.session.tool_editing(), "the merge is being computed until the reply goes");
assert_eq!(app.session.active().unwrap().revision, revision);
assert!(start.elapsed() < std::time::Duration::from_secs(60), "no reply");
std::thread::sleep(std::time::Duration::from_millis(2));
// A frame: the canvas checks on the tool, then the replies are answered.
let r = app.session.tool_poll(app.view_info(), false);
crate::canvas::apply_requests(&mut app, r);
app.answer_pointer_replies(&ctx);
};
assert_eq!(reply["ok"], true, "{reply}");
assert_eq!(reply["result"]["selection"].as_array().map(Vec::len), Some(41), "{reply}: the selection after the merge");
assert_ne!(app.session.active().unwrap().revision, revision);
let press = loop {
if let Ok(r) = press.try_recv() {
break r;
}
assert!(start.elapsed() < std::time::Duration::from_secs(60), "no reply to the Cmd-click");
let r = app.session.tool_poll(app.view_info(), false);
crate::canvas::apply_requests(&mut app, r);
app.answer_pointer_replies(&ctx);
};
assert_eq!(press["result"]["selection"].as_array().map(Vec::len), Some(0), "{press}: the lent selection tool deselected after the merge");
assert_eq!(press["result"]["tool"], "shapeBuilder", "{press}");
}
/// A Cmd-click held while a Shape Builder merge is computed on another thread makes the artboard
/// it lands in active once it is made, as a Cmd-click made at once does: by the frames' checks,
/// by choosing another tool first, or before a command that acts on the active artboard takes
/// it.
#[test]
fn a_held_cmd_click_makes_its_artboard_active() {
for then in ["frames", "another tool", "All on Active Artboard", "another document"] {
held_cmd_click(then);
}
}
fn held_cmd_click(then: &str) {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
if then == "another document" {
// A document with an artboard where the press is, whose view keeps its own.
app.run("file.new", json!({"width": 1400, "height": 200})).unwrap();
app.run("artboard.new", json!({"x": 1000, "y": 0, "width": 300, "height": 200})).unwrap();
app.view_mut().unwrap().artboard = 0;
}
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
app.run("artboard.new", json!({"x": 1000, "y": 0, "width": 300, "height": 200})).unwrap();
for k in 0..40 {
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
app.view_mut().unwrap().artboard = 0;
let view = app.view_info();
let at = |kind, x, mods| PointerEvent { kind, pos: vectorcraft_geom::Point::new(x, 10.0), mods, pressure: 1.0, backquote: false, pen: None };
crate::canvas::dispatch(&mut app, &at(PointerKind::Move, 15.0, Mods::default()), view);
app.session.tool_poll(view, true).unwrap();
crate::canvas::dispatch(&mut app, &at(PointerKind::Down, 15.0, Mods::default()), view);
crate::canvas::dispatch(&mut app, &at(PointerKind::Up, 15.0, Mods::default()), view);
let cmd = Mods { cmd: true, ..Default::default() };
crate::canvas::dispatch(&mut app, &at(PointerKind::Down, 1100.0, cmd), view);
crate::canvas::dispatch(&mut app, &at(PointerKind::Up, 1100.0, cmd), view);
assert_eq!(app.view().unwrap().artboard, 0, "{then}: the Cmd-click is held");
if then == "another tool" {
app.select_tool("rectangle");
assert!(!app.session.tool_editing(), "{then}");
}
if then == "All on Active Artboard" {
app.run("select.allOnArtboard", json!({})).unwrap();
assert!(app.session.doc().unwrap().selection.is_empty(), "{then}: the second artboard, which holds no art");
}
let doc = app.session.active_index().unwrap();
if then == "another document" {
// A document tab chosen.
app.session.set_active(0);
}
while app.session.tool_waiting() {
let r = app.session.tool_poll(view, true);
crate::canvas::apply_requests(&mut app, r);
}
app.sync_views();
assert_eq!(app.views[doc].artboard, 1, "{then}: the Cmd-click made the artboard it landed in active");
if then == "another document" {
assert_eq!(app.views[0].artboard, 0, "{then}: the other document's view keeps its artboard");
}
}
/// A dialog chosen from the menu, or with `ui.menuDialog`, while a Shape Builder merge is
/// computed on another thread opens after the merge is applied, on the merged art. So do the
/// app's own dialogs (Recolor Artwork), from the menu or a panel's button.
#[test]
fn menu_dialogs_open_after_a_merge_on_another_thread() {
for route in ["menu", "ui.menuDialog", "app dialog from the menu", "app dialog from a panel"] {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
for k in 0..40 {
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
let view = app.view_info();
let at = |kind| PointerEvent {
kind,
pos: vectorcraft_geom::Point::new(15.0, 10.0),
mods: Mods::default(),
pressure: 1.0,
backquote: false,
pen: None,
};
crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view);
app.session.tool_poll(view, true).unwrap();
crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view);
crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view);
assert!(app.session.tool_editing(), "{route}");
match route {
"menu" => crate::menus::invoke(&mut app, "object.move", json!({})),
"ui.menuDialog" => {
app.run("ui.menuDialog", json!({"command": "object.move"})).unwrap();
}
"app dialog from the menu" => crate::menus::invoke(&mut app, "ui.recolorDialog", json!({})),
_ => {
app.run("ui.recolorDialog", json!({})).unwrap();
}
}
assert!(app.ui.dialog.is_some(), "{route}: the dialog is open");
assert!(!app.session.tool_editing(), "{route}");
let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone());
assert_eq!(merged.as_deref(), Some("Shape Builder"), "{route}");
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 41, "{route}: the dialog acts on the merged art");
}
}
/// The Separations panel's checkboxes and a preference set while a Shape Builder merge is
/// computed on another thread give the documents new revisions: the merge is applied first,
/// and the document keeps it.
#[test]
fn panels_that_redraw_apply_a_merge_on_another_thread_first() {
let cases = [
("view.separationsPreview", json!({"on": false})),
("view.overprintPreview", json!({"on": false})),
("prefs.set", json!({"values": {"gridlineEvery": 90}})),
];
for (id, params) in cases {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
for k in 0..40 {
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
let view = app.view_info();
let at = |kind| PointerEvent {
kind,
pos: vectorcraft_geom::Point::new(15.0, 10.0),
mods: Mods::default(),
pressure: 1.0,
backquote: false,
pen: None,
};
crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view);
app.session.tool_poll(view, true).unwrap();
crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view);
crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view);
assert!(app.session.tool_editing(), "{id}");
app.run(id, params).unwrap();
assert!(!app.session.tool_editing(), "{id}");
for _ in 0..3 {
app.session.tool_poll(view, false).unwrap();
}
let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone());
assert_eq!(merged.as_deref(), Some("Shape Builder"), "{id}");
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 41, "{id}");
}
}
/// Saving, exporting or closing while a Shape Builder merge is computed on another thread
/// applies the merge first: the file holds it, and closing asks to save it.
#[test]
fn save_and_close_apply_a_merge_on_another_thread_first() {
let dir = vectorcraft_testkit::temp_dir("control-merge-save");
for then in ["file.saveAs", "file.exportAs", "file.close"] {
let services = Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), ..Default::default() };
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
for k in 0..40 {
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
// As if just saved: only the merge modifies it.
app.session.doc_mut().unwrap().mark_saved();
let view = app.view_info();
let at = |kind| PointerEvent {
kind,
pos: vectorcraft_geom::Point::new(15.0, 10.0),
mods: Mods::default(),
pressure: 1.0,
backquote: false,
pen: None,
};
crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view);
app.session.tool_poll(view, true).unwrap();
crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view);
crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view);
assert!(app.session.tool_editing() && !app.session.doc().unwrap().is_dirty(), "{then}");
let path = dir.join(if then == "file.exportAs" { "a.svg" } else { "a.vectorcraft" });
let p = if then == "file.close" { json!({}) } else { json!({"path": path.to_string_lossy()}) };
let r = app.run(then, p).unwrap();
assert!(!app.session.tool_editing(), "{then}");
let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone());
assert_eq!(merged.as_deref(), Some("Shape Builder"), "{then}");
match then {
"file.saveAs" => assert!(!app.session.doc().unwrap().is_dirty(), "the file holds the merge"),
"file.exportAs" => assert!(app.session.doc().unwrap().is_dirty(), "an export leaves the document modified"),
_ => assert_eq!(r["pending"], "saveChanges", "closing asks to save the merge"),
}
}
}
/// `ui.dialog.set` of a field the open dialog does not have is an error that lists its fields,
/// and the dialog stays as it was. Fields a dialog may not hold yet are still set: `discard`
/// with a discard button, `preview` with a live preview, `__` fields and the spec's `inputs`.
+1
View File
@@ -144,6 +144,7 @@ pub use document_setup::open as open_document_setup;
pub use effect::open as open_effect_dialog;
pub(crate) use effect::{FILM_GRAIN_FIELDS, PAINT_DAUB_BRUSH_TYPE_LABELS, SHADES, shown_choices};
pub use export_as::open as open_export_as;
pub(crate) use export_for_screens::drop_unread_screen_settings;
pub use export_for_screens::open as open_export_for_screens;
pub(crate) use export_for_screens::{
KIND as EXPORT_FOR_SCREENS, PresetPick as ScreenPresetPick, export_preset as screen_export_preset, formats as screen_formats,
+20 -1
View File
@@ -115,6 +115,23 @@ impl Channel {
Channel::Blue => (2, 0, 1),
}
}
/// The color field's axes (across, up) as keys and screen readers see them: hue in degrees,
/// saturation and brightness in percent, red, green and blue from 0 to 255.
fn field_axes(self) -> [widgets::FieldAxis<'static>; 2] {
let axis = |i: usize| {
let (name, units, suffix) = match (self.is_hsb(), i) {
(true, 0) => (tl!("Hue"), 360.0, "°"),
(true, 1) => (tl!("Saturation"), 100.0, "%"),
(true, _) => (tl!("Brightness"), 100.0, "%"),
(false, 0) => (tl!("Red"), 255.0, ""),
(false, 1) => (tl!("Green"), 255.0, ""),
(false, _) => (tl!("Blue"), 255.0, ""),
};
widgets::FieldAxis { name, units, suffix }
};
let (_, x, y) = self.axes();
[axis(x), axis(y)]
}
}
/// The picked colour with its HSB (hue in degrees), kept so the hue survives grey colours.
@@ -367,7 +384,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> DialogAction {
} else {
let (z, x, y) = field_pos(channel, &p);
let at = |x: f32, y: f32| c32(&field_pick(channel, z, x, y).snapped(web).color);
if let (Some((x, y)), _) = widgets::color_field(ui, "cp-field", vec2(side, side), (x, y), &at) {
if let (Some((x, y)), _) =
widgets::color_field(ui, "cp-field", (tl!("Color field"), channel.field_axes()), vec2(side, side), (x, y), true, &at)
{
pick = Some(field_pick(channel, z, x, y));
}
ui.add_space(SLIDER_GAP);
@@ -27,6 +27,7 @@
use serde::Deserialize;
use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, ScreenSize, pdf};
use vectorcraft_engine::cmd::params::{drop_unread_row_options, screen_formats};
use super::{DialogSpec, form, png_options, svg_options};
use crate::state::Dialog;
@@ -213,12 +214,29 @@ pub(crate) fn saved_options(app: &VectorcraftApp, saved: &Map<String, Value>, id
options.insert("antiAlias".into(), aa.clone());
}
if let Some(o) = saved.get("settings").and_then(|v| v.get(id)).and_then(Value::as_object) {
options.extend(o.iter().map(|(k, v)| (k.clone(), v.clone())));
let mut o = o.clone();
drop_unread_row_options(&mut o);
options.extend(o);
}
options
}
/// A row with its size as the text the Scale field shows (`2x`, `100w`, `100h`, `72ppi`).
/// Export settings `settings` (as `document.exportSettings` or a preset holds them) without the
/// keys the export no longer reads in its rows and per-format settings, and without settings for a
/// format it doesn't write.
pub(crate) fn drop_unread_screen_settings(settings: &mut Map<String, Value>) {
if let Some(rows) = settings.get_mut("formats").and_then(Value::as_array_mut) {
rows.iter_mut().filter_map(Value::as_object_mut).for_each(drop_unread_row_options);
}
if let Some(all) = settings.get_mut("settings").and_then(Value::as_object_mut) {
let formats = screen_formats();
all.retain(|id, _| formats.contains(&id.as_str()));
all.values_mut().filter_map(Value::as_object_mut).for_each(drop_unread_row_options);
}
}
/// A row with its size as the text the Scale field shows (`2x`, `100w`, `100h`, `72ppi`), without
/// the keys the export no longer reads.
fn editable_row(row: &Value) -> Value {
let num = |k: &str| row.get(k).and_then(Value::as_f64);
let text = match (num("width"), num("height"), num("ppi"), row.get("scale")) {
@@ -231,6 +249,7 @@ fn editable_row(row: &Value) -> Value {
};
let mut r = row.clone();
if let Some(o) = r.as_object_mut() {
drop_unread_row_options(o);
for k in ["width", "height", "ppi"] {
o.remove(k);
}
@@ -887,7 +906,11 @@ pub(super) fn apply_preset(d: &mut Dialog, preset: &Value) {
d.fields.insert("formats".into(), json!(rows));
d.fields.insert("preset".into(), json!(p));
}
if let (Some(all), Some(given)) = (d.fields.get_mut("settings").and_then(Value::as_object_mut), st["settings"].as_object()) {
// Settings for a format the export doesn't write, and keys it no longer reads, are left out.
let mut given = Map::from_iter([("settings".to_string(), st["settings"].clone())]);
drop_unread_screen_settings(&mut given);
if let (Some(all), Some(given)) = (d.fields.get_mut("settings").and_then(Value::as_object_mut), given.get("settings").and_then(Value::as_object))
{
for (id, o) in given {
match (all.get_mut(id).and_then(Value::as_object_mut), o.as_object()) {
(Some(mine), Some(o)) => mine.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))),
+15 -1
View File
@@ -112,7 +112,8 @@ pub fn open(app: &mut VectorcraftApp, mode: Mode, path: Option<&str>) {
/// only when saving).
fn options(d: &Dialog) -> Value {
let mode = Mode::of(d);
let mut o: Map<String, Value> = d.fields.iter().filter(|(k, _)| !UI_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
let mut o: Map<String, Value> =
d.fields.iter().filter(|(k, _)| !UI_KEYS.contains(&k.as_str()) && !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect();
if mode == Mode::Export {
o.remove("preserveEditing");
if d.bool("useArtboards") {
@@ -379,6 +380,19 @@ mod tests {
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("decimals", 0.0), 1.0);
}
/// A dialog a save panel opened carries the dialog layer's own `__` fields; the save is given
/// only the SVG options, so it is not refused for an unknown key.
#[test]
fn save_as_svg_from_a_save_panel_passes_only_the_options() {
let (mut app, written) = app("/tmp/panel.svg");
app.run("file.saveAs", Value::Null).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert(crate::dialogs::AFTER_PANEL.into(), json!(true));
assert!(options(app.ui.dialog.as_ref().unwrap()).get(crate::dialogs::AFTER_PANEL).is_none());
let d = app.ui.dialog.take().unwrap();
confirm(&mut app, &d).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(written.borrow()[0].0, "/tmp/panel.svg");
}
#[test]
fn all_artboards_export_one_file_each() {
let (mut app, written) = app("/tmp/set.svg");
+1
View File
@@ -654,6 +654,7 @@ impl PluginIntField {
author: String::new(),
api: 1,
permissions: None,
inputs: Vec::new(),
params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())],
extensions: Vec::new(),
budget: Default::default(),
@@ -957,6 +957,34 @@ fn saving_an_applied_preset_keeps_its_bleed_and_fill() {
assert_eq!((s.get("includeBleed"), s.get("includeArtboardFill")), (Some(&json!(true)), Some(&json!(false))));
}
/// Remembered settings and a preset that hold keys the export no longer reads, in a row, in a
/// format's settings and inside an object option, and settings for a format it doesn't write, open
/// without them, so the dialog's export is not refused.
#[test]
fn saved_keys_no_longer_read_are_left_out_of_the_dialog_s_export() {
let (mut app, _) = app(1, false);
let stale = json!({
"formats": [{"format": "png", "scale": "2x", "noSuchOption": 1}, {"format": "pdf", "marks": {"trim": true, "noSuchMark": 1}}],
"settings": {"png": {"noSuchSetting": true}, "dxf": {}, "pdf": {"bleed": {"top": 1, "inner": 2}}}
});
let Value::Object(saved) = stale.clone() else { panic!("an object") };
std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).export_settings = saved;
let folder = temp_folder("stale");
let check = |app: &VectorcraftApp| {
let p = export_for_screens::params(app, app.ui.dialog.as_ref().unwrap()).unwrap();
app.session.check_params("document.exportForScreens", &p).unwrap_or_else(|e| panic!("{e}: {p}"));
p
};
open(&mut app);
set(&mut app, "folder", json!(folder.to_string_lossy()));
let p = check(&app);
assert_eq!(p["formats"][0]["scale"], json!("2x"));
assert_eq!(p["formats"][1]["marks"]["trim"], json!(true));
export_for_screens::apply_preset(app.ui.dialog.as_mut().unwrap(), &json!({"name": "Old", "settings": stale}));
check(&app);
let _ = std::fs::remove_dir_all(&folder);
}
/// A preset that puts each item in its own folder (`item` or `itemFormat`, which the dialog has no
/// control for) keeps that folder mode when it is exported or saved again from the dialog, until
/// the sub-folder controls change (in the dialog or by `ui.dialog.set`).

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