Merge remote-tracking branch 'origin/main' into K-1397ff
This commit is contained in:
commit
e72a010e1c
File diff suppressed because one or more lines are too long
|
|
@ -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}`")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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})),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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, ¬ch)), (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, ¬ch)), (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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 >> >> >> >>"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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!({}))];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue