Merge remote-tracking branch 'origin/main' into K-1397ff

This commit is contained in:
bflatastic 2026-10-12 20:05:33 +07:00
commit e72a010e1c
74 changed files with 2767 additions and 225 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,8 @@
//! Parse automation roots before starting any desktop services. A malformed flag must not
//! silently remove a root and leave the control channel unconfined.
//! Parse the control port and automation roots before starting any desktop services. A malformed
//! flag must not silently remove a root and leave the control channel unconfined, or leave the app
//! running without the control channel that was asked for.
use std::ffi::OsString;
use std::path::PathBuf;
pub struct AutomationArgs {
@ -46,6 +48,50 @@ impl AutomationArgs {
}
}
/// `--control <port>` or `--control=<port>`, given at most once.
#[derive(Default)]
pub struct ControlArg {
pub port: Option<u16>,
}
impl ControlArg {
/// Returns whether this argument is `--control`. The next argument is its port whatever it
/// is, so `--control --automation-read-root` reports the flag rather than reading it as a root.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let value = match arg.strip_prefix("--control") {
Some("") => args.next(),
Some(rest) => match rest.strip_prefix('=') {
Some(value) => Some(value.to_owned()),
None => return Ok(false),
},
None => return Ok(false),
};
if self.port.is_some() {
return Err("--control given twice".into());
}
self.port = Some(port("--control", value.as_deref())?);
Ok(true)
}
}
/// `VECTORCRAFT_CONTROL_PORT`, for when `--control` isn't given: unset or empty is no control
/// channel, anything else must be a port.
pub fn env_control_port(value: Option<OsString>) -> Result<Option<u16>, String> {
match value {
Some(v) if !v.is_empty() => port("VECTORCRAFT_CONTROL_PORT", Some(&v.to_string_lossy())).map(Some),
_ => Ok(None),
}
}
/// A port from 1 to 65535 (the server would listen on 0's random port without saying which).
fn port(name: &str, value: Option<&str>) -> Result<u16, String> {
let need = format!("{name} needs a port number from 1 to 65535");
match value {
None => Err(need),
Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -87,4 +133,53 @@ mod tests {
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
}
fn control(args: &[&str]) -> Result<Option<u16>, String> {
let mut control = ControlArg::default();
let mut args = args.iter().map(|a| a.to_string());
while let Some(a) = args.next() {
assert!(control.consume(&a, &mut args)?, "{a}");
}
Ok(control.port)
}
#[test]
fn control_needs_a_port_from_1_to_65535() {
let need = "--control needs a port number from 1 to 65535";
assert_eq!(control(&["--control"]), Err(need.into()));
assert_eq!(control(&["--control", "--automation-read-root"]), Err(format!("{need}, not `--automation-read-root`")));
for bad in ["abc", "0", "70000", "-1", ""] {
assert_eq!(control(&["--control", bad]), Err(format!("{need}, not `{bad}`")));
assert_eq!(control(&[&format!("--control={bad}")]), Err(format!("{need}, not `{bad}`")));
}
assert_eq!(control(&["--control", "7979"]), Ok(Some(7979)));
assert_eq!(control(&["--control=65535"]), Ok(Some(65535)));
assert_eq!(control(&["--control", "7979", "--control=7980"]), Err("--control given twice".into()));
}
#[test]
fn other_arguments_are_not_control() {
let mut control = ControlArg::default();
let mut args = ["7979".to_owned()].into_iter();
for other in ["--controls", "--control-port", "drawing.svg", "--in-window-menus"] {
assert!(!control.consume(other, &mut args).unwrap(), "{other}");
}
assert_eq!(args.next().as_deref(), Some("7979"));
assert_eq!(control.port, None);
}
#[test]
fn control_port_variable_is_a_port_or_unset() {
assert_eq!(env_control_port(None), Ok(None));
assert_eq!(env_control_port(Some("".into())), Ok(None));
assert_eq!(env_control_port(Some("7979".into())), Ok(Some(7979)));
let need = "VECTORCRAFT_CONTROL_PORT needs a port number from 1 to 65535";
assert_eq!(env_control_port(Some("abc".into())), Err(format!("{need}, not `abc`")));
assert_eq!(env_control_port(Some("0".into())), Err(format!("{need}, not `0`")));
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
assert_eq!(env_control_port(Some(OsString::from_vec(vec![0x39, 0xff]))), Err(format!("{need}, not `9\u{fffd}`")));
}
}
}

View File

@ -6,7 +6,8 @@
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
//!
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`; a port from 1 to 65535, or the app doesn't
//! start) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `vectorcraft_ui_egui::control` for the methods. `--automation-read-root` and
//! `--automation-write-root` (or `VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`) confine the
@ -605,7 +606,7 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
fn main() -> std::process::ExitCode {
// Validate roots before logging, font scans, preferences or the window: a missing value
// must not clear an environment root and start the control channel without confinement.
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut control = automation_args::ControlArg::default();
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
let mut roots_args =
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
@ -613,7 +614,7 @@ fn main() -> std::process::ExitCode {
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match roots_args.consume(&a, &mut args) {
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
Ok(true) => continue,
Ok(false) => {}
Err(e) => {
@ -622,7 +623,6 @@ fn main() -> std::process::ExitCode {
}
}
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--in-window-menus" => in_window_menus = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
@ -632,6 +632,17 @@ fn main() -> std::process::ExitCode {
}
}
let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args;
// After the arguments: `--control` and `--version` win over a bad environment value.
let control_port = match control.port {
Some(port) => Some(port),
None => match automation_args::env_control_port(std::env::var_os("VECTORCRAFT_CONTROL_PORT")) {
Ok(port) => port,
Err(e) => {
eprintln!("vectorcraft: {e}");
return std::process::ExitCode::FAILURE;
}
},
};
// Before desktop services, so every start-up warning is recorded (`logging`).
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();

View File

@ -9,12 +9,17 @@ const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
/// Invalid roots must exit before desktop initialization on every platform, including a CI
/// machine with no display. A regression must fail rather than hang with a window open.
fn rejects(args: &[&str], message: &str) {
rejects_with_env(args, &[], message);
}
fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) {
let mut child = Command::new(BIN)
.args(args)
.env_remove("VECTORCRAFT_CONTROL_PORT")
.env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT")
.env_remove("VECTORCRAFT_AUTOMATION_WRITE_ROOT")
.env("VECTORCRAFT_NO_PREFS", "1")
.envs(env.iter().copied())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@ -68,3 +73,24 @@ fn explicit_dash_folder_does_not_consume_version() {
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
assert!(out.stderr.is_empty());
}
/// `--control` without a port no longer starts the app without a control server, and the
/// argument after it isn't reinterpreted (#1373).
#[test]
fn control_without_a_valid_port_fails_before_startup() {
let need = "--control needs a port number from 1 to 65535";
rejects(&["--control", "--automation-read-root", ".", "--automation-write-root", "."], &format!("{need}, not `--automation-read-root`"));
rejects(&["--control"], need);
rejects(&["--control", "abc"], &format!("{need}, not `abc`"));
rejects(&["--control=0"], &format!("{need}, not `0`"));
rejects(&["--control="], &format!("{need}, not ``"));
rejects(&["--control", "7979", "--control", "7980"], "--control given twice");
rejects_with_env(&[], &[("VECTORCRAFT_CONTROL_PORT", "abc")], "VECTORCRAFT_CONTROL_PORT needs a port number from 1 to 65535, not `abc`");
}
#[test]
fn version_wins_over_a_bad_control_port_variable() {
let out = Command::new(BIN).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
}

View File

@ -86,3 +86,6 @@ sync_github_name = false
real_name = "Aaron Guo"
display_name = "Aaron Guo"
sync_github_name = false
[people.Nael-Nathanael]
sync_github_name = true

View File

@ -31,6 +31,10 @@ impl Hit {
/// The object the Selection tool selects: the child of the layer (the outermost group).
/// Inside isolation mode (`scope`), the child of the isolated container instead.
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
// A single object isolated: it is the only object.
if scope == Some(self.leaf) {
return self.leaf;
}
// The innermost of the isolated container and an envelope whose contents are edited.
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
if let Some(i) = at(scope).max(at(self.contents_of)) {
@ -101,10 +105,16 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
// locked, and clicks pass through it (#833).
if editable_only
&& let Some(scope) = opt.scope
&& let Some(container) = doc.node(scope).filter(|n| n.is_container())
&& let Some(isolated) = doc.node(scope)
&& let Some(mut chain) = doc.ancestry(scope)
{
return hit_children(container, p, opt, skip, &mut chain, editable_only).map(finish);
if isolated.is_container() {
return hit_children(isolated, p, opt, skip, &mut chain, editable_only).map(finish);
}
// A single object isolated (a path, an image…): only it hits, among its siblings.
chain.pop();
let parent = chain.last().and_then(|id| doc.node(*id))?;
return hit_children(parent, p, opt, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish);
}
let mut chain = Vec::new();
for layer in doc.layers.iter().rev() {
@ -283,7 +293,11 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
let mut out = Vec::new();
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
Some(s) => match s.children() {
Some(c) => c.iter().collect(),
// A single object isolated: only it.
None => doc.parent_of(s.id).and_then(|p| doc.node(p)).and_then(Node::children).into_iter().flatten().filter(|c| c.id == s.id).collect(),
},
// The objects of the layers, looking through sublayers (hidden, locked and template ones
// are left out).
None => doc

View File

@ -33,8 +33,24 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc),
cmd!("shape.rectangularGrid", "Rectangular Grid", [], None, "{x, y, width, height, rows=5, columns=5} → {id}", has_doc, rect_grid),
cmd!("shape.polarGrid", "Polar Grid", [], None, "{x, y, width, height, concentric=5, radial=5} → {id}", has_doc, polar_grid),
cmd!(
"shape.rectangularGrid",
"Rectangular Grid",
[],
None,
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none)} → {id}",
has_doc,
rect_grid
),
cmd!(
"shape.polarGrid",
"Polar Grid",
[],
None,
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none)} → {id}",
has_doc,
polar_grid
),
cmd!(
"path.create",
"Create Path",
@ -351,14 +367,24 @@ fn arc(s: &mut Session, p: &Value) -> Result<Value> {
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
}
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Value> {
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
/// current fill when `fill`; a path of several subpaths becomes a compound path of them.
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) -> Result<Value> {
let parent = s.doc()?.target_parent()?;
let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width);
let fill = if fill { s.paint.fill.clone() } else { vectorcraft_color::Paint::None };
let look = s.new_art_look(fill, s.paint.stroke.clone(), s.paint.stroke_width);
let id = s.edit(label, |d, sel| {
let children = paths
.into_iter()
.map(|pd| {
let mut n = Node::path(d.alloc_id(), pd, Appearance::default());
let mut n = if pd.subpaths.len() > 1 {
let members =
pd.subpaths.into_iter().map(|sp| std::sync::Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default())));
let children = members.collect();
Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::NonZero })
} else {
Node::path(d.alloc_id(), pd, Appearance::default())
};
look.apply(d, &mut n);
std::sync::Arc::new(n)
})
@ -371,18 +397,43 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Valu
Ok(json!({ "id": id.0 }))
}
/// A grid's divider count `count` with its skew `key` in percent (0 when absent, clamped to
/// ±[`shapes::GRID_SKEW_MAX`]).
fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> {
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let skew = match p.get(key) {
None | Some(Value::Null) => 0.0,
Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?,
};
Ok((n, skew.clamp(-shapes::GRID_SKEW_MAX, shapes::GRID_SKEW_MAX)))
}
/// A grid's on/off option `key` (`default` when absent).
fn switch(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))),
}
}
fn rect_grid(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.rectangularGrid")?;
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
grid_group(s, "Rectangular Grid", shapes::rectangular_grid(r, rows, cols, true))
const CMD: &str = "shape.rectangularGrid";
let r = rect_of(p, CMD)?;
let paths =
shapes::rectangular_grid(r, dividers(p, "rows", "rowSkew", CMD)?, dividers(p, "columns", "columnSkew", CMD)?, switch(p, "frame", true, CMD)?);
grid_group(s, "Rectangular Grid", paths, switch(p, "fill", false, CMD)?)
}
fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.polarGrid")?;
let c = p.get("concentric").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let rad = p.get("radial").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad))
const CMD: &str = "shape.polarGrid";
let r = rect_of(p, CMD)?;
let paths = shapes::polar_grid(
r,
dividers(p, "concentric", "concentricSkew", CMD)?,
dividers(p, "radial", "radialSkew", CMD)?,
switch(p, "compound", false, CMD)?,
);
grid_group(s, "Polar Grid", paths, switch(p, "fill", false, CMD)?)
}
/// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when

View File

@ -222,7 +222,12 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
.flatten()
.map(|e| (e.intact, move || Some(e.data)));
restore_or_import(editing, || match super::pdfimport::import(bytes, opts) {
Ok((doc, warnings)) => Ok(through_editing_data(bytes, opts, doc, warnings)),
Ok(r) => {
// Pages of text alone: the editing copy alone where it reads, as for a file
// saved without its PDF part (#1387: else the page, which is the art).
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings)))
}
// Saved without its PDF part: the editing copy is all the file has.
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))

View File

@ -130,10 +130,9 @@ fn choice<T: Choice + Default>(cmd: &str, key: &str, id: Option<&str>) -> Result
.ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", "))))
}
/// Import the pages `o` pick → the document and import notes.
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?;
Ok((r.document, r.warnings))
/// Import the pages `o` pick → the document, import notes and what else the import tells.
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<vectorcraft_pdf::ImportReport> {
vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)
}
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one

View File

@ -154,6 +154,52 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
assert!(e.contains("placeholder"), "{e}");
}
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
/// The kinds of what the first layer holds: type (or its outlines, without the font) and others.
fn text_and_others(s: &Session) -> (usize, usize) {
let art = s.doc().unwrap().doc.layers[0].children().unwrap();
let text = art.iter().filter(|n| matches!(n.kind, NodeKind::Text(_)) || n.name.as_deref() == Some("<Text Outlines>")).count();
(text, art.len() - text)
}
#[test]
fn an_ai_file_whose_pdf_part_is_only_text_opens_from_it_when_its_editing_data_cant_be_read() {
// #1387. The page states where its art is (an art, trim or bleed box), which the notice
// page of a file saved without its PDF part has no art to do.
let unreadable = b"%AI24_ZStandard_Data\0\0\0\0\0\0\0\0";
for boxed in ["/ArtBox [0 0 200 100]", "/TrimBox [0 0 200 100]", "/BleedBox [0 0 200 100]"] {
let mut s = Session::new();
let r = open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({})).unwrap();
// Then a note on the font, where the machine lacks it.
assert_eq!(r["warnings"][0], json!(vectorcraft_pdf::OFF_ARTBOARD_NOTE), "{boxed}: {r}");
assert_eq!(text_and_others(&s), (1, 0), "{boxed}");
// The PDF part alone, as asked.
open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({"editingData": false})).unwrap();
assert_eq!(text_and_others(&s), (1, 0), "{boxed}");
}
// The same page without a box is the notice: the file still says why it can't open.
let mut s = Session::new();
let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({})).unwrap_err().to_string();
assert!(e.contains("without PDF compatibility") && e.contains("can't be read"), "{e}");
let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({"editingData": false})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
// Placed, it is read as its page too.
let r = s.execute("file.new", &json!({})).and_then(|_| {
let data = vectorcraft_format::base64_encode(&ai::ai_with(unreadable, TEXT_ART, "/ArtBox [0 0 200 100]"));
s.execute("file.place", &json!({"name": "text.ai", "dataBase64": data, "link": false}))
});
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn an_ai_file_whose_pdf_part_is_only_text_still_opens_from_editing_data_that_reads() {
let mut s = Session::new();
open(&mut s, "art.ai", &ai::ai_with(&compressed(&ai::sample_data()), TEXT_ART, "/ArtBox [0 0 200 100]"), json!({})).unwrap();
check_sample(&s);
}
#[test]
fn editing_data_false_opens_only_what_the_page_prints() {
let mut s = Session::new();

View File

@ -857,8 +857,10 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
.ok_or_else(|| bad("object.isolate", "missing id"))?;
let st = s.doc_mut()?;
if st.doc.node(id).is_none_or(|n| !n.is_container()) {
return Err(bad("object.isolate", "only groups and layers can be isolated"));
// Groups, layers and single objects (a path, a compound path, an image…); type isn't
// isolated: a double-click on it edits its text.
if st.doc.node(id).is_none_or(|n| matches!(n.kind, NodeKind::Text(_))) {
return Err(bad("object.isolate", "type objects can't be isolated"));
}
let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
st.isolation = Some(isolation);

View File

@ -193,7 +193,8 @@ pub fn specs() -> Vec<CommandSpec> {
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
match iso.and_then(|i| d.node(i)) {
Some(c) if d.ancestry(c.id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template()))) => {
c.layer_art(true)
// A single object isolated is all there is to select.
if c.is_container() { c.layer_art(true) } else { vec![c.id] }
}
Some(_) => vec![],
None => d.selectable_art(),

View File

@ -483,6 +483,12 @@ impl DocState {
{
return Some(i);
}
// A single object isolated: new art goes beside it, in its group or layer.
if let Some(p) = self.isolation.filter(|i| self.doc.node(*i).is_some_and(|n| !n.is_container())).and_then(|i| self.doc.parent_of(i))
&& self.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }))
{
return Some(p);
}
// A sublayer takes new art only while it and the layers around it are shown and unlocked.
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())
}

View File

@ -442,6 +442,132 @@ fn line_family_tools_draw_and_click_asks_dialog() {
assert_eq!(ui, vec![UiRequest::Dialog("polarGrid".into(), json!({"x": 10.0, "y": 10.0}))]);
}
/// The children of the grid a `shape.rectangularGrid` / `shape.polarGrid` call made, bottom first.
fn grid(s: &mut Session, cmd: &str, p: serde_json::Value) -> Vec<std::sync::Arc<vectorcraft_doc::Node>> {
let id = NodeId(s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap());
s.doc().unwrap().doc.node(id).unwrap().children().unwrap().to_vec()
}
/// `base` with the keys of `extra` added.
fn with(base: &serde_json::Value, extra: serde_json::Value) -> serde_json::Value {
let mut p = base.clone();
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
}
fn is_filled(n: &vectorcraft_doc::Node) -> bool {
n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None)
}
/// #1377: the Rectangular Grid options. By default the frame is one rectangle under evenly spaced
/// dividers and nothing is filled; `frame: false` draws its four sides instead, `fill` paints the
/// current fill and the skews crowd the dividers to the top / right (positive) or bottom / left.
#[test]
fn rectangular_grid_options_frame_fill_and_skew() {
const CMD: &str = "shape.rectangularGrid";
let mut s = session();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let base = json!({"x": 0, "y": 0, "width": 100, "height": 100, "rows": 3, "columns": 3});
// The y of the three horizontal dividers and the x of the three vertical ones (the last six children).
let dividers = |g: &[std::sync::Arc<vectorcraft_doc::Node>]| {
let starts: Vec<Point> = g[g.len() - 6..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[0].p).collect();
starts[..3].iter().map(|p| p.y).chain(starts[3..].iter().map(|p| p.x)).collect::<Vec<_>>()
};
let plain = grid(&mut s, CMD, base.clone());
assert_eq!(plain.len(), 7);
assert!(plain[0].path_data().unwrap().is_closed(), "the frame lies under the dividers");
assert!(plain.iter().all(|n| !is_filled(n) && n.appearance.stroke().is_some()));
assert_eq!(dividers(&plain), [25.0, 50.0, 75.0, 25.0, 50.0, 75.0]);
// No frame: its four sides are lines of their own.
let sides = grid(&mut s, CMD, with(&base, json!({"frame": false})));
assert_eq!(sides.len(), 10);
assert!(sides.iter().all(|n| !n.path_data().unwrap().is_closed() && n.path_data().unwrap().anchor_count() == 2));
assert_eq!(dividers(&sides), dividers(&plain));
let filled = grid(&mut s, CMD, with(&base, json!({"fill": true})));
assert!(filled.iter().all(|n| is_filled(n)), "Fill Grid paints the current fill");
assert_eq!(filled[0].appearance.fill().unwrap().paint, s.paint.fill);
// Skew: toward the top and the right when positive, the bottom and the left when negative;
// past ±500 % is ±500 %.
let top_right = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 100, "columnSkew": 100}))));
for (got, want) in top_right.iter().zip([6.25, 25.0, 56.25, 43.75, 75.0, 93.75]) {
assert!((got - want).abs() < 1e-9, "{top_right:?}");
}
let bottom_left = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": -100, "columnSkew": -100}))));
assert!(bottom_left[1] > 50.0 && bottom_left[4] < 50.0, "{bottom_left:?}");
let most = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 500, "columnSkew": -500}))));
let beyond = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 1e300, "columnSkew": -1e300}))));
assert_eq!(most, beyond);
assert!(most.iter().all(|v| *v > 0.0 && *v < 100.0), "{most:?}");
}
/// #1377: the Polar Grid options. The ellipses lie under the radial dividers, largest first;
/// `compound` pairs them into compound-path rings, `fill` paints the current fill and the skews
/// crowd the ellipses outward and the radial dividers clockwise of the top one (positive).
#[test]
fn polar_grid_options_compound_fill_and_skew() {
const CMD: &str = "shape.polarGrid";
let mut s = session();
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
let base = json!({"x": 0, "y": 0, "width": 100, "height": 100, "concentric": 3, "radial": 4});
let width = |n: &vectorcraft_doc::Node| n.geometric_bounds().unwrap().width();
let tips =
|g: &[std::sync::Arc<vectorcraft_doc::Node>]| g[4..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[1].p).collect::<Vec<_>>();
let plain = grid(&mut s, CMD, base.clone());
assert_eq!(plain.len(), 8);
assert_eq!(plain[..4].iter().map(|n| width(n).round()).collect::<Vec<_>>(), [100.0, 75.0, 50.0, 25.0], "largest at the bottom");
assert!(plain[..4].iter().all(|n| n.path_data().unwrap().is_closed()) && plain[4..].iter().all(|n| !n.path_data().unwrap().is_closed()));
assert!(plain.iter().all(|n| !is_filled(n)));
assert!(grid(&mut s, CMD, with(&base, json!({"fill": true}))).iter().all(|n| is_filled(n)));
// Compound: 4 ellipses make 2 rings, each a compound path of an outside and a hole running
// against it that carries the grid's paint itself; with 5, the innermost ellipse stays a path.
let rings = grid(&mut s, CMD, with(&base, json!({"compound": true, "fill": true})));
assert_eq!(rings.len(), 6);
for (ring, sizes) in rings[..2].iter().zip([[100.0, 75.0], [50.0, 25.0]]) {
let NodeKind::Compound { children, rule } = &ring.kind else { panic!("{:?}", ring.kind) };
assert_eq!(*rule, FillRule::NonZero);
assert_eq!(children.iter().map(|n| width(n).round()).collect::<Vec<_>>(), sizes);
let areas: Vec<f64> = children.iter().map(|n| n.path_data().unwrap().subpaths[0].signed_area()).collect();
assert!(areas[0] * areas[1] < 0.0, "the hole runs against the outside: {areas:?}");
assert!(is_filled(ring) && ring.appearance.stroke().is_some());
}
let five = grid(&mut s, CMD, with(&base, json!({"compound": true, "concentric": 4, "radial": 0})));
assert_eq!(five.iter().map(|n| matches!(n.kind, NodeKind::Compound { .. })).collect::<Vec<_>>(), [true, true, false]);
// Skew.
let out = grid(&mut s, CMD, with(&base, json!({"concentricSkew": 100, "radialSkew": 500})));
for (n, want) in out[..4].iter().zip([100.0, 93.75, 75.0, 43.75]) {
assert!((width(n) - want).abs() < 1e-6, "{}", width(n));
}
assert!(tips(&out)[0].distance(Point::new(50.0, 0.0)) < 1e-9, "the top divider stays");
assert!(tips(&out)[1..].iter().all(|t| t.x > 50.0), "clockwise of the top: {:?}", tips(&out));
let inward = grid(&mut s, CMD, with(&base, json!({"concentricSkew": -100, "radialSkew": -500})));
assert!((width(&inward[1]) - 56.25).abs() < 1e-6);
assert!(tips(&inward)[1..].iter().all(|t| t.x < 50.0), "counterclockwise of the top: {:?}", tips(&inward));
}
/// The grid options are checked: a skew is a number and a switch true or false, or the command
/// fails without an undo step.
#[test]
fn grid_commands_reject_options_of_the_wrong_type() {
let mut s = session();
let size = json!({"x": 0, "y": 0, "width": 10, "height": 10});
for (cmd, key, value) in [
("shape.rectangularGrid", "rowSkew", json!("50")),
("shape.rectangularGrid", "columnSkew", json!(true)),
("shape.rectangularGrid", "frame", json!(1)),
("shape.rectangularGrid", "fill", json!("yes")),
("shape.polarGrid", "concentricSkew", json!([1])),
("shape.polarGrid", "radialSkew", json!({})),
("shape.polarGrid", "compound", json!(0)),
("shape.polarGrid", "fill", json!("true")),
] {
let before = s.doc().unwrap().history.undo.len();
let p = with(&size, json!({key: value}));
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{cmd} left an undo step");
assert!(s.execute(cmd, &with(&size, json!({key: null}))).is_ok(), "{cmd}: null is the default");
}
}
#[test]
fn draw2_commands_reject_bad_params() {
let mut s = session();

View File

@ -298,6 +298,60 @@ fn selection_preferences_drive_the_selection_tool() {
assert!(double(&mut s).is_some(), "on: the group is isolated");
}
/// Double Click To Isolate: a double-click with the Selection tool isolates a single object too,
/// not only a group. Inside, only it is hit, marqueed and selected by Select All; new art goes
/// beside it; a double-click on the empty canvas leaves, with it selected. Type isn't isolated:
/// a double-click on it switches to the Type tool.
#[test]
fn double_clicking_a_single_object_isolates_it() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = |v: Value| NodeId(v["id"].as_u64().unwrap());
let rect = |s: &mut Session, x: f64| id(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 50, "height": 50})).unwrap());
let (a, b) = (rect(&mut s, 100.0), rect(&mut s, 200.0));
s.select_tool("selection", ViewInfo::default()).unwrap();
let ev = |s: &mut Session, kinds: &[PointerKind], x: f64, y: f64| {
for k in kinds {
s.pointer(&PointerEvent::new(*k, x, y), ViewInfo::default()).unwrap();
}
};
let click = [PointerKind::Down, PointerKind::Up];
let double = [PointerKind::DoubleClick];
ev(&mut s, &double, 125.0, 125.0);
assert_eq!(s.doc().unwrap().isolation, Some(a));
// Only the isolated object is hit: a click on the other selects nothing.
ev(&mut s, &click, 225.0, 125.0);
assert!(s.doc().unwrap().selection.objects.is_empty());
ev(&mut s, &click, 125.0, 125.0);
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
// A marquee over both and Select All take it alone.
s.execute("select.none", &json!({})).unwrap();
ev(&mut s, &[PointerKind::Down], 90.0, 90.0);
ev(&mut s, &[PointerKind::Move, PointerKind::Up], 300.0, 300.0);
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
s.execute("select.none", &json!({})).unwrap();
s.execute("select.all", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
// A second double-click on it stays in isolation; new art lands on its layer.
ev(&mut s, &double, 125.0, 125.0);
assert_eq!(s.doc().unwrap().isolation, Some(a));
let c = rect(&mut s, 300.0);
let st = s.doc().unwrap();
assert_eq!(st.doc.parent_of(c), st.doc.parent_of(a));
// A double-click on the empty canvas leaves isolation, the object selected.
ev(&mut s, &double, 50.0, 350.0);
let st = s.doc().unwrap();
assert_eq!((st.isolation, st.selection.objects.clone()), (None, vec![a]));
assert!(st.doc.node(b).is_some());
// Type is not isolated.
let t = id(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Type"})).unwrap());
assert!(s.execute("object.isolate", &json!({"id": t.0})).is_err());
s.execute("prefs.set", &json!({"key": "doubleClickToIsolate", "value": false})).unwrap();
ev(&mut s, &double, 225.0, 125.0);
assert_eq!(s.doc().unwrap().isolation, None, "off: no isolation");
}
/// General › Use Precise Cursors (#394): the Pen's pointer becomes a crosshair; the Selection
/// tool's arrow stays.
#[test]

View File

@ -325,6 +325,19 @@ fn structured_junk() {
("object.path.offsetPath", json!({"offset": 1e308})),
("object.path.offsetPath", json!({"offset": -1e308})),
("object.path.simplify", json!({"tolerance": -1, "cornerAngle": 1e308})),
(
"shape.rectangularGrid",
json!({"x": 0, "y": 0, "width": 1e308, "height": -1e308, "rows": 999, "columns": u64::MAX, "rowSkew": 1e308, "columnSkew": -1e308, "frame": false, "fill": true}),
),
("shape.rectangularGrid", json!({"x": 0, "y": 0, "width": 0, "height": 0, "rowSkew": "x", "frame": 3})),
(
"shape.polarGrid",
json!({"x": 0, "y": 0, "width": 1e308, "height": 1e-308, "concentric": 999, "radial": 999, "concentricSkew": -1e308, "radialSkew": 1e308, "compound": true, "fill": true}),
),
(
"shape.polarGrid",
json!({"x": 0, "y": 0, "width": 10, "height": 10, "concentric": -1, "radial": 0.5, "radialSkew": null, "compound": "yes"}),
),
("object.path.splitIntoGrid", json!({"rows": 500, "columns": 1, "gutter": 1e308})),
("object.path.splitIntoGrid", json!({"rows": 0.4})),
("object.setBounds", json!({"width": 0, "height": 0})),

View File

@ -713,7 +713,7 @@ fn rich_resources_pdf(content: &str, config: &str) -> Vec<u8> {
];
let (a, b, form, pat, mesh, hidden) = (x, x + 1, x + 2, x + 3, x + 4, x + 5);
let resources = format!(
"/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"
"/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /OP true /OPM 1 /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /OP true /op false /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> /GS2 << /OP 7 /op (x) /OPM -3 >> /GS3 << /op true >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"
);
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R 99 0 R] /D << {} >> >> ", config.replace("{B}", &format!("{b} 0 R")));
let extra: Vec<&str> = extra.iter().map(String::as_str).collect();
@ -734,6 +734,9 @@ fn arb_rich_op() -> impl Strategy<Value = String> {
"/X1 Do",
"/GS0 gs",
"/GS1 gs",
// Overprint (#1388): set, malformed, and for fills alone.
"/GS2 gs",
"/GS3 gs",
"/CS0 cs 1 0 0 /P0 scn",
"/Pattern cs /P0 scn",
"/Sh0 sh",
@ -1738,9 +1741,11 @@ fn editing_text() -> String {
"1 Xw".into(),
"u".into(),
"/AI11Text :\n0 /FreeUndo ,\n;".into(),
"/AI11Text :\n1 /FreeUndo ,\n;\n1 (Style) XW".into(),
"U".into(),
"0 Xw".into(),
"1 0 0 0 1 0 Bg".into(),
"Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n1 0 0 1 0 0 Xm\n0 0 0 0 0 0 Bm\n1 0 0 1 5 5 Bm\n0 BB".into(),
"0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(),
"0 0 0 1 (Black global) 0.4 0 Xk".into(),
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(),

View File

@ -40,6 +40,10 @@ pub(crate) fn text_frames(data: &[u8]) -> HashMap<(u32, usize), BezPath> {
continue;
}
let Some(head) = seg.get(..art) else { continue };
// The type in a drawn look is a story of another text document (see `Reader::text_slot`).
if number_before(head, b"/FreeUndo") == Some(1.0) {
continue;
}
let (Some(story), frame) = (number_before(head, b"/StoryIndex"), number_before(head, b"/FrameIndex").unwrap_or(0.0)) else { continue };
if !(0.0..f64::from(u32::MAX)).contains(&story) || !(0.0..1e6).contains(&frame) {
continue;
@ -123,6 +127,12 @@ mod tests {
assert_eq!(f.len(), 2, "point type has no frame: {f:?}");
assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(10.0, 20.0, 110.0, 80.0));
assert_eq!(f[&(60, 1)].elements().len(), 2, "a curve, led by %_");
// The type in a drawn look counts its stories in another text document: its frame isn't
// that of the text object which has the same number in this one.
let look = String::from_utf8_lossy(doc).replacen("0 /FreeUndo", "1 /FreeUndo", 1).replacen("10 20 m", "500 20 m", 1);
let own = "/AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r49 /StoryIndex ,\r/Art :\r1 2 m\r3 2 L\r3 4 L\rN\r;\r/ConfiningPath ,\r;\r";
let f = text_frames(format!("{look}{own}").as_bytes());
assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(1.0, 2.0, 3.0, 4.0));
assert!(text_frames(b"/AI11Text : garbage").is_empty());
}
}

View File

@ -9,8 +9,12 @@
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
//! tx ty Bg`, the gradient matrix `a b c d tx ty Bm`, the focal point `x y angle length Bh`,
//! `flag BB`.
//! tx ty Bg`, the focal point `x y angle length Bh`, `flag BB`. Newer files write the gradient's
//! place as matrices after `Bg` (whose own numbers are then not in art space): `a b c d tx ty Xm`
//! takes the unit vector onto a linear gradient's whole ramp (0 to 100, its angle and length in
//! it), and `a b c d tx ty Bm` takes it onto the part between two stops next to each other, one
//! `Bm` for each pair in the order of the ramp (`Bc`, around them, are the caps past the first and
//! last stops). A radial gradient has one `Bm`, which takes the unit circle onto it.
use std::collections::HashMap;
@ -166,8 +170,10 @@ pub(super) struct Instance {
length: f64,
/// `Bg`'s own matrix.
bg: Affine,
/// The gradient matrix (`Bm`), when there is one.
bm: Option<Affine>,
/// The matrix of a linear gradient's whole ramp (`Xm`), when there is one.
xm: Option<Affine>,
/// The first and the last gradient matrix (`Bm`), when there are any.
bm: Option<(Affine, Affine)>,
/// The focal point's offset from the origin (`Bh`).
hilight: Vec2,
}
@ -197,10 +203,22 @@ impl Instance {
}
}
/// `a b c d tx ty Bm`.
pub fn bm(&mut self, vals: &[V]) {
/// The matrix of `a b c d tx ty Bm` or `Xm`, when it can be inverted.
fn matrix(vals: &[V]) -> Option<Affine> {
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
self.bm = n.get(n.len().saturating_sub(6)..).and_then(affine).filter(|m| m.determinant().abs() > 1e-12);
n.get(n.len().saturating_sub(6)..).and_then(affine).filter(|m| m.determinant().abs() > 1e-12)
}
/// `a b c d tx ty Bm`: one for each pair of stops next to each other.
pub fn bm(&mut self, vals: &[V]) {
if let Some(m) = Self::matrix(vals) {
self.bm = Some((self.bm.map_or(m, |(first, _)| first), m));
}
}
/// `a b c d tx ty Xm`.
pub fn xm(&mut self, vals: &[V]) {
self.xm = Self::matrix(vals);
}
/// `x y angle length Bh`.
@ -215,19 +233,35 @@ impl Instance {
pub fn paint(&self, defs: &HashMap<String, Gradient>, to_doc: Affine) -> Option<Paint> {
let name = self.name.as_ref()?;
let gradient = defs.get(name)?.clone();
let m = to_doc * self.bm.unwrap_or(Affine::IDENTITY) * self.bg;
let a = self.angle.to_radians();
let dir = Vec2::new(a.cos(), a.sin()) * self.length;
let start = m * self.origin;
let end = m * (self.origin + dir);
let radial = gradient.kind == GradientKind::Radial;
// The unit vector through `m`, the point across it (for an elliptical radial gradient) and
// the focal point.
let unit = |m: Affine| (m * Point::ORIGIN, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0), m * self.hilight.to_point());
let whole = if radial { self.bm.map(|(m, _)| m).or(self.xm) } else { self.xm };
let (start, end, across, focal) = match (whole, self.bm) {
(Some(m), _) => unit(to_doc * m),
// A linear gradient's `Bm`s alone: they run from its first stop to its last.
(None, Some((first, last))) => {
let (a, b) = (to_doc * first * Point::ORIGIN, to_doc * last * Point::new(1.0, 0.0));
let from = gradient.stops.first().map_or(0.0, |s| f64::from(s.offset));
let to = gradient.stops.last().map_or(1.0, |s| f64::from(s.offset));
let whole = (b - a) / (to - from).max(1e-6);
let start = a - whole * from;
(start, start + whole, start, start)
}
// `Bg`'s own numbers, in art space through its matrix.
(None, None) => {
let m = to_doc * self.bg;
let a = self.angle.to_radians();
let dir = Vec2::new(a.cos(), a.sin()) * self.length;
(m * self.origin, m * (self.origin + dir), m * (self.origin + Vec2::new(-dir.y, dir.x)), m * (self.origin + self.hilight))
}
};
if !(start.is_finite() && end.is_finite()) || start.distance(end) < 1e-9 {
return None;
}
// Across the vector, for an elliptical radial gradient.
let across = m * (self.origin + Vec2::new(-dir.y, dir.x));
let aspect = (across.distance(start) / start.distance(end)).clamp(0.01, 100.0);
let focal = (self.hilight.hypot() > 1e-9).then(|| m * (self.origin + self.hilight)).filter(|p| p.is_finite());
let radial = gradient.kind == GradientKind::Radial;
let focal = (self.hilight.hypot() > 1e-9).then_some(focal).filter(|p| p.is_finite());
let geom = GradientGeom { start, end, aspect: if radial { aspect } else { 1.0 }, focal: if radial { focal } else { None } };
let angle = -(end - start).atan2().to_degrees();
Some(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle, swatch: Some(name.clone()), ..GradientPaint::new(gradient) })))
@ -308,4 +342,35 @@ mod tests {
assert!((geom.aspect - 1.0).abs() < 1e-9);
assert!(i.paint(&HashMap::new(), Affine::IDENTITY).is_none());
}
/// A linear gradient of three stops as newer files place it: `Bg` with numbers that aren't in
/// 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();
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![]);
}
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);
if xm {
i.xm(&nums(&[0.0, -200.0, -30.0, 0.0, 10.0, 300.0]));
}
// From the stop at 20 to the one at 60, then on to the one at 100.
i.bm(&nums(&[0.0, -80.0, -30.0, 0.0, 10.0, 260.0]));
i.bm(&nums(&[0.0, -80.0, -30.0, 0.0, 10.0, 180.0]));
let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() };
gp.geom.unwrap()
};
for xm in [true, false] {
let geom = instance(xm);
// (The stops' offsets are `f32`.)
assert!(geom.start.distance(Point::new(10.0, 300.0)) < 1e-5 && geom.end.distance(Point::new(10.0, 100.0)) < 1e-5, "{xm}: {geom:?}");
assert_eq!((geom.aspect, geom.focal), (1.0, None));
}
}
}

View File

@ -338,7 +338,6 @@ const IGNORED: &[&str] = &[
"X=",
"X+",
"Bc",
"Xm",
"XP",
"Np",
// Flatness (`i`): how finely curves print.
@ -921,13 +920,22 @@ impl<'a> Reader<'a> {
i.bm(&vals);
}
}
"Xm" => {
if let Some(i) = self.instance.as_mut() {
i.xm(&vals);
}
}
"Bh" => {
if let Some(i) = self.instance.as_mut() {
i.bh(&vals);
}
}
"BB" => {
if let Some(i) = self.instance.take() {
// An instance that closes with no path to paint is an object's own note of its
// gradient, written after it: it paints nothing (not the next path either).
if let Some(i) = self.instance.take()
&& !self.path.elements().is_empty()
{
self.set_instance(&i);
}
}
@ -1015,6 +1023,12 @@ impl<'a> Reader<'a> {
if self.pattern.is_some() {
self.unreadable("type in a pattern");
}
// `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its
// story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its
// stories on its own; the object itself follows the look, on `%_` lines, and is the type.
if o.nums("FreeUndo").first() == Some(&1.0) {
return Ok(());
}
let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0));
let gs = self.gs.clone();
@ -1280,9 +1294,20 @@ impl<'a> Reader<'a> {
if !self.hidden_ids.contains(&object.id) || self.hidden_ids.contains(&look.id) || !matches!(look.kind, NodeKind::Group { clip: false, .. }) {
return;
}
let is_text = slot_of(object.name.as_deref()).is_some();
let Some(object) = f.children.pop() else { return };
let Some(look) = f.children.last_mut() else { return };
strip(look, &self.hidden_ids);
if is_text {
// A text object stays the type it is: over what else its look draws, or in place of a
// look that was only its type.
if look.children().is_some_and(|c| c.is_empty()) {
*look = object;
} else {
f.children.push(object);
}
return;
}
look.name = object.name.clone().or(look.name.take());
look.visible = object.visible;
look.locked = object.locked;

View File

@ -281,6 +281,23 @@ fn gradients() {
assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0)));
// The gradient was the one object's.
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
// A linear gradient with a stop in the middle: `Xm` places its whole ramp, a `Bm` each of its
// two parts. The object's own note of its gradient after it paints nothing.
let defs = "(L) 0 3 Bd\n%_1 0 50 100 Bs\n%_0.5 0 50 40 Bs\n%_0 0 50 0 Bs\nBD\n";
let art_ = format!(
"{RED}{}Bb\n1 (L) 0 0 -90 1 1 0 0 1 0 0 1 Bg\n0 -100 -100 0 150 100 Xm\n0 -9000 -100 0 150 9100 Bc\n0 -40 -100 0 150 100 Bm\n\
0 -60 -100 0 150 60 Bm\n0 -9000 -100 0 150 0 Bc\nf\n0 BB\n0 1 Xd\nBb\n2 (L) 0 0 -90 1 1 0 0 1 0 0 1 Bg\n0 BB\n6 () XW\n{}f\n",
rect_path(50.0, 0.0, 150.0, 100.0),
rect_path(0.0, 0.0, 10.0, 10.0),
);
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
let a = art(&doc);
let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
assert_eq!(g.gradient.stops.len(), 3);
// Art space has y up from the crop marks' bottom.
let geom = g.geom.unwrap();
assert!(geom.start.distance(Point::new(150.0, 0.0)) < 1e-9 && geom.end.distance(Point::new(150.0, 100.0)) < 1e-9, "{geom:?}");
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "the note after the object isn't the next path's paint");
}
#[test]
@ -459,6 +476,20 @@ fn text_objects_are_slots_named_after_their_story() {
let r = read_ok(&layer(&format!("/AI11Text :\n0 /StoryIndex ,\n;\n{}{}", commented(0), commented(1))));
let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect();
assert_eq!(stories, [Some(Some(0)), Some(Some(1))]);
// A text object with an appearance: its drawn look (a group whose type is a story of the
// undo-free text document, which counts its own), then the object on `%_` lines, which is
// the type.
let look = "0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nU\n0 0 Xd\n6 () XW\n";
let own: String = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n7 /StoryIndex ,\n;\n".lines().map(|l| format!("%_{l}\n")).collect();
let plain = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n";
let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n{plain}")));
let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect();
assert_eq!(stories, [Some(Some(7)), Some(Some(0))], "the look's story 0 isn't the text document's");
// What else the look draws stays, under the type.
let look = look.replace("U\n", &format!("{RED}{}f\nU\n", rect_path(0.0, 0.0, 10.0, 10.0)));
let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n")));
let a = art(&r.doc);
assert_eq!((a.len(), a[0].children().map(Vec::len), slot_of(a[1].name.as_deref())), (2, Some(1), Some(Some(7))));
}
#[test]

View File

@ -666,6 +666,35 @@ impl Story {
Some(Node::new(id, NodeKind::Text(Box::new(t))))
}
/// Area type that the file lays lines out in, in a frame too short for its first line as it is
/// laid out here: a font that isn't available shows in the fallback font, whose lines may be
/// taller, and type that doesn't fit its frame isn't drawn. The frame is made taller, from its
/// top, by what the first line needs. Whether it was.
pub(super) fn fit_first_line(&self, t: &mut TextObject) -> bool {
let TextKind::Area { frame } = &t.kind else { return false };
let db = vectorcraft_text::FontDb::global();
let Some(b) = frame.bounds().filter(|b| b.height() > 1e-6 && !self.lines.is_empty()) else { return false };
if !vectorcraft_text::layout(db, t).lines.is_empty() {
return false;
}
// The height of its first line: the line's as point type.
let point = TextObject { kind: TextKind::Point, ..t.clone() };
let Some(need) = vectorcraft_text::layout(db, &point).lines.first().map(|l| l.ascent + l.descent) else { return false };
// (A little more than it, for the rounding of the layout.)
let by = (need + 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 });
// Something else keeps the line out (a frame narrower than a letter): as the file has it.
if vectorcraft_text::layout(db, t).lines.is_empty() {
t.kind = short;
return false;
}
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
@ -860,4 +889,30 @@ mod tests {
// One line has no distance to read.
assert_eq!(story("one\r", &[0.0], &Para::default()).line_leading(1.0), None);
}
#[test]
fn a_frame_too_short_for_its_first_line_is_made_taller() {
// A heading in a frame as tall as its size: a line of the font it shows in here is taller.
let s = story("Heading\r", &[0.0], &Para::default());
let area = |height: f64| {
let mut t = TextObject::point(Point::ZERO, "Heading", 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 height =
|t: &TextObject| if let TextKind::Area { frame } = &t.kind { frame.bounds().map(|b| (b.x0, b.y0, b.x1, b.height())) } else { None };
let lines = |t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines.len();
let mut t = area(13.0);
assert_eq!(lines(&t), 0, "nothing of it shows");
assert!(s.fit_first_line(&mut t));
let (x0, y0, x1, h) = height(&t).unwrap();
assert!((x0, y0, x1) == (10.0, 20.0, 210.0) && h > 13.0 && h < 26.0, "taller from its top: {h}");
assert_eq!(lines(&t), 1);
// A frame that holds the line stays as it is; so does one the file lays no line out in
// (its type is overset in the file too).
let mut t = area(40.0);
assert!(!s.fit_first_line(&mut t) && height(&t).unwrap().3 == 40.0);
let mut t = area(13.0);
assert!(!story("Heading\r", &[], &Para::default()).fit_first_line(&mut t) && height(&t).unwrap().3 == 13.0);
}
}

View File

@ -453,10 +453,11 @@ fn plan(
Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect())
})
.collect();
// The page's objects that are where a shown slot's lines are, nearest first.
// The page's objects that are where a slot's lines are, nearest first. A slot that doesn't show
// has them too: a file's page may draw what its layers have hidden since.
let mut pairs: Vec<(f64, usize, usize)> = vec![];
for (k, info) in infos.iter().enumerate() {
let Some(l) = lines.get(k).and_then(Option::as_ref).filter(|_| info.shown) else { continue };
for (k, _) in infos.iter().enumerate() {
let Some(l) = lines.get(k).and_then(Option::as_ref) else { continue };
for (j, object) in shown.iter().enumerate() {
if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) {
pairs.push((d, k, j));
@ -472,6 +473,9 @@ fn plan(
}
// Type the text document makes, shown or not: the page's pieces of it aren't needed.
let mut content: Vec<Content> = infos.iter().map(|_| Content::Empty).collect();
// Where the made type that doesn't show lies, by slot.
let mut hidden_at: Vec<(usize, Rect)> = vec![];
let mut taller = 0;
if let Some(t) = template {
for (k, info) in infos.iter().enumerate() {
let Some(Some(story)) = stories.get(k) else { continue };
@ -493,11 +497,24 @@ fn plan(
TextKind::Point => {}
}
}
if let (false, Some(r)) = (info.shown, region) {
hidden_at.push((k, r));
}
if let NodeKind::Text(text) = &mut node.kind
&& story.fit_first_line(text)
{
taller += 1;
}
if let Some(c) = content.get_mut(k) {
*c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]);
}
}
}
if taller > 0 {
warn.push(format!(
"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"
));
}
// The file's text document is trusted where it agrees with the page about something (its lines
// start where the page's type does, or the page has type where it lays type out), or where the
// page has no type to disagree with.
@ -506,9 +523,26 @@ fn plan(
if !trusted {
content.iter_mut().for_each(|c| *c = Content::Empty);
}
// Is `object` the type of a text object that doesn't show, as the page draws it: in its frame,
// with its words? (A copied object's lines are where its original's are, so they don't say.)
let hidden_type = |object: &[Arc<Node>]| {
let Some(r) = extent(object) else { return false };
let words: Vec<String> =
object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect();
hidden_at.iter().any(|(k, place)| {
place.intersect(r).area() >= 0.5 * r.area().max(1e-9)
&& lines
.get(*k)
.and_then(Option::as_ref)
.is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str()))))
})
};
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
for (j, object) in shown.iter().enumerate() {
match owner.get(j).copied().flatten().filter(|_| trusted) {
// The type of a text object the layers hide: the layers have it, hidden, so the page's
// isn't another text object's.
Some(k) if infos.get(k).is_some_and(|i| !i.shown) && matches!(content.get(k), Some(Content::Made(..))) => {}
Some(k) => match content.get_mut(k) {
Some(Content::Made(_, _, seen, _)) => *seen = true,
Some(Content::Page(p)) => p.extend(object.iter().cloned()),
@ -526,6 +560,7 @@ fn plan(
}
}
}
None if trusted && hidden_type(object) => {}
None => spare.push(object.clone()),
}
}
@ -857,7 +892,22 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) ->
text_objects(c, l.visible, &mut shown, &mut hidden);
}
}
// A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each
// goes where the layers have that artboard.
let pages: Vec<Rect> = visible.artboards.iter().map(|a| a.rect).collect();
let same =
|(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5;
let boards = (pages.len() == doc.artboards.len() && doc.artboards.iter().zip(&pages).all(same)).then_some(&doc.artboards);
for object in shown.iter_mut().chain(hidden.iter_mut()) {
// The page with most of it, the first for what is on none.
let on = extent(object).and_then(|r| {
let inside = |p: &Rect| p.intersect(r).area().max(0.0);
pages.iter().enumerate().skip(1).filter(|(_, p)| inside(p) > inside(&page_rect)).max_by(|a, b| inside(a.1).total_cmp(&inside(b.1)))
});
let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) {
Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()),
None => shift,
};
for n in object.iter_mut() {
Arc::make_mut(n).transform(shift, false);
}

View File

@ -491,6 +491,20 @@ fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() {
assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings);
}
#[test]
fn type_the_page_draws_of_a_text_object_on_a_hidden_layer_stays_hidden() {
// A file's page may draw what its layers have hidden since: story 1's type is on the page at
// (58.5, 30), and its text object is on a layer that doesn't show.
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Extra\r", 0, (8200.0, 8211.5), (0.0, 0.0), 0.0)]);
let art = native_with_text(&(layer("Art", true, &text_object(0)) + &layer("Spare", false, &text_object(1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 30 moveto (Extra) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Spare"], "{:?}", r.warnings);
let texts = |l: usize| text_of(&r.document.layers[l]).iter().map(|t| t.plain_text()).collect::<Vec<_>>();
assert_eq!((texts(0), texts(1)), (vec!["Hi there".to_string()], vec!["Extra".to_string()]), "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings);
}
#[test]
fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_object() {
// The file writes some stories twice (plain, then as comments) and others only as comments.
@ -587,6 +601,37 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err());
}
/// A `.ai`'s PDF part has a page for each artboard, in a row: the type on a later page goes to
/// its text object where the layers have that artboard, not by the first page's place.
#[test]
fn type_on_a_later_artboard_of_an_ai_file_goes_to_its_text_object() {
// Two 100-point artboards, the second 50 below the first; the type is 50 down the second
// (art space: (58.5, -100); the canvas centre 8191.5 is the template box's centre, (50, 50)).
let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8341.5), (0.0, 0.0), 0.0)]);
let art = native_with_text(&(boards.to_string() + &layer("Art", true, &(square(10, 10) + &text_object(0)))), &doc);
// The PDF part: the second page 20 points right of the first, the type 50 down it.
let mut pdf = pdf_part(10.0, 86.0);
let mut page = pdf.artboards[0].clone();
(page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
pdf.artboards.push(page);
let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(178.5, 50.0), "Hi there", style);
let (id, layer_id) = (pdf.alloc_id(), pdf.layers[0].id);
pdf.insert(Some(layer_id), 1, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap();
let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false);
assert!(read, "{w:?}");
assert_eq!(d.artboards[1].rect, vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0));
let texts = text_of(&d.layers[0]);
assert_eq!(texts.len(), 1, "{w:?}");
let [.., e, f] = texts[0].xf.as_coeffs();
assert!((e - 58.5).abs() < 1e-6 && (f - 200.0).abs() < 1e-6, "{:?}", texts[0].xf);
assert!(!w.iter().any(|w| w.contains("with another's") || w.contains("doesn't draw")), "{w:?}");
}
/// A global process colour (`Xk`) or a pattern fill on a sublayer no longer sends a `.ai` to its
/// PDF part, which has only the top-level layers (#1025).
#[test]

View File

@ -208,36 +208,70 @@ pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clo
PathData::single(sp)
}
/// Rectangular grid as separate open lines (plus an optional frame rectangle).
pub fn rectangular_grid(r: Rect, h_dividers: u32, v_dividers: u32, frame: bool) -> Vec<PathData> {
/// The largest grid divider skew, in percent either way.
pub const GRID_SKEW_MAX: f64 = 500.0;
/// Where a divider at fraction `t` (0..1) of a grid's extent lands under `skew` percent: evenly
/// spaced at 0, crowded toward 1 by a positive skew and toward 0 by a negative one (`t` raised to
/// 1 + |skew| / 100 from the far end).
fn skewed(t: f64, skew: f64) -> f64 {
// NaN reads as no skew.
let k = 1.0 + skew.abs().min(GRID_SKEW_MAX) / 100.0;
if skew > 0.0 {
1.0 - (1.0 - t).powf(k)
} else if skew < 0.0 {
t.powf(k)
} else {
t
}
}
/// Rectangular grid, bottom object first: the outside as one rectangle (`frame`) or as its four
/// sides (top, bottom, left, right), then the horizontal and the vertical dividers as open lines.
/// A positive `h_skew` crowds the horizontal dividers toward the top and a positive `v_skew` the
/// vertical ones toward the right (percent, see [`GRID_SKEW_MAX`]).
pub fn rectangular_grid(r: Rect, (h_dividers, h_skew): (u32, f64), (v_dividers, v_skew): (u32, f64), frame: bool) -> Vec<PathData> {
let r = r.abs();
let mut out = Vec::new();
let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1));
let mut out = if frame { vec![rectangle(r)] } else { vec![line(tl, tr), line(bl, br), line(tl, bl), line(tr, br)] };
for i in 1..=h_dividers {
let y = r.y0 + r.height() * i as f64 / (h_dividers + 1) as f64;
// From the top down: toward the top is toward 0.
let y = r.y0 + r.height() * skewed(i as f64 / (h_dividers + 1) as f64, -h_skew);
out.push(line(Point::new(r.x0, y), Point::new(r.x1, y)));
}
for i in 1..=v_dividers {
let x = r.x0 + r.width() * i as f64 / (v_dividers + 1) as f64;
let x = r.x0 + r.width() * skewed(i as f64 / (v_dividers + 1) as f64, v_skew);
out.push(line(Point::new(x, r.y0), Point::new(x, r.y1)));
}
if frame {
out.push(rectangle(r));
}
out
}
/// Polar grid: concentric ellipses plus radial dividers.
pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
/// Polar grid, bottom object first: the concentric ellipses from the outside in, then the radial
/// dividers from the top one clockwise. A positive `c_skew` crowds the ellipses toward the outside
/// and a positive `r_skew` the radial dividers clockwise of the top one (percent, see
/// [`GRID_SKEW_MAX`]). With `compound`, the ellipses pair up from the outside in into rings (two
/// subpaths, the inner one reversed so that it is a hole under either fill rule); an odd one out
/// is the innermost ellipse alone.
pub fn polar_grid(r: Rect, (concentric, c_skew): (u32, f64), (radial, r_skew): (u32, f64), compound: bool) -> Vec<PathData> {
let r = r.abs();
let c = r.center();
let mut out = Vec::new();
for i in 1..=concentric + 1 {
let f = i as f64 / (concentric + 1) as f64;
out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f))));
let mut out: Vec<PathData> = Vec::new();
for i in (1..=concentric + 1).rev() {
let f = skewed(i as f64 / (concentric + 1) as f64, c_skew);
let mut e = ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f)));
// Every second ellipse from the outside is the hole of the one before it.
match out.last_mut().filter(|_| compound && (concentric + 1 - i) % 2 == 1) {
Some(ring) => {
e.reverse();
ring.subpaths.append(&mut e.subpaths);
}
None => out.push(e),
}
}
// No dividers at 0 (the loop never divides by it).
for i in 0..radial {
let a = -PI / 2.0 + TAU * i as f64 / radial as f64;
// The turn runs clockwise on screen: clockwise of the top is toward its start.
let a = -PI / 2.0 + TAU * skewed(i as f64 / radial as f64, -r_skew);
out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin())));
}
out
@ -401,11 +435,88 @@ mod tests {
#[test]
fn grids() {
assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11);
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11);
let r = Rect::new(0.0, 0.0, 10.0, 10.0);
assert_eq!(rectangular_grid(r, (5, 0.0), (5, 0.0), true).len(), 11);
assert_eq!(polar_grid(r, (5, 0.0), (5, 0.0), false).len(), 11);
// #1008: no radial dividers is none, only the outer ellipse without concentric ones.
for (radial, len) in [(0, 1), (1, 2), (2, 3)] {
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, radial).len(), len, "{radial}");
assert_eq!(polar_grid(r, (0, 0.0), (radial, 0.0), false).len(), len, "{radial}");
}
}
fn close(got: &[f64], want: &[f64]) -> bool {
got.len() == want.len() && got.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9)
}
/// The first anchor of each of `paths`.
fn starts(paths: &[PathData]) -> Vec<Point> {
paths.iter().map(|p| p.subpaths[0].anchors[0].p).collect()
}
/// #1377: the frame is one rectangle or its four sides, at the bottom; a skew moves the
/// dividers toward the top / right (positive) or the bottom / left, in order and inside the
/// grid, and none leaves them evenly spaced.
#[test]
fn a_rectangular_grid_frames_and_skews_its_dividers() {
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
let framed = rectangular_grid(r, (3, 0.0), (1, 0.0), true);
assert!(framed[0].is_closed() && framed[1..].iter().all(|p| !p.is_closed()));
assert_eq!(starts(&framed[1..]), [Point::new(0.0, 25.0), Point::new(0.0, 50.0), Point::new(0.0, 75.0), Point::new(50.0, 0.0)]);
let sides = rectangular_grid(r, (3, 0.0), (1, 0.0), false);
assert_eq!(sides.len(), 8);
assert!(sides.iter().all(|p| !p.is_closed() && p.anchor_count() == 2));
assert_eq!(sides[4..], framed[1..]);
for (skew, toward_start) in [(100.0, true), (-100.0, false), (GRID_SKEW_MAX, true), (-1e9, false), (f64::INFINITY, true)] {
let g = rectangular_grid(r, (3, skew), (3, -skew), true);
let ys: Vec<f64> = starts(&g[1..4]).iter().map(|p| p.y).collect();
let xs: Vec<f64> = starts(&g[4..]).iter().map(|p| p.x).collect();
for v in [&ys, &xs] {
assert!(v.windows(2).all(|w| w[0] < w[1]) && v[0] > 0.0 && v[2] < 100.0, "{skew}: {v:?}");
// Toward the top (y) and the left (x) is toward 0: the middle divider passes the middle.
assert_eq!(v[1] < 50.0, toward_start, "{skew}: {v:?}");
}
assert_eq!(ys, xs, "one curve for both axes");
}
// 100 % squares the fraction from the side the dividers crowd to: 1/4, 1/2 and 3/4 from the top.
let g = rectangular_grid(r, (3, 100.0), (0, 0.0), true);
assert!(close(&starts(&g[1..]).iter().map(|p| p.y).collect::<Vec<_>>(), &[6.25, 25.0, 56.25]), "{g:?}");
assert_eq!(rectangular_grid(r, (3, f64::NAN), (1, f64::NAN), true), framed);
}
/// #1377: the ellipses come from the outside in and skew toward the outside (positive) or the
/// centre; the radial dividers keep the top one and skew clockwise (positive) of it or
/// counterclockwise; as compound paths the ellipses pair up into rings.
#[test]
fn a_polar_grid_skews_its_dividers_and_pairs_ellipses_into_rings() {
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
let widths = |g: &[PathData], n: usize| g[..n].iter().map(|p| p.bounds().unwrap().width()).collect::<Vec<_>>();
for (skew, want) in [(0.0, [100.0, 75.0, 50.0, 25.0]), (100.0, [100.0, 93.75, 75.0, 43.75]), (-100.0, [100.0, 56.25, 25.0, 6.25])] {
let got = widths(&polar_grid(r, (3, skew), (0, 0.0), false), 4);
assert!(close(&got, &want), "{skew}: {got:?}");
}
// Radial: four dividers point up, right, down and left; skewed, the first stays up and the
// other three all point to the right (clockwise of it) or to the left.
let ends = |skew: f64| polar_grid(r, (0, 0.0), (4, skew), false)[1..].iter().map(|p| p.subpaths[0].anchors[1].p).collect::<Vec<_>>();
let even = ends(0.0);
for (p, want) in even.iter().zip([(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)]) {
assert!(p.distance(Point::new(want.0, want.1)) < 1e-9, "{even:?}");
}
for (skew, right) in [(GRID_SKEW_MAX, true), (-GRID_SKEW_MAX, false)] {
let e = ends(skew);
assert!(e[0].distance(even[0]) < 1e-9);
assert!(e[1..].iter().all(|p| (p.x > 50.0) == right && (p.x - 50.0).abs() > 1e-6), "{skew}: {e:?}");
}
// Compound: 4 ellipses are 2 rings, 5 are 2 rings and the innermost ellipse; each ring's
// hole runs against its outside.
for (concentric, shapes) in [(3, vec![2, 2]), (4, vec![2, 2, 1]), (0, vec![1])] {
let g = polar_grid(r, (concentric, 0.0), (2, 0.0), true);
assert_eq!(g.len(), shapes.len() + 2);
assert_eq!(g[..shapes.len()].iter().map(|p| p.subpaths.len()).collect::<Vec<_>>(), shapes);
for ring in g.iter().filter(|p| p.subpaths.len() == 2) {
assert!(ring.is_closed());
assert!(ring.subpaths[0].signed_area() * ring.subpaths[1].signed_area() < 0.0);
assert!(ring.subpaths[0].signed_area().abs() > ring.subpaths[1].signed_area().abs());
}
}
}

View File

@ -21,13 +21,18 @@ use vectorcraft_geom::{FillRule, PathData};
use crate::import_color::{Colors, Native};
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, TextOverprint, all_on, hides_forms, scan_page, tag_key};
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_shape, mesh_shading, shading_gradient};
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
/// The name of the paths text imports as.
const TEXT_OUTLINES: &str = "<Text Outlines>";
/// A page whose overprint (ISO 32000-1 §8.6.7) is left out rather than given to the wrong objects.
const OVERPRINT_UNREAD: &str = "the overprint of a page's objects couldn't be read: they knock out";
/// The document overprints as overprint mode 1 does.
const OVERPRINT_ZERO_MODE: &str =
"overprint in the mode where a process colour's zero tints knock out (/OPM 0) is imported as overprint, where they don't";
/// The deepest soft masks, patterns and type 3 glyphs are read within each other.
const MAX_NESTED: u32 = 8;
/// A tiling pattern read this many times with the same art is taken to always draw it.
@ -191,8 +196,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
b.warnings = notes;
let cache = InterpreterCache::new();
let mut x = 0.0;
// Every page only a placeholder (text) over private data: the file's art isn't in its PDF part.
let mut placeholder = true;
// Every page only text over private data: a file saved without its PDF part shows a notice
// there, not its art. A page that states where its art is isn't that notice.
let (mut only_text, mut art_boxes) = (true, false);
let mut slots: Vec<Slot> = vec![];
let mut group_art: HashMap<usize, Vec<Arc<Node>>> = HashMap::new();
let mut nesting = Nesting::new();
@ -261,7 +267,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
b.warn(PAST_PAGE_NOTE);
}
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
placeholder &= ai && only_text(&children);
only_text &= ai && is_only_text(&children);
art_boxes |= crate::pages::states_art(page);
let mut right = ab.x1;
if opts.crop == CropTo::Bounding
&& let Some(art) = vectorcraft_doc::live::nodes_bounds(&children)
@ -303,7 +310,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
}
}
}
if placeholder {
if only_text && !art_boxes {
return Err(PdfError::PlaceholderOnly);
}
// A group the pages mark but draw nothing in is an empty layer, as the editor had it.
@ -375,7 +382,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
}
}
}
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf) })
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text })
}
/// The characters of a CID-keyed CFF font embedded without a ToUnicode map (as macOS writes
@ -599,7 +606,7 @@ fn drop_page_fill(parts: &mut Vec<(Option<usize>, Vec<Arc<Node>>)>, page: Rect)
}
/// Is this art text and nothing else (in groups and clips), with some text?
fn only_text(nodes: &[Arc<Node>]) -> bool {
fn is_only_text(nodes: &[Arc<Node>]) -> bool {
fn walk(nodes: &[Arc<Node>], text: &mut bool) -> bool {
nodes.iter().all(|n| match &n.kind {
NodeKind::Path { clipping: true, .. } => true,
@ -666,6 +673,7 @@ struct GlyphRun {
opacity: f32,
stroke: Option<StrokeProps>,
scale: f64,
overprint: bool,
}
/// How often a tiling pattern was read, and the art (as its paints) each reading drew with the
@ -734,6 +742,8 @@ struct Builder<'p> {
scan: Scan,
tag_at: usize,
group_at: usize,
/// The fills and strokes of paths drawn so far ([`Scan::paints`]).
paint_at: usize,
aligned: bool,
/// Whether optional content groups become layers.
route: bool,
@ -880,6 +890,7 @@ impl<'p> Builder<'p> {
scan: Scan::default(),
tag_at: 0,
group_at: 0,
paint_at: 0,
aligned: true,
route,
unsure: false,
@ -930,12 +941,49 @@ impl<'p> Builder<'p> {
self.masked.clear();
self.marked.clear();
self.pending = false;
(self.scan, self.tag_at, self.group_at, self.aligned) = (scan, 0, 0, true);
(self.scan, self.tag_at, self.group_at, self.aligned, self.paint_at) = (scan, 0, 0, true, 0);
if self.scan.overprints && self.scan.cut {
self.scan.overprints = false;
self.warn(OVERPRINT_UNREAD);
}
if self.scan.overprints && self.scan.zero_mode {
self.warn(OVERPRINT_ZERO_MODE);
}
}
/// Whether the path the interpreter draws now (a fill or a stroke, each counted) overprints.
fn path_overprint(&mut self) -> bool {
if self.nested > 0 || !self.scan.overprints {
return false;
}
self.paint_at += 1;
self.scan.paints.get(self.paint_at - 1).copied().unwrap_or(false)
}
/// Whether the glyph the interpreter draws now (its fill, or its `stroke`) overprints.
fn glyph_overprint(&mut self, stroke: bool) -> bool {
if self.nested > 0 || !self.scan.overprints {
return false;
}
match self.scan.texts.get(self.paint_at) {
Some(TextOverprint::All { fill, stroke: s }) => *if stroke { s } else { fill },
Some(TextOverprint::Mixed) => {
self.warn("some text is set to overprint in a way that can't be read: it knocks out");
false
}
_ => false,
}
}
/// The page's art, split by optional content group (in the order they first paint).
fn end_page(&mut self) -> Vec<(Option<usize>, Vec<Arc<Node>>)> {
let children = self.close_frames();
let mut children = self.close_frames();
// The interpreter drew another number of paths than the file was read to paint: which
// of them overprint can't be trusted.
if self.scan.overprints && self.paint_at != self.scan.paints.len() {
children.iter_mut().for_each(vectorcraft_doc::overprint::clear_overprints);
self.warn(OVERPRINT_UNREAD);
}
let groups = std::mem::take(&mut self.root_groups);
let mut parts: Vec<(Option<usize>, Vec<Arc<Node>>)> = vec![];
// Thousands of groups: find each one's part by index.
@ -1137,10 +1185,14 @@ impl<'p> Builder<'p> {
match &run.stroke {
None => {
let mut f = FillLayer::new(run.paint);
f.opacity = run.opacity;
(f.opacity, f.overprint) = (run.opacity, run.overprint);
ap.items.push(AppearanceItem::Fill(f));
}
Some(p) => ap.items.push(AppearanceItem::Stroke(stroke_layer(run.paint, run.opacity, p, run.scale))),
Some(p) => {
let mut st = stroke_layer(run.paint, run.opacity, p, run.scale);
st.overprint = run.overprint;
ap.items.push(AppearanceItem::Stroke(st));
}
}
let id = self.id();
let mut n = Node::path(id, PathData::from_bezpath(&run.path), ap);
@ -1398,6 +1450,7 @@ impl<'p> Builder<'p> {
scale: f64,
paint: &hayro_interpret::Paint<'_>,
stroke: Option<&StrokeProps>,
overprint: bool,
) -> bool {
if self.text_as != TextAs::Text || self.nested > 0 {
return false;
@ -1489,6 +1542,8 @@ impl<'p> Builder<'p> {
version,
size: at.size,
h_scale: at.h_scale,
overprint_fill: stroke.is_none() && overprint,
overprint_stroke: stroke.is_some() && overprint,
fill: stroke.is_none().then_some(paint),
stroke,
};
@ -1500,7 +1555,7 @@ impl<'p> Builder<'p> {
// A stroke over the glyph just filled (fill and stroke rendering).
if let (Some(s), Some(line)) = (&look.stroke, &mut self.text)
&& look.fill.is_none()
&& line.stroke_last(key, at, s.clone())
&& line.stroke_last(key, at, s.clone(), overprint)
{
return true;
}
@ -1566,6 +1621,7 @@ impl<'a> Device<'a> for Builder<'_> {
self.flush_glyphs();
self.flush_text();
self.pending = false;
let overprint = self.path_overprint();
let mut bp = path.clone();
bp.apply_affine(transform);
if bp.elements().is_empty() {
@ -1605,7 +1661,7 @@ impl<'a> Device<'a> for Builder<'_> {
match draw_mode {
PathDrawMode::Fill(rule) => {
let mut f = FillLayer::new(paint);
f.opacity = opacity;
(f.opacity, f.overprint) = (opacity, overprint);
let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() });
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
*r = fill_rule(*rule);
@ -1615,7 +1671,8 @@ impl<'a> Device<'a> for Builder<'_> {
self.push_node(n);
}
PathDrawMode::Stroke(props) => {
let st = stroke_layer(paint, opacity, props, mean_scale(transform));
let mut st = stroke_layer(paint, opacity, props, mean_scale(transform));
st.overprint = overprint;
// Fill-then-stroke of the same path (the `B` operator, or a fill and a stroke of
// the outline as Illustrator writes an object) becomes one object: a path with a
// fill and a live stroke.
@ -1699,7 +1756,8 @@ impl<'a> Device<'a> for Builder<'_> {
match glyph {
Glyph::Outline(o) => {
let scale = mean_scale(transform);
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref()) {
let overprint = self.glyph_overprint(stroke.is_some());
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref(), overprint) {
return;
}
self.flush_text();
@ -1709,12 +1767,13 @@ impl<'a> Device<'a> for Builder<'_> {
let same = self.glyphs.as_ref().is_some_and(|r| {
r.paint == paint
&& r.opacity == opacity
&& r.overprint == overprint
&& r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width)
&& (r.scale - scale).abs() < 1e-9
});
if !same {
self.flush_glyphs();
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale });
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale, overprint });
}
if let Some(r) = &mut self.glyphs {
r.path.extend(bp.iter());

View File

@ -1,12 +1,14 @@
//! What the interpreter doesn't tell the import, read from the file itself: the optional content
//! groups (layers) with their states and nesting, which group each marked-content sequence belongs
//! to, the isolate and knockout flags of transparency groups, and the names of the fonts.
//! to, the isolate and knockout flags of transparency groups, the overprint parameters each path
//! and each stretch of text is painted with, and the names of the fonts.
//!
//! hayro-interpret reports a marked-content sequence by its tag alone and a transparency group
//! without its flags, and skips the content of groups that are off. So each page's content is
//! walked here in the interpreter's order (page content, then form XObjects as they are drawn,
//! skipping what it skips): the k-th `BDC`/`BMC` it reports is the k-th one found here (its tag
//! checks that), and the k-th form transparency group is the k-th group found here. To import
//! checks that), the k-th form transparency group is the k-th group found here, and the k-th
//! path it fills or strokes is the k-th one painted here (the page's count checks that). To import
//! content that is off as hidden layers, [`all_on`] gives the file an update that turns every
//! group on.
@ -219,6 +221,39 @@ pub(crate) struct Scan {
pub groups: Vec<(bool, bool)>,
/// The walk stopped at [`MAX_OPS`] or [`MAX_TAGS`]: the rest of the page isn't told apart.
pub cut: bool,
/// Each fill and each stroke of a path the interpreter draws (a fill-and-stroke operator is
/// a fill, then a stroke; `sh` is a fill): whether it overprints.
pub paints: Vec<bool>,
/// How the text shown before each of [`Self::paints`] (since the one before it) overprints,
/// and last the text after them all: one more entry than `paints`.
pub texts: Vec<TextOverprint>,
/// Something on the page overprints (when nothing does, `paints` and `texts` aren't used).
pub overprints: bool,
/// Something overprints with overprint mode 0, where a `DeviceCMYK` colour's zero components
/// knock out; the document's overprint is the nonzero one (mode 1).
pub zero_mode: bool,
}
/// How the text shown between two painted paths overprints.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) enum TextOverprint {
/// No text is shown there.
#[default]
None,
/// All of it is shown with these parameters.
All { fill: bool, stroke: bool },
/// Text is shown with different parameters, which can't be told apart by glyph.
Mixed,
}
/// The overprint parameters of the graphics state (ISO 32000-1 §8.6.7; `OP`, `op` and `OPM` in
/// Table 58).
#[derive(Clone, Copy, Default)]
struct Overprint {
stroke: bool,
fill: bool,
/// Overprint mode 1 (nonzero overprint).
nonzero: bool,
}
/// A marked-content tag as [`Scan::tags`] keeps it: its FNV-1a hash (the tags are only compared,
@ -255,6 +290,11 @@ struct Walker<'w, 'p> {
out: Scan,
/// A form XObject hidden by its own optional content was met.
hidden_form: bool,
/// The saved graphics states' overprint parameters, the current one last, pushed and popped
/// as the interpreter does its states.
states: Vec<Overprint>,
/// The text shown since the last painted path.
text: TextOverprint,
}
impl Walker<'_, '_> {
@ -291,6 +331,49 @@ impl Walker<'_, '_> {
self.begin(on);
}
fn state(&self) -> Overprint {
self.states.last().copied().unwrap_or_default()
}
fn save(&mut self) {
self.states.push(self.state());
}
/// The interpreter never pops its first state.
fn restore(&mut self) {
if self.states.len() > 1 {
self.states.pop();
}
}
fn overprinting(&mut self, on: bool) {
self.out.overprints |= on;
self.out.zero_mode |= on && !self.state().nonzero;
}
/// The interpreter draws a path: a fill (or a `stroke`) of the current one.
fn painted(&mut self, stroke: bool) {
if !self.is_visible() {
return;
}
let s = self.state();
let on = if stroke { s.stroke } else { s.fill };
self.overprinting(on);
self.out.paints.push(on);
self.out.texts.push(std::mem::take(&mut self.text));
}
/// The interpreter shows text.
fn shown(&mut self) {
if !self.is_visible() {
return;
}
let s = self.state();
self.overprinting(s.fill || s.stroke);
let now = TextOverprint::All { fill: s.fill, stroke: s.stroke };
self.text = if self.text == TextOverprint::None || self.text == now { now } else { TextOverprint::Mixed };
}
fn note_fonts(&mut self, res: &Resources<'_>) {
for (key, _) in res.fonts.entries() {
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
@ -303,6 +386,15 @@ impl Walker<'_, '_> {
fn walk(&mut self, mut iter: TypedIter<'_>, res: &Resources<'_>, depth: u32) {
self.note_fonts(res);
// The interpreter saves the state for each content stream, and restores what the stream
// left saved when it ends.
let saved = self.states.len();
self.save();
self.walk_ops(&mut iter, res, depth);
self.states.truncate(saved);
}
fn walk_ops(&mut self, iter: &mut TypedIter<'_>, res: &Resources<'_>, depth: u32) {
while let Some(op) = iter.next() {
self.ops += 1;
if self.ops > self.max_ops || self.out.tags.len() >= MAX_TAGS {
@ -338,6 +430,40 @@ impl Walker<'_, '_> {
TypedInstruction::EndMarkedContent(_) => {
self.visible.pop();
}
TypedInstruction::SaveState(_) => self.save(),
TypedInstruction::RestoreState(_) => self.restore(),
TypedInstruction::SetGraphicsState(gs) => {
if let Some(d) = res.get_ext_g_state(gs.0)
&& let Some(s) = self.states.last_mut()
{
let stroke = d.get::<bool>(b"OP");
s.stroke = stroke.unwrap_or(s.stroke);
// Without `/op`, `/OP` sets both.
s.fill = d.get::<bool>(b"op").or(stroke).unwrap_or(s.fill);
s.nonzero = d.get::<u8>(b"OPM").map_or(s.nonzero, |m| m != 0);
}
}
TypedInstruction::FillPathEvenOdd(_) | TypedInstruction::FillPathNonZero(_) | TypedInstruction::FillPathNonZeroCompatibility(_) => {
self.painted(false);
}
TypedInstruction::StrokePath(_) | TypedInstruction::CloseAndStrokePath(_) => self.painted(true),
TypedInstruction::FillAndStrokeEvenOdd(_)
| TypedInstruction::FillAndStrokeNonZero(_)
| TypedInstruction::CloseFillAndStrokeEvenOdd(_)
| TypedInstruction::CloseFillAndStrokeNonZero(_) => {
self.painted(false);
self.painted(true);
}
// Drawn when the interpreter can read the shading: the page's count tells.
TypedInstruction::Shading(s) => {
if res.get_shading(s.0).is_some() {
self.painted(false);
}
}
TypedInstruction::ShowText(_)
| TypedInstruction::ShowTexts(_)
| TypedInstruction::NextLineAndShowText(_)
| TypedInstruction::ShowTextWithParameters(_) => self.shown(),
TypedInstruction::XObject(x) => {
if !self.is_visible() || depth >= MAX_DEPTH {
continue;
@ -360,7 +486,9 @@ impl Walker<'_, '_> {
self.out.groups.push((g.get::<bool>(b"I").unwrap_or(false), g.get::<bool>(b"K").unwrap_or(false)));
}
let inner = Resources::from_parent(d.get::<Dict<'_>>(b"Resources").unwrap_or_default(), res.clone());
self.save();
self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1);
self.restore();
}
if oc.is_some() {
self.visible.pop();
@ -399,8 +527,21 @@ pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames) -> Walker<'w, 'p> {
let off = if all_on { HashSet::new() } else { ocgs.off.clone() };
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, max_ops: max_ops(), out: Scan::default(), hidden_form: false };
let mut w = Walker {
ocgs,
off,
xref: page.xref(),
fonts,
visible: vec![],
ops: 0,
max_ops: max_ops(),
out: Scan::default(),
hidden_form: false,
states: vec![Overprint::default()],
text: TextOverprint::None,
};
w.walk(page.typed_operations(), page.resources(), 0);
w.out.texts.push(w.text);
w
}

View File

@ -81,6 +81,9 @@ pub(crate) struct Look {
pub h_scale: f64,
pub fill: Option<Paint>,
pub stroke: Option<(Paint, f64)>,
/// The fill, the stroke overprints.
pub overprint_fill: bool,
pub overprint_stroke: bool,
}
impl Look {
@ -91,7 +94,8 @@ impl Look {
&& (self.size - other.size).abs() <= self.size * 0.01
&& (self.h_scale - other.h_scale).abs() < 0.5
&& self.fill == other.fill
&& (other.stroke.is_none() || self.stroke == other.stroke)
&& self.overprint_fill == other.overprint_fill
&& (other.stroke.is_none() || (self.stroke == other.stroke && self.overprint_stroke == other.overprint_stroke))
}
fn style(&self) -> CharStyle {
@ -105,6 +109,8 @@ impl Look {
fill: self.fill.clone().unwrap_or(Paint::None),
stroke,
stroke_width,
overprint_fill: self.overprint_fill,
overprint_stroke: self.overprint_stroke,
..CharStyle::default()
}
}
@ -301,12 +307,13 @@ impl TextLine {
}
/// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill
/// and stroke rendering): the run is stroked. `false`: it isn't that glyph.
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64)) -> bool {
/// and stroke rendering), which `overprint`s or not: the run is stroked. `false`: it isn't
/// that glyph.
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool {
let Some((look, _)) = self.runs.last_mut().filter(|(l, _)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else {
return false;
};
look.stroke = Some(stroke);
(look.stroke, look.overprint_stroke) = (Some(stroke), overprint);
true
}

View File

@ -163,6 +163,9 @@ pub struct ImportReport {
pub warnings: Vec<String>,
/// The native document the PDF carries (written with Preserve Editing), if any.
pub native: Option<Editing>,
/// Every page shows only text, over an editor's private data: that data, where it reads,
/// is the file's art (a file saved without its PDF part shows a notice there instead).
pub only_text: bool,
}
/// Result of an export with non-fatal warnings (features approximated or dropped).
@ -237,6 +240,8 @@ mod tests_import_lines;
#[cfg(test)]
mod tests_import_options;
#[cfg(test)]
mod tests_import_overprint;
#[cfg(test)]
mod tests_layers;
#[cfg(test)]
mod tests_live_text;

View File

@ -197,6 +197,14 @@ pub(crate) fn has_private_data(page: &Page<'_>) -> bool {
piece_info_has(page, &[b"AIPrivateData"])
}
/// Does `page` state where its art is: an art, trim or bleed box (ISO 32000-1 §14.11.2, the
/// extent of the page's meaningful content and of the finished page)? A page written from an
/// artboard has them; the notice a file saved without its PDF part shows instead of its art is
/// the same page whatever the document, with no art to state the place of.
pub(crate) fn states_art(page: &Page<'_>) -> bool {
[&b"ArtBox"[..], b"TrimBox", b"BleedBox"].iter().any(|k| page.raw().get::<[f32; 4]>(*k).is_some())
}
/// Does `page`'s `PieceInfo` hold a key starting with one of `prefixes`, up to four dictionaries in?
fn piece_info_has(page: &Page<'_>, prefixes: &[&[u8]]) -> bool {
fn walk(d: &Dict<'_>, prefixes: &[&[u8]], depth: u32) -> bool {

View File

@ -0,0 +1,136 @@
//! Overprint on import (#1388): the graphics state's `/OP` (strokes), `/op` (fills, `/OP` when
//! absent) and `/OPM` (ISO 32000-1 §8.6.7, Table 58) become the fills' and strokes' overprint.
//! Every file here is written by hand from the specification.
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
use crate::tests_import_fidelity::stream;
use crate::*;
const ON: &str = "/ExtGState << /Off << /OP false /op false /OPM 0 >> /On << /OP true /op true /OPM 1 >> /Stroke << /OP true /op false /OPM 1 >> /Both << /OP true /OPM 1 >> /Zero << /OP true >> >>";
fn open(content: &str, resources: &str, extra: &[&str]) -> ImportReport {
let page = PdfPage { resources: format!("{ON} {resources}"), ..PdfPage::new(400.0, 400.0, content) };
import_with_report(&pdf_with(&[page], extra, None), &ImportOptions::default()).unwrap()
}
/// Each painted object in paint order: whether its fill and its stroke overprint (`None`: it has
/// none), type by its first run.
fn overprints(d: &Document) -> Vec<(Option<bool>, Option<bool>)> {
let mut out = vec![];
d.walk(|n: &Node| {
if let NodeKind::Text(t) = &n.kind {
out.extend(t.runs.first().map(|r| (Some(r.style.overprint_fill), (!r.style.stroke.is_none()).then_some(r.style.overprint_stroke))));
} else if !n.appearance.items.is_empty() {
out.push((n.appearance.fill().map(|f| f.overprint), n.appearance.stroke().map(|s| s.overprint)));
}
});
out
}
fn about_overprint(r: &ImportReport) -> Vec<&String> {
r.warnings.iter().filter(|w| w.contains("overprint")).collect()
}
const SPOT: &str = "/ColorSpace << /CS0 [/Separation /Spot1 /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>] >>";
#[test]
fn a_spot_colour_overprinting_a_process_colour_stays_overprinting_through_export() {
// The file of the report: a cyan square that knocks out, under a spot square that overprints.
let r = open("/Off gs 1 0 0 0 k 50 50 200 200 re f /On gs /CS0 cs 1 scn 150 150 200 200 re f", SPOT, &[]);
assert_eq!(overprints(&r.document), [(Some(false), None), (Some(true), None)]);
assert!(about_overprint(&r).is_empty(), "{:?}", r.warnings);
let spot = r.document.layers[0].children().unwrap().iter().find(|n| n.appearance.fill().is_some_and(|f| f.overprint)).unwrap();
assert!(format!("{:?}", spot.appearance.fill().unwrap().paint).contains("Spot1"), "the spot square is the one that overprints");
// Written again it overprints, and comes back the same.
let out = export(&r.document, &PdfOptions::uncompressed()).unwrap();
assert!(String::from_utf8_lossy(&out).contains("/OP true/op true/OPM 1"));
let again = import_with_report(&out, &ImportOptions::default()).unwrap();
assert_eq!(overprints(&again.document).iter().filter(|o| o.0 == Some(true)).count(), 1);
assert_eq!(overprints(&again.document).iter().filter(|o| o.0 == Some(false)).count(), 1);
}
#[test]
fn op_sets_strokes_and_fills_unless_the_state_has_its_own_fill_entry() {
// `/OP` alone is both; with `/op false` only strokes; `B` is one object with both paints.
let r = open("1 0 0 RG 0 0 1 rg /Both gs 10 10 50 50 re B /Stroke gs 100 10 50 50 re B 200 10 50 50 re f 200 100 50 50 re S", "", &[]);
assert_eq!(overprints(&r.document), [(Some(true), Some(true)), (Some(false), Some(true)), (Some(false), None), (None, Some(true))]);
assert!(about_overprint(&r).is_empty(), "{:?}", r.warnings);
}
#[test]
fn saved_states_and_forms_keep_their_own_overprint() {
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 400 400]", "200 10 50 50 re f /Off gs 200 100 50 50 re f");
let x = first_extra(1);
let r = open(
"0 0 1 rg q /On gs 10 10 50 50 re f Q 100 10 50 50 re f /On gs /X0 Do 300 10 50 50 re f",
&format!("/XObject << /X0 {x} 0 R >>"),
&[&form],
);
// In `q … Q`; after it; in the form (the page's state, then its own); after the form.
assert_eq!(overprints(&r.document).iter().map(|o| o.0).collect::<Vec<_>>(), [true, false, true, false, true].map(Some));
}
#[test]
fn overprint_mode_0_is_reported() {
// No `/OPM`: mode 0, where a DeviceCMYK colour's zero components knock out.
let r = open("/Zero gs 0 0 0 1 k 10 10 50 50 re f", "", &[]);
assert_eq!(overprints(&r.document), [(Some(true), None)]);
assert_eq!(about_overprint(&r).len(), 1, "{:?}", r.warnings);
assert!(about_overprint(&r)[0].contains("/OPM 0"));
// Nothing overprints: nothing to say.
assert!(about_overprint(&open("0 0 0 1 k 10 10 50 50 re f", "", &[])).is_empty());
}
const FONT: &str = "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>";
#[test]
fn text_overprints_as_its_graphics_state_says() {
// Type or its outlines, as the fonts of the machine allow.
let r = open("0 0 1 rg 10 300 50 50 re f /On gs BT /F1 36 Tf 40 200 Td (Ink) Tj ET /Off gs 100 300 50 50 re f", FONT, &[]);
assert_eq!(overprints(&r.document).iter().map(|o| o.0).collect::<Vec<_>>(), [false, true, false].map(Some), "{:?}", r.warnings);
assert!(about_overprint(&r).is_empty(), "{:?}", r.warnings);
}
#[test]
fn text_of_two_overprint_states_between_two_paths_knocks_out_with_a_warning() {
let r = open("0 0 1 rg BT /F1 36 Tf 40 200 Td /On gs (Ink) Tj /Off gs 0 -50 Td (Paper) Tj ET", FONT, &[]);
assert!(overprints(&r.document).iter().all(|o| o.0 == Some(false)), "{:?}", overprints(&r.document));
assert_eq!(about_overprint(&r).len(), 1, "{:?}", r.warnings);
}
#[test]
fn a_page_whose_paths_cant_be_counted_knocks_out_with_a_warning() {
// A shading the interpreter can't read draws nothing: the paths after it would take the
// overprint of the ones before them.
let r = open("0 0 1 rg /Sh0 sh /On gs 10 10 50 50 re f /Off gs 100 10 50 50 re f", "/Shading << /Sh0 << /ShadingType 99 >> >>", &[]);
assert_eq!(overprints(&r.document), [(Some(false), None), (Some(false), None)]);
assert_eq!(about_overprint(&r).len(), 1, "{:?}", r.warnings);
}
#[test]
fn hidden_optional_content_and_pattern_cells_dont_shift_the_count() {
let ocg = "<< /Type /OCG /Name (Hidden) >>";
let cell = stream(
"/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>",
"1 0 0 rg 0 0 5 5 re f 5 5 5 5 re f",
);
let x = first_extra(1);
let page = PdfPage {
resources: format!("{ON} /Properties << /MC0 {x} 0 R >> /Pattern << /P0 {} 0 R >>", x + 1),
..PdfPage::new(
400.0,
400.0,
"/OC /MC0 BDC 0 0 1 rg 10 10 50 50 re f EMC /Pattern cs /P0 scn 100 10 50 50 re f /On gs 0 0 1 rg 200 10 50 50 re f",
)
};
let catalog = format!("/OCProperties << /OCGs [{x} 0 R] /D << /OFF [{x} 0 R] >> >>");
let bytes = vectorcraft_testkit::pdf::pdf_with_catalog(&[page], &[ocg, &cell], &catalog, None);
for layers in [false, true] {
let r = import_with_report(&bytes, &ImportOptions { layers, ..Default::default() }).unwrap();
let on: Vec<_> = overprints(&r.document).into_iter().filter(|o| o.0 == Some(true)).collect();
assert_eq!(on.len(), 1, "layers {layers}: {:?} {:?}", overprints(&r.document), r.warnings);
assert!(about_overprint(&r).is_empty(), "layers {layers}: {:?}", r.warnings);
}
}

View File

@ -265,6 +265,8 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp
h_scale: 100.0,
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
stroke: None,
overprint_fill: false,
overprint_stroke: false,
};
let at = |x: f64, y: f64| Placement { origin: Point::new(x, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 };
let mut line = TextLine::new(at(100.0, 100.0), 1.0);
@ -288,6 +290,8 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() {
h_scale: 100.0,
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
stroke: None,
overprint_fill: false,
overprint_stroke: false,
};
// Glyphs set down a column 1.5 em apart (tracking 500): no spaces come in between them.
let at = |y: f64| Placement { origin: Point::new(100.0, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 };

View File

@ -18,6 +18,10 @@
//! ([`Renderer::pixel_content`]). The result is cached per object while it and the zoom don't
//! change.
//!
//! While [`crate::RenderOptions::interacting`], an object whose content only moved draws its
//! cached shadows, glows and filtered content at its new place instead of running the effects
//! again ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]).
//!
//! The same applies to one fill or stroke's own raster effects (around that item alone) and to
//! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is
//! drawn offscreen in the filter layers, reshaped first by their geometry effects. Groups and
@ -27,9 +31,10 @@
use std::sync::Arc;
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId, NodeKind};
use vectorcraft_effects::pixel::{Channels, PixelFx};
use vectorcraft_effects::{self as effects, GeomContext, RasterFx};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape, Vec2};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathEl, Point, Rect, Shape, Size, Vec2};
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_cpu::RenderContext;
use vello_cpu::peniko;
@ -230,9 +235,21 @@ pub(crate) struct PixelEntry {
rel: (f64, f64),
k: f64,
image: std::sync::Arc<vello_cpu::Pixmap>,
made: Made,
/// Made for the object before it moved ([`Renderer::move_pixel_content`]), or made over
/// rasters made before their objects moved: only a frame rendered while interacting draws
/// it.
moved: bool,
pub(crate) stamp: u64,
}
impl PixelEntry {
/// The bytes it holds, for [`FX_RASTER_BUDGET`].
pub(crate) fn bytes(&self) -> usize {
raster_bytes(&self.image) + self.made.bytes()
}
}
/// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys.
fn linear_key(view: Affine) -> [u64; 4] {
let c = view.as_coeffs();
@ -260,9 +277,87 @@ pub(crate) struct ShadowEntry {
/// Pixel offset of the raster's top-left from the anchor's view position.
rel: (f64, f64),
image: std::sync::Arc<vello_cpu::Pixmap>,
made: Made,
/// Made for the object before it moved ([`Renderer::move_shadow`]), or made over rasters
/// made before their objects moved: only a frame rendered while interacting draws it.
moved: bool,
pub(crate) stamp: u64,
}
impl ShadowEntry {
/// The bytes it holds, for [`FX_RASTER_BUDGET`].
pub(crate) fn bytes(&self) -> usize {
raster_bytes(&self.image) + self.made.bytes()
}
}
/// What a cached shadow, glow or filtered raster was made from. While
/// [`crate::RenderOptions::interacting`], an object whose content only moved after the raster
/// was made draws from that raster ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]).
pub(crate) struct Made {
/// The content's pixels as drawn into the raster before the effects ran: premultiplied RGBA,
/// or their alpha alone under a shadow or glow.
pixels: Vec<u8>,
/// The effects run on them.
fx: Vec<RasterFx>,
/// The size of the content's document-space bounds.
size: Size,
/// The color channels the Photoshop-style filters were told of (none for shadows and glows).
channels: Option<Channels>,
/// Feather's clip outline, moved to put the raster's anchor at the origin.
outline: Option<(BezPath, FillRule)>,
}
/// How far (document units) the size of a moved object's content and the points of its Feather
/// outline may lie from those of the content a raster was made from.
const MOVED_SLACK: f64 = 1e-6;
impl Made {
/// The bytes it holds beyond the raster.
fn bytes(&self) -> usize {
self.pixels.len() + self.outline.as_ref().map_or(0, |(g, _)| crate::lru::path_bytes(g))
}
/// Whether content of size `size` under the effects `fx`, told of `channels`, with Feather's
/// `outline` (moved to put its anchor at the origin) may be this content moved: the same
/// effects, channels and outline rule, and the size and outline within [`MOVED_SLACK`].
fn fits(&self, fx: &[&RasterFx], size: Size, channels: Option<Channels>, outline: Option<&(BezPath, FillRule)>) -> bool {
let near = |a: f64, b: f64| (a - b).abs() <= MOVED_SLACK;
self.fx.len() == fx.len()
&& self.fx.iter().zip(fx).all(|(a, b)| a == *b)
&& near(self.size.width, size.width)
&& near(self.size.height, size.height)
&& self.channels == channels
&& match (&self.outline, outline) {
(None, None) => true,
(Some((a, ra)), Some((b, rb))) => ra == rb && same_path(a, b),
_ => false,
}
}
}
/// Whether `a` and `b` have the same elements with points within [`MOVED_SLACK`] of each other.
fn same_path(a: &BezPath, b: &BezPath) -> bool {
let near = |p: &Point, q: &Point| (*p - *q).hypot() <= MOVED_SLACK;
a.elements().len() == b.elements().len()
&& a.elements().iter().zip(b.elements()).all(|pair| match pair {
(PathEl::MoveTo(p), PathEl::MoveTo(q)) | (PathEl::LineTo(p), PathEl::LineTo(q)) => near(p, q),
(PathEl::QuadTo(p0, p1), PathEl::QuadTo(q0, q1)) => near(p0, q0) && near(p1, q1),
(PathEl::CurveTo(p0, p1, p2), PathEl::CurveTo(q0, q1, q2)) => near(p0, q0) && near(p1, q1) && near(p2, q2),
(PathEl::ClosePath, PathEl::ClosePath) => true,
_ => false,
})
}
/// Feather's clip outline of `content` when `chain` feathers it, moved to put `anchor` at the
/// origin ([`Made::outline`]).
fn feather_outline(chain: &[&RasterFx], content: &Content, anchor: Point) -> Option<(BezPath, FillRule)> {
let (g, rule) = content.outline.filter(|_| chain.iter().any(|fx| matches!(fx, RasterFx::Feather { .. })))?;
let mut g = g.clone();
g.apply_affine(Affine::translate(-anchor.to_vec2()));
Some((g, rule))
}
/// The object a cached raster was made for: the drawn `Arc` itself when known (kept alive, so its
/// address, the cache key, names it alone and a key match is exact), else a copy compared by value.
pub(crate) enum Drawn {
@ -285,10 +380,18 @@ impl Drawn {
Self::Copy(c) => **c == *n,
}
}
/// The id of the object drawn.
fn id(&self) -> NodeId {
match self {
Self::Shared(a) => a.id,
Self::Copy(c) => c.id,
}
}
}
/// Most bytes the shadow and filtered-content rasters of one renderer hold together: the least
/// recently drawn go beyond it.
/// Most bytes the shadow and filtered-content rasters of one renderer hold together, with what each
/// was made from ([`Made`]): the least recently drawn go beyond it.
pub(crate) const FX_RASTER_BUDGET: usize = 512 << 20;
fn raster_bytes(pm: &vello_cpu::Pixmap) -> usize {
@ -308,8 +411,9 @@ pub(crate) struct FxGeom {
impl Renderer {
/// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster:
/// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused
/// while the object and the zoom/rotation are unchanged (pans only move it). Returns false when
/// the effect isn't a shadow/glow or the raster would be too large (caller falls back).
/// while the object and the zoom/rotation are unchanged (pans only move it), and while
/// interacting after the object only moved ([`Self::move_shadow`]). Returns false when the
/// effect isn't a shadow/glow or the raster would be too large (caller falls back).
#[allow(clippy::too_many_arguments)]
fn draw_shadow_cached(
&mut self,
@ -330,10 +434,16 @@ impl Renderer {
let linear = linear_key(f.view);
let key = (n as *const Node as usize, i, f.ink);
let stamp = self.stamp;
let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node.is(n));
let interacting = f.opts.interacting;
let exact = |e: &ShadowEntry| e.linear == linear && e.node.is(n) && (interacting || !e.moved);
if interacting && !self.shadows.get(&key).is_some_and(exact) {
self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, paint);
}
let hit = self.shadows.get_mut(&key).filter(|e| exact(e));
let (image, x, y) = match hit {
Some(e) => {
e.stamp = stamp;
self.stats.moved += usize::from(e.moved);
let p = f.view * e.anchor;
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round())
}
@ -350,10 +460,16 @@ impl Renderer {
// Silhouette (the object's painted alpha), offset by the shadow distance.
let mut off = f.offscreen_context(w, h);
let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
let before = self.stats.moved;
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
// A silhouette drawn over moved rasters makes a moved shadow.
let moved = self.stats.moved > before;
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources);
// What the raster is made from, for its object once it moves (`move_shadow`).
let made =
Made { pixels: pm.data().iter().map(|p| p.a).collect(), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None };
let (wu, hu) = (w as usize, h as usize);
let mut a: Vec<f32> = pm.data().iter().map(|p| p.a as f32).collect();
effects::pixel::blur_plane(&mut a, wu, hu, blur / 2.0 / f.px);
@ -367,7 +483,7 @@ impl Renderer {
let anchor = reach.origin();
let p = f.view * anchor;
let node = Drawn::of(n, owner);
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), stamp });
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), made, moved, stamp });
self.trim_fx_rasters(FX_RASTER_BUDGET);
(image, x0, y0)
}
@ -385,6 +501,54 @@ impl Renderer {
true
}
/// While interacting, give `n` (shadow-cache key `key`) the shadow or glow raster an earlier
/// frame made for its object (the same id, effect slot and ink, at this zoom and rotation) when
/// only the silhouette's place changed after that frame: made by the same effect `fx` from a
/// silhouette whose bounds have the size of `reach` (the content's bounds moved by the
/// shadow's offset), and whose alpha is the alpha of `n`'s silhouette drawn into a raster
/// placed as that one was against its anchor. The raster is kept as `n`'s, marked moved, and
/// is drawn at the nearest whole pixel.
#[allow(clippy::too_many_arguments)]
fn move_shadow(
&mut self,
f: &Frame,
n: &Node,
key: (usize, usize, Ink),
fx: &RasterFx,
reach: Rect,
owner: Option<&Arc<Node>>,
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) {
let (dx, dy) = match fx {
RasterFx::DropShadow { dx, dy, .. } => (*dx, *dy),
_ => (0.0, 0.0),
};
let (linear, stamp, size) = (linear_key(f.view), self.stamp, reach.size());
let fits = |k: &(usize, usize, Ink), e: &ShadowEntry| {
(k.1, k.2) == (key.1, key.2) && e.stamp < stamp && e.linear == linear && e.node.id() == n.id && e.made.fits(&[fx], size, None, None)
};
let Some((old, rel, w, h)) = self.shadows.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height()))
else {
return;
};
// The silhouette drawn where the old one lies in its raster.
let anchor = reach.origin();
let p = f.view * anchor;
let mut off = f.offscreen_context(w, h);
let crop = Frame { mt: false, view: Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view, ..*f };
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources);
if !self.shadows.get(&old).is_some_and(|e| pm.data().iter().map(|p| p.a).eq(e.made.pixels.iter().copied())) {
return;
}
if let Some(mut e) = self.shadows.remove(&old) {
(e.node, e.anchor, e.moved) = (Drawn::of(n, owner), anchor, true);
self.shadows.insert(key, e);
}
}
/// `draw_shape` for nodes with live effects.
pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) {
// The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path,
@ -628,7 +792,8 @@ impl Renderer {
/// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels:
/// larger content is cropped to the view when every effect only looks nearby, else rendered
/// at a lower resolution. A whole raster at full resolution is cached while the object and the
/// zoom stay the same (panning only moves it).
/// zoom stay the same (panning only moves it), and while interacting after the content only
/// moved ([`Self::move_pixel_content`]).
fn pixel_content(
&mut self,
ctx: &mut RenderContext,
@ -664,8 +829,23 @@ impl Renderer {
let linear = linear_key(f.view);
let key = content.cache.filter(|_| fits && k >= 1.0).map(|slot| (content.node as *const Node as usize, slot, f.ink));
let stamp = self.stamp;
if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| e.linear == linear && e.node.is(content.node)) {
let channels = match f.ink {
Ink::Cmy => effects::pixel::Channels::CmyPlane,
Ink::K => effects::pixel::Channels::KPlane,
Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb,
};
let interacting = f.opts.interacting;
let exact = |e: &PixelEntry| e.linear == linear && e.node.is(content.node) && (interacting || !e.moved);
if interacting
&& let Some(key) = key
&& !self.pixel_fx.get(&key).is_some_and(exact)
{
self.move_pixel_content(f, content, key, &chain, channels, paint);
}
if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| exact(e)) {
e.stamp = stamp;
self.stats.moved += usize::from(e.moved);
let p = f.view * e.anchor;
let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k);
draw_scaled(ctx, image, at, k);
@ -677,16 +857,21 @@ impl Renderer {
let px = f.px / k;
let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f };
let mut off = f.offscreen_context(wu, hu);
let before = self.stats.moved;
self.inside_layer(|r| paint(r, &mut off, &frame));
// Content drawn over moved rasters makes a moved raster.
let moved = self.stats.moved > before;
off.flush();
let mut pm = vello_cpu::Pixmap::new(wu, hu);
off.render(&mut pm, &mut self.resources);
let channels = match f.ink {
Ink::Cmy => effects::pixel::Channels::CmyPlane,
Ink::K => effects::pixel::Channels::KPlane,
Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb,
};
// What the raster is made from, for its object once it moves (`move_pixel_content`).
let made = key.map(|_| Made {
pixels: pm.data_as_u8_slice().to_vec(),
fx: chain.iter().map(|fx| (*fx).clone()).collect(),
size: content.reach.size(),
channels: Some(channels),
outline: feather_outline(&chain, content, content.reach.origin()),
});
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height()));
@ -706,23 +891,77 @@ impl Renderer {
}
}
let image = std::sync::Arc::new(pm);
if let Some(key) = key {
if let Some((key, made)) = key.zip(made) {
let anchor = content.reach.origin();
let p = f.view * anchor;
let rel = (x0 - p.x, y0 - p.y);
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
let node = Drawn::of(content.node, owner.as_ref());
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), stamp });
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, moved, stamp });
self.trim_fx_rasters(FX_RASTER_BUDGET);
}
draw_scaled(ctx, image, (x0, y0), k);
}
/// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame
/// made at full size for its object (the same id, slot and ink, at this zoom and rotation)
/// when only the content's place changed after that frame: made by the same effects `chain`,
/// told of the same `channels`, from content whose bounds have the same size (and the same
/// Feather outline against its anchor), and whose pixels are the pixels of `content` drawn
/// into a raster placed as that one was against its anchor. The raster is kept as
/// `content`'s, marked moved, and is drawn at the nearest whole pixel. Content under
/// De-Interlace takes none.
fn move_pixel_content(
&mut self,
f: &Frame,
content: &Content,
key: (usize, usize, Ink),
chain: &[&RasterFx],
channels: Channels,
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) {
if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) {
return;
}
let (linear, stamp, anchor) = (linear_key(f.view), self.stamp, content.reach.origin());
let outline = feather_outline(chain, content, anchor);
let fits = |k: &(usize, usize, Ink), e: &PixelEntry| {
(k.1, k.2) == (key.1, key.2)
&& e.stamp < stamp
&& e.linear == linear
&& e.k >= 1.0
&& e.node.id() == content.node.id
&& e.made.fits(chain, content.reach.size(), Some(channels), outline.as_ref())
};
let Some((old, rel, w, h)) = self.pixel_fx.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height()))
else {
return;
};
// The content drawn where the old content lies in its raster.
let p = f.view * anchor;
let view = Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view;
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
let frame = Frame { mt: false, view, visible, ..*f };
let mut off = f.offscreen_context(w, h);
self.inside_layer(|r| paint(r, &mut off, &frame));
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources);
if !self.pixel_fx.get(&old).is_some_and(|e| e.made.pixels.as_slice() == pm.data_as_u8_slice()) {
return;
}
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
if let Some(mut e) = self.pixel_fx.remove(&old) {
(e.node, e.anchor, e.moved) = (Drawn::of(content.node, owner.as_ref()), anchor, true);
self.pixel_fx.insert(key, e);
}
}
/// Drop the least recently drawn shadow and filtered-content rasters beyond `budget` bytes
/// ([`FX_RASTER_BUDGET`]).
fn trim_fx_rasters(&mut self, budget: usize) {
let shadows = self.shadows.iter().map(|(k, e)| (e.stamp, false, *k, raster_bytes(&e.image)));
let pixels = self.pixel_fx.iter().map(|(k, e)| (e.stamp, true, *k, raster_bytes(&e.image)));
let shadows = self.shadows.iter().map(|(k, e)| (e.stamp, false, *k, e.bytes()));
let pixels = self.pixel_fx.iter().map(|(k, e)| (e.stamp, true, *k, e.bytes()));
let mut all: Vec<_> = shadows.chain(pixels).collect();
let mut total: usize = all.iter().map(|e| e.3).sum();
if total <= budget {
@ -891,7 +1130,7 @@ mod tests {
use vectorcraft_doc::{Appearance, Document, Effect, Node};
use vectorcraft_geom::{Affine, Rect, shapes};
use super::{Drawn, raster_bytes};
use super::Drawn;
use crate::{RenderOptions, Rendered, Renderer};
fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document {
@ -976,8 +1215,8 @@ mod tests {
// A frame of other objects makes rasters of its own; the older ones go first.
let _ = r.render(&effects_page(), 400, 400, Affine::scale(2.0), &opts);
let newest = r.stamp;
let bytes = |r: &Renderer| r.shadows.values().map(|e| raster_bytes(&e.image)).sum::<usize>();
let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| raster_bytes(&e.image)).sum::<usize>();
let bytes = |r: &Renderer| r.shadows.values().map(|e| e.bytes()).sum::<usize>();
let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| e.bytes()).sum::<usize>();
assert!(bytes(&r) > latest);
r.trim_fx_rasters(latest);
assert_eq!(bytes(&r), latest);
@ -1202,7 +1441,163 @@ mod tests {
assert_eq!(b.pixels, c.pixels);
}
/// Reproduce #1257's moving, filtered rectangle without reusing a static frame's raster.
/// While interacting, an object that only moved draws its drop shadow and filtered content
/// from the rasters of an earlier frame, at the nearest whole pixel, without running the
/// effects again (#1257). The frame after the interaction runs them again: it is what a fresh
/// renderer draws.
#[test]
fn moved_objects_draw_their_effect_rasters_while_interacting() {
let effects =
vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2, "opacity": 80})), ("texture.craquelure", json!({"crackSpacing": 6}))];
let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), effects);
let id = base.layers[0].children().unwrap()[0].id;
// The object moved from where it started, as each frame of a drag moves it.
let moved = |dx: f64, dy: f64| {
let mut d = base.clone();
d.node_mut(id).unwrap().transform(Affine::translate((dx, dy)), false);
d
};
let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
let live = RenderOptions { interacting: true, ..still.clone() };
let fresh = |d: &Document| Renderer::new().render(d, 200, 200, view, &still).pixels;
let mut r = Renderer::new();
assert!(r.render(&base, 200, 200, view, &still).pixels == fresh(&base));
assert_eq!(r.stats.moved, 0);
let (mut d, mut last) = (base.clone(), vec![]);
for (dx, dy) in [(3.0, 0.0), (3.3, 1.2), (5.15, 2.5), (6.3, 2.2)] {
d = moved(dx, dy);
last = r.render(&d, 200, 200, view, &live).pixels;
assert_eq!(r.stats.moved, 2, "the shadow and the filtered content moved by ({dx}, {dy})");
// At the nearest whole pixel (2 a point): what a fresh renderer draws there.
let whole = moved((dx * 2.0).round() / 2.0, (dy * 2.0).round() / 2.0);
assert!(last == fresh(&whole), "moved by ({dx}, {dy})");
}
// The frame after the drag, of the same document: the effects run again where the object is.
let after = r.render(&d, 200, 200, view, &still).pixels;
assert_eq!(r.stats.moved, 0);
assert!(after == fresh(&d), "the frame after the drag");
assert!(after != last, "6.3 is not on a whole pixel");
}
/// While interacting, a raster moves with its object only when the content's pixels (for a
/// shadow, their alpha) and the effects stayed the same (#1257): a new fill color under
/// Craquelure, other effect settings, a pattern fill that stays on the page, a resized object,
/// art added beyond the old raster, a changed Feather outline and De-Interlace run the effects
/// again. A shadow keeps its raster under a new fill color.
#[test]
fn moved_rasters_need_the_same_content_and_effects() {
let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
let live = RenderOptions { interacting: true, ..still.clone() };
// `before` drawn, then `after` while interacting: the rasters drawn moved, and whether the
// frame is what a fresh renderer draws.
let drag = |before: &Document, after: &Document| {
let mut r = Renderer::new();
let _ = r.render(before, 200, 200, view, &still);
let frame = r.render(after, 200, 200, view, &live).pixels;
(r.stats.moved, frame == Renderer::new().render(after, 200, 200, view, &still).pixels)
};
// `d`'s object moved 5, 3 (10, 6 pixels), then changed by `edit`.
let moved = |d: &Document, patterns: bool, edit: &dyn Fn(&mut Node)| {
let mut d = d.clone();
let id = d.layers[0].children().unwrap()[0].id;
let n = d.node_mut(id).unwrap();
n.transform(Affine::translate((5.0, 3.0)), vectorcraft_doc::Scaling { patterns, ..Default::default() });
edit(n);
d
};
let cracks = || vec![("texture.craquelure", json!({"crackSpacing": 6}))];
let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), cracks());
assert_eq!(drag(&base, &moved(&base, false, &|_| {})), (1, true), "moved");
let recolor = |n: &mut Node| n.appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.1, 0.4, 0.9));
assert_eq!(drag(&base, &moved(&base, false, &recolor)), (0, true), "recolored");
let spacing = |n: &mut Node| n.appearance.effects[0].params = json!({"crackSpacing": 9});
assert_eq!(drag(&base, &moved(&base, false, &spacing)), (0, true), "other effect settings");
let resize = |n: &mut Node| n.transform(Affine::scale_about(1.5, vectorcraft_geom::Point::new(25.0, 23.0)), false);
assert_eq!(drag(&base, &moved(&base, false, &resize)), (0, true), "resized");
// A square added beyond the old raster leaves the content inside it as it was.
let beyond = |n: &mut Node| {
if let vectorcraft_doc::NodeKind::Path { path, .. } = &mut n.kind {
path.subpaths.extend(shapes::rectangle(Rect::new(75.0, 30.0, 80.0, 35.0)).subpaths);
}
};
assert_eq!(drag(&base, &moved(&base, false, &beyond)), (0, true), "art added beyond the old raster");
// At 72 ppi, De-Interlace works on lines 1 point tall. The object moves 3 points down, so
// De-Interlace makes its other field again.
let fields = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("video.deinterlace", json!({}))]);
assert_eq!(drag(&fields, &moved(&fields, false, &|_| {})), (0, true), "De-Interlace");
// A pattern fill: its tiles stay on the page unless they move with the object.
let mut tiled = base.clone();
let red = Node::path(
vectorcraft_doc::NodeId(900),
shapes::rectangle(Rect::new(0.0, 0.0, 3.0, 3.0)),
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
);
let mut def = vectorcraft_doc::pattern::PatternDef::new("Dots", vec![std::sync::Arc::new(red)]);
def.tile = Rect::new(0.0, 0.0, 7.0, 7.0);
tiled.patterns.push(def);
let id = tiled.layers[0].children().unwrap()[0].id;
tiled.node_mut(id).unwrap().appearance.fill_mut().unwrap().paint = vectorcraft_doc::pattern::pattern_paint("Dots");
assert_eq!(drag(&tiled, &moved(&tiled, false, &|_| {})), (0, true), "tiles that stay on the page");
assert_eq!(drag(&tiled, &moved(&tiled, true, &|_| {})), (1, true), "tiles that move with the object");
// Feather clips to the outline: a notch in the top edge where the tiles paint nothing
// (x 29 to 33 and y 20 to 24 once moved) leaves the content's pixels as they were.
let mut feathered = tiled.clone();
feathered.node_mut(id).unwrap().appearance.effects =
vec![Effect { id: "stylize.feather".into(), params: json!({"radius": 3}), visible: true }];
assert_eq!(drag(&feathered, &moved(&feathered, true, &|_| {})), (1, true), "feathered");
let notch = |n: &mut Node| {
let mut bp = vectorcraft_geom::BezPath::new();
bp.move_to((25.0, 23.0));
for p in [(30.0, 23.0), (30.5, 23.6), (31.5, 23.6), (32.0, 23.0), (55.0, 23.0), (55.0, 49.0), (25.0, 49.0)] {
bp.line_to(p);
}
bp.close_path();
if let vectorcraft_doc::NodeKind::Path { path, .. } = &mut n.kind {
*path = vectorcraft_geom::PathData::from_bezpath(&bp);
}
};
assert_eq!(drag(&feathered, &moved(&feathered, true, &notch)), (0, true), "a notch in the feathered outline");
// A shadow is made from the silhouette's alpha: recolored, the object takes its shadow
// along; notched, the object makes its shadow again.
let shadowed =
doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2}))]);
assert_eq!(drag(&shadowed, &moved(&shadowed, false, &recolor)), (1, true), "a shadow under a new color");
assert_eq!(drag(&shadowed, &moved(&shadowed, false, &notch)), (0, true), "a shadow under a notch");
}
/// A raster made over moved rasters counts as moved itself (#1257): a group with a drop shadow
/// and Gaussian Blur of its own, whose filtered member moves by parts of a pixel while the
/// other member stays, draws after the drag what a fresh renderer draws.
#[test]
fn rasters_made_over_moved_rasters_count_as_moved() {
let mut d = Document::new(100.0, 100.0);
let fill = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0);
let (sid, mid, gid) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let still = Node::path(sid, shapes::rectangle(Rect::new(60.0, 60.0, 80.0, 80.0)), fill(Color::rgb(0.1, 0.5, 0.2)));
let mut member = Node::path(mid, shapes::rectangle(Rect::new(20.0, 20.0, 50.0, 46.0)), fill(Color::rgb(0.9, 0.4, 0.1)));
member.appearance.effects = vec![Effect { id: "texture.craquelure".into(), params: json!({"crackSpacing": 6}), visible: true }];
let mut group = Node::group(gid, vec![std::sync::Arc::new(still), std::sync::Arc::new(member)]);
group.appearance.effects = vec![
Effect { id: "stylize.dropShadow".into(), params: json!({"x": 4, "y": 3, "blur": 2}), visible: true },
Effect { id: "blur.gaussian".into(), params: json!({"radius": 1}), visible: true },
];
let l = d.layers[0].id;
d.insert(Some(l), 0, group).unwrap();
let (view, opts) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
let mut r = Renderer::new();
let _ = r.render(&d, 200, 200, view, &opts);
let mut moved = d.clone();
moved.node_mut(mid).unwrap().transform(Affine::translate((3.3, 1.2)), false);
let _ = r.render(&moved, 200, 200, view, &RenderOptions { interacting: true, ..opts.clone() });
assert!(r.stats.moved > 0, "the member's filtered content moved");
let after = r.render(&moved, 200, 200, view, &opts).pixels;
assert!(after == Renderer::new().render(&moved, 200, 200, view, &opts).pixels, "the frame after the drag");
}
/// Reproduce #1257's moving, filtered rectangle: the first frame renders as before a drag,
/// and the next eight render as the canvas renders a drag's frames (`interacting`). Then the
/// eighth position renders as the canvas renders the frame after the drag, and that frame
/// renders once more with every raster cached.
/// Run with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`.
#[test]
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
@ -1222,23 +1617,33 @@ mod tests {
];
base.insert(Some(layer), 0, n).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, ..opts.clone() };
let view = Affine::scale(1.9007 * 1.5);
let mut r = Renderer::new();
r.threads = crate::default_threads();
let mut ms = vec![];
let (mut ms, mut doc) = (vec![], base.clone());
for frame in 0..9 {
let mut doc = base.clone();
doc = base.clone();
doc.node_mut(id).unwrap().transform(Affine::translate((3.0 + f64::from(frame) * 0.75, f64::from(frame) * 0.5)), false);
let start = std::time::Instant::now();
let image = r.render(&doc, 1800, 1500, view, &opts);
let image = r.render(&doc, 1800, 1500, view, if frame > 0 { &live } else { &opts });
let time = start.elapsed().as_secs_f64() * 1000.0;
if frame > 0 {
ms.push(time);
}
std::hint::black_box(image);
}
let mut time = |o: &RenderOptions| {
let start = std::time::Instant::now();
std::hint::black_box(r.render(&doc, 1800, 1500, view, o));
start.elapsed().as_secs_f64() * 1000.0
};
let (after, again) = (time(&opts), time(&opts));
ms.sort_by(f64::total_cmp);
println!("{effect}: moved CMYK rounded rectangle at 190.07%, DPI 1.5, 1800x1500: median {:.2} ms; samples {ms:?}", (ms[3] + ms[4]) * 0.5);
println!(
"{effect}: moved CMYK rounded rectangle at 190.07%, DPI 1.5, 1800x1500: median {:.2} ms; samples {ms:?}; the frame after the drag {after:.2} ms, that frame again {again:.2} ms",
(ms[3] + ms[4]) * 0.5
);
}
}

View File

@ -127,6 +127,12 @@ pub struct RenderOptions {
/// (box-filtered from the full image, kept with the decoded images), so the full image needn't
/// stay in memory or be sampled. Off, images always sample their full pixels (exports).
pub image_mips: bool,
/// Screen views while an interaction is in progress (a drag, a dialog's preview): an object
/// whose content only moved since an earlier frame draws its cached drop shadows, outer glows
/// and filtered content from the rasters made then, at the nearest whole pixel, without
/// running the effects again (see `fx`). Off, no raster made that way, or made over one, is
/// drawn: the effects run again.
pub interacting: bool,
/// Appearance of Black's Rich Black: pure CMYK black (black ink alone at 100%) shows as RGB
/// black. Off, it shows through the CMYK profile, a dark grey, as other colours do.
pub rich_black: bool,
@ -204,6 +210,7 @@ impl Default for RenderOptions {
precise: false,
supersample: false,
image_mips: false,
interacting: false,
rich_black: false,
}
}
@ -352,6 +359,21 @@ struct GeomEntry {
type ClipEntry = (Arc<Node>, Option<Arc<(BezPath, FillRule)>>);
/// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]).
type ClipPaintEntry = (Arc<Node>, Option<Arc<Node>>, Option<Arc<Node>>);
/// What symbol art placed by [`Renderer::placed_art`] is kept under: the art's address, the bits
/// of the transform's coefficients, whether strokes scale with it, and the stain ([`stain_bits`]).
type PlacedKey = (usize, [u64; 6], bool, [u32; 6]);
/// Symbol art placed by [`Renderer::placed_art`].
struct PlacedEntry {
/// The symbol's art, held weakly: the reference keeps the art's allocation, so no other art can
/// get its address while the entry exists, and does not keep the art's content.
art: std::sync::Weak<Node>,
/// The art placed, or `None` for art placed again in every frame ([`depends_on_document`]).
placed: Option<Arc<Node>>,
/// The font generation ([`vectorcraft_text::FontDb::generation`]) the art was placed in, when
/// it holds type: the art is placed again when the fonts change.
fonts: Option<u64>,
}
/// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded
/// images are shared by all renderers, see [`set_image_cache_budget`]).
@ -400,6 +422,8 @@ pub struct Renderer {
shape_of: usize,
/// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`).
fx_arts: FrameCache<usize, (Arc<Node>, Arc<Node>)>,
/// Symbol art as instances and inline graphics draw it (see [`Self::placed_art`]).
placed_arts: FrameCache<PlacedKey, PlacedEntry>,
/// Layers open around the drawing point in the current context: a non-isolated group can copy
/// its backdrop only when there are none (see [`Self::group`]).
nested: u32,
@ -444,6 +468,9 @@ static MISSING_INLINE_LOGGED: std::sync::atomic::AtomicBool = std::sync::atomic:
pub struct FrameStats {
pub drawn: usize,
pub culled: usize,
/// The drop shadows, outer glows and filtered contents the frame drew from rasters made before
/// their object moved, or made over such rasters ([`RenderOptions::interacting`]).
pub moved: usize,
pub micros: u64,
}
@ -508,6 +535,7 @@ impl Renderer {
knockout: false,
shape_of: 0,
fx_arts: FrameCache::new(lru::FX_ART_BUDGET, 256),
placed_arts: FrameCache::new(lru::PLACED_ART_BUDGET, 256),
nested: 0,
backdrop: None,
blends: PtrMap::default(),
@ -611,6 +639,7 @@ impl Renderer {
self.clips.sweep(g);
self.clip_paints.sweep(g);
self.fx_arts.sweep(g);
self.placed_arts.sweep(g);
self.brushes.entries.sweep(g);
self.live.sweep(g);
}
@ -1136,8 +1165,7 @@ impl Renderer {
NodeKind::Image(im) => self.draw_image(ctx, f, im),
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) {
let mut art = brush_fx::instance_art(&sym.art, n);
art.transform(*xf, false);
let art = self.placed_art(&sym.art, *xf, false, Some(n));
self.draw_node(ctx, f, &art, true);
}
}
@ -1471,14 +1499,46 @@ impl Renderer {
for ig in &g.inlines {
let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue };
let Some(sym) = f.doc.symbols.iter().find(|s| s.name == art.symbol) else { continue };
let mut node = (*sym.art).clone();
// Strokes scale with the art (it is sized to the type, as in the SVG `<use>`).
node.transform(t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true);
let node = self.placed_art(&sym.art, t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true, None);
self.draw_node(ctx, f, &node, true);
}
self.inline_depth -= 1;
}
/// Symbol art `art` as an instance or an inline graphic draws it ([`place`]). Art that is a
/// group or a layer without an appearance of its own is kept between frames under the art's
/// `Arc`, `xf`, `scale_strokes` and the stain of instance `inst`: its members keep their `Arc`s
/// from frame to frame, and the caches keyed by a node's address (geometry, glyphs, live-effect
/// geometry) hit for them. Other art is placed again in every frame, and so is art that holds
/// symbol instances, type with inline graphics, images, placed documents, live blends,
/// envelopes or repeats, or raster effects ([`depends_on_document`]). Art that holds type is
/// placed again when the fonts change.
fn placed_art(&mut self, art: &Arc<Node>, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Arc<Node> {
if !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || fx::has_object_fx(art) {
return place(art, xf, scale_strokes, inst);
}
let stain = stain_bits(inst.and_then(vectorcraft_brush::stain));
let key = (Arc::as_ptr(art) as usize, xf.as_coeffs().map(f64::to_bits), scale_strokes, stain);
if let Some(e) = self.placed_arts.get(&key, self.stamp)
&& std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art))
&& e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
{
return match &e.placed {
Some(placed) => placed.clone(),
None => place(art, xf, scale_strokes, inst),
};
}
let placed = place(art, xf, scale_strokes, inst);
let kept = (!depends_on_document(art)).then(|| placed.clone());
let fonts = (kept.is_some() && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation());
// The bytes count the key and the entry, the art's allocation that the weak reference
// keeps, and the kept art.
let bytes = size_of::<(PlacedKey, PlacedEntry, Node)>() + kept.as_deref().map_or(0, lru::node_bytes);
self.placed_arts.insert(key, PlacedEntry { art: Arc::downgrade(art), placed: kept, fonts }, bytes, self.stamp);
placed
}
/// Cached glyph geometry for a text node (keyed by Arc identity like paths).
fn text_geom_of(&mut self, a: &Arc<Node>, t: &TextObject) -> Arc<TextGeom> {
let fonts = vectorcraft_text::FontDb::global().generation();
@ -1844,6 +1904,61 @@ fn painted_items(n: &Node) -> usize {
.max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && !matches!(s.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None))) { 2 } else { 0 })
}
/// Symbol art `art` stained by instance `inst` ([`instance_art`]) and placed by `xf`, with
/// strokes scaled by `xf` when `scale_strokes`.
fn place(art: &Node, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Arc<Node> {
let mut placed = match inst {
Some(i) => brush_fx::instance_art(art, i),
None => art.clone(),
};
placed.transform(xf, scale_strokes);
Arc::new(placed)
}
/// Does `n` hold, in it or in the opacity masks of the objects in it, a symbol instance, type
/// with an inline graphic, an image, a placed document, a blend, an envelope, a repeat, or an
/// object or a fill or stroke with a visible raster effect?
fn depends_on_document(n: &Node) -> bool {
let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && effects::is_raster(&e.id));
let mut depends = false;
n.walk(&mut |c| {
depends |= match &c.kind {
NodeKind::SymbolInstance { .. }
| NodeKind::Image(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Repeat(_) => true,
NodeKind::Text(t) => t.runs.iter().any(|r| r.inline.is_some()),
_ => false,
} || raster(&c.appearance.effects)
|| c.appearance.items.iter().any(|i| raster(i.effects()))
|| c.extras().mask.as_ref().is_some_and(|m| depends_on_document(&m.art));
});
depends
}
/// Does `n` hold type, in it or in the opacity masks of the objects in it?
fn holds_type(n: &Node) -> bool {
let mut holds = false;
n.walk(&mut |c| holds |= matches!(c.kind, NodeKind::Text(_)) || c.extras().mask.as_ref().is_some_and(|m| holds_type(&m.art)));
holds
}
/// A symbol instance's stain ([`vectorcraft_brush::stain`]) as bits for a cache key: its color
/// model, the color's components and the amount (all 0 without a stain).
fn stain_bits(stain: Option<(vectorcraft_color::Color, f32)>) -> [u32; 6] {
use vectorcraft_color::Color;
let Some((color, amount)) = stain else { return [0; 6] };
let (model, [p, q, r, s]) = match color {
Color::Rgb { r, g, b } => (1, [r, g, b, 0.0]),
Color::Cmyk { c, m, y, k } => (2, [c, m, y, k]),
Color::Gray { k } => (3, [k, 0.0, 0.0, 0.0]),
Color::Lab { l, a, b } => (4, [l, a, b, 0.0]),
};
[model, p.to_bits(), q.to_bits(), r.to_bits(), s.to_bits(), amount.to_bits()]
}
/// Preferred rasterizer thread count set by the app (Preferences → Performance); negative = automatic.
static THREADS_OVERRIDE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);

View File

@ -30,6 +30,8 @@ pub(crate) const TEXT_BUDGET: usize = 128 << 20;
pub(crate) const CLIP_BUDGET: usize = 32 << 20;
/// The art drawn for objects with effects that apply through it (`Renderer::fx_art`).
pub(crate) const FX_ART_BUDGET: usize = 64 << 20;
/// Symbol art placed for instances and inline graphics (`Renderer::placed_art`).
pub(crate) const PLACED_ART_BUDGET: usize = 64 << 20;
/// Brush art per brushed stroke (`Renderer::draw_brush`).
pub(crate) const BRUSH_BUDGET: usize = 64 << 20;
/// Evaluated blends, envelopes and repeats (`Renderer::live_expanded`).

View File

@ -39,3 +39,25 @@ fn type_redraws_when_its_font_becomes_available() {
assert_ne!(before, after, "drawn in the font that arrived");
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
/// Type with a geometry effect in a symbol's art redraws in a font that becomes available: the art
/// kept for the instance is placed again when the fonts change.
#[test]
fn symbol_type_redraws_when_its_font_becomes_available() {
const FAMILY: &str = "Placed Serif 4";
let mut d = Document::new(300.0, 150.0);
let style = CharStyle { size: 60.0, font_family: FAMILY.into(), ..Default::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(-140.0, 25.0), "Hamburg", style))));
t.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true });
let art = Node::group(d.alloc_id(), vec![Arc::new(t)]);
d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 75.0)) })).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, after, "drawn in the font that arrived");
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}

View File

@ -1,5 +1,5 @@
//! What renderers keep between frames: decoded images shared by all of them, image mips, and
//! caches swept by every kind of render.
//! What renderers keep between frames: decoded images shared by all of them, image mips, symbol
//! art placed for instances and inline graphics, and caches swept by every kind of render.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
@ -174,6 +174,309 @@ fn swept_type_entries_give_back_their_bytes() {
assert_eq!((r.texts.len(), r.texts.bytes()), (0, 0));
}
/// A symbol's art inside the 20 × 20 square around the origin: a group of a square filled blue
/// with a 2 pt black stroke and a black line.
fn symbol_art(d: &mut Document) -> Node {
use vectorcraft_doc::Appearance;
let square = vectorcraft_geom::shapes::rectangle(Rect::new(-8.0, -8.0, 8.0, 8.0));
let square = Node::path(d.alloc_id(), square, Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(Color::BLACK), 2.0));
let line = vectorcraft_geom::shapes::line((-8.0, 4.0).into(), (8.0, 4.0).into());
let line = Node::path(d.alloc_id(), line, Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.5));
Node::group(d.alloc_id(), vec![Arc::new(square), Arc::new(line)])
}
/// Three instances of symbol `S` ([`symbol_art`]): two in a row, the second stained red, and a
/// third turned and scaled by 1.5.
fn symbol_doc() -> Document {
let mut d = Document::new(200.0, 100.0);
let art = symbol_art(&mut d);
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
let l = d.layers[0].id;
let xfs = [
Affine::translate((30.0, 30.0)),
Affine::translate((80.0, 30.0)),
Affine::translate((140.0, 60.0)) * Affine::rotate(0.5) * Affine::scale(1.5),
];
for (i, xf) in xfs.into_iter().enumerate() {
let mut inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf });
if i == 1 {
inst.appearance.set_fill(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
}
d.insert(Some(l), i, inst).unwrap();
}
d
}
/// The addresses of the nodes `r` keeps geometry for that are not the own nodes of any version
/// of the document drawn (`docs`): the symbol art it placed for instances and inline graphics.
fn placed_copies(r: &Renderer, docs: &[&Document]) -> std::collections::BTreeSet<usize> {
let mut own = std::collections::HashSet::new();
for d in docs {
d.walk(|n| {
own.insert(n as *const Node as usize);
});
}
r.geom.values().map(|e| Arc::as_ptr(&e.node) as usize).filter(|p| !own.contains(p)).collect()
}
/// A second frame of an unchanged document draws the symbol art placed for its instances in the
/// first: it copies no art, and the geometry kept for the art's paths is used again. The renderer
/// holds the symbol's art itself only weakly.
#[test]
fn symbol_instances_keep_their_placed_art_between_frames() {
let d = symbol_doc();
let opts = RenderOptions::default();
let mut r = Renderer::new();
let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
let placed = placed_copies(&r, &[&d]);
assert_eq!(placed.len(), 6, "the square and the line of three instances");
assert!(r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == first);
assert_eq!(placed_copies(&r, &[&d]), placed, "the second frame draws the art the first placed");
assert_eq!(Arc::strong_count(&d.symbols[0].art), 1, "only the document holds the symbol's art");
}
/// Moving an instance places its art again, and editing a symbol's art places the art of its
/// instances again: the other instances keep theirs, and each frame draws what a new renderer
/// draws.
#[test]
fn editing_an_instance_or_a_symbol_places_only_its_art_again() {
let mut d = symbol_doc();
// Symbol `T` is a green square, and one instance of it stands below the others.
let green = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0);
let square = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(-5.0, -5.0, 5.0, 5.0)), green);
let t = Node::group(d.alloc_id(), vec![Arc::new(square)]);
d.symbols.push(vectorcraft_doc::Symbol { name: "T".into(), art: Arc::new(t) });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "T".into(), xf: Affine::translate((30.0, 80.0)) })).unwrap();
let opts = RenderOptions::default();
let frame = |r: &mut Renderer, d: &Document| r.render(d, 200, 100, Affine::IDENTITY, &opts).pixels;
let mut r = Renderer::new();
frame(&mut r, &d);
let before = placed_copies(&r, &[&d]);
assert_eq!(before.len(), 7);
// The first instance of `S` moves 5 pt right.
let unmoved = d.clone();
let first = d.layers[0].children().unwrap()[1].id;
if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(first).map(|n| &mut n.kind) {
*xf = Affine::translate((5.0, 0.0)) * *xf;
}
assert!(frame(&mut r, &d) == frame(&mut Renderer::new(), &d));
let moved = placed_copies(&r, &[&unmoved, &d]);
assert!(before.is_subset(&moved) && moved.len() == before.len() + 2, "{} copies, then {}", before.len(), moved.len());
// `S`'s square is repainted red: its instances show it, and `T`'s instance keeps its art.
let mut art = (*d.symbols[0].art).clone();
if let Some(square) = art.children_mut().and_then(|c| c.first_mut()) {
Arc::make_mut(square).appearance.set_fill(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
}
d.symbols[0].art = Arc::new(art);
let red = frame(&mut r, &d);
assert!(red == frame(&mut Renderer::new(), &d));
let img = Rendered { width: 200, height: 100, pixels: red };
assert_eq!(img.pixel(140, 60), [255, 0, 0, 255], "the turned instance's square");
let edited = placed_copies(&r, &[&unmoved, &d]);
assert!(moved.is_subset(&edited) && edited.len() == moved.len() + 6, "{} copies, then {}", moved.len(), edited.len());
}
/// Draws `d` (200 × 100 points, on white), applies `edit` and draws it again with the same
/// renderer: the frame changed, a new renderer draws the same, and only the document holds the
/// symbols' art.
fn edit_draws_as_new(mut d: Document, edit: impl FnOnce(&mut Document)) {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
edit(&mut d);
let after = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
assert!(after != before, "the edit changes the frame");
assert!(after == Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &opts).pixels, "a new renderer draws the same");
assert!(d.symbols.iter().all(|s| Arc::strong_count(&s.art) == 1), "only the document holds the symbols' art");
}
/// `d` with symbol `Holder` (a group of `members`) shown by one instance at (100, 50).
fn holder_doc(mut d: Document, members: Vec<Node>) -> Document {
let holder = Node::group(d.alloc_id(), members.into_iter().map(Arc::new).collect());
d.symbols.push(vectorcraft_doc::Symbol { name: "Holder".into(), art: Arc::new(holder) });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Holder".into(), xf: Affine::translate((100.0, 50.0)) })).unwrap();
d
}
/// Symbol art that shows another symbol (type with an inline graphic of it, an instance of it with
/// a Transform effect) is placed again in every frame: when that symbol's art changes, the next
/// frame draws what a new renderer draws.
#[test]
fn art_showing_another_symbol_draws_its_edits() {
use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun};
let mut d = Document::new(200.0, 100.0);
let art = symbol_art(&mut d);
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
let style = CharStyle { size: 24.0, ..Default::default() };
let mut t = TextObject::point(vectorcraft_geom::Point::new(-60.0, 10.0), "A", style.clone());
t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone()));
t.runs.push(TextRun::new("B", style));
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let mut moved = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((40.0, 0.0)) });
moved.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 4.0}),
visible: true,
});
let d = holder_doc(d, vec![text, moved]);
// `S` becomes a wide red bar: the inline graphic grows, and the type after it moves.
edit_draws_as_new(d, |d| {
let bar = vectorcraft_geom::shapes::rectangle(Rect::new(-20.0, -4.0, 20.0, 4.0));
let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols[0].art = Arc::new(Node::path(d.alloc_id(), bar, red));
});
}
/// Symbol art with raster effects is placed again in every frame: when a pattern under a blur or
/// the document's raster resolution under De-Interlace changes, the next frame draws what a new
/// renderer draws.
#[test]
fn art_with_raster_effects_draws_pattern_and_resolution_edits() {
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
let effect = |id: &str, params: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params, visible: true };
let mut d = Document::new(200.0, 100.0);
d.raster_effects_ppi = 72.0;
// Pattern `Checks` is a red 10 × 10 square in a 20 × 20 tile.
let tile = |c: Color| {
let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
vec![Arc::new(Node::path(NodeId(900), square, vectorcraft_doc::Appearance::basic(Paint::solid(c), Paint::None, 0.0)))]
};
let mut def = PatternDef::new("Checks", tile(Color::rgb(1.0, 0.0, 0.0)));
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
d.patterns.push(def);
let band = vectorcraft_geom::shapes::rectangle(Rect::new(-90.0, -45.0, -20.0, -25.0));
let mut blurred = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
blurred.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 2.0})));
// The path holds ten bars, 1 pt high and 2 pt apart.
let mut bars = vectorcraft_geom::shapes::rectangle(Rect::new(20.0, -40.0, 90.0, -39.0));
for k in 1..10 {
let y = -40.0 + 2.0 * k as f64;
bars.subpaths.extend(vectorcraft_geom::shapes::rectangle(Rect::new(20.0, y, 90.0, y + 1.0)).subpaths);
}
let mut striped = Node::path(d.alloc_id(), bars, vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
striped.appearance.effects.push(effect("video.deinterlace", serde_json::json!({})));
let d = holder_doc(d, vec![blurred, striped]);
edit_draws_as_new(d, |d| {
if let Some(def) = d.pattern_mut("Checks") {
def.art = tile(Color::rgb(0.0, 0.0, 1.0));
}
d.raster_effects_ppi = 24.0;
});
}
/// Symbol art with a live object (an envelope that distorts a pattern fill) is placed again in
/// every frame: when the pattern's tile type changes, the next frame draws what a new renderer
/// draws.
#[test]
fn art_with_live_objects_draws_pattern_layout_edits() {
use vectorcraft_doc::live::EnvelopeOptions;
use vectorcraft_doc::pattern::{PatternDef, TileType, pattern_paint};
let mut d = Document::new(200.0, 100.0);
// Pattern `Checks` is a red 10 × 10 square in a 20 × 20 tile.
let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let mut def = PatternDef::new("Checks", vec![Arc::new(Node::path(NodeId(900), square, red))]);
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
d.patterns.push(def);
let band = vectorcraft_geom::shapes::rectangle(Rect::new(-80.0, -40.0, 80.0, 40.0));
let filled = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let options = EnvelopeOptions { distort_appearance: true, distort_pattern_fills: true, ..Default::default() };
let envelope = Node::new(
d.alloc_id(),
NodeKind::Envelope {
content: vec![Arc::new(filled)],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options,
frame: Box::new(Affine::IDENTITY),
},
);
let d = holder_doc(d, vec![envelope]);
edit_draws_as_new(d, |d| {
if let Some(def) = d.pattern_mut("Checks") {
def.tile_type = TileType::BrickByRow { offset: 0.5 };
}
});
}
/// Symbol instances draw what groups of their art, stained and placed, draw: in every frame, when
/// two instances placed alike are stained differently, when the art is a single path, and with a
/// cache too small for all of the art. Art no longer drawn leaves the cache after a few frames.
#[test]
fn symbol_instances_draw_as_groups_of_their_placed_art() {
let mut d = symbol_doc();
let l = d.layers[0].id;
// An instance placed like the first one lies over it, stained green.
let mut over = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((30.0, 30.0)) });
over.appearance.set_fill(Paint::solid(Color::rgb(0.0, 1.0, 0.0)));
d.insert(Some(l), 1, over).unwrap();
// Symbol `U` is a single path, a circle with a black stroke.
let circle = vectorcraft_geom::shapes::ellipse(Rect::new(-6.0, -6.0, 6.0, 6.0));
let ap = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.8, 0.0)), Paint::solid(Color::BLACK), 1.0);
let circle = Node::path(d.alloc_id(), circle, ap);
d.symbols.push(vectorcraft_doc::Symbol { name: "U".into(), art: Arc::new(circle) });
let id = d.alloc_id();
d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "U".into(), xf: Affine::translate((175.0, 25.0)) })).unwrap();
// `groups` holds the same art in place of the instances: groups, and the circle as a path.
let mut groups = d.clone();
let placed = d.layers[0].children().unwrap().iter().map(|c| match &c.kind {
NodeKind::SymbolInstance { symbol, xf } => {
let sym = d.symbols.iter().find(|s| s.name == *symbol).unwrap();
let mut art = instance_art(&sym.art, c);
art.transform(*xf, false);
Arc::new(art)
}
_ => c.clone(),
});
*groups.children_mut(Some(l)).unwrap() = placed.collect();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let want = Renderer::new().render(&groups, 200, 100, Affine::IDENTITY, &opts).pixels;
let mut r = Renderer::new();
r.placed_arts = FrameCache::new(lru::PLACED_ART_BUDGET, 0);
for frame in 0..3 {
assert!(r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == want, "frame {frame}");
}
// A cache that holds half the bytes of that art keeps what fits and places the rest every frame.
let all = r.placed_arts.bytes();
let mut small = Renderer::new();
small.placed_arts = FrameCache::new(all / 2, 0);
for frame in 0..3 {
assert!(small.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == want, "frame {frame} over the budget");
assert!(!small.placed_arts.is_empty() && small.placed_arts.bytes() <= all / 2, "{} of {all} bytes", small.placed_arts.bytes());
}
let empty = Document::new(10.0, 10.0);
for _ in 0..5 {
r.render(&empty, 10, 10, Affine::IDENTITY, &opts);
}
assert_eq!((r.placed_arts.len(), r.placed_arts.bytes()), (0, 0));
}
/// Inline graphics in type draw the art placed for them in an earlier frame, as instances do.
#[test]
fn inline_graphics_keep_their_placed_art_between_frames() {
use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun};
let mut d = Document::new(200.0, 100.0);
let art = symbol_art(&mut d);
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
let style = CharStyle { size: 24.0, ..Default::default() };
let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "A", style.clone());
t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone()));
t.runs.push(TextRun::new("B", style));
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
let opts = RenderOptions::default();
let mut r = Renderer::new();
let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
let placed = placed_copies(&r, &[&d]);
assert_eq!(placed.len(), 2, "the square and the line of the inline graphic");
assert!(r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == first);
assert_eq!(placed_copies(&r, &[&d]), placed, "the second frame draws the art the first placed");
}
/// Each run draws exactly the outlines it drew when kept as a path of its own: its glyphs and
/// decorations in layout order (the whole text's path is kept once, the runs as spans of it).
#[test]

View File

@ -104,7 +104,12 @@ pub fn eps(data: &[u8], page: &str) -> Vec<u8> {
/// `private` (the private data streams' contents, such as `%AI12_CompressedData` and zlib data) in
/// two streams.
pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
with_private(private, page, "AIPrivateData")
ai_with(private, page, "")
}
/// [`ai`] with more entries in the page dictionary (`/ArtBox [0 0 200 100]`).
pub fn ai_with(private: &[u8], page: &str, entries: &str) -> Vec<u8> {
with_private(private, page, "AIPrivateData", entries)
}
/// A PDF saved with Illustrator's editing data: [`ai`]'s file with the two streams under
@ -112,11 +117,12 @@ pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
/// #868, and nothing else of that file is used; ExifTool's PDF tag table lists them too
/// (<https://exiftool.org/TagNames/PDF.html>).
pub fn pdf(private: &[u8], page: &str) -> Vec<u8> {
with_private(private, page, "AIPDFPrivateData")
with_private(private, page, "AIPDFPrivateData", "")
}
/// The file of [`ai`] and [`pdf`], with `private` in the streams `{key}1` and `{key}2`.
fn with_private(private: &[u8], page: &str, key: &str) -> Vec<u8> {
/// The file of [`ai`] and [`pdf`], with `private` in the streams `{key}1` and `{key}2` and
/// `entries` more in the page dictionary.
fn with_private(private: &[u8], page: &str, key: &str, entries: &str) -> Vec<u8> {
let (first, second) = private.split_at(private.len() / 2);
let stream = |part: &[u8]| {
let mut s = format!("<< /Length {} >>\nstream\n", part.len()).into_bytes();
@ -128,7 +134,7 @@ fn with_private(private: &[u8], page: &str, key: &str) -> Vec<u8> {
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R \
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] {entries} /Contents 4 0 R \
/Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> \
/PieceInfo << /{PIECE} << /Private << /{key}1 5 0 R /{key}2 6 0 R /NumBlock 2 >> >> >> >>"
)

View File

@ -632,7 +632,10 @@ mod tests {
let cx2 = cx(&d, &whole, &p);
let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 190.0, 110.0));
assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0, 1, 2, 3]}))]);
assert!(crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0)).is_empty());
// Off the widgets, the Selection tool's double-click isolates the shape (Double Click To
// Isolate) instead.
let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0));
assert_eq!(a, vec![Action::Exec("object.isolate".into(), json!({"id": id.0}))]);
}
/// Hide Corner Widget for angles greater than (#394): a rectangle's right angles hide below

View File

@ -27,6 +27,14 @@ pub struct FamilyTool {
pub rows: u32,
/// Columns / radial dividers.
pub columns: u32,
/// The grids' divider skews in percent: rows / concentric, then columns / radial.
pub skew: [f64; 2],
/// Rectangular Grid: the outside is one rectangle (else its four sides).
pub frame: bool,
/// Polar Grid: the ellipses pair up into compound paths.
pub compound: bool,
/// The grids take the current fill.
pub fill: bool,
snap: DrawSnap,
}
@ -50,6 +58,10 @@ impl FamilyTool {
clockwise: true,
rows: 5,
columns: 5,
skew: [0.0; 2],
frame: true,
compound: false,
fill: false,
snap: DrawSnap::default(),
}
}
@ -79,19 +91,14 @@ impl FamilyTool {
json!({"cx": start.x, "cy": start.y, "radius": r, "decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}),
)
}
"rectangularGrid" => {
// The grids: the dragged box with the tool's options.
"rectangularGrid" | "polarGrid" => {
let r = drag_rect(start, p, m);
(
"shape.rectangularGrid".into(),
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "rows": self.rows, "columns": self.columns}),
)
}
"polarGrid" => {
let r = drag_rect(start, p, m);
(
"shape.polarGrid".into(),
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "concentric": self.rows, "radial": self.columns}),
)
let mut params = self.options();
if let Some(o) = params.as_object_mut() {
o.extend([("x", r.x0), ("y", r.y0), ("width", r.width()), ("height", r.height())].map(|(k, v)| (k.to_string(), json!(v))));
}
(format!("shape.{}", self.id), params)
}
_ => {
let d = if m.shift { square(p - start) } else { p - start };
@ -206,8 +213,12 @@ impl Tool for FamilyTool {
fn options(&self) -> Value {
match self.id {
"spiral" => json!({"decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}),
"rectangularGrid" => json!({"rows": self.rows, "columns": self.columns}),
"polarGrid" => json!({"concentric": self.rows, "radial": self.columns}),
"rectangularGrid" => json!({
"rows": self.rows, "columns": self.columns, "rowSkew": self.skew[0], "columnSkew": self.skew[1], "frame": self.frame, "fill": self.fill,
}),
"polarGrid" => json!({
"concentric": self.rows, "radial": self.columns, "concentricSkew": self.skew[0], "radialSkew": self.skew[1], "compound": self.compound, "fill": self.fill,
}),
_ => json!({"closed": self.closed}),
}
}
@ -219,11 +230,22 @@ impl Tool for FamilyTool {
"clockwise" => self.clockwise = v.as_bool().unwrap_or(self.clockwise),
"rows" | "concentric" => self.rows = v.as_u64().unwrap_or(5).min(999) as u32,
"columns" | "radial" => self.columns = v.as_u64().unwrap_or(5).min(999) as u32,
"rowSkew" | "concentricSkew" => self.skew[0] = grid_skew(v, self.skew[0]),
"columnSkew" | "radialSkew" => self.skew[1] = grid_skew(v, self.skew[1]),
"frame" => self.frame = v.as_bool().unwrap_or(self.frame),
"compound" => self.compound = v.as_bool().unwrap_or(self.compound),
"fill" => self.fill = v.as_bool().unwrap_or(self.fill),
_ => {}
}
}
}
/// A grid divider skew from option value `v`: a finite number within the range, else `current`.
fn grid_skew(v: &Value, current: f64) -> f64 {
let max = vectorcraft_geom::shapes::GRID_SKEW_MAX;
v.as_f64().filter(|s| s.is_finite()).map_or(current, |s| s.clamp(-max, max))
}
#[cfg(test)]
mod tests {
use super::*;
@ -243,7 +265,10 @@ mod tests {
assert_eq!(a[0], Action::Begin("Rectangular Grid".into()));
assert_eq!(
a[1],
Action::Preview("shape.rectangularGrid".into(), json!({"x": 10.0, "y": 10.0, "width": 50.0, "height": 30.0, "rows": 5, "columns": 5}))
Action::Preview(
"shape.rectangularGrid".into(),
json!({"x": 10.0, "y": 10.0, "width": 50.0, "height": 30.0, "rows": 5, "columns": 5, "rowSkew": 0.0, "columnSkew": 0.0, "frame": true, "fill": false})
)
);
let a = t.key(&cx, ToolKey::Right, Mods::default());
assert!(matches!(&a[0], Action::Preview(_, v) if v["columns"] == 6));
@ -256,6 +281,36 @@ mod tests {
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 90.0, 60.0)), vec![Action::Commit]);
}
/// #1377: a drag draws with the tool's stored options, which keep to their types and range.
#[test]
fn a_grid_drag_uses_the_stored_options() {
let mut t = FamilyTool::new("polarGrid");
for (key, value) in [
("concentric", json!(2)),
("concentricSkew", json!(-40)),
("radialSkew", json!(9000)),
("compound", json!(true)),
("fill", json!(true)),
// Not this tool's, and not values: ignored.
("frame", json!("no")),
("concentricSkew", json!("far")),
("fill", json!(null)),
] {
t.set_option(key, &value);
}
let options = json!({"concentric": 2, "radial": 5, "concentricSkew": -40.0, "radialSkew": 500.0, "compound": true, "fill": true});
assert_eq!(t.options(), options);
let (command, params) = t.command(Point::new(10.0, 20.0), Point::new(40.0, 80.0), Mods::default());
assert_eq!(command, "shape.polarGrid");
let mut want = options;
want.as_object_mut().unwrap().extend(json!({"x": 10.0, "y": 20.0, "width": 30.0, "height": 60.0}).as_object().unwrap().clone());
assert_eq!(params, want);
let mut t = FamilyTool::new("rectangularGrid");
t.set_option("frame", &json!(false));
t.set_option("rowSkew", &json!(12.5));
assert_eq!(t.options(), json!({"rows": 5, "columns": 5, "rowSkew": 12.5, "columnSkew": 0.0, "frame": false, "fill": false}));
}
#[test]
fn click_opens_dialog_and_arc_shift() {
let (d, _) = doc_with_rect();

View File

@ -3,8 +3,8 @@
//! Guides, or with them off View › Snap to Point, snap it),
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
//! drag a live rectangle's or polygon's corner widget to round its corners (Alt-click cycles their kind,
//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To
//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
//! double-click opens the Corners dialog), double-click a group or an object to enter isolation
//! mode (Double Click To Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
//! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path
//! ([`crate::pathtype`]), double-click the type widget to convert point type to area type and back
//! ([`crate::typewidget`]).
@ -260,12 +260,19 @@ impl Tool for SelectionTool {
}
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
let top = h.top_object(cx.isolation);
if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) {
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
}
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) {
return vec![Action::SwitchTool("type".into())];
}
// A group, a compound path or a single path, image, mesh or placed file, not
// the one already isolated. Blends, envelopes, repeats and symbol instances
// aren't: they have editing modes of their own.
use vectorcraft_doc::NodeKind as K;
let isolates = cx.doc.node(top).is_some_and(|n| {
matches!(n.kind, K::Group { .. } | K::Compound { .. } | K::Path { .. } | K::Image(_) | K::Mesh(_) | K::PlacedDocument(_))
});
if cx.double_click_isolate && isolates && cx.isolation != Some(top) {
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
}
} else if cx.isolation.is_some() {
return vec![Action::Exec("object.exitIsolation".into(), json!({}))];
}

View File

@ -27,8 +27,8 @@ const ROWS: &[Row] = &[
(&["puppetWarp"], None, &["showMesh", "expand"]),
(&["arc"], None, &["closed"]),
(&["spiral"], None, &["decay", "segments", "clockwise"]),
(&["rectangularGrid"], None, &["rows", "columns"]),
(&["polarGrid"], None, &["concentric", "radial"]),
(&["rectangularGrid"], None, &["rows", "columns", "rowSkew", "columnSkew", "frame", "fill"]),
(&["polarGrid"], None, &["concentric", "radial", "concentricSkew", "radialSkew", "compound", "fill"]),
(&["pencil", "paintbrush"], None, &["fidelity", "fill", "editWithin", "closeWithin"]),
(&["smooth"], None, &["fidelity"]),
(&["blobBrush", "eraser"], None, &["size"]),

View File

@ -237,6 +237,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let key = CacheKey {
doc: st.uid as usize,
revision: st.revision,
interacting: st.interaction.is_some(),
zoom: v.zoom,
cx: v.center.x,
cy: v.center.y,
@ -273,6 +274,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.canvas.key = Some(done.key);
app.perf.render_ms = done.ms;
app.canvas.last_ms = done.ms;
app.canvas.moved = done.moved > 0;
}
if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() {
let (w, h, view) = match pixel {
@ -307,13 +309,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
isolated: key.isolated.map(vectorcraft_doc::NodeId),
// Images far smaller on screen than they are sample their mip levels (#1244).
image_mips: true,
// During a drag or a dialog's preview, objects that only moved draw their raster
// effects from the cached rasters of an earlier frame (#1257).
interacting: key.interacting,
rich_black: key.rich_black,
..opts
};
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
// A document switch renders synchronously so another document's frame is never shown.
// The first frame after an interaction, when the frame shown drew moved rasters, also goes
// to the worker.
let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc);
let heavy = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some() && same_doc;
let settling = app.canvas.moved && !key.interacting;
let heavy = (app.canvas.last_ms > 8.0 || settling) && app.canvas.texture.is_some() && same_doc;
match (&mut app.canvas.worker, heavy) {
(Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }),
_ => {
@ -323,6 +331,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.canvas.key = Some(key.clone());
app.perf.render_ms = now_ms() - t0;
app.canvas.last_ms = app.perf.render_ms;
app.canvas.moved = app.canvas.renderer.stats.moved > 0;
}
}
}
@ -2882,6 +2891,91 @@ mod tests {
assert!(partial(&alphas(&mut app).expect("re-rendered")) > 50, "smooth again");
}
/// A drag of an object with raster effects (#1257): the drag's frames render while
/// interacting and draw the object's drop shadow and filtered content from the rasters of the
/// frame before; the frame after the drag renders without `interacting`, on the render worker,
/// and draws what a fresh renderer draws.
#[test]
fn dragging_an_object_with_raster_effects_draws_its_rasters_moved() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.canvas.worker_started = true; // render on this thread
let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone();
for effect in ["stylize.dropShadow", "texture.craquelure"] {
app.run("effect.apply", json!({"effect": effect, "ids": [id]})).unwrap();
}
let ctx = egui::Context::default();
// The art's raster uploaded by one frame, None when it was not re-rendered.
let upload = |app: &mut VectorcraftApp| -> Option<Vec<Color32>> {
let (_, mut delta) = frame_output(app, &ctx);
let art = app.canvas.texture.as_ref().unwrap().id();
let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
let egui::ImageData::Color(img) = &d.image;
img.pixels.clone()
});
delta.clear();
px
};
upload(&mut app).expect("the first frame renders the art");
app.select_tool("selection");
let view = app.view_info();
app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap();
for step in [1.0, 2.0, 3.0] {
app.session.pointer(&PointerEvent::new(PointerKind::Drag, 160.0 + 7.3 * step, 125.0 + 2.1 * step), view).unwrap();
upload(&mut app).expect("each frame of the drag renders");
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "step {step}");
assert_eq!(app.canvas.renderer.stats.moved, 2, "the shadow and the filtered content, step {step}");
assert!(app.canvas.moved);
}
// The frame after the drag runs the effects again: on the worker, while the canvas shows
// the drag's last frame.
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
app.session.pointer(&PointerEvent::new(PointerKind::Up, 181.9, 131.3), view).unwrap();
assert!(upload(&mut app).is_none(), "not rendered on this thread");
assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| !k.interacting), "sent to the worker");
let until = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut after = None;
while after.is_none() && std::time::Instant::now() < until {
std::thread::sleep(std::time::Duration::from_millis(5));
after = upload(&mut app);
}
let after = after.expect("the worker's frame arrives");
assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting));
assert!(!app.canvas.moved);
app.canvas.worker = None;
app.canvas.renderer = vectorcraft_render::Renderer::new();
app.canvas.key = None;
assert!(upload(&mut app) == Some(after), "the frame after the drag is what a fresh renderer draws");
}
/// The frame after an interaction renders without `interacting` also when the interaction
/// leaves the document as it was (#1257): here a drag back to where it started.
#[test]
fn a_drag_back_to_its_start_renders_again_after_it() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.canvas.worker_started = true; // render on this thread
let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone();
app.run("effect.apply", json!({"effect": "texture.craquelure", "ids": [id]})).unwrap();
let ctx = egui::Context::default();
shapes(&mut app, &ctx);
app.select_tool("selection");
let view = app.view_info();
app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap();
for (x, y) in [(173.3, 129.1), (160.0, 125.0)] {
app.session.pointer(&PointerEvent::new(PointerKind::Drag, x, y), view).unwrap();
shapes(&mut app, &ctx);
assert_eq!(app.canvas.renderer.stats.moved, 1, "the filtered content at ({x}, {y})");
}
let revision = app.session.active().unwrap().revision;
app.session.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 125.0), view).unwrap();
assert_eq!(app.session.active().unwrap().revision, revision, "the drag left the document as it was");
shapes(&mut app, &ctx);
assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting), "rendered again");
assert_eq!(app.canvas.renderer.stats.moved, 0);
assert!(!app.canvas.moved);
}
/// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel
/// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the
/// canvas always does out of Pixel Preview.

View File

@ -67,13 +67,25 @@ fn order(kind: &str) -> &'static [&'static str] {
match kind {
"offsetPath" => &["offset", "joins", "miterLimit"],
"splitIntoGrid" => &["rows", "columns", "gutter"],
"rectangularGrid" => &["width", "height", "rows", "columns"],
"polarGrid" => &["width", "height", "concentric", "radial"],
"rectangularGrid" => &["width", "height", "rows", "rowSkew", "columns", "columnSkew"],
"polarGrid" => &["width", "height", "concentric", "concentricSkew", "radial", "radialSkew"],
"artboardOptions" => &["name", "width", "height"],
_ => &[],
}
}
/// The form dialog ([`grid`]) fields that are a grid tool's divider skew, a slider in percent: the
/// words at the two ends of its rail (where the dividers crowd to), in the UI language.
fn skew_ends(kind: &str, key: &str) -> Option<(&'static str, &'static str)> {
match (kind, key) {
("rectangularGrid", "rowSkew") => Some((tl!("Bottom"), tl!("Top"))),
// The radial dividers gather on that side of the top one.
("rectangularGrid", "columnSkew") | ("polarGrid", "radialSkew") => Some((tl!("Left"), tl!("Right"))),
("polarGrid", "concentricSkew") => Some((tl!("Inside"), tl!("Outside"))),
_ => None,
}
}
/// The choices of a form dialog's ([`grid`]) dropdown field, as (value, label in the UI
/// language); empty for a field typed as text.
fn choices(kind: &str, key: &str) -> Vec<(&'static str, &'static str)> {
@ -163,8 +175,10 @@ fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, options: &
}
/// The generic dialog body: a field per non-boolean value (positions, indices and UI-only keys
/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns.
/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns and the
/// [`skew_ends`] fields as sliders.
pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
let rail = Tokens::get(ui.ctx()).input_border;
let (lengths, first) = (lengths(&d.kind), order(&d.kind));
let mut keys: Vec<String> = d
.fields
@ -179,6 +193,14 @@ pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
let options = choices(&d.kind, &k);
if lengths.contains(&k.as_str()) {
length_field(ui, d, &k, &humanized(&k), unit);
} else if let Some(ends) = skew_ends(&d.kind, &k) {
let max = vectorcraft_geom::shapes::GRID_SKEW_MAX;
row_label(ui, tl!("Skew:"));
ui.vertical(|ui| {
ui.horizontal(|ui| slider_field(ui, d, &k, -max..=max, "%", &|_| rail));
rail_ends(ui, ends);
});
ui.end_row();
} else if options.is_empty() {
let label = match (d.kind.as_str(), k.as_str()) {
("rectangularGrid", "rows") => tl!("Horizontal Dividers:").into(),
@ -465,17 +487,22 @@ pub(super) fn slider_rail(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: s
/// The words at the two ends of a slider's rail (Less … More), under a slider whose label column
/// is `label_w` wide.
pub(super) fn slider_ends(ui: &mut egui::Ui, label_w: f32, (left, right): (&str, &str)) {
let t = Tokens::get(ui.ctx());
pub(super) fn slider_ends(ui: &mut egui::Ui, label_w: f32, ends: (&str, &str)) {
ui.horizontal(|ui| {
ui.add_space(label_w + ui.spacing().item_spacing.x);
let (r, _) = ui.allocate_exact_size(egui::vec2(SLIDER_WIDTH, 14.0), egui::Sense::hover());
let font = egui::FontId::proportional(11.0);
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, left, font.clone(), t.text_dim);
ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, right, font, t.text_dim);
rail_ends(ui, ends);
});
}
/// The words of [`slider_ends`] under a rail that starts where the next widget does.
fn rail_ends(ui: &mut egui::Ui, (left, right): (&str, &str)) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(egui::vec2(SLIDER_WIDTH, 14.0), egui::Sense::hover());
let font = egui::FontId::proportional(11.0);
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, left, font.clone(), t.text_dim);
ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, right, font, t.text_dim);
}
/// The field where [`preview`] keeps the parameters it last previewed.
pub(super) const PREVIEWED: &str = "__previewed";

View File

@ -412,8 +412,8 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room)));
modal::heading(ui, &heading);
ui.add_space(12.0);
// Just opened: its first field takes the keyboard focus, as in the reference app (type a
// value, press Enter). Only the body's fields can take it.
// Just opened: its first enabled field takes the keyboard focus, as in the reference app
// (type a value, press Enter). Only the body's fields can take it.
if focus_first {
ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true));
}

View File

@ -1,13 +1,13 @@
//! Shape size dialogs (a click with a shape tool): Rectangle, Rounded Rectangle, Ellipse, Polygon,
//! Star and Line Segment Tool Options.
//! Star, Line Segment Tool Options and the two grids (size, divider counts and skews, switches).
use serde_json::{Value, json};
use super::{DialogSpec, run_and_close};
use super::{DialogSpec, form, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, ..DialogSpec::FORM };
fn title(kind: &str) -> &'static str {
match kind {
@ -22,6 +22,26 @@ fn title(kind: &str) -> &'static str {
}
}
/// The grids' divider counts and skews: (rows / concentric, columns / radial).
fn grid_keys(kind: &str) -> ([&'static str; 2], [&'static str; 2]) {
if kind == "polarGrid" { (["concentric", "radial"], ["concentricSkew", "radialSkew"]) } else { (["rows", "columns"], ["rowSkew", "columnSkew"]) }
}
/// The form's fields, then a grid's switches.
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::grid(ui, d, app.session.general_unit());
let own = match d.kind.as_str() {
"rectangularGrid" => ("frame", tl!("Use Outside Rectangle As Frame")),
"polarGrid" => ("compound", tl!("Create Compound Path From Ellipses")),
_ => return false,
};
ui.add_space(8.0);
for (key, label) in [own, ("fill", tl!("Fill Grid"))] {
form::check(ui, d, key, label);
}
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0));
let (id, params) = match d.kind.as_str() {
@ -31,16 +51,18 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
"shape.star",
json!({"cx": x, "cy": y, "radius1": d.f64("radius1", 50.0), "radius2": d.f64("radius2", 25.0), "points": d.f64("points", 5.0) as u64}),
),
"rectangularGrid" => (
"shape.rectangularGrid",
json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0),
"rows": (d.f64("rows", 5.0) as u64).min(999), "columns": (d.f64("columns", 5.0) as u64).min(999)}),
),
"polarGrid" => (
"shape.polarGrid",
json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0),
"concentric": (d.f64("concentric", 5.0) as u64).min(999), "radial": (d.f64("radial", 5.0) as u64).min(999)}),
),
kind @ ("rectangularGrid" | "polarGrid") => {
// The switches as they are; the typed fields as numbers.
let (mut p, (counts, skews)) = (form::params(d), grid_keys(kind));
for (k, default) in [("width", 100.0), ("height", 100.0)] {
p[k] = json!(d.f64(k, default));
}
for (count, skew) in counts.into_iter().zip(skews) {
p[count] = json!((d.f64(count, 5.0) as u64).min(999));
p[skew] = json!(d.f64(skew, 0.0));
}
(if kind == "polarGrid" { "shape.polarGrid" } else { "shape.rectangularGrid" }, p)
}
"lineSegment" => {
let (l, a) = (d.f64("length", 100.0), d.f64("angle", 0.0).to_radians());
("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()}))

View File

@ -566,6 +566,37 @@ fn a_dialog_opened_by_a_click_focuses_its_first_field_each_time_it_opens() {
}
}
/// A greyed-out field can't hold the keyboard focus, so it doesn't take a just-opened dialog's
/// either: the focus waits for the first enabled field. Blend Options on Smooth Color shows its
/// steps greyed out; choosing Specified Steps enables the field and it takes the focus (type a
/// count, press Enter).
#[test]
fn a_dialogs_focus_skips_a_greyed_out_first_field() {
let mut app = app();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
};
let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
app.run("ui.blendOptions", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("spacing".into(), json!("smooth"));
for _ in 0..3 {
frame(&mut app, vec![]);
}
assert!(ctx.memory(|m| m.focused()).is_none(), "nothing to type in yet");
app.ui.dialog.as_mut().unwrap().fields.insert("spacing".into(), json!("steps"));
frame(&mut app, vec![]);
assert!(ctx.memory(|m| m.focused()).is_some(), "the steps field takes the focus once it is enabled");
frame(&mut app, vec![egui::Event::Text("7".into())]);
frame(&mut app, vec![enter]);
assert!(app.ui.dialog.is_none());
let info = app.run("object.blend.info", json!({})).unwrap();
assert_eq!((info["spacing"].clone(), info["steps"].clone()), (json!("steps"), json!(7)), "{info}");
}
/// #1000: a count typed in Object › Create Object Mosaic… reaches the command (a whole number typed
/// in a command's dialog is an integer), so the mosaic has that many columns, not the 10 × 10 grid.
#[test]
@ -729,6 +760,54 @@ fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() {
assert_eq!(app.session.doc().unwrap().doc, made);
}
/// #1377: Polar Grid's dialog has a skew slider under each divider count, Create Compound Path
/// From Ellipses and Fill Grid, opened with the tool's stored values; OK draws with them and
/// stores them.
#[test]
fn polar_grid_dialog_edits_skews_compound_and_fill() {
use vectorcraft_doc::{NodeId, NodeKind};
let mut app = app();
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
app.run("tool.setOption", json!({"tool": "polarGrid", "values": {"concentric": 3, "radial": 4, "radialSkew": 500}})).unwrap();
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
let labels = [
"Concentric Dividers:",
"Skew:",
"Inside",
"Outside",
"Radial Dividers:",
"Skew:",
"Left",
"Right",
"Create Compound Path From Ellipses",
"Fill Grid",
];
let mut from = 0;
for label in labels {
from += text[from..].find(&format!("{label}\n")).unwrap_or_else(|| panic!("no {label:?} in order in {text}")) + label.len();
}
assert!(!text.contains("Use Outside Rectangle As Frame"), "{text}");
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.fields["concentricSkew"].clone(), d.fields["radialSkew"].clone()), (json!(0.0), json!(500.0)));
assert_eq!((d.fields["compound"].clone(), d.fields["fill"].clone()), (json!(false), json!(false)));
for (key, value) in [("concentricSkew", json!(100)), ("compound", json!(true)), ("fill", json!(true))] {
d.fields.insert(key.into(), value);
}
let result = confirm(&mut app).unwrap();
let s = app.session.doc().unwrap();
let children = s.doc.node(NodeId(result["id"].as_u64().unwrap())).unwrap().children().unwrap();
// Two rings under four radial dividers, all in the current fill.
assert_eq!(children.iter().map(|n| matches!(n.kind, NodeKind::Compound { .. })).collect::<Vec<_>>(), [true, true, false, false, false, false]);
assert!(children.iter().all(|n| n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None)));
let widths: Vec<f64> = children[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap().width()).collect();
assert!((widths[0] - 100.0).abs() < 1e-6 && (widths[1] - 93.75).abs() < 1e-6, "outward: {widths:?}");
let tips: Vec<f64> = children[3..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[1].p.x).collect();
assert!(tips.iter().all(|x| *x > 60.0), "right of the top divider: {tips:?}");
let saved = json!({"concentric": 3, "radial": 4, "concentricSkew": 100.0, "radialSkew": 500.0, "compound": true, "fill": true});
assert_eq!(app.session.tool_options_of("polarGrid"), saved);
}
#[test]
fn polar_grid_shared_cancel_reopen_zero_counts_and_failed_creation() {
let mut app = app();

View File

@ -85,11 +85,17 @@ fn assert_path(path: &PathData, expected: &[Point], closed: bool) {
}
}
/// Independent coordinate oracle: counts mean interior dividers, plus one closed outer frame.
/// Independent coordinate oracle: one closed outer frame at the bottom, then the interior
/// dividers the counts mean.
fn assert_grid(app: &VectorcraftApp, id: NodeId, bounds: Rect, rows: u32, columns: u32) {
let paths = paths(app, id);
assert_eq!(paths.len(), (rows + columns + 1) as usize);
let mut remaining = paths.iter();
assert_path(
remaining.next().unwrap(),
&[Point::new(bounds.x0, bounds.y0), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)],
true,
);
for i in 1..=rows {
let y = bounds.y0 + bounds.height() * f64::from(i) / f64::from(rows + 1);
assert_path(remaining.next().unwrap(), &[Point::new(bounds.x0, y), Point::new(bounds.x1, y)], false);
@ -98,11 +104,6 @@ fn assert_grid(app: &VectorcraftApp, id: NodeId, bounds: Rect, rows: u32, column
let x = bounds.x0 + bounds.width() * f64::from(i) / f64::from(columns + 1);
assert_path(remaining.next().unwrap(), &[Point::new(x, bounds.y0), Point::new(x, bounds.y1)], false);
}
assert_path(
remaining.next().unwrap(),
&[Point::new(bounds.x0, bounds.y0), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)],
true,
);
assert!(remaining.next().is_none());
}
@ -196,7 +197,8 @@ fn rectangular_grid_creates_exact_dividers_as_one_undo_redo_step() {
assert_eq!(app.session.doc().unwrap().doc, before.doc);
assert_eq!(app.session.doc().unwrap().selection.objects, before.selected);
assert_eq!(app.session.doc().unwrap().history.undo.len(), before.undo);
assert_eq!(app.session.tool_options_of(KIND), json!({"rows": 2, "columns": 3}), "undo does not undo the saved tool settings");
let saved = json!({"rows": 2, "columns": 3, "rowSkew": 0.0, "columnSkew": 0.0, "frame": true, "fill": false});
assert_eq!(app.session.tool_options_of(KIND), saved, "undo does not undo the saved tool settings");
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, created);
assert_grid(&app, id, Rect::new(125.0, 75.0, 245.0, 135.0), 2, 3);
@ -273,16 +275,16 @@ fn rectangular_grid_perspective_click_projects_each_anchor_and_is_one_undo_step(
values(&mut app, json!(96), json!(54), json!(1), json!(2));
let id = confirm(&mut app);
let expected = [
vec![at, Point::new(at.x + 96.0, at.y), Point::new(at.x + 96.0, at.y + 54.0), Point::new(at.x, at.y + 54.0)],
vec![Point::new(at.x, at.y + 27.0), Point::new(at.x + 96.0, at.y + 27.0)],
vec![Point::new(at.x + 32.0, at.y), Point::new(at.x + 32.0, at.y + 54.0)],
vec![Point::new(at.x + 64.0, at.y), Point::new(at.x + 64.0, at.y + 54.0)],
vec![at, Point::new(at.x + 96.0, at.y), Point::new(at.x + 96.0, at.y + 54.0), Point::new(at.x, at.y + 54.0)],
];
let actual = paths(&app, id);
assert_eq!(actual.len(), expected.len());
for (index, (path, points)) in actual.iter().zip(expected).enumerate() {
let projected: Vec<Point> = points.into_iter().map(|p| map.apply(p).unwrap()).collect();
assert_path(path, &projected, index == 3);
assert_path(path, &projected, index == 0);
}
let state = app.session.doc().unwrap();
assert_eq!(PerspectiveGrid::effective(&state.doc).attachment_of(&state.doc, id), Some((Plane::Right, 0.0)));
@ -311,3 +313,63 @@ fn rectangular_grid_failed_creation_does_not_save_counts() {
assert_eq!(app.session.tool_options_of(KIND), before.options);
assert_eq!(app.session.doc().unwrap().history.undo.len(), before.undo);
}
/// #1377: the dialog shows the tool's stored skews (sliders under their divider counts) and
/// switches; OK draws with them and stores them, and the next drag uses them too.
#[test]
fn rectangular_grid_dialog_edits_skews_frame_and_fill() {
let mut app = app();
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
app.run("tool.setOption", json!({"tool": KIND, "values": {"rowSkew": 100, "frame": false}})).unwrap();
click(&mut app, Point::new(10.0, 20.0));
assert_eq!((field(&app, "rowSkew"), field(&app, "columnSkew")), (json!(100.0), json!(0.0)));
assert_eq!((field(&app, "frame"), field(&app, "fill")), (json!(false), json!(false)));
let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::show(app, ui.ctx()));
let labels = [
"Horizontal Dividers:",
"Skew:",
"Bottom",
"Top",
"Vertical Dividers:",
"Skew:",
"Left",
"Right",
"Use Outside Rectangle As Frame",
"Fill Grid",
];
let mut from = 0;
for label in labels {
from += text[from..].find(&format!("{label}\n")).unwrap_or_else(|| panic!("no {label:?} in order in {text}")) + label.len();
}
assert!(!text.contains("Row Skew") && !text.contains("false"), "the skews and switches aren't plain fields: {text}");
values(&mut app, json!(100), json!(100), json!(3), json!(1));
set(&mut app, "columnSkew", json!("-250"));
set(&mut app, "fill", json!(true));
let id = confirm(&mut app);
let paths = paths(&app, id);
// No frame: four sides, then the dividers, crowded toward the top (100 %: 1/16, 1/4 and 9/16 down).
assert_eq!(paths.len(), 8);
assert!(paths.iter().all(|p| !p.is_closed()));
for (path, y) in paths[4..7].iter().zip([6.25, 25.0, 56.25]) {
assert_path(path, &[Point::new(10.0, 20.0 + y), Point::new(110.0, 20.0 + y)], false);
}
let x = paths[7].subpaths[0].anchors[0].p.x;
assert!(x > 10.0 && x < 30.0, "toward the left: {x}");
let saved = json!({"rows": 3, "columns": 1, "rowSkew": 100.0, "columnSkew": -250.0, "frame": false, "fill": true});
assert_eq!(app.session.tool_options_of(KIND), saved);
// A drag draws with the same options.
pointer(&mut app, PointerKind::Down, Point::new(300.0, 300.0));
pointer(&mut app, PointerKind::Drag, Point::new(400.0, 400.0));
pointer(&mut app, PointerKind::Up, Point::new(400.0, 400.0));
let dragged = app.session.doc().unwrap().selection.objects[0];
assert_ne!(dragged, id);
let doc = &app.session.doc().unwrap().doc;
let offset =
|id: NodeId| doc.node(id).unwrap().children().unwrap().iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[0].p).collect::<Vec<_>>();
for (a, b) in offset(id).iter().zip(offset(dragged)) {
assert!((b.x - a.x - 290.0).abs() < 1e-9 && (b.y - a.y - 280.0).abs() < 1e-9, "{a:?} {b:?}");
}
assert!(
doc.node(dragged).unwrap().children().unwrap().iter().all(|n| n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None))
);
}

View File

@ -16,16 +16,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
"polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})),
"star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})),
"lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})),
"rectangularGrid" => {
let options = app.session.tool_options_of(kind);
Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "rows": options["rows"], "columns": options["columns"]}))
}
"polarGrid" => {
let options = app.session.tool_options_of(kind);
Dialog::new(
kind,
json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "concentric": options["concentric"], "radial": options["radial"]}),
)
// The grids: the tool's stored options (divider counts and skews, switches) at the default size.
"rectangularGrid" | "polarGrid" => {
let mut fields = app.session.tool_options_of(kind);
for (key, value) in [("x", json!(x)), ("y", json!(y)), ("width", json!("100 pt")), ("height", json!("100 pt"))] {
fields[key] = value;
}
Dialog::new(kind, fields)
}
// Graph tool click: the graph's size.
"graph" => Dialog::new(

View File

@ -4129,6 +4129,7 @@ effect Regular Pravidelný
@msg an image VectorCraft can't read was left out obrázek, který VectorCraft neumí přečíst, byl vynechán
@msg an image whose data is cut short was left out obrázek s useknutými daty byl vynechán
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel propojené obrázky se zobrazují jako náhled v nízkém rozlišení uložený v souboru, dokud se nenajdou jejich soubory: znovu je propojte v panelu Propojení
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise text v oblasti v rámcích příliš nízkých pro řádek písma, ve kterém se zde zobrazuje, dostal vyšší rámce ({_1}): jinak by nebyl vidět
@msg its editing data doesn't say where its art is z jeho dat pro úpravy není zřejmé, kde je kresba
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) tento soubor byl uložen bez kompatibility s PDF a jeho data pro úpravy nelze přečíst ({why})
@msg No fonts are missing in this document V tomto dokumentu nechybí žádná písma
@ -4501,3 +4502,10 @@ mark-style Japanese Japonské
@msg the file stopped being read (please report this bug) čtení souboru se přerušilo (nahlaste prosím tuto chybu)
Concentric Dividers: Soustředné dělicí čáry:
Radial Dividers: Radiální dělicí čáry:
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: Zkosení:
Inside Uvnitř
Outside Vně
Use Outside Rectangle As Frame Použít vnější obdélník jako rámeček
Create Compound Path From Ellipses Vytvořit složenou cestu z elips
Fill Grid Vyplnit mřížku

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3844,6 +3844,7 @@ theme Appearance Erscheinungsbild
@msg an image VectorCraft can't read was left out ein Bild, das VectorCraft nicht lesen kann, wurde ausgelassen
@msg an image whose data is cut short was left out ein Bild mit abgeschnittenen Daten wurde ausgelassen
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel verknüpfte Bilder zeigen die in der Datei gespeicherte Vorschau in niedriger Auflösung, bis ihre Dateien gefunden werden: Verknüpfen Sie sie im Bedienfeld „Verknüpfungen“ neu
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise Flächentext in Rahmen, die für eine Zeile der Schrift, in der er hier erscheint, zu niedrig sind, hat höhere Rahmen bekommen ({_1}): Sonst wäre der Text nicht zu sehen
@msg its editing data doesn't say where its art is seine Bearbeitungsdaten geben nicht an, wo seine Grafik liegt
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) diese Datei wurde ohne PDF-Kompatibilität gespeichert, und ihre Bearbeitungsdaten können nicht gelesen werden ({why})
@msg No fonts are missing in this document In diesem Dokument fehlen keine Schriften
@ -4152,3 +4153,10 @@ theme Appearance Erscheinungsbild
@msg the file stopped being read (please report this bug) das Lesen der Datei wurde abgebrochen (bitte melden Sie diesen Fehler)
Concentric Dividers: Konzentrische Trennlinien:
Radial Dividers: Radiale Trennlinien:
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: Neigung:
Inside Innen
Outside Außen
Use Outside Rectangle As Frame Äußeres Rechteck als Rahmen verwenden
Create Compound Path From Ellipses Zusammengesetzten Pfad aus Ellipsen erstellen
Fill Grid Raster füllen

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3944,6 +3944,7 @@ axis Both Ambos
@msg an image VectorCraft can't read was left out se omitió una imagen que VectorCraft no puede leer
@msg an image whose data is cut short was left out se omitió una imagen con datos incompletos
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel las imágenes vinculadas muestran la vista previa de baja resolución guardada en el archivo hasta que se encuentren sus archivos: vuelva a vincularlas en el panel Vínculos
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise el texto de área en marcos demasiado bajos para una línea de la fuente en que se muestra recibió marcos más altos ({_1}): de otro modo no se vería
@msg its editing data doesn't say where its art is sus datos de edición no dicen dónde está su ilustración
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) este archivo se guardó sin compatibilidad con PDF y sus datos de edición no se pueden leer ({why})
@msg No fonts are missing in this document No faltan fuentes en este documento
@ -4195,6 +4196,13 @@ lighting Light Luz
@msg the file stopped being read (please report this bug) se dejó de leer el archivo (informe de este error)
Concentric Dividers: Divisiones concéntricas:
Radial Dividers: Divisiones radiales:
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: Sesgo:
Inside Dentro
Outside Fuera
Use Outside Rectangle As Frame Usar rectángulo exterior como marco
Create Compound Path From Ellipses Crear trazado compuesto a partir de elipses
Fill Grid Rellenar cuadrícula
# Rows other catalogs already had: document and print presets, keyboard-shortcut editor, print and
# PDF options, arrowheads, harmony rules, Save for Web, graphic styles and file filters.

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3941,6 +3941,7 @@ axis Both Les deux
@msg an image VectorCraft can't read was left out une image que VectorCraft ne peut pas lire a été ignorée
@msg an image whose data is cut short was left out une image aux données tronquées a été ignorée
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel les images liées affichent l’aperçu basse résolution enregistré dans le fichier tant que leurs fichiers ne sont pas trouvés : liez-les à nouveau dans le panneau Liens
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise le texte captif dans des cadres trop bas pour une ligne de la police dans laquelle il s’affiche a reçu des cadres plus hauts ({_1}) : il ne s’afficherait pas autrement
@msg its editing data doesn't say where its art is ses données d’édition ne disent pas où se trouve son illustration
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) ce fichier a été enregistré sans compatibilité PDF et ses données d’édition sont illisibles ({why})
@msg No fonts are missing in this document Aucune police ne manque dans ce document
@ -4205,3 +4206,10 @@ lighting Light Lumière
@msg the file stopped being read (please report this bug) la lecture du fichier s’est interrompue (veuillez signaler ce bogue)
Concentric Dividers: Divisions concentriques :
Radial Dividers: Divisions radiales :
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: Inclinaison :
Inside Intérieur
Outside Extérieur
Use Outside Rectangle As Frame Utiliser le rectangle extérieur comme cadre
Create Compound Path From Ellipses Créer un tracé transparent à partir des ellipses
Fill Grid Remplir la grille

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -4028,6 +4028,7 @@ theme Appearance Kinézet
@msg an image VectorCraft can't read was left out kimaradt egy kép, amelyet a VectorCraft nem tud olvasni
@msg an image whose data is cut short was left out kimaradt egy csonka adatú kép
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel a csatolt képek a fájlban mentett kis felbontású előnézetet mutatják, amíg a fájljaik elő nem kerülnek: csatolja őket újra a Csatolások panelen
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise a túl alacsony keretben lévő területszöveg, amelybe a megjelenítő betűtípus egy sora sem fér el, magasabb keretet kapott ({_1}): különben nem látszana
@msg an object can't be read egy objektum nem olvasható
@msg an object in perspective would leave the edited grid egy perspektivikus objektum kikerülne a szerkesztett rácsból
@msg anchor must name a registered panel az anchor egy regisztrált panelt kell megnevezzen
@ -4595,3 +4596,10 @@ theme Appearance Kinézet
@msg the file stopped being read (please report this bug) a fájl beolvasása megszakadt (kérjük, jelentsd ezt a hibát)
Concentric Dividers: Koncentrikus osztók:
Radial Dividers: Sugárirányú osztók:
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: Ferdítés:
Inside Belül
Outside Kívül
Use Outside Rectangle As Frame Külső téglalap használata keretként
Create Compound Path From Ellipses Összetett útvonal létrehozása az ellipszisekből
Fill Grid Rács kitöltése

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3944,6 +3944,7 @@ axis Both Entrambi
@msg an image VectorCraft can't read was left out un'immagine che VectorCraft non può leggere è stata omessa
@msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel le immagini collegate mostrano l'anteprima a bassa risoluzione salvata nel file finché i loro file non vengono trovati: ricollegale nel pannello Collegamenti
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise il testo in area in cornici troppo basse per una riga del font in cui viene mostrato ha ricevuto cornici più alte ({_1}): altrimenti non sarebbe visibile
@msg its editing data doesn't say where its art is i suoi dati di modifica non dicono dove si trova la grafica
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) questo file è stato salvato senza compatibilità PDF e i suoi dati di modifica non si possono leggere ({why})
@msg No fonts are missing in this document In questo documento non mancano font
@ -4197,3 +4198,10 @@ lighting Light Luce
@msg the file stopped being read (please report this bug) la lettura del file si è interrotta (segnala questo errore)
Concentric Dividers: Divisori concentrici:
Radial Dividers: Divisori radiali:
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: Inclinazione:
Inside Interno
Outside Esterno
Use Outside Rectangle As Frame Usa rettangolo esterno come cornice
Create Compound Path From Ellipses Crea tracciato composto dalle ellissi
Fill Grid Riempi griglia

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3680,6 +3680,7 @@ theme Appearance 外観
@msg an image VectorCraft can't read was left out VectorCraft で読み込めない画像は除外されました
@msg an image whose data is cut short was left out データが途中で切れている画像は除外されました
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel リンク画像は、元のファイルが見つかるまで、ファイルに保存された低解像度のプレビューで表示されます:リンクパネルで再リンクしてください
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 表示に使われるフォントの 1 行に対して低すぎるフレーム内のエリア内文字は、フレームを高くしました ({_1}): そうしないと文字が表示されません
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it グラデーション「{name}」はファイル内で定義されていません: 代わりに単色を使用しています
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 塗りや線が複数ある、または効果やブラシを適用したオブジェクト {_1} 個は見た目のまま読み込まれました: アピアランスはまだ編集できません
@msg patterns nested too deeply are filled with mid-grey 入れ子が深すぎるパターンは中間のグレーで塗りつぶされています
@ -4191,3 +4192,10 @@ lighting Light ライト
@msg the file stopped being read (please report this bug) ファイルの読み込みが中断されました (このバグを報告してください)
Concentric Dividers: 同心円の分割線:
Radial Dividers: 放射状の分割線:
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: 歪み:
Inside 内側
Outside 外側
Use Outside Rectangle As Frame 外側の長方形をフレームとして使用
Create Compound Path From Ellipses 楕円形から複合パスを作成
Fill Grid グリッドを塗りつぶす

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3658,6 +3658,7 @@ theme Appearance 외관
@msg an image VectorCraft can't read was left out VectorCraft가 읽을 수 없는 이미지는 제외했습니다
@msg an image whose data is cut short was left out 데이터가 잘린 이미지는 제외했습니다
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 연결된 그림은 해당 파일을 찾을 때까지 파일에 저장된 저해상도 미리 보기로 표시됩니다: 링크 패널에서 다시 연결하세요
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 표시되는 글꼴의 한 줄보다 낮은 프레임의 영역 문자는 프레임을 더 높였습니다({_1}). 그렇지 않으면 문자가 표시되지 않습니다
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 그레이디언트 “{name}”이(가) 파일에 정의되어 있지 않아 단색으로 대체했습니다
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 칠이나 선이 여러 개인 개체, 효과, 브러시가 있는 개체는 그려진 모양({_1})으로 가져왔습니다: 모양새는 아직 편집할 수 없습니다
@msg patterns nested too deeply are filled with mid-grey 너무 깊게 중첩된 패턴은 중간 회색으로 채웁니다
@ -4161,3 +4162,10 @@ lighting Light 광원
@msg the file stopped being read (please report this bug) 파일 읽기가 중단되었습니다(이 버그를 신고해 주세요)
Concentric Dividers: 동심원 구분선:
Radial Dividers: 방사형 구분선:
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: 기울임:
Inside 안쪽
Outside 바깥쪽
Use Outside Rectangle As Frame 바깥쪽 사각형을 프레임으로 사용
Create Compound Path From Ellipses 원에서 컴파운드 패스 만들기
Fill Grid 격자 칠하기

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3679,3 +3679,10 @@ axis Both Ambos
@msg the file stopped being read (please report this bug) a leitura do arquivo foi interrompida (informe este erro)
Concentric Dividers: Divisórias concêntricas:
Radial Dividers: Divisórias radiais:
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: Inclinação:
Inside Dentro
Outside Fora
Use Outside Rectangle As Frame Usar Retângulo Externo como Moldura
Create Compound Path From Ellipses Criar Caminho Composto a partir das Elipses
Fill Grid Preencher Grade

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3940,6 +3940,7 @@ axis Both Оба
@msg an image VectorCraft can't read was left out изображение, которое VectorCraft не может прочитать, пропущено
@msg an image whose data is cut short was left out изображение с обрезанными данными пропущено
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel связанные изображения показываются как сохранённый в файле просмотр низкого разрешения, пока их файлы не найдены: восстановите связи на панели «Связи»
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise текст в области в рамках, слишком низких для строки шрифта, которым он отображается, получил более высокие рамки ({_1}): иначе он не был бы виден
@msg its editing data doesn't say where its art is в его данных редактирования не указано, где находится графика
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) этот файл сохранён без совместимости с PDF, и его данные редактирования не удаётся прочитать ({why})
@msg No fonts are missing in this document В этом документе нет отсутствующих шрифтов
@ -4193,3 +4194,10 @@ lighting Light Свет
@msg the file stopped being read (please report this bug) чтение файла прервалось (пожалуйста, сообщите об этой ошибке)
Concentric Dividers: Концентрические разделители:
Radial Dividers: Радиальные разделители:
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: Скос:
Inside Внутри
Outside Снаружи
Use Outside Rectangle As Frame Использовать внешний прямоугольник как рамку
Create Compound Path From Ellipses Создать составной контур из эллипсов
Fill Grid Залить сетку

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3928,6 +3928,7 @@ axis Both Обидві
@msg an image VectorCraft can't read was left out зображення, яке VectorCraft не може прочитати, пропущено
@msg an image whose data is cut short was left out зображення з обрізаними даними пропущено
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel пов’язані зображення показуються як збережений у файлі перегляд низької роздільності, доки їхні файли не знайдено: відновіть зв’язки на панелі «Зв’язки»
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise текст в області в рамках, занизьких для рядка шрифту, яким він показується, отримав вищі рамки ({_1}): інакше його не було б видно
@msg its editing data doesn't say where its art is у даних редагування не зазначено, де знаходиться графіка
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) цей файл збережений без сумісності з PDF, і його дані редагування не вдається прочитати ({why})
@msg No fonts are missing in this document У цьому документі немає відсутніх шрифтів
@ -4177,3 +4178,10 @@ lighting Light Світло
@msg the file stopped being read (please report this bug) читання файлу перервалося (будь ласка, повідомте про цю помилку)
Concentric Dividers: Концентричні розділювачі:
Radial Dividers: Радіальні розділювачі:
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: Скіс:
Inside Усередині
Outside Зовні
Use Outside Rectangle As Frame Використовувати зовнішній прямокутник як рамку
Create Compound Path From Ellipses Створити складений контур з еліпсів
Fill Grid Залити сітку

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -2937,6 +2937,7 @@
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 个链接图像已在磁盘上更改。要显示新版本吗?
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 个在磁盘上更改的链接文件
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 链接的图片在找到其文件之前显示文件中存储的低分辨率预览:请在“链接”面板中重新链接
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 框架高度不足以容纳所显示字体一行的区域文字已加高框架 ({_1}):否则文字不会显示
Update Modified Links 更新已修改的链接
Updated {} image(s) 已更新 {} 张图像
UPPERCASE 大写
@ -3789,3 +3790,10 @@ lighting Light 光源
Highlight Area 高光区域
Concentric Dividers: 同心分隔线:
Radial Dividers: 径向分隔线:
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: 倾斜:
Inside 内侧
Outside 外侧
Use Outside Rectangle As Frame 使用外部矩形作为框架
Create Compound Path From Ellipses 从椭圆形创建复合路径
Fill Grid 网格填色

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3030,6 +3030,7 @@
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 個連結影像已在磁碟上變更。要顯示新版本嗎?
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 個在磁碟上變更的連結檔案
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 連結的圖片在找到其檔案之前顯示檔案中儲存的低解析度預覽:請在「連結」面板中重新連結
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 框架高度不足以容納所顯示字體一行的區域文字已加高框架 ({_1}):否則文字不會顯示
Update Modified Links 更新已修改的連結
Updated {} image(s) 已更新 {} 張影像
UPPERCASE 全部大寫
@ -3570,3 +3571,10 @@ lighting Light 光源
Highlight Area 亮部範圍
Concentric Dividers: 同心分隔線:
Radial Dividers: 徑向分隔線:
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
Skew: 傾斜:
Inside 內側
Outside 外側
Use Outside Rectangle As Frame 使用外部矩形作為框架
Create Compound Path From Ellipses 從橢圓形建立複合路徑
Fill Grid 格線填色

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -267,6 +267,10 @@ pub struct CanvasCache {
pub texture: Option<egui::TextureHandle>,
pub key: Option<CacheKey>,
pub last_ms: f64,
/// The frame shown drew raster effects from rasters made before their objects moved
/// ([`vectorcraft_render::FrameStats::moved`]): the next frame outside an interaction runs
/// them again, on the worker when there is one.
pub moved: bool,
pub worker: Option<render_worker::Worker>,
pub worker_started: bool,
/// The slices as laid out for (document uid, revision): the canvas draws them every frame.
@ -291,6 +295,8 @@ pub type PrintTilingCache = ((u64, u64), std::sync::Arc<Vec<vectorcraft_pdf::Til
pub struct CacheKey {
pub doc: usize,
pub revision: u64,
/// An interaction is in progress ([`vectorcraft_render::RenderOptions::interacting`]).
pub interacting: bool,
pub zoom: f64,
pub cx: f64,
pub cy: f64,
@ -454,6 +460,7 @@ impl VectorcraftApp {
texture: None,
key: None,
last_ms: 0.0,
moved: false,
worker: None,
worker_started: false,
slices: None,

View File

@ -24,6 +24,8 @@ pub struct Done {
pub key: CacheKey,
pub img: Rendered,
pub ms: f64,
/// [`vectorcraft_render::FrameStats::moved`] of the frame.
pub moved: usize,
}
pub struct Worker {
@ -51,7 +53,7 @@ impl Worker {
}
let t0 = crate::now_ms();
let img = r.render(&job.doc, job.w, job.h, job.view, &job.opts);
if done_tx.send(Done { key: job.key, img, ms: crate::now_ms() - t0 }).is_err() {
if done_tx.send(Done { key: job.key, img, ms: crate::now_ms() - t0, moved: r.stats.moved }).is_err() {
break;
}
ctx.request_repaint();

View File

@ -280,16 +280,17 @@ fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) ->
}
/// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the
/// first field drawn takes it ([`take_dialog_focus`]).
/// first enabled field drawn takes it ([`take_dialog_focus`]).
pub(crate) fn dialog_focus_flag() -> egui::Id {
egui::Id::new("dialog-focus-first-field")
}
/// If field `id` (showing `text`) is the first field of a dialog that has just opened, give it the
/// keyboard focus with all of `text` selected: typing replaces the value and Enter applies it.
/// If field `id` (showing `text`) is the first enabled field of a dialog that has just opened, give
/// it the keyboard focus with all of `text` selected: typing replaces the value and Enter applies it.
pub(crate) fn take_dialog_focus(ui: &Ui, id: egui::Id, text: &str) {
// A new window's first frame only measures its contents: nothing can take focus yet.
if ui.is_sizing_pass() || ui.data_mut(|d| d.remove_temp::<bool>(dialog_focus_flag())).is_none() {
// A new window's first frame only measures its contents: nothing can take focus yet. Nor can a
// greyed-out field (egui drops its focus): the flag stays for the first field that is enabled.
if ui.is_sizing_pass() || !ui.is_enabled() || ui.data_mut(|d| d.remove_temp::<bool>(dialog_focus_flag())).is_none() {
return;
}
focus_field(ui, id, text);

View File

@ -1,6 +1,8 @@
# Control protocol
`vectorcraft --control <port>` listens on `127.0.0.1:<port>` (loopback only). One JSON request per line:
`vectorcraft --control <port>` listens on `127.0.0.1:<port>` (loopback only). The port must be a number
from 1 to 65535 (`--control=<port>` works too), given once; otherwise the app prints an error and exits
before it starts. One JSON request per line:
`{"id": 1, "method": "ui.inspect", "params": {}}` → `{"id": 1, "ok": true, "result": {...}}` or `{"id":1,"ok":false,"error":"..."}`.
Set `VECTORCRAFT_CONFIG_DIR` to a dedicated directory for an isolated native test profile. Preferences,

View File

@ -78,7 +78,7 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`.
| Variable | Effect |
|---|---|
| `VECTORCRAFT_CONTROL_PORT` | Same as `--control <port>` |
| `VECTORCRAFT_CONTROL_PORT` | Same as `--control <port>`, which overrides it; a value that isn't a port from 1 to 65535 stops the app |
| `VECTORCRAFT_IN_WINDOW_MENUS` | Any value but empty or `0`: same as `--in-window-menus` (see [macOS: the menu bar](#macos-the-menu-bar)) |
| `VECTORCRAFT_NO_PREFS` | No preferences read or written, no default Data Recovery folder and no log file (agents' test runs) |
| `WGPU_POWER_PREF` | Graphics adapter: `low`, `high` or `none` (see [Desktop graphics processor](#desktop-graphics-processor)) |

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
@ -234,6 +234,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | G9: in `.ai` editing data, gradients with middle stops (#1390), type of objects with an appearance, hidden layers and later artboards (#1391) and area type in a frame too short for the fallback font (#1392) open as saved; G15: the art placed for symbol instances and inline graphics is kept between frames (#1386; the budget run on an idle machine remains); G1: dialogs take the keyboard focus on every open (#1367, #1395); estimates unchanged |
| 2026-10-12 | minor | G9: placed (linked) files in `.ai` editing data come in as linked images showing their saved preview, so such files open through their layers with their guides (#1264); estimate unchanged |
| 2026-10-12 | minor | G9: gradient-filled shapes from a PDF or `.ai` open as paths, not clip groups (#1304); CMYK files with transparency show in applications reading soft masks by luminosity (#1286, in part: the layer groups' bounding boxes remain); G15: hiding or locking a layer deselects in linear time (#1353); estimates unchanged |
| 2026-10-12 | minor | G7: custom document ruler origin (#1212) done; global/artboard mode switching and video rulers remain, estimate unchanged |

View File

@ -1159,7 +1159,7 @@ and `layerRows`, the rows highlighted in the panel.
`layer.reverse {ids?}`, `layer.template {ids?, on?}`, `layer.hideOthers` / `layer.showAll`,
`layer.outlineOthers` / `layer.previewAll`, `layer.lockOthers` / `layer.unlockAll` (Alt-clicking an eye or a lock
runs Hide or Lock Others for the row's layer, or Show or Unlock All when the others already are), `layer.pasteRemembersLayers`,
`object.isolate {id}` / `object.exitIsolation`.
`object.isolate {id}` (a group, a layer or a single object; not type) / `object.exitIsolation`.
- **Dialogs and panel state** (control channel and the app's MCP): `ui.layerOptions {ids?}` and `ui.newLayer
{sublayer?}` open Layer Options (dialog `layerOptions`: name, color, template, locked, visible, printable, preview,
dimImages, dimPercent; OK is one undo step), `ui.layersPanelOptions` opens Panel Options (`layersPanelOptions`:
@ -1572,7 +1572,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
- `ctrlClickSelectsBehind` (on by default): a Selection tool click with `mods: {cmd: true}` (Command on macOS, Ctrl
elsewhere) selects the object under the selected one there, the next such click the one under that, then the
topmost again. Cmd held to borrow the selection tool from another tool clicks as usual.
- `doubleClickToIsolate` (on by default): off, a `doubleclick` on a group with the Selection tool no longer isolates it.
- `doubleClickToIsolate` (on by default): a `doubleclick` with the Selection tool isolates a group, a compound path or a
single path, image, mesh or placed file (not type, blends, envelopes, repeats or symbol instances); off, it no longer does.
- `usePreciseCursors`: the Pen, Eyedropper, Slice and Blend tools' pointers are a crosshair.
- `snapToPointTolerance` (1–8 px, 2 by default): with View › Snap to Point on and Smart Guides off (desktop app), the
point a selection is dragged by, a drawn point and a transform tool's reference point land on an anchor or a ruler

View File

@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
@ -80,6 +80,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | M4: `.ai` editing data follow-ups to #1264 (gradient stops #1390, type placement #1391, short area-type frames #1392) and PDF bleed with editing data (#1379); M14: dialogs take the keyboard focus on every open (#1367, #1395); M5: symbol instance art kept between frames (#1386); M7: characters no font had are looked for again after a font rescan (#1376); estimates unchanged |
| 2026-10-12 | minor | M4: gradient-filled shapes from a PDF or `.ai` open as paths (#1304); constant alpha soft masks export white (#1286, in part); M2: a Direct Selection click on a Live Corners widget selects its corner (#1239); estimates unchanged |
| 2026-10-12 | minor | M2: custom ruler origin (#1212), with drag preview, active-artboard reset, relative X/Y and native round-trip; remaining ruler modes and estimates unchanged |
| 2026-10-12 | minor | M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged |

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@ -56,6 +56,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | A character no installed font had is looked for again after the fonts are rescanned, so installing a font fixes it without a restart (#1376, from #992); scores and hours unchanged |
| 2026-10-12 | minor | Full-name font matching and Missing Fonts searches (#1258): synthetic static/variable font regressions; Windows font-service QA pending; scores and hours unchanged |
| 2026-10-11 | minor | Middle Eastern: digit types done (Character panel menu › Digits: Default, Arabic, Hindi, Farsi; `text.setFormat` / `text.setRangeStyle {digits}`); Character Direction, kashidas, composers and the split caret still missing; scores unchanged at this rounding |
| 2026-10-11 | minor | Thai, Lao, Khmer and Myanmar words for the caret (Ctrl/Alt+arrows) and double-click; combining marks count as part of a word; score unchanged (estimated) |

View File

@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
@ -74,7 +74,7 @@ tools, never checked side by side) isn't captured row by row.
| Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | #1278: segment Delete preserves neighboring edges and endpoint anchors; a second Delete removes the remainder; explicit anchor Delete retains its behavior |
| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing |
| Corners between curved sides (#1144), Relative rounding | partial | Relative rounding missing |
| Isolation mode by double-click, breadcrumbs, Esc to exit | done | |
| Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)) |
| "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not an Illustrator feature as documented; under discussion |
## Smart guides and snapping
@ -123,7 +123,8 @@ tools, never checked side by side) isn't captured row by row.
| Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) |
| Field precision: 3 decimals for steps; values kept as f64 | done | |
| Scrubby labels (beyond Illustrator) | done | #400 |
| Dialogs open with the first field focused and selected; Enter confirms | done | |
| Dialogs open with the first field focused and selected; Enter confirms | done | on every open, not only the first (#1367); a first field that starts greyed out takes the focus once enabled (#1395) |
| Rectangular Grid and Polar Grid click dialogs: size, divider counts with a skew slider each, Use Outside Rectangle As Frame / Create Compound Path From Ellipses, Fill Grid; kept as the tool's options, used by drags too | partial | #1377. The options are the documented ones; how far a skew percentage moves the dividers and which way the radial skew turns are our own choice (the public documentation gives neither), and the reference point locator and a double-click on the tool are missing |
## Modifier keys and temporary tools
@ -186,6 +187,8 @@ re-measure of the panels added since.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Dialogs opened from a menu take the keyboard focus every time they open (#1367); a first field that starts greyed out takes it once enabled (#1395); art flush with an artboard within a rounding error moves, copies and scales with it (#1375, from #1295); scores and hours unchanged |
| 2026-10-12 | minor | Rectangular Grid and Polar Grid tool options (skew, frame, compound path, Fill Grid; #1377, in part): a skew slider under each divider count, kept as tool options and used by drags; the skew curve is our own; score unchanged (estimated) |
| 2026-10-12 | minor | Live Corners: a Direct Selection click on a widget selects its corner, Shift-click toggles it (#1239); #908 and #1232 stay open; score unchanged (estimated) |
| 2026-10-12 | minor | Custom ruler origin (#1212): corner drag previews and sets it; double-click resets to the active artboard; X/Y fields use it and native files keep it; scores unchanged (estimated) |
| 2026-10-12 | minor | Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged |
@ -205,6 +208,7 @@ re-measure of the panels added since.
| 2026-10-12 | minor | Attributes: directly selected compound members read and change the compound fill rule; path direction stays member-local (#1197); score unchanged |
| 2026-10-12 | minor | Pathfinder Divide: open cutting lines split filled paths with one Undo (#1307); scores and hours unchanged |
| 2026-10-12 | minor | Numeric entry: click-to-focus selection waits for release so text drags keep their start (#1326, in part); keyboard focus, double-click, arrows and wheel preserved; scores and hours unchanged; short movements below the click threshold remain open |
| 2026-10-12 | minor | Double Click To Isolate: the Selection tool also isolates a single path, compound path, image, mesh or placed file; only it is hit, marqueed and selected inside; scores and hours unchanged |
| 2026-10-11 | minor | A marquee inside a filled shape no longer selects it: only touching its path does (#1103); score unchanged (estimated) |
| 2026-10-11 | minor | Live Corners: corners between curved sides round too, the curve trimmed to what the cut leaves (#1144); score unchanged (estimated) |
| 2026-10-11 | minor | Artboards: Shift-click selects several, which move, copy and delete together (#1139); score unchanged (estimated) |