mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
Merge main into feat/live-rasterize-effect
This commit is contained in:
@@ -11,11 +11,12 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_brush::{self as brush, Brush};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolRegistration};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::pathops::{remove_each, separate};
|
||||
use super::swatch::names_param;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -65,8 +66,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
brush_new
|
||||
),
|
||||
cmd!("brush.delete", "Delete Brush", [], None, "{name} delete a brush; strokes using it become plain strokes", has_doc, brush_delete),
|
||||
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} → {name}", has_doc, brush_duplicate),
|
||||
cmd!(
|
||||
"brush.delete",
|
||||
"Delete Brush",
|
||||
[],
|
||||
None,
|
||||
"{name} | {names: [name…]} delete brushes; strokes using them become plain strokes",
|
||||
has_doc,
|
||||
brush_delete
|
||||
),
|
||||
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} | {names: [name…]} → {name} | {names}", has_doc, brush_duplicate),
|
||||
cmd!(
|
||||
"brush.options",
|
||||
"Brush Options…",
|
||||
@@ -101,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Symbol…",
|
||||
[],
|
||||
None,
|
||||
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic} make a symbol from the selection and replace it with an instance → {name, id, registration, exportType}",
|
||||
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}",
|
||||
has_selection,
|
||||
symbol_new
|
||||
),
|
||||
@@ -110,7 +119,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Symbol Options…",
|
||||
[],
|
||||
None,
|
||||
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic} rename a symbol and/or set Symbol Options › Registration and Export Type (default name: current) → {name, registration, exportType}",
|
||||
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array} rename a symbol and/or set Symbol Options › Registration and Export Type and 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1; default name: current) → {name, registration, exportType, nineSlice}",
|
||||
has_doc,
|
||||
symbol_options
|
||||
),
|
||||
@@ -339,10 +348,10 @@ fn merge(base: &mut Value, patch: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, rename: Option<(String, Option<String>)>) -> Result<()> {
|
||||
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, renames: &[(String, Option<String>)]) -> Result<()> {
|
||||
s.edit(label, |d, _| {
|
||||
brush::store(d, &lib);
|
||||
if let Some((from, to)) = rename {
|
||||
for (from, to) in renames {
|
||||
let ids: Vec<NodeId> = {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
@@ -402,7 +411,7 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v[k] = serde_json::to_value(&art).unwrap_or_default();
|
||||
}
|
||||
lib.push(parse_brush(C, v)?);
|
||||
save_library(s, "New Brush", lib, None)?;
|
||||
save_library(s, "New Brush", lib, &[])?;
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
@@ -414,26 +423,35 @@ fn parse_brush(cmd: &str, v: Value) -> Result<Brush> {
|
||||
}
|
||||
|
||||
fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = name_param(p, "brush.delete")?.to_string();
|
||||
const C: &str = "brush.delete";
|
||||
let names = names_param(p, C)?;
|
||||
let mut lib = brush::library(&s.doc()?.doc);
|
||||
let n = lib.len();
|
||||
lib.retain(|b| b.name != name);
|
||||
if lib.len() == n {
|
||||
return Err(bad("brush.delete", format!("no brush named `{name}`")));
|
||||
if let Some(name) = names.iter().find(|n| !lib.iter().any(|b| &b.name == *n)) {
|
||||
return Err(bad(C, format!("no brush named `{name}`")));
|
||||
}
|
||||
save_library(s, "Delete Brush", lib, Some((name, None)))?;
|
||||
lib.retain(|b| !names.contains(&b.name));
|
||||
let renames: Vec<(String, Option<String>)> = names.into_iter().map(|n| (n, None)).collect();
|
||||
save_library(s, "Delete Brush", lib, &renames)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given
|
||||
/// without `names`, names the copy.
|
||||
fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = name_param(p, "brush.duplicate")?;
|
||||
let mut lib = brush::library(&s.doc()?.doc);
|
||||
let mut b = lib.iter().find(|b| b.name == name).cloned().ok_or_else(|| bad("brush.duplicate", format!("no brush named `{name}`")))?;
|
||||
b.name = brush::unique_name(&lib, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let out = b.name.clone();
|
||||
lib.push(b);
|
||||
save_library(s, "Duplicate Brush", lib, None)?;
|
||||
Ok(json!({ "name": out }))
|
||||
const C: &str = "brush.duplicate";
|
||||
let names = names_param(p, C)?;
|
||||
let before = brush::library(&s.doc()?.doc);
|
||||
let mut lib = before.clone();
|
||||
let mut out = vec![];
|
||||
for name in &names {
|
||||
let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
|
||||
let new_name = str_param(p, "newName").filter(|_| p.get("names").is_none()).map_or_else(|| format!("{name} copy"), str::to_string);
|
||||
b.name = brush::unique_name(&lib, &new_name);
|
||||
out.push(b.name.clone());
|
||||
lib.push(b);
|
||||
}
|
||||
save_library(s, "Duplicate Brush", lib, &[])?;
|
||||
Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) })
|
||||
}
|
||||
|
||||
fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -458,7 +476,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
lib[i] = parse_brush(C, v)?;
|
||||
let out = lib[i].name.clone();
|
||||
let rename = new_name.map(|nn| (name, Some(nn)));
|
||||
save_library(s, "Brush Options", lib, rename)?;
|
||||
save_library(s, "Brush Options", lib, rename.as_slice())?;
|
||||
Ok(json!({ "name": out }))
|
||||
}
|
||||
|
||||
@@ -540,6 +558,33 @@ fn export_type_param(p: &Value, cmd: &str) -> Result<Option<SymbolExportType>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural
|
||||
/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it
|
||||
/// on with guides `g` (the symbol's current ones, else the centre third, when none are given).
|
||||
fn nine_slice_param(p: &Value, cmd: &str, current: Option<SymbolNineSlice>) -> Result<Option<Option<SymbolNineSlice>>> {
|
||||
let on = match p.get("nineSlice") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?),
|
||||
};
|
||||
let guides =
|
||||
match p.get("nineSliceGuides") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| {
|
||||
bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1")
|
||||
})?),
|
||||
};
|
||||
Ok(match (on, guides) {
|
||||
(Some(false), _) => Some(None),
|
||||
(None, None) => None,
|
||||
(_, Some(g)) => Some(Some(g)),
|
||||
(Some(true), None) => Some(Some(current.unwrap_or_default())),
|
||||
})
|
||||
}
|
||||
|
||||
fn nine_slice_json(d: &Document, name: &str) -> Value {
|
||||
d.symbol_nine_slice(name).map_or(Value::Null, SymbolNineSlice::to_value)
|
||||
}
|
||||
|
||||
fn rename_natural_size(d: &mut Document, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
@@ -596,6 +641,7 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"size": [w, h],
|
||||
"registration": d.symbol_registration(&sym.name).0,
|
||||
"exportType": d.symbol_export_type(&sym.name).id(),
|
||||
"nineSlice": nine_slice_json(d, &sym.name),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -626,6 +672,7 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER);
|
||||
let export = export_type_param(p, C)?.unwrap_or_default();
|
||||
let nine = nine_slice_param(p, C, None)?.flatten();
|
||||
// Keep the art where it was: the geometric centre stays at `c`, whatever the registration is.
|
||||
let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg);
|
||||
let id = s.edit("New Symbol", |d, sel| {
|
||||
@@ -634,12 +681,15 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
set_natural_size(d, &name, Some(size));
|
||||
d.set_symbol_registration(&name, reg);
|
||||
d.set_symbol_export_type(&name, export);
|
||||
d.set_symbol_nine_slice(&name, nine);
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
|
||||
let id = replace_with_instance(d, &ids, &name, place)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id() }))
|
||||
Ok(
|
||||
json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }),
|
||||
)
|
||||
}
|
||||
|
||||
fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -647,9 +697,10 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = resolve_symbol(&s.doc()?.doc, p, C)?;
|
||||
let export = export_type_param(p, C)?;
|
||||
let reg = registration_param(p, C)?;
|
||||
let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?;
|
||||
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if new_name.is_none() && export.is_none() && reg.is_none() {
|
||||
return Err(bad(C, "pass newName, registration and/or exportType"));
|
||||
if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() {
|
||||
return Err(bad(C, "pass newName, registration, exportType and/or nineSlice"));
|
||||
}
|
||||
if let Some(ref n) = new_name
|
||||
&& n != &name
|
||||
@@ -673,6 +724,7 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
rename_natural_size(d, &name, n);
|
||||
d.rename_symbol_registration(&name, n);
|
||||
d.rename_symbol_export_type(&name, n);
|
||||
d.rename_symbol_nine_slice(&name, n);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(n));
|
||||
}
|
||||
@@ -683,6 +735,9 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(kind) = export {
|
||||
d.set_symbol_export_type(&final_name, kind);
|
||||
}
|
||||
if let Some(g) = nine {
|
||||
d.set_symbol_nine_slice(&final_name, g);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let d = &s.doc()?.doc;
|
||||
@@ -690,6 +745,7 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"name": final_name,
|
||||
"registration": d.symbol_registration(&final_name).0,
|
||||
"exportType": d.symbol_export_type(&final_name).id(),
|
||||
"nineSlice": nine_slice_json(d, &final_name),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -848,6 +904,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
set_natural_size(d, &name, None);
|
||||
d.set_symbol_registration(&name, SymbolRegistration::CENTER);
|
||||
d.clear_symbol_export_type(&name);
|
||||
d.set_symbol_nine_slice(&name, None);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.remove(CURRENT_SYMBOL);
|
||||
}
|
||||
@@ -865,15 +922,17 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let size = natural_size(d, &name);
|
||||
let reg = d.symbol_registration(&name);
|
||||
let export = d.symbol_export_type(&name);
|
||||
let nine = d.symbol_nine_slice(&name);
|
||||
s.edit("Duplicate Symbol", |d, _| {
|
||||
let art = d.reid(&sym.art);
|
||||
d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) });
|
||||
set_natural_size(d, &new, Some(size));
|
||||
d.set_symbol_registration(&new, reg);
|
||||
d.set_symbol_export_type(&new, export);
|
||||
d.set_symbol_nine_slice(&new, nine);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id() }))
|
||||
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }))
|
||||
}
|
||||
|
||||
fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
@@ -88,6 +88,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_ids,
|
||||
lp_expand
|
||||
),
|
||||
cmd!(
|
||||
"livePaint.gapOptions",
|
||||
"Gap Options",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
|
||||
has_selection_or_ids,
|
||||
lp_gap_options
|
||||
),
|
||||
cmd!(
|
||||
"livePaint.fill",
|
||||
"Live Paint Bucket",
|
||||
@@ -111,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Info",
|
||||
[],
|
||||
None,
|
||||
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources}",
|
||||
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}",
|
||||
has_doc,
|
||||
lp_info
|
||||
),
|
||||
@@ -383,10 +392,11 @@ fn source_nodes(n: &Node) -> Vec<Arc<Node>> {
|
||||
if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] }
|
||||
}
|
||||
|
||||
/// Planar map children (sources group, faces, edges) for `sources`.
|
||||
fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<Node>>, usize, usize)> {
|
||||
/// The planar map's inputs from `sources` (paths and compound paths, back → front), with the
|
||||
/// fill, stroke and stroke width of each.
|
||||
fn source_shapes(sources: &[Arc<Node>]) -> (Vec<po::Shape>, Vec<(Paint, Paint, f64)>) {
|
||||
let mut lv = vec![];
|
||||
for n in &sources {
|
||||
for n in sources {
|
||||
b::leaf_shapes(n, &mut lv);
|
||||
}
|
||||
let mut shapes = vec![];
|
||||
@@ -400,6 +410,13 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0)));
|
||||
}
|
||||
}
|
||||
(shapes, styles)
|
||||
}
|
||||
|
||||
/// Planar map children (sources group, faces, edges) for `sources`; with `max_gap` (Gap
|
||||
/// Detection), paint stops at the gaps up to that wide.
|
||||
fn build_children(d: &mut Document, sources: Vec<Arc<Node>>, max_gap: Option<f64>) -> Result<(Vec<Arc<Node>>, usize, usize)> {
|
||||
let (shapes, styles) = source_shapes(&sources);
|
||||
if shapes.is_empty() {
|
||||
return Err(EngineError::Other("Live Paint: select paths or compound paths".into()));
|
||||
}
|
||||
@@ -410,7 +427,13 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
src_group.visible = false;
|
||||
children.push(Arc::new(src_group));
|
||||
// Every path is an edge, open ones included; faces are the areas they enclose.
|
||||
let (regs, edges) = po::live_paint(&shapes);
|
||||
let (regs, edges) = match max_gap {
|
||||
Some(gap) => {
|
||||
let (regs, edges, _) = po::live_paint_with_gaps(&shapes, gap);
|
||||
(regs, edges)
|
||||
}
|
||||
None => po::live_paint(&shapes),
|
||||
};
|
||||
if regs.is_empty() && edges.is_empty() {
|
||||
return Err(EngineError::Other("Live Paint: these paths are too degenerate to paint".into()));
|
||||
}
|
||||
@@ -433,6 +456,51 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
Ok((children, nfaces, nedges))
|
||||
}
|
||||
|
||||
/// Give the faces and edges of `g`, a Live Paint group made again from the sources of `old`, the
|
||||
/// look of `old`'s face or edge where they lie: each face takes everything but the outline (its
|
||||
/// appearance, none included, its transparency and the rest) of the old face its inside point is
|
||||
/// in, and each edge that of the old edge its middle lies on. Faces and edges where `old` had none
|
||||
/// keep the paint of their sources.
|
||||
fn repaint_from(g: &mut Node, old: &Node) {
|
||||
/// How far an edge's middle may be from the old edge it lies on.
|
||||
const ON_EDGE: f64 = 1e-3;
|
||||
let Some(ch) = g.children_mut() else { return };
|
||||
for c in ch.iter_mut() {
|
||||
let Some(pd) = c.path_data() else { continue };
|
||||
let was = match c.name.as_deref() {
|
||||
Some(FACE_NAME) => po::interior_point(pd).and_then(|pt| face_at(old, pt)),
|
||||
Some(EDGE_NAME) => pd
|
||||
.subpaths
|
||||
.first()
|
||||
.filter(|sp| sp.segment_count() > 0)
|
||||
.and_then(|sp| edge_near(old, kurbo::ParamCurve::eval(&sp.segment(0), 0.5), ON_EDGE)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(was) = was {
|
||||
let mut n = (**was).clone();
|
||||
// Gradients fitted to the old outline keep that fit on the new one.
|
||||
n.pin_gradients();
|
||||
n.id = c.id;
|
||||
n.kind = c.kind.clone();
|
||||
n.bbox_angle = c.bbox_angle;
|
||||
*c = Arc::new(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `options` as stored on a Live Paint group: none for the defaults.
|
||||
fn stored_gap_options(options: GapOptions) -> Option<Box<GapOptions>> {
|
||||
(options != GapOptions::default()).then(|| Box::new(options))
|
||||
}
|
||||
|
||||
/// The Gap Options of Live Paint groups merged into one, given bottom-most first: the bottom-most
|
||||
/// group's when its Gap Detection is on; else Gap Detection on with Small Gaps when another
|
||||
/// group's is on; else the bottom-most group's.
|
||||
fn merged_gap_options(groups: &[GapOptions]) -> GapOptions {
|
||||
let Some(&bottom) = groups.first() else { return GapOptions::default() };
|
||||
if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom }
|
||||
}
|
||||
|
||||
/// Make a Live Paint group from `roots`; returns (group, faces, edges).
|
||||
fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
|
||||
let mut sources = vec![];
|
||||
@@ -440,7 +508,7 @@ fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<
|
||||
let n = d.node(*r).ok_or(EngineError::NoNode(*r))?;
|
||||
sources.extend(source_nodes(n));
|
||||
}
|
||||
let (children, nf, ne) = build_children(d, sources)?;
|
||||
let (children, nf, ne) = build_children(d, sources, None)?;
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = Some(LIVE_PAINT_NAME.into());
|
||||
@@ -484,11 +552,15 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?));
|
||||
}
|
||||
}
|
||||
let (children, nf, ne) = build_children(d, sources)?;
|
||||
// Merge applies the Gap Options' merge rules to the groups' options, bottom-most first.
|
||||
let groups: Vec<GapOptions> = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect();
|
||||
let options = merged_gap_options(&groups);
|
||||
let (children, nf, ne) = build_children(d, sources, options.max_gap())?;
|
||||
let mut g = Node::group(target, children);
|
||||
g.name = old.name.clone();
|
||||
g.opacity = old.opacity;
|
||||
g.blend = old.blend;
|
||||
g.extras_mut().live_paint_gaps = stored_gap_options(options);
|
||||
for (pt, paint) in keep_faces {
|
||||
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
|
||||
&& let Some(ch) = g.children_mut()
|
||||
@@ -553,7 +625,11 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for g in &targets {
|
||||
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
n.name = None;
|
||||
n.extras_mut().trace = None;
|
||||
{
|
||||
let mut e = n.extras_mut();
|
||||
e.trace = None;
|
||||
e.live_paint_gaps = None;
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
ch.retain(|c| match c.name.as_deref() {
|
||||
Some(SOURCES_NAME) => false,
|
||||
@@ -569,6 +645,81 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&targets) }))
|
||||
}
|
||||
|
||||
/// Gaps as `[x0, y0, x1, y1]` (the loose end, then where it closes to).
|
||||
fn gaps_json(gaps: &[[Point; 2]]) -> Value {
|
||||
json!(gaps.iter().map(|[a, b]| [a.x, a.y, b.x, b.y]).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// A group's Gap Options as `livePaint.info` gives them.
|
||||
fn gap_options_json(o: GapOptions) -> Value {
|
||||
let [r, g, b] = o.color;
|
||||
let size = o.size.id().map_or_else(|| json!(o.size.points()), |id| json!(id));
|
||||
json!({ "detect": o.detect, "size": size, "points": o.size.points(), "color": format!("#{r:02x}{g:02x}{b:02x}") })
|
||||
}
|
||||
|
||||
/// The gaps up to `options`' size wide among a Live Paint group's sources.
|
||||
fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> {
|
||||
po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points())
|
||||
}
|
||||
|
||||
fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "livePaint.gapOptions";
|
||||
let detect = match p.get("detect").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "detect must be true or false"))?),
|
||||
None => None,
|
||||
};
|
||||
let size =
|
||||
match p.get("size").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(GapSize::parse(v).ok_or_else(|| {
|
||||
bad(C, format!("size must be \"small\", \"medium\", \"large\" or a number of points over 0 up to {}", GapSize::MAX))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let color = match p.get("color").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()),
|
||||
None => None,
|
||||
};
|
||||
let close = bool_or(p, "closeGaps", false);
|
||||
let targets = live_targets(s, p)?;
|
||||
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
|
||||
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
|
||||
let mut changed = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let mut options = old.gap_options();
|
||||
options.detect = detect.unwrap_or(options.detect);
|
||||
options.size = size.unwrap_or(options.size);
|
||||
options.color = color.unwrap_or(options.color);
|
||||
let mut sources = source_nodes(&old);
|
||||
if close {
|
||||
// Close Gaps With Paths adds an unpainted line across each gap to the group's paths.
|
||||
for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) {
|
||||
let line = PathData::single(SubPath::polyline(&gap, false));
|
||||
sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default())));
|
||||
closed += 1;
|
||||
}
|
||||
}
|
||||
let mut n = old.clone();
|
||||
// The faces change only with what paint stops at, or with new paths.
|
||||
if close || options.max_gap() != old.gap_options().max_gap() {
|
||||
let (children, _, _) = build_children(d, sources, options.max_gap())?;
|
||||
if let Some(ch) = n.children_mut() {
|
||||
*ch = children;
|
||||
}
|
||||
repaint_from(&mut n, &old);
|
||||
}
|
||||
n.extras_mut().live_paint_gaps = stored_gap_options(options);
|
||||
faces += b::faces(&n).len();
|
||||
gaps.extend(open_gaps(&n, options));
|
||||
changed.put(d, *g, vec![n])?;
|
||||
}
|
||||
changed.finish(d)?;
|
||||
sel.set(targets.iter().copied());
|
||||
Ok((faces, gaps, closed))
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": closed }))
|
||||
}
|
||||
|
||||
fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "livePaint.fill";
|
||||
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
|
||||
@@ -637,7 +788,14 @@ fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() }))
|
||||
.collect();
|
||||
let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0);
|
||||
Ok(json!({ "faces": faces, "edges": edges, "sources": sources }))
|
||||
let options = n.gap_options();
|
||||
Ok(json!({
|
||||
"faces": faces,
|
||||
"edges": edges,
|
||||
"sources": sources,
|
||||
"gapOptions": gap_options_json(options),
|
||||
"gaps": gaps_json(&open_gaps(n, options)),
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Image Trace ----------
|
||||
|
||||
@@ -80,22 +80,22 @@ pub struct Clipboard {
|
||||
pub para_styles: Vec<TextStyleDef>,
|
||||
/// Brushes the objects' strokes use.
|
||||
pub brushes: Vec<Brush>,
|
||||
/// The artboard the objects were copied from (none when they lie on no artboard): Paste on All
|
||||
/// Artboards keeps their offset to it.
|
||||
/// The artboard the objects were copied from (none when they lie on no artboard; the first of
|
||||
/// [`Self::artboards`] when there are any): Paste on All Artboards keeps their offset to it.
|
||||
pub source_artboard: Option<Rect>,
|
||||
/// Per object: the name of the layer it was copied from (Paste Remembers Layers).
|
||||
pub source_layers: Vec<Option<String>>,
|
||||
/// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its
|
||||
/// own resources as they are now.
|
||||
pub source_doc: Option<u64>,
|
||||
/// An artboard copied with the Artboard tool (`artboard.copy`); `nodes` are then its art. Pasting
|
||||
/// adds a copy of it with the art.
|
||||
pub artboard: Option<vectorcraft_doc::Artboard>,
|
||||
/// The artboards copied with the Artboard tool (`artboard.copy`); `nodes` are then their art.
|
||||
/// Pasting adds a copy of each with the art.
|
||||
pub artboards: Vec<vectorcraft_doc::Artboard>,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty() && self.artboard.is_none()
|
||||
self.nodes.is_empty() && self.artboards.is_empty()
|
||||
}
|
||||
|
||||
/// Objects `roots` of `st`'s document with their resources, source artboard and layers.
|
||||
@@ -109,7 +109,9 @@ impl Clipboard {
|
||||
// The nearest enclosing layer (or sublayer).
|
||||
source_layers.push(anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()));
|
||||
}
|
||||
let bounds = nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b));
|
||||
// Symbol instances are measured by their art.
|
||||
let symbols = d.symbol_boxes();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b));
|
||||
// Art on no artboard has no source artboard: in-place pastes put it back where it was (#1924).
|
||||
let board = bounds.and_then(|b| d.artboard_at(b.center()));
|
||||
Self {
|
||||
@@ -127,9 +129,13 @@ impl Clipboard {
|
||||
Self::gather(src, nodes)
|
||||
}
|
||||
|
||||
/// Union of the objects' visual bounds.
|
||||
/// Union of the objects' visual bounds, with symbol instances measured by the art of the
|
||||
/// symbols the clipboard holds.
|
||||
pub fn bounds(&self) -> Option<Rect> {
|
||||
self.nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b))
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.symbols = self.symbols.clone();
|
||||
let symbols = d.symbol_boxes();
|
||||
self.nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// The swatch name conflicts pasting into `st` raises, as (pasted, document) swatches: none
|
||||
|
||||
@@ -81,6 +81,7 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
|
||||
// reads SVGZ data.
|
||||
"svgz" if unpacks_to_svg(head) => (SVG, SVG),
|
||||
"pdf" => (PDF, PDF),
|
||||
"ps" => (EPS, EPS),
|
||||
"eps" => (EPS, EPS),
|
||||
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
|
||||
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
|
||||
@@ -418,7 +419,8 @@ fn import_emf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "clipboard.importEps";
|
||||
let bytes = data_param(p, C)?;
|
||||
if !vectorcraft_pdf::is_postscript(&bytes) {
|
||||
// Programs whose header is only `%!` are read too, as they are when a `.ps` or `.eps` file opens.
|
||||
if !bytes.starts_with(b"%!") && !vectorcraft_pdf::is_postscript(&bytes) {
|
||||
return Err(bad(C, "the data isn't an EPS or PostScript picture"));
|
||||
}
|
||||
import_picture(s, p, C, "Clipboard.eps", &bytes)
|
||||
|
||||
@@ -39,10 +39,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load Color Profile…",
|
||||
[],
|
||||
None,
|
||||
"{path: .icc/.icm file} register an ICC profile until the app quits or the `run` ends (native only; it isn't remembered, so load it again in each run that names it) → {name, kind}",
|
||||
"{path: .icc/.icm file of at most 32 MB} register an ICC profile (native only). The desktop app and vectorcraft-cli keep a copy of the file in the `Color Profiles` folder next to the preferences and register it again at every start (not with VECTORCRAFT_NO_PREFS, or in a confined `mcp --automation-*-root` server: there it lasts until the process ends) → {name, kind, remembered: whether a copy was kept}",
|
||||
always,
|
||||
load_profile
|
||||
),
|
||||
cmd!(
|
||||
"color.removeProfile",
|
||||
"Remove Color Profile…",
|
||||
[],
|
||||
None,
|
||||
"{name} forget a profile color.loadProfile loaded, and the copy kept of it (built-in profiles and the working spaces stay) → {removed: name}",
|
||||
always,
|
||||
remove_profile
|
||||
),
|
||||
cmd!(
|
||||
"edit.assignProfile",
|
||||
"Assign Profile…",
|
||||
@@ -178,12 +187,35 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_profile(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "color.loadProfile";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)?;
|
||||
let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?;
|
||||
Ok(json!({"name": info.name, "kind": info.kind}))
|
||||
let bytes = crate::store::read_capped(std::path::Path::new(path), crate::store::MAX_PROFILE_FILE).map_err(|e| bad(C, format!("{path}: {e}")))?;
|
||||
let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?;
|
||||
// The bytes are kept, not the path: the file may move or go away.
|
||||
let remembered = s.store_profile(&info.name, &bytes);
|
||||
Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered}))
|
||||
}
|
||||
|
||||
/// Forget a profile `color.loadProfile` loaded, and the copy that is kept of it.
|
||||
fn remove_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "color.removeProfile";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let info = cms::profiles().into_iter().find(|i| i.name == name).ok_or_else(|| bad(C, format!("no profile named `{name}`")))?;
|
||||
if info.builtin {
|
||||
return Err(bad(C, format!("`{name}` is a built-in profile and stays")));
|
||||
}
|
||||
let working = cms::active_settings();
|
||||
if working.rgb == name || working.cmyk == name {
|
||||
return Err(bad(C, format!("`{name}` is a working space: choose another with edit.colorSettings first")));
|
||||
}
|
||||
if !cms::unregister_icc(&info.name) {
|
||||
return Err(bad(C, format!("no profile named `{name}`")));
|
||||
}
|
||||
s.unstore_profile(&info.name);
|
||||
touch_all(s);
|
||||
Ok(json!({"removed": info.name}))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -512,6 +512,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?;
|
||||
let id = made.get("id").and_then(Value::as_u64).map(NodeId);
|
||||
let made = super::typecmd::with_bounds(s, id, made);
|
||||
Ok(super::typecmd::with_overflow(s, id.as_slice(), made))
|
||||
}
|
||||
|
||||
|
||||
@@ -746,13 +746,9 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Forget the Puppet Warp pins: not an undo step, and a saved document stays saved (pins are
|
||||
/// never saved).
|
||||
/// never saved, [`crate::DocState::change_unsaved`]).
|
||||
pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
|
||||
let clean = !st.is_dirty();
|
||||
Arc::make_mut(&mut st.doc).puppet = None;
|
||||
if clean {
|
||||
st.mark_saved();
|
||||
}
|
||||
st.change_unsaved(|d| Arc::make_mut(d).puppet = None);
|
||||
}
|
||||
|
||||
fn puppet_pins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1335,7 +1331,8 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and
|
||||
/// in every undo state (the flat view is never an undo step of its own).
|
||||
/// in every undo state (the flat view is never an undo step of its own, and never saved: a saved
|
||||
/// state stays saved, [`crate::DocState::change_unsaved`]).
|
||||
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
|
||||
let clear = |doc: &mut Arc<Document>| {
|
||||
// Undo steps keep no lookup cache: look without building one, and drop the one an edit
|
||||
@@ -1346,14 +1343,14 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
|
||||
p.editing = false;
|
||||
}
|
||||
};
|
||||
let saved = Arc::ptr_eq(&st.doc, &st.saved_doc);
|
||||
clear(&mut st.doc);
|
||||
if saved {
|
||||
st.saved_doc = st.doc.clone();
|
||||
}
|
||||
st.change_unsaved(clear);
|
||||
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
|
||||
let saved = Arc::ptr_eq(&e.doc, &st.saved_doc);
|
||||
clear(&mut e.doc);
|
||||
e.doc.drop_lookup_cache();
|
||||
if saved {
|
||||
st.saved_doc = e.doc.clone();
|
||||
}
|
||||
}
|
||||
if let Some(it) = &mut st.interaction {
|
||||
clear(&mut it.doc);
|
||||
|
||||
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Lock Guides",
|
||||
["View", "Guides"],
|
||||
Some("Cmd+Alt+;"),
|
||||
"{locked?: bool} toggle (or set) the session's guide lock → {locked}",
|
||||
"{locked?: bool} toggle (or set) the session's guide lock; locking deselects the ruler guides and guide paths in every open document → {locked}",
|
||||
has_doc,
|
||||
lock_guides
|
||||
),
|
||||
@@ -601,12 +601,20 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
|
||||
s.menu.guides_locked = v;
|
||||
// Locked guides can't stay selected, ruler guides or guide paths ([`Session::select`] lets
|
||||
// those go).
|
||||
// Locked guides can't stay selected, ruler guides or guide paths, in any open document
|
||||
// ([`Session::select`] lets those go in the active one).
|
||||
let st = s.doc()?;
|
||||
if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) {
|
||||
s.select(|_, sel| sel.guides.clear())?;
|
||||
}
|
||||
if v {
|
||||
let active = s.active;
|
||||
for (i, st) in s.docs.iter_mut().enumerate() {
|
||||
if Some(i) != active && crate::deselect_guides(&st.doc, &mut st.selection) {
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "locked": v }))
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value
|
||||
"typographersQuotes" => s.typographers_quotes = flag(k, v)?,
|
||||
"superscript" => s.superscript = script_param(k, v, s.superscript)?,
|
||||
"subscript" => s.subscript = script_param(k, v, s.subscript)?,
|
||||
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, 200.0)?,
|
||||
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, vectorcraft_doc::setup::MAX_SMALL_CAPS)?,
|
||||
"exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?,
|
||||
"backgroundContents" => s.background = background_param(v, C)?,
|
||||
// Read-only values `document.setup` reports.
|
||||
@@ -223,8 +223,8 @@ fn script_param(k: &str, v: &Value, mut m: ScriptMetrics) -> Result<ScriptMetric
|
||||
let o = v.as_object().ok_or_else(|| bad(C, format!("{k} must be {{size?, position?}}")))?;
|
||||
for (key, x) in o {
|
||||
match key.as_str() {
|
||||
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, 500.0)?,
|
||||
"position" => m.position = percent(&format!("{k}.position"), x, -500.0, 500.0)?,
|
||||
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, vectorcraft_doc::setup::MAX_SCRIPT)?,
|
||||
"position" => m.position = percent(&format!("{k}.position"), x, -vectorcraft_doc::setup::MAX_SCRIPT, vectorcraft_doc::setup::MAX_SCRIPT)?,
|
||||
_ => return Err(bad(C, format!("unknown {k} key `{key}` (size, position)"))),
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ pub(crate) fn script_params(p: &Value, setup: &DocSetup, cmd: &str) -> Result<(O
|
||||
}
|
||||
|
||||
/// A superscript or subscript in `setup`'s proportions.
|
||||
fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
|
||||
pub(crate) fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
|
||||
match p {
|
||||
CharPosition::Normal => CharPosition::Normal,
|
||||
CharPosition::Superscript(_) => CharPosition::Superscript(setup.superscript),
|
||||
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Segment",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t, dx, dy} bend a segment so the point at t moves by (dx, dy)",
|
||||
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
has_doc,
|
||||
reshape_segment
|
||||
),
|
||||
@@ -889,10 +889,12 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !(dv.x.is_finite() && dv.y.is_finite()) {
|
||||
return Err(bad(C, "invalid delta"));
|
||||
}
|
||||
let perpendicular = bool_or(p, "perpendicular", false);
|
||||
s.edit("Reshape", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if !sp.bend_segment(seg, t, dv) {
|
||||
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
|
||||
if !bent {
|
||||
return Err(EngineError::Other("no such segment".into()));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+155
-42
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, Unit};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, RulerMode, Unit};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::clipboard::SwatchChoices;
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
|
||||
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
@@ -37,13 +37,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
set_units
|
||||
),
|
||||
cmd!(query "document.rulerOrigin", "Ruler Origin", [], None, "{} → {x, y} the rulers' zero point, in absolute document points", has_doc, |s, _| Ok(json!(s.doc()?.doc.ruler_origin))),
|
||||
cmd!(
|
||||
query "document.rulerOrigin",
|
||||
"Ruler Origin",
|
||||
[],
|
||||
None,
|
||||
"{artboard?: index=0 (the active artboard, which artboard rulers measure from)} → {x, y} the rulers' zero point, in absolute document points",
|
||||
has_doc,
|
||||
ruler_origin
|
||||
),
|
||||
cmd!(
|
||||
"document.setRulerOrigin",
|
||||
"Ruler Origin",
|
||||
[],
|
||||
None,
|
||||
"{x, y} set the rulers' zero point in absolute document points; art, guides and command coordinates stay in place",
|
||||
"{x, y, artboard?: index=0} set the rulers' zero point in absolute document points: the document's with global rulers, that artboard's with artboard rulers (it moves with the artboard); art, guides and command coordinates stay in place",
|
||||
has_doc,
|
||||
set_ruler_origin
|
||||
),
|
||||
@@ -52,10 +60,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reset Ruler Origin",
|
||||
[],
|
||||
None,
|
||||
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner",
|
||||
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner (with artboard rulers, that artboard's own zero point)",
|
||||
has_doc,
|
||||
reset_ruler_origin
|
||||
),
|
||||
cmd!(
|
||||
"view.globalRulers",
|
||||
"Change to Global Rulers",
|
||||
["View", "Rulers"],
|
||||
Some("Cmd+Alt+R"),
|
||||
"{global?: bool (default: toggle)} global rulers measure the whole document from one zero point; artboard rulers (a new document's) from the active artboard's top-left corner, or the zero point set for it. Saved with the document → {global}",
|
||||
has_doc,
|
||||
global_rulers
|
||||
),
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!(
|
||||
@@ -83,7 +100,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste",
|
||||
["Edit"],
|
||||
Some("Cmd+V"),
|
||||
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). An artboard copied with artboard.copy comes with its art, right of the last artboard (the other paste commands but Paste on All Artboards: where it was), its art back in the layers it came from → {ids, artboard: the new artboard's index or null, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Offset)
|
||||
),
|
||||
@@ -172,6 +189,26 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if summary { Ok(inspect::node_summary_opts(n, opts, &doc.symbol_boxes())) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
|
||||
}
|
||||
|
||||
/// Most node ids `document.inspect {ids}` takes.
|
||||
const MAX_INSPECT_IDS: usize = 10_000;
|
||||
|
||||
fn doc_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.inspect";
|
||||
let opts = slice_opts(p, C)?;
|
||||
let ids = match p.get("ids") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) if a.len() > MAX_INSPECT_IDS => return Err(bad(C, format!("`ids` takes at most {MAX_INSPECT_IDS} ids"))),
|
||||
Some(_) => Some(checked_ids_param(s, p, "ids", C)?),
|
||||
};
|
||||
let kind = match p.get("kind") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(k)) => Some(k.clone()),
|
||||
Some(_) => return Err(bad(C, "`kind` must be a string")),
|
||||
};
|
||||
let scope = inspect::Scope { ids, kind, limit: opt_u64(p, C, "limit")?, offset: opt_u64(p, C, "offset")?.unwrap_or(0) };
|
||||
Ok(inspect::document_scoped(s, opts, &scope))
|
||||
}
|
||||
|
||||
/// `depth` / `childLimit` for a summary read: absent (or null) means no limit.
|
||||
fn slice_opts(p: &Value, cmd: &str) -> Result<inspect::SummaryOpts> {
|
||||
Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")?, compound_member: false })
|
||||
@@ -209,33 +246,91 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given.
|
||||
fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
|
||||
let index = match p.get("artboard") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?,
|
||||
};
|
||||
if index >= s.doc()?.doc.artboards.len() {
|
||||
return Err(bad(c, "index out of range"));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
// A document without artboards measures from its global zero point.
|
||||
let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? };
|
||||
Ok(json!(s.doc()?.doc.ruler_origin_for(index)))
|
||||
}
|
||||
|
||||
fn set_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.setRulerOrigin";
|
||||
let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
change_ruler_origin(s, origin, "Ruler Origin")
|
||||
let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? };
|
||||
change_ruler_origin(s, index, Some(origin), "Ruler Origin")
|
||||
}
|
||||
|
||||
fn reset_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.resetRulerOrigin";
|
||||
let index = match p.get("artboard") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(C, "index out of range"))?,
|
||||
};
|
||||
let ab = s.doc()?.doc.artboards.get(index).ok_or_else(|| bad(C, "index out of range"))?;
|
||||
change_ruler_origin(s, Point::new(ab.rect.x0, ab.rect.y0), "Reset Ruler Origin")
|
||||
let index = artboard_param(s, p, "document.resetRulerOrigin")?;
|
||||
change_ruler_origin(s, index, None, "Reset Ruler Origin")
|
||||
}
|
||||
|
||||
fn change_ruler_origin(s: &mut Session, origin: Point, label: &str) -> Result<Value> {
|
||||
if !origin.is_finite() || origin.x.abs() > crate::MAX_COORD || origin.y.abs() > crate::MAX_COORD {
|
||||
/// Move the rulers' zero point to `origin`, or back to artboard `index`'s top-left corner: the
|
||||
/// document's (global rulers) or that artboard's (artboard rulers, kept as an offset from its
|
||||
/// corner so it follows the artboard).
|
||||
fn change_ruler_origin(s: &mut Session, index: usize, origin: Option<Point>, label: &str) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let global = d.rulers == RulerMode::Global;
|
||||
let corner = d.artboards.get(index).map(|ab| Point::new(ab.rect.x0, ab.rect.y0));
|
||||
let target = origin.or(corner).unwrap_or(Point::ZERO);
|
||||
if !target.is_finite() || target.x.abs() > crate::MAX_COORD || target.y.abs() > crate::MAX_COORD {
|
||||
return Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()));
|
||||
}
|
||||
if s.doc()?.doc.ruler_origin != origin {
|
||||
if global {
|
||||
if d.ruler_origin != target {
|
||||
s.edit(label, |d, _| {
|
||||
d.ruler_origin = target;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
return Ok(json!(target));
|
||||
}
|
||||
let Some((id, corner)) = d.artboards.get(index).map(|ab| ab.id).zip(corner) else {
|
||||
return Err(bad("document.setRulerOrigin", "index out of range"));
|
||||
};
|
||||
let offset = Some(target - corner).filter(|o| *o != Vec2::ZERO);
|
||||
if d.artboard_ruler_origins.get(&id).copied() != offset {
|
||||
s.edit(label, |d, _| {
|
||||
d.ruler_origin = origin;
|
||||
// Origins of artboards that no longer exist go too.
|
||||
let ids: BTreeSet<u32> = d.artboards.iter().map(|ab| ab.id).collect();
|
||||
d.artboard_ruler_origins.retain(|id, _| ids.contains(id));
|
||||
match offset {
|
||||
Some(o) => d.artboard_ruler_origins.insert(id, o),
|
||||
None => d.artboard_ruler_origins.remove(&id),
|
||||
};
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!(origin))
|
||||
Ok(json!(target))
|
||||
}
|
||||
|
||||
/// View › Rulers › Change to Global / Artboard Rulers.
|
||||
fn global_rulers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let current = s.doc()?.doc.rulers == RulerMode::Global;
|
||||
let global = match p.get("global") {
|
||||
None => !current,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad("view.globalRulers", "global must be true or false"))?,
|
||||
};
|
||||
if global != current {
|
||||
let label = if global { "Change to Global Rulers" } else { "Change to Artboard Rulers" };
|
||||
s.edit(label, |d, _| {
|
||||
d.rulers = if global { RulerMode::Global } else { RulerMode::Artboard };
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "global": global }))
|
||||
}
|
||||
|
||||
/// A typing session in progress (the Type tool previews the whole session as one interaction).
|
||||
@@ -276,6 +371,7 @@ pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let label = e.label.clone();
|
||||
@@ -288,12 +384,17 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
// Guides locked since the step was made stay deselected.
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
super::maskedit::follow_history(st, editing);
|
||||
Ok(json!({ "undone": label }))
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
||||
let label = e.label.clone();
|
||||
@@ -306,6 +407,9 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
super::maskedit::follow_history(st, editing);
|
||||
Ok(json!({ "redone": label }))
|
||||
}
|
||||
@@ -324,22 +428,23 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
|
||||
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
|
||||
}
|
||||
|
||||
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): in
|
||||
/// an isolated compound path its paths are objects of their own, so the hole of a ring moves
|
||||
/// without the ring. Paint and the rest still go to the compound path, which holds the paint.
|
||||
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a
|
||||
/// path of a compound path selected on its own (Group Selection, or in the isolated compound path)
|
||||
/// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the
|
||||
/// rest still go to the compound path, which holds the paint.
|
||||
pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), st.isolation))
|
||||
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), true))
|
||||
}
|
||||
|
||||
/// The top-level objects among `ids` (in paint order): compound-path members stand for their
|
||||
/// compound, and ids whose ancestor is also listed, and layers, are dropped.
|
||||
pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
roots_within(doc, ids, None)
|
||||
roots_within(doc, ids, false)
|
||||
}
|
||||
|
||||
/// [`roots_of`], less the swap for the members of `isolation` when it is a compound path.
|
||||
fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) -> Vec<NodeId> {
|
||||
/// [`roots_of`]; with `members`, a compound path's member listed stays itself.
|
||||
fn roots_within(doc: &Document, ids: Vec<NodeId>, members: bool) -> Vec<NodeId> {
|
||||
// Every id's place in the tree, found in one walk (a lookup per id is a walk per id).
|
||||
let paths = doc.index_paths(ids.iter().copied());
|
||||
// A compound-path member acts as its compound (compounds are one object).
|
||||
@@ -349,7 +454,7 @@ fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) ->
|
||||
let path = paths.get(&id);
|
||||
let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty());
|
||||
match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) {
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && Some(n.id) != isolation => (n.id, Some(up.to_vec())),
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !members => (n.id, Some(up.to_vec())),
|
||||
_ => (id, path.cloned()),
|
||||
}
|
||||
})
|
||||
@@ -495,16 +600,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
let st = s.doc()?;
|
||||
let same_doc = clip.source_doc == Some(st.uid);
|
||||
let parent = st.target_parent()?;
|
||||
// A copied artboard comes back with its art: right of the last artboard, or where it was
|
||||
// (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art.
|
||||
let board = clip
|
||||
.artboard
|
||||
.as_ref()
|
||||
// Copied artboards come back with their art: together right of the last artboard, as they lay,
|
||||
// or where they were (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the
|
||||
// art.
|
||||
let boards = clip
|
||||
.artboards
|
||||
.iter()
|
||||
.map(|a| a.rect)
|
||||
.reduce(|a, b| a.union(b))
|
||||
.filter(|_| mode != PasteMode::AllArtboards)
|
||||
.map(|a| (a, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, a.rect) } else { Vec2::ZERO }));
|
||||
.map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO }));
|
||||
// Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art
|
||||
// always goes back into its layers.
|
||||
let remember = (st.doc.paste_remembers_layers || board.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
let remember = (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
// Front/back: relative to the selection (top-most / bottom-most selected object); with
|
||||
// nothing selected, the top / bottom of the current layer.
|
||||
let anchor = match mode {
|
||||
@@ -515,7 +623,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let placements: Vec<Affine> = match (mode, board) {
|
||||
let placements: Vec<Affine> = match (mode, boards) {
|
||||
(_, Some((_, dv))) => vec![Affine::translate(dv)],
|
||||
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
|
||||
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
||||
@@ -528,9 +636,13 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
// In place, in front, in back: onto the active artboard when the UI names it.
|
||||
_ => vec![onto_artboard(&st.doc, p, clip)],
|
||||
};
|
||||
let (ids, imported, artboard) = s.edit(mode.label(), |d, sel| {
|
||||
let (ids, imported, artboards) = s.edit(mode.label(), |d, sel| {
|
||||
let imported = clip.import_into(d, choices, same_doc);
|
||||
let artboard = board.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv));
|
||||
let artboards: Vec<usize> = boards
|
||||
.into_iter()
|
||||
.flat_map(|(all, dv)| all.iter().map(move |a| (a, dv)))
|
||||
.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv))
|
||||
.collect();
|
||||
let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new();
|
||||
// Objects pasted into each parent so far (keeps their order in front and back pastes).
|
||||
let mut placed: BTreeMap<Option<NodeId>, usize> = BTreeMap::new();
|
||||
@@ -565,15 +677,16 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
// Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are
|
||||
// right away (opening the file later would, but paste must not depend on a reopen).
|
||||
super::typecmd::refresh_bounds_of(d, &new_ids);
|
||||
Ok((new_ids, imported, artboard))
|
||||
Ok((new_ids, imported, artboards))
|
||||
})?;
|
||||
// The Artboard tool takes the pasted artboard.
|
||||
if let Some(i) = artboard.filter(|_| s.tool_id() == "artboard") {
|
||||
s.set_tool_option("active", &json!(i));
|
||||
// The Artboard tool selects the pasted artboards, the first one active.
|
||||
if !artboards.is_empty() && s.tool_id() == "artboard" {
|
||||
s.set_tool_option("selected", &json!(artboards));
|
||||
}
|
||||
Ok(json!({
|
||||
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"artboard": artboard,
|
||||
"artboard": artboards.first(),
|
||||
"artboards": artboards,
|
||||
"added": imported.added,
|
||||
"merged": imported.merged,
|
||||
"renamed": imported.renamed,
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}",
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
@@ -138,6 +138,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !params.is_null() && !params.is_object() {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
}
|
||||
super::params::check_effect_color(C, id, ¶ms)?;
|
||||
// Crop Marks take their style from the preferences when applied.
|
||||
if id == effects::CROP_MARKS && params.get("style").is_none() {
|
||||
params["style"] = json!(s.crop_mark_style().id());
|
||||
@@ -313,7 +314,11 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.transpose()?;
|
||||
let visible = p.get("visible").and_then(Value::as_bool);
|
||||
let item = item_target(s, p, C)?;
|
||||
if set_params_effects(s, p).iter().any(|e| e == GLASS) {
|
||||
let edited = set_params_effects(s, p);
|
||||
for effect in &edited {
|
||||
super::params::check_effect_color(C, effect, ¶ms)?;
|
||||
}
|
||||
if edited.iter().any(|e| e == GLASS) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
for id in appearance_targets(s, p)? {
|
||||
@@ -505,10 +510,11 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
|
||||
expand_pieces(d, &mut m, false, &mut stroke_art);
|
||||
m
|
||||
})
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) {
|
||||
effects::expand_leaf(d, &v, &mut stroke_art)
|
||||
} else {
|
||||
// Type, images, symbol instances, live objects: through their outlines.
|
||||
// Type, images, symbol instances, live objects (and paths whose Transform effect copies
|
||||
// them with a gradient): through their outlines.
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
@@ -546,6 +552,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
|
||||
|
||||
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = appearance_targets(s, p)?;
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut out = vec![];
|
||||
@@ -557,7 +564,12 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
let mut out = json!({ "ids": ids_json(&ids) });
|
||||
// Raster effects become images rendered from the art.
|
||||
if let Some(w) = rasterfx::undecoded_warning(failed) {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -996,4 +1008,27 @@ mod tests {
|
||||
assert_eq!(ch.len(), 1);
|
||||
assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() {
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
let (mut s, id) = session_with_rect();
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
s.edit("gradient", |d, _| {
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.set_fill(ramp);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
let mut boxes = vec![];
|
||||
node(&s, id).walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) {
|
||||
boxes.push(n.geometric_bounds().unwrap().width());
|
||||
}
|
||||
});
|
||||
assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient");
|
||||
}
|
||||
}
|
||||
|
||||
+429
-14
@@ -1,8 +1,9 @@
|
||||
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
|
||||
//! (Fill) into plain art in one undo step; blends and repeats become groups of their steps and instances. Placed documents (Object) become editable copies of the
|
||||
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient and pattern
|
||||
//! fills (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the
|
||||
//! art they show, their resources joining the document. A linear or radial gradient fill becomes a
|
||||
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
|
||||
//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling.
|
||||
//! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip
|
||||
//! group shaped like the object. Create Gradient Mesh shares the sampling.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -12,13 +13,14 @@ use vectorcraft_color::freeform::spread_scale;
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
|
||||
use vectorcraft_doc::live::{GradientMesh, lerp_color};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::pattern::{PatternDef, place_art};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::menucmds::{run_raw, squash};
|
||||
use super::object::as_clipping_path;
|
||||
use super::pathops::{assemble, node_path, replace_leaves, styled_copy, with_transparency};
|
||||
use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency};
|
||||
use super::*;
|
||||
|
||||
const C: &str = "object.expand";
|
||||
@@ -37,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -46,11 +48,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Options",
|
||||
[],
|
||||
None,
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke} (true: something to expand)",
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke}))
|
||||
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient}))
|
||||
}
|
||||
),
|
||||
]
|
||||
@@ -99,6 +101,8 @@ struct Expandable {
|
||||
live: bool,
|
||||
effects: bool,
|
||||
fill: bool,
|
||||
/// Gradient fills (the ones Expand Gradient To applies to), a part of `fill`.
|
||||
gradient: bool,
|
||||
stroke: bool,
|
||||
/// An envelope (Object expands it into its distorted content).
|
||||
envelope: bool,
|
||||
@@ -108,6 +112,8 @@ struct Expandable {
|
||||
placed: bool,
|
||||
/// Repeats (Object expands them into groups of their instances).
|
||||
repeats: bool,
|
||||
/// Intertwine groups (Object expands them into groups of what they draw).
|
||||
intertwines: bool,
|
||||
}
|
||||
|
||||
impl Expandable {
|
||||
@@ -123,18 +129,26 @@ impl Expandable {
|
||||
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
|
||||
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
|
||||
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
|
||||
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
|
||||
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
|
||||
e.fill |= match &c.kind {
|
||||
e.gradient |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
|
||||
};
|
||||
e.fill |= e.gradient;
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => {
|
||||
t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some()))
|
||||
}
|
||||
_ => shape && c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()),
|
||||
};
|
||||
});
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
fn object(&self) -> bool {
|
||||
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats
|
||||
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +163,17 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The definition and placement of a visible pattern fill whose pattern `d` defines.
|
||||
fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> {
|
||||
match i {
|
||||
AppearanceItem::Fill(f) if f.visible => match &f.paint {
|
||||
Paint::Pattern { pattern, xf } => Some((f, d.pattern(pattern)?, *xf)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let object = bool_or(p, "object", true);
|
||||
let fill = bool_or(p, "fill", true);
|
||||
@@ -162,6 +187,12 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
|
||||
}
|
||||
// Intertwine groups before what they hold: their objects, and the copies of the parts drawn
|
||||
// over the others, expand below.
|
||||
if object && e.intertwines {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| super::intertwine::expand_intertwines(d, &roots).map(|_| ()))?;
|
||||
}
|
||||
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
|
||||
if object && e.blends {
|
||||
let roots = selected_roots(s)?;
|
||||
@@ -203,6 +234,7 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if fill {
|
||||
let _ = expand_gradient_fills(s, to);
|
||||
let _ = expand_pattern_fills(s);
|
||||
}
|
||||
if s.doc()?.revision == rev0 {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
@@ -241,18 +273,27 @@ fn expand_gradient_fills(s: &mut Session, to: GradientTo) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Path `l` with each gradient fill turned into art, in appearance order: the other items stay
|
||||
/// on plain copies of the path. `None` when it has no gradient fill.
|
||||
/// on plain copies of the path. The path's geometry effects ([`reshaped`]) and each fill's own
|
||||
/// ([`fill_shape`]) shape the outline the art fills first, as on the canvas. `None` when no
|
||||
/// gradient fill can be expanded.
|
||||
fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
|
||||
return None;
|
||||
}
|
||||
let reshaped = reshaped(l);
|
||||
let l = reshaped.as_ref().unwrap_or(l);
|
||||
let (path, _) = node_path(l)?;
|
||||
let bounds = path.bounds()?;
|
||||
let mut parts = vec![];
|
||||
let mut rest = vec![];
|
||||
let mut expanded = false;
|
||||
for item in &l.appearance.items {
|
||||
// Items that paint nothing go; a gradient that can't be expanded stays.
|
||||
let Some(art) = gradient_fill(item).and_then(|(f, g)| gradient_art(d, l, &path, bounds, f, g, to)) else {
|
||||
let art = gradient_fill(item).and_then(|(f, g)| {
|
||||
let (fl, path, bounds) = fill_shape(l, f, &path, bounds)?;
|
||||
gradient_art(d, &fl, &path, bounds, f, g, to)
|
||||
});
|
||||
let Some(art) = art else {
|
||||
if item.visible() && !item.paint().is_none() {
|
||||
rest.push(item.clone());
|
||||
}
|
||||
@@ -262,6 +303,10 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut rest)));
|
||||
}
|
||||
parts.push(art);
|
||||
expanded = true;
|
||||
}
|
||||
if !expanded {
|
||||
return None;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
parts.push(styled_copy(d, l, rest));
|
||||
@@ -270,7 +315,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
}
|
||||
|
||||
/// Gradient fill `f` (gradient `g`) of path `l` (outline `path`, box `bounds`) as art, with the
|
||||
/// fill's opacity, blend mode and effects.
|
||||
/// fill's opacity, blend mode and effects other than geometry effects ([`fill_effects`]).
|
||||
fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &FillLayer, g: &GradientPaint, to: GradientTo) -> Option<Node> {
|
||||
let content = if g.gradient.kind == GradientKind::Freeform {
|
||||
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds))?;
|
||||
@@ -299,7 +344,377 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
|
||||
Node::new(d.alloc_id(), NodeKind::Group { children, clip: true })
|
||||
};
|
||||
let mut art = with_transparency(d, art, f.opacity, f.blend);
|
||||
art.appearance.effects = f.effects.clone();
|
||||
art.appearance.effects = fill_effects(f);
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
|
||||
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
|
||||
/// visible geometry effect.
|
||||
fn reshaped(l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry};
|
||||
if !has_geometry(&l.appearance.effects) {
|
||||
return None;
|
||||
}
|
||||
let (path, _) = node_path(l)?;
|
||||
let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l));
|
||||
let mut m = with_path(l, g);
|
||||
m.appearance.effects.retain(|e| !is_geometry(&e.id));
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Path `l` (outline `path`, box `bounds`) as fill `f` paints it on the canvas, with its outline
|
||||
/// and box: when the fill has visible geometry effects, `l` with the outline they make of `path`
|
||||
/// as a plain path, and that outline's box.
|
||||
fn fill_shape(l: &Node, f: &FillLayer, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry};
|
||||
if !has_geometry(&f.effects) {
|
||||
return Some((l.clone(), path.clone(), bounds));
|
||||
}
|
||||
let outline = apply_geometry_with(&f.effects, path, bounds, &GeomContext::of(l));
|
||||
let b = outline.bounds()?;
|
||||
Some((with_path(l, outline.clone()), outline, b))
|
||||
}
|
||||
|
||||
/// Path or compound path `l` with `path` as its path, a plain path.
|
||||
fn with_path(l: &Node, path: PathData) -> Node {
|
||||
let mut m = l.clone();
|
||||
m.kind = match &l.kind {
|
||||
NodeKind::Path { rule, clipping, guide, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: *clipping, guide: *guide },
|
||||
NodeKind::Compound { rule, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: false, guide: false },
|
||||
k => k.clone(),
|
||||
};
|
||||
m
|
||||
}
|
||||
|
||||
/// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which
|
||||
/// shape the outline it paints ([`fill_shape`]).
|
||||
fn fill_effects(f: &FillLayer) -> Vec<vectorcraft_doc::Effect> {
|
||||
f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect()
|
||||
}
|
||||
|
||||
/// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]).
|
||||
fn expand_pattern_fills(s: &mut Session) -> Result<()> {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, sel| {
|
||||
let (mut ids, mut changed) = (vec![], 0);
|
||||
for r in &roots {
|
||||
let (id, n) = replace_leaves(d, *r, expand_patterns)?;
|
||||
ids.push(id);
|
||||
changed += n;
|
||||
}
|
||||
if changed == 0 {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
}
|
||||
sel.set(ids);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Path `l` with each pattern fill turned into the tiles it paints over the box of the outline it
|
||||
/// fills, in a clip group shaped like that outline with the fill's opacity, blend mode and effects
|
||||
/// other than geometry effects, in appearance order: the other items stay on plain copies of the
|
||||
/// path. The path's geometry effects ([`reshaped`]) and each fill's own ([`fill_shape`]) shape
|
||||
/// that outline first, as on the canvas. The tiles are drawn as the canvas draws them
|
||||
/// ([`pattern_tiles`]). A pattern fill that lays no tile there stays. `None` when no pattern fill
|
||||
/// lays a tile.
|
||||
fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
|
||||
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) {
|
||||
return None;
|
||||
}
|
||||
let reshaped = reshaped(l);
|
||||
let l = reshaped.as_ref().unwrap_or(l);
|
||||
let (path, _) = node_path(l)?;
|
||||
let bounds = path.bounds()?;
|
||||
let mut parts = vec![];
|
||||
let mut rest = vec![];
|
||||
let mut expanded = false;
|
||||
for item in &l.appearance.items {
|
||||
let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf));
|
||||
let tiles = fill.and_then(|(f, def, xf)| {
|
||||
let (fl, _, bounds) = fill_shape(l, f, &path, bounds)?;
|
||||
let t = pattern_tiles(d, &def, xf, bounds);
|
||||
(!t.is_empty()).then_some((f, fl, t))
|
||||
});
|
||||
let Some(art) = tiles.and_then(|(f, fl, t)| tile_art(d, &fl, f, &t)) else {
|
||||
if item.visible() && !item.paint().is_none() {
|
||||
rest.push(item.clone());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if !rest.is_empty() {
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut rest)));
|
||||
}
|
||||
parts.push(art);
|
||||
expanded = true;
|
||||
}
|
||||
if !expanded {
|
||||
return None;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
parts.push(styled_copy(d, l, rest));
|
||||
}
|
||||
assemble(d, l, parts)
|
||||
}
|
||||
|
||||
/// The tiles pattern `def` placed by `xf` lays over `bounds` in document `d`, as the canvas draws
|
||||
/// them: their stroke weights and effects scale with the pattern, and when it also turns, flips,
|
||||
/// stretches or shears them, the strokes (brushed ones included) and geometry effects of their
|
||||
/// paths and opacity masks become filled art and their live shapes become plain paths first. The
|
||||
/// same happens when it scales art with geometry effects (Roughen's detail, for one, is per inch
|
||||
/// and does not scale). No tiles when it turns, flips, stretches or shears art with raster or 3D
|
||||
/// effects or with a geometry effect on a group or type, or scales art with a geometry effect on a
|
||||
/// group or type, which the canvas draws turned or scaled with the tile and live effects would
|
||||
/// not, and when it does more than move art that holds a symbol instance, whose strokes the canvas
|
||||
/// draws scaled with the tile and an instance does not scale. Random Transform effects and brushes
|
||||
/// that vary at random in the art are baked first ([`seeds_baked`], [`random_brushes_baked`]).
|
||||
fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect) -> Vec<Node> {
|
||||
if !moves_only(xf) && def.art.iter().any(|a| has_instances(a)) {
|
||||
return vec![];
|
||||
}
|
||||
let Some(def) = seeds_baked(def) else {
|
||||
return vec![];
|
||||
};
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let def = random_brushes_baked(d, &brushes, def);
|
||||
let def = &*def;
|
||||
let scaling = Scaling { strokes: true, effects: Some(vectorcraft_render::effects::scale_effect), patterns: true, ..Scaling::default() };
|
||||
if scales_evenly(xf) && (moves_only(xf) || !def.art.iter().any(|a| has_geometry_effects(a))) {
|
||||
return def.instances_in_with(xf, bounds, scaling);
|
||||
}
|
||||
if def.art.iter().any(|a| has_unbakeable_effects(a)) {
|
||||
return vec![];
|
||||
}
|
||||
let placed = Scaling { patterns: true, ..Scaling::default() };
|
||||
let mut tiles = def.instances_in_with(Affine::IDENTITY, xf.inverse().transform_rect_bbox(bounds), placed);
|
||||
for t in &mut tiles {
|
||||
*t = prepared(d, &brushes, t);
|
||||
place_art(t, xf, scaling);
|
||||
}
|
||||
tiles
|
||||
}
|
||||
|
||||
/// Pattern `def` with each path in its art (opacity masks included) that has a random Transform
|
||||
/// effect baked into filled art ([`bake_appearance`](vectorcraft_render::effects::bake_appearance))
|
||||
/// while it keeps its own id: the effect takes its seed from the id, and the canvas draws the art
|
||||
/// with those ids for every tile, while the tiles get ids of their own. `None` when something
|
||||
/// other than a path has such an effect.
|
||||
fn seeds_baked(def: &PatternDef) -> Option<std::borrow::Cow<'_, PatternDef>> {
|
||||
if !def.art.iter().any(|a| has_seeded(a)) {
|
||||
return Some(std::borrow::Cow::Borrowed(def));
|
||||
}
|
||||
let mut def = def.clone();
|
||||
def.art = def.art.iter().map(|a| bake_seeded(a).map(Arc::new)).collect::<Option<_>>()?;
|
||||
Some(std::borrow::Cow::Owned(def))
|
||||
}
|
||||
|
||||
/// Pattern `def` with each path in its art (opacity masks included) whose stroke has a brush that
|
||||
/// varies at random ([`draws_random_art`]) [`prepared`] where the pattern defines it: the brush
|
||||
/// takes its seed from the path's points, and the canvas draws the art there for every tile.
|
||||
fn random_brushes_baked<'a>(
|
||||
d: &mut Document,
|
||||
brushes: &[vectorcraft_brush::Brush],
|
||||
def: std::borrow::Cow<'a, PatternDef>,
|
||||
) -> std::borrow::Cow<'a, PatternDef> {
|
||||
if !def.art.iter().any(|a| has_random_brush(brushes, a)) {
|
||||
return def;
|
||||
}
|
||||
let mut def = def.into_owned();
|
||||
def.art = def.art.iter().map(|a| Arc::new(bake_random_brushes(d, brushes, a))).collect();
|
||||
std::borrow::Cow::Owned(def)
|
||||
}
|
||||
|
||||
/// Does brush `name` of `brushes` draw different art for different points: a calligraphic or
|
||||
/// scatter brush with Random variation, or a bristle brush?
|
||||
fn draws_random_art(brushes: &[vectorcraft_brush::Brush], name: &str) -> bool {
|
||||
use vectorcraft_brush::{BrushKind, Variation};
|
||||
brushes.iter().find(|b| b.name == name).is_some_and(|b| match &b.kind {
|
||||
BrushKind::Calligraphic(c) => c.modes().contains(&Variation::Random),
|
||||
BrushKind::Scatter(s) => s.modes().contains(&Variation::Random),
|
||||
BrushKind::Bristle(_) => true,
|
||||
BrushKind::Art(_) | BrushKind::Pattern(_) => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `n` a path with a visible stroke whose brush varies at random ([`draws_random_art`])?
|
||||
fn random_brushed(brushes: &[vectorcraft_brush::Brush], n: &Node) -> bool {
|
||||
let random = |st: &vectorcraft_doc::appearance::StrokeLayer| st.visible && st.brush.as_deref().is_some_and(|b| draws_random_art(brushes, b));
|
||||
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
&& n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if random(st)))
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a stroke whose brush varies at
|
||||
/// random ([`random_brushed`])?
|
||||
fn has_random_brush(brushes: &[vectorcraft_brush::Brush], n: &Node) -> bool {
|
||||
random_brushed(brushes, n)
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_random_brush(brushes, &m.art))
|
||||
|| (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_random_brush(brushes, c))))
|
||||
}
|
||||
|
||||
/// `n` with the paths in it whose brush varies at random [`prepared`] ([`random_brushes_baked`]).
|
||||
fn bake_random_brushes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node) -> Node {
|
||||
if !has_random_brush(brushes, n) {
|
||||
return n.clone();
|
||||
}
|
||||
if random_brushed(brushes, n) {
|
||||
return prepared(d, brushes, n);
|
||||
}
|
||||
let mut n = n.clone();
|
||||
if !matches!(n.kind, NodeKind::Compound { .. }) {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
*c = Arc::new(bake_random_brushes(d, brushes, c));
|
||||
}
|
||||
}
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
m.art = Arc::new(bake_random_brushes(d, brushes, &m.art));
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a visible geometry effect?
|
||||
fn has_geometry_effects(n: &Node) -> bool {
|
||||
let geometry = |e: &vectorcraft_doc::Effect| e.visible && vectorcraft_render::effects::is_geometry(&e.id);
|
||||
n.appearance.effects.iter().any(geometry)
|
||||
|| n.appearance.items.iter().any(|i| i.effects().iter().any(geometry))
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_geometry_effects(&m.art))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| has_geometry_effects(c)))
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks hold a symbol instance?
|
||||
fn has_instances(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::SymbolInstance { .. })
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_instances(&m.art))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| has_instances(c)))
|
||||
}
|
||||
|
||||
/// Does `a` only move (no scale, turn, flip, stretch or shear)?
|
||||
fn moves_only(a: Affine) -> bool {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let eps = 1e-9;
|
||||
(m0 - 1.0).abs() <= eps && m1.abs() <= eps && m2.abs() <= eps && (m3 - 1.0).abs() <= eps
|
||||
}
|
||||
|
||||
/// Does `n` have a visible Transform effect with Random on, its own or a fill's or stroke's?
|
||||
fn seeded_here(n: &Node) -> bool {
|
||||
let seeded =
|
||||
|e: &vectorcraft_doc::Effect| e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true);
|
||||
n.appearance.effects.iter().any(seeded) || n.appearance.items.iter().any(|i| i.effects().iter().any(seeded))
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a random Transform effect
|
||||
/// ([`seeded_here`])?
|
||||
fn has_seeded(n: &Node) -> bool {
|
||||
seeded_here(n)
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_seeded(&m.art))
|
||||
|| (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_seeded(c))))
|
||||
}
|
||||
|
||||
/// `n` with the paths in it that have a random Transform effect baked ([`seeds_baked`]). `None`
|
||||
/// when something else has one.
|
||||
fn bake_seeded(n: &Node) -> Option<Node> {
|
||||
if !has_seeded(n) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let mut n = if seeded_here(n) {
|
||||
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
return None;
|
||||
}
|
||||
vectorcraft_render::effects::bake_appearance(n)?
|
||||
} else {
|
||||
n.clone()
|
||||
};
|
||||
if !matches!(n.kind, NodeKind::Compound { .. }) {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
*c = Arc::new(bake_seeded(c)?);
|
||||
}
|
||||
}
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
m.art = Arc::new(bake_seeded(&m.art)?);
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a visible effect that [`prepared`]
|
||||
/// cannot bake: a raster or 3D effect, or a geometry effect on something other than a path?
|
||||
fn has_unbakeable_effects(n: &Node) -> bool {
|
||||
use vectorcraft_render::effects::{is_geometry, is_raster, is_three_d};
|
||||
let path = matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
|
||||
let unbakeable = |e: &vectorcraft_doc::Effect| e.visible && (is_raster(&e.id) || is_three_d(&e.id) || (!path && is_geometry(&e.id)));
|
||||
n.appearance.effects.iter().any(unbakeable)
|
||||
|| n.appearance.items.iter().any(|i| i.effects().iter().any(unbakeable))
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_unbakeable_effects(&m.art))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| has_unbakeable_effects(c)))
|
||||
}
|
||||
|
||||
/// Tile art `n` ready for a turn, flip, stretch or shear: the strokes and geometry effects of its
|
||||
/// paths baked ([`baked`], [`outline_brushed`]) and its live shapes turned into plain paths (a
|
||||
/// stretch would rebuild their corners round), in its opacity masks too.
|
||||
fn prepared(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node) -> Node {
|
||||
let mut t = baked(n);
|
||||
outline_brushed(d, brushes, &mut t);
|
||||
drop_live(&mut t);
|
||||
prepare_masks(d, brushes, &mut t);
|
||||
t
|
||||
}
|
||||
|
||||
/// The opacity masks of `n` and of everything in it [`prepared`].
|
||||
fn prepare_masks(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) {
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
m.art = Arc::new(prepared(d, brushes, &m.art));
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
prepare_masks(d, brushes, Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` with each path in it that has a brushed stroke turned into filled art and brush art
|
||||
/// ([`outline_strokes`]).
|
||||
fn outline_brushed(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) {
|
||||
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
let brushed = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some()));
|
||||
if brushed && let Some(o) = outline_strokes(d, brushes, n) {
|
||||
*n = o;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
outline_brushed(d, brushes, Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `a` a move and a positive uniform scale (which brush nibs, strokes and effects follow by
|
||||
/// scaling alone)?
|
||||
fn scales_evenly(a: Affine) -> bool {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let eps = 1e-9 * m0.abs().max(m3.abs());
|
||||
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
|
||||
}
|
||||
|
||||
/// `n` with the strokes and geometry effects of each path in it baked into filled art
|
||||
/// ([`bake_appearance`](vectorcraft_render::effects::bake_appearance)).
|
||||
fn baked(n: &Node) -> Node {
|
||||
if let Some(b) = vectorcraft_render::effects::bake_appearance(n) {
|
||||
return b;
|
||||
}
|
||||
let mut n = n.clone();
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
*c = Arc::new(baked(c));
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Pattern tiles `tiles` of fill `f` of path `l` as a clip group shaped like `l` that isolates
|
||||
/// their blending (as the canvas draws a tile on its own), with the fill's opacity, blend mode and
|
||||
/// effects other than geometry effects ([`fill_effects`]).
|
||||
fn tile_art(d: &mut Document, l: &Node, f: &FillLayer, tiles: &[Node]) -> Option<Node> {
|
||||
let mut clip = styled_copy(d, l, vec![]);
|
||||
as_clipping_path(&mut clip).ok()?;
|
||||
let tiles: Vec<Node> = tiles.iter().map(|t| d.reid(t)).collect();
|
||||
let children = std::iter::once(clip).chain(tiles).map(Arc::new).collect();
|
||||
let mut art = Node::new(d.alloc_id(), NodeKind::Group { children, clip: true });
|
||||
art.isolate = true;
|
||||
let mut art = with_transparency(d, art, f.opacity, f.blend);
|
||||
art.appearance.effects = fill_effects(f);
|
||||
Some(art)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
//! Save and Close, or Folder; Override Open / Save / Export. Cited:
|
||||
//! <https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html>
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::*;
|
||||
|
||||
@@ -125,6 +127,9 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run:
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
||||
use super::distinct_names;
|
||||
use super::screens::file_part;
|
||||
|
||||
let st = s.doc()?;
|
||||
let names: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
||||
if names.is_empty() {
|
||||
@@ -134,12 +139,16 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets")));
|
||||
}
|
||||
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
|
||||
// Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder
|
||||
// destination uses the same name). `file_part` writes `/`, `\` and `:` in the data set's name as
|
||||
// `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
|
||||
let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name))));
|
||||
let steps = filter_steps(run.steps, true, false);
|
||||
let run = Run { steps: &steps, ..*run };
|
||||
let mut processed = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for name in &names {
|
||||
match process_one_dataset(s, &run, name, &doc_stem) {
|
||||
for (name, stem) in names.iter().zip(&stems) {
|
||||
match process_one_dataset(s, &run, name, stem) {
|
||||
Ok(row) => processed.push(row),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
@@ -182,14 +191,12 @@ fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext:
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, doc_stem: &str) -> Result<Value> {
|
||||
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, stem: &str) -> Result<Value> {
|
||||
s.execute("dataset.select", &json!({"name": name}))?;
|
||||
// File + Data Set Name naming when overriding Save/Export (helpx).
|
||||
let stem = format!("{doc_stem}_{name}");
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, "vectorcraft");
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, "vectorcraft");
|
||||
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
|
||||
// Data sets share one open document: write each row out but leave it open for the next.
|
||||
finish_destination(s, run, &stem, "vectorcraft", "", false)?;
|
||||
finish_destination(s, run, stem, "vectorcraft", "", false)?;
|
||||
Ok(json!({"dataset": name, "result": batch}))
|
||||
}
|
||||
|
||||
@@ -432,6 +439,102 @@ mod tests {
|
||||
assert_eq!(written.len(), 2, "{written:?}");
|
||||
}
|
||||
|
||||
/// The names of the files in `dir`, sorted.
|
||||
fn file_names(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut names: Vec<String> =
|
||||
std::fs::read_dir(dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// A new document with an empty data set for each of `names`.
|
||||
fn with_data_sets(names: &[&str]) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
for name in names {
|
||||
s.execute("dataset.new", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_keeps_data_set_files_inside_the_destination_folder() {
|
||||
let root = tmp("set-names-root");
|
||||
let dest = root.join("out");
|
||||
// The folder that `a/../../x` would pass through on its way out of `out`.
|
||||
std::fs::create_dir_all(dest.join("Untitled_a")).unwrap();
|
||||
let mut s = with_data_sets(&["a/../../x", "../y"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_..-y.vectorcraft", "Untitled_a", "Untitled_a-..-..-x.vectorcraft"], "{r}");
|
||||
assert_eq!(file_names(&root), ["out"], "nothing is written outside the destination folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_saves_data_sets_named_with_separators() {
|
||||
let dest = tmp("set-names-sep");
|
||||
let exports = tmp("set-names-sep-export");
|
||||
let mut s = with_data_sets(&["a/b", "c\\d", "e:f"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_a-b.vectorcraft", "Untitled_c-d.vectorcraft", "Untitled_e-f.vectorcraft"], "{r}");
|
||||
// Override Export points each data set's export at the destination folder under the same name.
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destinationFolder": exports.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "document.export", "params": {"format": "svg"}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_gives_each_data_set_a_file_of_its_own() {
|
||||
let dest = tmp("set-names-same");
|
||||
let mut s = with_data_sets(&["a:b", "a-b", "A-B"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_A-B-3.vectorcraft", "Untitled_a-b-2.vectorcraft", "Untitled_a-b.vectorcraft"], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_refuses_nesting_and_empty_commands() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -99,7 +99,7 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
|
||||
/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection,
|
||||
/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left
|
||||
/// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay
|
||||
/// written.
|
||||
/// written. Lock Guides stays as the steps left it, and while it is on the guides are deselected.
|
||||
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
|
||||
let open = std::mem::take(&mut s.docs);
|
||||
s.docs = start;
|
||||
@@ -107,5 +107,8 @@ fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
|
||||
// Past the newest revision the document reached, so the restored one redraws.
|
||||
let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision);
|
||||
st.revision = newest.saturating_add(1);
|
||||
if s.menu.guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +307,32 @@ fn use_artboards(p: &Value) -> Result<Option<bool>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when
|
||||
/// absent; an error when it is not four finite numbers with a positive size, when the format isn't
|
||||
/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would
|
||||
/// contradict it). The image size limits are checked with the scale, as for an artboard.
|
||||
fn page_rect(f: &Format, p: &Value) -> Result<Option<Rect>> {
|
||||
let v = match p.get("rect") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
if !f.raster {
|
||||
return Err(bad(C, format!("rect applies to raster formats, not {}", f.label)));
|
||||
}
|
||||
if let Some(k) = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).find(|k| p.get(**k).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad(C, format!("rect and {k} contradict each other: give the area or the artboards")));
|
||||
}
|
||||
let nums: Option<Vec<f64>> = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect());
|
||||
let Some(&[x, y, w, h]) = nums.as_deref() else {
|
||||
return Err(bad(C, "rect is [x, y, width, height] in points"));
|
||||
};
|
||||
let (x1, y1) = (x + w, y + h);
|
||||
if !(x.is_finite() && y.is_finite() && w > 0.0 && h > 0.0 && x1.is_finite() && y1.is_finite()) {
|
||||
return Err(bad(C, "rect needs finite numbers and a positive width and height"));
|
||||
}
|
||||
Ok(Some(Rect::new(x, y, x1, y1)))
|
||||
}
|
||||
|
||||
/// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds).
|
||||
pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document {
|
||||
with_single_artboard(doc.clone(), rect, name)
|
||||
@@ -359,7 +385,7 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
|
||||
/// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet,
|
||||
/// features approximated or left out; every format that draws type: fonts written in the fallback
|
||||
/// font).
|
||||
/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame).
|
||||
pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
encode_all(doc, f.id, p)?.single(f)
|
||||
@@ -387,10 +413,14 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let mut enc = encode_files(doc, f, p)?;
|
||||
// Baking, flattening and drawing stop early when cancelled and leave a truncated result.
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
// An image the renderer couldn't decode draws nothing: say so rather than write a picture
|
||||
// that silently lacks it.
|
||||
if f.raster && vectorcraft_render::decode_failures() > failed {
|
||||
enc.warnings.push("an image couldn't be decoded and is missing from the picture".into());
|
||||
if f.raster
|
||||
&& let Some(w) = crate::cmd::rasterfx::undecoded_warning(failed)
|
||||
{
|
||||
enc.warnings.push(w.into());
|
||||
}
|
||||
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
|
||||
// themselves: SVG only when it outlines or embeds fonts).
|
||||
@@ -408,6 +438,15 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
let n = doc.artboards.len();
|
||||
// SVG reads its own `useArtboards` (an SVG option).
|
||||
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
|
||||
// `rect`: that area of the page, as if it were the only artboard (the document is not changed).
|
||||
if let Some(rect) = page_rect(f, p)? {
|
||||
let mut q = super::export::without_artboards(p);
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("rect");
|
||||
o.remove("useArtboards");
|
||||
}
|
||||
return encode_files(&single_artboard(doc, rect, "Rect"), f, &q);
|
||||
}
|
||||
if use_artboards == Some(false) {
|
||||
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
let mut q = super::export::without_artboards(p);
|
||||
@@ -467,7 +506,10 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
};
|
||||
let mut enc = Encoded::default();
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
for b in chosen {
|
||||
let total = chosen.len() as u64;
|
||||
for (done, b) in chosen.into_iter().enumerate() {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
vectorcraft_doc::cancel::progress(done as u64, total, &format!("Rendering artboard {} of {total}", done + 1));
|
||||
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
|
||||
check_format_size(f, region.width() * scale, region.height() * scale)?;
|
||||
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
|
||||
@@ -479,11 +521,16 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
// PNG: say the colours are sRGB (what the renderer draws), unless `embedIcc` is false.
|
||||
let bytes =
|
||||
if format == RasterFormat::Png && settings.jpeg.embed_icc { vectorcraft_render::encode::png::with_srgb(bytes) } else { bytes };
|
||||
enc.files.push((Some(b), bytes));
|
||||
}
|
||||
if enc.files.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let regions: Vec<Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
if format == RasterFormat::WebP && o.lossless == Some(false) {
|
||||
enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into());
|
||||
}
|
||||
|
||||
@@ -270,6 +270,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
if pages.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let regions: Vec<Rect> = pages.iter().map(|(_, region, _)| *region).collect();
|
||||
let transparent = doc.layers.iter().any(|l| l.shows_transparency());
|
||||
let flat = flatten_document(doc, &set.flatten)?;
|
||||
let drawn = flat.as_ref().unwrap_or(doc);
|
||||
@@ -302,6 +303,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
}
|
||||
}
|
||||
}
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +76,10 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
||||
return format("svgz");
|
||||
}
|
||||
if vectorcraft_pdf::is_postscript(bytes) {
|
||||
// PostScript (.eps, and .ai saved without PDF compatibility) by any name; .ai and .ait
|
||||
// keep their meaning (a template), read as PostScript all the same.
|
||||
return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("eps"));
|
||||
// PostScript (.eps, .ps, and .ai saved without PDF compatibility) by any name; .ai, .ait
|
||||
// and .ps keep their meaning (a template, a PostScript file), read as PostScript all the
|
||||
// same.
|
||||
return by_name.filter(|f| matches!(f.id, "ai" | "ait" | "ps")).or_else(|| format("eps"));
|
||||
}
|
||||
if bytes.starts_with(b"%PDF") {
|
||||
// .ai and .ait files are PDF inside; the extension keeps the template meaning.
|
||||
@@ -167,6 +168,12 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
|
||||
if !read {
|
||||
return (doc, warnings);
|
||||
}
|
||||
// The layers' text objects hold type and the stroked paths around it, which can include stroked
|
||||
// glyphs the PDF part kept as outlines: without any such glyph in the document, the warnings on
|
||||
// outlines are dropped.
|
||||
if !vectorcraft_pdf::holds_outlines(&doc) {
|
||||
warnings.retain(|w| !vectorcraft_pdf::is_outlines_note(w));
|
||||
}
|
||||
// The layers hold the art that the PDF part leaves out or, in a PDF, that the import clipped
|
||||
// to the page.
|
||||
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE && w != vectorcraft_pdf::PAST_PAGE_NOTE);
|
||||
@@ -197,8 +204,8 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let mut converted = false;
|
||||
let mut preview_only = false;
|
||||
let (mut doc, mut warnings, restored) = match format.id {
|
||||
// EPS and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
// EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
|
||||
restore_or_import(editing, || {
|
||||
let r = vectorcraft_eps::import_with(bytes, opts.editing_data).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
|
||||
@@ -441,24 +448,37 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
|
||||
pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC;
|
||||
|
||||
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode (its
|
||||
/// header says so; the pixels aren't read): the renderer, which has the same allowance, draws
|
||||
/// nothing for them, so Open, Place and the Links panel say it (#1479). `None` for images that
|
||||
/// decode, and for bytes that aren't an image.
|
||||
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode,
|
||||
/// counted as RGBA (4 bytes a pixel) or as the decoder gives them when that is more (its header
|
||||
/// says so; the pixels aren't read): Open, Place and the Links panel say it (#1479). `None` for
|
||||
/// images within the allowance, and for bytes that aren't an image.
|
||||
pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
use image::ImageDecoder as _;
|
||||
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
|
||||
return Some(format!("an image of {w} × {h} pixels is too large to draw"));
|
||||
}
|
||||
let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
|
||||
let (w, h) = d.dimensions();
|
||||
(d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
|
||||
// The allowance also counts the pixels as RGBA: an image of fewer than 4 bytes a pixel (gray,
|
||||
// gray with alpha, RGB) takes more than the decoder counts (#1717).
|
||||
let rgba_over = u64::from(w) * u64::from(h) > MAX_RASTER_ALLOC / 4;
|
||||
(rgba_over || d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
|
||||
}
|
||||
|
||||
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
|
||||
/// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop
|
||||
/// documents, whose merged image is read ([`psdread`]). Grayscale TIFFs with an alpha channel or
|
||||
/// other extra channels, which the decoder can't read either, become RGBA too.
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]), except that ones storing one plane per
|
||||
/// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]);
|
||||
/// CMYK TIFFs with an alpha channel, which the decoder can't read, become RGBA in the active color
|
||||
/// settings' CMYK, as do CMYK Photoshop documents, whose merged image is read ([`psdread`]).
|
||||
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
|
||||
/// either, become RGBA too, whether they store their channels together or one plane per channel.
|
||||
/// A TIFF with more strips or tiles than the decoder can count
|
||||
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw.
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let ppi = super::ppi::resolution(bytes);
|
||||
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
|
||||
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
if psdread::is_psd(bytes) {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
|
||||
@@ -469,10 +489,14 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
reader.limits(limits);
|
||||
let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?;
|
||||
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
|
||||
let cmyk = || ImageBlob::new(f.mime, bytes.to_vec()).cmyk().is_some();
|
||||
if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) {
|
||||
let kept = match f.id {
|
||||
"png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()),
|
||||
"tiff" => vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, ppi),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kept) = kept {
|
||||
let (width, height) = reader.into_dimensions().map_err(err)?;
|
||||
return stored(bytes.to_vec(), f.mime, (width, height), ppi);
|
||||
return stored(kept, f.mime, (width, height), ppi);
|
||||
}
|
||||
let extra_samples = || {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
|
||||
@@ -42,6 +42,8 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>, kin
|
||||
if enc.files.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
@@ -186,7 +186,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Batch…",
|
||||
[],
|
||||
None,
|
||||
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for Override Save/Export). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
|
||||
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\` and `:` in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
|
||||
always,
|
||||
action_batch::action_batch
|
||||
),
|
||||
@@ -311,6 +311,12 @@ const USE_ARTBOARDS: FormatOption = FormatOption {
|
||||
default: "null",
|
||||
description: "true: every chosen artboard (default all), one file each named <file>-<artboard>.<ext> (PDF and AI: one page each); false: the bounds of the visible art",
|
||||
};
|
||||
const RECT: FormatOption = FormatOption {
|
||||
name: "rect",
|
||||
ty: "array",
|
||||
default: "null",
|
||||
description: "[x, y, width, height] in document points, y down: exports that area of the page (no artboard needed, the document is not changed); not with artboard, artboards, range or useArtboards",
|
||||
};
|
||||
const PPI: FormatOption = FormatOption {
|
||||
name: "ppi",
|
||||
ty: "number",
|
||||
@@ -348,7 +354,7 @@ const EMBED_ICC: FormatOption = FormatOption {
|
||||
name: "embedIcc",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve",
|
||||
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve; PNG: mark the file as sRGB (an sRGB chunk)",
|
||||
};
|
||||
const IMAGE_MAP: FormatOption = FormatOption {
|
||||
name: "imageMap",
|
||||
@@ -398,6 +404,7 @@ const PALETTE_OPTIONS: &[FormatOption] = &[
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
@@ -419,7 +426,7 @@ const BYTE_ORDER: FormatOption = FormatOption {
|
||||
description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)",
|
||||
};
|
||||
const TIFF_OPTIONS: &[FormatOption] =
|
||||
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
|
||||
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
|
||||
const BMP_COLOR_MODEL: FormatOption =
|
||||
FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" };
|
||||
const BMP_DEPTH: FormatOption = FormatOption {
|
||||
@@ -447,6 +454,7 @@ const BMP_OPTIONS: &[FormatOption] = &[
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
@@ -466,7 +474,7 @@ const TGA_DEPTH: FormatOption = FormatOption {
|
||||
default: "24",
|
||||
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
|
||||
};
|
||||
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
|
||||
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
|
||||
const PSD_LAYERS: FormatOption = FormatOption {
|
||||
name: "layers",
|
||||
ty: "boolean",
|
||||
@@ -490,6 +498,7 @@ const PSD_OPTIONS: &[FormatOption] = &[
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
@@ -560,7 +569,7 @@ pub const FORMATS: &[Format] = &[
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED],
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC],
|
||||
},
|
||||
Format {
|
||||
id: "jpg",
|
||||
@@ -575,6 +584,7 @@ pub const FORMATS: &[Format] = &[
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
@@ -596,7 +606,7 @@ pub const FORMATS: &[Format] = &[
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
|
||||
},
|
||||
Format {
|
||||
id: "tiff",
|
||||
@@ -625,7 +635,7 @@ pub const FORMATS: &[Format] = &[
|
||||
Format {
|
||||
id: "eps",
|
||||
label: "EPS",
|
||||
extensions: &["eps"],
|
||||
extensions: &["eps", "epsf"],
|
||||
mime: "application/postscript",
|
||||
read: true,
|
||||
write: true,
|
||||
@@ -641,6 +651,8 @@ pub const FORMATS: &[Format] = &[
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
|
||||
// Photoshop's large document format: read like a PSD (its merged image), never written.
|
||||
reader("psb", "PSB", &["psb"], "image/x-psb", true),
|
||||
// PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't.
|
||||
reader("ps", "PostScript", &["ps"], "application/postscript", false),
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
@@ -667,6 +679,8 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"emf",
|
||||
"wmf",
|
||||
"eps",
|
||||
"epsf",
|
||||
"ps",
|
||||
"af",
|
||||
"afdesign",
|
||||
"afpub",
|
||||
@@ -674,8 +688,9 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
|
||||
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
|
||||
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
|
||||
/// them (File › Open and Place still read them).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb"];
|
||||
/// them, and the packaging declares no `.epsf` or `.ps` type (File › Open and Place still read
|
||||
/// them all).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps"];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
|
||||
@@ -692,7 +707,10 @@ pub fn export_extensions() -> Vec<&'static str> {
|
||||
/// Text files: File → Place sets them as area type (Text Import Options).
|
||||
pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`].
|
||||
/// RTF files: File → Place sets them as area type with their formatting.
|
||||
pub const RTF_EXTS: &[&str] = &["rtf"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`] and [`RTF_EXTS`].
|
||||
pub const PLACE_EXTS: &[&str] = &[
|
||||
"vectorcraft",
|
||||
"drawcraft",
|
||||
@@ -716,10 +734,13 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"emf",
|
||||
"wmf",
|
||||
"eps",
|
||||
"epsf",
|
||||
"ps",
|
||||
"af",
|
||||
"afdesign",
|
||||
"afpub",
|
||||
"txt",
|
||||
"rtf",
|
||||
];
|
||||
|
||||
/// One dialog filter per readable format.
|
||||
@@ -743,11 +764,13 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text.
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text
|
||||
/// and RTF.
|
||||
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once(("All placeable files", PLACE_EXTS))
|
||||
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
|
||||
.chain(std::iter::once(("Text", TEXT_EXTS)))
|
||||
.chain(std::iter::once(("RTF", RTF_EXTS)))
|
||||
}
|
||||
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
|
||||
@@ -851,6 +874,8 @@ pub(crate) fn read_file_head(path: &str, max: usize) -> Result<Vec<u8>> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
// Work that stops early for a cancel can leave a truncated result: none of it is written.
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
may_write(path)?;
|
||||
// Never a half-written file: see [`write_atomic`].
|
||||
write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
@@ -859,6 +884,7 @@ pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
/// [`write_file`] with what `fill` writes into the file (a save streamed into it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
may_write(path)?;
|
||||
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
@@ -1063,14 +1089,18 @@ pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) ->
|
||||
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
|
||||
/// (compared without case).
|
||||
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
distinct_names(names.into_iter().map(|(name, fallback)| {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() { fallback } else { base }
|
||||
}))
|
||||
}
|
||||
|
||||
/// `names` with `-2`, `-3`… after a name already used (compared without case).
|
||||
fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
names
|
||||
.into_iter()
|
||||
.map(|(name, fallback)| {
|
||||
let mut base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() {
|
||||
base = fallback;
|
||||
}
|
||||
.map(|base| {
|
||||
let mut name = base.clone();
|
||||
for i in 2.. {
|
||||
if taken.insert(name.to_lowercase()) {
|
||||
|
||||
@@ -169,6 +169,7 @@ pub(crate) fn merge(base: &mut Value, over: &Value) {
|
||||
pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
|
||||
match e {
|
||||
PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()),
|
||||
PdfError::Cancelled => EngineError::Cancelled,
|
||||
e => EngineError::Other(e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -253,9 +254,21 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Stri
|
||||
let (full, mut warnings) = crate::cmd::place::document::full_documents(doc);
|
||||
let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)))?;
|
||||
warnings.extend(drawn_warnings);
|
||||
warnings.extend(overflow_warning(cmd, &full, p)?);
|
||||
Ok((bytes, warnings))
|
||||
}
|
||||
|
||||
/// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and
|
||||
/// whose text doesn't fit (see [`crate::cmd::fonts::export_overflow_warning`]): on the pages
|
||||
/// chosen, leaving out the layers that don't print unless the PDF writes them.
|
||||
fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result<Option<String>> {
|
||||
let opts = options(cmd, doc, p)?;
|
||||
let areas = vectorcraft_pdf::page_areas(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
let set = &opts.settings;
|
||||
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
|
||||
Ok(crate::cmd::fonts::export_overflow_warning(doc, printed.as_deref().unwrap_or(&doc.layers), Some(&areas)))
|
||||
}
|
||||
|
||||
/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
|
||||
/// native document with its save options, whose pages may be left blank).
|
||||
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
@@ -273,7 +286,11 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
|
||||
}
|
||||
}
|
||||
let set = &opts.settings;
|
||||
if set.pdf13() {
|
||||
vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency");
|
||||
}
|
||||
let flat = flatten_for(cmd, doc, set, p)?;
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
if flat.is_some() {
|
||||
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
|
||||
warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"));
|
||||
|
||||
@@ -185,6 +185,9 @@ fn parent_folder(path: &str) -> Option<String> {
|
||||
fn save_format_of(f: &str) -> std::result::Result<&'static Format, String> {
|
||||
match format(f) {
|
||||
Some(f) if SAVE_FORMATS.contains(&f.id) => Ok(f),
|
||||
Some(f) if TEMPLATE_FORMATS.contains(&f.id) => {
|
||||
Err(format!("Save writes {}, not {}: use file.saveAsTemplate to write it", SAVE_FORMATS.join(", "), f.label))
|
||||
}
|
||||
Some(f) => Err(format!("Save writes {}, not {}: use document.export for other formats", SAVE_FORMATS.join(", "), f.label)),
|
||||
None => Err(format!("unknown format `{f}` (Save writes {})", SAVE_FORMATS.join(", "))),
|
||||
}
|
||||
@@ -196,6 +199,18 @@ pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Res
|
||||
save_format_of(format.or_else(|| path.and_then(format_for_name).map(|f| f.id)).unwrap_or("vectorcraft"))
|
||||
}
|
||||
|
||||
/// `path` for a template in format `f`: kept when its extension names `f`, with the extension
|
||||
/// replaced when it names another format (`card.png` → `card.vctemplate`), else with `f`'s
|
||||
/// extension added (`card`, `card v1.2`).
|
||||
fn template_path(path: &str, f: &Format) -> String {
|
||||
let ext = f.extensions[0];
|
||||
match format_for_name(path) {
|
||||
Some(g) if g.id == f.id => path.to_string(),
|
||||
Some(_) => std::path::Path::new(path).with_extension(ext).to_string_lossy().into_owned(),
|
||||
None => format!("{path}.{ext}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`save_format_of`] for command `cmd`.
|
||||
fn checked(cmd: &str, f: &str) -> Result<&'static Format> {
|
||||
save_format_of(f).map_err(|e| bad(cmd, e))
|
||||
@@ -217,6 +232,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
(_, None, Some(f)) => checked(cmd, f.id)?,
|
||||
(_, None, None) => checked(cmd, st.format)?,
|
||||
};
|
||||
let path = if mode == SaveMode::Template { path.map(|p| template_path(&p, format)) } else { path };
|
||||
let mut given: Map<String, Value> = match p.get("options") {
|
||||
Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(),
|
||||
None | Some(Value::Null) => Map::new(),
|
||||
@@ -280,17 +296,18 @@ fn is_svg(f: &Format) -> bool {
|
||||
}
|
||||
|
||||
/// The document as written: native files carry the view to reopen at and the Layers panel's open
|
||||
/// rows.
|
||||
/// rows and transparency grid visibility.
|
||||
fn doc_to_save(st: &DocState, f: &Format) -> Arc<Document> {
|
||||
if !is_native(f) {
|
||||
return st.doc.clone();
|
||||
}
|
||||
let open = st.layers_open.saved(&st.doc);
|
||||
if st.doc.last_view == st.view && st.doc.layers_open == open {
|
||||
if st.doc.last_view == st.view && st.doc.layers_open == open && st.doc.transparency_grid == st.transparency_grid {
|
||||
return st.doc.clone();
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
d.last_view = st.view.clone();
|
||||
d.transparency_grid = st.transparency_grid;
|
||||
d.layers_open = open;
|
||||
Arc::new(d)
|
||||
}
|
||||
@@ -631,7 +648,8 @@ impl SaveJob {
|
||||
|
||||
/// Once written: Save and Save As (except to a template) make the file `st`'s own: path,
|
||||
/// title, format, options, and the snapshot as the saved state (edits made since the job was
|
||||
/// made keep the document modified).
|
||||
/// made keep the document modified; changes that are not edits to save don't,
|
||||
/// [`DocState::same_content`]).
|
||||
pub fn finish(self, st: &mut DocState) {
|
||||
let retargets = self.plan.retargets();
|
||||
if let (true, Some(path)) = (retargets, self.plan.path) {
|
||||
@@ -643,6 +661,12 @@ impl SaveJob {
|
||||
if let Some(at) = self.stamped {
|
||||
if Arc::ptr_eq(&st.doc, &self.source) {
|
||||
st.doc = self.snapshot.clone();
|
||||
} else if st.same_content(&self.source) {
|
||||
// Changed while the save ran only by changes that are not edits to save
|
||||
// (Puppet Warp pins dropped, Edit Text ended, type laid out again after a font
|
||||
// scan): with the dates, the document counts as saved.
|
||||
stamp_save_dates(st, at);
|
||||
return st.mark_saved();
|
||||
} else {
|
||||
// Edited while the save ran: the dates still go on, the edits stay unsaved. The
|
||||
// undo history holds the document as it was before the dates, so that is the
|
||||
@@ -755,7 +779,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save As…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+S"),
|
||||
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
|
||||
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::SaveAs, p)
|
||||
),
|
||||
@@ -773,7 +797,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{…as document.save} {path?, format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
"{…as document.save} {path? (saved with the format's extension: one naming another format is replaced, and one is added to a name without it), format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Template, p)
|
||||
),
|
||||
|
||||
@@ -148,9 +148,9 @@ fn safe_name(s: &str) -> Option<String> {
|
||||
(!n.trim_matches('.').is_empty()).then_some(n)
|
||||
}
|
||||
|
||||
/// Text that goes into a file name (prefix, suffix): path separators and `:` become `-`, so it can't
|
||||
/// start a folder or climb out of the export folder.
|
||||
fn file_part(s: &str) -> String {
|
||||
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch): path separators and
|
||||
/// `:` become `-`, so the name cannot make a subfolder or point outside the folder the file is written to.
|
||||
pub(super) fn file_part(s: &str) -> String {
|
||||
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,10 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
// that preparation out of the native attachment so missing links remain relinkable.
|
||||
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
|
||||
let doc = &*full;
|
||||
// A chosen artboard's file holds the art over it; otherwise the file holds all of it.
|
||||
let regions: Option<Vec<vectorcraft_geom::Rect>> =
|
||||
chosen.then(|| boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect());
|
||||
let overflow = crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, regions.as_deref());
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
@@ -184,5 +188,6 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
{
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
enc.warnings.extend(overflow);
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
@@ -114,6 +114,100 @@ fn cmyk_tiffs_keep_their_inks_and_other_tiffs_become_png() {
|
||||
assert_eq!(rgb.blob.mime, "image/png");
|
||||
}
|
||||
|
||||
/// A 3 × 2 TIFF at 150 ppi of `channels` (`extra`: their ExtraSamples values), 8 or 16 `bits` a
|
||||
/// sample, chunky or `planar`.
|
||||
fn hand_tiff(photometric: u16, bits: u16, extra: &[u16], planar: bool, channels: &[Vec<u16>]) -> Vec<u8> {
|
||||
let t = vectorcraft_testkit::tiff::Tiff { width: 3, height: 2, photometric, bits, extra, planar, ppi: Some(150), channels };
|
||||
vectorcraft_testkit::tiff::tiff(&t)
|
||||
}
|
||||
|
||||
/// What Place keeps of TIFF `bytes`: the ink amounts, when it keeps them, and the pixels any image
|
||||
/// reader decodes from the stored image, as RGBA.
|
||||
fn kept(what: &str, bytes: &[u8]) -> (Option<Vec<u8>>, Vec<u8>) {
|
||||
let r = raster_image(bytes).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!((r.width, r.height, r.ppi), (3, 2, Some((150.0, 150.0))), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&r.blob.bytes), Some((150.0, 150.0)), "{what}: the stored image keeps the resolution");
|
||||
let rgba = image::load_from_memory(&r.blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_rgba8().into_raw();
|
||||
(r.blob.cmyk().map(|i| i.data), rgba)
|
||||
}
|
||||
|
||||
/// TIFFs that store one plane per channel (PlanarConfiguration 2) read as their chunky versions do:
|
||||
/// grayscale and RGB with extra channels, and CMYK with and without them (#1466).
|
||||
#[test]
|
||||
fn planar_tiffs_read_as_their_chunky_versions() {
|
||||
let ch = |v: [u16; 6]| v.to_vec();
|
||||
let (gray, alpha, spot) = (ch([0, 51, 102, 153, 204, 255]), ch([255, 128, 0, 255, 64, 255]), ch([9, 8, 7, 6, 5, 4]));
|
||||
let (r, g, b) = (ch([255, 0, 0, 10, 20, 30]), ch([0, 255, 0, 40, 50, 60]), ch([0, 0, 255, 70, 80, 90]));
|
||||
let (c, m, y, k) = (ch([255, 0, 0, 0, 12, 200]), ch([0, 255, 0, 0, 34, 100]), ch([0, 0, 255, 0, 56, 50]), ch([0, 0, 0, 255, 78, 0]));
|
||||
// Pixel by pixel, the samples of `px`.
|
||||
let interleave = |px: &[&Vec<u16>]| -> Vec<u8> { (0..6).flat_map(|i| px.iter().map(move |v| v[i] as u8)).collect() };
|
||||
let opaque = ch([255; 6]);
|
||||
let inks = interleave(&[&c, &m, &y, &k]);
|
||||
// (photometric, ExtraSamples, channels, the RGBA pixels when the color is gray or RGB, the inks when it is CMYK)
|
||||
type Case = (u16, Vec<u16>, Vec<Vec<u16>>, Option<Vec<u8>>, Option<Vec<u8>>);
|
||||
let cases: Vec<Case> = vec![
|
||||
(1, vec![2], vec![gray.clone(), alpha.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None),
|
||||
(1, vec![2, 0], vec![gray.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None),
|
||||
(2, vec![0], vec![r.clone(), g.clone(), b.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &opaque])), None),
|
||||
(2, vec![2, 0], vec![r.clone(), g.clone(), b.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &alpha])), None),
|
||||
(5, vec![], vec![c.clone(), m.clone(), y.clone(), k.clone()], None, Some(inks.clone())),
|
||||
(5, vec![0], vec![c.clone(), m.clone(), y.clone(), k.clone(), spot.clone()], None, Some(inks.clone())),
|
||||
(5, vec![2], vec![c.clone(), m.clone(), y.clone(), k.clone(), alpha.clone()], None, None),
|
||||
];
|
||||
for (photometric, extra, channels, want_rgba, want_inks) in cases {
|
||||
for bits in [8, 16] {
|
||||
let wide: Vec<Vec<u16>> = channels.iter().map(|ch| ch.iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect();
|
||||
let what = format!("photometric {photometric}, extra samples {extra:?}, {bits} bits");
|
||||
let chunky = kept(&format!("{what}, chunky"), &hand_tiff(photometric, bits, &extra, false, &wide));
|
||||
let planar = kept(&format!("{what}, planar"), &hand_tiff(photometric, bits, &extra, true, &wide));
|
||||
assert_eq!(planar.0, chunky.0, "{what}: planar keeps the inks chunky keeps");
|
||||
assert_eq!(planar.0, want_inks, "{what}: the inks");
|
||||
// A 16-bit chunky CMYK file is stored as it is, and a planar one is written again with
|
||||
// its 8-bit inks: their RGB pixels may be a level apart.
|
||||
let close = planar.1.len() == chunky.1.len() && planar.1.iter().zip(&chunky.1).all(|(a, b)| a.abs_diff(*b) <= 1);
|
||||
assert!(close, "{what}: planar reads as chunky: {:?} against {:?}", planar.1, chunky.1);
|
||||
if let Some(want) = &want_rgba {
|
||||
assert_eq!(&planar.1, want, "{what}: the pixels");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows
|
||||
/// a strip each, times 5 planes) is refused as too large to draw, without a panic.
|
||||
#[test]
|
||||
fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
|
||||
let plane = |v: u16| vec![v; 6];
|
||||
let mut file = hand_tiff(5, 8, &[0], true, &[plane(200), plane(40), plane(10), plane(0), plane(90)]);
|
||||
// ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the
|
||||
// eighth, at offset 102).
|
||||
file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes());
|
||||
file[102..106].copy_from_slice(&1u32.to_le_bytes());
|
||||
let e = raster_image(&file).err().expect("refused");
|
||||
assert!(e.to_string().contains("an image of 3 × 2147483649 pixels is too large to draw"), "{e}");
|
||||
assert!(too_large_note(&file).is_some());
|
||||
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
|
||||
}
|
||||
|
||||
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
|
||||
/// reads (#1466).
|
||||
#[test]
|
||||
fn planar_cmyk_tiffs_open_and_place_with_their_inks() {
|
||||
let inks = [vec![255u16, 0, 12, 0, 40, 9], vec![0, 255, 34, 0, 50, 8], vec![0, 0, 56, 0, 60, 7], vec![0, 0, 78, 255, 70, 6]];
|
||||
let file = hand_tiff(5, 8, &[], true, &inks);
|
||||
let want: Vec<u8> = (0..6).flat_map(|i| inks.iter().map(move |v| v[i] as u8)).collect();
|
||||
assert!(image::load_from_memory(&file).is_err(), "the image decoder reads planar CMYK now");
|
||||
let mut s = Session::new();
|
||||
open(&mut s, "planar.tif", &file);
|
||||
let blob = &s.doc().unwrap().doc.images[&image_of(&s).key];
|
||||
assert_eq!(blob.cmyk().map(|i| i.data), Some(want.clone()), "Open keeps the inks");
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
s.execute("file.place", &json!({"name": "planar.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
|
||||
let blob = &s.doc().unwrap().doc.images[&image_of(&s).key];
|
||||
assert_eq!(blob.cmyk().map(|i| i.data), Some(want), "Place keeps the inks");
|
||||
assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_beats_a_wrong_extension() {
|
||||
let png = image_bytes(2, 2, image::ImageFormat::Png);
|
||||
@@ -129,7 +223,8 @@ fn templates_open_untitled() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let dir = tmp_dir("tpl");
|
||||
let path = dir.join("t.vectorcraft").to_string_lossy().to_string();
|
||||
s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
|
||||
// A template under a native file name still opens untitled.
|
||||
std::fs::write(&path, b64(&s.execute("file.saveAsTemplate", &json!({})).unwrap())).unwrap();
|
||||
let r = s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
|
||||
@@ -303,6 +303,144 @@ fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part()
|
||||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
}
|
||||
|
||||
/// `data` as ASCII85, up to the `~>`.
|
||||
fn ascii85(data: &[u8]) -> String {
|
||||
let mut out = String::new();
|
||||
for chunk in data.chunks(4) {
|
||||
let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
|
||||
let mut d = [0u8; 5];
|
||||
for c in d.iter_mut().rev() {
|
||||
*c = (x % 85) as u8 + b'!';
|
||||
x /= 85;
|
||||
}
|
||||
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
|
||||
}
|
||||
out.push_str("~>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Page content drawing one glyph and then `rest` in 10 pt on the baseline at `y`, from x = 14, in
|
||||
/// rendering mode `mode` (0 fills the type, 1 strokes it). The glyph is code 0x41 of the font `F2`,
|
||||
/// standing for the "fi" of the story, and `F2` isn't installed and its `ToUnicode` map gives that
|
||||
/// glyph U+FFFD, so that the PDF part keeps it as outlines; `rest` is in Helvetica (`F1`).
|
||||
fn half_outlined_line(y: f64, rest: &str, mode: u8) -> String {
|
||||
format!("{mode} Tr BT /F2 10 Tf 14 {y} Td (A) Tj ET BT /F1 10 Tf 19 {y} Td ({rest}) Tj ET ")
|
||||
}
|
||||
|
||||
/// A `.ai` whose page draws a gray square and `type_content` (see [`half_outlined_line`]), and whose
|
||||
/// layer `Art` has the square and text objects: the first of story 0, which `story` gives in the
|
||||
/// text document (a line starting at (14, 65)), then `more` of a story the text document doesn't
|
||||
/// have. Without `story` the file has no text document.
|
||||
fn half_outlined_ai(story: Option<&str>, more: usize, type_content: &str) -> Vec<u8> {
|
||||
let text = |story: u32| format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n;\n");
|
||||
let texts: String = std::iter::once(text(0)).chain((0..more).map(|_| text(99))).collect();
|
||||
let mut body =
|
||||
format!("%AI3_TemplateBox: 100 50 100 50\n{}", ai::layer("Art", &format!("0.5 g\n{}f\n{texts}", ai::rect(120.0, 20.0, 160.0, 60.0))));
|
||||
if let Some(story) = story {
|
||||
// The story's one line starts at the canvas center (8191.5, the template box's center) moved
|
||||
// to (14, 65): its y runs down the page.
|
||||
let n = story.chars().count();
|
||||
let document = format!(
|
||||
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\
|
||||
/1 << /1 [ << /0 << /0 ({story}\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {} >> ] >> \
|
||||
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 10.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 0.0 0.0 1.0 ] >> >> >> >> >> /1 {} >> ] >> >> \
|
||||
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8105.5 8176.5 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
|
||||
<< /99 /S /0 << /0 [ 0.0 0.0 ] >> /15 << /0 {} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >> ] /2 << /1 12.0 >> >>\n",
|
||||
n + 1,
|
||||
n + 1,
|
||||
n + 1
|
||||
);
|
||||
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||||
body.push_str(&format!("%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n", lines.join("\n")));
|
||||
}
|
||||
let data = ai::editing_data(200.0, 100.0, &body);
|
||||
let content = format!("0.5 g 120 20 40 40 re f 0.2 g 0.2 G {type_content}");
|
||||
let page = PdfPage {
|
||||
resources: format!(
|
||||
"/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> \
|
||||
/F2 << /Type /Font /Subtype /Type1 /BaseFont /VectorCraftTestUnnamed /ToUnicode {} 0 R >> >>",
|
||||
first_extra(1) + 1
|
||||
),
|
||||
entries: format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
|
||||
..PdfPage::new(200.0, 100.0, &content)
|
||||
};
|
||||
let cmap = "begincmap\n1 begincodespacerange\n<00> <FF>\nendcodespacerange\n1 beginbfchar\n<41> <FFFD>\nendbfchar\nendcmap\n";
|
||||
pdf_with(
|
||||
&[page],
|
||||
&[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len()), &format!("<< /Length {} >>\nstream\n{cmap}endstream", cmap.len())],
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn outlines_notes(r: &Value) -> Vec<String> {
|
||||
let notes = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str);
|
||||
notes.filter(|w| vectorcraft_pdf::is_outlines_note(w)).map(String::from).collect()
|
||||
}
|
||||
|
||||
/// The notes the PDF part of [`half_outlined_ai`] reads with: its glyph of U+FFFD, kept as outlines.
|
||||
fn pdf_part_outlines_notes() -> [String; 2] {
|
||||
[format!("text in Vector Craft Test Unnamed {}", vectorcraft_pdf::UNNAMED_CHARACTERS_NOTE), vectorcraft_pdf::OUTLINED_TEXT_NOTE.into()]
|
||||
}
|
||||
|
||||
/// The note on text objects that hold the page's type without some of its characters, for `n` of
|
||||
/// them.
|
||||
fn without_outlines_note(n: usize) -> Value {
|
||||
json!(format!("{n} text objects hold their type as the page draws it, without the characters the page has only as outlines"))
|
||||
}
|
||||
|
||||
/// The texts in the first layer.
|
||||
fn texts(s: &Session) -> Vec<String> {
|
||||
let art = s.doc().unwrap().doc.layers[0].children().unwrap();
|
||||
art.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// The note that glyphs were kept as outlines is dropped when the type comes from the editing data's
|
||||
/// text document, and stays with the PDF part alone.
|
||||
#[test]
|
||||
fn type_from_the_text_document_opens_without_the_pdf_parts_note_on_outlines() {
|
||||
let file = half_outlined_ai(Some("first draft"), 0, &half_outlined_line(65.0, "rst draft", 0));
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "draft.ai", &file, json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert_eq!(texts(&s), ["first draft"], "{r}");
|
||||
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
|
||||
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
|
||||
let r = open(&mut s, "draft.ai", &file, json!({"editingData": false})).unwrap();
|
||||
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
|
||||
// A second text object that takes the page's type, away from the glyph: no note on it.
|
||||
let page = half_outlined_line(65.0, "rst draft", 0) + "BT /F1 10 Tf 14 30 Td (notes) Tj ET";
|
||||
let r = open(&mut s, "notes.ai", &half_outlined_ai(Some("first draft"), 1, &page), json!({})).unwrap();
|
||||
assert_eq!(texts(&s), ["first draft", "notes"], "{r}");
|
||||
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
|
||||
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
|
||||
}
|
||||
|
||||
/// A text object that takes the page's type lacks the glyphs next to it that the page kept as
|
||||
/// outlines: a note counts such objects, in place of the PDF part's notes. Stroked glyphs that
|
||||
/// come in with the stroked type keep the PDF part's notes.
|
||||
#[test]
|
||||
fn type_from_the_page_opens_with_a_note_on_the_characters_it_has_only_as_outlines() {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "draft.ai", &half_outlined_ai(None, 0, &half_outlined_line(65.0, "rst draft", 0)), json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert_eq!(texts(&s), ["rst draft"], "{r}");
|
||||
assert!(!vectorcraft_pdf::holds_outlines(&s.doc().unwrap().doc), "{r}");
|
||||
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
|
||||
assert!(r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
|
||||
// Stroked: the glyph comes in with the type.
|
||||
let r = open(&mut s, "draft.ai", &half_outlined_ai(None, 0, &half_outlined_line(65.0, "rst draft", 1)), json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert!(vectorcraft_pdf::holds_outlines(&s.doc().unwrap().doc), "{r}");
|
||||
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
|
||||
assert!(!r["warnings"].to_string().contains("without the characters"), "{r}");
|
||||
// A stroked line whose glyph comes in, and a filled one whose glyph doesn't.
|
||||
let page = half_outlined_line(65.0, "rst draft", 1) + &half_outlined_line(30.0, "nd more", 0);
|
||||
let r = open(&mut s, "both.ai", &half_outlined_ai(None, 1, &page), json!({})).unwrap();
|
||||
assert_eq!(texts(&s), ["rst draft", "nd more"], "{r}");
|
||||
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
|
||||
assert!(r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` saved in units of 10 points: its PDF part's page is 200 × 100 with `/UserUnit 10`, and
|
||||
/// its editing data has the artboard and a red rectangle in the same units. It opens at 2000 × 1000
|
||||
/// pt, from its editing data and from its PDF part alone, with the rectangle ten times as large,
|
||||
|
||||
@@ -172,3 +172,44 @@ fn eps_files_place_as_one_group_clipped_to_their_bounding_box() {
|
||||
assert!(format("eps").is_some_and(|f| f.read && f.write));
|
||||
assert!(OPEN_EXTS.contains(&"eps") && PLACE_EXTS.contains(&"eps"));
|
||||
}
|
||||
|
||||
/// PostScript `.ps` files open and place as EPS files do, under a PostScript format that exports
|
||||
/// don't write, and `.epsf` files are EPS files; the open and place dialogs list both.
|
||||
#[test]
|
||||
fn ps_and_epsf_files_open_and_place() {
|
||||
use crate::cmd::clipboard::{EPS, PASTE_ORDER, file_flavour};
|
||||
// Pages without a bounding box (each artboard is a letter page), one of them a program whose
|
||||
// header is only `%!`.
|
||||
let page = b"%!PS-Adobe-3.0\n%%Pages: 1\n%%EndComments\n%%Page: 1 1\n1 0 0 setrgbcolor\n\
|
||||
newpath 72 72 moveto 144 0 rlineto 0 72 rlineto -144 0 rlineto closepath fill\nshowpage\n%%EOF\n";
|
||||
let bare = b"%!\n0 0 1 setrgbcolor\n100 100 50 50 rectfill\nshowpage\n";
|
||||
let mut s = Session::new();
|
||||
for (name, bytes, id) in [("page.ps", &page[..], "ps"), ("PAGE.PS", page, "ps"), ("bare.ps", bare, "ps"), ("bare.epsf", bare, "eps")] {
|
||||
let r = open(&mut s, name, bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!((&r["format"], &r["warnings"]), (&json!(id), &json!([])), "{name}: {r}");
|
||||
assert_eq!(objects(&s), 1, "{name}");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(612.0, 792.0), "{name}");
|
||||
}
|
||||
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
|
||||
for (name, bytes, id) in [("page.ps", &page[..], "ps"), ("bare.ps", bare, "ps"), ("bare.epsf", bare, "eps")] {
|
||||
let r = s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
|
||||
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!(id), Some(1)), "{name}: {r}");
|
||||
}
|
||||
// Copied in a file manager, they paste as EPS.
|
||||
for (name, bytes) in [("page.ps", &page[..]), ("bare.ps", bare), ("bare.epsf", bare), ("bare.eps", bare)] {
|
||||
assert_eq!(file_flavour(name, bytes, &PASTE_ORDER), Some(EPS), "{name}");
|
||||
let r = s
|
||||
.execute("clipboard.importEps", &json!({"dataBase64": vectorcraft_format::base64_encode(bytes)}))
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r["count"], json!(1), "{name}: {r}");
|
||||
}
|
||||
// The dialogs list them, and the desktop packaging doesn't associate them with the app.
|
||||
assert!(format("ps").is_some_and(|f| f.read && !f.write) && format("epsf").is_some_and(|f| f.id == "eps" && f.write));
|
||||
for e in ["ps", "epsf"] {
|
||||
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
|
||||
}
|
||||
assert!(open_filters().any(|f| f == ("PostScript", &["ps"][..])) && place_filters().any(|f| f == ("PostScript", &["ps"][..])));
|
||||
// Exports write no .ps: Print to PostScript does.
|
||||
let e = writable_format(None, Some("out.ps")).unwrap_err();
|
||||
assert_eq!(e, "PostScript files can be opened but not written (see document.formats)");
|
||||
}
|
||||
|
||||
@@ -201,6 +201,37 @@ fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() {
|
||||
assert!(vectorcraft_pdf::editing(&b64(&r)).is_some_and(|e| e.intact));
|
||||
}
|
||||
|
||||
/// Save as Template writes a file named with its template format's extension: another format's
|
||||
/// extension is replaced and a name without one gets it; Save and Save As name the command that
|
||||
/// writes a `.ait`.
|
||||
#[test]
|
||||
fn save_as_template_names_its_file_after_the_template_format() {
|
||||
let mut s = rich();
|
||||
for (name, format, saved) in [
|
||||
("card.png", None, "card.vctemplate"),
|
||||
("card.vectorcraft", None, "card.vctemplate"),
|
||||
("card.ait", Some("template"), "card.vctemplate"),
|
||||
("card.pdf", Some("ait"), "card.ait"),
|
||||
("card v1.2", None, "card v1.2.vctemplate"),
|
||||
] {
|
||||
let (typed, want) = (tmp(&format!("named/{name}")), tmp(&format!("named/{saved}")));
|
||||
let _ = std::fs::create_dir_all(std::path::Path::new(&typed).parent().unwrap());
|
||||
let _ = std::fs::remove_file(&typed);
|
||||
let _ = std::fs::remove_file(&want);
|
||||
let r = s.execute("file.saveAsTemplate", &json!({"path": typed, "format": format})).unwrap();
|
||||
assert_eq!(r["path"], want.as_str(), "{name}: {r}");
|
||||
let bytes = std::fs::read(&want).unwrap_or_else(|e| panic!("{name}: {saved} not written: {e}"));
|
||||
let ait = saved.ends_with(".ait");
|
||||
assert_eq!(bytes.starts_with(b"%PDF"), ait, "{name}");
|
||||
assert!(ait || vectorcraft_format::load(&bytes).is_ok_and(|d| d.template), "{name}: a native template");
|
||||
assert!(!std::path::Path::new(&typed).exists(), "{name}: nothing written under the typed name");
|
||||
}
|
||||
for (cmd, p) in [("file.saveAs", json!({"format": "ait"})), ("document.save", json!({"path": tmp("card.ait")}))] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("file.saveAsTemplate") && !e.contains("document.export"), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standards_and_screens_leave_the_editing_data_out() {
|
||||
let mut s = rich();
|
||||
|
||||
@@ -159,6 +159,21 @@ fn black_output_rich_covers_ink_plane_documents_and_leaves_cmyk_exports_alone()
|
||||
assert!(inks == [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")], "CMYK exports keep the document's inks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_says_it_is_srgb_unless_embed_icc_is_false() {
|
||||
let mut s = session(40.0, 30.0, 1);
|
||||
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
|
||||
let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap());
|
||||
let on = png(&mut s, json!({"format": "png"}));
|
||||
assert_eq!(png_chunk(&on, b"sRGB"), Some(&[0u8][..]), "an sRGB chunk, perceptual intent");
|
||||
let off = png(&mut s, json!({"format": "png", "embedIcc": false}));
|
||||
assert!(png_chunk(&off, b"sRGB").is_none());
|
||||
assert_eq!(rgba(&on), rgba(&off), "same pixels either way");
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
|
||||
assert!(f["options"].get("embedIcc").is_some(), "png lists embedIcc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_list_the_raster_options() {
|
||||
let mut s = Session::new();
|
||||
@@ -217,6 +232,53 @@ fn an_image_opens_at_the_size_its_resolution_declares() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() {
|
||||
// While another thread keeps exporting a document whose image can't be decoded (two exports at
|
||||
// once, or the canvas drawing such a document), PNG exports of art with no image report
|
||||
// nothing, and every export on the other thread still reports its own image.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
|
||||
let mut broken = vectorcraft_doc::Document::new(40.0, 20.0);
|
||||
broken.images.insert("broken".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
|
||||
let im = ImageObject {
|
||||
key: "broken".into(),
|
||||
width: 2,
|
||||
height: 1,
|
||||
xf: vectorcraft_geom::Affine::scale(20.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
};
|
||||
let (id, layer) = (broken.alloc_id(), broken.layers[0].id);
|
||||
broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
let mut s = session(400.0, 400.0, 1);
|
||||
s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 360, "height": 360})).unwrap();
|
||||
let says_so = |w: &[Value]| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded")));
|
||||
let stop = AtomicBool::new(false);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
// Nothing in the scope panics, so the other thread is always told to stop.
|
||||
let (theirs, ours) = std::thread::scope(|scope| {
|
||||
let other = scope.spawn(|| {
|
||||
let mut warned = vec![];
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let w = encode_with_warnings(&broken, "png", &json!({})).map(|(_, w)| w.into_iter().map(Value::from).collect::<Vec<_>>());
|
||||
warned.push(w.is_ok_and(|w| says_so(&w)));
|
||||
let _ = tx.send(());
|
||||
}
|
||||
warned
|
||||
});
|
||||
let started = rx.recv_timeout(std::time::Duration::from_secs(60)).is_ok();
|
||||
let ours: Vec<Option<bool>> = (0..5)
|
||||
.map(|_| s.execute("document.export", &json!({"format": "png"})).ok().map(|r| says_so(r["warnings"].as_array().map_or(&[], |w| w))))
|
||||
.collect();
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
(other.join().ok(), started.then_some(ours))
|
||||
});
|
||||
let theirs = theirs.expect("the other thread's exports");
|
||||
assert!(!theirs.is_empty() && theirs.iter().all(|w| *w), "the other thread's exports: {theirs:?}");
|
||||
assert_eq!(ours.expect("the other thread started"), vec![Some(false); 5], "exports with no image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
|
||||
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
|
||||
@@ -233,3 +295,41 @@ fn a_raster_export_says_when_an_image_could_not_be_decoded() {
|
||||
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_rect_writes_that_page_area_and_leaves_the_document_alone() {
|
||||
let mut s = session(200.0, 400.0, 1);
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
|
||||
let boards = s.doc().unwrap().doc.artboards.len();
|
||||
let r = s.execute("document.export", &json!({"format": "png", "rect": [20, 40, 40, 300], "ppi": 72})).unwrap();
|
||||
let img = rgba(&b64(&r));
|
||||
assert_eq!((img.width(), img.height()), (40, 300));
|
||||
assert_eq!(img.get_pixel(20, 150).0[3], 255, "the strip is all art");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "no helper artboard is left");
|
||||
// Twice the resolution, and a window that holds both art and empty page.
|
||||
let img = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "rect": [0.0, 40.0, 40.0, 10.0], "scale": 2})).unwrap()));
|
||||
assert_eq!((img.width(), img.height()), (80, 20));
|
||||
assert_eq!((img.get_pixel(10, 10).0[3], img.get_pixel(60, 10).0[3]), (0, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_rect_refuses_what_cannot_be_exported() {
|
||||
let mut s = session(200.0, 400.0, 1);
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
|
||||
let bad = [
|
||||
json!({"format": "png", "rect": [0, 0, 0, 10]}),
|
||||
json!({"format": "png", "rect": [0, 0, -5, 10]}),
|
||||
json!({"format": "png", "rect": [0, 0, 10]}),
|
||||
json!({"format": "png", "rect": [0, 0, "a", 10]}),
|
||||
json!({"format": "png", "rect": "20,40,40,300"}),
|
||||
json!({"format": "png", "rect": [1e308, 0, 1e308, 10]}),
|
||||
json!({"format": "png", "rect": [0, 0, 100000, 100000]}),
|
||||
json!({"format": "png", "rect": [0, 0, 10, 10], "artboard": 0}),
|
||||
json!({"format": "png", "rect": [0, 0, 10, 10], "useArtboards": true}),
|
||||
json!({"format": "svg", "rect": [0, 0, 10, 10]}),
|
||||
];
|
||||
for p in bad {
|
||||
assert!(s.execute("document.export", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Flatten Transparency…",
|
||||
["Object"],
|
||||
None,
|
||||
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options}",
|
||||
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
has_doc,
|
||||
flatten
|
||||
),
|
||||
@@ -259,13 +259,19 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other("Flatten Transparency: select objects to flatten".into()));
|
||||
}
|
||||
let src = s.doc()?.doc.clone();
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let plan = plan(&src, &roots, &o, true)?;
|
||||
let warning = super::rasterfx::undecoded_warning(failed);
|
||||
let options = serde_json::to_value(&o).unwrap_or_default();
|
||||
if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) {
|
||||
return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": 0, "vector": 0, "options": options }));
|
||||
}
|
||||
let (ids, vector, rasterized) = s.edit("Flatten Transparency", |d, sel| apply(d, sel, plan, &o))?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options }))
|
||||
let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------- presets ----------
|
||||
@@ -818,7 +824,10 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R
|
||||
let see_through: Vec<bool> = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect();
|
||||
let cmyk = src.color_mode == ColorMode::Cmyk;
|
||||
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
|
||||
// Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing).
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
for g in overlapping(&reaches) {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
|
||||
let group = g.iter().any(|&i| see_through[i]).then(|| {
|
||||
let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect();
|
||||
@@ -950,6 +959,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
|
||||
);
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
|
||||
// The drawing stops early when cancelled: this image is not whole.
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
|
||||
// Without transparency the clip alone shapes the image: it is opaque, over white (its
|
||||
// edge pixels too).
|
||||
|
||||
@@ -136,6 +136,19 @@ pub(super) fn printed_type(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -
|
||||
(walk.fonts, walk.overflow)
|
||||
}
|
||||
|
||||
/// The warning of an export that draws `layers` of `d` over `areas` (document space; `None`: the
|
||||
/// whole of them) about the area type and type on a path that it draws and whose text doesn't fit,
|
||||
/// found as [`printed_type`] finds them for Print: visible objects of the document that aren't on
|
||||
/// a template layer.
|
||||
pub(super) fn export_overflow_warning(d: &Document, layers: &[Arc<Node>], areas: Option<&[Rect]>) -> Option<String> {
|
||||
let mut walk = Printed { d, own: true, fonts: BTreeSet::new(), overflow: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
|
||||
for l in layers {
|
||||
walk.node(l, areas);
|
||||
}
|
||||
let overflow: Vec<NodeId> = walk.overflow.into_iter().collect();
|
||||
super::typecmd::export_overflow_warning(&overflow)
|
||||
}
|
||||
|
||||
/// The walk of [`printed_type`].
|
||||
struct Printed<'a> {
|
||||
d: &'a Document,
|
||||
@@ -462,16 +475,14 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
});
|
||||
if !texts.is_empty() {
|
||||
let saved = !d.is_dirty();
|
||||
let doc = std::sync::Arc::make_mut(&mut d.doc);
|
||||
for id in texts {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
refresh_bounds(t);
|
||||
d.change_unsaved(|doc| {
|
||||
let doc = std::sync::Arc::make_mut(doc);
|
||||
for id in texts {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
if saved {
|
||||
d.mark_saved();
|
||||
}
|
||||
});
|
||||
}
|
||||
d.revision += 1;
|
||||
}
|
||||
|
||||
+782
-38
@@ -11,9 +11,10 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, CharStyle, Document, GraphDesign, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind, TextObject, TickLength, ValueAxisSide,
|
||||
Appearance, CharStyle, ColumnDesign, ColumnType, Document, Fractions, GraphDesign, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind,
|
||||
TextObject, TickLength, ValueAxisSide,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
|
||||
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::*;
|
||||
@@ -56,6 +57,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
design
|
||||
),
|
||||
cmd!(
|
||||
"graph.column",
|
||||
"Column…",
|
||||
["Object", "Graph"],
|
||||
None,
|
||||
"{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}",
|
||||
has_selection,
|
||||
set_column
|
||||
),
|
||||
cmd!(
|
||||
"graph.marker",
|
||||
"Marker…",
|
||||
@@ -82,20 +92,42 @@ impl Cell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `s` at each `sep` outside straight quotes (a `""` inside quotes toggles twice, so stays inside).
|
||||
fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
|
||||
let mut parts = vec![];
|
||||
let mut quoted = false;
|
||||
let mut start = 0;
|
||||
for (i, ch) in s.char_indices() {
|
||||
if ch == '"' {
|
||||
quoted = !quoted;
|
||||
} else if ch == sep && !quoted {
|
||||
parts.push(s.get(start..i).unwrap_or_default());
|
||||
start = i + ch.len_utf8();
|
||||
}
|
||||
}
|
||||
parts.push(s.get(start..).unwrap_or_default());
|
||||
parts
|
||||
}
|
||||
|
||||
/// One CSV cell: a cell wholly in quotes keeps its inner text (`""` = `"`), others are trimmed.
|
||||
fn cell(c: &str) -> Cell {
|
||||
let c = c.trim();
|
||||
match c.strip_prefix('"').and_then(|c| c.strip_suffix('"')) {
|
||||
Some(inner) => Cell { text: inner.replace("\"\"", "\""), quoted: true },
|
||||
None => Cell { text: c.trim_matches('"').to_string(), quoted: false },
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<Option<f64>>>) {
|
||||
let rows: Vec<Vec<Cell>> = csv
|
||||
.lines()
|
||||
// Records end at line breaks outside quotes; a record is tab-separated if it has a tab outside quotes.
|
||||
let rows: Vec<Vec<Cell>> = split_unquoted(csv, '\n')
|
||||
.into_iter()
|
||||
.map(|l| l.trim_end_matches('\r'))
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let sep = if l.contains('\t') { '\t' } else { ',' };
|
||||
l.split(sep)
|
||||
.map(|c| {
|
||||
let c = c.trim();
|
||||
let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"');
|
||||
Cell { text: c.trim_matches('"').to_string(), quoted }
|
||||
})
|
||||
.collect()
|
||||
let tabs = split_unquoted(l, '\t');
|
||||
let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') };
|
||||
parts.into_iter().map(cell).collect()
|
||||
})
|
||||
.collect();
|
||||
let Some(first) = rows.first() else { return (vec![], vec![], vec![]) };
|
||||
@@ -192,7 +224,9 @@ fn drop_empty_rows(g: &mut GraphSpec) {
|
||||
|
||||
fn fmt_value(v: f64) -> String {
|
||||
if (v - v.round()).abs() < 1e-9 {
|
||||
format!("{}", v.round() as i64)
|
||||
// Beyond the i64 range the cast would saturate; every f64 that large is a whole number, so
|
||||
// `{:.0}` prints its exact digits.
|
||||
if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") }
|
||||
} else {
|
||||
format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
@@ -287,6 +321,8 @@ struct Gen<'a> {
|
||||
out: Vec<Arc<Node>>,
|
||||
/// The labels' kept character styles.
|
||||
styles: &'a vectorcraft_doc::LabelStyles,
|
||||
/// What the column designs may still add.
|
||||
columns: Allowance,
|
||||
}
|
||||
|
||||
/// What a graph label shows, for its kept character style.
|
||||
@@ -373,10 +409,26 @@ impl Gen<'_> {
|
||||
/// A data point's marker filling `cell`: the series' marker design, else the default square in the series' paint.
|
||||
fn marker(&mut self, design: Option<&Arc<Node>>, cell: Rect, (fill, stroke, w): (Paint, Paint, f64)) -> Arc<Node> {
|
||||
match design.and_then(|a| fit_design(self.d, a, cell)) {
|
||||
Some(n) => n,
|
||||
Some(n) => self.design_copy(n),
|
||||
None => self.path(shapes::rectangle(cell), fill, stroke, w),
|
||||
}
|
||||
}
|
||||
/// A legend swatch filling `cell`: the series' column design, else its [`Self::marker`].
|
||||
fn swatch(&mut self, column: Option<&ColumnArt>, marker: Option<&Arc<Node>>, cell: Rect, paint: (Paint, Paint, f64)) -> Arc<Node> {
|
||||
let fitted = match column {
|
||||
Some(c) if self.columns.take(c.art.count()) => fit_design(self.d, &c.art, cell),
|
||||
_ => None,
|
||||
};
|
||||
match fitted {
|
||||
Some(n) => self.design_copy(n),
|
||||
None => self.marker(marker, cell, paint),
|
||||
}
|
||||
}
|
||||
/// A copy of design art drawn in a series: a single path gets a group of its own, as painting a series paints the
|
||||
/// paths directly inside its group and the design keeps its own paint.
|
||||
fn design_copy(&mut self, n: Arc<Node>) -> Arc<Node> {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) { Arc::new(Node::group(self.d.alloc_id(), vec![n])) } else { n }
|
||||
}
|
||||
/// A series group. Empty series are omitted by the caller; the index is stored on the group
|
||||
/// so later paint edits find the series without counting siblings.
|
||||
fn series(&mut self, name: &str, index: usize, children: Vec<Arc<Node>>) -> Arc<Node> {
|
||||
@@ -471,24 +523,260 @@ fn marker_cost(g: &GraphSpec, designs: &[Option<Arc<Node>>]) -> usize {
|
||||
designs.iter().flatten().map(|a| a.count().saturating_mul(points)).fold(0, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// The frame graph design `art` is drawn around: its backmost object's bounds (the whole art's when the design is a
|
||||
/// single object). `None` for art with no area.
|
||||
fn design_frame(art: &Node) -> Option<Rect> {
|
||||
let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite();
|
||||
let group = matches!(art.kind, NodeKind::Group { .. });
|
||||
let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable);
|
||||
backmost.or_else(|| art.geometric_bounds().filter(usable))
|
||||
}
|
||||
|
||||
/// Graph design `art` scaled into `cell` (Object › Graph › Marker): its backmost object, the rectangle a design is
|
||||
/// drawn around, fills the cell (the whole art does when the design is a single object), strokes scaling with it.
|
||||
/// Fresh ids. `None` for art with no area.
|
||||
fn fit_design(d: &mut Document, art: &Node, cell: Rect) -> Option<Arc<Node>> {
|
||||
let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite();
|
||||
let group = matches!(art.kind, NodeKind::Group { .. });
|
||||
let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable);
|
||||
let frame = backmost.or_else(|| art.geometric_bounds().filter(usable))?;
|
||||
let a = Affine::translate(cell.origin().to_vec2())
|
||||
* Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height())
|
||||
* Affine::translate(-frame.origin().to_vec2());
|
||||
let a = fit_affine(design_frame(art)?, cell);
|
||||
// Strokes, effects and pattern tiles scale with the art.
|
||||
let art = super::place::transformed(art.clone(), a);
|
||||
Some(Arc::new(d.reid(&art)))
|
||||
}
|
||||
|
||||
/// Build the graph's children.
|
||||
/// The map that puts a design's frame `frame` on `cell` ([`fit_design`]).
|
||||
fn fit_affine(frame: Rect, cell: Rect) -> Affine {
|
||||
Affine::translate(cell.origin().to_vec2())
|
||||
* Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height())
|
||||
* Affine::translate(-frame.origin().to_vec2())
|
||||
}
|
||||
|
||||
/// Most objects a graph's column designs may add (each column, repeated copy and legend swatch draws a copy).
|
||||
const COLUMN_BUDGET: usize = 200_000;
|
||||
|
||||
/// A series' column design (Object › Graph › Column): its options, its art without guide lines, and the bounds of
|
||||
/// those guides.
|
||||
struct ColumnArt {
|
||||
options: ColumnDesign,
|
||||
art: Node,
|
||||
guides: Vec<Rect>,
|
||||
}
|
||||
|
||||
/// `art` without the paths made into guides, at any depth.
|
||||
fn without_guides(art: &Node) -> Node {
|
||||
let mut n = art.clone();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old = std::mem::take(ch);
|
||||
*ch = old.iter().filter(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })).map(|c| Arc::new(without_guides(c))).collect();
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Each series' column design, when the document has the design.
|
||||
fn column_designs(d: &Document, g: &GraphSpec) -> Vec<Option<ColumnArt>> {
|
||||
let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES);
|
||||
(0..nser)
|
||||
.map(|s| {
|
||||
let options = g.series_columns.get(s)?.as_ref()?;
|
||||
let art = &d.graph_designs.iter().find(|x| x.name == options.design)?.art;
|
||||
let mut guides = vec![];
|
||||
art.walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Path { guide: true, .. })
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
{
|
||||
guides.push(b);
|
||||
}
|
||||
});
|
||||
Some(ColumnArt { options: options.clone(), art: without_guides(art), guides })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The value one copy of a repeating design stands for, when the design repeats and the value is usable.
|
||||
fn repeat_each(c: &ColumnDesign) -> Option<f64> {
|
||||
(c.column_type == ColumnType::Repeating && c.each.is_finite() && c.each > 0.0).then_some(c.each)
|
||||
}
|
||||
|
||||
/// The objects a graph's column designs may still add (out of [`COLUMN_BUDGET`]); `short` once a copy needed more than
|
||||
/// was left, and the graph is then drawn with plain columns.
|
||||
struct Allowance {
|
||||
left: usize,
|
||||
short: bool,
|
||||
}
|
||||
|
||||
impl Allowance {
|
||||
fn new() -> Self {
|
||||
Self { left: COLUMN_BUDGET, short: false }
|
||||
}
|
||||
/// Spend `n` objects, when that many are left.
|
||||
fn take(&mut self, n: usize) -> bool {
|
||||
match self.left.checked_sub(n) {
|
||||
Some(rest) => {
|
||||
self.left = rest;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.short = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Column or bar `rect` drawn with column design `c` (Object › Graph › Column), as a group of fresh art, its copies
|
||||
/// spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a column above
|
||||
/// zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot points per
|
||||
/// unit of value. `None` for art with no area, for a column of no length and when `left` runs out, which are drawn plain.
|
||||
fn column_art(
|
||||
d: &mut Document,
|
||||
c: &ColumnArt,
|
||||
rect: Rect,
|
||||
(horizontal, far_low): (bool, bool),
|
||||
per_unit: f64,
|
||||
left: &mut Allowance,
|
||||
) -> Option<Arc<Node>> {
|
||||
let frame = design_frame(&c.art)?;
|
||||
let length = |r: Rect| if horizontal { r.width() } else { r.height() };
|
||||
let thickness = |r: Rect| if horizontal { r.height() } else { r.width() };
|
||||
if length(rect) <= 1e-9 || !length(rect).is_finite() {
|
||||
return None;
|
||||
}
|
||||
let cost = c.art.count();
|
||||
// A copy of the design fitted to `cell`; and a copy moved by `a` and clipped to what it paints, cut at `at` (an x when
|
||||
// `along_x`, else a y) with the side below `at` kept when `keep_low` (two objects more).
|
||||
let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| if left.take(cost) { fit_design(d, &c.art, cell) } else { None };
|
||||
let clipped = |d: &mut Document, a: Affine, (along_x, keep_low, at): (bool, bool, f64), left: &mut Allowance| {
|
||||
let art = left.take(cost + 2).then(|| d.reid(&super::place::transformed(c.art.clone(), a)))?;
|
||||
// The box of the copy and of each object in it that isn't a group or layer, as the brush art past a brushed
|
||||
// stroke counts only in its own object's box.
|
||||
let mut b = vectorcraft_render::painted_bounds(d, &art);
|
||||
art.walk(&mut |n| {
|
||||
if !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) {
|
||||
b = vectorcraft_geom::union_opt(b, vectorcraft_render::painted_bounds(d, n));
|
||||
}
|
||||
});
|
||||
let b = b.or_else(|| art.geometric_bounds())?;
|
||||
let keep = match (along_x, keep_low) {
|
||||
(true, true) => Rect::new(b.x0, b.y0, at.max(b.x0), b.y1),
|
||||
(true, false) => Rect::new(at.min(b.x1), b.y0, b.x1, b.y1),
|
||||
(false, true) => Rect::new(b.x0, b.y0, b.x1, at.max(b.y0)),
|
||||
(false, false) => Rect::new(b.x0, at.min(b.y1), b.x1, b.y1),
|
||||
};
|
||||
Some(Arc::new(super::rasterfx::clip_group(d, shapes::rectangle(keep), FillRule::NonZero, art)))
|
||||
};
|
||||
// The part of the column from `a` to `b` points out from its zero end.
|
||||
let piece = |a: f64, b: f64| match (horizontal, far_low) {
|
||||
(true, true) => Rect::new(rect.x1 - b, rect.y0, rect.x1 - a, rect.y1),
|
||||
(true, false) => Rect::new(rect.x0 + a, rect.y0, rect.x0 + b, rect.y1),
|
||||
(false, true) => Rect::new(rect.x0, rect.y1 - b, rect.x1, rect.y1 - a),
|
||||
(false, false) => Rect::new(rect.x0, rect.y0 + a, rect.x1, rect.y0 + b),
|
||||
};
|
||||
let mut parts = vec![];
|
||||
match c.options.column_type {
|
||||
ColumnType::Uniform => {
|
||||
// Scaled both ways to the column's length, centered across it.
|
||||
let t = thickness(frame) * length(rect) / length(frame) / 2.0;
|
||||
let cell = if horizontal {
|
||||
Rect::new(rect.x0, rect.center().y - t, rect.x1, rect.center().y + t)
|
||||
} else {
|
||||
Rect::new(rect.center().x - t, rect.y0, rect.center().x + t, rect.y1)
|
||||
};
|
||||
parts.push(fit(d, cell, left)?);
|
||||
}
|
||||
ColumnType::Repeating => {
|
||||
// When the value per copy is unusable, too small to draw or makes one copy longer than the canvas, the design
|
||||
// is drawn vertically scaled.
|
||||
let Some(unit) = repeat_each(&c.options).map(|e| e * per_unit).filter(|u| u.is_finite() && *u > 1e-9 && *u <= crate::MAX_COORD) else {
|
||||
parts.push(fit(d, rect, left)?);
|
||||
return Some(Arc::new(Node::group(d.alloc_id(), parts)));
|
||||
};
|
||||
// The whole copies, and the points of the column left for the last one (none under a millionth of a point).
|
||||
let whole = ((length(rect) + 1e-6) / unit).floor();
|
||||
let rest = length(rect) - whole * unit;
|
||||
// Each copy is spent from `left`, which stops the loop.
|
||||
for i in 0..whole as usize {
|
||||
parts.push(fit(d, piece(i as f64 * unit, (i + 1) as f64 * unit), left)?);
|
||||
}
|
||||
if rest > 1e-6 {
|
||||
let start = whole * unit;
|
||||
match c.options.fractions {
|
||||
Fractions::Scale => parts.push(fit(d, piece(start, start + rest), left)?),
|
||||
Fractions::Chop => {
|
||||
// The last copy cut off where the column ends, and nowhere else.
|
||||
let cut = match (horizontal, far_low) {
|
||||
(true, true) => (true, false, rect.x0),
|
||||
(true, false) => (true, true, rect.x1),
|
||||
(false, true) => (false, false, rect.y0),
|
||||
(false, false) => (false, true, rect.y1),
|
||||
};
|
||||
parts.push(clipped(d, fit_affine(frame, piece(start, start + unit)), cut, left)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ColumnType::Sliding => match slide(frame, &c.guides, rect, horizontal) {
|
||||
Some((kept, stretched, at)) => {
|
||||
// Each copy cut at the guide, on its own side.
|
||||
parts.push(clipped(d, kept, (horizontal, !horizontal, at), left)?);
|
||||
parts.push(clipped(d, stretched, (horizontal, horizontal, at), left)?);
|
||||
}
|
||||
None => parts.push(fit(d, rect, left)?),
|
||||
},
|
||||
ColumnType::Vertical => parts.push(fit(d, rect, left)?),
|
||||
}
|
||||
Some(Arc::new(Node::group(d.alloc_id(), parts)))
|
||||
}
|
||||
|
||||
/// Sliding design with frame `frame` in column `rect`, at the column's width: the map of the part above the first
|
||||
/// horizontal guide in `guides` inside the frame (in a bar, right of the first vertical one), scaled in proportion and
|
||||
/// put at the rect's top (in a bar, its right end), the value end for values above zero; the map of the part below (left
|
||||
/// of) it, stretched or compressed to fill the rest; and where the guide lands (y, or x in a bar). Both maps put the guide there. `None` without such a guide, or when the column is too
|
||||
/// short for the part kept in proportion.
|
||||
fn slide(frame: Rect, guides: &[Rect], rect: Rect, horizontal: bool) -> Option<(Affine, Affine, f64)> {
|
||||
// A horizontal line (in a bar, a vertical one): longer than it is across, and at most a hundredth of the frame across.
|
||||
let inside = |g: &&Rect| {
|
||||
if horizontal {
|
||||
g.width() < g.height() && g.width() <= frame.width() / 100.0 && g.center().x > frame.x0 && g.center().x < frame.x1
|
||||
} else {
|
||||
g.height() < g.width() && g.height() <= frame.height() / 100.0 && g.center().y > frame.y0 && g.center().y < frame.y1
|
||||
}
|
||||
};
|
||||
let g = guides.iter().find(inside)?.center();
|
||||
if horizontal {
|
||||
let s = rect.height() / frame.height();
|
||||
let (kept, stretched) = (frame.x1 - g.x, g.x - frame.x0);
|
||||
let rest = rect.width() - kept * s;
|
||||
if !(rest.is_finite() && rest > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let k = rest / stretched;
|
||||
let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x1 - frame.x1 * s, rect.y0 - frame.y0 * s]);
|
||||
let stretched_map = Affine::new([k, 0.0, 0.0, s, rect.x0 - frame.x0 * k, rect.y0 - frame.y0 * s]);
|
||||
Some((kept_map, stretched_map, rect.x1 - kept * s))
|
||||
} else {
|
||||
let s = rect.width() / frame.width();
|
||||
let (kept, stretched) = (g.y - frame.y0, frame.y1 - g.y);
|
||||
let rest = rect.height() - kept * s;
|
||||
if !(rest.is_finite() && rest > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let k = rest / stretched;
|
||||
let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x0 - frame.x0 * s, rect.y0 - frame.y0 * s]);
|
||||
let stretched_map = Affine::new([s, 0.0, 0.0, k, rect.x0 - frame.x0 * s, rect.y1 - frame.y1 * k]);
|
||||
Some((kept_map, stretched_map, rect.y0 + kept * s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the graph's children: with each series' column design (Object › Graph › Column) when the document has it,
|
||||
/// and with plain columns for all when the designs would draw more than COLUMN_BUDGET objects.
|
||||
fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let column_looks = column_designs(d, g);
|
||||
match generate_with(d, g, &column_looks) {
|
||||
Some(out) => out,
|
||||
None => generate_with(d, g, &[]).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`generate`] with the column designs `column_looks`; `None` when they would draw more than COLUMN_BUDGET objects.
|
||||
fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option<ColumnArt>]) -> Option<Vec<Arc<Node>>> {
|
||||
let mark = |i: usize, series_fill: bool, stroke: Paint, width: f64| series_marks(g, i, series_fill, stroke, width);
|
||||
// Each series' marker design (Object › Graph › Marker), when the document has it; default squares for all
|
||||
// when the designs would draw more than MARKER_BUDGET objects.
|
||||
@@ -497,7 +785,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
designs.clear();
|
||||
}
|
||||
let design = |s: usize| designs.get(s).and_then(Option::as_ref);
|
||||
let mut b = Gen { d, out: vec![], styles: &g.label_styles };
|
||||
let column_look = |s: usize| column_looks.get(s).and_then(Option::as_ref);
|
||||
let mut b = Gen { d, out: vec![], styles: &g.label_styles, columns: Allowance::new() };
|
||||
// The labels' sizes (9 pt unless formatted), which their spacing follows.
|
||||
let (vs, cs, ls) =
|
||||
(label_size(&g.label_styles, Role::Value), label_size(&g.label_styles, Role::Category), label_size(&g.label_styles, Role::Legend));
|
||||
@@ -514,6 +803,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
// Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own.
|
||||
let kinds: Vec<GraphKind> = (0..nser).map(|s| g.series_kind(s)).collect();
|
||||
let kind = |s: usize| kinds.get(s).copied().unwrap_or(g.kind);
|
||||
// Series drawn as columns or bars, which column designs apply to.
|
||||
let columnar = |s: usize| matches!(kind(s), GraphKind::Column | GraphKind::StackedColumn | GraphKind::Bar | GraphKind::StackedBar);
|
||||
let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar);
|
||||
// Where the art right of the plot ends (the right value axis' labels), so the legend starts after it.
|
||||
let mut right_edge = r.x1;
|
||||
@@ -735,6 +1026,11 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let (lo, hi, _) = scale(a);
|
||||
vpos(a, 0.0f64.clamp(lo, hi))
|
||||
};
|
||||
// Plot points per unit of value along value axis `a`.
|
||||
let per_unit = |a: usize| {
|
||||
let (lo, hi, _) = scale(a);
|
||||
(if horizontal { r.width() } else { r.height() }) / (hi - lo)
|
||||
};
|
||||
let mut axes = vec![];
|
||||
// Value axes with ticks and labels: along the bottom for bar graphs, else on the chosen sides (the right
|
||||
// one shows the right scale).
|
||||
@@ -940,10 +1236,24 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let Some(v) = cell(c, s) else { continue };
|
||||
let a = c0 + slot * i as f64 + (slot - bar) / 2.0;
|
||||
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
|
||||
let rect = bar_rect(a, bar, zero(axis(s)), vpos(axis(s), v));
|
||||
let (v0, v1) = (zero(axis(s)), vpos(axis(s), v));
|
||||
let rect = bar_rect(a, bar, v0, v1);
|
||||
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, w);
|
||||
shadowed.push((s, n.clone()));
|
||||
// A column drawn with a design casts no shadow.
|
||||
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns)) {
|
||||
Some(n) => {
|
||||
// A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too.
|
||||
if let Some(art) = n.geometric_bounds() {
|
||||
bars_reach = Some(bars_reach.map_or(art, |r| r.union(art)));
|
||||
}
|
||||
n
|
||||
}
|
||||
None => {
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, w);
|
||||
shadowed.push((s, n.clone()));
|
||||
n
|
||||
}
|
||||
};
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
@@ -971,11 +1281,22 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
|
||||
let rect = bar_rect(a, w, v0, v1);
|
||||
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, sw);
|
||||
// A segment of 0 has no area to shade.
|
||||
if v != 0.0 {
|
||||
shadowed.push((s, n.clone()));
|
||||
}
|
||||
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) {
|
||||
Some(n) => {
|
||||
if let Some(art) = n.geometric_bounds() {
|
||||
bars_reach = Some(bars_reach.map_or(art, |r| r.union(art)));
|
||||
}
|
||||
n
|
||||
}
|
||||
None => {
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, sw);
|
||||
// A segment of 0 has no area to shade.
|
||||
if v != 0.0 {
|
||||
shadowed.push((s, n.clone()));
|
||||
}
|
||||
n
|
||||
}
|
||||
};
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
@@ -1057,7 +1378,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
for (s, label, w) in row {
|
||||
let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(*s) == GraphKind::Line;
|
||||
let paint = mark(*s, true, Paint::None, 0.0);
|
||||
let swatch = b.marker(design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint);
|
||||
let swatch =
|
||||
b.swatch(column_look(*s).filter(|_| columnar(*s)), design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint);
|
||||
if let Some(items) = series.get_mut(*s) {
|
||||
items.push(swatch);
|
||||
}
|
||||
@@ -1079,10 +1401,11 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
}
|
||||
y += ls * 1.8 * label.split('|').count() as f64;
|
||||
let y = row_y;
|
||||
// Series drawn with markers show their marker design in the legend.
|
||||
// Series drawn with markers show their marker design in the legend, and series drawn with a column design
|
||||
// that design.
|
||||
let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(s) == GraphKind::Line;
|
||||
let paint = mark(s, true, Paint::None, 0.0);
|
||||
items.push(b.marker(design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint));
|
||||
items.push(b.swatch(column_look(s).filter(|_| columnar(s)), design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint));
|
||||
labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), &label, Justify::Left, false));
|
||||
}
|
||||
}
|
||||
@@ -1129,7 +1452,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let grp = b.group("Legend", labels);
|
||||
b.out.push(grp);
|
||||
}
|
||||
b.out
|
||||
(!b.columns.short).then_some(b.out)
|
||||
}
|
||||
|
||||
fn children_bounds(children: &[Arc<Node>]) -> Option<Rect> {
|
||||
@@ -1475,11 +1798,16 @@ fn design_names(d: &Document) -> Vec<String> {
|
||||
d.graph_designs.iter().map(|x| x.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Whether graph `g` draws a series with design `name`, as its marker or its columns.
|
||||
fn draws_with(g: &GraphSpec, name: &str) -> bool {
|
||||
g.series_markers.iter().flatten().any(|m| m == name) || g.series_columns.iter().flatten().any(|c| c.design == name)
|
||||
}
|
||||
|
||||
/// Regenerate the graphs whose series use design `name` (it was added or removed).
|
||||
fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> {
|
||||
let mut ids = vec![];
|
||||
d.walk(|n| {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, name)) {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
@@ -1497,6 +1825,7 @@ fn unused_designs(d: &Document) -> Vec<String> {
|
||||
d.walk(|n| {
|
||||
if let Some(g) = n.extras().graph.as_deref() {
|
||||
used.extend(g.series_markers.iter().flatten().cloned());
|
||||
used.extend(g.series_columns.iter().flatten().map(|c| c.design.clone()));
|
||||
}
|
||||
});
|
||||
d.graph_designs.iter().map(|x| x.name.clone()).filter(|n| !used.contains(n)).collect()
|
||||
@@ -1570,7 +1899,7 @@ fn design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut graphs = vec![];
|
||||
d.walk(|n| {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, &old)) {
|
||||
graphs.push(n.id);
|
||||
}
|
||||
});
|
||||
@@ -1579,6 +1908,9 @@ fn design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for m in g.series_markers.iter_mut().flatten().filter(|m| **m == old) {
|
||||
*m = new.clone();
|
||||
}
|
||||
for c in g.series_columns.iter_mut().flatten().filter(|c| c.design == old) {
|
||||
c.design = new.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1669,6 +2001,74 @@ fn set_marker(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn set_column(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.column";
|
||||
let id = target(s, p, C)?;
|
||||
let mut spec = spec_of(s, id)?;
|
||||
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES);
|
||||
let picked = picked_series(s, p, id, count, C)?;
|
||||
let names = design_names(&s.doc()?.doc);
|
||||
let all: Vec<usize> = if picked.is_empty() { (0..count).collect() } else { picked.clone() };
|
||||
// The column design the picked series (all of them with none picked) share; a design deleted since doesn't count,
|
||||
// as those series draw plain columns.
|
||||
let used: Vec<Option<&ColumnDesign>> =
|
||||
all.iter().map(|i| spec.series_columns.get(*i).and_then(Option::as_ref).filter(|c| names.contains(&c.design))).collect();
|
||||
let shared = used.first().copied().flatten().filter(|c| used.iter().all(|x| *x == Some(*c))).cloned();
|
||||
let Some(v) = p.get("design") else {
|
||||
let options = shared.clone().unwrap_or_default();
|
||||
return Ok(json!({
|
||||
"designs": names,
|
||||
"design": shared.map(|c| c.design),
|
||||
"columnType": options.column_type.id(),
|
||||
"each": options.each,
|
||||
"fractions": options.fractions.id(),
|
||||
}));
|
||||
};
|
||||
let design = match v {
|
||||
Value::Null => None,
|
||||
Value::String(n) if names.contains(n) => Some(n.clone()),
|
||||
Value::String(n) => return Err(bad(C, format!("no design named `{n}`"))),
|
||||
_ => return Err(bad(C, "`design` is a design name, or null for plain columns")),
|
||||
};
|
||||
let mut options = shared.unwrap_or_default();
|
||||
if let Some(t) = p.get("columnType") {
|
||||
options.column_type =
|
||||
t.as_str().and_then(ColumnType::parse).ok_or_else(|| bad(C, "`columnType` is vertical, uniform, repeating or sliding"))?;
|
||||
}
|
||||
if let Some(e) = p.get("each") {
|
||||
options.each =
|
||||
e.as_f64().filter(|e| e.is_finite() && *e > 0.0).ok_or_else(|| bad(C, "`each` is the value one design stands for, more than 0"))?;
|
||||
}
|
||||
if let Some(f) = p.get("fractions") {
|
||||
options.fractions = f.as_str().and_then(Fractions::parse).ok_or_else(|| bad(C, "`fractions` is chop or scale"))?;
|
||||
}
|
||||
let reselect: Vec<u32> = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] };
|
||||
spec.series_columns.truncate(count);
|
||||
spec.series_columns.resize(count, None);
|
||||
for i in all {
|
||||
if let Some(c) = spec.series_columns.get_mut(i) {
|
||||
*c = design.clone().map(|design| ColumnDesign { design, ..options.clone() });
|
||||
}
|
||||
}
|
||||
while spec.series_columns.last().is_some_and(Option::is_none) {
|
||||
spec.series_columns.pop();
|
||||
}
|
||||
// Drawn once on a copy of the document, to refuse designs past the budget before the edit.
|
||||
let mut probe = (*s.doc()?.doc).clone();
|
||||
let looks = column_designs(&probe, &spec);
|
||||
if generate_with(&mut probe, &spec, &looks).is_none() {
|
||||
return Err(bad(C, "the design is too big to draw in every column of this graph"));
|
||||
}
|
||||
s.edit("Graph Column", |d, sel| {
|
||||
regenerate(d, id, spec)?;
|
||||
if !reselect.is_empty() {
|
||||
reselect_series(d, sel, id, &reselect);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.setType";
|
||||
let id = target(s, p, C)?;
|
||||
@@ -1896,10 +2296,20 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, GraphKind, GraphSpec, NodeKind};
|
||||
use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind};
|
||||
|
||||
use crate::{NodeId, Session};
|
||||
|
||||
#[test]
|
||||
fn fmt_value_does_not_saturate_beyond_i64() {
|
||||
assert_eq!(super::fmt_value(1e19), "10000000000000000000");
|
||||
assert_eq!(super::fmt_value(2e19), "20000000000000000000");
|
||||
assert_eq!(super::fmt_value(-2e19), "-20000000000000000000");
|
||||
assert_eq!(super::fmt_value(42.0), "42");
|
||||
assert_eq!(super::fmt_value(-0.0), "0");
|
||||
assert_eq!(super::fmt_value(2.5), "2.5");
|
||||
}
|
||||
|
||||
fn graph(s: &mut Session, ty: &str) -> NodeId {
|
||||
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
|
||||
NodeId(
|
||||
@@ -2358,6 +2768,30 @@ mod tests {
|
||||
assert_eq!(cells, g.cells(), "{csv}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]);
|
||||
let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]);
|
||||
let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]);
|
||||
let r = s
|
||||
.execute(
|
||||
"graph.create",
|
||||
&json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}),
|
||||
)
|
||||
.unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
let before = s.execute("graph.setData", &json!({"id": id})).unwrap();
|
||||
assert_eq!(before["series"], series);
|
||||
assert_eq!(before["categories"], categories);
|
||||
s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap();
|
||||
let after = s.execute("graph.setData", &json!({"id": id})).unwrap();
|
||||
assert_eq!(after["series"], before["series"], "{}", before["csv"]);
|
||||
assert_eq!(after["categories"], before["categories"], "{}", before["csv"]);
|
||||
assert_eq!(after["rows"], before["rows"], "{}", before["csv"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_cells_are_blank_and_quoted_numbers_are_labels() {
|
||||
// Quoted years label the rows; empty and non-numeric cells are blank values, not zeros.
|
||||
@@ -3307,6 +3741,16 @@ mod tests {
|
||||
assert!(!painted.contains(&Some("#ff0000".into())), "{painted:?}");
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert_eq!(design_fill(&s), painted);
|
||||
// A design of one path too.
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [r], "color": "#00f"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Square"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.marker", &json!({"design": "Square"})).unwrap();
|
||||
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
|
||||
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
|
||||
assert_eq!(design_fill(&s), [Some("#0000ff".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3356,6 +3800,306 @@ mod tests {
|
||||
assert!(s.execute("graph.design", &json!({"delete": ["Nope"]})).is_err());
|
||||
}
|
||||
|
||||
/// A column design: a `w` × `h` rectangle at the back, a `w`-wide ellipse over its top `top` points and a rectangle
|
||||
/// over the rest, with a horizontal guide line between them when `guide`, saved as `name`.
|
||||
fn save_column_design(s: &mut Session, name: &str, (w, h, top): (f64, f64, f64), guide: bool) {
|
||||
let shape = |s: &mut Session, kind: &str, x: f64, y: f64, w: f64, h: f64| {
|
||||
s.execute(kind, &json!({"x": 600.0 + x, "y": 600.0 + y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let mut ids = vec![shape(s, "shape.rectangle", 0.0, 0.0, w, h), shape(s, "shape.ellipse", 0.0, 0.0, w, top)];
|
||||
ids.push(shape(s, "shape.rectangle", w / 4.0, top, w / 2.0, h - top));
|
||||
if guide {
|
||||
let line =
|
||||
s.execute("shape.line", &json!({"x1": 600, "y1": 600.0 + top, "x2": 600.0 + w, "y2": 600.0 + top})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [line]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
ids.push(line);
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": name})).unwrap();
|
||||
}
|
||||
|
||||
/// A column graph of one series with `values`, its value axis 0 to 50 over 200 points (4 points a unit), the zero
|
||||
/// line at y = 300.
|
||||
fn one_series(s: &mut Session, ty: &str, values: &[f64]) -> NodeId {
|
||||
let rows: Vec<Vec<f64>> = values.iter().map(|v| vec![*v]).collect();
|
||||
let p = json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "series": ["A"], "rows": rows});
|
||||
let id = NodeId(s.execute("graph.create", &p).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 50})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// The bounds of the backmost object (the frame) of each copy of a design inside `n`, a column drawn with it.
|
||||
fn frames(n: &vectorcraft_doc::Node) -> Vec<vectorcraft_geom::Rect> {
|
||||
n.children().unwrap().iter().filter_map(|c| c.children()?.first()?.geometric_bounds()).collect()
|
||||
}
|
||||
|
||||
fn near(a: vectorcraft_geom::Rect, b: [f64; 4]) -> bool {
|
||||
[a.x0 - b[0], a.y0 - b[1], a.x1 - b[2], a.y1 - b[3]].iter().all(|d| d.abs() < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_column_design_stacks_a_copy_per_ten_and_chops_or_scales_the_last() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = one_series(&mut s, "column", &[35.0]);
|
||||
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
|
||||
assert!(near(plain, [plain.x0, 160.0, plain.x1, 300.0]), "{plain:?}");
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 10})).unwrap();
|
||||
// 35 at 10 a copy: three copies of 40 points up from the zero line, and a fourth cut off at the value.
|
||||
let column = series_parts(&s, id, 0)[0].clone();
|
||||
let parts = column.children().unwrap();
|
||||
assert_eq!(parts.len(), 4, "3.5 designs");
|
||||
let at = |y0: f64, y1: f64| [plain.x0, y0, plain.x1, y1];
|
||||
let copies = frames(&column);
|
||||
assert!(near(copies[0], at(260.0, 300.0)) && near(copies[1], at(220.0, 260.0)) && near(copies[2], at(180.0, 220.0)), "{copies:?}");
|
||||
let NodeKind::Group { clip: true, children } = &parts[3].kind else { panic!("the last copy is clipped: {:?}", parts[3].kind) };
|
||||
assert!(near(frames(&parts[3])[0], at(140.0, 180.0)), "a whole copy");
|
||||
let (clip, copy) =
|
||||
(children[0].geometric_bounds().unwrap(), vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, &children[1]).unwrap());
|
||||
assert_eq!(clip.y0, 160.0, "cut off at the value");
|
||||
// Its sides and its foot, strokes included, are not cut.
|
||||
assert!(clip.x0 <= copy.x0 && clip.x1 >= copy.x1 && clip.y1 >= copy.y1 && copy.x0 < plain.x0, "{clip:?} {copy:?}");
|
||||
// Nor is the brush art of a brushed path inside a group of the design.
|
||||
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
|
||||
let square = |x0: f64, x1: f64| vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x0, 0.0, x1, 10.0));
|
||||
let frame = vectorcraft_doc::Node::path(d.alloc_id(), square(0.0, 10.0), vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
let mut look = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 1.0);
|
||||
for item in &mut look.items {
|
||||
if let vectorcraft_doc::AppearanceItem::Stroke(st) = item {
|
||||
st.brush = Some("Wide".into());
|
||||
}
|
||||
}
|
||||
let brushed = vectorcraft_doc::Node::path(d.alloc_id(), square(4.0, 6.0), look);
|
||||
let inner = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(brushed)]);
|
||||
let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(inner)]);
|
||||
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Brushed".into(), art: std::sync::Arc::new(art) });
|
||||
let options = ColumnDesign { design: "Brushed".into(), column_type: ColumnType::Repeating, ..ColumnDesign::default() };
|
||||
let g = GraphSpec {
|
||||
rows: vec![vec![35.0]],
|
||||
axis_min: Some(0.0),
|
||||
axis_max: Some(50.0),
|
||||
series_columns: vec![Some(options)],
|
||||
..GraphSpec::default()
|
||||
};
|
||||
let out = super::generate(&mut d, &g);
|
||||
let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone();
|
||||
let last = column.children().unwrap().last().unwrap().clone();
|
||||
let NodeKind::Group { clip: true, children } = &last.kind else { panic!("the last copy is clipped: {:?}", last.kind) };
|
||||
let clip = children[0].geometric_bounds().unwrap();
|
||||
let mut reach = None;
|
||||
children[1].walk(&mut |n| {
|
||||
if vectorcraft_brush::has_brush(n) {
|
||||
reach = vectorcraft_render::painted_bounds(&d, n);
|
||||
}
|
||||
});
|
||||
let reach = reach.unwrap();
|
||||
assert!(clip.x0 <= reach.x0 && clip.x1 >= reach.x1 && clip.y1 >= reach.y1, "{clip:?} {reach:?}");
|
||||
// Scale: the last copy half as tall instead.
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "fractions": "scale"})).unwrap();
|
||||
let column = series_parts(&s, id, 0)[0].clone();
|
||||
let copies = frames(&column);
|
||||
assert_eq!(copies.len(), 4);
|
||||
assert!(near(copies[3], at(160.0, 180.0)), "{copies:?}");
|
||||
let c = &spec(&s, id).series_columns[0];
|
||||
assert_eq!((c.as_ref().map(|c| (c.column_type, c.each, c.fractions))), Some((ColumnType::Repeating, 10.0, Fractions::Scale)));
|
||||
// A bar graph stacks them along its bars, and a negative value stacks them the other way from the zero line.
|
||||
let bars = one_series(&mut s, "bar", &[-15.0]);
|
||||
s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": bars.0, "design": "Coin", "columnType": "repeating", "each": 10, "fractions": "scale"})).unwrap();
|
||||
let bar = series_parts(&s, bars, 0)[0].clone();
|
||||
let copies = frames(&bar);
|
||||
// The value axis runs from -50 to 50 over 300 points: 3 points a unit, the zero line at x = 250.
|
||||
let (zero, unit) = (250.0, 3.0);
|
||||
assert!((copies[0].x1 - zero).abs() < 1e-6 && (copies[0].width() - 10.0 * unit).abs() < 1e-6, "{copies:?}");
|
||||
assert!((copies[1].x1 - copies[0].x0).abs() < 1e-6 && (copies[1].width() - 5.0 * unit).abs() < 1e-6, "{copies:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sliding_column_design_keeps_the_part_above_its_guide_and_stretches_the_rest() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
// 10 wide and 20 tall, the guide 5 points from the top.
|
||||
save_column_design(&mut s, "Pencil", (10.0, 20.0, 5.0), true);
|
||||
let id = one_series(&mut s, "column", &[35.0, 20.0]);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "columnWidth": 20})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "sliding"})).unwrap();
|
||||
let parts = series_parts(&s, id, 0);
|
||||
for (column, value_end) in parts.iter().zip([160.0, 220.0]) {
|
||||
// The columns are 24 points wide (2.4 times the design): two copies, each clipped at the guide, which lands
|
||||
// 12 points (2.4 times 5) below the value end.
|
||||
let halves = column.children().unwrap();
|
||||
assert_eq!(halves.len(), 2, "{halves:?}");
|
||||
let half = |i: usize| {
|
||||
let NodeKind::Group { clip: true, children } = &halves[i].kind else { panic!("a clip group: {:?}", halves[i].kind) };
|
||||
(children[0].geometric_bounds().unwrap(), children[1].children().unwrap().to_vec())
|
||||
};
|
||||
let ((above, kept), (below, stretched)) = (half(0), half(1));
|
||||
let at = value_end + 12.0;
|
||||
assert!((above.y1 - at).abs() < 1e-6 && (below.y0 - at).abs() < 1e-6, "{above:?} {below:?}");
|
||||
assert_eq!(kept.len(), 3, "the guide is not drawn");
|
||||
// Above the guide the design keeps its proportions: the 10 × 5 ellipse is 24 × 12 at the value end.
|
||||
let top = kept[1].geometric_bounds().unwrap();
|
||||
assert!((top.y0 - value_end).abs() < 1e-6 && (top.width() - 24.0).abs() < 1e-6 && (top.height() - 12.0).abs() < 1e-6, "{top:?}");
|
||||
// Below it the design is stretched down to the zero line.
|
||||
let (frame, body) = (stretched[0].geometric_bounds().unwrap(), stretched[2].geometric_bounds().unwrap());
|
||||
assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.y1 - 300.0).abs() < 1e-6, "{frame:?}");
|
||||
assert!((body.y0 - at).abs() < 1e-6 && (body.y1 - 300.0).abs() < 1e-6, "{body:?}");
|
||||
}
|
||||
// Vertically Scaled stretches the whole design, the top part included.
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "vertical"})).unwrap();
|
||||
let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec();
|
||||
assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6);
|
||||
// Without a guide a sliding design is scaled vertically too.
|
||||
save_column_design(&mut s, "Plain", (10.0, 20.0, 5.0), false);
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Plain", "columnType": "sliding"})).unwrap();
|
||||
let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec();
|
||||
assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6);
|
||||
// Guides that are not a horizontal line, a 2 × 2 square and a vertical line 0.1 points long, made guides before
|
||||
// the line, are passed over.
|
||||
let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap();
|
||||
let mut ids = vec![
|
||||
id_of(s.execute("shape.rectangle", &json!({"x": 700, "y": 600, "width": 10, "height": 20})).unwrap()),
|
||||
id_of(s.execute("shape.ellipse", &json!({"x": 700, "y": 600, "width": 10, "height": 5})).unwrap()),
|
||||
];
|
||||
let square = id_of(s.execute("shape.rectangle", &json!({"x": 704, "y": 611, "width": 2, "height": 2})).unwrap());
|
||||
let tick = id_of(s.execute("shape.line", &json!({"x1": 702, "y1": 615, "x2": 702, "y2": 615.1})).unwrap());
|
||||
let line = id_of(s.execute("shape.line", &json!({"x1": 700, "y1": 605, "x2": 710, "y2": 605})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [square, tick, line]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
ids.extend([square, tick, line]);
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Boxed"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Boxed", "columnType": "sliding"})).unwrap();
|
||||
let halves = series_parts(&s, id, 0)[0].children().unwrap().to_vec();
|
||||
let above = halves[0].children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!((above.y1 - (160.0 + 12.0)).abs() < 1e-6, "cut at the line, not at the square or the short vertical line: {above:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniformly_scaled_column_designs_keep_their_proportions_and_cast_no_shadow() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = one_series(&mut s, "column", &[35.0, 20.0]);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "dropShadow": true})).unwrap();
|
||||
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "uniform"})).unwrap();
|
||||
for (column, length) in series_parts(&s, id, 0).iter().zip([140.0, 80.0]) {
|
||||
let frame = frames(column)[0];
|
||||
assert!((frame.width() - length).abs() < 1e-6 && (frame.height() - length).abs() < 1e-6, "{frame:?}");
|
||||
assert!((frame.center().x - column.geometric_bounds().unwrap().center().x).abs() < 1e-6);
|
||||
}
|
||||
assert!((frames(&series_parts(&s, id, 0)[0])[0].center().x - plain.center().x).abs() < 1e-6, "centered on the column");
|
||||
// A design twice as wide as it is tall reaches past the plot, and the legend starts right of it.
|
||||
save_column_design(&mut s, "Strip", (20.0, 10.0, 4.0), false);
|
||||
let wide = one_series(&mut s, "column", &[20.0, 35.0]);
|
||||
s.execute("graph.column", &json!({"id": wide.0, "design": "Strip", "columnType": "uniform"})).unwrap();
|
||||
let parts = series_parts(&s, wide, 0);
|
||||
let (reach, legend) = (frames(&parts[1])[0], parts.last().unwrap().geometric_bounds().unwrap());
|
||||
assert!(reach.x1 > 400.0 && legend.x0 > reach.x1, "{reach:?} {legend:?}");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow")), "no shadow for designed columns");
|
||||
// The legend swatch shows the design, its frame filling the 8-point square.
|
||||
let swatch = series_parts(&s, id, 0).last().unwrap().clone();
|
||||
let frame = swatch.children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!((frame.width() - 8.0).abs() < 1e-6 && (frame.height() - 8.0).abs() < 1e-6, "{frame:?}");
|
||||
// Painting the series leaves the design's paint alone.
|
||||
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
|
||||
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
|
||||
let mut red = false;
|
||||
s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| red |= n.appearance.fill_paint().color().is_some_and(|c| c.to_hex() == "#ff0000"));
|
||||
assert!(!red);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_designs_survive_save_and_open_and_follow_their_design() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = graph(&mut s, "stackedColumn");
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
|
||||
// Every series takes the design; only the stacked column series draws it.
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"});
|
||||
s.execute("graph.column", &p).unwrap();
|
||||
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })), "columns and swatch");
|
||||
assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series");
|
||||
let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap();
|
||||
assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"}));
|
||||
let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale };
|
||||
assert_eq!(spec(&s, id).series_columns, [Some(options.clone()), Some(options)]);
|
||||
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesColumns").is_none());
|
||||
// Renamed, the graph draws it still; in use, it isn't unused; deleted, the columns are plain again.
|
||||
s.execute("graph.design", &json!({"rename": "Coin", "to": "Token"})).unwrap();
|
||||
assert!(spec(&s, id).series_columns.iter().flatten().all(|c| c.design == "Token"));
|
||||
assert_eq!(s.execute("graph.design", &json!({"selectUnused": true})).unwrap()["unused"], json!([]));
|
||||
s.execute("graph.design", &json!({"delete": "Token"})).unwrap();
|
||||
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
assert!(s.execute("graph.column", &json!({"id": id.0})).unwrap()["design"].is_null());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })));
|
||||
// Null goes back to plain columns.
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": null})).unwrap();
|
||||
assert!(spec(&s, id).series_columns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_column_params_are_checked_and_designs_too_big_are_drawn_plain() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = one_series(&mut s, "column", &[35.0]);
|
||||
for p in [
|
||||
json!({"design": "Nope"}),
|
||||
json!({"design": 3}),
|
||||
json!({"design": "Coin", "columnType": "diagonal"}),
|
||||
json!({"design": "Coin", "each": 0}),
|
||||
json!({"design": "Coin", "each": -1}),
|
||||
json!({"design": "Coin", "each": "10"}),
|
||||
json!({"design": "Coin", "fractions": "round"}),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["id"] = json!(id.0);
|
||||
assert!(s.execute("graph.column", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(spec(&s, id).series_columns.is_empty());
|
||||
// 350,000 copies of a three-object design are past the budget: refused, and a file asking for it draws plain columns.
|
||||
let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 0.0001});
|
||||
assert!(s.execute("graph.column", &p).is_err());
|
||||
let mut g = spec(&s, id);
|
||||
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 0.0001, fractions: Fractions::Chop };
|
||||
g.series_columns = vec![Some(options.clone())];
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
let art = super::generate(&mut d, &g);
|
||||
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
|
||||
assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
||||
// Columns of 0 on a value axis from 10,000 to 10,001 run 2,000,000 points from the clamped zero line: a copy each
|
||||
// 0.1 is past the budget too.
|
||||
let far = one_series(&mut s, "column", &[0.0, 0.0]);
|
||||
s.execute("graph.setType", &json!({"id": far.0, "axisMin": 10_000, "axisMax": 10_001})).unwrap();
|
||||
assert!(s.execute("graph.column", &json!({"id": far.0, "design": "Coin", "columnType": "repeating", "each": 0.1})).is_err());
|
||||
assert!(spec(&s, far).series_columns.is_empty());
|
||||
// A file's unusable value per copy draws the design vertically scaled, and so does a copy longer than the canvas:
|
||||
// 35 at 1,000,000,000 a copy, 4,000,000,000 points a copy.
|
||||
for (each, fractions) in [(-1.0, Fractions::Chop), (1e9, Fractions::Scale), (1e9, Fractions::Chop)] {
|
||||
g.series_columns = vec![Some(ColumnDesign { each, fractions, ..options.clone() })];
|
||||
let art = super::generate(&mut d, &g);
|
||||
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
|
||||
let column = s0.children().unwrap()[0].clone();
|
||||
assert_eq!(frames(&column).len(), 1, "{each} {fractions:?}");
|
||||
assert!((frames(&column)[0].height() - 140.0).abs() < 1e-6, "{each} {fractions:?}");
|
||||
assert!(!matches!(column.children().unwrap()[0].kind, NodeKind::Group { clip: true, .. }), "{each} {fractions:?}: not clipped");
|
||||
}
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId {
|
||||
NodeId(
|
||||
s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Vector Halftone…",
|
||||
["Object"],
|
||||
None,
|
||||
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots}",
|
||||
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
has_selection,
|
||||
halftone
|
||||
)]
|
||||
@@ -185,11 +185,13 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let (w, h) = vectorcraft_render::region_pixels(region, scale);
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let px = if mono {
|
||||
r.render_region(&tmp, region, scale, true).pixels
|
||||
} else {
|
||||
r.render_region_inks(&tmp, region, scale, &vectorcraft_render::RenderOptions { skip_templates: true, ..Default::default() })
|
||||
};
|
||||
let warning = super::rasterfx::undecoded_warning(failed);
|
||||
if px.len() < w * h * 4 {
|
||||
return Err(EngineError::Other("the art could not be sampled".into()));
|
||||
}
|
||||
@@ -241,7 +243,11 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "dots": dots }))
|
||||
let mut out = json!({ "id": id.0, "dots": dots });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The dots of one screen over `bounds` as one path, with their count.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Window → SVG Interactivity: the JavaScript an object runs on each event in web output
|
||||
//! (`svgInteractivity.setEvent`, `removeEvent`, `clearEvents`) and the JavaScript files the
|
||||
//! document links (`svgInteractivity.addFile`, `removeFile`). SVG export writes the events as
|
||||
//! event attributes (`onclick="…"`) and the files as `<script xlink:href>` elements; SVG import
|
||||
//! reads both back as text. The panel reads everything with `svgInteractivity.info`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, MAX_SVG_SCRIPTS, NodeId, SVG_EVENTS};
|
||||
|
||||
use super::overprint::edit_counted;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "svgInteractivity.info",
|
||||
"SVG Interactivity",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} → {ids, events: [{event, script}…] in the panel's order | null (the objects' events differ), files: [the linked JavaScript files…], eventNames: [the events the panel lists…]} the SVG Interactivity panel's values for ids or the selection",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.setEvent",
|
||||
"Add Event",
|
||||
[],
|
||||
None,
|
||||
"{event: \"onclick\"|\"onmouseover\"|… (svgInteractivity.info's eventNames), script: string (the JavaScript; \"\" removes the event), ids?|id?} set the JavaScript an event runs on ids or the selection, replacing the event's script where it has one, as one undo step; SVG export writes it as the event attribute → {changed: objects}",
|
||||
has_doc,
|
||||
set_event
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.removeEvent",
|
||||
"Remove Selected Entry",
|
||||
[],
|
||||
None,
|
||||
"{event, ids?|id?} remove an event and its JavaScript from ids or the selection, as one undo step → {changed: objects}",
|
||||
has_doc,
|
||||
remove_event
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.clearEvents",
|
||||
"Clear Events",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} remove every event from ids or the selection, as one undo step → {changed: objects}",
|
||||
has_doc,
|
||||
clear_events
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.addFile",
|
||||
"Add JavaScript File",
|
||||
[],
|
||||
None,
|
||||
"{file: path or URL (written as given, without surrounding spaces)} link a JavaScript file to the document, after the others: SVG export writes a <script xlink:href> for each linked file. A file already linked stays where it is; a document links at most 64 files → {files}",
|
||||
has_doc,
|
||||
add_file
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.removeFile",
|
||||
"Remove JavaScript File",
|
||||
[],
|
||||
None,
|
||||
"{file} unlink a JavaScript file from the document → {files}",
|
||||
has_doc,
|
||||
remove_file
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// `p`'s `event`, one of [`SVG_EVENTS`] (any case).
|
||||
fn event_param(p: &Value, cmd: &str) -> Result<&'static str> {
|
||||
let e = str_param(p, "event").ok_or_else(|| bad(cmd, "give `event`, such as onclick"))?;
|
||||
SVG_EVENTS
|
||||
.iter()
|
||||
.find(|n| n.eq_ignore_ascii_case(e.trim()))
|
||||
.copied()
|
||||
.ok_or_else(|| bad(cmd, format!("`event` must be one of {}", SVG_EVENTS.join(", "))))
|
||||
}
|
||||
|
||||
/// The events of object `id` in `d` (none for a missing object).
|
||||
fn events_of(d: &Document, id: NodeId) -> Vec<(String, String)> {
|
||||
d.node(id).and_then(|n| n.extras().attrs.as_deref()).map(|a| a.events.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Change the events of `ids` (or the selection) with `f`, as one undo step named `label`; an
|
||||
/// object whose events `f` leaves as they were isn't changed → `{changed: objects}`.
|
||||
fn edit_events(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl Fn(&mut Vec<(String, String)>)) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select the objects (or give their ids)"));
|
||||
}
|
||||
let changed = edit_counted(s, label, |d| {
|
||||
let mut changed = 0;
|
||||
for id in &ids {
|
||||
let mut events = events_of(d, *id);
|
||||
let before = events.clone();
|
||||
f(&mut events);
|
||||
if events != before
|
||||
&& let Some(n) = d.node_mut(*id)
|
||||
{
|
||||
n.edit_attrs(|a| a.events = events);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
})?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let lists: Vec<Vec<(String, String)>> = ids.iter().map(|id| events_of(d, *id)).collect();
|
||||
let events = lists
|
||||
.first()
|
||||
.filter(|first| lists.iter().all(|l| l == *first))
|
||||
.map(|l| l.iter().map(|(event, script)| json!({ "event": event, "script": script })).collect::<Vec<_>>());
|
||||
Ok(json!({
|
||||
"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||||
"events": events,
|
||||
"files": d.svg_scripts,
|
||||
"eventNames": SVG_EVENTS,
|
||||
}))
|
||||
}
|
||||
|
||||
fn set_event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.setEvent";
|
||||
let event = event_param(p, C)?;
|
||||
let script = p.get("script").map(|v| v.as_str().ok_or_else(|| bad(C, "`script` must be a string"))).transpose()?;
|
||||
let script = script.ok_or_else(|| bad(C, "give `script`, the JavaScript the event runs (\"\" removes the event)"))?.trim().to_string();
|
||||
edit_events(s, p, C, "Add Event", |events| {
|
||||
if script.is_empty() {
|
||||
events.retain(|(e, _)| e != event);
|
||||
} else if let Some((_, js)) = events.iter_mut().find(|(e, _)| e == event) {
|
||||
js.clone_from(&script);
|
||||
} else {
|
||||
events.push((event.to_string(), script.clone()));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.removeEvent";
|
||||
let event = event_param(p, C)?;
|
||||
edit_events(s, p, C, "Remove Selected Entry", |events| events.retain(|(e, _)| e != event))
|
||||
}
|
||||
|
||||
fn clear_events(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_events(s, p, "svgInteractivity.clearEvents", "Clear Events", Vec::clear)
|
||||
}
|
||||
|
||||
/// `p`'s `file`, trimmed and not empty.
|
||||
fn file_param(p: &Value, cmd: &str) -> Result<String> {
|
||||
let f = str_param(p, "file").map(str::trim).filter(|f| !f.is_empty());
|
||||
f.map(str::to_string).ok_or_else(|| bad(cmd, "give `file`, the JavaScript file's path or URL"))
|
||||
}
|
||||
|
||||
fn files(s: &Session) -> Result<Value> {
|
||||
Ok(json!({ "files": s.doc()?.doc.svg_scripts }))
|
||||
}
|
||||
|
||||
fn add_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.addFile";
|
||||
let file = file_param(p, C)?;
|
||||
let linked = &s.doc()?.doc.svg_scripts;
|
||||
if !linked.contains(&file) {
|
||||
if linked.len() >= MAX_SVG_SCRIPTS {
|
||||
return Err(bad(C, format!("a document links at most {MAX_SVG_SCRIPTS} JavaScript files")));
|
||||
}
|
||||
s.edit("Add JavaScript File", |d, _| {
|
||||
d.svg_scripts.push(file);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
files(s)
|
||||
}
|
||||
|
||||
fn remove_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.removeFile";
|
||||
let file = file_param(p, C)?;
|
||||
if !s.doc()?.doc.svg_scripts.contains(&file) {
|
||||
return Err(bad(C, format!("{file:?} isn't linked")));
|
||||
}
|
||||
s.edit("Remove JavaScript File", |d, _| {
|
||||
d.svg_scripts.retain(|f| *f != file);
|
||||
Ok(())
|
||||
})?;
|
||||
files(s)
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
//! `object.intertwine.arrange` picks overlap areas, by a click (`at`) or by an enclosed path drawn
|
||||
//! around them (`area`), and changes which member is drawn on top there: each member keeps the
|
||||
//! area where it is drawn over the others (`NodeExtras::intertwine`, see
|
||||
//! `vectorcraft_doc::intertwine`).
|
||||
//! `vectorcraft_doc::intertwine`). Object › Expand turns the group into a plain group of what it
|
||||
//! draws ([`expand_intertwines`]), which Ungroup takes apart.
|
||||
//!
|
||||
//! An overlap area is a face of the planar arrangement of what the members paint (their fills and
|
||||
//! strokes, type outlined; other objects by their bounds): the part where two or more of them
|
||||
@@ -186,6 +187,41 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- Expand ----------
|
||||
|
||||
/// Replace Intertwine group `id` by a plain group of the art it draws
|
||||
/// ([`vectorcraft_doc::intertwine::expand`]: its objects, then each part drawn over the others as a
|
||||
/// clip group), keeping everything about the group itself; the generated art gets ids of its own.
|
||||
fn expand_group(d: &mut Document, id: NodeId) -> Result<()> {
|
||||
let Some(mut g) = d.node(id).cloned() else { return Ok(()) };
|
||||
let NodeKind::Intertwine { children } = &g.kind else { return Ok(()) };
|
||||
g.kind = NodeKind::Group { children: vectorcraft_doc::intertwine::expand(children).into_iter().map(Arc::new).collect(), clip: false };
|
||||
super::live::fix_ids(d, &mut g);
|
||||
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Object › Expand: every Intertwine group in the subtrees of `roots` becomes a group of the art
|
||||
/// it draws ([`expand_group`]), inner ones first so an outer one copies plain art. Returns how
|
||||
/// many. A live Intertwine is expanded before it can be ungrouped:
|
||||
/// <https://community.adobe.com/questions-652/expand-ungroup-800281>.
|
||||
pub(crate) fn expand_intertwines(d: &mut Document, roots: &[NodeId]) -> Result<usize> {
|
||||
let mut groups = vec![];
|
||||
for r in roots {
|
||||
if let Some(n) = d.node(*r) {
|
||||
n.walk(&mut |c| {
|
||||
if is_intertwine(c) {
|
||||
groups.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
for g in groups.iter().rev() {
|
||||
expand_group(d, *g)?;
|
||||
}
|
||||
Ok(groups.len())
|
||||
}
|
||||
|
||||
// ---------- Arrange ----------
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
|
||||
@@ -286,7 +286,7 @@ pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
|
||||
}
|
||||
|
||||
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
|
||||
fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
|
||||
pub(super) fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
|
||||
match v {
|
||||
Value::Number(n) => match n.as_u64() {
|
||||
Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
|
||||
@@ -834,7 +834,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let i = super::panelcmds::artboard_index(&s.doc()?.doc, p, "artboard.setProps")?;
|
||||
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let now = rect_from(p, was);
|
||||
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
|
||||
@@ -933,8 +933,8 @@ fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let st = s.doc()?;
|
||||
let i = if p.get("index").is_none() { 0 } else { super::panelcmds::artboard_index(&st.doc, p, "artboard.fitToSelection")? };
|
||||
let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?;
|
||||
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
|
||||
}
|
||||
|
||||
@@ -271,8 +271,19 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.len() < 2 {
|
||||
return Err(bad(C, "highlight two or more rows to merge"));
|
||||
}
|
||||
let into =
|
||||
ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
|
||||
// The last highlighted layer that is not inside another highlighted row: with a layer and its
|
||||
// sublayer both highlighted (Select All in the panel), the sublayer travels with its layer
|
||||
// instead of becoming the target (#2383).
|
||||
let roots: HashSet<NodeId> = row_roots(d, &ids).into_iter().collect();
|
||||
let is_layer = |id: &NodeId| d.node(*id).is_some_and(Node::is_layer);
|
||||
let into = ids
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.filter(is_layer)
|
||||
.find(|id| roots.contains(id))
|
||||
.or_else(|| ids.iter().rev().copied().find(is_layer))
|
||||
.ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
|
||||
let around = d.ancestry(into).unwrap_or_default();
|
||||
// (Rows inside the target layer merge too: only rows inside other listed rows go with those.)
|
||||
let rest: Vec<NodeId> = ids.iter().copied().filter(|id| *id != into).collect();
|
||||
|
||||
@@ -101,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Spine Segment",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's: a straight spine turns into a curve (#1111)",
|
||||
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy, perpendicular?: bool} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's (perpendicular too): a straight spine turns into a curve (#1111)",
|
||||
has_doc,
|
||||
spine_reshape_segment
|
||||
),
|
||||
@@ -238,7 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Gradient Mesh…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point) → {ids, index?}",
|
||||
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point); an embedded image becomes a mesh over its rectangle (its transform kept) whose points take the average colour and opacity of the picture around them (a linked image is refused: embed it first) → {ids, index?}",
|
||||
has_selection,
|
||||
mesh_create
|
||||
),
|
||||
@@ -383,7 +383,7 @@ fn replace_with(d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<Vec<No
|
||||
}
|
||||
|
||||
/// Fresh ids for any id-0 (generated) node in `n`'s subtree; keeps real ids.
|
||||
fn fix_ids(d: &mut Document, n: &mut Node) {
|
||||
pub(super) fn fix_ids(d: &mut Document, n: &mut Node) {
|
||||
if n.id == NodeId(0) {
|
||||
n.id = d.alloc_id();
|
||||
}
|
||||
@@ -402,16 +402,20 @@ const DEFAULT_DISTANCE: f64 = 10.0;
|
||||
|
||||
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32));
|
||||
let mode = str_param(p, "spacing");
|
||||
let value = num("value");
|
||||
let sp = match mode {
|
||||
Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor,
|
||||
Some("steps") => BlendSpacing::Steps(value.or(num("steps")).unwrap_or(DEFAULT_STEPS as f64) as u32),
|
||||
Some("steps") => match value {
|
||||
Some(v) => steps("value", v)?,
|
||||
None => steps("steps", num("steps").unwrap_or(DEFAULT_STEPS as f64))?,
|
||||
},
|
||||
Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(DEFAULT_DISTANCE)),
|
||||
Some(o) => return Err(bad(cmd, format!("unknown spacing `{o}` (smooth|steps|distance)"))),
|
||||
None => {
|
||||
if let Some(n) = num("steps") {
|
||||
BlendSpacing::Steps(n as u32)
|
||||
steps("steps", n)?
|
||||
} else if let Some(d) = num("distance") {
|
||||
BlendSpacing::Distance(d)
|
||||
} else if bool_or(p, "smooth", false) {
|
||||
@@ -657,7 +661,11 @@ fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
|
||||
return Err(bad(C, "t, dx and dy must be finite"));
|
||||
}
|
||||
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| if sp.bend_segment(seg, t, dv) { Ok(()) } else { Err(bad(C, "no such spine segment")) })?;
|
||||
let perpendicular = bool_or(p, "perpendicular", false);
|
||||
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| {
|
||||
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
|
||||
if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) }
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -955,14 +963,15 @@ fn envelope(id: NodeId, content: Vec<Arc<Node>>, kind: EnvelopeKind, (options, f
|
||||
Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) })
|
||||
}
|
||||
|
||||
/// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`.
|
||||
/// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`.
|
||||
fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> {
|
||||
let rows = f64_or(p, "rows", default.0 as f64);
|
||||
let cols = f64_or(p, "cols", f64_or(p, "columns", default.1 as f64));
|
||||
let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" };
|
||||
let cols = f64_or(p, cols_key, default.1 as f64);
|
||||
if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) {
|
||||
return Err(bad(cmd, "rows and cols must be 1..50"));
|
||||
}
|
||||
Ok((rows as u32, cols as u32))
|
||||
Ok((whole(rows, "rows", cmd)? as u32, whole(cols, cols_key, cmd)? as u32))
|
||||
}
|
||||
|
||||
fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1299,7 +1308,7 @@ fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.setMeshPoint";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let index = f64_req(p, "index", C)? as usize;
|
||||
let index = index_param(p, "index", C)?;
|
||||
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let handle = handle_param(p, C)?;
|
||||
s.edit("Move Envelope Point", |d, _| {
|
||||
@@ -1344,18 +1353,33 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut index = None;
|
||||
let mut refused = None;
|
||||
for id in &roots {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let Some(path) = outline_of(&n) else { continue };
|
||||
let m = match n.appearance.fill_paint() {
|
||||
// The points take the colours the gradient paints there.
|
||||
Paint::Gradient(g) => {
|
||||
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
|
||||
let m = if let NodeKind::Image(im) = &n.kind {
|
||||
// An embedded image: the points take the picture's colours there.
|
||||
match image_mesh(d, im, rows, cols) {
|
||||
Ok(mut m) => {
|
||||
m.highlight(app, highlight);
|
||||
m
|
||||
})
|
||||
Some(m)
|
||||
}
|
||||
Err(why) => {
|
||||
refused.get_or_insert(why);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let Some(path) = outline_of(&n) else { continue };
|
||||
match n.appearance.fill_paint() {
|
||||
// The points take the colours the gradient paints there.
|
||||
Paint::Gradient(g) => {
|
||||
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
|
||||
m.highlight(app, highlight);
|
||||
m
|
||||
})
|
||||
}
|
||||
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
|
||||
}
|
||||
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
|
||||
};
|
||||
let Some(mut m) = m else { continue };
|
||||
if let Some(q) = at {
|
||||
@@ -1371,7 +1395,7 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out.push(*id);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(bad(C, "select a path or compound path"));
|
||||
return Err(bad(C, refused.unwrap_or("select a path, a compound path or an embedded image")));
|
||||
}
|
||||
sel.set(out.iter().copied());
|
||||
Ok((out, index))
|
||||
@@ -1383,6 +1407,31 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Create Gradient Mesh on an embedded image: a `rows` × `cols` mesh over the picture's rectangle
|
||||
/// (placed with the image's transform), each point taking the average colour and opacity of the
|
||||
/// area around it. Linked images are refused: the documentation allows embedded ones only.
|
||||
fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: u32) -> std::result::Result<GradientMesh, &'static str> {
|
||||
if im.link.is_some() {
|
||||
return Err("a linked image can't become a gradient mesh: embed it first");
|
||||
}
|
||||
let xf = im.xf.as_coeffs();
|
||||
if im.width == 0 || im.height == 0 || !xf.iter().all(|k| k.is_finite()) {
|
||||
return Err("the image has no usable size or position");
|
||||
}
|
||||
let samples = d.images.get(&im.key).and_then(|b| b.grid_samples(rows, cols)).ok_or("the image's pixels can't be read")?;
|
||||
let (w, h) = (im.width as f64, im.height as f64);
|
||||
let at = |u: f64, v: f64| im.xf * Point::new(u * w, v * h);
|
||||
if ![at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0), at(1.0, 1.0)].iter().all(|p| p.x.abs() < 1e15 && p.y.abs() < 1e15) {
|
||||
return Err("the image has no usable size or position");
|
||||
}
|
||||
let cols_n = cols as usize + 1;
|
||||
let steps = |n: u32| (0..=n).map(|i| i as f64 / n as f64).collect::<Vec<_>>();
|
||||
Ok(GradientMesh::from_grid(&steps(cols), &steps(rows), &at, &|r, c, _| {
|
||||
let [pr, pg, pb, pa] = samples.get(r * cols_n + c).copied().unwrap_or([0; 4]);
|
||||
(Color::rgb8(pr, pg, pb), pa as f32 / 255.0)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh
|
||||
/// envelope's grid too (its points and handles are written back: editing fills in the handles).
|
||||
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
|
||||
@@ -1422,7 +1471,12 @@ fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
if i < 0.0 {
|
||||
return Err(bad(cmd, "index must be ≥ 0"));
|
||||
}
|
||||
Ok(i as usize)
|
||||
Ok(whole(i, key, cmd)? as usize)
|
||||
}
|
||||
|
||||
/// `x`, the value of `key`, when it is a whole number, and an error otherwise.
|
||||
fn whole(x: f64, key: &str, cmd: &str) -> Result<f64> {
|
||||
if x.fract() == 0.0 { Ok(x) } else { Err(bad(cmd, format!("`{key}` must be a whole number"))) }
|
||||
}
|
||||
|
||||
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rasterize…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height}",
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
has_selection,
|
||||
rasterize
|
||||
),
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Object Mosaic…",
|
||||
["Object"],
|
||||
None,
|
||||
"{columns=10, rows=10, width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
|
||||
"{columns=10, rows=10 (whole numbers, kept within 1..1000), width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
|
||||
has_image,
|
||||
object_mosaic
|
||||
),
|
||||
@@ -112,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Crop Image",
|
||||
["Object"],
|
||||
None,
|
||||
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim)}",
|
||||
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim), warning? (without trim: the image couldn't be decoded, as object.rasterize says)}",
|
||||
has_image,
|
||||
crop_image
|
||||
),
|
||||
@@ -168,6 +168,20 @@ fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
/// The whole number `p` gives for `key`, written `4` or `4.0`, or `default` when `p` gives none or null. A value that
|
||||
/// is not a number gives the [`check_numbers`] error, and a fraction or a negative number gives
|
||||
/// "`{key}` must be a whole number".
|
||||
fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result<u64> {
|
||||
check_numbers(p, &[key], cmd)?;
|
||||
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(default) };
|
||||
match (v.as_u64(), v.as_f64()) {
|
||||
(Some(n), _) => Ok(n),
|
||||
// A whole number past `u64::MAX` saturates to it.
|
||||
(None, Some(x)) if x >= 0.0 && x.fract() == 0.0 => Ok(x as u64),
|
||||
_ => Err(bad(cmd, format!("`{key}` must be a whole number"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command's implementation without the enablement check or journaling.
|
||||
pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
@@ -210,8 +224,9 @@ fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let symbols = d.symbol_boxes();
|
||||
// `selected` holds each selected object's visual bounds, by layer, with the index there of its
|
||||
// ancestor directly inside the layer.
|
||||
// ancestor directly inside the layer. Symbol instances are measured by their art.
|
||||
let mut selected: BTreeMap<NodeId, Vec<(usize, Rect)>> = BTreeMap::new();
|
||||
for id in &roots {
|
||||
let Some(layer) = d.layer_of(*id) else { continue };
|
||||
@@ -219,7 +234,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
let anc = d.ancestry(*id).unwrap_or_default();
|
||||
let Some(pos) = anc.iter().position(|a| *a == layer) else { continue };
|
||||
let top = anc.get(pos + 1).copied().unwrap_or(*id);
|
||||
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds()) else { continue };
|
||||
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds_with(&symbols)) else { continue };
|
||||
let Some((_, idx, _)) = d.position(top) else { continue };
|
||||
selected.entry(layer).or_default().push((idx, b));
|
||||
}
|
||||
@@ -239,7 +254,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
if under.is_empty() || roots.contains(&n.id) {
|
||||
continue;
|
||||
}
|
||||
if n.visual_bounds().is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
|
||||
if n.visual_bounds_with(&symbols).is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
|
||||
hits.insert(n.id);
|
||||
}
|
||||
}
|
||||
@@ -335,13 +350,13 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
let rx = bool_or(p, "reflectX", false);
|
||||
let ry = bool_or(p, "reflectY", false);
|
||||
let random = bool_or(p, "random", false);
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
|
||||
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let objects = super::object::transforms_objects(p);
|
||||
if sh == 0.0 || sv == 0.0 {
|
||||
return Err(bad("object.transformEach", "scale must be non-zero"));
|
||||
}
|
||||
let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1));
|
||||
let mut rng = Rng(whole_or(p, "seed", 0x9E37_79B9_7F4A_7C15, "object.transformEach")?.max(1));
|
||||
let mut sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
|
||||
sc.patterns = super::object::transform_patterns(s, p, &roots)?;
|
||||
// With no object to transform, or without a copy when both scales are 100% and there is no
|
||||
@@ -352,8 +367,11 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
}
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() };
|
||||
for id in &targets {
|
||||
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
|
||||
// Measure each object's geometric bounds first, with symbol instances measured by their art.
|
||||
let symbols = d.symbol_boxes();
|
||||
let boxes: Vec<Option<Rect>> = targets.iter().map(|id| d.node(*id).and_then(|n| n.geometric_bounds_with(&symbols))).collect();
|
||||
for (id, b) in targets.iter().zip(boxes) {
|
||||
let Some(b) = b else { continue };
|
||||
let (mut k_sh, mut k_sv, mut k_mh, mut k_mv, mut k_rot) = (sh, sv, mh, mv, rot);
|
||||
if random {
|
||||
k_sh = 100.0 + (sh - 100.0) * rng.next();
|
||||
@@ -408,21 +426,24 @@ pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`.
|
||||
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`, with the note that an
|
||||
/// image in it couldn't be decoded ([`super::rasterfx::undecoded_warning`]).
|
||||
fn render_png(
|
||||
doc: &vectorcraft_doc::Document,
|
||||
region: Rect,
|
||||
scale: f64,
|
||||
opts: &vectorcraft_render::RenderOptions,
|
||||
color: RasterColorModel,
|
||||
) -> Result<(Vec<u8>, u32, u32)> {
|
||||
) -> Result<(Vec<u8>, u32, u32, Option<&'static str>)> {
|
||||
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
|
||||
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
|
||||
}
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let mut img = r.render_region_with(doc, region, scale, opts);
|
||||
let warning = super::rasterfx::undecoded_warning(failed);
|
||||
RasterEffectsSettings { color_model: color, ..Default::default() }.finish_pixels(&mut img.pixels);
|
||||
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height))
|
||||
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height, warning))
|
||||
}
|
||||
|
||||
/// The image Object › Rasterize makes of some art, before it goes in a document
|
||||
@@ -435,6 +456,8 @@ pub(crate) struct Rasterized {
|
||||
xf: Affine,
|
||||
/// The art's outline, with Create Clipping Mask.
|
||||
clip: Option<(PathData, vectorcraft_geom::FillRule)>,
|
||||
/// The note that an image in the art couldn't be decoded ([`render_png`]).
|
||||
warning: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Rasterized {
|
||||
@@ -519,9 +542,9 @@ pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p
|
||||
.flatten()
|
||||
.map(|(bp, rule)| (PathData::from_bezpath(&bp), rule));
|
||||
let tmp = isolated_doc(doc, nodes);
|
||||
let (png, width, height) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
|
||||
let (png, width, height, warning) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
Ok(Rasterized { png, width, height, xf, clip })
|
||||
Ok(Rasterized { png, width, height, xf, clip, warning })
|
||||
}
|
||||
|
||||
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -530,7 +553,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<Node> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect();
|
||||
let image = rasterize_art(&st.doc, nodes, p, C)?;
|
||||
let (w, h) = (image.width, image.height);
|
||||
let (w, h, warning) = (image.width, image.height, image.warning);
|
||||
if roots.is_empty() {
|
||||
return Err(bad(C, "nothing selected"));
|
||||
}
|
||||
@@ -540,7 +563,11 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "width": w, "height": h }))
|
||||
let mut out = json!({ "id": id.0, "width": w, "height": h });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn has_image(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -567,8 +594,8 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
|
||||
let blob = st.doc.images.get(&im.key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| bad(C, "the image has no embedded pixels"))?;
|
||||
let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
|
||||
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
|
||||
let cols = whole_or(p, "columns", 10, C)?.clamp(1, 1000) as u32;
|
||||
let rows = whole_or(p, "rows", 10, C)?.clamp(1, 1000) as u32;
|
||||
let gray = bool_or(p, "gray", false);
|
||||
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
|
||||
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
|
||||
@@ -677,7 +704,8 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
bare.visible = true;
|
||||
let tmp = isolated_doc(&st.doc, vec![bare]);
|
||||
let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale);
|
||||
let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
|
||||
let (png, w, h, warning) =
|
||||
render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
|
||||
s.edit("Crop Image", |d, _| {
|
||||
let key = unique_key(d, "crop");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
@@ -693,7 +721,11 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "width": w, "height": h }))
|
||||
let mut out = json!({ "id": id.0, "width": w, "height": h });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the
|
||||
@@ -707,9 +739,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
|
||||
}
|
||||
// Print rasters run to gigabytes decoded: the same allowance opening them has.
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(C, "the image's pixels can't be read"))?;
|
||||
let reader = || {
|
||||
image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(C, "the image's pixels can't be read"))
|
||||
};
|
||||
// The image's size as RGBA, read from its header, is held to that allowance before anything
|
||||
// is decoded (#1717).
|
||||
let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
|
||||
if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
let mut reader = reader()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
@@ -906,9 +947,11 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let n = s.edit("Convert to Artboards", |d, sel| {
|
||||
// Measure each object's geometric bounds first, with symbol instances measured by their art.
|
||||
let symbols = d.symbol_boxes();
|
||||
let boxes: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, d.node(*id)?.geometric_bounds_with(&symbols)?))).collect();
|
||||
let mut converted = vec![];
|
||||
for id in &roots {
|
||||
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
|
||||
for (id, b) in boxes {
|
||||
if b.width() <= 0.0 || b.height() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
@@ -920,7 +963,7 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
});
|
||||
converted.push(*id);
|
||||
converted.push(id);
|
||||
}
|
||||
if converted.is_empty() {
|
||||
return Err(EngineError::Other("Convert to Artboards: selection has no area".into()));
|
||||
@@ -950,8 +993,8 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let count = s.doc()?.doc.artboards.len();
|
||||
let n_ab = count.max(1);
|
||||
// Columns of a grid by row, rows of a grid by column (NaN casts to 0).
|
||||
let lines = (f64_or(p, "columns", 2.0).clamp(1.0, n_ab as f64) as usize).max(1);
|
||||
// Columns of a grid by row, rows of a grid by column.
|
||||
let lines = whole_or(p, "columns", 2, CMD)?.clamp(1, n_ab as u64) as usize;
|
||||
let (rows, cols) = match layout {
|
||||
L::GridByRow => (n_ab.div_ceil(lines), lines),
|
||||
L::GridByColumn => (lines, n_ab.div_ceil(lines)),
|
||||
@@ -972,8 +1015,10 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) {
|
||||
return Ok(json!({ "artboards": count, "rows": rows, "columns": cols }));
|
||||
}
|
||||
// With moveArtwork, each top-level object moves with the artboard its center is on; locked and
|
||||
// With moveArtwork, each top-level object moves with the artboard its center is on, with symbol
|
||||
// instances measured by their art as `Document::art_on_artboard` measures them; locked and
|
||||
// hidden art moves only when prefs moveLockedWithArtboard is on.
|
||||
let symbols = d.symbol_boxes();
|
||||
let art: Vec<(NodeId, Vec2)> = if move_art {
|
||||
d.layers
|
||||
.iter()
|
||||
@@ -981,8 +1026,8 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|n| n.rides_with_artboard(locked_and_hidden))
|
||||
.filter_map(|n| {
|
||||
let c = n.geometric_bounds()?.center();
|
||||
let dl = *rects.iter().position(|r| r.contains(c)).and_then(|i| deltas.get(i))?;
|
||||
let c = n.geometric_bounds_with(&symbols)?.center();
|
||||
let dl = *d.artboard_holding(c, false).and_then(|i| deltas.get(i))?;
|
||||
(dl != Vec2::ZERO).then_some((n.id, dl))
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -34,6 +34,7 @@ mod halftone;
|
||||
pub mod help;
|
||||
pub mod importmatches;
|
||||
pub(crate) mod inline;
|
||||
mod interactivity;
|
||||
mod intertwine;
|
||||
mod layer;
|
||||
mod layerpanel;
|
||||
@@ -263,6 +264,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(library::specs());
|
||||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
v.extend(interactivity::specs());
|
||||
v.extend(newart::specs());
|
||||
v.extend(colortheme::specs());
|
||||
v.extend(fileio::pdf::specs());
|
||||
|
||||
@@ -103,28 +103,45 @@ impl PreviewMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved preset's raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite).
|
||||
fn de_ppi<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
|
||||
Ok(Some(f64::deserialize(d)?).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0))
|
||||
}
|
||||
|
||||
/// Everything New Document sets: a preset (built-in, saved or recent) or `file.new`'s params.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DocSettings {
|
||||
/// The preset's name.
|
||||
pub name: String,
|
||||
/// Artboard size in points.
|
||||
/// Artboard size in points, more than 0 and up to `MAX_SIDE` (a saved or Recent preset's
|
||||
/// value outside that range is read as the nearer end, and one of 0 or less is read as 1).
|
||||
#[serde(deserialize_with = "de_side")]
|
||||
pub width: f64,
|
||||
#[serde(deserialize_with = "de_side")]
|
||||
pub height: f64,
|
||||
pub units: Unit,
|
||||
/// 1 to `MAX_ARTBOARDS` (a saved or Recent preset's value outside that range is read as the
|
||||
/// nearer end).
|
||||
#[serde(deserialize_with = "de_artboards")]
|
||||
pub artboards: u32,
|
||||
pub layout: ArtboardLayout,
|
||||
/// Columns of a grid layout.
|
||||
pub columns: u32,
|
||||
/// Space between artboards, points.
|
||||
/// Space between artboards, points: 0 to `MAX_SPACING` (a saved or Recent preset's value
|
||||
/// outside that range is read as the nearer end).
|
||||
#[serde(deserialize_with = "de_spacing")]
|
||||
pub spacing: f64,
|
||||
/// Change to Right-to-Left Layout: the first artboard at the right.
|
||||
pub right_to_left: bool,
|
||||
/// Top, bottom, left, right, points.
|
||||
/// Top, bottom, left, right, points: 0 to `MAX_BLEED` each (a saved or Recent preset's value
|
||||
/// outside that range is read as the nearer end).
|
||||
#[serde(deserialize_with = "de_bleed")]
|
||||
pub bleed: [f64; 4],
|
||||
pub background: Background,
|
||||
pub color_mode: ColorMode,
|
||||
/// 1 to 2400 ppi (a saved preset's value outside that range is read as the nearer end).
|
||||
#[serde(deserialize_with = "de_ppi")]
|
||||
pub raster_effects_ppi: f64,
|
||||
pub preview_mode: PreviewMode,
|
||||
}
|
||||
@@ -341,6 +358,28 @@ pub fn color_mode_id(m: ColorMode) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved or Recent preset's artboard side: up to [`MAX_SIDE`] points, and 1 when it is 0 or less.
|
||||
fn de_side<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
|
||||
let v = f64::deserialize(d)?;
|
||||
Ok(if v > 0.0 { v.min(MAX_SIDE) } else { 1.0 })
|
||||
}
|
||||
|
||||
/// A saved or Recent preset's artboard count, rounded and kept to 1 to [`MAX_ARTBOARDS`] as
|
||||
/// `file.new` reads its `artboards` param.
|
||||
fn de_artboards<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<u32, D::Error> {
|
||||
Ok(f64::deserialize(d)?.round().clamp(1.0, MAX_ARTBOARDS as f64) as u32)
|
||||
}
|
||||
|
||||
/// A saved or Recent preset's artboard spacing, kept to 0 to [`MAX_SPACING`] points.
|
||||
fn de_spacing<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
|
||||
Ok(f64::deserialize(d)?.clamp(0.0, MAX_SPACING))
|
||||
}
|
||||
|
||||
/// A saved or Recent preset's bleed, each side kept to 0 to `MAX_BLEED` points.
|
||||
fn de_bleed<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<[f64; 4], D::Error> {
|
||||
Ok(vectorcraft_doc::setup::bleed_in_range(<[f64; 4]>::deserialize(d)?))
|
||||
}
|
||||
|
||||
/// Top-left corners for artboards of `sizes` from `origin`: `columns` per row, `spacing` apart,
|
||||
/// each column as wide and each row as tall as its largest artboard; `by_column` fills columns
|
||||
/// top to bottom first, `rtl` puts the first column at the right.
|
||||
|
||||
@@ -459,7 +459,7 @@ pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: boo
|
||||
if let Some(k) = &it.copies
|
||||
&& Arc::ptr_eq(&k.from, &it.doc)
|
||||
&& k.roots == roots
|
||||
&& (!k.selection.guides.is_empty() == has_guide_copies)
|
||||
&& (k.selection.guides.is_empty() != has_guide_copies)
|
||||
{
|
||||
return Ok(Some(k.clone()));
|
||||
}
|
||||
@@ -1061,11 +1061,16 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(i) = st.isolation.take() {
|
||||
super::distortcmds::finish_edit_text(st, i);
|
||||
let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i);
|
||||
st.selection.set([target]);
|
||||
// A guide path isolated while Lock Guides is on isn't selected (#1534).
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
}
|
||||
st.revision += 1;
|
||||
ok()
|
||||
|
||||
@@ -298,14 +298,31 @@ fn new_color_model(s: &Session, p: &Value) -> Option<vectorcraft_color::cms::Mod
|
||||
(mode != vectorcraft_doc::ColorMode::Rgb && !bool_or(p, "keepModel", false)).then(|| mode.model())
|
||||
}
|
||||
|
||||
/// `c` in `model` ([`new_color_model`]) when it is an RGB color, converted through the color
|
||||
/// settings (the working spaces and their intent) as Document Color Mode converts it. Other colors
|
||||
/// are returned as given.
|
||||
fn stored_color(model: Option<vectorcraft_color::cms::Model>, c: Color) -> Color {
|
||||
match (c, model) {
|
||||
(Color::Rgb { .. }, Some(m)) => {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
cms.convert(&c, m, cms.settings().intent)
|
||||
}
|
||||
_ => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// A color given in `p` as the document stores it: an RGB color in a CMYK document (without
|
||||
/// `keepModel`) becomes CMYK through the color settings ([`stored_color`]).
|
||||
pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color {
|
||||
stored_color(new_color_model(s, p), c)
|
||||
}
|
||||
|
||||
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
|
||||
/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]).
|
||||
/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]),
|
||||
/// converted through the color settings.
|
||||
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
||||
let model = new_color_model(s, p);
|
||||
let new_color = |c: Color| match (c, model) {
|
||||
(Color::Rgb { .. }, Some(m)) => c.in_model(m),
|
||||
_ => c,
|
||||
};
|
||||
let new_color = |c: Color| stored_color(model, c);
|
||||
if bool_or(p, "none", false) {
|
||||
return Ok(Some(Paint::None));
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index?: n (default: the Artboard tool's artboard), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} copy placed right of the last artboard, with copies of the art fully inside it when `art` (locked and hidden art only with prefs moveLockedWithArtboard) → {index, ids: the art's copies}",
|
||||
"{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}",
|
||||
has_doc,
|
||||
artboard_duplicate
|
||||
),
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index?, art?} (defaults as artboard.duplicate) put the artboard, and the art fully inside it when `art`, on the clipboard: edit.paste adds a copy right of the last artboard, edit.pasteInPlace/InFront/InBack where it was, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
"{index? | indices?, art?} (defaults as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
has_doc,
|
||||
|s, p| artboard_copy(s, p, false)
|
||||
),
|
||||
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cut Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index?, art?} artboard.copy, then delete the artboard and that art in one undo step (not the only artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
"{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
|
||||
has_doc,
|
||||
|s, p| artboard_copy(s, p, true)
|
||||
),
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "artboard.reorder";
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize;
|
||||
let i = artboard_index(&s.doc()?.doc, p, C)?;
|
||||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
|
||||
s.edit("Reorder Artboards", |d, _| {
|
||||
if i >= d.artboards.len() {
|
||||
@@ -68,16 +68,37 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The artboard (`index`) and whether its art comes along (`art`) for the artboard copy
|
||||
/// commands: by default the Artboard tool's artboard and its Move/Copy Artwork with Artboard option.
|
||||
fn board_and_art(s: &Session, p: &Value) -> (usize, bool) {
|
||||
/// The artboards (`index` or `indices`) and whether their art comes along (`art`) for the artboard
|
||||
/// copy commands: by default the artboards the Artboard tool has selected (its active one when none
|
||||
/// of them is in the document) and its Move/Copy Artwork with Artboard option.
|
||||
fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<usize>, bool)> {
|
||||
let t = s.tool_options_of("artboard");
|
||||
let i = p.get("index").and_then(Value::as_u64).or_else(|| t["active"].as_u64()).unwrap_or(0) as usize;
|
||||
(i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true)))
|
||||
let d = &s.doc()?.doc;
|
||||
let indices = if p.get("index").is_some() || p.get("indices").is_some() {
|
||||
artboard_indices(d, p, cmd)?
|
||||
} else {
|
||||
let mut picked: Vec<u64> = t["selected"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_u64)
|
||||
.filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len()))
|
||||
.collect();
|
||||
if picked.is_empty() {
|
||||
picked.push(t["active"].as_u64().unwrap_or(0));
|
||||
}
|
||||
artboard_indices(d, &json!({ "indices": picked }), cmd)?
|
||||
};
|
||||
Ok((indices, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true))))
|
||||
}
|
||||
|
||||
/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of
|
||||
/// [`artboard_indices`] (`missing index`, `no such artboard`).
|
||||
pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?;
|
||||
artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into()))
|
||||
}
|
||||
|
||||
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
|
||||
/// ones only with prefs moveLockedWithArtboard), in paint order.
|
||||
/// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`,
|
||||
/// in order and once each.
|
||||
pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
@@ -93,55 +114,87 @@ pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &s
|
||||
Ok(indices)
|
||||
}
|
||||
|
||||
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
|
||||
/// ones only with prefs moveLockedWithArtboard), in paint order.
|
||||
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
Ok(if art { d.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] })
|
||||
}
|
||||
|
||||
/// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object
|
||||
/// counted once, with the first of them it is on (artboards can overlap).
|
||||
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut arts = vec![];
|
||||
for &i in indices {
|
||||
let art = artboard_art(s, i, art)?.into_iter().filter(|id| seen.insert(*id)).collect();
|
||||
arts.push((i, art));
|
||||
}
|
||||
Ok(arts)
|
||||
}
|
||||
|
||||
/// How far a copy of `rect` moves to sit right of the last artboard (Duplicate Artboards, Paste).
|
||||
pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec2 {
|
||||
let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max);
|
||||
Vec2::new(right + 20.0 - rect.x0, 0.0)
|
||||
}
|
||||
|
||||
/// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep
|
||||
/// their places relative to each other, together right of the last artboard.
|
||||
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (i, art) = board_and_art(s, p);
|
||||
let art = artboard_art(s, i, art)?;
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &art)?, ..Scaling::default() };
|
||||
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
|
||||
let arts = artboards_art(s, &indices, art)?;
|
||||
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() };
|
||||
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
|
||||
let dv = d.artboards.get(i).map(|a| beside_artboards(d, a.rect)).unwrap_or_default();
|
||||
copy_artboard(d, i, dv, &art, sc)
|
||||
let dv = arts
|
||||
.iter()
|
||||
.filter_map(|(i, _)| d.artboards.get(*i))
|
||||
.map(|a| a.rect)
|
||||
.reduce(|a, b| a.union(b))
|
||||
.map(|r| beside_artboards(d, r))
|
||||
.unwrap_or_default();
|
||||
let (mut index, mut ids) = (vec![], vec![]);
|
||||
for (i, art) in &arts {
|
||||
let (at, copies) = copy_artboard(d, *i, dv, art, sc)?;
|
||||
index.push(at);
|
||||
ids.extend(copies);
|
||||
}
|
||||
Ok((index, ids))
|
||||
})?;
|
||||
Ok(json!({"index": index, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
Ok(json!({"index": index.first(), "indices": index, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Artboard Copy / Cut: the artboard and its art onto the clipboard (pasting brings both); a cut
|
||||
/// then deletes them.
|
||||
/// Artboard Copy / Cut: the artboards and their art onto the clipboard (pasting brings them all);
|
||||
/// a cut then deletes them.
|
||||
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
|
||||
let (i, art) = board_and_art(s, p);
|
||||
let art = artboard_art(s, i, art)?;
|
||||
let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
|
||||
let arts = artboards_art(s, &indices, art)?;
|
||||
let st = s.doc()?;
|
||||
if cut && st.doc.artboards.len() <= 1 {
|
||||
if cut && st.doc.artboards.len() <= indices.len() {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
}
|
||||
let board = st.doc.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let clip = Clipboard { source_artboard: Some(board.rect), artboard: Some(board), ..Clipboard::copy(st, &art) };
|
||||
let art = st.doc.paint_order(arts.into_iter().flat_map(|(_, a)| a));
|
||||
let boards: Vec<_> = indices.iter().filter_map(|i| st.doc.artboards.get(*i).cloned()).collect();
|
||||
let clip = Clipboard { source_artboard: boards.first().map(|a| a.rect), artboards: boards, ..Clipboard::copy(st, &art) };
|
||||
let copied = clip.nodes.len();
|
||||
if cut {
|
||||
s.edit("Cut Artboard", |d, sel| {
|
||||
if i >= d.artboards.len() {
|
||||
if indices.iter().any(|i| *i >= d.artboards.len()) {
|
||||
return Err(EngineError::Other("no such artboard".into()));
|
||||
}
|
||||
// Its guides go with it.
|
||||
let gone = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
|
||||
// The last first, so the others keep their places; their guides go with them.
|
||||
for &i in indices.iter().rev() {
|
||||
let gone = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
|
||||
}
|
||||
d.remove_many(&art)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
if s.tool_id() == "artboard" {
|
||||
s.set_tool_option("active", &json!(i.saturating_sub(1)));
|
||||
s.set_tool_option("active", &json!(indices.first().map_or(0, |i| i.saturating_sub(1))));
|
||||
}
|
||||
}
|
||||
s.clipboard = clip;
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
//! command with its default. So are the `params` of an effect (#1592): `effect.apply` and
|
||||
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
|
||||
//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes
|
||||
//! what it is given, so the journal of an older version still replays.
|
||||
//! what it is given, so the journal of an older version still replays, except for an effect's
|
||||
//! `color` that the effect can't read, which `effect.apply` and `effect.setParams` refuse
|
||||
//! themselves ([`check_effect_color`]).
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -212,8 +214,9 @@ fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
|
||||
|
||||
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
|
||||
/// effect takes (those of its dialog defaults and of its params description), each of its
|
||||
/// default's type. Numbers out of range pass: the effect clamps them when it draws. An effect
|
||||
/// plug-in resolves its own against its manifest, and an unknown effect is the command's to report.
|
||||
/// default's type, and a `color` the effect can draw ([`check_effect_color`]). Numbers out of
|
||||
/// range pass: the effect clamps them when it draws. An effect plug-in resolves its own against
|
||||
/// its manifest, and an unknown effect is the command's to report.
|
||||
pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> {
|
||||
let Value::Object(given) = params else { return Ok(()) };
|
||||
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
|
||||
@@ -240,6 +243,40 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
check_effect_color(id, effect, params)
|
||||
}
|
||||
|
||||
/// Can an effect read `v` as its `color`: a string [`Color::from_hex`] reads (`#rrggbb`, `#rgb`,
|
||||
/// `#rrggbbaa`, black, white, red, green, blue), `[r, g, b]` whose first three entries are numbers
|
||||
/// (read on a 0..1 scale) or a color object (`{"model": "rgb", "r": …}`)? The effect draws its
|
||||
/// default color for any other value, except that an array of three or more entries draws an
|
||||
/// entry that isn't a number as 0.
|
||||
///
|
||||
/// [`Color::from_hex`]: vectorcraft_color::Color::from_hex
|
||||
fn is_effect_color(v: &Value) -> bool {
|
||||
match v {
|
||||
Value::String(s) => vectorcraft_color::Color::from_hex(s).is_some(),
|
||||
Value::Array(a) => a.len() >= 3 && a.iter().take(3).all(Value::is_number),
|
||||
Value::Object(_) => serde_json::from_value::<vectorcraft_color::Color>(v.clone()).is_ok(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `color` in `params` of built-in effect `effect`, when the effect has a `color` parameter,
|
||||
/// as command `id` was given it: a parameter error naming it unless the effect can read it
|
||||
/// ([`is_effect_color`]). `effect.apply` and `effect.setParams` run this themselves before they
|
||||
/// edit, so the app's own edits, the steps of a `command.batch` and a replayed journal are held to
|
||||
/// it too.
|
||||
pub(crate) fn check_effect_color(id: &str, effect: &str, params: &Value) -> Result<()> {
|
||||
let key = "color";
|
||||
let Some(v) = params.get(key) else { return Ok(()) };
|
||||
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
|
||||
return Ok(());
|
||||
}
|
||||
let takes_color = vectorcraft_render::effects::effect_info(effect).is_some_and(|info| info.defaults.get(key).is_some_and(Value::is_string));
|
||||
if takes_color && !is_effect_color(v) {
|
||||
return Err(bad(id, format!("`{key}` must be a #rrggbb color")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -582,9 +582,9 @@ pub(crate) fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, ble
|
||||
n
|
||||
}
|
||||
|
||||
/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art, or the
|
||||
/// outline of the stroke (as on the canvas) filled with the stroke's paint, opacity, blend mode
|
||||
/// and effects (a gradient along or across the stroke: gradient meshes in a clip group shaped
|
||||
/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art (with the
|
||||
/// stroke's opacity, blend mode and raster effects), or the outline of the stroke (as on the canvas)
|
||||
/// filled with the stroke's paint, opacity, blend mode and effects (a gradient along or across the stroke: gradient meshes in a clip group shaped
|
||||
/// like the outline). `None` when it paints nothing.
|
||||
pub(crate) fn outlined_stroke(
|
||||
d: &mut Document,
|
||||
@@ -601,7 +601,15 @@ pub(crate) fn outlined_stroke(
|
||||
_ => Node::group(NodeId(0), pieces.into_iter().map(Arc::new).collect()),
|
||||
};
|
||||
let art = d.reid(&art);
|
||||
return Some(with_transparency(d, art, st.opacity, st.blend));
|
||||
let mut art = with_transparency(d, art, st.opacity, st.blend);
|
||||
let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect();
|
||||
if !raster.is_empty() {
|
||||
if !art.appearance.effects.is_empty() {
|
||||
art = Node::group(d.alloc_id(), vec![Arc::new(art)]);
|
||||
}
|
||||
art.appearance.effects = raster;
|
||||
}
|
||||
return Some(art);
|
||||
}
|
||||
let outline = vectorcraft_render::effects::stroke::outline_region(path, rule, st);
|
||||
if outline.is_empty() {
|
||||
@@ -673,12 +681,13 @@ pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>
|
||||
f
|
||||
}
|
||||
|
||||
/// What stands for object `l` once its appearance became `parts` (paint order): the one part or a
|
||||
/// group of them, with `l`'s transparency, name, opacity mask and effects. `None` without parts.
|
||||
/// What stands for object `l` once its appearance became `parts` (paint order): the one part (in a
|
||||
/// group when it has effects of its own) or a group of them, with `l`'s transparency, name, opacity
|
||||
/// mask and effects. `None` without parts.
|
||||
pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec<Node>) -> Option<Node> {
|
||||
let root = match parts.len() {
|
||||
0 => return None,
|
||||
1 => parts.remove(0),
|
||||
1 if parts[0].appearance.effects.is_empty() => parts.remove(0),
|
||||
_ => Node::group(d.alloc_id(), parts.into_iter().map(Arc::new).collect()),
|
||||
};
|
||||
let mut root = with_transparency(d, root, l.opacity, l.blend);
|
||||
|
||||
@@ -18,10 +18,16 @@ pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT];
|
||||
|
||||
/// What `pdf.preset.export` writes and `pdf.preset.import` reads.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct PresetFile {
|
||||
format: String,
|
||||
pub(crate) struct PresetFile {
|
||||
pub(crate) format: String,
|
||||
#[serde(default)]
|
||||
presets: Vec<PdfPreset>,
|
||||
pub(crate) presets: Vec<PdfPreset>,
|
||||
}
|
||||
|
||||
impl PresetFile {
|
||||
pub(crate) fn new(presets: Vec<PdfPreset>) -> Self {
|
||||
Self { format: PRESET_FORMAT.into(), presets }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -112,6 +118,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let at = s.pdf_saved_index(&name);
|
||||
let saved = at.and_then(|i| s.prefs.pdf_presets.get(i)).cloned();
|
||||
let old_name = saved.as_ref().map(|old| old.name.clone());
|
||||
// A saved preset changes from its own settings unless `preset` says where to start.
|
||||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let (Some(old), None) = (&saved, str_param(p, "preset")) {
|
||||
@@ -127,6 +134,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let description = str_param(p, "description").map(str::to_string).or_else(|| saved.map(|old| old.description)).unwrap_or_default();
|
||||
let preset = PdfPreset { name: name.clone(), description, settings: settings.clone() };
|
||||
s.store_pdf_preset(old_name.as_deref(), &preset);
|
||||
match at.and_then(|i| s.prefs.pdf_presets.get_mut(i)) {
|
||||
Some(slot) => *slot = preset,
|
||||
None => s.prefs.pdf_presets.push(preset),
|
||||
@@ -141,7 +149,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset and stays")));
|
||||
}
|
||||
let i = s.pdf_saved_index(name).ok_or_else(|| bad(C, format!("no saved PDF preset named `{name}`")))?;
|
||||
Ok(json!({ "deleted": s.prefs.pdf_presets.remove(i).name }))
|
||||
let deleted = s.prefs.pdf_presets.remove(i).name;
|
||||
s.unstore_pdf_preset(&deleted);
|
||||
Ok(json!({ "deleted": deleted }))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -196,12 +206,14 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(slot) = s.prefs.pdf_presets.get_mut(i) {
|
||||
preset.name = slot.name.clone();
|
||||
imported.push(preset.name.clone());
|
||||
*slot = preset;
|
||||
*slot = preset.clone();
|
||||
s.store_pdf_preset(Some(&preset.name), &preset);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
preset.name = unique_name(&base, |n| s.pdf_preset_taken(n, None));
|
||||
imported.push(preset.name.clone());
|
||||
s.store_pdf_preset(None, &preset);
|
||||
s.prefs.pdf_presets.push(preset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//! (`crop: "bounding"`).
|
||||
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
|
||||
//! [`text`]).
|
||||
//! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]).
|
||||
//! - A VectorCraft document placed from a file with Link (the default) becomes a placed document:
|
||||
//! one locked object showing the artboard `page`, linked to the file and read again when it
|
||||
//! changes ([`document`]); without Link, an editable copy of its art.
|
||||
@@ -27,6 +28,7 @@
|
||||
|
||||
pub(crate) mod adopt;
|
||||
pub(crate) mod document;
|
||||
mod rtf;
|
||||
mod text;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -96,8 +98,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// A file's art at its natural size, before it joins a document.
|
||||
enum Art {
|
||||
Image(RasterImage),
|
||||
/// A text file's text, placed as area type filling the natural box.
|
||||
Text(String),
|
||||
/// A text or RTF file's text, placed as area type filling the natural box.
|
||||
Text(text::Story),
|
||||
/// A VectorCraft document placed linked: the file read, the link, and whether the art's
|
||||
/// bounds (not the artboard) are shown.
|
||||
Document(document::Source, LinkInfo, bool),
|
||||
@@ -142,7 +144,16 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
if text.trim().is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no text to place")));
|
||||
}
|
||||
return Ok(Loaded { name, format: &text::FORMAT, art: Art::Text(text), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
|
||||
let art = Art::Text(text::Story::plain(text));
|
||||
return Ok(Loaded { name, format: &text::FORMAT, art, natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
|
||||
}
|
||||
// An RTF file is recognized by its content, whatever its name says (a .txt file is plain text).
|
||||
if rtf::is_rtf(&src.bytes) {
|
||||
let story = rtf::import(&src.bytes, cmd)?;
|
||||
if story.plain_text().trim().is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no text to place")));
|
||||
}
|
||||
return Ok(Loaded { name, format: &rtf::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
|
||||
}
|
||||
let format = fileio::detect(src.name, &src.bytes)
|
||||
.ok_or_else(|| bad(cmd, format!("can't place `{name}`: not a format VectorCraft reads (see document.formats)")))?;
|
||||
@@ -298,7 +309,7 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
|
||||
};
|
||||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
|
||||
}
|
||||
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))),
|
||||
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural, &d.setup)))),
|
||||
Art::Document(s, link, bounding) => document::node(d, &s, link, bounding),
|
||||
Art::Vector { src, mut nodes, clip } => {
|
||||
adopt::adopt(d, &src, &mut nodes);
|
||||
@@ -437,9 +448,9 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
)
|
||||
}
|
||||
|
||||
/// The artboard under `at`, else the first.
|
||||
/// The artboard under `at` (its edges included, [`Document::artboard_holding`]), else the first.
|
||||
fn board_at(d: &Document, at: Option<Point>) -> Option<Rect> {
|
||||
at.and_then(|c| d.artboards.iter().find(|a| a.rect.contains(c))).or(d.artboards.first()).map(|a| a.rect)
|
||||
at.and_then(|c| d.artboard_holding(c, false)).and_then(|i| d.artboards.get(i)).or(d.artboards.first()).map(|a| a.rect)
|
||||
}
|
||||
|
||||
/// Placed type's frame without `rect` or Replace: the artboard under `at` (else the first) less a
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
//! Placing an RTF file: its text set as area type with the character and paragraph formatting
|
||||
//! VectorCraft has, read as the Rich Text Format (RTF) Specification 1.9.1 describes the format
|
||||
//! (<https://www.microsoft.com/en-us/download/details.aspx?id=10725>).
|
||||
//!
|
||||
//! - Text: `\par`, `\line` and `\sect` start a new paragraph, and so does `\row` (the end of a
|
||||
//! table row, whose cells, ended by `\cell` or `\nestcell`, are separated by tabs). `\tab` is a
|
||||
//! tab, `\uN` a Unicode character (the `\ucN` fallback characters after it are skipped), and
|
||||
//! `\'hh` a byte in Windows-1252, or in Mac Roman after `\mac` or `\ansicpg10000`. Of a `\upr`
|
||||
//! group's ANSI and Unicode (`\*\ud`) groups, the Unicode one is read. The control
|
||||
//! symbols `\~`, `\-` and `\_` and the control words for quotes, dashes, bullets and special
|
||||
//! spaces are their characters.
|
||||
//! - Character formatting, reset by `\plain`: the font table's font (`\f`, else `\deff`), the size
|
||||
//! (`\fs`), bold, italic, underline (every underline style), strikethrough, all caps, small caps,
|
||||
//! superscript and subscript, baseline shift (`\up`, `\dn`) and the color table's color (`\cf`).
|
||||
//! - Paragraph formatting, reset by `\pard`: alignment (`\ql`, `\qc`, `\qr`, `\qj`, `\qd`), left,
|
||||
//! right and first-line indents, space before and after, and direction (`\rtlpar`, `\ltrpar`).
|
||||
//!
|
||||
//! Other control words are skipped, and so are hidden text (`\v`) and the groups of destinations
|
||||
//! that are not the document's text: every `\*` destination, the style sheet, document
|
||||
//! information, pictures, objects, shapes, headers, footers, notes, note separators, the text for
|
||||
//! readers without nested tables (`\nonesttables`) and field instructions (a field's result is
|
||||
//! read).
|
||||
//! Groups nested deeper than [`MAX_GROUPS`] share the formatting of the deepest one read, and a
|
||||
//! file with more than [`MAX_PARAGRAPHS`] paragraphs or [`MAX_RUNS`] runs of one formatting is
|
||||
//! refused.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
|
||||
|
||||
use super::text::{self, Story};
|
||||
use super::*;
|
||||
|
||||
/// RTF files as `file.place` reports them (not a format `document.open` reads).
|
||||
pub(super) const FORMAT: fileio::Format = fileio::Format {
|
||||
id: "rtf",
|
||||
label: "RTF",
|
||||
extensions: fileio::RTF_EXTS,
|
||||
mime: "application/rtf",
|
||||
read: true,
|
||||
write: false,
|
||||
raster: false,
|
||||
options: &[],
|
||||
};
|
||||
|
||||
/// The deepest group nesting read; deeper groups share the formatting of the deepest one read.
|
||||
const MAX_GROUPS: usize = 512;
|
||||
/// The most fonts and colors the font and color tables hold.
|
||||
const MAX_TABLE: usize = 4096;
|
||||
/// The reader adds no more characters to a font name once it holds this many bytes.
|
||||
const MAX_NAME: usize = 256;
|
||||
/// The most paragraphs placed type takes from one RTF file.
|
||||
const MAX_PARAGRAPHS: usize = 100_000;
|
||||
/// The most runs of one character formatting placed type takes from one RTF file.
|
||||
const MAX_RUNS: usize = 100_000;
|
||||
|
||||
/// Does `bytes` hold an RTF file (`{\rtf` after any white space)?
|
||||
pub(super) fn is_rtf(bytes: &[u8]) -> bool {
|
||||
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(bytes.len());
|
||||
bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf"))
|
||||
}
|
||||
|
||||
/// The text of an RTF file's `bytes` with its character and paragraph formatting.
|
||||
pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> {
|
||||
if bytes.len() > text::MAX_TEXT {
|
||||
return Err(bad(cmd, format!("the RTF file is too large to place as type (at most {} MB)", text::MAX_TEXT >> 20)));
|
||||
}
|
||||
if !is_rtf(bytes) {
|
||||
return Err(bad(cmd, "not an RTF file: it doesn't start with {\\rtf"));
|
||||
}
|
||||
let mut r = Reader::default();
|
||||
let mut i = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
|
||||
let mut starred = false;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if r.full {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
match b {
|
||||
b'{' => {
|
||||
starred = false;
|
||||
r.open();
|
||||
}
|
||||
b'}' => {
|
||||
starred = false;
|
||||
// The end of the outermost group is the end of the document.
|
||||
if !r.close() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
b'\\' => {
|
||||
let Some(&next) = bytes.get(i) else { break };
|
||||
if next.is_ascii_alphabetic() {
|
||||
let (word, n, end) = control_word(bytes, i);
|
||||
i = end;
|
||||
if word == "bin" {
|
||||
// Binary data: never text, and one character of a `\uN` fallback.
|
||||
i = i.saturating_add(usize::try_from(n.unwrap_or(0)).unwrap_or(0)).min(bytes.len());
|
||||
r.skip = r.skip.saturating_sub(1);
|
||||
continue;
|
||||
}
|
||||
r.word(word, n, std::mem::take(&mut starred));
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
match next {
|
||||
b'*' => starred = true,
|
||||
b'\'' => {
|
||||
if let Some(v) = bytes.get(i..i + 2).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||
i += 2;
|
||||
r.byte(v);
|
||||
}
|
||||
}
|
||||
b'\r' | b'\n' => r.word("par", None, false),
|
||||
b'\t' => r.put('\t'),
|
||||
b'~' => r.put('\u{A0}'),
|
||||
b'-' => r.put('\u{AD}'),
|
||||
b'_' => r.put('\u{2011}'),
|
||||
b'\\' | b'{' | b'}' => r.put(char::from(next)),
|
||||
// Other control symbols (index entries, formula characters) count as one
|
||||
// character of a `\uN` fallback.
|
||||
_ => r.skip = r.skip.saturating_sub(1),
|
||||
}
|
||||
}
|
||||
// Line breaks in the file are not text.
|
||||
b'\r' | b'\n' => {}
|
||||
b'\t' => r.put('\t'),
|
||||
0..=0x1F => {}
|
||||
_ => r.byte(b),
|
||||
}
|
||||
}
|
||||
r.finish(cmd)
|
||||
}
|
||||
|
||||
/// The control word whose letters start at `bytes[at]`: its name (at most 32 letters), its numeric
|
||||
/// parameter, and where the text after it starts (a space after it belongs to the word).
|
||||
fn control_word(bytes: &[u8], at: usize) -> (&str, Option<i32>, usize) {
|
||||
let rest = bytes.get(at..).unwrap_or_default();
|
||||
let letters = rest.iter().take(32).take_while(|b| b.is_ascii_alphabetic()).count();
|
||||
let word = rest.get(..letters).and_then(|w| std::str::from_utf8(w).ok()).unwrap_or_default();
|
||||
let mut end = at + letters;
|
||||
let negative = bytes.get(end) == Some(&b'-') && bytes.get(end + 1).is_some_and(u8::is_ascii_digit);
|
||||
if negative {
|
||||
end += 1;
|
||||
}
|
||||
let digits = bytes.get(end..).unwrap_or_default().iter().take(10).take_while(|b| b.is_ascii_digit()).count();
|
||||
let n = bytes
|
||||
.get(end..end + digits)
|
||||
.filter(|d| !d.is_empty())
|
||||
.and_then(|d| std::str::from_utf8(d).ok())
|
||||
.and_then(|d| d.parse::<i64>().ok())
|
||||
.map(|v| (if negative { -v } else { v }).clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32);
|
||||
end += digits;
|
||||
if bytes.get(end) == Some(&b' ') {
|
||||
end += 1;
|
||||
}
|
||||
(word, n, end)
|
||||
}
|
||||
|
||||
/// What the characters of the current group are part of.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Dest {
|
||||
/// The document's text.
|
||||
Body,
|
||||
/// The font table: font names.
|
||||
Fonts,
|
||||
/// The color table: `\red`, `\green` and `\blue` entries ended by `;`.
|
||||
Colors,
|
||||
/// Anything else: skipped.
|
||||
Skip,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Script {
|
||||
#[default]
|
||||
Normal,
|
||||
Super,
|
||||
Sub,
|
||||
}
|
||||
|
||||
/// Character formatting (what `\plain` resets).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Chars {
|
||||
/// The font table's font number; None: the default font (`\deff`).
|
||||
font: Option<i32>,
|
||||
/// Size in half points.
|
||||
half_points: i32,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
strike: bool,
|
||||
caps: bool,
|
||||
small_caps: bool,
|
||||
script: Script,
|
||||
/// Baseline shift in half points (up positive).
|
||||
shift: i32,
|
||||
/// The color table's color number; a number without an entry, or whose entry has no values, gives the default color.
|
||||
color: i32,
|
||||
/// Hidden text (`\v`): not placed.
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
impl Default for Chars {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
font: None,
|
||||
half_points: 24,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strike: false,
|
||||
caps: false,
|
||||
small_caps: false,
|
||||
script: Script::Normal,
|
||||
shift: 0,
|
||||
color: 0,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph formatting (what `\pard` resets); lengths in twips.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Para {
|
||||
justify: Justify,
|
||||
left: i32,
|
||||
right: i32,
|
||||
first: i32,
|
||||
before: i32,
|
||||
after: i32,
|
||||
rtl: Option<bool>,
|
||||
}
|
||||
|
||||
/// The state a group keeps: it is restored when the group ends.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Group {
|
||||
dest: Dest,
|
||||
chars: Chars,
|
||||
para: Para,
|
||||
/// How many fallback characters follow each `\uN`.
|
||||
uc: usize,
|
||||
/// A `\upr` group: of its two groups, the ANSI one is skipped and the `\*\ud` one is read.
|
||||
upr: bool,
|
||||
}
|
||||
|
||||
impl Default for Group {
|
||||
fn default() -> Self {
|
||||
Self { dest: Dest::Body, chars: Chars::default(), para: Para::default(), uc: 1, upr: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Reader {
|
||||
group: Group,
|
||||
stack: Vec<Group>,
|
||||
/// Groups opened past [`MAX_GROUPS`] and not yet closed.
|
||||
overflow: usize,
|
||||
/// `\'hh` bytes are Mac Roman (else Windows-1252).
|
||||
mac: bool,
|
||||
default_font: i32,
|
||||
fonts: HashMap<i32, String>,
|
||||
/// The font the font table is naming.
|
||||
font: i32,
|
||||
font_name: String,
|
||||
colors: Vec<Option<[u8; 3]>>,
|
||||
rgb: [Option<u8>; 3],
|
||||
/// Fallback characters of a `\uN` still to skip.
|
||||
skip: usize,
|
||||
/// The high surrogate of a character written as two `\uN`.
|
||||
high: Option<u16>,
|
||||
/// A cell just ended: a tab goes before the next character of its row.
|
||||
cell_tab: bool,
|
||||
/// The text, in runs of one character formatting; paragraphs end with `\n`.
|
||||
spans: Vec<(String, Chars)>,
|
||||
/// The formatting of each paragraph that has ended.
|
||||
paras: Vec<Para>,
|
||||
/// The formatting of the paragraph being read, as at its last character or paragraph
|
||||
/// formatting word.
|
||||
last: Para,
|
||||
/// Past [`MAX_PARAGRAPHS`] or [`MAX_RUNS`].
|
||||
full: bool,
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
fn open(&mut self) {
|
||||
self.skip = 0;
|
||||
if self.stack.len() >= MAX_GROUPS {
|
||||
self.overflow += 1;
|
||||
} else {
|
||||
self.stack.push(self.group);
|
||||
if std::mem::take(&mut self.group.upr) {
|
||||
self.group.dest = Dest::Skip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends a group; false when it was the outermost one.
|
||||
fn close(&mut self) -> bool {
|
||||
self.skip = 0;
|
||||
if self.overflow > 0 {
|
||||
self.overflow -= 1;
|
||||
return true;
|
||||
}
|
||||
if self.group.dest == Dest::Fonts {
|
||||
self.commit_font();
|
||||
}
|
||||
match self.stack.pop() {
|
||||
Some(g) => {
|
||||
self.group = g;
|
||||
!self.stack.is_empty()
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_font(&mut self) {
|
||||
let name = std::mem::take(&mut self.font_name);
|
||||
let name = name.trim();
|
||||
if !name.is_empty() && self.fonts.len() < MAX_TABLE {
|
||||
self.fonts.insert(self.font, name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_color(&mut self) {
|
||||
let rgb = std::mem::take(&mut self.rgb);
|
||||
if self.colors.len() < MAX_TABLE {
|
||||
self.colors.push(rgb.iter().any(Option::is_some).then(|| rgb.map(|v| v.unwrap_or(0))));
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte `b` of the file's character set.
|
||||
fn byte(&mut self, b: u8) {
|
||||
self.put(if self.mac { text::mac_roman(b) } else { text::windows_1252(b) });
|
||||
}
|
||||
|
||||
/// Character `c` read from the file: skipped when it is a `\uN` fallback character.
|
||||
fn put(&mut self, c: char) {
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
return;
|
||||
}
|
||||
self.high = None;
|
||||
self.text(c);
|
||||
}
|
||||
|
||||
/// Character `c` in the current destination.
|
||||
fn text(&mut self, c: char) {
|
||||
match self.group.dest {
|
||||
Dest::Body if self.group.chars.hidden => {}
|
||||
Dest::Body => {
|
||||
if std::mem::take(&mut self.cell_tab) {
|
||||
self.push('\t');
|
||||
}
|
||||
self.push(c);
|
||||
}
|
||||
Dest::Fonts if c == ';' => self.commit_font(),
|
||||
Dest::Fonts if self.font_name.len() < MAX_NAME => self.font_name.push(c),
|
||||
Dest::Colors if c == ';' => self.commit_color(),
|
||||
Dest::Fonts | Dest::Colors | Dest::Skip => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `c` to the document's text in the current character formatting.
|
||||
fn push(&mut self, c: char) {
|
||||
let chars = self.group.chars;
|
||||
let runs = self.spans.len();
|
||||
match self.spans.last_mut() {
|
||||
Some((s, f)) if *f == chars => s.push(c),
|
||||
_ if runs >= MAX_RUNS => {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
_ => self.spans.push((c.to_string(), chars)),
|
||||
}
|
||||
self.last = self.group.para;
|
||||
}
|
||||
|
||||
/// Ends the paragraph being read (unless its mark is hidden text).
|
||||
fn end_paragraph(&mut self) {
|
||||
if self.group.dest != Dest::Body || self.group.chars.hidden {
|
||||
return;
|
||||
}
|
||||
self.cell_tab = false;
|
||||
if self.paras.len() >= MAX_PARAGRAPHS {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
self.paras.push(self.group.para);
|
||||
self.push('\n');
|
||||
}
|
||||
|
||||
/// Unicode character (or UTF-16 code unit) `n`, a negative `n` standing for `n + 65536`.
|
||||
fn unicode(&mut self, n: i32) {
|
||||
let unit = n.rem_euclid(0x10000) as u16;
|
||||
match (self.high.take(), unit) {
|
||||
(Some(high), 0xDC00..=0xDFFF) => {
|
||||
if let Some(Ok(c)) = char::decode_utf16([high, unit]).next() {
|
||||
self.text(c);
|
||||
}
|
||||
}
|
||||
(_, 0xD800..=0xDBFF) => self.high = Some(unit),
|
||||
(_, 0xDC00..=0xDFFF) => {}
|
||||
(_, unit) => {
|
||||
if let Some(c) = char::from_u32(u32::from(unit)) {
|
||||
self.text(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.skip = self.group.uc;
|
||||
}
|
||||
|
||||
/// Control word `w` with parameter `n`; `starred` when `\*` came before it.
|
||||
fn word(&mut self, w: &str, n: Option<i32>, starred: bool) {
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
return;
|
||||
}
|
||||
if w != "u" {
|
||||
self.high = None;
|
||||
}
|
||||
// The Unicode group of a `\upr` group is read in place of its ANSI group.
|
||||
if w == "ud"
|
||||
&& self.overflow == 0
|
||||
&& let Some(parent) = self.stack.last().filter(|g| g.upr)
|
||||
{
|
||||
self.group.dest = parent.dest;
|
||||
return;
|
||||
}
|
||||
// An ignorable destination (`\*`): VectorCraft reads none of them.
|
||||
if starred {
|
||||
self.group.dest = Dest::Skip;
|
||||
return;
|
||||
}
|
||||
if self.group.dest == Dest::Skip {
|
||||
return;
|
||||
}
|
||||
// `\b`, `\b1`: on; `\b0`: off.
|
||||
let on = n != Some(0);
|
||||
let v = n.unwrap_or(0);
|
||||
let para = self.group.para;
|
||||
let c = &mut self.group.chars;
|
||||
let p = &mut self.group.para;
|
||||
match w {
|
||||
"fonttbl" => self.group.dest = Dest::Fonts,
|
||||
"colortbl" => self.group.dest = Dest::Colors,
|
||||
"upr" => self.group.upr = true,
|
||||
"stylesheet" | "info" | "pict" | "object" | "shp" | "nonshppict" | "header" | "headerl" | "headerr" | "headerf" | "footer"
|
||||
| "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "fldinst" | "pn" | "listtable" | "listoverridetable" | "revtbl"
|
||||
| "rsidtbl" | "filetbl" | "bkmkstart" | "bkmkend" | "xe" | "tc" | "txe" | "template" | "ftnsep" | "ftnsepc" | "ftncn" | "aftnsep"
|
||||
| "aftnsepc" | "aftncn" | "nonesttables" => self.group.dest = Dest::Skip,
|
||||
"ansi" | "pc" | "pca" => self.mac = false,
|
||||
"mac" => self.mac = true,
|
||||
"ansicpg" => self.mac = v == 10000,
|
||||
"deff" => self.default_font = v,
|
||||
"f" if self.group.dest == Dest::Fonts => {
|
||||
self.commit_font();
|
||||
self.font = v;
|
||||
}
|
||||
"f" => c.font = Some(v),
|
||||
"red" | "green" | "blue" if self.group.dest == Dest::Colors => {
|
||||
let i = match w {
|
||||
"red" => 0,
|
||||
"green" => 1,
|
||||
_ => 2,
|
||||
};
|
||||
if let Some(slot) = self.rgb.get_mut(i) {
|
||||
*slot = Some(v.clamp(0, 255) as u8);
|
||||
}
|
||||
}
|
||||
"uc" => self.group.uc = usize::try_from(v).unwrap_or(0),
|
||||
"u" => self.unicode(v),
|
||||
"par" | "line" | "sect" => self.end_paragraph(),
|
||||
"row" | "nestrow" => {
|
||||
self.cell_tab = false;
|
||||
self.end_paragraph();
|
||||
}
|
||||
"cell" | "nestcell" if self.group.dest == Dest::Body && !c.hidden => {
|
||||
if self.cell_tab {
|
||||
self.push('\t');
|
||||
}
|
||||
self.cell_tab = true;
|
||||
}
|
||||
"tab" => self.text('\t'),
|
||||
"emdash" => self.text('\u{2014}'),
|
||||
"endash" => self.text('\u{2013}'),
|
||||
"bullet" => self.text('\u{2022}'),
|
||||
"lquote" => self.text('\u{2018}'),
|
||||
"rquote" => self.text('\u{2019}'),
|
||||
"ldblquote" => self.text('\u{201C}'),
|
||||
"rdblquote" => self.text('\u{201D}'),
|
||||
"emspace" => self.text('\u{2003}'),
|
||||
"enspace" => self.text('\u{2002}'),
|
||||
"qmspace" => self.text('\u{2005}'),
|
||||
"zwj" => self.text('\u{200D}'),
|
||||
"zwnj" => self.text('\u{200C}'),
|
||||
"ltrmark" => self.text('\u{200E}'),
|
||||
"rtlmark" => self.text('\u{200F}'),
|
||||
"plain" => *c = Chars::default(),
|
||||
"fs" => c.half_points = n.unwrap_or(24),
|
||||
"b" => c.bold = on,
|
||||
"i" => c.italic = on,
|
||||
"ul" | "uld" | "uldash" | "uldashd" | "uldashdd" | "uldb" | "ulhwave" | "ulldash" | "ulth" | "ulthd" | "ulthdash" | "ulthdashd"
|
||||
| "ulthdashdd" | "ulthldash" | "ululdbwave" | "ulw" | "ulwave" => c.underline = on,
|
||||
"ulnone" => c.underline = false,
|
||||
"strike" | "striked" => c.strike = on,
|
||||
"caps" => c.caps = on,
|
||||
"scaps" => c.small_caps = on,
|
||||
"super" => c.script = Script::Super,
|
||||
"sub" => c.script = Script::Sub,
|
||||
"nosupersub" => c.script = Script::Normal,
|
||||
"up" => c.shift = n.unwrap_or(6),
|
||||
"dn" => c.shift = n.unwrap_or(6).saturating_neg(),
|
||||
"cf" => c.color = v,
|
||||
"v" => c.hidden = on,
|
||||
"pard" => *p = Para::default(),
|
||||
"ql" => p.justify = Justify::Left,
|
||||
"qc" => p.justify = Justify::Center,
|
||||
"qr" => p.justify = Justify::Right,
|
||||
"qj" => p.justify = Justify::JustifyLeft,
|
||||
"qd" => p.justify = Justify::JustifyAll,
|
||||
"li" => p.left = v,
|
||||
"ri" => p.right = v,
|
||||
"fi" => p.first = v,
|
||||
"sb" => p.before = v,
|
||||
"sa" => p.after = v,
|
||||
"rtlpar" => p.rtl = Some(true),
|
||||
"ltrpar" => p.rtl = Some(false),
|
||||
_ => {}
|
||||
}
|
||||
// Paragraph formatting after a paragraph's last character still applies to it.
|
||||
if self.group.para != para && self.group.dest == Dest::Body {
|
||||
self.last = self.group.para;
|
||||
}
|
||||
}
|
||||
|
||||
/// The character attributes of formatting `c`.
|
||||
fn style(&self, c: &Chars) -> CharStyle {
|
||||
let mut s = CharStyle::default();
|
||||
if let Some(name) = self.fonts.get(&c.font.unwrap_or(self.default_font)) {
|
||||
s.font_family.clone_from(name);
|
||||
}
|
||||
s.font_style = match (c.bold, c.italic) {
|
||||
(true, true) => "Bold Italic",
|
||||
(true, false) => "Bold",
|
||||
(false, true) => "Italic",
|
||||
(false, false) => "Regular",
|
||||
}
|
||||
.into();
|
||||
s.size = (f64::from(c.half_points) / 2.0).clamp(0.1, 1296.0);
|
||||
s.baseline_shift = (f64::from(c.shift) / 2.0).clamp(-1296.0, 1296.0);
|
||||
if let Some(Some([r, g, b])) = usize::try_from(c.color).ok().and_then(|i| self.colors.get(i)) {
|
||||
s.fill = Paint::solid(vectorcraft_color::Color::rgb8(*r, *g, *b));
|
||||
}
|
||||
s.underline = c.underline;
|
||||
s.strikethrough = c.strike;
|
||||
s.all_caps = c.caps;
|
||||
// Document Setup's proportions replace these when the type is placed.
|
||||
s.small_caps = c.small_caps.then_some(70.0);
|
||||
s.position = match c.script {
|
||||
Script::Normal => CharPosition::Normal,
|
||||
Script::Super => CharPosition::Superscript(ScriptMetrics::DEFAULT),
|
||||
Script::Sub => CharPosition::Subscript(ScriptMetrics::DEFAULT),
|
||||
};
|
||||
s
|
||||
}
|
||||
|
||||
/// The text read, without blank paragraphs at its end, and each paragraph's attributes.
|
||||
fn finish(mut self, cmd: &str) -> Result<Story> {
|
||||
let too_many = || {
|
||||
bad(
|
||||
cmd,
|
||||
format!(
|
||||
"the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs)"
|
||||
),
|
||||
)
|
||||
};
|
||||
if self.full {
|
||||
return Err(too_many());
|
||||
}
|
||||
self.paras.push(self.last);
|
||||
let mut all: String = self.spans.iter().map(|(s, _)| s.as_str()).collect();
|
||||
while self.paras.len() > 1
|
||||
&& let Some(at) = all.rfind('\n')
|
||||
&& all.get(at + 1..).is_some_and(|tail| tail.trim().is_empty())
|
||||
{
|
||||
all.truncate(at);
|
||||
self.paras.pop();
|
||||
}
|
||||
if self.paras.len() > MAX_PARAGRAPHS {
|
||||
return Err(too_many());
|
||||
}
|
||||
let mut keep = all.len();
|
||||
let mut runs: Vec<TextRun> = vec![];
|
||||
for (s, chars) in &self.spans {
|
||||
let piece = s.get(..keep.min(s.len())).unwrap_or_default();
|
||||
keep -= piece.len();
|
||||
if piece.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let style = self.style(chars);
|
||||
match runs.last_mut() {
|
||||
Some(r) if r.style == style => r.text.push_str(piece),
|
||||
_ => runs.push(TextRun::new(piece, style)),
|
||||
}
|
||||
}
|
||||
let twips = |v: i32| (f64::from(v) / 20.0).clamp(-1296.0, 1296.0);
|
||||
let paras = self
|
||||
.paras
|
||||
.iter()
|
||||
.map(|p| ParaStyle {
|
||||
justify: p.justify,
|
||||
left_indent: twips(p.left),
|
||||
right_indent: twips(p.right),
|
||||
first_line_indent: twips(p.first),
|
||||
space_before: twips(p.before),
|
||||
space_after: twips(p.after),
|
||||
direction: p.rtl.map(|rtl| if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }),
|
||||
..ParaStyle::default()
|
||||
})
|
||||
.collect();
|
||||
Ok(Story { runs, paras })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn read(rtf: &str) -> Story {
|
||||
import(rtf.as_bytes(), "file.place").unwrap()
|
||||
}
|
||||
|
||||
fn text_of(s: &Story) -> String {
|
||||
s.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_escapes_skip_their_fallback_characters() {
|
||||
// `\u` then 8364: the euro sign.
|
||||
let s = read(concat!(r"{\rtf1\ansi\uc1 caf\u233?\uc2 \u", "8364", r"\'80\'80 \uc1\u-10179?\u-8704? end\'93q\'94}"));
|
||||
assert_eq!(text_of(&s), "café€ 😀 end“q”");
|
||||
assert_eq!(text_of(&read(r"{\rtf1\uc1\u233\bin1 ?X}")), "éX", "binary data is one fallback character");
|
||||
let mac = read(r"{\rtf1\mac \'8e\'a5}");
|
||||
assert_eq!(text_of(&mac), "é•", "Mac Roman");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upr_groups_read_their_unicode_text() {
|
||||
let s = read(concat!(r"{\rtf1 a{\upr{\f0 b?}{\*\ud{\f0 c\u", "945", r"?}}}d}"));
|
||||
assert_eq!(text_of(&s), "acαd");
|
||||
let fonts = read(r"{\rtf1{\fonttbl{\upr{\f0 Ansi Name;}{\*\ud{\f0 Unicode Name;}}}}x}");
|
||||
assert_eq!(fonts.runs.first().map(|r| r.style.font_family.as_str()), Some("Unicode Name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destinations_that_are_not_text_are_skipped() {
|
||||
let s = read(concat!(
|
||||
r"{\rtf1{\fonttbl{\f0 Arial;}}{\*\generator Some Writer;}{\info{\title T}}{\stylesheet{\s1 Heading;}}",
|
||||
r"{\header H}A{\*\bkmkstart b}{\field{\*\fldinst HYPERLINK x}{\fldrslt link}}{\pict\pngblip 89504e47}\bin2 {}B}",
|
||||
"trailing"
|
||||
));
|
||||
assert_eq!(text_of(&s), "AlinkB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_text_and_the_text_for_older_readers_are_skipped() {
|
||||
let s = read(r"{\rtf1 One{\v hidden\par more\cell\par}\sect Two {\nonesttables flat copy}three}");
|
||||
assert_eq!(text_of(&s), "One\nTwo three");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_are_tabs_and_rows_paragraphs() {
|
||||
let s = read(r"{\rtf1\trowd\cellx100\cellx200 a\cell b\cell\row\trowd c\cell\cell d\cell\row}");
|
||||
assert_eq!(text_of(&s), "a\tb\nc\t\td");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_formatting_after_the_last_character_applies() {
|
||||
let s = read(r"{\rtf1 One\par Two\qc\li400}");
|
||||
let justify: Vec<Justify> = s.paras.iter().map(|p| p.justify).collect();
|
||||
assert_eq!(justify, [Justify::Left, Justify::Center]);
|
||||
assert_eq!(s.paras.last().map(|p| p.left_indent), Some(20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_and_stray_braces_end_cleanly() {
|
||||
let deep = format!("{{\\rtf1 {}x{}}}", "{".repeat(5000), "}".repeat(5000));
|
||||
assert_eq!(text_of(&read(&deep)), "x");
|
||||
assert_eq!(text_of(&read(r"{\rtf1 unclosed {\b group")), "unclosed group");
|
||||
assert!(import(b"{\\rt", "file.place").is_err(), "not RTF");
|
||||
assert!(import(b"Plain words", "file.place").is_err(), "not RTF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_paragraphs_or_runs_are_refused() {
|
||||
let most = format!("{{\\rtf1 {}}}", "a\\par ".repeat(MAX_PARAGRAPHS));
|
||||
assert_eq!(read(&most).paras.len(), MAX_PARAGRAPHS);
|
||||
let paras = format!("{{\\rtf1 {}a}}", "a\\par ".repeat(MAX_PARAGRAPHS));
|
||||
assert!(import(paras.as_bytes(), "file.place").is_err());
|
||||
let marks = format!("{{\\rtf1 {}}}", "a\\par ".repeat(MAX_PARAGRAPHS + 1));
|
||||
assert!(import(marks.as_bytes(), "file.place").is_err());
|
||||
let runs = format!("{{\\rtf1 {}}}", "{\\b a}b".repeat(MAX_RUNS / 2 + 1));
|
||||
assert!(import(runs.as_bytes(), "file.place").is_err());
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ pub(super) const FORMAT: fileio::Format = fileio::Format {
|
||||
options: &[],
|
||||
};
|
||||
|
||||
/// The most bytes a text file placed as type may have.
|
||||
const MAX_TEXT: usize = 4 << 20;
|
||||
/// The most bytes a text or RTF file placed as type may have.
|
||||
pub(super) const MAX_TEXT: usize = 4 << 20;
|
||||
|
||||
/// Text Import Options (`file.place`'s `text` param).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
@@ -95,7 +95,7 @@ fn decode(bytes: &[u8], o: TextOptions) -> String {
|
||||
}
|
||||
|
||||
/// Byte `b` of Windows-1252.
|
||||
fn windows_1252(b: u8) -> char {
|
||||
pub(super) fn windows_1252(b: u8) -> char {
|
||||
const HIGH: [char; 32] = [
|
||||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', //
|
||||
'\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
|
||||
@@ -107,7 +107,7 @@ fn windows_1252(b: u8) -> char {
|
||||
}
|
||||
|
||||
/// Byte `b` of Mac Roman.
|
||||
fn mac_roman(b: u8) -> char {
|
||||
pub(super) fn mac_roman(b: u8) -> char {
|
||||
const HIGH: [char; 128] = [
|
||||
'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', //
|
||||
'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', //
|
||||
@@ -190,11 +190,41 @@ fn tabs_for_spaces(s: &str, n: usize) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The area type of `text` filling `frame` (at the origin; placing moves it).
|
||||
pub(super) fn area_text(text: String, frame: Rect) -> vectorcraft_doc::TextObject {
|
||||
/// The text placed type gets from a text or RTF file.
|
||||
pub(super) struct Story {
|
||||
/// The text in runs of one character formatting; paragraphs are separated by `\n`.
|
||||
pub(super) runs: Vec<vectorcraft_doc::TextRun>,
|
||||
/// Each paragraph's attributes; empty: the defaults.
|
||||
pub(super) paras: Vec<vectorcraft_doc::ParaStyle>,
|
||||
}
|
||||
|
||||
impl Story {
|
||||
/// `text` in the default character and paragraph attributes.
|
||||
pub(super) fn plain(text: String) -> Self {
|
||||
Self { runs: vec![vectorcraft_doc::TextRun::new(text, vectorcraft_doc::CharStyle::default())], paras: vec![] }
|
||||
}
|
||||
|
||||
/// The text without its formatting.
|
||||
pub(super) fn plain_text(&self) -> String {
|
||||
self.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The area type of `story` filling `frame` (at the origin; placing moves it), its superscripts,
|
||||
/// subscripts and small caps in the proportions of `setup` (Document Setup).
|
||||
pub(super) fn area_text(story: Story, frame: Rect, setup: &vectorcraft_doc::DocSetup) -> vectorcraft_doc::TextObject {
|
||||
let mut t = vectorcraft_doc::TextObject::point(Point::ORIGIN, "", vectorcraft_doc::CharStyle::default());
|
||||
if let Some(run) = t.runs.first_mut() {
|
||||
run.text = text;
|
||||
if !story.runs.is_empty() {
|
||||
t.runs = story.runs;
|
||||
}
|
||||
for run in &mut t.runs {
|
||||
run.style.position = crate::cmd::docsetup::position_in(setup, run.style.position);
|
||||
if run.style.small_caps.is_some() {
|
||||
run.style.small_caps = Some(setup.small_caps_size);
|
||||
}
|
||||
}
|
||||
if !story.paras.is_empty() {
|
||||
t.set_paragraph_styles(story.paras);
|
||||
}
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::from_origin_size(Point::ORIGIN, frame.size())) };
|
||||
crate::cmd::typecmd::refresh_bounds(&mut t);
|
||||
|
||||
@@ -138,6 +138,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool),
|
||||
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
|
||||
p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool),
|
||||
p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool),
|
||||
// Selection & Anchor Display
|
||||
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
|
||||
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Setup",
|
||||
[],
|
||||
None,
|
||||
"{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)",
|
||||
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
|
||||
has_doc,
|
||||
setup
|
||||
),
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Preview",
|
||||
[],
|
||||
None,
|
||||
"{settings?: {…settings of print.setup} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing",
|
||||
"{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing",
|
||||
has_doc,
|
||||
preview
|
||||
),
|
||||
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print",
|
||||
[],
|
||||
Some("Cmd+P"),
|
||||
"{settings?: {…settings of print.setup} (over the document's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device",
|
||||
"{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device",
|
||||
has_doc,
|
||||
print
|
||||
),
|
||||
@@ -57,6 +57,23 @@ pub(crate) fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSett
|
||||
settings_over(cmd, saved(doc), p)
|
||||
}
|
||||
|
||||
/// The settings `p` asks for: those of the print preset `p.preset` names ([Default] or a saved
|
||||
/// one, any case), else those saved with `doc`, with `p.settings` over them, checked.
|
||||
fn preset_or_saved(cmd: &str, s: &Session, doc: &Document, p: &Value) -> Result<PrintSettings> {
|
||||
let base = match p.get("preset") {
|
||||
None | Some(Value::Null) => saved(doc),
|
||||
Some(Value::String(name)) => {
|
||||
let set = s.print_preset_settings(name).ok_or_else(|| {
|
||||
let names: Vec<String> = s.print_presets().into_iter().map(|q| q.name).collect();
|
||||
bad(cmd, format!("no print preset named `{name}` (presets: {})", names.join(", ")))
|
||||
})?;
|
||||
to_json(&set)?
|
||||
}
|
||||
Some(_) => return Err(bad(cmd, "preset must be the name of a print preset")),
|
||||
};
|
||||
settings_over(cmd, base, p)
|
||||
}
|
||||
|
||||
/// The settings `v` (JSON) with `p.settings` over them, checked.
|
||||
pub(crate) fn settings_over(cmd: &str, mut v: Value, p: &Value) -> Result<PrintSettings> {
|
||||
match p.get("settings") {
|
||||
@@ -75,8 +92,9 @@ pub(crate) fn to_json(set: &PrintSettings) -> Result<Value> {
|
||||
|
||||
fn setup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = to_json(&settings(SETUP, doc, p)?)?;
|
||||
if p.get("settings").is_some_and(|v| !v.is_null()) && doc.print_setup.as_ref() != Some(&set) {
|
||||
let set = to_json(&preset_or_saved(SETUP, s, doc, p)?)?;
|
||||
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
|
||||
if (given("settings") || given("preset")) && doc.print_setup.as_ref() != Some(&set) {
|
||||
let stored = set.clone();
|
||||
s.edit("Print Setup", |d, _| {
|
||||
d.print_setup = Some(stored);
|
||||
@@ -89,7 +107,7 @@ fn setup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "print.preview";
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = settings(C, doc, p)?;
|
||||
let set = preset_or_saved(C, s, doc, p)?;
|
||||
super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?;
|
||||
let mut pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
|
||||
pv.warnings.extend(type_warnings(doc, set.print_layers, &areas(&pv)));
|
||||
@@ -148,7 +166,7 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let postscript = to_postscript(p)?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut set = settings(PRINT, doc, p)?;
|
||||
let mut set = preset_or_saved(PRINT, s, doc, p)?;
|
||||
if postscript {
|
||||
// PostScript has no transparency: `flattenerPreset`, else the Advanced preset, else medium.
|
||||
let given = str_param(p, "flattenerPreset").filter(|n| !n.trim().is_empty());
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_pdf::{TileOrigin, TilingPage};
|
||||
|
||||
use super::fileio::pdf::pdf_error;
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Tiling",
|
||||
[],
|
||||
None,
|
||||
"{origin: [x, y] (document point: the top-left corner of the first page's imageable area), artboard? (0-based: the artboard it is measured from; default the printed artboard containing origin, else the first; ignored with artboards: ignore)} | {reset: true} place the printed pages as the Print Tiling tool does, saved with the print settings (tileOrigin: {placed, x, y}, from the artboard's top-left corner; it replaces the placement, and tiles start there), as one undo step; reset → the placement places them again → {tileOrigin}",
|
||||
"{origin: [x, y] (document point: the top-left corner of the first page's imageable area), artboard? (0-based: the artboard it is measured from; default the printed artboard containing origin, its edges included, else the first; ignored with artboards: ignore)} | {reset: true} place the printed pages as the Print Tiling tool does, saved with the print settings (tileOrigin: {placed, x, y}, from the artboard's top-left corner; it replaces the placement, and tiles start there), as one undo step; reset → the placement places them again → {tileOrigin}",
|
||||
has_doc,
|
||||
set_origin
|
||||
),
|
||||
@@ -77,6 +77,13 @@ fn point(p: &Value, key: &str) -> Option<Point> {
|
||||
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
}
|
||||
|
||||
/// The first of the print `regions` holding `at`: one that holds it inside or on its left or top
|
||||
/// edge, else one that holds it on its right or bottom edge or within a millionth of a point
|
||||
/// outside an edge (the edges `Document::art_on_artboard` counts).
|
||||
fn region_holding(regions: &[(Option<usize>, Rect)], at: Point) -> Option<&(Option<usize>, Rect)> {
|
||||
regions.iter().find(|(_, r)| r.contains(at)).or_else(|| regions.iter().find(|(_, r)| vectorcraft_doc::artboard_holds(*r, at)))
|
||||
}
|
||||
|
||||
fn set_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let current = settings(SET, doc, &json!({}))?;
|
||||
@@ -92,7 +99,7 @@ fn set_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = v.as_u64().ok_or_else(|| bad(SET, "artboard must be a 0-based index"))? as usize;
|
||||
regions.iter().find(|(a, _)| a.is_none() || *a == Some(i)).ok_or_else(|| bad(SET, format!("artboard {i} doesn't print")))?
|
||||
}
|
||||
None => regions.iter().find(|(_, r)| r.contains(at)).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?,
|
||||
None => region_holding(®ions, at).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?,
|
||||
};
|
||||
let corner = picked.1.origin();
|
||||
TileOrigin { placed: true, x: at.x - corner.x, y: at.y - corner.y }
|
||||
|
||||
@@ -161,6 +161,12 @@ fn render(doc: &Document, nodes: Vec<Node>, region: Rect, scale: f64, anti_alias
|
||||
r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, supersample: true, ..Default::default() })
|
||||
}
|
||||
|
||||
/// The note for pixels drawn since [`vectorcraft_render::decode_failures`] stood at `failed`, when
|
||||
/// an image among them couldn't be decoded and so drew nothing; `None` otherwise.
|
||||
pub(crate) fn undecoded_warning(failed: u64) -> Option<&'static str> {
|
||||
(vectorcraft_render::decode_failures() > failed).then_some("an image couldn't be decoded and is missing from the picture")
|
||||
}
|
||||
|
||||
/// Pixels per point of raster effects rendered as images (the document's raster effects
|
||||
/// resolution).
|
||||
pub(crate) fn effects_scale(doc: &Document) -> f64 {
|
||||
@@ -172,6 +178,8 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 {
|
||||
/// fills' and strokes' raster effects included). With `knockout`, the coverage of that art is knocked out
|
||||
/// of the image, which then only holds what the effects add around it (a shadow to go under the
|
||||
/// vector object). The document's raster effects settings add room around it and finish its pixels.
|
||||
/// `None` when it paints nothing, when the area it covers overflows `f64` or the scale is not
|
||||
/// positive, or when its PNG can't be encoded.
|
||||
pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option<Node> {
|
||||
// What it paints (its members' effects included), and how far its fills' and strokes' raster
|
||||
// effects reach beyond that.
|
||||
@@ -179,8 +187,16 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
|
||||
let look = out.raster_effects.clone();
|
||||
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around;
|
||||
let b = b.inflate(reach, reach);
|
||||
// An area too large for f64 gives a zero scale and an image transform that is not finite.
|
||||
let area = b.width() * b.height();
|
||||
if !area.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Whole pixels, and no larger than 64 Mpx.
|
||||
let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt());
|
||||
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt());
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
|
||||
// Anti-alias off: hard edges (the effects themselves stay smooth).
|
||||
let anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None };
|
||||
@@ -464,6 +480,10 @@ fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) {
|
||||
/// has none. An object whose effects all paint below it keeps its vector art over an image of
|
||||
/// them; one whose effects change it (blur, feather, inner glow) becomes the image.
|
||||
fn walk(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option<Node> {
|
||||
// A cancelled export leaves the rest of the document as it is: the caller drops the result.
|
||||
if vectorcraft_doc::cancel::is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
let checkpoint = report.checkpoint();
|
||||
let result = walk_inner(out, n, report);
|
||||
report.attribute_since(checkpoint, n);
|
||||
@@ -582,6 +602,8 @@ pub fn export_pdf_with_report(
|
||||
) -> std::result::Result<vectorcraft_pdf::ExportReport, vectorcraft_pdf::PdfError> {
|
||||
let mut report = FlattenReport::new(Which::All, doc);
|
||||
let flat = flatten(doc, &mut report);
|
||||
// Flattening stops early when cancelled, leaving a half-flattened copy that must not be written.
|
||||
vectorcraft_doc::cancel::check().map_err(|_| vectorcraft_pdf::PdfError::Cancelled)?;
|
||||
let mut exported = vectorcraft_pdf::export_with_report(flat.as_ref().unwrap_or(doc), opts)?;
|
||||
exported.warnings.extend(report.warnings());
|
||||
Ok(exported)
|
||||
|
||||
@@ -697,7 +697,7 @@ pub fn skip_reason(s: &Session, st: &DocState) -> Option<&'static str> {
|
||||
if !st.is_dirty() {
|
||||
return Some("no unsaved changes");
|
||||
}
|
||||
if st.recovery.as_ref().is_some_and(|c| Arc::ptr_eq(&c.doc, &st.doc)) {
|
||||
if st.recovery.as_ref().is_some_and(|c| st.same_content(&c.doc)) {
|
||||
return Some("unchanged since its last recovery copy");
|
||||
}
|
||||
if s.prefs.recovery_off_for_complex && object_count(&st.doc) > COMPLEX_OBJECTS {
|
||||
|
||||
@@ -259,9 +259,16 @@ fn all_on_artboard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?;
|
||||
// Symbol instances are measured by their art.
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
let ids: Vec<NodeId> = selectable(&st.doc, st.isolation)
|
||||
.into_iter()
|
||||
.filter(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds()).is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.center())))
|
||||
.filter(|id| {
|
||||
st.doc
|
||||
.node(*id)
|
||||
.and_then(|n| n.geometric_bounds_with(&symbols))
|
||||
.is_some_and(|b| b.intersect(r).area() > 0.0 || vectorcraft_doc::artboard_holds(r, b.center()))
|
||||
})
|
||||
.collect();
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
ok()
|
||||
|
||||
@@ -204,7 +204,7 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// The `names` list and `name` parameters, without repeats; at least one is required.
|
||||
fn names_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
pub(super) fn names_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
let mut names: Vec<String> = vec![];
|
||||
for n in str_list(p, "names").into_iter().chain(str_param(p, "name").map(str::to_string)) {
|
||||
if !names.contains(&n) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
use vectorcraft_text::edit;
|
||||
|
||||
use super::stroke::StrokeChange;
|
||||
@@ -31,6 +31,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
cmd!(
|
||||
"text.touchType",
|
||||
"Touch Type",
|
||||
[],
|
||||
None,
|
||||
"{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first character of a line and for a right-to-left character; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, along its own up axis when it is rotated); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)",
|
||||
has_doc,
|
||||
touch_type
|
||||
),
|
||||
cmd!(
|
||||
query "text.getRange",
|
||||
"Get Text Range",
|
||||
@@ -45,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area / Path Type",
|
||||
[],
|
||||
None,
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, overflow?: [id] (the text doesn't fit; left out when it fits)}",
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}",
|
||||
has_doc,
|
||||
create_in_path
|
||||
),
|
||||
@@ -550,6 +559,185 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id.0, "runs": n}))
|
||||
}
|
||||
|
||||
/// The em a laid-out glyph's kerning and tracking are measured in, in text space: the size it
|
||||
/// was shaped at (superscript, small caps and Shrink Text to Fit included).
|
||||
fn glyph_em(t: &TextObject, g: &vectorcraft_text::PositionedGlyph) -> Option<f64> {
|
||||
let face = vectorcraft_text::FontDb::global().face_by_id(g.font_id)?;
|
||||
let h_scale = edit::slice_runs(&t.runs, g.byte, g.byte + g.len).first()?.style.h_scale / 100.0;
|
||||
let [a, b, ..] = g.xf.as_coeffs();
|
||||
Some(a.hypot(b) * face.units_per_em() / h_scale).filter(|em| em.is_finite() && *em > 0.0)
|
||||
}
|
||||
|
||||
fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.touchType";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?;
|
||||
if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad(C, "nothing to change: give dx, dy, hScale, vScale or rotation"));
|
||||
}
|
||||
let num = |k: &str| match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(0.0),
|
||||
Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`{k}` must be a number"))),
|
||||
};
|
||||
let (dx, dy) = (num("dx")?, num("dy")?);
|
||||
let look = json!({"hScale": p.get("hScale"), "vScale": p.get("vScale"), "rotation": p.get("rotation")});
|
||||
let mut change = CharChange::parse(&look, C, &s.doc()?.doc.setup)?;
|
||||
let t = text_ref(s, id)?;
|
||||
change.localize(&t, C)?;
|
||||
let points = t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points;
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let g = lay.glyphs.iter().find(|g| g.byte <= start && start < g.byte + g.len).ok_or_else(|| bad(C, format!("no character at byte {start}")))?;
|
||||
let (a, b) = (g.byte, g.byte + g.len);
|
||||
// The line's direction in reading order.
|
||||
let d = Vec2::new(g.angle.cos(), g.angle.sin());
|
||||
let along = if g.rtl { -d } else { d };
|
||||
let linear = |m: Affine, v: Vec2| m * (Point::ZERO + v) - m * Point::ZERO;
|
||||
// Document points per text-space point along `v`.
|
||||
let doc_len = |v: Vec2| linear(t.xf, v).hypot() / v.hypot();
|
||||
let limit = 1296.0 / points;
|
||||
if dx != 0.0 && g.rtl {
|
||||
return Err(bad(C, "a right-to-left character moves only up and down (dy)"));
|
||||
}
|
||||
let prev = lay.glyphs.iter().find(|h| h.line == g.line && h.byte + h.len == a);
|
||||
let pr = prev.map(|h| (h.byte, h.byte + h.len));
|
||||
let em = glyph_em(&t, g).ok_or_else(|| bad(C, "the character's size can't be measured"))?;
|
||||
let em_prev = match prev {
|
||||
Some(h) => Some(glyph_em(&t, h).ok_or_else(|| bad(C, "the character's size can't be measured"))?),
|
||||
None if dx != 0.0 => return Err(bad(C, "the first character of a line has no character before it to kern: it moves only up and down (dy)")),
|
||||
None => None,
|
||||
};
|
||||
let kerning_in = |(x, y): (usize, usize)| edit::slice_runs(&t.runs, x, y).first().and_then(|r| r.style.kerning).unwrap_or(0.0);
|
||||
// The move along the line, in text space, limited to what kerning from -1000 to 10000 (1/1000
|
||||
// em) on the character before it and on the character itself can give.
|
||||
let mut moved = dx / doc_len(along);
|
||||
if let (Some(range), Some(ep)) = (pr, em_prev) {
|
||||
let (kp, kc) = (kerning_in(range), kerning_in((a, b)));
|
||||
let hi = ((10000.0 - kp) * ep).min((kc + 1000.0) * em) / 1000.0;
|
||||
let lo = ((-1000.0 - kp) * ep).max((kc - 10000.0) * em) / 1000.0;
|
||||
if lo <= hi {
|
||||
moved = moved.clamp(lo, hi);
|
||||
}
|
||||
}
|
||||
// The character's scales, rotation and baseline shift, and kerning added to the character
|
||||
// before it (`before`) and to the character itself (`own`), in 1/1000 em.
|
||||
let touch = |t: &mut TextObject, before: Option<f64>, own: Option<f64>, raise: f64| {
|
||||
for (range, by) in [(pr, before), (Some((a, b)), own)] {
|
||||
if let (Some((x, y)), Some(by)) = (range, by) {
|
||||
edit::style_range(&mut t.runs, x, y, |st| st.kerning = Some((st.kerning.unwrap_or(0.0) + by).clamp(-1000.0, 10000.0)));
|
||||
}
|
||||
}
|
||||
edit::style_range(&mut t.runs, a, b, |st| {
|
||||
if raise != 0.0 {
|
||||
st.baseline_shift = (st.baseline_shift + raise).clamp(-limit, limit);
|
||||
}
|
||||
change.apply(st);
|
||||
});
|
||||
};
|
||||
// The baseline shift that raises the character `dy` document points. Baseline shift moves it
|
||||
// along its own up axis, turned with its rotation (the new one, when the call sets it), by a
|
||||
// length that the object transform, Shrink Text to Fit and a path effect such as Skew scale:
|
||||
// two layouts with the call's other changes and shifts one step apart, both within the range,
|
||||
// measure that length. Neither shift is one that a run already has, so the character's run
|
||||
// splits off alike in both.
|
||||
let raise = if dy == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
let shift = edit::slice_runs(&t.runs, a, b).first().map_or(0.0, |r| r.style.baseline_shift);
|
||||
let from = shift.clamp(-limit, limit);
|
||||
let free = |v: f64| v.abs() <= limit && t.runs.iter().all(|r| r.style.baseline_shift != v);
|
||||
// A step of a quarter of the range at most leaves room for two steps up or down.
|
||||
let unit = (limit / 4.0).min(1.0);
|
||||
let step =
|
||||
[1.0, -1.0, 0.75, -0.75].into_iter().map(|f| f * unit).find(|p| free(from + p) && free(from + 2.0 * p)).unwrap_or(-unit.copysign(from));
|
||||
// The character's pen with its baseline shift set to `to`.
|
||||
let pen_at = |to: f64| {
|
||||
let mut trial = t.clone();
|
||||
touch(&mut trial, None, None, to - shift);
|
||||
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial);
|
||||
l.glyphs.iter().find(|h| h.byte == a).map(|h| h.xf * Point::ZERO)
|
||||
};
|
||||
let per_step = match (pen_at(from + step), pen_at(from + 2.0 * step)) {
|
||||
(Some(p), Some(q)) => linear(t.xf, q - p).hypot() / step.abs(),
|
||||
_ => 0.0,
|
||||
};
|
||||
dy / per_step
|
||||
};
|
||||
if !raise.is_finite() {
|
||||
return Err(bad(C, "the character's size can't be measured"));
|
||||
}
|
||||
// Where a glyph starts along the line.
|
||||
let pen = |h: &vectorcraft_text::PositionedGlyph| h.origin.to_vec2().dot(along);
|
||||
// How much further the character's new scales advance it along the line: the character laid
|
||||
// out alone, in point type of the same orientation, before and after the change.
|
||||
let grown = {
|
||||
let mut alone = TextObject { kind: TextKind::Point, runs: edit::slice_runs(&t.runs, a, b), paras: Vec::new(), ..t.clone() };
|
||||
let advance = |o: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), o).glyphs.iter().map(|h| h.advance).sum::<f64>();
|
||||
let old = advance(&alone);
|
||||
edit::style_range(&mut alone.runs, 0, b - a, |st| change.apply(st));
|
||||
(advance(&alone) - old) * lay.fit_scale
|
||||
};
|
||||
// The character lands `moved` further on, and the character after it on its line keeps its
|
||||
// distance from it, wider or narrower only by the character's new scales.
|
||||
let goal = pen(g) + moved;
|
||||
let next_goal = lay.glyphs.iter().find(|h| h.byte == b && h.line == g.line).map(|h| pen(h) + grown);
|
||||
// How far the character and the character after it on its line miss their places, along the
|
||||
// line, with the kerning `k` (before, own) added.
|
||||
let miss = |k: (Option<f64>, Option<f64>)| {
|
||||
let mut trial = t.clone();
|
||||
touch(&mut trial, k.0, k.1, raise);
|
||||
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial);
|
||||
let h = l.glyphs.iter().find(|h| h.byte == a)?;
|
||||
let next = match (l.glyphs.iter().find(|n| n.byte == b && n.line == h.line), next_goal) {
|
||||
(Some(n), Some(at)) => pen(n) - at,
|
||||
_ => 0.0,
|
||||
};
|
||||
Some((pen(h) - goal, next))
|
||||
};
|
||||
let placed = |m: (f64, f64)| m.0.abs() <= 1e-9 && m.1.abs() <= 1e-9;
|
||||
let mut k = if moved != 0.0 { (em_prev.map(|ep| moved / ep * 1000.0), Some(-moved / em * 1000.0)) } else { (None, None) };
|
||||
// A manual kerning value turns off the font's kerning for its character, each style run is
|
||||
// shaped apart, and centered, right-aligned and justified lines move as their width changes, so
|
||||
// the first values can miss. Layouts then measure what each kerning value does to the two
|
||||
// characters, and the values are solved for from that (at most six layouts).
|
||||
if !g.rtl && miss(k).is_some_and(|m| !placed(m)) {
|
||||
k = (pr.map(|_| k.0.unwrap_or(0.0)), Some(k.1.unwrap_or(0.0)));
|
||||
if let Some(mut m) = miss(k) {
|
||||
const STEP: f64 = 10.0;
|
||||
let column = |dk: (f64, f64)| miss((k.0.map(|v| v + dk.0), k.1.map(|v| v + dk.1))).map(|n| ((n.0 - m.0) / STEP, (n.1 - m.1) / STEP));
|
||||
let by_before = if k.0.is_some() { column((STEP, 0.0)) } else { None };
|
||||
let by_own = column((0.0, STEP));
|
||||
// The kerning changes that cancel the misses `m`.
|
||||
let solve = |m: (f64, f64)| match (by_before, by_own) {
|
||||
(Some(p), Some(q)) if (p.0 * q.1 - q.0 * p.1).abs() > 1e-12 => {
|
||||
let det = p.0 * q.1 - q.0 * p.1;
|
||||
((q.0 * m.1 - m.0 * q.1) / det, (m.0 * p.1 - p.0 * m.1) / det)
|
||||
}
|
||||
(Some(p), _) if p.0.abs() > 1e-12 => (-m.0 / p.0, 0.0),
|
||||
(_, Some(q)) if q.1.abs() > 1e-12 => (0.0, -m.1 / q.1),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
for _ in 0..2 {
|
||||
if placed(m) {
|
||||
break;
|
||||
}
|
||||
let (dp, dq) = solve(m);
|
||||
k = (k.0.map(|v| v + dp), k.1.map(|v| v + dq));
|
||||
match miss(k) {
|
||||
Some(n) => m = n,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.edit("Touch Type", |d, _| {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
touch(t, k.0, k.1, raise);
|
||||
refresh_bounds(t);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"id": id.0, "start": a, "end": b}))
|
||||
}
|
||||
|
||||
fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.createInPath";
|
||||
let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?;
|
||||
@@ -585,6 +773,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
@@ -609,7 +798,8 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(super::typecmd::with_overflow(s, &[id], json!({"id": id.0})))
|
||||
let made = super::typecmd::with_bounds(s, Some(id), json!({"id": id.0}));
|
||||
Ok(super::typecmd::with_overflow(s, &[id], made))
|
||||
}
|
||||
|
||||
/// What `type.step` changes.
|
||||
@@ -803,6 +993,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -133,11 +133,52 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
|
||||
m
|
||||
}
|
||||
|
||||
/// Captured character styles set Auto leading explicitly. A missing style attribute instead
|
||||
/// leaves the text's existing leading alone, unlike a run's omitted Auto value.
|
||||
fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
/// Every character attribute of `v` as a JSON map, without the style name: those the run's JSON
|
||||
/// leaves out at their defaults (Auto leading and kerning, no OpenType features, Roman baseline
|
||||
/// alignment…) are written out. A style captured from text sets them all; an attribute missing
|
||||
/// from a style instead leaves the text's own value alone.
|
||||
pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
let mut m = to_attrs(v);
|
||||
m.insert("leading".into(), json!(v.leading));
|
||||
for (k, x) in [
|
||||
("font_version", json!(v.font_version)),
|
||||
("leading", json!(v.leading)),
|
||||
("kerning", json!(v.kerning)),
|
||||
("features", json!(v.features)),
|
||||
("overprint_fill", json!(v.overprint_fill)),
|
||||
("overprint_stroke", json!(v.overprint_stroke)),
|
||||
("stroke_cap", json!(v.stroke_cap)),
|
||||
("stroke_join", json!(v.stroke_join)),
|
||||
("stroke_miter_limit", json!(v.stroke_miter_limit)),
|
||||
("stroke_dash", json!(v.stroke_dash)),
|
||||
("stroke_align", json!(v.stroke_align)),
|
||||
("position", json!(v.position)),
|
||||
("small_caps", json!(v.small_caps)),
|
||||
("charAlign", json!(v.char_align)),
|
||||
("proportionalMetrics", json!(v.proportional_metrics)),
|
||||
("digits", json!(v.digits)),
|
||||
] {
|
||||
m.insert(k.into(), x);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Every paragraph attribute of `v` as a JSON map, without the style name: those the paragraph's
|
||||
/// JSON leaves out at their defaults (the Hyphenation options, the Every-line Composer, no tab
|
||||
/// stops, Auto direction…) are written out, as [`char_attrs`] does for characters.
|
||||
pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
|
||||
let mut m = to_attrs(v);
|
||||
for (k, x) in [
|
||||
("hyphenation", json!(v.hyphenation)),
|
||||
("composer", json!(v.composer)),
|
||||
("tabs", json!(v.tabs)),
|
||||
("mojikumi", json!(v.mojikumi)),
|
||||
("direction", json!(v.direction)),
|
||||
("leading_model", json!(v.leading_model)),
|
||||
("burasagari", json!(v.burasagari)),
|
||||
("kinsoku", json!(v.kinsoku)),
|
||||
] {
|
||||
m.insert(k.into(), x);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
@@ -187,13 +228,19 @@ fn with_attrs<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Val
|
||||
}
|
||||
|
||||
/// `cur` after its style changed from `old` to `new`: attributes still equal to the old style's
|
||||
/// value (or not set by it) take the new value; overrides stay. Unchanged if the style doesn't
|
||||
/// round-trip through JSON.
|
||||
fn restyle<T: Serialize + DeserializeOwned + Clone>(cur: &T, old: &Map<String, Value>, new: &Map<String, Value>) -> T {
|
||||
let Some(mut m) = to_map(cur) else { return cur.clone() };
|
||||
// Runs omit Auto leading from JSON; it still matches a style's explicit null.
|
||||
if old.get("leading") == Some(&Value::Null) {
|
||||
m.entry("leading").or_insert(Value::Null);
|
||||
/// value (or not set by it) take the new value; overrides stay. `attrs` lists every attribute of
|
||||
/// `cur` ([`char_attrs`], [`para_attrs`]), so one at its default matches a style that sets the
|
||||
/// default. Unchanged if the style doesn't round-trip through JSON.
|
||||
fn restyle<T: Serialize + DeserializeOwned + Clone>(
|
||||
cur: &T,
|
||||
attrs: fn(&T) -> Map<String, Value>,
|
||||
old: &Map<String, Value>,
|
||||
new: &Map<String, Value>,
|
||||
) -> T {
|
||||
let Some(full) = to_map(cur) else { return cur.clone() };
|
||||
let mut m = attrs(cur);
|
||||
if let Some(name) = full.get("style_name") {
|
||||
m.insert("style_name".into(), name.clone());
|
||||
}
|
||||
for (k, v) in new {
|
||||
if old.get(k).is_none_or(|o| m.get(k) == Some(o)) {
|
||||
@@ -246,7 +293,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
|
||||
Kind::Char => {
|
||||
for r in &mut t.runs {
|
||||
if r.style.style_name == target {
|
||||
r.style = restyle(&r.style, old, new);
|
||||
r.style = restyle(&r.style, char_attrs, old, new);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -254,7 +301,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
|
||||
Kind::Para => {
|
||||
for pa in t.para_styles_mut() {
|
||||
if pa.style_name == target {
|
||||
*pa = restyle(pa, old, new);
|
||||
*pa = restyle(pa, para_attrs, old, new);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -286,7 +333,7 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
|
||||
char_attrs(&style)
|
||||
}
|
||||
// The paragraph at `start`.
|
||||
Kind::Para => to_attrs(t.para_at(t.paragraphs_in(at, at).start)),
|
||||
Kind::Para => para_attrs(t.para_at(t.paragraphs_in(at, at).start)),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -314,7 +361,7 @@ fn attrs_param(p: &Value, kind: Kind, verb: &str) -> Result<Option<Map<String, V
|
||||
let c = kind.cmd(verb);
|
||||
let full = match kind {
|
||||
Kind::Char => char_attrs(&with_attrs(&CharStyle::default(), m, &c)?),
|
||||
Kind::Para => to_attrs(&with_attrs(&ParaStyle::default(), m, &c)?),
|
||||
Kind::Para => para_attrs(&with_attrs(&ParaStyle::default(), m, &c)?),
|
||||
};
|
||||
Ok(Some(m.keys().filter_map(|k| full.get(k).map(|v| (k.clone(), v.clone()))).collect()))
|
||||
}
|
||||
@@ -708,6 +755,78 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The attribute names a style of type `T` can set, without the style name.
|
||||
fn settable<T: DeserializeOwned>() -> std::collections::BTreeSet<String> {
|
||||
attr_names::<T>().into_iter().filter(|&n| n != "style_name").map(String::from).collect()
|
||||
}
|
||||
|
||||
/// New and Redefine capture every paragraph attribute. A paragraph style redefined from a
|
||||
/// paragraph at the defaults gives its paragraphs the defaults back where they had the old
|
||||
/// definition's values, and `setAttrs` with a default value does too; local overrides, and
|
||||
/// attributes `setAttrs` leaves out, stay.
|
||||
#[test]
|
||||
fn para_style_redefined_to_the_defaults_updates_its_paragraphs() {
|
||||
use vectorcraft_doc::{Composer, HyphenationOptions};
|
||||
let (mut s, source) = session_with_text("Source");
|
||||
let [linked, local] =
|
||||
["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap());
|
||||
let ids = [source, linked, local];
|
||||
let para = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.para_at(0).clone(),
|
||||
_ => panic!("not text"),
|
||||
};
|
||||
let limits = |s: &Session| ids.map(|id| para(s, id).hyphenation.hyphen_limit);
|
||||
let composers = |s: &Session| ids.map(|id| para(s, id).composer);
|
||||
let defined = |s: &Session, i: usize| s.doc().unwrap().doc.para_styles[i].attrs.keys().cloned().collect::<std::collections::BTreeSet<_>>();
|
||||
s.execute("text.setFormat", &json!({"ids": ids, "hyphenLimit": 2, "composer": "singleLine"})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Body", "id": source})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [local], "hyphenLimit": 4})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [source], "hyphenLimit": 0, "composer": "everyLine"})).unwrap();
|
||||
s.execute("paraStyle.redefine", &json!({"name": "Body", "id": source})).unwrap();
|
||||
assert_eq!((limits(&s), composers(&s)), ([0, 0, 4], [Composer::EveryLine; 3]));
|
||||
assert_eq!(para(&s, linked).hyphenation, HyphenationOptions::default());
|
||||
assert!(ids.iter().all(|&id| para(&s, id).style_name.as_deref() == Some("Body")));
|
||||
assert_eq!(defined(&s, 0), settable::<ParaStyle>(), "Redefine captures every attribute");
|
||||
s.execute("paraStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
|
||||
assert_eq!(defined(&s, 1), settable::<ParaStyle>(), "New captures every attribute");
|
||||
// Style options: a default value reaches the users too; an attribute left out stays as it is.
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"hyphenation": {"hyphen_limit": 3}, "composer": "singleLine"}})).unwrap();
|
||||
assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::SingleLine; 3]));
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"composer": "everyLine"}})).unwrap();
|
||||
assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::EveryLine; 3]));
|
||||
assert_eq!(Value::Object(s.doc().unwrap().doc.para_styles[0].attrs.clone()), json!({"composer": "everyLine"}));
|
||||
}
|
||||
|
||||
/// The same for character styles: Auto kerning and the Roman baseline alignment, which the
|
||||
/// run's JSON leaves out, are captured and reach the style's users.
|
||||
#[test]
|
||||
fn char_style_redefined_to_the_defaults_updates_its_text() {
|
||||
use vectorcraft_doc::CharAlign;
|
||||
let (mut s, source) = session_with_text("Source");
|
||||
let [linked, local] =
|
||||
["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap());
|
||||
let ids = [source, linked, local];
|
||||
let kerning = |s: &Session| ids.map(|id| runs(s, id)[0].style.kerning);
|
||||
let aligns = |s: &Session| ids.map(|id| runs(s, id)[0].style.char_align);
|
||||
let defined = |s: &Session, i: usize| s.doc().unwrap().doc.char_styles[i].attrs.keys().cloned().collect::<std::collections::BTreeSet<_>>();
|
||||
s.execute("text.setFormat", &json!({"ids": ids, "kerning": 40, "charAlign": "emBoxCenter"})).unwrap();
|
||||
s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [local], "kerning": 60})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [source], "kerning": "auto", "charAlign": "romanBaseline"})).unwrap();
|
||||
s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap();
|
||||
assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3]));
|
||||
assert_eq!(defined(&s, 0), settable::<CharStyle>(), "Redefine captures every attribute");
|
||||
s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
|
||||
assert_eq!(defined(&s, 1), settable::<CharStyle>(), "New captures every attribute");
|
||||
s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"kerning": 10, "charAlign": "emBoxTop"}})).unwrap();
|
||||
assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::EmBoxTop; 3]));
|
||||
s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"charAlign": "romanBaseline"}})).unwrap();
|
||||
assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::RomanBaseline; 3]));
|
||||
assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_leading_new_distinguishes_unset_attributes() {
|
||||
let (mut s, id) = session_with_text("Title");
|
||||
|
||||
@@ -34,6 +34,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
create_outlines
|
||||
),
|
||||
cmd!(
|
||||
"type.opticalMarginAlignment",
|
||||
"Optical Margin Alignment",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type (on omitted: the opposite of the first object's setting): in every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both → {on, ids}",
|
||||
has_selection,
|
||||
optical_margin_alignment
|
||||
),
|
||||
cmd!(
|
||||
"text.setText",
|
||||
"Set Text",
|
||||
@@ -97,6 +106,28 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
|
||||
Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Type › Optical Margin Alignment: set `on` (without it, the opposite of the first object's
|
||||
/// setting) on every selected text object, in one undo step.
|
||||
fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = text_targets(s, p, "Optical Margin Alignment")?;
|
||||
let first = ids.first().and_then(|id| match s.doc().ok()?.doc.node(*id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => Some(t.optical_margin),
|
||||
_ => None,
|
||||
});
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!first.unwrap_or(false));
|
||||
s.edit("Optical Margin Alignment", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
};
|
||||
t.optical_margin = on;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"on": on, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Recompute the layout caches (bounds and baselines) after a text edit. Every engine text edit ends here (typing
|
||||
/// through `text.editRange` too), inside its `Session::edit`, so this is where Auto Size area type
|
||||
/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo
|
||||
@@ -269,10 +300,32 @@ pub(crate) fn with_overflow(s: &Session, ids: &[NodeId], mut result: Value) -> V
|
||||
result
|
||||
}
|
||||
|
||||
/// Adds `bounds` to the result of a command that made the type object `id`: the same
|
||||
/// `{x, y, width, height}` box `document.inspect` reports for it (after `justify`, so a script can
|
||||
/// place the new type without a helper object); `null` when it has none.
|
||||
pub(crate) fn with_bounds(s: &Session, id: Option<NodeId>, mut result: Value) -> Value {
|
||||
let Ok(st) = s.doc() else { return result };
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
let b = id.and_then(|i| st.doc.node(i)).and_then(|n| n.geometric_bounds_with(&symbols));
|
||||
result["bounds"] = crate::inspect::rect_json(b);
|
||||
result
|
||||
}
|
||||
|
||||
/// The warning that Print gives for `ids`, area type and type on a path whose text doesn't fit
|
||||
/// (as [`with_overflow`] finds it): the text past the end of their frames doesn't print. It names
|
||||
/// the first 20 ids and counts the rest; `None` when `ids` is empty.
|
||||
pub(crate) fn overflow_warning(ids: &[NodeId]) -> Option<String> {
|
||||
overflow_note(ids, "doesn't print")
|
||||
}
|
||||
|
||||
/// [`overflow_warning`] as an export that draws type gives it: the text past the end of the frames
|
||||
/// isn't drawn in the export.
|
||||
pub(crate) fn export_overflow_warning(ids: &[NodeId]) -> Option<String> {
|
||||
overflow_note(ids, "isn't drawn in the export")
|
||||
}
|
||||
|
||||
/// [`overflow_warning`], saying that the text past the end of the frames `left_out`.
|
||||
fn overflow_note(ids: &[NodeId], left_out: &str) -> Option<String> {
|
||||
const NAMED: usize = 20;
|
||||
let (named, rest) = ids.split_at(ids.len().min(NAMED));
|
||||
let mut list = named.iter().map(|i| i.0.to_string()).collect::<Vec<_>>().join(", ");
|
||||
@@ -281,8 +334,8 @@ pub(crate) fn overflow_warning(ids: &[NodeId]) -> Option<String> {
|
||||
}
|
||||
match ids {
|
||||
[] => None,
|
||||
[_] => Some(format!("1 text object doesn't fit its frame: the text past the end of the frame doesn't print (id {list})")),
|
||||
_ => Some(format!("{} text objects don't fit their frames: the text past the end of each frame doesn't print (ids {list})", ids.len())),
|
||||
[_] => Some(format!("1 text object doesn't fit its frame: the text past the end of the frame {left_out} (id {list})")),
|
||||
_ => Some(format!("{} text objects don't fit their frames: the text past the end of each frame {left_out} (ids {list})", ids.len())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,6 +836,30 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod area_tests {
|
||||
use super::*;
|
||||
|
||||
/// #2413: `text.create` and `text.createInPath` return the new type's `bounds`, the box
|
||||
/// `document.inspect` reports for it, so a script can place it without a helper object.
|
||||
#[test]
|
||||
fn text_create_returns_the_inspect_bounds() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
|
||||
let inspected = |s: &mut Session, id: &Value| {
|
||||
let doc = s.execute("document.inspect", &json!({})).unwrap();
|
||||
fn find(v: &Value, id: &Value) -> Option<Value> {
|
||||
if &v["id"] == id {
|
||||
return Some(v["bounds"].clone());
|
||||
}
|
||||
v.get("children").or_else(|| v.get("layers"))?.as_array()?.iter().find_map(|c| find(c, id))
|
||||
}
|
||||
find(&doc, id).unwrap()
|
||||
};
|
||||
let made = s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Hello", "size": 24, "justify": "center"})).unwrap();
|
||||
assert!(made["bounds"]["width"].as_f64().unwrap() > 10.0, "{made}");
|
||||
assert_eq!(made["bounds"], inspected(&mut s, &made["id"]));
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 200, "width": 100, "height": 50})).unwrap();
|
||||
let area = s.execute("text.createInPath", &json!({"path": rect["id"], "mode": "area", "text": "Boxed"})).unwrap();
|
||||
assert_eq!(area["bounds"], inspected(&mut s, &area["id"]));
|
||||
}
|
||||
|
||||
/// #1748: `text.create` takes `justify`; point type then lines up on its point (centred on
|
||||
/// it, or ending there), and `text.setStyle` moves it the same way afterwards.
|
||||
#[test]
|
||||
@@ -888,6 +965,57 @@ mod area_tests {
|
||||
assert_eq!(old.burasagari, Burasagari::None);
|
||||
}
|
||||
|
||||
/// Type › Optical Margin Alignment: `type.opticalMarginAlignment` sets or toggles the flag on
|
||||
/// the selected type in one undo step, the opening quotation mark then hangs left of the
|
||||
/// frame, and the flag is saved only when on and survives a native save.
|
||||
#[test]
|
||||
fn optical_margin_alignment_is_set_per_object_and_saved_only_when_on() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s
|
||||
.execute("text.create", &json!({"x": 10, "y": 10, "size": 40, "text": "“Quoted” words", "area": {"width": 300, "height": 100}}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
// The left edge of the glyphs in text space, where the frame's left edge is at 0.
|
||||
let left = |s: &Session| text(s).cached_bounds.unwrap().x0;
|
||||
// New type: off, and not saved.
|
||||
assert!(!text(&s).optical_margin);
|
||||
assert!(serde_json::to_value(text(&s)).unwrap().get("opticalMarginAlignment").is_none());
|
||||
let inside = left(&s);
|
||||
assert!(inside >= 0.0, "{inside}");
|
||||
// Nothing but a rectangle selected: an error.
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [rect]})).unwrap();
|
||||
assert!(s.execute("type.opticalMarginAlignment", &json!({})).is_err());
|
||||
// Toggled on: the quotation mark hangs outside the frame.
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], true);
|
||||
assert!(text(&s).optical_margin);
|
||||
assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s));
|
||||
assert_eq!(serde_json::to_value(text(&s)).unwrap()["opticalMarginAlignment"], true);
|
||||
// `on` sets it; without `on` it toggles.
|
||||
assert_eq!(s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap()["on"], true);
|
||||
assert!(text(&s).optical_margin);
|
||||
assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false);
|
||||
assert!(!text(&s).optical_margin);
|
||||
assert_eq!(left(&s), inside);
|
||||
// One undo step each.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(text(&s).optical_margin);
|
||||
// Saved and reopened as it was; a document from before it reads as off.
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert!(t.optical_margin);
|
||||
let mut old = serde_json::to_value(text(&s)).unwrap();
|
||||
old.as_object_mut().unwrap().remove("opticalMarginAlignment");
|
||||
assert!(!serde_json::from_value::<TextObject>(old).unwrap().optical_margin);
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Hyphenation…: `text.setFormat` sets each paragraph's hyphenation
|
||||
/// options, rounded and clamped, in one undo step; they are saved only when not the defaults,
|
||||
/// and paragraph styles carry them.
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
//! variable, or a graph root to a `graph` variable (CSV data), put a row of values in
|
||||
//! each dataset, and `dataset.select` swaps the whole document to that row in one undo
|
||||
//! step. `dataset.import` / `dataset.export` move those rows as CSV (header: `name`
|
||||
//! then variable names). Agents reach every command through `run_command`, like all
|
||||
//! engine commands.
|
||||
//! then variable names); import also reads tab-separated files and files without the
|
||||
//! `name` column. Agents reach every command through `run_command`, like all engine
|
||||
//! commands.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -28,6 +29,19 @@ const MAX_CSV_BYTES: usize = 1_048_576;
|
||||
const MAX_CSV_ROWS: usize = 1_000;
|
||||
const MAX_CSV_COLS: usize = 256;
|
||||
|
||||
/// Lock Variables refuses New Variable, Delete Variable, Rename Variable (Variable Options) and
|
||||
/// Make … Dynamic; binding, unbinding and the dataset commands still run.
|
||||
fn variables_unlocked(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|st| st.doc.variables.locked) { Err("variables are locked".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Make … Dynamic needs a selection, and defines a variable, which Lock Variables refuses.
|
||||
fn selection_and_unlocked(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
variables_unlocked(s)
|
||||
}
|
||||
|
||||
fn var_name(p: &Value, cmd: &str) -> Result<String> {
|
||||
named(str_param(p, "name"), "name", cmd)
|
||||
}
|
||||
@@ -60,9 +74,8 @@ fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked
|
||||
/// layer is not the document's to rewrite; a text variable only drives type; an image variable
|
||||
/// only drives an image object.
|
||||
/// Refuse what a variable of `kind` can't be bound to: art that is locked or on a hidden or locked
|
||||
/// layer; a text variable only drives type; an image variable only drives an image object.
|
||||
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
|
||||
for id in ids {
|
||||
match doc.node(*id) {
|
||||
@@ -232,7 +245,21 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
json!({"name": v.name, "kind": v.kind.label(), "bindings": bindings})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"variables": variables}))
|
||||
Ok(json!({"variables": variables, "locked": st.doc.variables.locked}))
|
||||
}
|
||||
|
||||
/// Lock Variables: without `locked` it toggles. Setting the state the document already has
|
||||
/// does not edit the document or add an undo step.
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let was = s.doc()?.doc.variables.locked;
|
||||
let locked = bool_or(p, "locked", !was);
|
||||
if locked != was {
|
||||
s.edit(if locked { "Lock Variables" } else { "Unlock Variables" }, |d, _| {
|
||||
d.variables.locked = locked;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({"locked": locked}))
|
||||
}
|
||||
|
||||
fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -368,8 +395,8 @@ fn dataset_capture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Update Data Set: write what the bound objects hold now into the row that is active, for each
|
||||
/// variable the row holds. A variable whose objects report no value (locked, inside a hidden or
|
||||
/// locked layer or group, or gone) keeps the row's value. With `all`, every other variable the
|
||||
/// variable the row holds. A variable whose objects report no value (locked, inside a locked
|
||||
/// layer or group, or gone) keeps the row's value. With `all`, every other variable the
|
||||
/// objects report a value for is added to the row. Without an active row there is nothing to
|
||||
/// update, so say so rather than picking one.
|
||||
fn dataset_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -438,28 +465,6 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
for v in &d.variables.variables {
|
||||
pairs.extend(d.variables.objects_of(&v.name).into_iter().map(|id| (id, v.name.clone())));
|
||||
}
|
||||
// The row's values go in three passes: the visibility values that show an object,
|
||||
// outermost object first; the other values, in definition order; the visibility values
|
||||
// that hide an object, innermost object first. An object inside a group is then rewritten
|
||||
// after the row shows the group and before the row hides it. An object with several
|
||||
// visibility variables goes in the pass of the last one's value, and each pass keeps
|
||||
// definition order otherwise.
|
||||
let mut shows = std::collections::HashMap::new();
|
||||
for (id, var) in &pairs {
|
||||
let kind = d.variables.variable(var).map(|v| v.kind);
|
||||
if let (Some(VariableKind::Visibility), Some(DataValue::Visible(show))) = (kind, ds.values.get(var)) {
|
||||
shows.insert(*id, *show);
|
||||
}
|
||||
}
|
||||
let depths = d.index_paths(shows.keys().copied());
|
||||
pairs.sort_by_cached_key(|(id, var)| {
|
||||
let depth = depths.get(id).map_or(0, Vec::len);
|
||||
match (d.variables.variable(var).map(|v| v.kind), shows.get(id)) {
|
||||
(Some(VariableKind::Visibility), Some(true)) => (0, depth),
|
||||
(Some(VariableKind::Visibility), Some(false)) => (2, usize::MAX - depth),
|
||||
_ => (1, 0),
|
||||
}
|
||||
});
|
||||
let mut applied = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
// Collects the type objects whose text the row rewrote, for the reply's `overflow`.
|
||||
@@ -475,10 +480,10 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Locked art, art on a locked or hidden layer, and text whose object is gone, can't
|
||||
// be rewritten: skipped and counted, like the rest. A hidden object is rewritten: a
|
||||
// row that hid it shows it again.
|
||||
if !d.variable_target(id) {
|
||||
// Locked art, art in a locked layer or group, and text whose object is gone, can't be
|
||||
// rewritten: skipped and counted, like the rest. Hidden art is rewritten, in a hidden
|
||||
// layer or group too, and stays hidden unless a visibility value shows it.
|
||||
if !d.dataset_target(id) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -535,16 +540,61 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Parse a datasets CSV into rows of cells (no header interpretation yet): commas between cells,
|
||||
/// `"` quotes a cell (doubled inside), which may then hold commas and line breaks, as
|
||||
/// [`csv_escape`] writes them. An unquoted cell is trimmed. A quoted cell keeps what its quotes
|
||||
/// hold as written, spaces included, and drops white space after the closing quote at the end
|
||||
/// of the cell. Blank lines are skipped.
|
||||
fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
|
||||
/// A row of a datasets CSV: the file line it starts on, and its cells.
|
||||
type CsvRow = (usize, Vec<String>);
|
||||
|
||||
/// Whether a datasets CSV separates its cells with tabs: its header row (the first line that
|
||||
/// isn't blank), read with tabs between cells, has a tab outside quotes and no comma outside
|
||||
/// quotes.
|
||||
fn tab_separated(text: &str) -> bool {
|
||||
// `start`: nothing but `\r` read yet in this cell. `blank`: no cell on this line holds
|
||||
// anything but white space yet.
|
||||
let (mut in_quotes, mut start, mut blank, mut tab) = (false, true, true, false);
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' if in_quotes => {
|
||||
if chars.peek() == Some(&'"') {
|
||||
chars.next();
|
||||
blank = false;
|
||||
} else {
|
||||
in_quotes = false;
|
||||
}
|
||||
}
|
||||
'"' if start => in_quotes = true,
|
||||
c if in_quotes => blank &= c.is_whitespace(),
|
||||
',' => return false,
|
||||
'\t' => {
|
||||
tab = true;
|
||||
start = true;
|
||||
continue;
|
||||
}
|
||||
'\r' => continue,
|
||||
'\n' if blank => {
|
||||
tab = false;
|
||||
start = true;
|
||||
continue;
|
||||
}
|
||||
'\n' => return tab,
|
||||
c => blank &= c.is_whitespace(),
|
||||
}
|
||||
start = false;
|
||||
}
|
||||
tab
|
||||
}
|
||||
|
||||
/// Parse a datasets CSV into rows of cells (no header interpretation yet). Cells are separated
|
||||
/// by commas, or by tabs when [`tab_separated`] says so. `"` quotes a cell (doubled inside),
|
||||
/// which may then hold separators and line breaks, as [`csv_escape`] writes them. An unquoted
|
||||
/// cell is trimmed. A quoted cell keeps what its quotes hold as written, spaces included, and
|
||||
/// drops white space after the closing quote at the end of the cell. Blank lines are skipped.
|
||||
fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<CsvRow>> {
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let sep = if tab_separated(text) { '\t' } else { ',' };
|
||||
let (mut rows, mut row, mut cur) = (Vec::new(), Vec::<String>::new(), String::new());
|
||||
// `closed`: where the closing quote of a quoted cell falls in `cur`.
|
||||
let (mut in_quotes, mut closed, mut line) = (false, None, 1usize);
|
||||
// `closed`: where the closing quote of a quoted cell falls in `cur`. `start`: the line the
|
||||
// current row starts on.
|
||||
let (mut in_quotes, mut closed, mut line, mut start) = (false, None, 1usize, 1usize);
|
||||
let mut chars = text.chars().peekable();
|
||||
loop {
|
||||
let c = chars.next();
|
||||
@@ -559,12 +609,12 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
|
||||
}
|
||||
}
|
||||
Some('"') if cur.is_empty() => in_quotes = true,
|
||||
Some(',') if !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)),
|
||||
Some(ch) if ch == sep && !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)),
|
||||
Some('\r') if !in_quotes => {}
|
||||
Some('\n') | None if !in_quotes => {
|
||||
row.push(end_csv_cell(&mut cur, &mut closed));
|
||||
if row.iter().any(|c| !c.trim().is_empty()) {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
rows.push((start, std::mem::take(&mut row)));
|
||||
// +1 for the header.
|
||||
if rows.len() > MAX_CSV_ROWS + 1 {
|
||||
return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows")));
|
||||
@@ -575,6 +625,7 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
|
||||
return Ok(rows);
|
||||
}
|
||||
line += 1;
|
||||
start = line;
|
||||
}
|
||||
None => return Err(bad(cmd, format!("line {line}: unclosed quote"))),
|
||||
Some(c) => {
|
||||
@@ -659,27 +710,27 @@ fn datasets_to_csv(vars: &Variables) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Import datasets from CSV: first column `name`, remaining headers are variable names.
|
||||
/// Existing rows of the same name are replaced; new names are appended. One undo step.
|
||||
/// Import datasets from CSV. A first header cell `name` makes the first column the dataset
|
||||
/// names; the other headers are variable names. Without it, every header is a variable name
|
||||
/// and the rows take the first free names "Data Set 1", "Data Set 2" and so on. Existing rows
|
||||
/// of the same name are replaced; new names are appended. One undo step.
|
||||
fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.import";
|
||||
let bytes = datasets_csv_bytes(p, C)?;
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| bad(C, "CSV is not UTF-8"))?;
|
||||
let rows = parse_datasets_csv(text, C)?;
|
||||
let Some(header) = rows.first() else {
|
||||
let Some((_, header)) = rows.first() else {
|
||||
return Err(bad(C, "CSV is empty"));
|
||||
};
|
||||
if header.first().map(|h| h.trim().eq_ignore_ascii_case("name")) != Some(true) {
|
||||
return Err(bad(C, "first header cell must be `name`"));
|
||||
}
|
||||
let name_column = header.first().is_some_and(|h| h.trim().eq_ignore_ascii_case("name"));
|
||||
let (kinds, existing_names) = {
|
||||
let st = s.doc()?;
|
||||
let kinds: BTreeMap<String, VariableKind> = st.doc.variables.variables.iter().map(|v| (v.name.clone(), v.kind)).collect();
|
||||
let existing: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
||||
let existing: BTreeSet<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
||||
(kinds, existing)
|
||||
};
|
||||
let mut columns: Vec<(usize, String, VariableKind)> = Vec::new();
|
||||
for (i, h) in header.iter().enumerate().skip(1) {
|
||||
for (i, h) in header.iter().enumerate().skip(usize::from(name_column)) {
|
||||
let name = h.trim();
|
||||
if name.is_empty() {
|
||||
return Err(bad(C, format!("empty header in column {}", i + 1)));
|
||||
@@ -691,19 +742,25 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "CSV has a header but no data rows"));
|
||||
}
|
||||
let mut parsed: Vec<DataSet> = Vec::new();
|
||||
for (r_i, row) in rows.iter().enumerate().skip(1) {
|
||||
let line = r_i + 1;
|
||||
let raw_name = row.first().map(|c| c.trim()).unwrap_or("");
|
||||
// The names of the rows read so far, and the last number given to a row without a name.
|
||||
let (mut seen, mut number) = (BTreeSet::new(), 0usize);
|
||||
for &(line, ref row) in rows.iter().skip(1) {
|
||||
let raw_name = if name_column { row.first().map_or("", |c| c.trim()) } else { "" };
|
||||
let name = if raw_name.is_empty() {
|
||||
// Same numbering as Capture Data Set when a row leaves the name blank.
|
||||
(1..)
|
||||
.map(|n| format!("Data Set {n}"))
|
||||
.find(|n| parsed.iter().all(|d| d.name != *n) && !existing_names.iter().any(|e| e == n))
|
||||
.unwrap_or_else(|| format!("Data Set {line}"))
|
||||
// Same numbering as Capture Data Set when a row leaves the name blank or the file has
|
||||
// no name column. The search starts after the last number given.
|
||||
let (n, name) = (number + 1..)
|
||||
.map(|n| (n, format!("Data Set {n}")))
|
||||
.find(|(_, n)| !seen.contains(n) && !existing_names.contains(n))
|
||||
.unwrap_or_else(|| (line, format!("Data Set {line}")));
|
||||
number = n;
|
||||
name
|
||||
} else if raw_name.chars().count() > MAX_NAME {
|
||||
return Err(bad(C, format!("line {line}: `name` is longer than {MAX_NAME} characters")));
|
||||
} else {
|
||||
named(Some(raw_name), "name", C)?
|
||||
raw_name.to_string()
|
||||
};
|
||||
if parsed.iter().any(|d| d.name == name) {
|
||||
if !seen.insert(name.clone()) {
|
||||
return Err(bad(C, format!("line {line}: dataset `{name}` appears twice")));
|
||||
}
|
||||
let mut values = BTreeMap::new();
|
||||
@@ -782,8 +839,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Variable…",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name, kind: text|visibility|image|graph} define a variable (graph: CSV data on a graph root)",
|
||||
has_doc,
|
||||
"{name, kind: text|visibility|image|graph} define a variable (graph: CSV data on a graph root); refused while the variables are locked",
|
||||
variables_unlocked,
|
||||
define
|
||||
),
|
||||
cmd!(
|
||||
@@ -791,8 +848,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Variable",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{names?} delete variables with their bindings and values (default: the highlighted one)",
|
||||
has_doc,
|
||||
"{names?} delete variables with their bindings and values (default: the highlighted one); refused while the variables are locked",
|
||||
variables_unlocked,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
@@ -800,18 +857,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rename Variable",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name, newName} rename a variable, with its bindings and its values in every dataset",
|
||||
has_doc,
|
||||
"{name, newName} rename a variable, with its bindings and its values in every dataset; refused while the variables are locked",
|
||||
variables_unlocked,
|
||||
rename
|
||||
),
|
||||
cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list),
|
||||
cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts, and whether they are locked → {variables, locked}", has_doc, list),
|
||||
cmd!(
|
||||
"variable.makeTextDynamic",
|
||||
"Make Text Dynamic",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{} define a text variable over the selection, named after it and bound to it",
|
||||
has_selection,
|
||||
"{} define a text variable over the selection, named after it and bound to it; refused while the variables are locked",
|
||||
selection_and_unlocked,
|
||||
|s, _| make_dynamic(s, VariableKind::Text, "Text")
|
||||
),
|
||||
cmd!(
|
||||
@@ -819,8 +876,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make Visibility Dynamic",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{} define a visibility variable over the selection, named after it and bound to it",
|
||||
has_selection,
|
||||
"{} define a visibility variable over the selection, named after it and bound to it; refused while the variables are locked",
|
||||
selection_and_unlocked,
|
||||
|s, _| make_dynamic(s, VariableKind::Visibility, "Visibility")
|
||||
),
|
||||
cmd!(
|
||||
@@ -828,8 +885,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make Image Dynamic",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{} define an image variable over the selection, named after it and bound to it",
|
||||
has_selection,
|
||||
"{} define an image variable over the selection, named after it and bound to it; refused while the variables are locked",
|
||||
selection_and_unlocked,
|
||||
|s, _| make_dynamic(s, VariableKind::Image, "Image")
|
||||
),
|
||||
cmd!(
|
||||
@@ -837,8 +894,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make Graph Dynamic",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{} define a graph variable over the selected graph root(s), named after it and bound to it; each dataset row supplies CSV as graph.setData",
|
||||
has_selection,
|
||||
"{} define a graph variable over the selected graph root(s), named after it and bound to it; each dataset row supplies CSV as graph.setData; refused while the variables are locked",
|
||||
selection_and_unlocked,
|
||||
|s, _| make_dynamic(s, VariableKind::Graph, "Graph")
|
||||
),
|
||||
cmd!(
|
||||
@@ -859,6 +916,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
unbind
|
||||
),
|
||||
cmd!(
|
||||
"variable.lock",
|
||||
"Lock Variables",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{locked?: bool} lock or unlock the variables (default: toggle); saved with the document; a change is one undo step. While locked, variable.define, variable.delete, variable.rename and variable.make…Dynamic are refused; binding, unbinding and the dataset commands still run → {locked}",
|
||||
has_doc,
|
||||
lock
|
||||
),
|
||||
cmd!(
|
||||
"variable.highlight",
|
||||
"Highlight Variable",
|
||||
@@ -893,7 +959,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Update Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{all?: bool} write what the bound objects hold now into the active dataset, for each variable the dataset holds; a variable whose objects report no value (locked, inside a hidden or locked layer or group, or gone) keeps the dataset's value. `all: true` also adds every other variable the objects report a value for; `left` names the variables it didn't write → {values, left}",
|
||||
"{all?: bool} write what the bound objects hold now into the active dataset, for each variable the dataset holds; a variable whose objects report no value (locked, inside a locked layer or group, or gone) keeps the dataset's value. `all: true` also adds every other variable the objects report a value for; `left` names the variables it didn't write → {values, left}",
|
||||
has_doc,
|
||||
dataset_update
|
||||
),
|
||||
@@ -937,7 +1003,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Data Sets…",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{path? | data? | dataBase64? | csv?} load datasets from CSV (header: name, then variable names; one row per dataset). Rows whose name already exists are replaced → {imported, updated, names}",
|
||||
"{path? | data? | dataBase64? | csv?} load datasets from CSV, comma- or tab-separated (header: name, then variable names; one row per dataset; without the name column every header is a variable and the rows take the first free names Data Set 1, 2…). Rows whose name already exists are replaced → {imported, updated, names}",
|
||||
has_doc,
|
||||
dataset_import
|
||||
),
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Sample Color",
|
||||
[],
|
||||
None,
|
||||
"{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient)",
|
||||
"{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient); in a CMYK document an RGB color is stored as CMYK through the color settings, as paint.setFill stores it",
|
||||
has_doc,
|
||||
sample_color
|
||||
),
|
||||
@@ -346,7 +346,7 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })),
|
||||
Some(v) => {
|
||||
let i = v.as_u64().ok_or_else(|| bad(C, "`stop` must be a stop index"))? as usize;
|
||||
let color = color_value(c).ok_or_else(|| bad(C, format!("bad color {c}")))?;
|
||||
let color = super::paint::new_color(s, p, color_value(c).ok_or_else(|| bad(C, format!("bad color {c}")))?);
|
||||
let (fill, stroke_paint) = s.proxy_paints();
|
||||
let Paint::Gradient(g) = (if stroke { stroke_paint } else { fill }) else {
|
||||
return Err(bad(C, "the paint behind the proxy is not a gradient"));
|
||||
|
||||
Reference in New Issue
Block a user