mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
Merge storytold/main into AF.3 native Affinity import
Conflicts: README and ROADMAP (both sides kept: the French and Russian interface with the Affinity sentence; the placed-documents bullet with the Affinity one); place and swatch libraries (upstream's byte sniffing and comments, with the preview-only refusal kept). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
This commit is contained in:
@@ -803,9 +803,10 @@ impl Picked {
|
||||
run_attrs(&mut r.style, &placed, ap);
|
||||
}
|
||||
if let Some((_, para)) = text.filter(|_| a.paragraph) {
|
||||
t.para = para.clone();
|
||||
// The source's first paragraph's attributes, on every paragraph.
|
||||
t.set_all_paras(para.clone());
|
||||
}
|
||||
if text.is_some() && a.character {
|
||||
if text.is_some() && (a.character || a.paragraph) {
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -18,10 +18,10 @@ use super::edit::selected_roots;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
const SIZES_KEY: &str = "symbolSizes";
|
||||
const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES;
|
||||
const CURRENT_SYMBOL: &str = "currentSymbol";
|
||||
/// Half the instance box `Node::geometric_bounds` uses for symbol instances.
|
||||
const HALF: f64 = 10.0;
|
||||
const HALF: f64 = vectorcraft_doc::SYMBOL_HALF;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -14,8 +14,8 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}} → {id, paths, anchors, colors}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult)} → {id, paths, anchors, colors}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
@@ -151,6 +151,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_ids,
|
||||
trace_expand
|
||||
),
|
||||
cmd!(
|
||||
"imageTrace.setView",
|
||||
"Image Trace View",
|
||||
[],
|
||||
None,
|
||||
"{id?|ids?: Image Trace objects (default: selection), view: \"tracingResult\"|\"tracingResultWithOutlines\"|\"outlines\"|\"outlinesWithSourceImage\"|\"sourceImage\"} how they draw on screen; exports, printing and Expand keep the tracing result → {ids, view}",
|
||||
has_selection_or_ids,
|
||||
trace_set_view
|
||||
),
|
||||
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| {
|
||||
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::<Vec<_>>() }))
|
||||
}),
|
||||
@@ -250,7 +259,8 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Edges come back from the planar map within a few hundredths of the outlines they follow.
|
||||
let cut_tol = step * 0.2;
|
||||
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
|
||||
let union = if picked.is_empty() { PathData::default() } else { po::merge_regions(&picked) };
|
||||
let faces = po::FaceMerger::new(&map.shapes);
|
||||
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
|
||||
// The merged shape takes the look of the front-most object over the first region, or (for an
|
||||
// area only lines enclose) of the front-most object and the current fill.
|
||||
let covered = picked.iter().find_map(|r| r.top());
|
||||
@@ -278,7 +288,17 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// its outline, shared edges from every shape along them).
|
||||
let in_union = picked.iter().any(|r| r.sources.contains(&i));
|
||||
let mut rest = if in_union {
|
||||
po::boolean(&sh.path, sh.rule, &union, FillRule::NonZero, po::BoolOp::Difference)
|
||||
// What is left of it: its faces not merged or deleted, united. They share their
|
||||
// edges with the merged faces exactly, where subtracting the merged shape left
|
||||
// slivers of the outline along them (#569).
|
||||
let kept: Vec<&po::Region> = map
|
||||
.regions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k))
|
||||
.map(|(_, (r, ..))| r)
|
||||
.collect();
|
||||
if kept.is_empty() { PathData::default() } else { faces.merge(&kept) }
|
||||
} else {
|
||||
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
|
||||
};
|
||||
@@ -628,6 +648,13 @@ fn is_trace_group(n: &Node) -> bool {
|
||||
&& n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_))))
|
||||
}
|
||||
|
||||
/// The `view` param ([`TraceView`] id), if given.
|
||||
fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
|
||||
let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let ids = || TraceView::ALL.map(TraceView::id).join(", ");
|
||||
v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids())))
|
||||
}
|
||||
|
||||
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
|
||||
let name = str_param(p, "preset").unwrap_or("Default");
|
||||
let base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
@@ -637,12 +664,49 @@ fn trace_params(p: &Value) -> Result<tr::TraceParams> {
|
||||
for (k, val) in src {
|
||||
o.insert(k.clone(), val.clone());
|
||||
}
|
||||
serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))
|
||||
let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))?;
|
||||
if !params.fills && !params.strokes {
|
||||
return Err(bad("imageTrace.make", "Create needs fills, strokes or both"));
|
||||
}
|
||||
if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) {
|
||||
return Err(bad("imageTrace.make", "strokeWidth must be a positive number of pixels"));
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
/// Document Library in Color mode: look the library's colours up into `params.swatches` → each
|
||||
/// one as traced (sRGB) with the colour to fill with.
|
||||
fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8; 3], Color)>> {
|
||||
const C: &str = "imageTrace.make";
|
||||
if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let key = params.library.as_str();
|
||||
let colors = super::swatchlib::library_colors(s, key)
|
||||
.ok_or_else(|| bad(C, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?;
|
||||
if colors.is_empty() {
|
||||
return Err(bad(C, format!("`{key}` has no colours")));
|
||||
}
|
||||
let library: Vec<([u8; 3], Color)> = colors
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
([r, g, b], c)
|
||||
})
|
||||
.collect();
|
||||
params.swatches = library.iter().map(|l| l.0).collect();
|
||||
Ok(library)
|
||||
}
|
||||
|
||||
/// The most anchor points an Image Trace may make: a photo traced at high fidelity can make
|
||||
/// millions, more than the canvas draws smoothly or a laptop has memory for, so the trace is
|
||||
/// refused with advice instead (#525).
|
||||
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
|
||||
|
||||
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
const C: &str = "imageTrace.make";
|
||||
let params = trace_params(p)?;
|
||||
let mut params = trace_params(p)?;
|
||||
let view = view_param(C, p)?;
|
||||
let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// The preset's name if the parameters are exactly that preset's, else "Custom".
|
||||
let named = str_param(p, "preset").unwrap_or("Default");
|
||||
@@ -671,11 +735,14 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
};
|
||||
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
|
||||
let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let res = tr::trace(&raster, ¶ms);
|
||||
let library = trace_library(s, &mut params)?;
|
||||
let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// Pixel space of the decoded raster → the image object's pixel space → document.
|
||||
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
|
||||
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
|
||||
let xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy);
|
||||
// Document units per raster pixel (stroke widths).
|
||||
let unit = xf.determinant().abs().sqrt();
|
||||
let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len());
|
||||
let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" };
|
||||
let id = s.edit(label, |d, sel| {
|
||||
@@ -698,14 +765,30 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
for tp in res.paths {
|
||||
let path = tp.path.transformed(xf);
|
||||
let mut n = shape_node(d, path, None);
|
||||
n.appearance = fill_only(Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])));
|
||||
// A library colour fills with the swatch's own colour (its model kept).
|
||||
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
|
||||
let paint = Paint::solid(color);
|
||||
n.appearance = match tp.stroke {
|
||||
// Traced lines: round ends and corners, as drawn lines have.
|
||||
Some(width) => Appearance {
|
||||
items: vec![AppearanceItem::Stroke(StrokeLayer {
|
||||
cap: LineCap::Round,
|
||||
join: LineJoin::Round,
|
||||
..StrokeLayer::new(paint, width * unit)
|
||||
})],
|
||||
..Default::default()
|
||||
},
|
||||
None => fill_only(paint),
|
||||
};
|
||||
children.push(Arc::new(n));
|
||||
}
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
|
||||
if !expand {
|
||||
g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json })));
|
||||
// Tracing again keeps the object's view unless another is asked for.
|
||||
let view = view.unwrap_or_else(|| src.trace_view());
|
||||
g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() })));
|
||||
}
|
||||
let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?;
|
||||
d.remove(target)?;
|
||||
@@ -725,6 +808,28 @@ fn trace_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn trace_set_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "imageTrace.setView";
|
||||
let view = view_param(C, p)?.ok_or_else(|| bad(C, "view is required"))?;
|
||||
let targets = trace_targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
if targets.iter().all(|g| d.node(*g).is_some_and(|n| n.trace_view() == view)) {
|
||||
return Ok(json!({ "ids": ids_json(&targets), "view": view.id() }));
|
||||
}
|
||||
s.edit("Image Trace View", |d, _| {
|
||||
for g in &targets {
|
||||
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
if let Some(t) = n.trace.as_deref_mut().and_then(Value::as_object_mut) {
|
||||
t.insert("view".into(), json!(view.id()));
|
||||
} else {
|
||||
n.trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() })));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&targets), "view": view.id() }))
|
||||
}
|
||||
|
||||
fn trace_release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = trace_targets(s, p)?;
|
||||
let ids = s.edit("Release Image Trace", |d, sel| {
|
||||
|
||||
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour};
|
||||
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
@@ -220,6 +220,25 @@ mod tests {
|
||||
s
|
||||
}
|
||||
|
||||
/// The address a browser's Copy Image puts next to the picture (#597), and text that isn't one.
|
||||
#[test]
|
||||
fn an_address_alone_is_told_from_other_text() {
|
||||
for a in [
|
||||
"https://example.com/cat.png",
|
||||
" http://x.org/a?b=1
|
||||
",
|
||||
"HTTPS://EXAMPLE.COM",
|
||||
"file:///C:/art/cat.png",
|
||||
"data:image/png;base64,iVBOR",
|
||||
"blob:https://x.org/1",
|
||||
] {
|
||||
assert!(is_address(a), "{a}");
|
||||
}
|
||||
for t in ["Pasted words", "see https://example.com", "https://a.org https://b.org", "https://", "example.com/cat.png", ""] {
|
||||
assert!(!is_address(t), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_exports_svg_that_round_trips_through_paste() {
|
||||
let mut s = session();
|
||||
@@ -240,6 +259,26 @@ mod tests {
|
||||
assert!((b.width() - 30.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_type_gets_its_layout_bounds_cache() {
|
||||
// Reported from Illustrator: pasted type kept no layout bounds, so its box (and alignment)
|
||||
// fell back to the rough estimate until the file was saved and reopened.
|
||||
let mut s = session();
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="500" height="120"><text x="0" y="80" font-family="Source Sans 3" font-size="40">WWWWWWWW</text></svg>"#;
|
||||
s.execute("clipboard.importSvg", &json!({"svg": svg})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let mut checked = 0;
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
let vectorcraft_doc::NodeKind::Text(t) = &n.kind else { return };
|
||||
let cached = t.cached_bounds.expect("pasted type computed its bounds");
|
||||
let real = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds;
|
||||
assert!((cached.width() - real.width()).abs() < 1e-6, "{cached:?} {real:?}");
|
||||
assert!(cached.width() > t.estimate_bounds().width() + 1.0, "{cached:?} is just the estimate");
|
||||
checked += 1;
|
||||
});
|
||||
assert_eq!(checked, 1, "one text object pasted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_never_lands_in_an_object_that_reused_an_undone_layers_id() {
|
||||
// Found by the model-based test: undo restores the id counter, so a compound path made
|
||||
|
||||
@@ -30,6 +30,15 @@ pub const EMF: &str = "image/emf";
|
||||
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
|
||||
/// (word processors offer a picture of copied text too).
|
||||
pub const PASTE_ORDER: [&str; 5] = [SVG, PDF, EMF, TEXT, BITMAP];
|
||||
/// Is `text` only an address (a web page's, an image's, a file's)? A browser's Copy Image puts the
|
||||
/// image's address as text next to the picture: then the picture is what Paste takes (#597). An
|
||||
/// address alone pastes as text, and nothing is fetched.
|
||||
pub fn is_address(text: &str) -> bool {
|
||||
const SCHEMES: [&str; 6] = ["http://", "https://", "file://", "ftp://", "data:", "blob:"];
|
||||
let t = text.trim();
|
||||
!t.chars().any(char::is_whitespace) && SCHEMES.iter().any(|s| t.get(..s.len()).is_some_and(|h| h.eq_ignore_ascii_case(s)) && t.len() > s.len())
|
||||
}
|
||||
|
||||
/// How much of a copied file [`file_flavour`] needs to tell what it is.
|
||||
pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
|
||||
@@ -122,11 +122,12 @@ fn node_refs(n: &Node, f: Visit) {
|
||||
f(Ref::Name(Res::CharStyle, s));
|
||||
}
|
||||
}
|
||||
if let Some(s) = &t.para.style_name {
|
||||
for s in t.para_styles().filter_map(|pa| pa.style_name.as_ref()) {
|
||||
f(Ref::Name(Res::ParaStyle, s));
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => f(Ref::Name(Res::Image, &im.key)),
|
||||
NodeKind::PlacedDocument(pl) => f(Ref::Name(Res::Image, &pl.key)),
|
||||
NodeKind::SymbolInstance { symbol, .. } => f(Ref::Name(Res::Symbol, symbol)),
|
||||
_ => {}
|
||||
}
|
||||
@@ -439,11 +440,14 @@ impl Relink {
|
||||
self.name(Res::CharStyle, s);
|
||||
}
|
||||
}
|
||||
if let Some(s) = &mut t.para.style_name {
|
||||
self.name(Res::ParaStyle, s);
|
||||
for pa in t.para_styles_mut() {
|
||||
if let Some(s) = &mut pa.style_name {
|
||||
self.name(Res::ParaStyle, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => self.name(Res::Image, &mut im.key),
|
||||
NodeKind::PlacedDocument(pl) => self.name(Res::Image, &mut pl.key),
|
||||
NodeKind::SymbolInstance { symbol, .. } => self.name(Res::Symbol, symbol),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,16 @@ use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("shape.rectangle", "Rectangle", [], None, "{x, y, width, height, radius?: pt} → {id}", has_doc, rectangle),
|
||||
cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height} → {id}", has_doc, ellipse),
|
||||
cmd!(
|
||||
"shape.rectangle",
|
||||
"Rectangle",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, radius?: pt, rotation?: deg (about centre)} → {id}",
|
||||
has_doc,
|
||||
rectangle
|
||||
),
|
||||
cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height, rotation?: deg (about centre)} → {id}", has_doc, ellipse),
|
||||
cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon),
|
||||
cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star),
|
||||
cmd!(
|
||||
@@ -50,7 +58,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}",
|
||||
"{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), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -163,6 +171,17 @@ fn rect_of(p: &Value, cmd: &str) -> Result<Rect> {
|
||||
Ok(Rect::new(x, y, x + w, y + h).abs())
|
||||
}
|
||||
|
||||
fn rect_transform(r: Rect, p: &Value, cmd: &str) -> Result<Affine> {
|
||||
let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", cmd)? } else { 0.0 };
|
||||
if !rotation.is_finite() {
|
||||
return Err(bad(cmd, "rotation must be finite"));
|
||||
}
|
||||
if rotation == 0.0 {
|
||||
return Ok(Affine::translate(r.origin().to_vec2()));
|
||||
}
|
||||
Ok(Affine::translate(r.center().to_vec2()) * Affine::rotate(rotation.to_radians()) * Affine::translate((-r.width() / 2.0, -r.height() / 2.0)))
|
||||
}
|
||||
|
||||
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_of(p, "shape.rectangle")?;
|
||||
let radius = f64_or(p, "radius", 0.0).max(0.0);
|
||||
@@ -171,7 +190,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
h: r.height(),
|
||||
radii: [radius; 4],
|
||||
kinds: Default::default(),
|
||||
xf: Affine::translate(r.origin().to_vec2()),
|
||||
xf: rect_transform(r, p, "shape.rectangle")?,
|
||||
};
|
||||
let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" };
|
||||
add_art(s, label, path_kind(live.to_path(), Some(live)), None)
|
||||
@@ -179,7 +198,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_of(p, "shape.ellipse")?;
|
||||
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: Affine::translate(r.origin().to_vec2()) };
|
||||
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: rect_transform(r, p, "shape.ellipse")? };
|
||||
add_art(s, "Ellipse", path_kind(live.to_path(), Some(live)), None)
|
||||
}
|
||||
|
||||
@@ -415,10 +434,12 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let w = f64_or(a, "width", 200.0);
|
||||
let h = f64_or(a, "height", 100.0);
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) };
|
||||
t.area.fit = new_area_fit(s, a, "text.create")?;
|
||||
}
|
||||
if bool_or(p, "placeholder", false) {
|
||||
super::typemenu::fill_with_placeholder(&mut t);
|
||||
} else {
|
||||
// Lays the text out (and sizes an Auto Size frame).
|
||||
super::typecmd::refresh_bounds(&mut t);
|
||||
}
|
||||
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
|
||||
@@ -426,8 +447,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
|
||||
/// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed
|
||||
/// with Line-end Punctuation Half Width and Standard burasagari (as in Illustrator). Imported text
|
||||
/// keeps its own.
|
||||
/// with Line-end Punctuation Half Width and Standard burasagari. Imported text keeps its own.
|
||||
pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
|
||||
vectorcraft_doc::ParaStyle {
|
||||
justify: vectorcraft_doc::Justify::Auto,
|
||||
@@ -471,6 +491,13 @@ pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The fit new area type gets: `fit`/`fitMinPercent` from `p` (as `text.areaOptions` takes
|
||||
/// them), else Auto Size when the "Auto Size New Area Type" preference is on.
|
||||
pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result<vectorcraft_doc::AreaFit> {
|
||||
let default = if s.prefs.auto_size_area_type { vectorcraft_doc::AreaFit::AutoHeight } else { vectorcraft_doc::AreaFit::None };
|
||||
Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default))
|
||||
}
|
||||
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
|
||||
/// defaults, filled with the current fill unless that is None or white (black then).
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
|
||||
@@ -418,6 +418,7 @@ fn not_liquified(n: &Node) -> Option<&'static str> {
|
||||
NodeKind::Mesh(_) => "meshes",
|
||||
NodeKind::Envelope { .. } => "envelopes",
|
||||
NodeKind::Repeat(_) => "repeats",
|
||||
NodeKind::PlacedDocument(_) => "placed documents",
|
||||
NodeKind::Blend { .. } => "blends",
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -49,13 +49,14 @@ pub const CATEGORIES: [(&str, &str); 11] = [
|
||||
];
|
||||
|
||||
/// The object counts of `document.info` and their labels.
|
||||
const OBJECT_LABELS: [(&str, &str); 16] = [
|
||||
const OBJECT_LABELS: [(&str, &str); 17] = [
|
||||
("paths", "Paths"),
|
||||
("compoundPaths", "Compound Paths"),
|
||||
("groups", "Groups"),
|
||||
("clipGroups", "Clipping Masks"),
|
||||
("textObjects", "Text Objects"),
|
||||
("images", "Images"),
|
||||
("placedDocuments", "Placed Documents"),
|
||||
("symbolInstances", "Symbol Instances"),
|
||||
("gradients", "Gradient Objects"),
|
||||
("patterns", "Pattern Objects"),
|
||||
@@ -252,6 +253,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
NodeKind::Envelope { .. } => Some("envelopes"),
|
||||
NodeKind::Mesh(_) => Some("meshes"),
|
||||
NodeKind::Repeat(_) => Some("repeats"),
|
||||
NodeKind::PlacedDocument(_) => Some("placedDocuments"),
|
||||
};
|
||||
if let Some(k) = k {
|
||||
*counts.entry(k).or_default() += 1;
|
||||
|
||||
@@ -381,6 +381,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
}
|
||||
}
|
||||
sel.set(new_ids.iter().copied());
|
||||
// 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))
|
||||
})?;
|
||||
// The Artboard tool takes the pasted artboard.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
|
||||
//! (Fill) into plain art in one undo step. 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
|
||||
//! (Fill) into plain art in one undo step. 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.
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -36,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, envelopes → their distorted content), 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, 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}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -55,6 +56,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it
|
||||
/// shows, keeping its id and place; the resources the art uses join `d` (a name `d` uses for
|
||||
/// something else gets a free variant).
|
||||
fn expand_placed_under(d: &mut Document, roots: &[NodeId]) -> Result<()> {
|
||||
let mut ids = vec![];
|
||||
for n in roots.iter().filter_map(|r| d.node(*r)) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::PlacedDocument(_)) {
|
||||
ids.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
ids.into_iter().try_for_each(|id| super::place::document::expand(d, id, C))
|
||||
}
|
||||
|
||||
/// Expand Gradient To.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum GradientTo {
|
||||
@@ -88,6 +104,8 @@ struct Expandable {
|
||||
envelope: bool,
|
||||
/// Live blends (Object expands them into groups of their steps).
|
||||
blends: bool,
|
||||
/// Placed documents (Object expands them into the art they show).
|
||||
placed: bool,
|
||||
}
|
||||
|
||||
impl Expandable {
|
||||
@@ -101,6 +119,7 @@ impl Expandable {
|
||||
e.effects |= !c.appearance.effects.is_empty();
|
||||
e.envelope |= matches!(c.kind, NodeKind::Envelope { .. });
|
||||
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
|
||||
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
|
||||
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
@@ -112,7 +131,7 @@ impl Expandable {
|
||||
}
|
||||
|
||||
fn object(&self) -> bool {
|
||||
self.text || self.live || self.effects || self.envelope || self.blends
|
||||
self.text || self.live || self.effects || self.envelope || self.blends || self.placed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +154,11 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = s.doc()?.history.undo.len();
|
||||
let rev0 = s.doc()?.revision;
|
||||
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
// Placed documents first: their art (type, strokes, gradients) expands below.
|
||||
if object && e.placed {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
|
||||
}
|
||||
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
|
||||
if object && e.blends {
|
||||
let roots = selected_roots(s)?;
|
||||
|
||||
@@ -448,7 +448,7 @@ impl Builder {
|
||||
None => Paint::None,
|
||||
};
|
||||
// Affinity separates paragraphs with U+2029 and lines with U+2028.
|
||||
TextRun { text: r.text.replace(['\u{2029}', '\u{2028}'], "\n"), style }
|
||||
TextRun::new(r.text.replace(['\u{2029}', '\u{2028}'], "\n"), style)
|
||||
})
|
||||
.collect();
|
||||
obj.para.justify = match t.align {
|
||||
|
||||
@@ -364,6 +364,22 @@ pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(
|
||||
|
||||
/// [`encode`], with every file the export writes.
|
||||
pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
// Placed documents output their files' art (a native file keeps what it keeps: see
|
||||
// `native::encode`).
|
||||
if !matches!(format, "vectorcraft" | "template") {
|
||||
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
|
||||
if !warnings.is_empty() {
|
||||
let mut enc = encode_all_of(&full, format, p)?;
|
||||
enc.warnings.extend(warnings);
|
||||
return Ok(enc);
|
||||
}
|
||||
return encode_all_of(&full, format, p);
|
||||
}
|
||||
encode_all_of(doc, format, p)
|
||||
}
|
||||
|
||||
/// [`encode_all`] of `doc` as it is.
|
||||
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let mut enc = encode_files(doc, f, p)?;
|
||||
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
|
||||
@@ -401,6 +417,12 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
"txt" => super::text::encode(doc, p)?,
|
||||
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
|
||||
"dxf" => return super::dxf::encode(doc, p, use_artboards),
|
||||
// As Save As writes it (a PDF of every artboard carrying the native document).
|
||||
"ai" => {
|
||||
let mut q = p.clone();
|
||||
super::save::ai_params(&mut q);
|
||||
return super::save::encode_ai(C, f, doc, &q);
|
||||
}
|
||||
// EPS reads its own `useArtboards` (the art's bounds unless asked).
|
||||
"eps" => return super::eps::encode(doc, p),
|
||||
"emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf),
|
||||
|
||||
@@ -87,6 +87,26 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
|
||||
/// out, at any depth of layers and sublayers, and objects that reach it stay whole (the file's
|
||||
/// bounds clip them), as DXF's crop leaves them. A clipping layer keeps every object, its clipping
|
||||
/// path among them. An artboard's file then holds its own art, not a whole document of artboards.
|
||||
pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document {
|
||||
fn kept(n: &Arc<Node>, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
|
||||
if matches!(n.kind, NodeKind::Layer { clip: false, .. }) {
|
||||
let mut layer = Node::clone(n);
|
||||
let children = layer.children_mut()?;
|
||||
*children = children.iter().filter_map(|c| kept(c, rect)).collect();
|
||||
return Some(Arc::new(layer));
|
||||
}
|
||||
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
|
||||
n.visual_bounds().is_none_or(over).then(|| n.clone())
|
||||
}
|
||||
let mut d = doc.clone();
|
||||
d.layers = doc.layers.iter().filter_map(|l| kept(l, rect)).collect();
|
||||
d
|
||||
}
|
||||
|
||||
/// `p` without its artboard choice, also inside its SVG options (for documents made of one
|
||||
/// synthetic artboard, and for callers that pick the artboard themselves).
|
||||
pub(super) fn without_artboards(p: &Value) -> Value {
|
||||
|
||||
@@ -466,8 +466,8 @@ pub const FORMATS: &[Format] = &[
|
||||
extensions: &["ai"],
|
||||
mime: "application/pdf",
|
||||
read: true,
|
||||
// Saved (Save As), not exported.
|
||||
write: false,
|
||||
// Save As writes it; exports write the same file.
|
||||
write: true,
|
||||
raster: false,
|
||||
options: native::AI_OPTIONS,
|
||||
},
|
||||
|
||||
@@ -127,6 +127,13 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->
|
||||
/// The `.vectorcraft` file of `doc` with the options in `p`.
|
||||
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
|
||||
let (mut so, extras) = save_options(cmd, f, doc, p)?;
|
||||
// Include Linked Files keeps placed documents' files; older versions get their art.
|
||||
let full = if so.include_linked || so.version < vectorcraft_format::VERSION {
|
||||
crate::cmd::place::document::full_documents(doc).0
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(doc)
|
||||
};
|
||||
let doc = &*full;
|
||||
if extras.preview {
|
||||
so.preview = preview_png(doc)?;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -216,15 +216,20 @@ fn is_svg(f: &Format) -> bool {
|
||||
matches!(f.id, "svg" | "svgz")
|
||||
}
|
||||
|
||||
/// The document as written: native files carry the view to reopen at.
|
||||
/// The document as written: native files carry the view to reopen at and the Layers panel's open
|
||||
/// rows.
|
||||
fn doc_to_save(st: &DocState, f: &Format) -> Arc<Document> {
|
||||
if is_native(f) && st.doc.last_view != st.view {
|
||||
let mut d = (*st.doc).clone();
|
||||
d.last_view = st.view.clone();
|
||||
Arc::new(d)
|
||||
} else {
|
||||
st.doc.clone()
|
||||
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 {
|
||||
return st.doc.clone();
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
d.last_view = st.view.clone();
|
||||
d.layers_open = open;
|
||||
Arc::new(d)
|
||||
}
|
||||
|
||||
/// What a format loses against a native file (reported whenever a save writes it).
|
||||
@@ -274,6 +279,7 @@ fn artboard_doc(doc: &Document, i: usize) -> Document {
|
||||
d.artboards = vec![a.clone()];
|
||||
// It opens fitted to its artboard, not at the master file's view.
|
||||
d.last_view = None;
|
||||
d.layers_open = None;
|
||||
for layer in &mut d.layers {
|
||||
keep_on(layer, r);
|
||||
}
|
||||
@@ -305,6 +311,40 @@ fn blank_pages(doc: &Document) -> Document {
|
||||
/// Why a `.ai` file saved without PDF content looks empty elsewhere.
|
||||
const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages";
|
||||
|
||||
/// The encoder's params for a `.ai` file, from a save's or an export's: a PDF of every artboard
|
||||
/// with the PDF options, always carrying the native document, its layers and sublayers PDF layers
|
||||
/// (hidden ones off) unless asked otherwise; Use Compression compresses the PDF's content.
|
||||
pub(super) fn ai_params(params: &mut Value) {
|
||||
let Some(o) = params.as_object_mut() else { return };
|
||||
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
|
||||
// Apps that read the PDF part (not the native document) then find the layers, the hidden ones
|
||||
// with their art (#372); a PDF standard decides for itself.
|
||||
if o.get("standard").is_none_or(Value::is_null) {
|
||||
o.entry("createLayers").or_insert(json!(true));
|
||||
}
|
||||
if let Some(c) = o.get("compress").and_then(Value::as_bool) {
|
||||
let compression = o.entry("compression").or_insert_with(|| json!({}));
|
||||
if let Some(m) = compression.as_object_mut() {
|
||||
m.insert("compressText".into(), json!(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode `doc` as a PDF-compatible `.ai` file (`params` as [`ai_params`] makes them) for command
|
||||
/// `cmd`: its pages (blank with `pdfCompatible: false`, which it then notes), carrying the native
|
||||
/// document. File › Save As writes it, and so do the exports (`document.export`, the CLI).
|
||||
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, params: &Value) -> Result<Encoded> {
|
||||
let compatible = bool_or(params, "pdfCompatible", true);
|
||||
let pages = doc.without_edit_modes();
|
||||
let pages = if compatible { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) };
|
||||
let native = || super::native::ai_native(cmd, f, doc, params);
|
||||
let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, params, native)?;
|
||||
if !compatible {
|
||||
warnings.insert(0, NOT_PDF_COMPATIBLE.to_string());
|
||||
}
|
||||
Ok(Encoded { warnings, ..Encoded::one(bytes) })
|
||||
}
|
||||
|
||||
/// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the
|
||||
/// document's own). Bad options fail here, before anything is encoded.
|
||||
pub fn save_job(s: &mut Session, plan: SavePlan) -> Result<SaveJob> {
|
||||
@@ -339,16 +379,8 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result<Save
|
||||
}
|
||||
let boards = separate_boards(cmd, plan.format, &doc, ¶ms)?;
|
||||
let mut params = super::pdf::expand_preset(s, cmd, ¶ms)?.into_owned();
|
||||
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
|
||||
// A PDF of every artboard with the PDF options, always carrying the native document.
|
||||
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
|
||||
// Use Compression compresses the PDF's content.
|
||||
if let Some(c) = o.get("compress").and_then(Value::as_bool) {
|
||||
let compression = o.entry("compression").or_insert_with(|| json!({}));
|
||||
if let Some(m) = compression.as_object_mut() {
|
||||
m.insert("compressText".into(), json!(c));
|
||||
}
|
||||
}
|
||||
if plan.format.id == "ai" {
|
||||
ai_params(&mut params);
|
||||
}
|
||||
Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone(), boards })
|
||||
}
|
||||
@@ -372,25 +404,14 @@ impl SaveJob {
|
||||
let one = self.encode_one(&artboard_doc(&self.doc, b))?;
|
||||
enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes)));
|
||||
}
|
||||
let pdf_less = self.plan.format.id == "ai" && !bool_or(&self.params, "pdfCompatible", true);
|
||||
let notes = fidelity_warning(self.plan.format).into_iter().chain(pdf_less.then(|| NOT_PDF_COMPATIBLE.to_string()));
|
||||
enc.warnings.splice(0..0, notes);
|
||||
enc.warnings.splice(0..0, fidelity_warning(self.plan.format));
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// Encode one file of `doc` in the job's format.
|
||||
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
|
||||
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
|
||||
let enc = if f.id == "ai" {
|
||||
// The pages (blank without PDF content), carrying the native document.
|
||||
let pages = doc.without_edit_modes();
|
||||
let pages = if bool_or(&self.params, "pdfCompatible", true) { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) };
|
||||
let native = || super::native::ai_native(cmd, f, doc, &self.params);
|
||||
let (bytes, warnings) = super::pdf::encode_carrying(cmd, &pages, &self.params, native)?;
|
||||
Encoded { warnings, ..Encoded::one(bytes) }
|
||||
} else {
|
||||
encode_all(doc, f.id, &self.params)?
|
||||
};
|
||||
let enc = if f.id == "ai" { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
|
||||
if enc.files.len() != 1 {
|
||||
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
|
||||
}
|
||||
@@ -450,12 +471,17 @@ impl SaveJob {
|
||||
/// its own also ends its Data Recovery copy.
|
||||
pub fn complete(self, s: &mut Session, uid: u64) {
|
||||
let saved = self.plan.retargets() && self.plan.path.is_some();
|
||||
// The other open documents that place this one show the new version.
|
||||
let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template"));
|
||||
if let Some(st) = s.document_mut(uid) {
|
||||
self.finish(st);
|
||||
}
|
||||
if saved {
|
||||
crate::cmd::recovery::forget(s, uid);
|
||||
}
|
||||
if let Some(path) = written {
|
||||
crate::cmd::links::refresh_placed(s, uid, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ impl Row {
|
||||
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
|
||||
}
|
||||
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
|
||||
if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
|
||||
if matches!(format.id, "vectorcraft" | "template" | "ai" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
|
||||
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf"));
|
||||
}
|
||||
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
|
||||
|
||||
@@ -19,7 +19,7 @@ pub const OPTIONS: &[FormatOption] = &[
|
||||
name: "useArtboards",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "false: one SVG of the bounds of all art instead of artboards (several artboards write one file each)",
|
||||
description: "false: one SVG of the bounds of all art instead of artboards (several artboards write one file each; a named artboard's file holds only the art over it)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "styling",
|
||||
@@ -70,7 +70,7 @@ pub const OPTIONS: &[FormatOption] = &[
|
||||
name: "hiddenLayers",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "keep hidden layers, not displayed (display:none); document.save sets it unless given",
|
||||
description: "keep hidden layers and objects, not displayed (display=\"none\"); document.save sets it unless given",
|
||||
},
|
||||
FormatOption {
|
||||
name: "encoding",
|
||||
@@ -125,8 +125,10 @@ pub fn options_map(p: &Value) -> Result<Map<String, Value>, String> {
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers.
|
||||
fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>), String> {
|
||||
/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers,
|
||||
/// and whether `p` named those artboards (else the first stands for the whole document, as Save
|
||||
/// writes it).
|
||||
fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>, bool), String> {
|
||||
let mut m = options_map(p)?;
|
||||
// Fonts: unless chosen, Document Setup → Type → Export decides (appearance = outlines).
|
||||
if !m.contains_key("outlineText") && doc.setup.export_text == vectorcraft_doc::ExportText::Appearance {
|
||||
@@ -138,21 +140,24 @@ fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>)
|
||||
opts.check().map_err(|e| format!("SVG options: {e}"))?;
|
||||
let n = doc.artboards.len();
|
||||
if n == 0 || boards.use_artboards == Some(false) {
|
||||
return Ok((opts, vec![None]));
|
||||
return Ok((opts, vec![None], false));
|
||||
}
|
||||
let picked = boards.pick.resolve(n)?.unwrap_or_else(|| vec![0]);
|
||||
Ok((opts, picked.into_iter().map(Some).collect()))
|
||||
let named = boards.pick.resolve(n)?;
|
||||
let chosen = named.is_some();
|
||||
Ok((opts, named.unwrap_or_else(|| vec![0]).into_iter().map(Some).collect(), chosen))
|
||||
}
|
||||
|
||||
/// Encode `doc` as SVG (or gzipped, SVGZ): one file per chosen artboard, with the images they
|
||||
/// link to and the writer's warnings.
|
||||
pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Encoded, String> {
|
||||
let (opts, boards) = plan(doc, p)?;
|
||||
let (opts, boards, chosen) = plan(doc, p)?;
|
||||
// Preserve editing embeds the native document (once, shared by every file).
|
||||
let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false));
|
||||
let mut enc = Encoded::default();
|
||||
for artboard in boards {
|
||||
let mut out = vectorcraft_svg::export_full(doc, &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
|
||||
// A chosen artboard's file holds the art over it (#550).
|
||||
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect));
|
||||
let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
|
||||
let bytes = out.take_bytes();
|
||||
for l in out.linked {
|
||||
if !enc.linked.iter().any(|e| e.name == l.name) {
|
||||
|
||||
@@ -173,6 +173,7 @@ fn export_extensions_name_every_writable_format() {
|
||||
"svg",
|
||||
"svgz",
|
||||
"pdf",
|
||||
"ai",
|
||||
"png",
|
||||
"jpg",
|
||||
"gif",
|
||||
@@ -206,6 +207,7 @@ fn formats_query_lists_readers_writers_and_options() {
|
||||
"svg",
|
||||
"svgz",
|
||||
"pdf",
|
||||
"ai",
|
||||
"png",
|
||||
"jpg",
|
||||
"gif",
|
||||
|
||||
@@ -118,6 +118,27 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() {
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
}
|
||||
|
||||
/// #526: the exports (`document.export`, the CLI's `--export` and `convert`) write `.ai` as Save
|
||||
/// As does, leaving the document's own path alone.
|
||||
#[test]
|
||||
fn exports_write_ai_as_save_as_does() {
|
||||
let mut s = rich();
|
||||
let before = comparable(&s.doc().unwrap().doc);
|
||||
let path = tmp("exported.ai");
|
||||
let r = s.execute("document.export", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["format"], "ai", "{r}");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF") && vectorcraft_pdf::editing(&bytes).is_some_and(|e| e.intact), "PDF-compatible, carrying the document");
|
||||
assert_eq!(s.doc().unwrap().path, None, "an export doesn't give the document a path");
|
||||
let r = s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(true)), "{r}");
|
||||
assert_eq!(comparable(&s.doc().unwrap().doc), before);
|
||||
// Its options as Save As takes them; no path gives the bytes.
|
||||
let r = s.execute("document.export", &json!({"format": "ai", "pdfCompatible": false})).unwrap();
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("without PDF content"))), "{r}");
|
||||
assert!(b64(&r).starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ait_templates_open_their_document_untitled() {
|
||||
let mut s = rich();
|
||||
|
||||
@@ -59,3 +59,35 @@ fn export_pdf_overprint_is_preserved_or_discarded() {
|
||||
assert_eq!(overprints(&mut s, json!({"compression": {"compressText": false}})), 1);
|
||||
assert_eq!(overprints(&mut s, json!({"advanced": {"overprint": "discard"}})), 0);
|
||||
}
|
||||
|
||||
/// A `.ai` file's PDF part, which apps that don't read the native document open, has the layers
|
||||
/// and sublayers as PDF layers, the hidden ones with their art (#372).
|
||||
#[test]
|
||||
fn ai_files_write_their_layers_and_sublayers_as_pdf_layers() {
|
||||
let mut s = session();
|
||||
let layer1 = s.execute("document.inspect", &json!({})).unwrap()["layers"][1]["id"].as_u64().unwrap();
|
||||
let sub = s.execute("layer.newSublayer", &json!({"parent": layer1, "name": "Sketch"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setCurrent", &json!({"id": sub})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": 10, "y": 40, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": sub, "visible": false})).unwrap();
|
||||
// Name, shown and what it holds (sublayers by name), top-level layers in paint order.
|
||||
fn rows(layers: &[std::sync::Arc<vectorcraft_doc::Node>]) -> Vec<(String, bool, Vec<String>)> {
|
||||
layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let held =
|
||||
l.children().into_iter().flatten().map(|c| c.name.clone().filter(|_| c.is_layer()).unwrap_or_else(|| "art".into())).collect();
|
||||
(l.name.clone().unwrap_or_default(), l.visible, held)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
let v = s.execute("document.export", &json!({"format": "ai"})).unwrap();
|
||||
let back = vectorcraft_pdf::import(&b64(&v)).unwrap();
|
||||
let art = || "art".to_string();
|
||||
assert_eq!(rows(&back.layers), [("Layer 1".to_string(), true, vec![art(), "Sketch".into()]), ("Notes".to_string(), false, vec![art()])]);
|
||||
let sketch = back.layers.first().and_then(|l| l.children()?.last().cloned()).unwrap();
|
||||
assert_eq!(rows(&[sketch]), [("Sketch".to_string(), false, vec![art()])]);
|
||||
// Asked not to, it writes none.
|
||||
let v = s.execute("document.export", &json!({"format": "ai", "createLayers": false})).unwrap();
|
||||
assert_eq!(vectorcraft_pdf::import(&b64(&v)).unwrap().layers.len(), 1);
|
||||
}
|
||||
|
||||
@@ -49,3 +49,28 @@ fn profile_and_embedded_fonts_reach_the_writer() {
|
||||
assert!(s.execute("document.serialize", &json!({"format": "svg", "svg": {"profile": "svg2"}})).is_err(), "unknown profiles are refused");
|
||||
assert!(s.execute("document.serialize", &json!({"format": "svg", "encoding": "ebcdic"})).is_err(), "unknown encodings are refused");
|
||||
}
|
||||
|
||||
/// #550: an SVG of chosen artboards holds the art over each, not every artboard's art; with no
|
||||
/// artboard named (as Save writes it) the whole document's art is there.
|
||||
#[test]
|
||||
fn a_chosen_artboard_s_svg_holds_only_its_own_art() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 3})).unwrap();
|
||||
let x = |s: &Session, b: usize| s.doc().unwrap().doc.artboards[b].rect.x0;
|
||||
let (first, third) = (x(&s, 0), x(&s, 2));
|
||||
s.execute("shape.rectangle", &json!({"x": first + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": third + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
// A rectangle has straight sides only; the ellipse is curves.
|
||||
let shapes = |svg: &str| (svg.matches("<path").count() + svg.matches("<rect").count(), svg.contains(" C") || svg.contains("<ellipse"));
|
||||
let text = |v: &serde_json::Value| v["text"].as_str().unwrap().to_string();
|
||||
let third_only = text(&s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "3"})).unwrap());
|
||||
assert_eq!(shapes(&third_only), (1, true), "{third_only}");
|
||||
let all = s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "all"})).unwrap();
|
||||
let files: Vec<String> = all["files"].as_array().unwrap().iter().map(text).collect();
|
||||
assert_eq!(files.iter().map(|f| shapes(f)).collect::<Vec<_>>(), [(1, false), (0, false), (1, true)]);
|
||||
// Export for Screens names one artboard at a time the same way.
|
||||
let screen = text(&s.execute("document.serialize", &json!({"format": "svg", "artboard": 0})).unwrap());
|
||||
assert_eq!(shapes(&screen), (1, false));
|
||||
let whole = text(&s.execute("document.serialize", &json!({"format": "svg"})).unwrap());
|
||||
assert_eq!(shapes(&whole).0, 2, "nothing named: the whole document's art");
|
||||
}
|
||||
|
||||
@@ -1185,6 +1185,8 @@ impl Scratch {
|
||||
let art = self.doc.symbols.iter().find(|s| &s.name == symbol).map(|s| vectorcraft_render::instance_art(&s.art, n));
|
||||
art.and_then(|a| effects::outline_art(n, Some(&a))).map(|g| carry(n, g))
|
||||
}
|
||||
// A placed document stays one: its art's resources aren't the document's.
|
||||
NodeKind::PlacedDocument(_) => None,
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &effects::outline_text;
|
||||
Some(carry(n, vectorcraft_doc::live::expanded_group(n, Some(hook))))
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Inline graphics in text: document symbols placed in a run of text, flowing like a glyph
|
||||
//! (beyond Illustrator; InDesign's inline anchored objects). See [`vectorcraft_doc::InlineArt`].
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::text::INLINE_MAX_SCALE;
|
||||
use vectorcraft_doc::{Document, InlineArt, NodeId, NodeKind, TextRun};
|
||||
use vectorcraft_text::edit;
|
||||
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"text.insertInline",
|
||||
"Insert Inline Symbol",
|
||||
["Type"],
|
||||
None,
|
||||
"{id?: text id (default: the text the Type tool edits), at?: byte offset (default: the Type tool's selection, which it replaces; clamped to the text), symbol?: symbol name (default: the Symbols panel's current symbol), scale?: art height ÷ font size = 1 (0 < scale ≤ 100), shift?: pt raise = 0} place a document symbol inline in the text as one character (U+FFFC) that flows like a glyph: its art is scaled to `scale` × the font size tall, its vertical centre on the middle of the cap height raised by `shift`. Typing, Backspace and Delete over it remove it whole → {id, caret}",
|
||||
has_doc,
|
||||
insert_inline
|
||||
)]
|
||||
}
|
||||
|
||||
/// Inline graphics follow their symbols: after an edit of a document that has (or had) symbols,
|
||||
/// resolve the inline art of every text again (only texts whose art changed are touched).
|
||||
pub(crate) fn refresh(before: &Document, d: &mut Document) {
|
||||
if before.symbols.is_empty() && d.symbols.is_empty() {
|
||||
return;
|
||||
}
|
||||
for id in d.resolve_inline_art() {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_inline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.insertInline";
|
||||
let tool = s.tool_options();
|
||||
let editing = tool.get("editing").and_then(Value::as_u64).map(NodeId);
|
||||
let id = match p.get("id") {
|
||||
None | Some(Value::Null) => editing.ok_or_else(|| bad(C, "give `id` (no text is being edited)"))?,
|
||||
Some(_) => id_param(p, "id").ok_or_else(|| bad(C, "`id` must be a node id"))?,
|
||||
};
|
||||
let doc = &s.doc()?.doc;
|
||||
let NodeKind::Text(t) = &doc.node(id).ok_or(EngineError::NoNode(id))?.kind else {
|
||||
return Err(bad(C, format!("node {} is not text", id.0)));
|
||||
};
|
||||
let len = edit::runs_len(&t.runs);
|
||||
let from_tool = editing == Some(id);
|
||||
let (a, b) = match p.get("at") {
|
||||
None | Some(Value::Null) if from_tool => {
|
||||
let g = |k: &str| tool.get(k).and_then(Value::as_u64).map_or(len, |v| (v as usize).min(len));
|
||||
let (x, y) = (g("start"), g("end"));
|
||||
(x.min(y), x.max(y))
|
||||
}
|
||||
None | Some(Value::Null) => (len, len),
|
||||
Some(v) => {
|
||||
let at = v.as_u64().ok_or_else(|| bad(C, "`at` must be a byte offset"))?.min(len as u64) as usize;
|
||||
(at, at)
|
||||
}
|
||||
};
|
||||
let symbol = match str_param(p, "symbol") {
|
||||
Some(n) if doc.symbols.iter().any(|s| s.name == n) => n.to_string(),
|
||||
Some(n) => return Err(bad(C, format!("no symbol named `{n}`"))),
|
||||
None if p.get("symbol").is_some_and(|v| !v.is_null()) => return Err(bad(C, "`symbol` must be a symbol name")),
|
||||
None => current_symbol(doc).ok_or_else(|| EngineError::Other("the document has no symbols".into()))?,
|
||||
};
|
||||
let scale = match p.get("scale") {
|
||||
None | Some(Value::Null) => 1.0,
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|k| k.is_finite() && *k > 0.0 && *k <= INLINE_MAX_SCALE)
|
||||
.ok_or_else(|| bad(C, "`scale` must be a number in (0, 100]"))?,
|
||||
};
|
||||
let shift = match p.get("shift") {
|
||||
None | Some(Value::Null) => 0.0,
|
||||
Some(v) => v.as_f64().filter(|k| k.is_finite() && k.abs() <= 1e5).ok_or_else(|| bad(C, "`shift` must be a number of points"))?,
|
||||
};
|
||||
// A typing session ends first: the insertion is a step of its own.
|
||||
let typing = tool.get("typing").and_then(Value::as_bool) == Some(true);
|
||||
if typing && from_tool {
|
||||
s.set_tool_option("commitTyping", &Value::Bool(true));
|
||||
s.commit_interaction()?;
|
||||
}
|
||||
let caret = s.edit("Insert Inline Symbol", |d, _| {
|
||||
let mut art = InlineArt { scale, baseline_shift: shift, ..InlineArt::new(symbol.clone()) };
|
||||
art.bounds = d.symbol_art_bounds(&symbol);
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let style = edit::insertion_style(&t.runs, a, b);
|
||||
let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]);
|
||||
refresh_bounds(t);
|
||||
Ok(caret)
|
||||
})?;
|
||||
if from_tool {
|
||||
s.set_tool_option("select", &json!({"start": caret, "end": caret}));
|
||||
}
|
||||
Ok(json!({"id": id.0, "caret": caret}))
|
||||
}
|
||||
|
||||
/// The Symbols panel's current symbol, else the first one.
|
||||
fn current_symbol(d: &Document) -> Option<String> {
|
||||
d.unknown
|
||||
.get("currentSymbol")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|n| d.symbols.iter().any(|s| s.name == *n))
|
||||
.map(str::to_string)
|
||||
.or_else(|| d.symbols.first().map(|s| s.name.clone()))
|
||||
}
|
||||
+364
-118
@@ -16,13 +16,17 @@
|
||||
//! selects one (`links.goTo`), Embed keeps a linked file's pixels in the document
|
||||
//! (`links.embed`) and Unembed writes an embedded image to a file it then links to
|
||||
//! (`links.unembed`).
|
||||
//!
|
||||
//! Placed documents ([`PlacedDocument`], VectorCraft documents placed with Link) link to their
|
||||
//! files the same way: checking, updating, relinking and finding them on open work alike, and they
|
||||
//! are listed. Embed (Break Link) turns one into an editable copy of its art.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Component, Path};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacementOptions};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacedDocument, PlacementOptions};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
|
||||
@@ -35,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Check Links",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image ids] (default: every linked image)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}",
|
||||
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}",
|
||||
has_doc,
|
||||
check
|
||||
),
|
||||
@@ -44,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Update Links",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image ids] (default: the linked images whose file was modified or is showing its preview)} read their linked files again; each image keeps its bounds. One undo step → {updated: [ids], missing: [ids]}",
|
||||
"{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. One undo step → {updated: [ids], missing: [ids]}",
|
||||
has_doc,
|
||||
update
|
||||
),
|
||||
@@ -53,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Relink",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image ids] (default: the selected images; with folder and nothing selected, every missing link), path | folder} link images to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names]}",
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names]}",
|
||||
has_doc,
|
||||
relink
|
||||
),
|
||||
@@ -62,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Links",
|
||||
[],
|
||||
None,
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order, top first} every image object in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview)}], missing, modified, embedded}",
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order, top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
@@ -71,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Go To Link",
|
||||
[],
|
||||
None,
|
||||
"{id?} select image `id` (default: the first selected image); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}",
|
||||
"{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}",
|
||||
has_doc,
|
||||
go_to
|
||||
),
|
||||
@@ -80,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Embed Image",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image ids] (default: the selected linked images)} keep the linked files' pixels in the document and drop the links (an image whose file can't be found and that shows the saved preview stays linked: relink it first). One undo step → {embedded: [ids], missing: [ids]}",
|
||||
"{ids?: [image or placed document ids] (default: the selected linked ones)} break the links (the Links panel's Break Link): images keep the linked files' pixels in the document; a placed document becomes an editable copy of its art, as placing its file without link gives (its symbols, patterns, swatches and images joining the document). An image whose file can't be found and that shows the saved preview, or a placed document whose file can't be read or changed, stays linked: relink or update it first. One undo step → {embedded: [ids], missing: [ids]}",
|
||||
has_doc,
|
||||
embed
|
||||
),
|
||||
@@ -98,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Link Info",
|
||||
[],
|
||||
None,
|
||||
"{id?} an image's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch)",
|
||||
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -107,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Placement Options",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image ids] (default: the selected images), preserve?: transforms|bounds (default)|fileDimensions|fit|fill, align?: topLeft|top|topRight|left|center (default)|right|bottomLeft|bottom|bottomRight (where the new art sits; not for bounds), clip?: false (clip it to the old bounds where it is larger)} how a file read again (links.relink, links.update) takes each image's place: transforms keeps its scale (relative to each file's 100% size), rotation and position; bounds stretches it into the old bounds; fileDimensions puts it at 100%, unrotated; fit and fill scale it proportionally to fit inside or cover the old bounds. Without options → {placement} of the first image; else one undo step → {ids, placement}",
|
||||
"{ids?: [image or placed document ids] (default: the selected ones), preserve?: transforms|bounds (default)|fileDimensions|fit|fill, align?: topLeft|top|topRight|left|center (default)|right|bottomLeft|bottom|bottomRight (where the new art sits; not for bounds), clip?: false (clip it to the old bounds where it is larger)} how a file read again (links.relink, links.update) takes each image's place: transforms keeps its scale (relative to each file's 100% size), rotation and position; bounds stretches it into the old bounds; fileDimensions puts it at 100%, unrotated; fit and fill scale it proportionally to fit inside or cover the old bounds. Without options → {placement} of the first image; else one undo step → {ids, placement}",
|
||||
has_doc,
|
||||
placement_options
|
||||
),
|
||||
@@ -134,22 +138,34 @@ impl Status {
|
||||
}
|
||||
}
|
||||
|
||||
/// The image objects that show one linked file as one content (same path, same image).
|
||||
/// The image objects that show one linked file as one content (same path, same image), or the
|
||||
/// placed documents that show one artboard of it.
|
||||
struct Group {
|
||||
link: LinkInfo,
|
||||
key: String,
|
||||
/// The objects in the layers (images in symbols and patterns share the content, not an id).
|
||||
ids: Vec<NodeId>,
|
||||
/// Placed documents: whether the art's bounds (not the artboard) are shown.
|
||||
document: Option<bool>,
|
||||
}
|
||||
|
||||
/// The linked images of `d` by file and content: those in the layers (only `ids` when given), then,
|
||||
/// without `ids`, the ones only symbols and patterns show.
|
||||
/// The linked object `n` as (link, content key, placed document's bounding).
|
||||
fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<bool>)> {
|
||||
match &n.kind {
|
||||
NodeKind::Image(im) => Some((im.link.as_ref()?, &im.key, None)),
|
||||
NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some(p.bounding))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when
|
||||
/// given), then, without `ids`, the ones only symbols and patterns show.
|
||||
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
||||
let mut out: Vec<Group> = vec![];
|
||||
let mut index: BTreeMap<(String, String), usize> = BTreeMap::new();
|
||||
let mut add = |link: &LinkInfo, key: &str, id: Option<NodeId>| {
|
||||
let i = *index.entry((link.path.clone(), key.to_string())).or_insert_with(|| {
|
||||
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![] });
|
||||
let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new();
|
||||
let mut add = |link: &LinkInfo, key: &str, document: Option<bool>, id: Option<NodeId>| {
|
||||
let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| {
|
||||
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document });
|
||||
out.len() - 1
|
||||
});
|
||||
if let (Some(id), Some(g)) = (id, out.get_mut(i)) {
|
||||
@@ -157,19 +173,19 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
||||
}
|
||||
};
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& let Some(l) = &im.link
|
||||
if let Some((l, key, document)) = linked(n)
|
||||
&& ids.is_none_or(|ids| ids.contains(&n.id))
|
||||
{
|
||||
add(l, &im.key, Some(n.id));
|
||||
add(l, key, document, Some(n.id));
|
||||
}
|
||||
});
|
||||
if ids.is_none() {
|
||||
d.visit_images(|_, im| {
|
||||
if let Some(l) = &im.link {
|
||||
add(l, &im.key, None);
|
||||
add(l, &im.key, None, None);
|
||||
}
|
||||
});
|
||||
d.visit_placed(|_, p| add(&p.link, &p.key, Some(p.bounding), None));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -238,7 +254,7 @@ fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option<Found>) {
|
||||
let same = match &l.hash {
|
||||
Some(h) => *h == hash_bytes(&bytes),
|
||||
// Linked before links kept a hash: the image the file decodes to.
|
||||
None => fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key),
|
||||
None => g.document.is_none() && fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key),
|
||||
};
|
||||
f.bytes = Some(bytes);
|
||||
(if same { Status::Ok } else { Status::Modified }, Some(f))
|
||||
@@ -254,6 +270,15 @@ fn previewing(d: &Document, key: &str) -> bool {
|
||||
d.images.get(key).is_none_or(ImageBlob::is_proxy)
|
||||
}
|
||||
|
||||
/// Does group `g` of `d` show something other than its file: an image its preview, a placed
|
||||
/// document nothing (its file's bytes are gone)?
|
||||
fn stale(d: &Document, g: &Group) -> bool {
|
||||
match g.document {
|
||||
None => previewing(d, &g.key),
|
||||
Some(_) => !d.images.contains_key(&g.key),
|
||||
}
|
||||
}
|
||||
|
||||
/// A linked file of a document (File → Package): its path, name and bytes.
|
||||
pub(crate) struct LinkedFile {
|
||||
pub path: String,
|
||||
@@ -301,6 +326,51 @@ fn read_image(f: &mut Found, base: &LinkInfo) -> Result<FileRead> {
|
||||
Ok((f.link(&bytes, base), fileio::raster_image(&bytes)?))
|
||||
}
|
||||
|
||||
/// A linked file read for the objects that show it.
|
||||
enum Read {
|
||||
Image(FileRead),
|
||||
/// A VectorCraft document read for placed documents: the link to it and what they show.
|
||||
Document(LinkInfo, super::place::document::Source),
|
||||
}
|
||||
|
||||
/// The file at `f` read for group `g`: its image, or the artboard `g` shows.
|
||||
fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result<Read> {
|
||||
let Some(bounding) = g.document else { return read_image(f, &g.link).map(Read::Image) };
|
||||
let bytes = f.read()?;
|
||||
let page = g.link.page.unwrap_or(1);
|
||||
let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, cmd)?;
|
||||
Ok(Read::Document(f.link(&bytes, &g.link), src))
|
||||
}
|
||||
|
||||
/// Objects `ids` show what was read (see [`install`], [`install_document`]).
|
||||
fn install_read(d: &mut Document, ids: &[NodeId], read: Read, keep_bounds: bool) -> Result<()> {
|
||||
match read {
|
||||
Read::Image(r) => install(d, ids, r, keep_bounds),
|
||||
Read::Document(link, src) => install_document(d, ids, &link, &src, keep_bounds),
|
||||
}
|
||||
}
|
||||
|
||||
/// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its
|
||||
/// Placement Options say (`keep_bounds`: in its bounds, whatever they say).
|
||||
fn install_document(d: &mut Document, ids: &[NodeId], link: &LinkInfo, src: &super::place::document::Source, keep_bounds: bool) -> Result<()> {
|
||||
let mut clips = vec![];
|
||||
let (w, h) = (src.frame.width(), src.frame.height());
|
||||
for id in ids {
|
||||
let Some(NodeKind::PlacedDocument(p)) = d.node(*id).map(|n| &n.kind) else { continue };
|
||||
let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..p.placement } } else { p.placement };
|
||||
let (xf, clip) = placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o);
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& let NodeKind::PlacedDocument(p) = &mut n.kind
|
||||
{
|
||||
let p: &mut PlacedDocument = p;
|
||||
(p.key, p.width, p.height, p.xf, p.link) = (src.key.clone(), w, h, xf, link.clone());
|
||||
}
|
||||
clips.extend(clip.map(|c| (*id, c)));
|
||||
}
|
||||
super::place::document::store(d, src);
|
||||
clips.into_iter().try_for_each(|(id, clip)| clip_image(d, id, clip))
|
||||
}
|
||||
|
||||
/// Points per pixel of the file image `key` of `d` shows, at the resolution it declares (72 ppi
|
||||
/// when it declares none, or when only its preview is loaded).
|
||||
fn file_pt(d: &Document, key: &str) -> (f64, f64) {
|
||||
@@ -318,9 +388,10 @@ type Clip = (Affine, (f64, f64));
|
||||
|
||||
/// The transform an image placed with `xf` showing `old` pixels (`old_pt`: points per pixel at
|
||||
/// 100%) gives a new file of `new` pixels (`new_pt`), as Placement Options `o` say; and the clip
|
||||
/// when `o.clip` and the new art overflows the old bounds.
|
||||
fn placed_xf(xf: Affine, old: (u32, u32), old_pt: (f64, f64), new: (u32, u32), new_pt: (f64, f64), o: PlacementOptions) -> (Affine, Option<Clip>) {
|
||||
let (ow, oh, nw, nh) = (old.0 as f64, old.1 as f64, new.0.max(1) as f64, new.1.max(1) as f64);
|
||||
/// when `o.clip` and the new art overflows the old bounds. (A placed document's "pixels" are points,
|
||||
/// one point each.)
|
||||
fn placed_xf(xf: Affine, old: (f64, f64), old_pt: (f64, f64), new: (f64, f64), new_pt: (f64, f64), o: PlacementOptions) -> (Affine, Option<Clip>) {
|
||||
let (ow, oh, nw, nh) = (old.0, old.1, new.0.max(1e-9), new.1.max(1e-9));
|
||||
let [a, b, c, d, ..] = xf.as_coeffs();
|
||||
// The bounds' frame: the transform without its scale (rotation, reflection and position).
|
||||
let (su, sv) = (a.hypot(b), c.hypot(d));
|
||||
@@ -388,7 +459,8 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, keep_bounds:
|
||||
for id in ids {
|
||||
let Some(NodeKind::Image(im)) = d.node(*id).map(|n| &n.kind) else { continue };
|
||||
let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..im.placement } } else { im.placement };
|
||||
let (xf, clip) = placed_xf(im.xf, (im.width, im.height), file_pt(d, &im.key), (img.width, img.height), new_pt, o);
|
||||
let size = |w: u32, h: u32| (w as f64, h as f64);
|
||||
let (xf, clip) = placed_xf(im.xf, size(im.width, im.height), file_pt(d, &im.key), size(img.width, img.height), new_pt, o);
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& let NodeKind::Image(im) = &mut n.kind
|
||||
{
|
||||
@@ -436,11 +508,34 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv
|
||||
let row = rows(std::iter::once(&g));
|
||||
match probe(&g, folder(doc_path)) {
|
||||
(Status::Missing, _) => out.missing.extend(row),
|
||||
(Status::Modified, Some(mut f)) if update => match read_image(&mut f, &g.link).and_then(|read| install(d, &g.ids, read, false)) {
|
||||
Ok(()) => out.updated.extend(row),
|
||||
Err(_) => out.missing.extend(row),
|
||||
},
|
||||
(Status::Modified, Some(mut f)) if update => {
|
||||
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, false)) {
|
||||
Ok(()) => out.updated.extend(row),
|
||||
Err(_) => out.missing.extend(row),
|
||||
}
|
||||
}
|
||||
(Status::Modified, _) => out.modified.extend(row),
|
||||
// A placed document keeps its file's bytes: only lost ones are read again.
|
||||
(Status::Ok, Some(mut f)) if g.document.is_some() => {
|
||||
let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None };
|
||||
match read {
|
||||
Some(Ok(read)) => {
|
||||
if install_read(d, &g.ids, read, true).is_err() {
|
||||
out.missing.extend(row);
|
||||
}
|
||||
}
|
||||
Some(Err(_)) => out.missing.extend(row),
|
||||
None => {
|
||||
let link = match f.bytes.take() {
|
||||
Some(b) => f.link(&b, &g.link),
|
||||
None => LinkInfo { path: f.path.clone(), ..g.link.clone() },
|
||||
};
|
||||
if link != g.link {
|
||||
set_links(d, &g.ids, &link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(Status::Ok, Some(mut f)) => {
|
||||
let preview = previewing(d, &g.key);
|
||||
// Read when the images show the preview, or when the file's details changed.
|
||||
@@ -471,17 +566,58 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv
|
||||
out
|
||||
}
|
||||
|
||||
/// Image objects `ids` link to `link`.
|
||||
/// The link of linked object `n` (a linked image or a placed document) to change.
|
||||
fn link_mut(n: &mut Node) -> Option<&mut LinkInfo> {
|
||||
match &mut n.kind {
|
||||
NodeKind::Image(im) => im.link.as_mut(),
|
||||
NodeKind::PlacedDocument(p) => Some(&mut p.link),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Linked image objects (or placed documents) `ids` link to `link`.
|
||||
fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) {
|
||||
for id in ids {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& let NodeKind::Image(im) = &mut n.kind
|
||||
{
|
||||
im.link = Some(link.clone());
|
||||
if let Some(l) = d.node_mut(*id).and_then(link_mut) {
|
||||
*l = link.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open
|
||||
/// documents: read again from the new file, each as one Update Links step of its document.
|
||||
pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) {
|
||||
let path = absolute_path(path);
|
||||
let targets: Vec<(usize, Vec<u64>)> = s
|
||||
.documents()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, st)| st.uid != saved)
|
||||
.filter_map(|(i, st)| {
|
||||
let mut ids = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if let NodeKind::PlacedDocument(p) = &n.kind
|
||||
&& p.link.path == path
|
||||
{
|
||||
ids.push(n.id.0);
|
||||
}
|
||||
});
|
||||
(!ids.is_empty()).then_some((i, ids))
|
||||
})
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Each document is updated as the active one, without switching tools.
|
||||
let back = s.active;
|
||||
for (i, ids) in targets {
|
||||
s.active = Some(i);
|
||||
// A file that can't be read leaves what its objects show: links.check reports it.
|
||||
let _ = update(s, &json!({ "ids": ids }));
|
||||
}
|
||||
s.active = back;
|
||||
}
|
||||
|
||||
// ---------- relative paths ----------
|
||||
|
||||
/// `target` relative to folder `base` with `/` separators; `None` when they share no root (another
|
||||
@@ -509,9 +645,7 @@ pub fn with_relative_paths(d: &Document, dest: &str) -> Option<Document> {
|
||||
let dir = Path::new(&dest).parent()?;
|
||||
let mut changes = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& let Some(l) = &im.link
|
||||
{
|
||||
if let Some((l, _, _)) = linked(n) {
|
||||
let r = relative_path(Path::new(&l.path), dir);
|
||||
if r != l.relative {
|
||||
changes.push((n.id, r));
|
||||
@@ -523,10 +657,7 @@ pub fn with_relative_paths(d: &Document, dest: &str) -> Option<Document> {
|
||||
}
|
||||
let mut d = d.clone();
|
||||
for (id, r) in changes {
|
||||
if let Some(n) = d.node_mut(id)
|
||||
&& let NodeKind::Image(im) = &mut n.kind
|
||||
&& let Some(l) = &mut im.link
|
||||
{
|
||||
if let Some(l) = d.node_mut(id).and_then(link_mut) {
|
||||
l.relative = r;
|
||||
}
|
||||
}
|
||||
@@ -548,7 +679,7 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = found.filter(|f| f.path != g.link.path) {
|
||||
row["found"] = json!(f.path);
|
||||
}
|
||||
if previewing(&st.doc, &g.key) {
|
||||
if stale(&st.doc, g) {
|
||||
row["preview"] = json!(true);
|
||||
}
|
||||
links.push(row);
|
||||
@@ -564,9 +695,9 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match probe(&g, folder(st.path.as_deref())) {
|
||||
(Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)),
|
||||
// Unchanged and showing the file: nothing to read unless asked for by id.
|
||||
(Status::Ok, Some(_)) if ids.is_none() && !previewing(&st.doc, &g.key) => {}
|
||||
// Unreadable as an image counts as missing.
|
||||
(_, Some(mut f)) => match read_image(&mut f, &g.link) {
|
||||
(Status::Ok, Some(_)) if ids.is_none() && !stale(&st.doc, &g) => {}
|
||||
// Unreadable as an image (or a document) counts as missing.
|
||||
(_, Some(mut f)) => match read_for(&mut f, &g, "links.update") {
|
||||
Ok(read) => reads.push((g.ids, read)),
|
||||
Err(_) => missing.extend(ids_json(&g.ids)),
|
||||
},
|
||||
@@ -574,7 +705,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let updated: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
|
||||
if !reads.is_empty() {
|
||||
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install(d, &ids, read, false)))?;
|
||||
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, false)))?;
|
||||
}
|
||||
Ok(json!({ "updated": updated, "missing": missing }))
|
||||
}
|
||||
@@ -583,7 +714,14 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.relink";
|
||||
let st = s.doc()?;
|
||||
let folder_param = str_param(p, "folder");
|
||||
let image = |id: &NodeId| st.doc.node(*id).and_then(|n| if let NodeKind::Image(im) = &n.kind { Some(im) } else { None });
|
||||
// An image or a placed document, with its link.
|
||||
let image = |id: &NodeId| {
|
||||
st.doc.node(*id).and_then(|n| match &n.kind {
|
||||
NodeKind::Image(im) => Some(im.link.as_ref()),
|
||||
NodeKind::PlacedDocument(p) => Some(Some(&p.link)),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let ids: Vec<NodeId> = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => {
|
||||
@@ -599,7 +737,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
if let Some(id) = ids.iter().find(|id| image(id).is_none()) {
|
||||
return Err(bad(C, format!("object {} is not an image", id.0)));
|
||||
return Err(bad(C, format!("object {} is not an image or a linked file", id.0)));
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select the images to relink, or pass ids"));
|
||||
@@ -611,7 +749,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(Some(path), _) => files.push((ids, absolute_path(path))),
|
||||
(None, Some(dir)) => {
|
||||
for id in ids {
|
||||
let Some(link) = image(&id).and_then(|im| im.link.as_ref()) else { continue };
|
||||
let Some(link) = image(&id).flatten() else { continue };
|
||||
let path = absolute_path(&Path::new(dir).join(link.name()).to_string_lossy());
|
||||
match files.iter_mut().find(|(_, p)| *p == path) {
|
||||
Some((ids, _)) => ids.push(id),
|
||||
@@ -625,12 +763,34 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut reads = vec![];
|
||||
for (ids, path) in files {
|
||||
let bytes = read_file(&path)?;
|
||||
let img = fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?;
|
||||
reads.push((ids, (link_info(&path, &bytes), img)));
|
||||
let link = link_info(&path, &bytes);
|
||||
// Images show the file; placed documents each show their artboard of it.
|
||||
let mut images = vec![];
|
||||
let mut documents: Vec<((u32, bool), Vec<NodeId>)> = vec![];
|
||||
for id in ids {
|
||||
match st.doc.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::PlacedDocument(pl)) => {
|
||||
let k = (pl.page(), pl.bounding);
|
||||
match documents.iter_mut().find(|(e, _)| *e == k) {
|
||||
Some((_, ids)) => ids.push(id),
|
||||
None => documents.push((k, vec![id])),
|
||||
}
|
||||
}
|
||||
_ => images.push(id),
|
||||
}
|
||||
}
|
||||
if !images.is_empty() {
|
||||
let img = fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?;
|
||||
reads.push((images, Read::Image((link.clone(), img))));
|
||||
}
|
||||
for ((page, bounding), ids) in documents {
|
||||
let src = super::place::document::source(&path, &bytes, Some(&path), page, bounding, C)?;
|
||||
reads.push((ids, Read::Document(LinkInfo { page: Some(page), ..link.clone() }, src)));
|
||||
}
|
||||
}
|
||||
let relinked: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
|
||||
if !reads.is_empty() {
|
||||
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install(d, &ids, read, false)))?;
|
||||
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, false)))?;
|
||||
}
|
||||
not_found.sort();
|
||||
not_found.dedup();
|
||||
@@ -652,18 +812,6 @@ fn selected_images(st: &crate::DocState, keep: impl Fn(&ImageObject) -> bool) ->
|
||||
st.selection.objects.iter().copied().filter(|id| image_of(&st.doc, *id).is_some_and(&keep)).collect()
|
||||
}
|
||||
|
||||
/// `ids` (else the selected images `keep` accepts), checked to be images; an error when none.
|
||||
fn images_param(st: &crate::DocState, p: &Value, cmd: &str, keep: impl Fn(&ImageObject) -> bool) -> Result<Vec<NodeId>> {
|
||||
let ids = ids_param(p, "ids").unwrap_or_else(|| selected_images(st, keep));
|
||||
if let Some(id) = ids.iter().find(|id| image_of(&st.doc, **id).is_none()) {
|
||||
return Err(bad(cmd, format!("object {} is not an image", id.0)));
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select the images, or pass ids"));
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// `id`, else the first selected image.
|
||||
fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
let id = id_param(p, "id").or_else(|| selected_images(st, |_| true).first().copied()).ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
|
||||
@@ -674,6 +822,35 @@ fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Placement Options of image or placed document `n`.
|
||||
fn placement_mut(n: &mut Node) -> Option<&mut PlacementOptions> {
|
||||
match &mut n.kind {
|
||||
NodeKind::Image(im) => Some(&mut im.placement),
|
||||
NodeKind::PlacedDocument(p) => Some(&mut p.placement),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Placement Options of image or placed document `id` of `d`.
|
||||
fn placement_of(d: &Document, id: NodeId) -> Option<PlacementOptions> {
|
||||
match &d.node(id)?.kind {
|
||||
NodeKind::Image(im) => Some(im.placement),
|
||||
NodeKind::PlacedDocument(p) => Some(p.placement),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `id`, else the first selected image or placed document.
|
||||
fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
let first = || st.selection.objects.iter().copied().find(|id| placement_of(&st.doc, *id).is_some());
|
||||
let id = id_param(p, "id").or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
|
||||
match placement_of(&st.doc, id) {
|
||||
Some(_) => Ok(id),
|
||||
None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)),
|
||||
None => Err(bad(cmd, format!("object {} is not an image or a linked file", id.0))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per linked image in the layers of `st`: its file's status, where it was found when away from
|
||||
/// its path, and whether the image shows its preview (each file probed once).
|
||||
fn statuses(st: &crate::DocState) -> BTreeMap<NodeId, (Status, Option<String>, bool)> {
|
||||
@@ -681,7 +858,7 @@ fn statuses(st: &crate::DocState) -> BTreeMap<NodeId, (Status, Option<String>, b
|
||||
for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) {
|
||||
let (status, found) = probe(g, folder(st.path.as_deref()));
|
||||
let found = found.map(|f| f.path).filter(|p| *p != g.link.path);
|
||||
let preview = previewing(&st.doc, &g.key);
|
||||
let preview = stale(&st.doc, g);
|
||||
out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview))));
|
||||
}
|
||||
out
|
||||
@@ -696,6 +873,12 @@ fn format_label(d: &Document, im: &ImageObject) -> &'static str {
|
||||
.map_or("Image", |f| f.label)
|
||||
}
|
||||
|
||||
/// A placed document's `links.list` fields: `format`, `document: true` and its artboard's size
|
||||
/// (`pageWidth`, `pageHeight`, pt) instead of pixels.
|
||||
fn document_row(p: &PlacedDocument) -> Value {
|
||||
json!({ "format": "VectorCraft", "document": true, "pageWidth": p.width, "pageHeight": p.height })
|
||||
}
|
||||
|
||||
/// Sort rank of a `links.list` status: missing, modified, ok, embedded.
|
||||
fn status_rank(status: &str) -> usize {
|
||||
["missing", "modified", "ok", "embedded"].iter().position(|s| *s == status).unwrap_or(4)
|
||||
@@ -709,17 +892,17 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded")));
|
||||
}
|
||||
let statuses = statuses(st);
|
||||
let mut images = vec![];
|
||||
let mut objects = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
images.push((n, im));
|
||||
if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) {
|
||||
objects.push(n);
|
||||
}
|
||||
});
|
||||
// Top first, as the Layers panel lists them.
|
||||
images.reverse();
|
||||
objects.reverse();
|
||||
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
let mut rows = vec![];
|
||||
for (n, im) in images {
|
||||
for n in objects {
|
||||
let (status, found, preview) = match statuses.get(&n.id) {
|
||||
Some((s, f, p)) => (s.as_str(), f.as_deref(), *p),
|
||||
None => ("embedded", None, false),
|
||||
@@ -728,17 +911,21 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if show != "all" && show != status {
|
||||
continue;
|
||||
}
|
||||
let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string());
|
||||
let mut row = json!({
|
||||
"id": n.id.0,
|
||||
"name": name,
|
||||
"linked": im.link.is_some(),
|
||||
"status": status,
|
||||
"format": format_label(&st.doc, im),
|
||||
"pixelWidth": im.width,
|
||||
"pixelHeight": im.height,
|
||||
});
|
||||
if let Some(l) = &im.link {
|
||||
let (link, mut row) = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
(im.link.as_ref(), json!({ "format": format_label(&st.doc, im), "pixelWidth": im.width, "pixelHeight": im.height }))
|
||||
}
|
||||
NodeKind::PlacedDocument(p) => (Some(&p.link), document_row(p)),
|
||||
_ => continue,
|
||||
};
|
||||
let name = link.map_or_else(|| n.display_name(), |l| l.name().to_string());
|
||||
if let Some(o) = row.as_object_mut() {
|
||||
o.insert("id".into(), json!(n.id.0));
|
||||
o.insert("name".into(), json!(name));
|
||||
o.insert("linked".into(), json!(link.is_some()));
|
||||
o.insert("status".into(), json!(status));
|
||||
}
|
||||
if let Some(l) = link {
|
||||
row["path"] = json!(l.path);
|
||||
if let Some(page) = l.page {
|
||||
row["page"] = json!(page);
|
||||
@@ -766,7 +953,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn go_to(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id = image_param(st, p, "links.goTo")?;
|
||||
let id = linked_param(st, p, "links.goTo")?;
|
||||
let b = st.doc.node(id).and_then(Node::geometric_bounds).unwrap_or_default();
|
||||
s.select(|_, sel| sel.set([id]))?;
|
||||
Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1] }))
|
||||
@@ -775,9 +962,29 @@ fn go_to(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn embed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.embed";
|
||||
let st = s.doc()?;
|
||||
let ids = images_param(st, p, C, |im| im.link.is_some())?;
|
||||
let kind = |id: &NodeId| st.doc.node(*id).map(|n| &n.kind);
|
||||
let linked = |id: &NodeId| match kind(id) {
|
||||
Some(NodeKind::Image(im)) => im.link.is_some(),
|
||||
Some(NodeKind::PlacedDocument(_)) => true,
|
||||
_ => false,
|
||||
};
|
||||
let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect());
|
||||
if let Some(id) = ids.iter().find(|id| !matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_)))) {
|
||||
return Err(bad(C, format!("object {} is not an image or a placed document", id.0)));
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select the linked images or placed documents, or pass ids"));
|
||||
}
|
||||
// Placed documents become editable copies of their art: those whose file can be read.
|
||||
let (mut documents, mut missing) = (vec![], vec![]);
|
||||
for id in ids.iter().copied() {
|
||||
let Some(NodeKind::PlacedDocument(p)) = kind(&id) else { continue };
|
||||
let readable = st.doc.images.get(&p.key).is_some_and(|b| vectorcraft_doc::placed_document::full_bytes(p, b).is_some());
|
||||
if readable { documents.push(id) } else { missing.push(id) }
|
||||
}
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| matches!(kind(id), Some(NodeKind::Image(_)))).collect();
|
||||
// Images showing their preview take their file's pixels first.
|
||||
let (mut done, mut missing, mut reads) = (vec![], vec![], vec![]);
|
||||
let (mut done, mut reads) = (vec![], vec![]);
|
||||
for g in groups(&st.doc, Some(&ids)) {
|
||||
if !previewing(&st.doc, &g.key) {
|
||||
done.extend(g.ids);
|
||||
@@ -794,21 +1001,20 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => missing.extend(g.ids),
|
||||
}
|
||||
}
|
||||
if !done.is_empty() {
|
||||
if !done.is_empty() || !documents.is_empty() {
|
||||
s.edit("Embed", |d, _| {
|
||||
for (ids, read) in reads {
|
||||
install(d, &ids, read, true)?;
|
||||
}
|
||||
for id in &done {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& let NodeKind::Image(im) = &mut n.kind
|
||||
{
|
||||
if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) {
|
||||
im.link = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
documents.iter().try_for_each(|id| super::place::document::expand(d, *id, C))
|
||||
})?;
|
||||
}
|
||||
done.extend(documents);
|
||||
Ok(json!({ "embedded": ids_json(&done), "missing": ids_json(&missing) }))
|
||||
}
|
||||
|
||||
@@ -862,41 +1068,77 @@ fn placement_json(o: PlacementOptions) -> Value {
|
||||
json!({ "preserve": o.preserve.id(), "align": o.align.id(), "clip": o.clip })
|
||||
}
|
||||
|
||||
/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`.
|
||||
fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
|
||||
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, folder(st.path.as_deref())).0);
|
||||
let stamp = file_stamp(&l.path);
|
||||
let folder_len = l.path.len().saturating_sub(l.name().len());
|
||||
out["status"] = json!(status.as_str());
|
||||
out["path"] = json!(l.path);
|
||||
out["fileName"] = json!(l.name());
|
||||
out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\']));
|
||||
for (k, v) in [
|
||||
("page", l.page.map(u64::from)),
|
||||
("fileSize", stamp.map(|s| s.0).or(l.size)),
|
||||
("modified", stamp.and_then(|s| s.1).or(l.modified)),
|
||||
("created", file_created(&l.path)),
|
||||
] {
|
||||
if let Some(v) = v {
|
||||
out[k] = json!(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale (% of 100%, given points per unit at 100%) and counter-clockwise rotation of `xf`.
|
||||
fn scale_rotation(xf: Affine, (px, py): (f64, f64)) -> (Value, Value) {
|
||||
let [a, b, c, d, ..] = xf.as_coeffs();
|
||||
// Counter-clockwise on the page (y points down). Adding 0 turns -0 into 0.
|
||||
(json!([a.hypot(b) / px * 100.0, c.hypot(d) / py * 100.0]), json!((-b).atan2(a).to_degrees() + 0.0))
|
||||
}
|
||||
|
||||
/// Link Info of placed document `id`.
|
||||
fn placed_info(st: &crate::DocState, id: NodeId) -> Result<Value> {
|
||||
let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?;
|
||||
let NodeKind::PlacedDocument(pl) = &n.kind else { return Err(bad("links.info", format!("object {} is not a placed document", id.0))) };
|
||||
let size = n.geometric_bounds().unwrap_or_default();
|
||||
let (scale, rotation) = scale_rotation(pl.xf, (1.0, 1.0));
|
||||
let mut out = document_row(pl);
|
||||
let fields = [
|
||||
("id", json!(id.0)),
|
||||
("name", json!(n.display_name())),
|
||||
("linked", json!(true)),
|
||||
("link", json!(pl.link.path)),
|
||||
("width", json!(size.width())),
|
||||
("height", json!(size.height())),
|
||||
("scale", scale),
|
||||
("rotation", rotation),
|
||||
("placement", placement_json(pl.placement)),
|
||||
];
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.extend(fields.into_iter().map(|(k, v)| (k.to_string(), v)));
|
||||
}
|
||||
file_fields(st, id, &pl.link, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.info";
|
||||
let id = image_param(s.doc()?, p, C)?;
|
||||
let id = linked_param(s.doc()?, p, C)?;
|
||||
if image_of(&s.doc()?.doc, id).is_none() {
|
||||
return placed_info(s.doc()?, id);
|
||||
}
|
||||
let mut out = super::place::image_info(s, &json!({ "id": id.0 }))?;
|
||||
let st = s.doc()?;
|
||||
let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?;
|
||||
let (px, py) = file_pt(&st.doc, &im.key);
|
||||
let [a, b, c, d, ..] = im.xf.as_coeffs();
|
||||
out["effectivePpi"] = out["ppi"].take();
|
||||
out["ppi"] = json!([72.0 / px, 72.0 / py]);
|
||||
out["scale"] = json!([a.hypot(b) / px * 100.0, c.hypot(d) / py * 100.0]);
|
||||
// Counter-clockwise on the page (y points down).
|
||||
// Adding 0 turns -0 into 0.
|
||||
out["rotation"] = json!((-b).atan2(a).to_degrees() + 0.0);
|
||||
(out["scale"], out["rotation"]) = scale_rotation(im.xf, (px, py));
|
||||
out["format"] = json!(format_label(&st.doc, im));
|
||||
out["placement"] = placement_json(im.placement);
|
||||
out["status"] = json!("embedded");
|
||||
if let Some(l) = &im.link {
|
||||
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, folder(st.path.as_deref())).0);
|
||||
let stamp = file_stamp(&l.path);
|
||||
let folder_len = l.path.len().saturating_sub(l.name().len());
|
||||
out["status"] = json!(status.as_str());
|
||||
out["path"] = json!(l.path);
|
||||
out["fileName"] = json!(l.name());
|
||||
out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\']));
|
||||
for (k, v) in [
|
||||
("page", l.page.map(u64::from)),
|
||||
("fileSize", stamp.map(|s| s.0).or(l.size)),
|
||||
("modified", stamp.and_then(|s| s.1).or(l.modified)),
|
||||
("created", file_created(&l.path)),
|
||||
] {
|
||||
if let Some(v) = v {
|
||||
out[k] = json!(v);
|
||||
}
|
||||
}
|
||||
file_fields(st, id, l, &mut out);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -904,7 +1146,13 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.placementOptions";
|
||||
let st = s.doc()?;
|
||||
let ids = images_param(st, p, C, |_| true)?;
|
||||
let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect());
|
||||
if let Some(id) = ids.iter().find(|id| placement_of(&st.doc, **id).is_none()) {
|
||||
return Err(bad(C, format!("object {} is not an image or a linked file", id.0)));
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select the images, or pass ids"));
|
||||
}
|
||||
let choice = |k: &str| -> Result<Option<&str>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
@@ -918,7 +1166,7 @@ fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "clip must be true or false"))?),
|
||||
};
|
||||
let first = ids.first().and_then(|id| image_of(&st.doc, *id)).map(|im| im.placement).unwrap_or_default();
|
||||
let first = ids.first().and_then(|id| placement_of(&st.doc, *id)).unwrap_or_default();
|
||||
if preserve.is_none() && align.is_none() && clip.is_none() {
|
||||
return Ok(json!({ "placement": placement_json(first) }));
|
||||
}
|
||||
@@ -929,10 +1177,8 @@ fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
s.edit("Placement Options", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& let NodeKind::Image(im) = &mut n.kind
|
||||
{
|
||||
set(&mut im.placement);
|
||||
if let Some(o) = d.node_mut(*id).and_then(placement_mut) {
|
||||
set(o);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -29,6 +29,7 @@ pub(crate) mod gradient;
|
||||
pub(crate) mod graph;
|
||||
mod halftone;
|
||||
pub mod help;
|
||||
pub(crate) mod inline;
|
||||
mod layer;
|
||||
mod layerpanel;
|
||||
pub mod links;
|
||||
@@ -48,7 +49,7 @@ mod pathops;
|
||||
mod patterncmds;
|
||||
pub mod pdfcmds;
|
||||
pub mod perspgrid;
|
||||
mod place;
|
||||
pub(crate) mod place;
|
||||
pub mod plugin;
|
||||
pub mod prefscmds;
|
||||
pub mod print;
|
||||
@@ -59,6 +60,7 @@ pub mod rasterfx;
|
||||
mod recolor;
|
||||
pub mod recovery;
|
||||
mod select;
|
||||
pub(crate) mod shaper;
|
||||
pub(crate) mod slices;
|
||||
mod stroke;
|
||||
mod style;
|
||||
@@ -217,6 +219,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(colormgmt::specs());
|
||||
v.extend(typemenu::specs());
|
||||
v.extend(textedit::specs());
|
||||
v.extend(inline::specs());
|
||||
v.extend(textstyles::specs());
|
||||
v.extend(fonts::specs());
|
||||
v.extend(help::specs());
|
||||
@@ -231,6 +234,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(docinfo::specs());
|
||||
v.extend(panelcmds::specs());
|
||||
v.extend(buildcmds::specs());
|
||||
v.extend(shaper::specs());
|
||||
v.extend(brushsym::specs());
|
||||
v.extend(patterncmds::specs());
|
||||
v.extend(prefscmds::specs());
|
||||
|
||||
@@ -150,7 +150,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Object Properties",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool}",
|
||||
"{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
|
||||
has_doc,
|
||||
set_props
|
||||
),
|
||||
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
has_selection,
|
||||
align
|
||||
),
|
||||
@@ -177,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Distribute Spacing",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{axis: \"horizontal\"|\"vertical\", spacing?: pt, bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
has_multi,
|
||||
distribute_spacing
|
||||
),
|
||||
@@ -722,7 +722,8 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.doc.node(id).is_none_or(|n| !n.is_container()) {
|
||||
return Err(bad("object.isolate", "only groups and layers can be isolated"));
|
||||
}
|
||||
st.isolation = Some(id);
|
||||
let isolation = st.doc.node(id).filter(|n| n.shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
|
||||
st.isolation = Some(isolation);
|
||||
st.selection.clear();
|
||||
st.revision += 1;
|
||||
ok()
|
||||
@@ -732,12 +733,39 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(i) = st.isolation.take() {
|
||||
super::distortcmds::finish_edit_text(st, i);
|
||||
st.selection.set([i]);
|
||||
let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.shaper.is_some())).unwrap_or(i);
|
||||
st.selection.set([target]);
|
||||
}
|
||||
st.revision += 1;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The most data entries `object.setProps` takes at once.
|
||||
const MAX_DATA: usize = 256;
|
||||
|
||||
/// `object.setProps`'s `data`: each key (a data-* attribute's name: letters, digits, `-`, `_`,
|
||||
/// `.`) with its value, or `None` to remove it.
|
||||
fn data_param(m: &serde_json::Map<String, Value>) -> Result<Vec<(String, Option<String>)>> {
|
||||
const C: &str = "object.setProps";
|
||||
if m.len() > MAX_DATA {
|
||||
return Err(bad(C, format!("data takes at most {MAX_DATA} keys at once")));
|
||||
}
|
||||
m.iter()
|
||||
.map(|(k, v)| {
|
||||
let k = k.strip_prefix("data-").unwrap_or(k);
|
||||
if k.is_empty() || !k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) || k == "name" || k.starts_with("vc-") {
|
||||
return Err(bad(C, format!("data key `{k}`: letters, digits, -, _ and . (not name or vc-…)")));
|
||||
}
|
||||
let v = match v {
|
||||
Value::Null => None,
|
||||
Value::String(s) => Some(s.clone()),
|
||||
other => Some(other.to_string()),
|
||||
};
|
||||
Ok((k.to_string(), v))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
|
||||
@@ -745,6 +773,11 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let data = match p.get("data") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Object(m)) => Some(data_param(m)?),
|
||||
Some(v) => return Err(bad("object.setProps", format!("data must be an object of key: value, not {v}"))),
|
||||
};
|
||||
let knockout = match p.get("knockout") {
|
||||
Some(v) => Some(
|
||||
Knockout::from_value(v)
|
||||
@@ -779,6 +812,24 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = p.get("knockoutShape").and_then(Value::as_bool) {
|
||||
n.knockout_shape = v;
|
||||
}
|
||||
if let Some(changes) = &data {
|
||||
n.edit_attrs(|a| {
|
||||
for (k, v) in changes {
|
||||
match (a.data.iter().position(|(key, _)| key == k), v) {
|
||||
(Some(i), Some(v)) => {
|
||||
if let Some(e) = a.data.get_mut(i) {
|
||||
e.1 = v.clone();
|
||||
}
|
||||
}
|
||||
(Some(i), None) => {
|
||||
a.data.remove(i);
|
||||
}
|
||||
(None, Some(v)) => a.data.push((k.clone(), v.clone())),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -813,9 +864,15 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec
|
||||
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
|
||||
}
|
||||
|
||||
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
|
||||
/// selection.
|
||||
fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
|
||||
str_param(p, "to").or_else(|| s.doc().ok()?.selection.key.map(|_| "key"))
|
||||
}
|
||||
|
||||
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
|
||||
let st = s.doc()?;
|
||||
match str_param(p, "to") {
|
||||
match align_to(s, p) {
|
||||
Some("artboard") => {
|
||||
let b = st.doc.bounds_of(ids, preview).unwrap_or_default();
|
||||
let i = st.doc.artboard_at(b.center()).unwrap_or(0);
|
||||
@@ -840,7 +897,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
items_bounds(d, &ids, preview)
|
||||
.into_iter()
|
||||
.filter(|(id, _)| Some(*id) != key || str_param(p, "to") != Some("key"))
|
||||
.filter(|(id, _)| Some(*id) != key || align_to(s, p) != Some("key"))
|
||||
.map(|(id, b)| {
|
||||
let dx = match h {
|
||||
Some("left") => r.x0 - b.x0,
|
||||
@@ -926,22 +983,31 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
|
||||
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
|
||||
let n = items.len();
|
||||
let &[(_, first), .., (_, last)] = items.as_slice() else {
|
||||
return Err(EngineError::Other("select two or more objects to distribute".into()));
|
||||
};
|
||||
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
|
||||
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
|
||||
let gap = match p.get("spacing").and_then(Value::as_f64) {
|
||||
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|g| g.is_finite());
|
||||
let gap = match spacing {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
let span = if horiz { items[n - 1].1.x1 - items[0].1.x0 } else { items[n - 1].1.y1 - items[0].1.y0 };
|
||||
let span = if horiz { last.x1 - first.x0 } else { last.y1 - first.y0 };
|
||||
(span - items.iter().map(|i| size(&i.1)).sum::<f64>()) / (n - 1) as f64
|
||||
}
|
||||
};
|
||||
let mut pos = start(&items[0].1);
|
||||
let mut moves = vec![];
|
||||
let mut pos = start(&first);
|
||||
let mut deltas = vec![];
|
||||
for (id, r) in &items {
|
||||
let delta = pos - start(r);
|
||||
moves.push((*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }));
|
||||
deltas.push((*id, pos - start(r)));
|
||||
pos += size(r) + gap;
|
||||
}
|
||||
// With a spacing (aligning to a key object) the key object stays where it is: the others
|
||||
// are spaced from it.
|
||||
let key = s.doc()?.selection.key;
|
||||
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
|
||||
let moves: Vec<(NodeId, Vec2)> =
|
||||
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
|
||||
s.edit("Distribute Spacing", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Character/Paragraph attributes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_doc::{Composer, NodeKind};
|
||||
use vectorcraft_geom::{Rect, Vec2};
|
||||
|
||||
use super::*;
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\")}",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -180,6 +180,9 @@ pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vector
|
||||
|
||||
// ---------- text ----------
|
||||
|
||||
/// Largest indent or paragraph spacing (points; Illustrator's limit).
|
||||
const MAX_PARA: f64 = 1296.0;
|
||||
|
||||
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setFormat";
|
||||
let ids: Vec<NodeId> = {
|
||||
@@ -219,10 +222,19 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"leadingModel",
|
||||
"charAlign",
|
||||
"burasagari",
|
||||
"composer",
|
||||
];
|
||||
if !keys.iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let composer = match p.get("composer") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(match v.as_str() {
|
||||
Some("singleLine") => Composer::SingleLine,
|
||||
Some("everyLine") => Composer::EveryLine,
|
||||
_ => return Err(bad(C, "composer must be \"singleLine\" or \"everyLine\"")),
|
||||
}),
|
||||
};
|
||||
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
|
||||
let char_align = super::textedit::char_align_param(p, C)?;
|
||||
let mojikumi = match p.get("mojikumi") {
|
||||
@@ -259,11 +271,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")),
|
||||
}),
|
||||
};
|
||||
let range = super::typecmd::TextRange::parse(p, C)?;
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
for r in &mut t.runs {
|
||||
let st = &mut r.style;
|
||||
super::typecmd::style_chars(t, range, |st| {
|
||||
if let Some(k) = kerning {
|
||||
st.kerning = k;
|
||||
}
|
||||
@@ -297,38 +309,43 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = char_align {
|
||||
st.char_align = v;
|
||||
}
|
||||
}
|
||||
let para = &mut t.para;
|
||||
if let Some(v) = num("leftIndent") {
|
||||
para.left_indent = v;
|
||||
}
|
||||
if let Some(v) = num("rightIndent") {
|
||||
para.right_indent = v;
|
||||
}
|
||||
if let Some(v) = num("firstLineIndent") {
|
||||
para.first_line_indent = v;
|
||||
}
|
||||
if let Some(v) = num("spaceBefore") {
|
||||
para.space_before = v;
|
||||
}
|
||||
if let Some(v) = num("spaceAfter") {
|
||||
para.space_after = v;
|
||||
}
|
||||
if let Some(v) = flag("hyphenate") {
|
||||
para.hyphenate = v;
|
||||
}
|
||||
if let Some(v) = mojikumi {
|
||||
para.mojikumi = v;
|
||||
}
|
||||
if let Some(v) = direction {
|
||||
para.direction = v;
|
||||
}
|
||||
if let Some(v) = leading_model {
|
||||
para.leading_model = v;
|
||||
}
|
||||
if let Some(v) = burasagari {
|
||||
para.burasagari = v;
|
||||
}
|
||||
});
|
||||
let span = super::typecmd::para_span(range, t);
|
||||
t.edit_paras(span, |para| {
|
||||
if let Some(v) = num("leftIndent") {
|
||||
para.left_indent = v.clamp(-MAX_PARA, MAX_PARA);
|
||||
}
|
||||
if let Some(v) = num("rightIndent") {
|
||||
para.right_indent = v.clamp(-MAX_PARA, MAX_PARA);
|
||||
}
|
||||
if let Some(v) = num("firstLineIndent") {
|
||||
para.first_line_indent = v.clamp(-MAX_PARA, MAX_PARA);
|
||||
}
|
||||
if let Some(v) = num("spaceBefore") {
|
||||
para.space_before = v.clamp(-MAX_PARA, MAX_PARA);
|
||||
}
|
||||
if let Some(v) = num("spaceAfter") {
|
||||
para.space_after = v.clamp(-MAX_PARA, MAX_PARA);
|
||||
}
|
||||
if let Some(v) = flag("hyphenate") {
|
||||
para.hyphenate = v;
|
||||
}
|
||||
if let Some(v) = mojikumi {
|
||||
para.mojikumi = v;
|
||||
}
|
||||
if let Some(v) = direction {
|
||||
para.direction = v;
|
||||
}
|
||||
if let Some(v) = leading_model {
|
||||
para.leading_model = v;
|
||||
}
|
||||
if let Some(v) = burasagari {
|
||||
para.burasagari = v;
|
||||
}
|
||||
if let Some(v) = composer {
|
||||
para.composer = v;
|
||||
}
|
||||
});
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -67,6 +67,7 @@ fn refs(n: &mut Node, f: &mut impl FnMut(Ref)) {
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => f(Ref::Image(&mut im.key)),
|
||||
NodeKind::PlacedDocument(pl) => f(Ref::Image(&mut pl.key)),
|
||||
NodeKind::SymbolInstance { symbol, .. } => f(Ref::Symbol(symbol)),
|
||||
_ => {}
|
||||
}
|
||||
@@ -129,7 +130,7 @@ fn copies(m: &Renames) -> Vec<(&str, &str)> {
|
||||
|
||||
/// Bring the resources `nodes` (art from `src`) use into `dst`, renaming on conflict, and point
|
||||
/// `nodes` at the names they got.
|
||||
pub(super) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
|
||||
pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
|
||||
let mut used = Used::default();
|
||||
for n in nodes.iter_mut() {
|
||||
refs(n, &mut |r| used.note(r));
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Placed documents (`file.place` of a VectorCraft document from a file, with `link`): one
|
||||
//! locked object showing an artboard of the document, a [`PlacedDocument`].
|
||||
//!
|
||||
//! The object keeps the file's bytes (in the document's images, like image data) and the link; the
|
||||
//! art it shows is read from them the way placing the file without Link reads it
|
||||
//! ([`document_art`]: the artboard's art, with the file's own linked images found), so Break Link
|
||||
//! and Object › Expand ([`expand`]) give exactly that editable copy. The Links commands
|
||||
//! ([`super::super::links`]) tell when the file changed and read it again.
|
||||
//!
|
||||
//! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept
|
||||
//! as the blob's [`ImageBlob::proxy`]: a save writes only the preview (unless Include Linked
|
||||
//! Files), as for linked images. The file is read again when it is needed ([`read_file_again`]:
|
||||
//! output, or the screen zoomed past the preview), while it is unchanged, and output falls back to
|
||||
//! the preview with a warning when it can't be ([`full_documents`]).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::links::hash_bytes;
|
||||
use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for};
|
||||
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PlacedDocument, PlacementOptions};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use super::super::fileio;
|
||||
use super::super::*;
|
||||
|
||||
/// The largest file placed (bytes).
|
||||
pub(crate) const MAX_FILE: usize = 256 << 20;
|
||||
/// The format ids of VectorCraft documents.
|
||||
const NATIVE: [&str; 2] = ["vectorcraft", "template"];
|
||||
/// The type a placed file's bytes are stored with.
|
||||
const MIME: &str = "application/json";
|
||||
|
||||
/// Is `name` with `bytes` a VectorCraft document?
|
||||
pub(crate) fn is_native(name: &str, bytes: &[u8]) -> bool {
|
||||
fileio::detect(name, bytes).is_some_and(|f| NATIVE.contains(&f.id))
|
||||
}
|
||||
|
||||
/// An artboard of a VectorCraft document, read as art to place.
|
||||
pub(crate) struct DocumentArt {
|
||||
/// The document, its layers taken: the art's resources.
|
||||
pub doc: Document,
|
||||
/// The art on the artboard (guides, hidden and template layers left out).
|
||||
pub nodes: Vec<Node>,
|
||||
/// The artboard the art is clipped to (`None`: the art's bounds are shown).
|
||||
pub clip: Option<Rect>,
|
||||
/// What is shown: the artboard, or the art's bounds.
|
||||
pub frame: Rect,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when
|
||||
/// there is one, so its linked images are found), clipped to the artboard unless `bounding`.
|
||||
pub(crate) fn document_art(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result<DocumentArt> {
|
||||
let file = fileio::file_name(name);
|
||||
if !is_native(name, bytes) {
|
||||
return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document")));
|
||||
}
|
||||
if bytes.len() > MAX_FILE {
|
||||
return Err(bad(cmd, format!("`{file}` is over {} MB", MAX_FILE >> 20)));
|
||||
}
|
||||
let mut l = fileio::load(name, bytes)?;
|
||||
// Its linked images show their files; its placed documents stay as they are.
|
||||
super::super::links::resolve(&mut l.doc, path, false);
|
||||
l.doc.drop_edit_modes();
|
||||
let n = l.doc.artboards.len();
|
||||
let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect);
|
||||
let board = board.ok_or_else(|| bad(cmd, format!("page {page}: `{file}` has {n} artboard(s)")))?;
|
||||
let layers = std::mem::take(&mut l.doc.layers);
|
||||
let nodes = super::on_artboard(&layers, board);
|
||||
let frame = if bounding { nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())) } else { Some(board) };
|
||||
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?;
|
||||
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings })
|
||||
}
|
||||
|
||||
/// `nodes` as one group, clipped to `clip` (its own ids: what drawing reads).
|
||||
fn group(nodes: Vec<Node>, clip: Option<Rect>) -> Node {
|
||||
let mut children: Vec<Arc<Node>> = nodes.into_iter().map(Arc::new).collect();
|
||||
if let Some(r) = clip {
|
||||
children.insert(0, Arc::new(super::clip_path(NodeId(0), r)));
|
||||
}
|
||||
Node::new(NodeId(0), NodeKind::Group { children, clip: clip.is_some() })
|
||||
}
|
||||
|
||||
/// `p`'s file (`bytes`) read as the art it shows: how the document model reads placed documents
|
||||
/// ([`vectorcraft_doc::placed_document::set_loader`]).
|
||||
pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> {
|
||||
let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, "draw").ok()?;
|
||||
Some((a.doc, group(a.nodes, a.clip), a.frame))
|
||||
}
|
||||
|
||||
/// `p`'s file read again, while it is the file it was read from (how the document model gets it
|
||||
/// when only the preview is stored, [`vectorcraft_doc::placed_document::set_file_reader`]).
|
||||
pub(crate) fn read_file_again(p: &PlacedDocument) -> Option<Vec<u8>> {
|
||||
let bytes = fileio::read_file(&p.link.path).ok()?;
|
||||
if p.link.hash.as_ref().is_some_and(|h| *h != hash_bytes(&bytes)) {
|
||||
return None;
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// A VectorCraft document read for placing linked.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Source {
|
||||
/// The file's bytes, with their preview.
|
||||
pub blob: ImageBlob,
|
||||
/// The blob's key ([`key_for`]).
|
||||
pub key: String,
|
||||
/// What is shown, in the file's coordinates: the artboard or the art's bounds.
|
||||
pub frame: Rect,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing
|
||||
/// artboard `page` (its art's bounds when `bounding`).
|
||||
pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result<Source> {
|
||||
// The same document placed the same way is read once (placing it again is free).
|
||||
let key = key_for(&hash_bytes(bytes), page, bounding);
|
||||
if let Some(s) = lock_memo().iter().find(|s| s.key == key).cloned() {
|
||||
return Ok(s);
|
||||
}
|
||||
let a = document_art(name, bytes, path, page, bounding, cmd)?;
|
||||
let mut blob = ImageBlob::new(MIME, bytes.to_vec());
|
||||
let shown = PlacedDocument {
|
||||
link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) },
|
||||
key: key.clone(),
|
||||
bounding,
|
||||
width: a.frame.width(),
|
||||
height: a.frame.height(),
|
||||
xf: Affine::IDENTITY,
|
||||
placement: PlacementOptions::default(),
|
||||
};
|
||||
let art = group(a.nodes, a.clip);
|
||||
blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new);
|
||||
// Drawing it needn't read it again.
|
||||
vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame));
|
||||
let s = Source { blob, key, frame: a.frame, warnings: a.warnings };
|
||||
let mut m = lock_memo();
|
||||
if m.len() >= MEMO_SIZE {
|
||||
m.remove(0);
|
||||
}
|
||||
m.push(s.clone());
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The most files remembered.
|
||||
const MEMO_SIZE: usize = 32;
|
||||
|
||||
/// Files read lately for placing, by key.
|
||||
static MEMO: std::sync::Mutex<Vec<Source>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn lock_memo() -> std::sync::MutexGuard<'static, Vec<Source>> {
|
||||
// Entries are only pushed and removed: a panic while held leaves it whole.
|
||||
MEMO.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The preview of `art` (of document `doc`, showing `frame`): [`PREVIEW_PX_PER_PT`], at most
|
||||
/// [`PREVIEW_MAX`] pixels a side; JPEG when opaque (inside its outer pixels), else PNG.
|
||||
fn preview(doc: &Document, art: &Node, frame: Rect) -> Option<Vec<u8>> {
|
||||
let k = PREVIEW_PX_PER_PT.min(PREVIEW_MAX as f64 / frame.width().max(frame.height()).max(1e-9));
|
||||
let side = |v: f64| (v * k).round().clamp(1.0, PREVIEW_MAX as f64) as u32;
|
||||
let (w, h) = (side(frame.width()), side(frame.height()));
|
||||
let view = Affine::scale_non_uniform(w as f64 / frame.width().max(1e-9), h as f64 / frame.height().max(1e-9))
|
||||
* Affine::translate((-frame.x0, -frame.y0));
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let img = vectorcraft_render::placed_document::render_inside(&mut r, doc, &Arc::new(art.clone()), w as u16, h as u16, view)?;
|
||||
// Premultiplied → straight.
|
||||
let px = img
|
||||
.pixels
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|&[r, g, b, a]| {
|
||||
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
|
||||
[un(r), un(g), un(b), a]
|
||||
})
|
||||
.collect();
|
||||
let rgba = image::RgbaImage::from_raw(w, h, px)?;
|
||||
let mut out = vec![];
|
||||
// Opaque but for the edge's anti-aliasing (an artboard drawn to whole pixels) and compositing's
|
||||
// rounding: JPEG.
|
||||
let inner = |x: u32, y: u32| x > 0 && y > 0 && x + 1 < w && y + 1 < h;
|
||||
if rgba.enumerate_pixels().all(|(x, y, p)| p[3] >= 250 || !inner(x, y)) {
|
||||
let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80).encode_image(&rgb).ok()?;
|
||||
} else {
|
||||
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `s` stored in `d` (objects showing the same thing share one blob).
|
||||
pub(crate) fn store(d: &mut Document, s: &Source) {
|
||||
d.images.entry(s.key.clone()).or_insert_with(|| s.blob.clone());
|
||||
}
|
||||
|
||||
/// A new placed document of `d` showing `s` at 100%, where its art lies in the file.
|
||||
pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool) -> Node {
|
||||
store(d, s);
|
||||
let placed = PlacedDocument {
|
||||
link,
|
||||
key: s.key.clone(),
|
||||
bounding,
|
||||
width: s.frame.width(),
|
||||
height: s.frame.height(),
|
||||
xf: Affine::translate((s.frame.x0, s.frame.y0)),
|
||||
placement: PlacementOptions::default(),
|
||||
};
|
||||
Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed)))
|
||||
}
|
||||
|
||||
/// `d` for output: the placed documents that keep only their previews get their files back,
|
||||
/// else (their files gone or changed) output as their previews, with a warning.
|
||||
pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec<String>) {
|
||||
let mut need: Vec<PlacedDocument> = vec![];
|
||||
d.visit_placed(|_, p| {
|
||||
if d.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) {
|
||||
need.push(p.clone());
|
||||
}
|
||||
});
|
||||
if need.is_empty() {
|
||||
return (Cow::Borrowed(d), vec![]);
|
||||
}
|
||||
let mut out = d.clone();
|
||||
let mut previews = BTreeSet::new();
|
||||
for p in need {
|
||||
let Some(blob) = d.images.get(&p.key) else { continue };
|
||||
match vectorcraft_doc::placed_document::full_bytes(&p, blob) {
|
||||
Some(bytes) => {
|
||||
out.images.insert(p.key.clone(), ImageBlob { mime: MIME.into(), bytes, proxy: blob.proxy.clone() });
|
||||
}
|
||||
None => {
|
||||
previews.insert(p.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut warnings = vec![];
|
||||
if !previews.is_empty() {
|
||||
warnings.push(format!(
|
||||
"{} placed document(s) output as their previews: their files can't be found, or changed since they were read (relink or update them)",
|
||||
previews.len()
|
||||
));
|
||||
out.placed_as_previews(&previews);
|
||||
}
|
||||
(Cow::Owned(out), warnings)
|
||||
}
|
||||
|
||||
/// Placed document `id` of `d` replaced by an editable copy of its art, as placing its file
|
||||
/// without Link gives (its resources joining `d`, renamed where `d` uses a name for something
|
||||
/// else), keeping the object's id, place, name and transparency: Break Link and Object › Expand.
|
||||
pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> {
|
||||
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
let NodeKind::PlacedDocument(p) = &n.kind else { return Err(bad(cmd, format!("object {} is not a placed document", id.0))) };
|
||||
let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?;
|
||||
let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob)
|
||||
.ok_or_else(|| bad(cmd, format!("{} can't be read, or changed since it was read: relink or update it first", p.link.path)))?;
|
||||
let a = document_art(p.link.name(), &bytes, Some(&p.link.path), p.page(), p.bounding, cmd)?;
|
||||
let m = p.art_xf(a.frame).ok_or_else(|| bad(cmd, format!("object {} has no size", id.0)))?;
|
||||
let mut nodes = a.nodes;
|
||||
super::adopt::adopt(d, &a.doc, &mut nodes);
|
||||
let mut children: Vec<Arc<Node>> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect();
|
||||
if let Some(r) = a.clip {
|
||||
children.insert(0, Arc::new(super::clip_path(d.alloc_id(), r)));
|
||||
}
|
||||
let art = super::transformed(Node::new(d.alloc_id(), NodeKind::Group { children, clip: a.clip.is_some() }), m);
|
||||
let mut out = n.clone();
|
||||
out.kind = art.kind;
|
||||
let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
d.remove(id)?;
|
||||
d.insert(par, index, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,13 +16,17 @@
|
||||
//! (`crop: "bounding"`).
|
||||
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
|
||||
//! [`text`]).
|
||||
//! - 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.
|
||||
//!
|
||||
//! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was,
|
||||
//! with its transform; Template puts it on a new template layer. The images, symbols, patterns and
|
||||
//! swatches it uses join the document ([`adopt`]). `file.place.queue` loads the place cursor (the
|
||||
//! `place` tool) with several files, which emits one `file.place` per click or drag.
|
||||
|
||||
mod adopt;
|
||||
pub(crate) mod adopt;
|
||||
pub(crate) mod document;
|
||||
mod text;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -53,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -94,6 +98,9 @@ enum Art {
|
||||
Image(RasterImage),
|
||||
/// A text file's text, placed as area type filling the natural box.
|
||||
Text(String),
|
||||
/// 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),
|
||||
Vector {
|
||||
/// The loaded document: the source of the art's resources.
|
||||
src: Box<Document>,
|
||||
@@ -158,6 +165,19 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
));
|
||||
}
|
||||
let crop = opts.crop != CropTo::Bounding;
|
||||
// A VectorCraft document: placed linked from its file, else as an editable copy.
|
||||
if matches!(format.id, "vectorcraft" | "template") {
|
||||
let page = page as u32;
|
||||
if let Some(path) = src.path.filter(|_| bool_or(p, "link", true)) {
|
||||
let s = document::source(src.name, &src.bytes, Some(path), page, !crop, cmd)?;
|
||||
let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) };
|
||||
let (natural, warnings) = (s.frame, s.warnings.clone());
|
||||
return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None });
|
||||
}
|
||||
let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, cmd)?;
|
||||
let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip };
|
||||
return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None });
|
||||
}
|
||||
if format.raster {
|
||||
let img = fileio::raster_image(&src.bytes)?;
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
@@ -187,7 +207,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
),
|
||||
));
|
||||
}
|
||||
// Linked images (a native document's) show their files, found from its folder.
|
||||
// Linked images show their files, found from its folder.
|
||||
super::links::resolve(&mut l.doc, src.path, false);
|
||||
(l.doc, l.warnings)
|
||||
}
|
||||
@@ -201,22 +221,31 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
// The page placed is a PDF import's only one.
|
||||
let index = if pdf { 0 } else { page - 1 };
|
||||
let n = doc.artboards.len();
|
||||
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| {
|
||||
let what = if matches!(format.id, "vectorcraft" | "template") { "artboard" } else { "page" };
|
||||
bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)"))
|
||||
})?;
|
||||
// A PDF import has one layer per page; a native document's (or an EPS file's) art is
|
||||
// whatever lies on the artboard.
|
||||
let pages: Vec<&Arc<Node>> = if pdf { layers.get(index).into_iter().collect() } else { layers.iter().collect() };
|
||||
let on_board = |c: &&Arc<Node>| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()));
|
||||
let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board));
|
||||
(nodes, crop.then_some(board))
|
||||
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
|
||||
// A PDF import has one layer per page; an EPS file's art is whatever lies on the artboard.
|
||||
let pages = if pdf { layers.get(index..=index).unwrap_or_default() } else { &layers[..] };
|
||||
(on_artboard(pages, board), crop.then_some(board))
|
||||
};
|
||||
let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())));
|
||||
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
|
||||
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board })
|
||||
}
|
||||
|
||||
/// The placeable art of `layers` that lies on `board`.
|
||||
fn on_artboard(layers: &[Arc<Node>], board: Rect) -> Vec<Node> {
|
||||
let on_board = |c: &&Arc<Node>| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()));
|
||||
art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board))
|
||||
}
|
||||
|
||||
/// A clipping path of rectangle `r`, with id `id`.
|
||||
fn clip_path(id: NodeId, r: Rect) -> Node {
|
||||
let mut c = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
if let NodeKind::Path { clipping, .. } = &mut c.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// A visible, non-template layer.
|
||||
fn placeable(l: &Node) -> bool {
|
||||
l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. })
|
||||
@@ -254,15 +283,12 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
|
||||
Node::new(d.alloc_id(), NodeKind::Image(im))
|
||||
}
|
||||
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))),
|
||||
Art::Document(s, link, bounding) => document::node(d, &s, link, bounding),
|
||||
Art::Vector { src, mut nodes, clip } => {
|
||||
adopt::adopt(d, &src, &mut nodes);
|
||||
let mut children: Vec<Arc<Node>> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect();
|
||||
if let Some(r) = clip {
|
||||
let mut c = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
if let NodeKind::Path { clipping, .. } = &mut c.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
children.insert(0, Arc::new(c));
|
||||
children.insert(0, Arc::new(clip_path(d.alloc_id(), r)));
|
||||
}
|
||||
Node::new(d.alloc_id(), NodeKind::Group { children, clip: clip.is_some() })
|
||||
}
|
||||
|
||||
@@ -118,6 +118,9 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let preset = given.or(Some(set.advanced.flattener_preset.as_str()).filter(|n| !n.trim().is_empty())).unwrap_or("medium");
|
||||
set.advanced.flattener_preset = preset.to_string();
|
||||
}
|
||||
// Placed documents print their files' art.
|
||||
let (full, _) = super::place::document::full_documents(doc);
|
||||
let doc = &*full;
|
||||
// Raster effects print as images at the document's raster effects resolution.
|
||||
let flat = super::rasterfx::flatten_raster_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
|
||||
@@ -36,7 +36,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
toggle
|
||||
),
|
||||
cmd!("select.key", "Set Key Object", [], None, "{id?} (none clears)", has_doc, key),
|
||||
cmd!(
|
||||
"select.key",
|
||||
"Set Key Object",
|
||||
[],
|
||||
None,
|
||||
"{id?: a selected object (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it",
|
||||
has_doc,
|
||||
key
|
||||
),
|
||||
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
|
||||
cmd!(
|
||||
"select.anchorsMany",
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
//! Shaper compositions are groups of generated fills/strokes with transparent, editable source
|
||||
//! art retained underneath. Recipes select topology regions; source edits re-evaluate them in
|
||||
//! the same undo transaction. Rendering and exporters only see ordinary groups and paths.
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use vectorcraft_doc::shaper::{EdgeEdit, FILL_PREFIX, FaceEdit, FaceSelector, SOURCES, STROKE_PREFIX, ShaperSpec};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{self as b, BuilderMap, Hit};
|
||||
|
||||
const MAX_SOURCES: usize = 64;
|
||||
const MAX_POINTS: usize = 4096;
|
||||
const MAX_EDITS: usize = 8192;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"shaper.scribble",
|
||||
"Shaper",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], ids?: [id…] (default: nearby art), tolerance?: pt (4)} interior scribbles remove fills; scribbles through several regions merge with the origin fill; ending outside removes fills and boundary strokes; outside scribbles erase stroke pieces. Originals remain editable → {id, regions, strokes}",
|
||||
has_doc,
|
||||
scribble
|
||||
),
|
||||
cmd!(
|
||||
"shaper.select",
|
||||
"Select Shaper Face",
|
||||
[],
|
||||
None,
|
||||
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original → {id}",
|
||||
has_doc,
|
||||
select
|
||||
),
|
||||
cmd!(
|
||||
"shaper.release",
|
||||
"Release Shaper Group",
|
||||
["Object", "Shaper"],
|
||||
None,
|
||||
"{id? (default: selection)} restore the original paths → {ids}",
|
||||
has_doc,
|
||||
release
|
||||
),
|
||||
cmd!(
|
||||
"shaper.expand",
|
||||
"Expand Shaper Group",
|
||||
["Object", "Shaper"],
|
||||
None,
|
||||
"{id? (default: selection)} keep the visible result as ordinary paths → {id}",
|
||||
has_doc,
|
||||
expand
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn source_group(n: &Node) -> Option<&Node> {
|
||||
n.shaper.as_ref()?;
|
||||
n.children()?.first().map(AsRef::as_ref).filter(|g| g.name.as_deref() == Some(SOURCES))
|
||||
}
|
||||
fn sources(n: &Node) -> Vec<Arc<Node>> {
|
||||
source_group(n).and_then(Node::children).cloned().unwrap_or_default()
|
||||
}
|
||||
fn finite(p: Point) -> bool {
|
||||
p.x.is_finite() && p.y.is_finite() && p.x.abs() <= 1e9 && p.y.abs() <= 1e9
|
||||
}
|
||||
|
||||
fn map_of(src: &[Arc<Node>]) -> Result<BuilderMap> {
|
||||
if src.is_empty() || src.len() > MAX_SOURCES {
|
||||
return Err(bad("shaper.scribble", "Shaper needs 1–64 source paths"));
|
||||
}
|
||||
let mut count = 0usize;
|
||||
let mut shapes = vec![];
|
||||
for (i, n) in src.iter().enumerate() {
|
||||
let (mut path, rule) = b::node_outline(n).ok_or_else(|| bad("shaper.scribble", "use paths or compound paths"))?;
|
||||
count = count.saturating_add(path.subpaths.iter().map(|s| s.segment_count()).sum::<usize>());
|
||||
if count > 4096 || path.subpaths.iter().flat_map(|s| &s.anchors).any(|a| !finite(a.p) || !finite(a.h_in) || !finite(a.h_out)) {
|
||||
return Err(bad("shaper.scribble", "source paths are too large for Shaper"));
|
||||
}
|
||||
if !n.visible {
|
||||
path = PathData::default();
|
||||
} else if !n.appearance.fill_paint().is_none() {
|
||||
for sub in path.subpaths.iter_mut().filter(|s| !s.closed) {
|
||||
sub.closed = po::encloses_area(sub);
|
||||
}
|
||||
}
|
||||
shapes.push(po::Shape::new(path, rule, i as u64));
|
||||
}
|
||||
let arrangement = po::shape_builder(&shapes, true);
|
||||
let bounded = |pieces: Vec<po::Shape>| pieces.into_iter().filter_map(|p| p.path.bounds().map(|b| (p, b))).collect();
|
||||
let regions = arrangement
|
||||
.regions
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let bounds = r.path.bounds()?;
|
||||
let path = r.path.to_bezpath();
|
||||
Some((r, path, bounds))
|
||||
})
|
||||
.collect();
|
||||
let map = BuilderMap { shapes, regions, edges: bounded(arrangement.edges), lines: bounded(arrangement.lines) };
|
||||
if map.regions.is_empty() && map.lines.is_empty() && map.edges.is_empty() {
|
||||
return Err(bad("shaper.scribble", "these paths are too degenerate for Shaper"));
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn relative(src: &[Arc<Node>], i: usize, p: Point) -> Point {
|
||||
let b = src.get(i).and_then(|n| n.geometric_bounds()).unwrap_or(Rect::ZERO);
|
||||
Point::new((p.x - b.x0) / b.width().max(1e-9), (p.y - b.y0) / b.height().max(1e-9))
|
||||
}
|
||||
fn absolute(src: &[Arc<Node>], i: usize, p: Point) -> Point {
|
||||
let b = src.get(i).and_then(|n| n.geometric_bounds()).unwrap_or(Rect::ZERO);
|
||||
Point::new(b.x0 + p.x * b.width(), b.y0 + p.y * b.height())
|
||||
}
|
||||
fn face_selector(map: &BuilderMap, src: &[Arc<Node>], i: usize) -> Option<FaceSelector> {
|
||||
let (r, _, bounds) = map.regions.get(i)?;
|
||||
let source = *r.sources.first()?;
|
||||
let point = po::interior_point(&r.path).unwrap_or(bounds.center());
|
||||
Some(FaceSelector { sources: r.sources.clone(), anchor: relative(src, source, point) })
|
||||
}
|
||||
fn selected_face(map: &BuilderMap, src: &[Arc<Node>], selector: &FaceSelector) -> Option<usize> {
|
||||
let point = absolute(src, *selector.sources.first()?, selector.anchor);
|
||||
map.regions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (r, _, _))| r.sources == selector.sources)
|
||||
.min_by(|(_, a), (_, b)| {
|
||||
let distance = |r: &b::Face| if r.1.winding(point) != 0 { 0.0 } else { r.2.center().distance(point) };
|
||||
distance(a).total_cmp(&distance(b))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
fn piece_point(path: &PathData) -> Option<Point> {
|
||||
use vectorcraft_geom::ParamCurve as _;
|
||||
let sub = path.subpaths.first()?;
|
||||
(sub.segment_count() > 0).then(|| sub.segment(sub.segment_count() / 2).eval(0.5))
|
||||
}
|
||||
fn pieces(map: &BuilderMap) -> Vec<&po::Shape> {
|
||||
map.edges.iter().chain(&map.lines).map(|(p, _)| p).collect()
|
||||
}
|
||||
fn side_sources(map: &BuilderMap, p: &po::Shape) -> [Vec<usize>; 2] {
|
||||
let mut sides = map.sides(p).map(|i| i.and_then(|i| map.regions.get(i)).map_or(vec![], |r| r.0.sources.clone()));
|
||||
sides.sort();
|
||||
sides
|
||||
}
|
||||
fn edge_selector(map: &BuilderMap, src: &[Arc<Node>], piece: &po::Shape) -> Option<EdgeEdit> {
|
||||
let source = usize::try_from(piece.key).ok()?;
|
||||
Some(EdgeEdit { source, sides: side_sources(map, piece), anchor: relative(src, source, piece_point(&piece.path)?), erase: true, paint: None })
|
||||
}
|
||||
fn selected_edge(map: &BuilderMap, src: &[Arc<Node>], edit: &EdgeEdit) -> Option<usize> {
|
||||
let point = absolute(src, edit.source, edit.anchor);
|
||||
pieces(map)
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.key == edit.source as u64 && side_sources(map, p) == edit.sides)
|
||||
.filter_map(|(i, p)| p.path.nearest(point).map(|(_, _, _, _, distance)| (i, distance)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|p| p.0)
|
||||
}
|
||||
fn face_edits<'a>(map: &BuilderMap, src: &[Arc<Node>], spec: &'a ShaperSpec) -> BTreeMap<usize, &'a FaceEdit> {
|
||||
spec.faces.iter().filter_map(|e| selected_face(map, src, &e.selector).map(|i| (i, e))).collect()
|
||||
}
|
||||
fn fill_source(src: &[Arc<Node>], r: &po::Region) -> Option<usize> {
|
||||
r.sources.iter().rev().copied().find(|i| src.get(*i).is_some_and(|s| s.visible && !s.appearance.fill_paint().is_none()))
|
||||
}
|
||||
/// Regions separated only by covered strokes are one visible face. A visible line
|
||||
/// through a circle still separates two faces, even though their fills have the same source.
|
||||
fn visible_components(map: &BuilderMap, src: &[Arc<Node>], edits: &BTreeMap<usize, &FaceEdit>) -> Vec<usize> {
|
||||
fn root(parents: &[usize], mut i: usize) -> usize {
|
||||
for _ in 0..parents.len() {
|
||||
let Some(&next) = parents.get(i) else { return i };
|
||||
if next == i {
|
||||
return i;
|
||||
}
|
||||
i = next;
|
||||
}
|
||||
i
|
||||
}
|
||||
let owner = |i: usize| {
|
||||
let edit = edits.get(&i);
|
||||
if edit.is_some_and(|e| e.erase || e.paint.as_ref().is_some_and(|a| a.fill_paint().is_none())) {
|
||||
return None;
|
||||
}
|
||||
edit.and_then(|e| e.paint_source).or_else(|| map.regions.get(i).and_then(|r| fill_source(src, &r.0)))
|
||||
};
|
||||
let mut parents: Vec<usize> = (0..map.regions.len()).collect();
|
||||
for piece in pieces(map) {
|
||||
let [Some(a), Some(b)] = map.sides(piece) else { continue };
|
||||
let (Some(left), Some(right)) = (owner(a), owner(b)) else { continue };
|
||||
let merge = |i| edits.get(&i).and_then(|e| e.merge);
|
||||
let merged = merge(a).is_some() && merge(a) == merge(b);
|
||||
let covered = left as u64 > piece.key && right as u64 > piece.key;
|
||||
let unstroked = src.get(piece.key as usize).is_none_or(|n| !n.visible || n.appearance.stroke_paint().is_none());
|
||||
if (left == right || merged) && (merged || covered || unstroked) {
|
||||
let (a, b) = (root(&parents, a), root(&parents, b));
|
||||
if let Some(parent) = parents.get_mut(a) {
|
||||
*parent = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
(0..parents.len()).map(|i| root(&parents, i)).collect()
|
||||
}
|
||||
|
||||
fn fill_style(mut a: Appearance) -> Appearance {
|
||||
a.items.retain(AppearanceItem::is_fill);
|
||||
a
|
||||
}
|
||||
fn stroke_style(mut a: Appearance) -> Appearance {
|
||||
a.items.retain(|i| !i.is_fill());
|
||||
a
|
||||
}
|
||||
|
||||
/// Join compatible strokes at two-way junctions. Branches stay separate; a line
|
||||
/// crossing a circle must not turn into the circle's outline at the intersection.
|
||||
fn join_strokes(nodes: Vec<Arc<Node>>) -> Vec<Arc<Node>> {
|
||||
const TOL: f64 = 1e-5;
|
||||
type End = (usize, bool);
|
||||
let mut buckets: BTreeMap<(i64, i64), Vec<(End, Point)>> = BTreeMap::new();
|
||||
let key = |p: Point| finite(p).then(|| ((p.x / TOL).floor() as i64, (p.y / TOL).floor() as i64));
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
let Some(sp) = n.path_data().filter(|p| p.subpaths.len() == 1).and_then(|p| p.subpaths.first()).filter(|p| !p.closed) else { continue };
|
||||
for (last, a) in [(false, sp.anchors.first()), (true, sp.anchors.last())] {
|
||||
if let Some(a) = a
|
||||
&& let Some(key) = key(a.p)
|
||||
{
|
||||
buckets.entry(key).or_default().push(((i, last), a.p));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut connections: BTreeMap<End, End> = BTreeMap::new();
|
||||
for entries in buckets.values() {
|
||||
for &(end, p) in entries {
|
||||
let Some((x, y)) = key(p) else { continue };
|
||||
let mut near = vec![];
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
if let Some(entries) = buckets.get(&(x + dx, y + dy)) {
|
||||
near.extend(entries.iter().filter(|(_, q)| p.distance(*q) <= TOL).map(|(end, _)| *end));
|
||||
}
|
||||
}
|
||||
}
|
||||
if near.len() != 2 {
|
||||
continue;
|
||||
}
|
||||
let Some(other) = near.into_iter().find(|e| *e != end) else { continue };
|
||||
let Some((a, b)) = nodes.get(end.0).zip(nodes.get(other.0)) else { continue };
|
||||
if a.appearance == b.appearance && a.opacity == b.opacity && a.blend == b.blend {
|
||||
connections.insert(end, other);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut out = vec![];
|
||||
// Open chains first, then closed loops. This avoids beginning in a chain's middle.
|
||||
let starts = nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, _)| [(i, false), (i, true)])
|
||||
.filter(|e| !connections.contains_key(e))
|
||||
.chain(nodes.iter().enumerate().map(|(i, _)| (i, false)));
|
||||
for start in starts {
|
||||
if seen.contains(&start.0) {
|
||||
continue;
|
||||
}
|
||||
if let Some(node) = nodes.get(start.0).filter(|n| n.path_data().is_none_or(|p| p.subpaths.len() != 1)) {
|
||||
seen.insert(start.0);
|
||||
out.push(node.clone());
|
||||
continue;
|
||||
}
|
||||
let mut end = start;
|
||||
let mut prototype = start.0;
|
||||
let mut path = vectorcraft_geom::SubPath::default();
|
||||
loop {
|
||||
if !seen.insert(end.0) {
|
||||
break;
|
||||
}
|
||||
prototype = prototype.min(end.0);
|
||||
let Some(mut next) = nodes.get(end.0).and_then(|n| n.path_data()).and_then(|p| p.subpaths.first()).cloned() else { break };
|
||||
if end.1 {
|
||||
next.reverse();
|
||||
}
|
||||
if path.anchors.is_empty() {
|
||||
path = next;
|
||||
} else {
|
||||
if let Some((a, b)) = path.anchors.last_mut().zip(next.anchors.first()) {
|
||||
*a = vectorcraft_geom::Anchor::with_handles(a.p, a.h_in, b.h_out);
|
||||
}
|
||||
path.anchors.extend(next.anchors.into_iter().skip(1));
|
||||
}
|
||||
let Some(&next) = connections.get(&(end.0, !end.1)) else { break };
|
||||
if next == start {
|
||||
if let Some(last) = path.anchors.pop()
|
||||
&& let Some(first) = path.anchors.first_mut()
|
||||
{
|
||||
*first = vectorcraft_geom::Anchor::with_handles(first.p, last.h_in, first.h_out);
|
||||
}
|
||||
path.closed = true;
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
if let Some(mut node) = nodes.get(prototype).cloned() {
|
||||
if let Some(p) = Arc::make_mut(&mut node).path_data_mut() {
|
||||
*p = PathData::new(vec![path]);
|
||||
}
|
||||
out.push(node);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn generated(d: &mut Document, src: &[Arc<Node>], spec: &ShaperSpec, map: &BuilderMap) -> Result<Vec<Arc<Node>>> {
|
||||
if spec.faces.len() + spec.edges.len() > MAX_EDITS {
|
||||
return Err(bad("shaper.scribble", "too many Shaper edits"));
|
||||
}
|
||||
let edits = face_edits(map, src, spec);
|
||||
let mut fills: BTreeMap<(Option<u64>, usize), Vec<usize>> = BTreeMap::new();
|
||||
for (i, (r, _, _)) in map.regions.iter().enumerate() {
|
||||
let edit = edits.get(&i);
|
||||
if edit.is_some_and(|e| e.erase) {
|
||||
continue;
|
||||
}
|
||||
if edit.and_then(|e| e.paint_source).or_else(|| fill_source(src, r)).is_none() {
|
||||
continue;
|
||||
}
|
||||
let merge = edit.and_then(|e| e.merge);
|
||||
fills.entry((merge, if merge.is_some() { 0 } else { i })).or_default().push(i);
|
||||
}
|
||||
let mut out = vec![];
|
||||
for indices in fills.values() {
|
||||
let Some(&i) = indices.first() else { continue };
|
||||
let Some((region, _, _)) = map.regions.get(i) else { continue };
|
||||
let edit = edits.get(&i);
|
||||
let Some(source) = edit.and_then(|e| e.paint_source).or_else(|| fill_source(src, region)).and_then(|i| src.get(i)) else { continue };
|
||||
let path = po::unite_all(&indices.iter().filter_map(|i| map.regions.get(*i).map(|r| (&r.0.path, FillRule::NonZero))).collect::<Vec<_>>());
|
||||
let style = edit.and_then(|e| e.paint.clone()).unwrap_or_else(|| source.appearance.clone());
|
||||
let mut n = Node::path(d.alloc_id(), path, fill_style(style));
|
||||
n.opacity = source.opacity;
|
||||
n.blend = source.blend;
|
||||
n.name = Some(format!("{FILL_PREFIX}{i}"));
|
||||
out.push(Arc::new(n));
|
||||
}
|
||||
let edge_edits: BTreeMap<usize, &EdgeEdit> = spec.edges.iter().filter_map(|e| selected_edge(map, src, e).map(|i| (i, e))).collect();
|
||||
let mut strokes = vec![];
|
||||
for (i, piece) in pieces(map).into_iter().enumerate() {
|
||||
let sides = map.sides(piece);
|
||||
let merge = |r: Option<usize>| r.and_then(|r| edits.get(&r)).and_then(|e| e.merge);
|
||||
let merged_inside = merge(sides[0]).is_some() && merge(sides[0]) == merge(sides[1]);
|
||||
// An exiting scribble removes the exposed stroke around the deleted area.
|
||||
// Its shared boundary with surviving art still closes that art's contour.
|
||||
let erased_boundary =
|
||||
sides.iter().any(Option::is_some) && sides.iter().all(|r| r.is_none_or(|r| edits.get(&r).is_some_and(|e| e.erase && e.erase_edges)));
|
||||
let occluded = sides
|
||||
.iter()
|
||||
.all(|r| r.and_then(|r| map.regions.get(r)).and_then(|r| fill_source(src, &r.0)).is_some_and(|source| source as u64 > piece.key));
|
||||
if merged_inside || erased_boundary || occluded || edge_edits.get(&i).is_some_and(|e| e.erase) {
|
||||
continue;
|
||||
}
|
||||
let Some(source) = src.get(piece.key as usize) else { continue };
|
||||
let style = edge_edits.get(&i).and_then(|e| e.paint.clone()).unwrap_or_else(|| source.appearance.clone());
|
||||
if style.stroke_paint().is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut n = Node::path(d.alloc_id(), piece.path.clone(), stroke_style(style));
|
||||
n.opacity = source.opacity;
|
||||
n.blend = source.blend;
|
||||
n.name = Some(format!("{STROKE_PREFIX}{i}"));
|
||||
strokes.push(Arc::new(n));
|
||||
}
|
||||
out.extend(join_strokes(strokes));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Re-evaluate only groups whose original art changed. Errors reach Session::edit's rollback.
|
||||
pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
let mut ids = vec![];
|
||||
d.walk(|n| {
|
||||
if n.shaper.is_some() {
|
||||
ids.push(n.id)
|
||||
}
|
||||
});
|
||||
for id in ids {
|
||||
let Some(current) = d.node(id).cloned() else { continue };
|
||||
let Some(old) = before.node(id) else { continue };
|
||||
let src = sources(¤t);
|
||||
let source_changed = sources(old) != src;
|
||||
let output_changed = current.children().is_some_and(|children| {
|
||||
children
|
||||
.iter()
|
||||
.skip(1)
|
||||
.any(|n| old.children().and_then(|c| c.iter().find(|o| o.id == n.id)).is_some_and(|o| o.appearance != n.appearance))
|
||||
});
|
||||
if !source_changed && !output_changed {
|
||||
continue;
|
||||
}
|
||||
if src.is_empty() {
|
||||
d.remove(id)?;
|
||||
continue;
|
||||
}
|
||||
let map = map_of(&src)?;
|
||||
let mut spec = current.shaper.as_deref().cloned().unwrap_or_default();
|
||||
let previous = sources(old);
|
||||
if previous.iter().map(|s| s.id).ne(src.iter().map(|s| s.id)) {
|
||||
// Source insertion, deletion or reordering updates the index-based recipes.
|
||||
let index = |i: usize| previous.get(i).and_then(|old| src.iter().position(|n| n.id == old.id));
|
||||
spec.faces.retain_mut(|e| {
|
||||
let Some(indices) = e.selector.sources.iter().map(|i| index(*i)).collect::<Option<Vec<_>>>() else { return false };
|
||||
let paint_source = match e.paint_source {
|
||||
Some(i) => {
|
||||
let Some(i) = index(i) else { return false };
|
||||
Some(i)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
e.selector.sources = indices;
|
||||
e.selector.sources.sort();
|
||||
e.paint_source = paint_source;
|
||||
true
|
||||
});
|
||||
spec.edges.retain_mut(|e| {
|
||||
let Some(source) = index(e.source) else { return false };
|
||||
for side in &mut e.sides {
|
||||
let Some(indices) = side.iter().map(|i| index(*i)).collect::<Option<Vec<_>>>() else { return false };
|
||||
*side = indices;
|
||||
side.sort();
|
||||
}
|
||||
e.sides.sort();
|
||||
e.source = source;
|
||||
true
|
||||
});
|
||||
}
|
||||
if output_changed {
|
||||
for n in current.children().into_iter().flatten().skip(1) {
|
||||
let Some(o) = old.children().and_then(|c| c.iter().find(|o| o.id == n.id)) else { continue };
|
||||
if o.appearance == n.appearance {
|
||||
continue;
|
||||
}
|
||||
if let Some(i) = n.name.as_deref().and_then(|s| s.strip_prefix(FILL_PREFIX)).and_then(|s| s.parse::<usize>().ok()) {
|
||||
let existing = face_edits(&map, &src, &spec);
|
||||
let merge = existing.get(&i).and_then(|e| e.merge);
|
||||
let components = visible_components(&map, &src, &existing);
|
||||
let related: Vec<usize> = if let Some(m) = merge {
|
||||
existing.iter().filter(|(_, e)| e.merge == Some(m)).map(|(i, _)| *i).collect()
|
||||
} else {
|
||||
components.iter().enumerate().filter(|(_, c)| Some(*c) == components.get(i)).map(|(i, _)| i).collect()
|
||||
};
|
||||
for i in related {
|
||||
let Some(selector) = face_selector(&map, &src, i) else { continue };
|
||||
if let Some(e) = spec.faces.iter_mut().rev().find(|e| e.selector == selector) {
|
||||
e.paint = Some(n.appearance.clone());
|
||||
} else {
|
||||
spec.faces.push(FaceEdit {
|
||||
selector,
|
||||
erase: false,
|
||||
erase_edges: false,
|
||||
paint_source: None,
|
||||
merge,
|
||||
paint: Some(n.appearance.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX)) {
|
||||
// One joined output may contain pieces from several original paths.
|
||||
for piece in pieces(&map) {
|
||||
let contained = piece
|
||||
.path
|
||||
.subpaths
|
||||
.iter()
|
||||
.flat_map(|s| &s.anchors)
|
||||
.all(|a| n.path_data().and_then(|p| p.nearest(a.p)).is_some_and(|p| p.4 < 1e-5))
|
||||
&& piece_point(&piece.path).is_some_and(|point| n.path_data().and_then(|p| p.nearest(point)).is_some_and(|p| p.4 < 1e-5));
|
||||
if contained && let Some(mut e) = edge_selector(&map, &src, piece) {
|
||||
e.erase = false;
|
||||
e.paint = Some(n.appearance.clone());
|
||||
spec.edges.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut children = current.children().and_then(|c| c.first()).cloned().into_iter().collect::<Vec<_>>();
|
||||
let old_ids: BTreeMap<&str, NodeId> =
|
||||
current.children().into_iter().flatten().skip(1).filter_map(|n| n.name.as_deref().map(|name| (name, n.id))).collect();
|
||||
for mut n in generated(d, &src, &spec, &map)? {
|
||||
if let Some(id) = n.name.as_deref().and_then(|name| old_ids.get(name)) {
|
||||
Arc::make_mut(&mut n).id = *id;
|
||||
}
|
||||
children.push(n);
|
||||
}
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
n.shaper = Some(Box::new(spec));
|
||||
if let Some(ch) = n.children_mut() {
|
||||
*ch = children;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
let area = points.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| b.union_pt(*p)).inflate(tolerance, tolerance);
|
||||
let supplied = p.get("ids").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_u64).map(NodeId).collect::<Vec<_>>());
|
||||
let expand = supplied.is_none() && st.selection.objects.len() < 2;
|
||||
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
|
||||
let eligible: Vec<NodeId> = ids
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
st.doc.is_editable(*id)
|
||||
&& st.doc.node(*id).is_some_and(|n| {
|
||||
!matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. })
|
||||
&& (n.shaper.is_some() || b::node_outline(n).is_some())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let bounds = |id| st.doc.node(id).and_then(Node::geometric_bounds).map(|b| b.inflate(tolerance, tolerance));
|
||||
let overlaps = |a: Rect, b: Rect| a.intersect(b).area() > 0.0;
|
||||
let mut ids: Vec<NodeId> = eligible.iter().copied().filter(|id| bounds(*id).is_some_and(|b| overlaps(b, area))).collect();
|
||||
if expand {
|
||||
// A protruding line must still be split at the shape it passes through, even when
|
||||
// the scribble itself never enters that shape. Include the connected neighbourhood.
|
||||
loop {
|
||||
let next: Vec<NodeId> = eligible
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| {
|
||||
!ids.contains(id)
|
||||
&& bounds(*id).is_some_and(|bb| {
|
||||
ids.iter()
|
||||
.any(|other| st.doc.parent_of(*other) == st.doc.parent_of(*id) && bounds(*other).is_some_and(|b| overlaps(bb, b)))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if next.is_empty() {
|
||||
break;
|
||||
}
|
||||
ids.extend(next);
|
||||
if ids.len() > MAX_SOURCES {
|
||||
return Err(bad("shaper.scribble", "too many overlapping paths for Shaper"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids = b::sorted_roots(&st.doc, &ids);
|
||||
let parent = ids.first().and_then(|id| st.doc.position(*id)).map(|p| p.0);
|
||||
if ids.iter().any(|id| st.doc.position(*id).map(|p| p.0) != parent) {
|
||||
return Err(bad("shaper.scribble", "overlapping Shaper paths must be in the same layer or group"));
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "shaper.scribble";
|
||||
let input = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|a| !a.is_empty() && a.len() <= MAX_POINTS)
|
||||
.ok_or_else(|| bad(C, "points must contain 1–4096 coordinates"))?;
|
||||
let pts = input
|
||||
.iter()
|
||||
.map(|v| point_param(&json!({"p": v}), "p").filter(|p| finite(*p)).ok_or_else(|| bad(C, "invalid point")))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let tolerance = f64_or(p, "tolerance", 4.0);
|
||||
if !tolerance.is_finite() || !(0.01..=1000.0).contains(&tolerance) {
|
||||
return Err(bad(C, "invalid tolerance"));
|
||||
}
|
||||
let step = (tolerance / 2.0).max(0.5);
|
||||
let sample_count = pts.windows(2).map(|w| (w[0].distance(w[1]) / step).ceil()).sum::<f64>();
|
||||
if sample_count > 65_536.0 {
|
||||
return Err(bad(C, "scribble is too long"));
|
||||
}
|
||||
let points = b::sample_polyline(&pts, step);
|
||||
let roots = candidates(s, p, &points, tolerance)?;
|
||||
if roots.is_empty() {
|
||||
return Ok(json!({"id": null, "regions": 0, "strokes": 0}));
|
||||
}
|
||||
let mut src = vec![];
|
||||
let mut spec = ShaperSpec::default();
|
||||
let mut next_merge = 1u64;
|
||||
for id in &roots {
|
||||
let n = s.doc()?.doc.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if let Some(previous) = n.shaper.as_deref() {
|
||||
let offset = src.len();
|
||||
let merge_offset = next_merge;
|
||||
for mut e in previous.faces.clone() {
|
||||
for i in &mut e.selector.sources {
|
||||
*i = i.saturating_add(offset);
|
||||
}
|
||||
e.paint_source = e.paint_source.map(|i| i.saturating_add(offset));
|
||||
e.merge = e.merge.map(|i| i.saturating_add(merge_offset));
|
||||
next_merge = next_merge.max(e.merge.unwrap_or(0).saturating_add(1));
|
||||
spec.faces.push(e);
|
||||
}
|
||||
for mut e in previous.edges.clone() {
|
||||
e.source = e.source.saturating_add(offset);
|
||||
for side in &mut e.sides {
|
||||
for i in side {
|
||||
*i = i.saturating_add(offset);
|
||||
}
|
||||
}
|
||||
spec.edges.push(e);
|
||||
}
|
||||
src.extend(sources(n));
|
||||
} else {
|
||||
src.push(Arc::new(n.clone()));
|
||||
}
|
||||
}
|
||||
let map = map_of(&src)?;
|
||||
let edits = face_edits(&map, &src, &spec);
|
||||
let filled = |i: usize| edits.get(&i).is_none_or(|e| !e.erase) && map.regions.get(i).and_then(|r| fill_source(&src, &r.0)).is_some();
|
||||
let components = visible_components(&map, &src, &edits);
|
||||
let faces: std::collections::BTreeSet<usize> =
|
||||
points.iter().filter_map(|p| map.region_at(*p)).filter(|i| filled(*i)).filter_map(|i| components.get(i).copied()).collect();
|
||||
let touched: Vec<usize> = components.iter().enumerate().filter(|(_, c)| faces.contains(c)).map(|(i, _)| i).collect();
|
||||
let first = points.first().copied().unwrap_or(Point::ZERO);
|
||||
let last = points.last().copied().unwrap_or(first);
|
||||
let origin = map.region_at(first).filter(|i| filled(*i));
|
||||
let exits = map.region_at(last).is_none();
|
||||
let merge = origin.is_some() && faces.len() > 1 && !exits;
|
||||
let paint_source =
|
||||
origin.and_then(|i| edits.get(&i).and_then(|e| e.paint_source).or_else(|| map.regions.get(i).and_then(|r| fill_source(&src, &r.0))));
|
||||
let origin_paint = origin.and_then(|i| edits.get(&i).and_then(|e| e.paint.clone()));
|
||||
for &i in &touched {
|
||||
if let Some(selector) = face_selector(&map, &src, i) {
|
||||
spec.faces.push(FaceEdit {
|
||||
selector,
|
||||
erase: !merge,
|
||||
erase_edges: exits,
|
||||
paint_source,
|
||||
merge: merge.then_some(next_merge),
|
||||
paint: origin_paint.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut strokes = 0;
|
||||
if touched.is_empty() {
|
||||
let mut piece_ids = vec![];
|
||||
for point in &points {
|
||||
let index = match map.hit(*point, Some(tolerance)) {
|
||||
Some(Hit::Line(i)) => Some(map.edges.len() + i),
|
||||
Some(Hit::Edge(i)) => Some(i),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(i) = index
|
||||
&& !piece_ids.contains(&i)
|
||||
{
|
||||
piece_ids.push(i);
|
||||
}
|
||||
}
|
||||
for i in piece_ids {
|
||||
if let Some(e) = pieces(&map).get(i).and_then(|p| edge_selector(&map, &src, p)) {
|
||||
spec.edges.push(e);
|
||||
strokes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if touched.is_empty() && strokes == 0 {
|
||||
return Ok(json!({"id": null, "regions": 0, "strokes": 0}));
|
||||
}
|
||||
let id = s.edit("Shaper", |d, sel| {
|
||||
let top = *roots.last().ok_or_else(|| bad(C, "no source art"))?;
|
||||
let (parent, index, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let earlier = roots.iter().filter(|id| **id != top && d.position(**id).is_some_and(|p| p.1 < index)).count();
|
||||
let source_id = d.alloc_id();
|
||||
let mut source = Node::group(source_id, src.clone());
|
||||
source.name = Some(SOURCES.into());
|
||||
source.opacity = 0.0;
|
||||
let mut children = vec![Arc::new(source)];
|
||||
children.extend(generated(d, &src, &spec, &map)?);
|
||||
let id = d.alloc_id();
|
||||
let mut group = Node::group(id, children);
|
||||
group.name = Some("Shaper Group".into());
|
||||
group.shaper = Some(Box::new(spec));
|
||||
for id in &roots {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
d.insert(parent, index.saturating_sub(earlier), group)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
s.doc_mut()?.isolation = None;
|
||||
Ok(json!({"id": id.0, "regions": touched.len(), "strokes": strokes}))
|
||||
}
|
||||
|
||||
fn group_at(d: &Document, point: Point, construction: bool) -> Option<NodeId> {
|
||||
let mut groups = vec![];
|
||||
d.walk(|n| {
|
||||
if n.shaper.is_some()
|
||||
&& d.is_editable(n.id)
|
||||
&& (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() })
|
||||
.is_some_and(|b| b.inflate(6.0, 6.0).contains(point))
|
||||
{
|
||||
groups.push(n.id)
|
||||
}
|
||||
});
|
||||
groups.last().copied()
|
||||
}
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let point = point_param(p, "point").filter(|p| finite(*p)).ok_or_else(|| bad("shaper.select", "missing point"))?;
|
||||
let st = s.doc()?;
|
||||
let source = bool_or(p, "source", false);
|
||||
let Some(group) = group_at(&st.doc, point, source) else { return Ok(json!({"id": null})) };
|
||||
let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?;
|
||||
let target = if source {
|
||||
sources(node)
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|n| {
|
||||
b::node_outline(n)
|
||||
.is_some_and(|(path, _)| path.to_bezpath().winding(point) != 0 || path.nearest(point).is_some_and(|(_, _, _, _, d)| d < 6.0))
|
||||
})
|
||||
.map(|n| n.id)
|
||||
.unwrap_or(group)
|
||||
} else if st.selection.contains(group) {
|
||||
node.children()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.skip(1)
|
||||
.find(|n| {
|
||||
n.name.as_deref().is_some_and(|n| n.starts_with(FILL_PREFIX)) && n.path_data().is_some_and(|p| p.to_bezpath().winding(point) != 0)
|
||||
})
|
||||
.map_or(group, |n| n.id)
|
||||
} else {
|
||||
group
|
||||
};
|
||||
let isolation = source.then(|| source_group(node).map(|n| n.id)).flatten();
|
||||
s.select(|_, sel| sel.set([target]))?;
|
||||
if let Some(id) = isolation {
|
||||
s.doc_mut()?.isolation = Some(id);
|
||||
}
|
||||
Ok(json!({"id": target.0}))
|
||||
}
|
||||
fn target(s: &Session, p: &Value) -> Result<NodeId> {
|
||||
let id = id_param(p, "id")
|
||||
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
|
||||
.ok_or_else(|| bad("shaper.release", "select a Shaper group"))?;
|
||||
s.doc()?.doc.node(id).filter(|n| n.shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?;
|
||||
Ok(id)
|
||||
}
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p)?;
|
||||
let ids = s.edit("Release Shaper Group", |d, sel| {
|
||||
let original = d.node(id).map(sources).ok_or(EngineError::NoNode(id))?;
|
||||
let (parent, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
d.remove(id)?;
|
||||
let mut ids = vec![];
|
||||
for (i, n) in original.into_iter().enumerate() {
|
||||
ids.push(d.insert(parent, index + i, (*n).clone())?);
|
||||
}
|
||||
sel.set(ids.iter().copied());
|
||||
Ok(ids)
|
||||
})?;
|
||||
s.doc_mut()?.isolation = None;
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p)?;
|
||||
s.edit("Expand Shaper Group", |d, sel| {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
n.shaper = None;
|
||||
n.name = None;
|
||||
if let Some(children) = n.children_mut()
|
||||
&& children.first().is_some_and(|c| c.name.as_deref() == Some(SOURCES))
|
||||
{
|
||||
children.remove(0);
|
||||
}
|
||||
sel.set([id]);
|
||||
Ok(())
|
||||
})?;
|
||||
s.doc_mut()?.isolation = None;
|
||||
Ok(json!({"id": id.0}))
|
||||
}
|
||||
@@ -70,8 +70,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
impl LibraryFile for StyleLibrary {
|
||||
const EXTS: &'static [&'static str] = &[STYLES_EXT];
|
||||
fn read(text: &str, stem: &str) -> std::result::Result<Self, String> {
|
||||
style_libs::read(text, stem)
|
||||
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String> {
|
||||
style_libs::read(&String::from_utf8_lossy(bytes), stem)
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
|
||||
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Other Library…",
|
||||
["Window", "Swatch Libraries"],
|
||||
None,
|
||||
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches or .gpl library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}",
|
||||
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}",
|
||||
always,
|
||||
load
|
||||
),
|
||||
@@ -98,23 +98,24 @@ struct Extra<L> {
|
||||
pub trait LibraryFile: Sized {
|
||||
/// The extensions of its files (lower case).
|
||||
const EXTS: &'static [&'static str];
|
||||
/// Read a library file whose name without the extension is `stem` (an unnamed library's name).
|
||||
fn read(text: &str, stem: &str) -> std::result::Result<Self, String>;
|
||||
/// Read the bytes of a library file whose name without the extension is `stem` (an unnamed
|
||||
/// library's name).
|
||||
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String>;
|
||||
fn name(&self) -> &str;
|
||||
}
|
||||
|
||||
impl LibraryFile for SwatchLibrary {
|
||||
const EXTS: &'static [&'static str] = LIBRARY_EXTS;
|
||||
fn read(text: &str, stem: &str) -> std::result::Result<Self, String> {
|
||||
palette_io::read(text, stem)
|
||||
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String> {
|
||||
palette_io::read_bytes(bytes, stem)
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
|
||||
/// The extensions of library files [`palette_io::read`] reads.
|
||||
pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl"];
|
||||
/// The extensions of library files [`palette_io::read_bytes`] reads.
|
||||
pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl", "ase"];
|
||||
|
||||
impl<L: LibraryFile> Libraries<L> {
|
||||
pub fn user_dir(&self) -> Option<&str> {
|
||||
@@ -133,7 +134,7 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
let Some(dir) = self.user_dir.clone() else { return };
|
||||
for path in library_files(&dir, L::EXTS) {
|
||||
let file = file_name(&path);
|
||||
let Some(lib) = read_file(&path).ok().and_then(|b| L::read(&String::from_utf8_lossy(&b), stem(&file)).ok()) else { continue };
|
||||
let Some(lib) = read_file(&path).ok().and_then(|b| L::read(&b, stem(&file)).ok()) else { continue };
|
||||
let info = LibraryInfo { id: format!("user/{file}"), name: lib.name().to_string(), category: "user" };
|
||||
self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) });
|
||||
}
|
||||
@@ -275,11 +276,18 @@ pub const DOCUMENT_SWATCHES: &str = "document";
|
||||
/// [`DOCUMENT_SWATCHES`], of the active document's swatches and colour groups. `None` when there is
|
||||
/// no such library (or document).
|
||||
pub fn limit_palette(s: &Session, key: &str) -> Option<Palette> {
|
||||
Some(Palette::new(library_colors(s, key)?))
|
||||
}
|
||||
|
||||
/// The solid colours of library `key` (an id or name) or, for [`DOCUMENT_SWATCHES`], of the
|
||||
/// active document's swatches and colour groups. `None` when there is no such library (or
|
||||
/// document).
|
||||
pub fn library_colors(s: &Session, key: &str) -> Option<Vec<Color>> {
|
||||
let colors = |sw: &Swatch| sw.paint.color();
|
||||
if key == DOCUMENT_SWATCHES {
|
||||
return Some(Palette::new(s.active()?.doc.swatches_iter().filter_map(colors)));
|
||||
return Some(s.active()?.doc.swatches_iter().filter_map(colors).collect());
|
||||
}
|
||||
Some(Palette::new(library(s, key)?.1.iter().filter_map(colors)))
|
||||
Some(library(s, key)?.1.iter().filter_map(colors).collect())
|
||||
}
|
||||
|
||||
/// The `limitTo` parameter's palette ([`limit_palette`]); `None` without one (or for "").
|
||||
@@ -351,15 +359,14 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.library.load";
|
||||
let (info, lib) = s.swatch_libraries.load(p, C, |bytes, file| {
|
||||
// A library file, or a document whose swatches become the library.
|
||||
match std::str::from_utf8(bytes).ok().filter(|t| palette_io::sniff(t)) {
|
||||
Some(t) => palette_io::read(t, stem(file)).map_err(|e| bad(C, e)),
|
||||
None => {
|
||||
let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
if loaded.preview_only {
|
||||
return Err(bad(C, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning"));
|
||||
}
|
||||
document_library(&loaded.doc, &[], stem(file).to_string(), C)
|
||||
if palette_io::sniff_bytes(bytes) {
|
||||
palette_io::read_bytes(bytes, stem(file)).map_err(|e| bad(C, e))
|
||||
} else {
|
||||
let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
if loaded.preview_only {
|
||||
return Err(bad(C, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning"));
|
||||
}
|
||||
document_library(&loaded.doc, &[], stem(file).to_string(), C)
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
|
||||
@@ -420,12 +427,17 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
s.edit("Add to Swatches", |d, _| {
|
||||
let mut out = vec![];
|
||||
// Each library group goes into one document group: the group of that name the document
|
||||
// already had, or one made here.
|
||||
let before = d.swatch_groups.len();
|
||||
let mut placed: Vec<(&str, usize)> = vec![];
|
||||
for (group, w) in fresh {
|
||||
let name = d.free_swatch_name(&w.name);
|
||||
let sw = Swatch { name: name.clone(), ..(*w).clone() };
|
||||
match group {
|
||||
Some(g) => {
|
||||
let i = match d.swatch_groups.iter().position(|x| x.name == *g) {
|
||||
let found = placed.iter().find(|(n, _)| *n == g).map(|&(_, i)| i);
|
||||
let i = match found.or_else(|| d.swatch_groups.iter().take(before).position(|x| x.name == *g)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let name = d.free_swatch_name(g);
|
||||
@@ -433,6 +445,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.swatch_groups.len() - 1
|
||||
}
|
||||
};
|
||||
if found.is_none() {
|
||||
placed.push((g, i));
|
||||
}
|
||||
d.swatch_groups[i].swatches.push(sw);
|
||||
}
|
||||
None => d.swatches.push(sw),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, TabAlign, TabStop};
|
||||
|
||||
use super::typecmd::{refresh_bounds, text_targets};
|
||||
use super::typecmd::{TextRange, para_span, refresh_bounds, text_targets};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -13,12 +13,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Tab Stops",
|
||||
["Window", "Tabs"],
|
||||
None,
|
||||
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?} replace the tab stops of the selected text",
|
||||
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches)",
|
||||
has_selection,
|
||||
set
|
||||
),
|
||||
cmd!(query "text.tabs.get", "Tab Stops", [], None, "{ids?} → {stops} of the first selected text object", has_selection, get),
|
||||
cmd!("text.tabs.clear", "Clear All Tabs", ["Window", "Tabs"], None, "{ids?}", has_selection, |s, p| apply(s, p, vec![], "Clear All Tabs")),
|
||||
cmd!(
|
||||
query "text.tabs.get",
|
||||
"Tab Stops",
|
||||
[],
|
||||
None,
|
||||
"{ids?, start?: byte} → {stops} of the first selected text object (its paragraph at `start`; default the first)",
|
||||
has_selection,
|
||||
get
|
||||
),
|
||||
cmd!("text.tabs.clear", "Clear All Tabs", ["Window", "Tabs"], None, "{ids?, start?: byte, end?: byte}", has_selection, |s, p| apply(
|
||||
s,
|
||||
p,
|
||||
vec![],
|
||||
"Clear All Tabs"
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -54,11 +67,13 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result<Value> {
|
||||
let ids = text_targets(s, p, "text.tabs.set")?;
|
||||
let range = TextRange::parse(p, "text.tabs.set")?;
|
||||
let n = stops.len();
|
||||
s.edit(label, |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
t.para.tabs = stops.clone();
|
||||
let span = para_span(range, t);
|
||||
t.edit_paras(span, |pa| pa.tabs = stops.clone());
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
@@ -69,9 +84,10 @@ fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = text_targets(s, p, "text.tabs.get")?;
|
||||
let range = TextRange::parse(p, "text.tabs.get")?;
|
||||
let st = s.doc()?;
|
||||
let tabs = ids.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => Some(t.para.tabs.clone()),
|
||||
Some(NodeKind::Text(t)) => Some(t.para_at(para_span(range, t).map_or(0, |r| r.start)).tabs.clone()),
|
||||
_ => None,
|
||||
});
|
||||
Ok(json!({ "stops": serde_json::to_value(tabs.unwrap_or_default()).unwrap_or(Value::Null) }))
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
|
||||
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -45,7 +45,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?, placeholder?: bool (placeholder text instead, as text.create), 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}",
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, 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), 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}",
|
||||
has_doc,
|
||||
create_in_path
|
||||
),
|
||||
@@ -135,6 +135,13 @@ fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let insert = str_param(p, "insert").unwrap_or("").to_string();
|
||||
let caret = s.edit("Typing", |d, _| {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
// Paragraph styles follow the edit: a split paragraph's new one continues its style, a
|
||||
// merge keeps the first paragraph's.
|
||||
let inserted: String = match &styled {
|
||||
Some(r) => r.iter().map(|r| r.text.as_str()).collect(),
|
||||
None => insert.clone(),
|
||||
};
|
||||
t.splice_paras(a, b, &inserted);
|
||||
let caret = match &styled {
|
||||
Some(r) => edit::replace_range_styled(&mut t.runs, a, b, r),
|
||||
None => edit::replace_range(&mut t.runs, a, b, &insert),
|
||||
@@ -191,7 +198,7 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>
|
||||
Ok(Some(vectorcraft_text::OtFeatures::default().with_tags(tags).to_tags()))
|
||||
}
|
||||
|
||||
/// `charAlign: "romanBaseline"|"emBoxTop"|"emBoxCenter"|"emBoxBottom"` (Character Alignment).
|
||||
/// `charAlign: "romanBaseline"|"emBoxTop"|"emBoxCenter"|"emBoxBottom"|"icfTop"|"icfBottom"` (Character Alignment).
|
||||
pub(crate) fn char_align_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::CharAlign>> {
|
||||
use vectorcraft_doc::CharAlign;
|
||||
let Some(v) = p.get("charAlign").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
@@ -200,7 +207,14 @@ pub(crate) fn char_align_param(p: &Value, cmd: &str) -> Result<Option<vectorcraf
|
||||
Some("emBoxTop") => CharAlign::EmBoxTop,
|
||||
Some("emBoxCenter") => CharAlign::EmBoxCenter,
|
||||
Some("emBoxBottom") => CharAlign::EmBoxBottom,
|
||||
_ => return Err(bad(cmd, "`charAlign` must be \"romanBaseline\", \"emBoxTop\", \"emBoxCenter\" or \"emBoxBottom\"")),
|
||||
Some("icfTop") => CharAlign::IcfTop,
|
||||
Some("icfBottom") => CharAlign::IcfBottom,
|
||||
_ => {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"`charAlign` must be \"romanBaseline\", \"emBoxTop\", \"emBoxCenter\", \"emBoxBottom\", \"icfTop\" or \"icfBottom\"",
|
||||
));
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -461,8 +475,9 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false),
|
||||
kind,
|
||||
xf: Affine::IDENTITY,
|
||||
runs: vec![TextRun { text, style }],
|
||||
runs: vec![TextRun { text, style, inline: None }],
|
||||
para: super::create::new_type_para(),
|
||||
paras: Vec::new(),
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
@@ -472,6 +487,9 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
super::create::new_type_alignment(s, p, C, &mut t)?;
|
||||
if !on_path {
|
||||
t.area.fit = super::create::new_area_fit(s, p, C)?;
|
||||
}
|
||||
if bool_or(p, "placeholder", false) {
|
||||
super::typemenu::fill_with_placeholder(&mut t);
|
||||
} else {
|
||||
@@ -597,6 +615,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
xf: Affine::IDENTITY,
|
||||
runs: head.clone(),
|
||||
para: Default::default(),
|
||||
paras: Vec::new(),
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
|
||||
@@ -66,7 +66,7 @@ macro_rules! style_cmds {
|
||||
concat!($p, ".apply"),
|
||||
concat!("Apply ", $what, " Style"),
|
||||
&[],
|
||||
"{name, clearOverrides?, ids?|id + start?/end? (a Type tool range)} → {count}",
|
||||
"{name, clearOverrides?, ids?|id + start?/end? (a Type tool range: character styles style it, paragraph styles apply to the paragraphs it touches)} → {count}",
|
||||
|s, p| apply(s, p, $kind),
|
||||
true,
|
||||
),
|
||||
@@ -179,7 +179,7 @@ fn text_mut(d: &mut Document, id: NodeId) -> Option<&mut vectorcraft_doc::TextOb
|
||||
}
|
||||
}
|
||||
|
||||
/// How many runs (char) or text objects (para) use `name`.
|
||||
/// How many runs (char) or paragraphs (para) use `name`.
|
||||
fn uses(d: &Document, kind: Kind, name: &str) -> usize {
|
||||
let target = (name != kind.normal()).then_some(name);
|
||||
let mut n = 0;
|
||||
@@ -187,7 +187,7 @@ fn uses(d: &Document, kind: Kind, name: &str) -> usize {
|
||||
if let NodeKind::Text(t) = &node.kind {
|
||||
n += match kind {
|
||||
Kind::Char => t.runs.iter().filter(|r| r.style.style_name.as_deref() == target).count(),
|
||||
Kind::Para => usize::from(t.para.style_name.as_deref() == target),
|
||||
Kind::Para => (0..t.paragraph_count()).filter(|&i| t.para_at(i).style_name.as_deref() == target).count(),
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -210,9 +210,11 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
|
||||
}
|
||||
}
|
||||
Kind::Para => {
|
||||
if t.para.style_name == target {
|
||||
t.para = restyle(&t.para, old, new);
|
||||
changed = true;
|
||||
for pa in t.para_styles_mut() {
|
||||
if pa.style_name == target {
|
||||
*pa = restyle(pa, old, new);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,12 +233,11 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
|
||||
None => text_targets(s, &json!({}), "").ok().and_then(|v| v.first().copied()),
|
||||
};
|
||||
let Some(NodeKind::Text(t)) = id.and_then(|i| d.node(i)).map(|n| &n.kind) else { return Ok(None) };
|
||||
let at = p.get("start").and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX));
|
||||
Ok(Some(match kind {
|
||||
Kind::Char => {
|
||||
let at = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
to_attrs(&vectorcraft_text::edit::style_at(&t.runs, at))
|
||||
}
|
||||
Kind::Para => to_attrs(&t.para),
|
||||
Kind::Char => to_attrs(&vectorcraft_text::edit::style_at(&t.runs, at)),
|
||||
// The paragraph at `start`.
|
||||
Kind::Para => to_attrs(t.para_at(t.paragraphs_in(at, at).start)),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -329,8 +330,9 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
let normal = kind.attrs(d, kind.normal()).unwrap_or_default();
|
||||
let clear = bool_or(p, "clearOverrides", false);
|
||||
let style_name = (name != kind.normal()).then(|| name.clone());
|
||||
// A Type tool range (character styles only) or whole text objects.
|
||||
let range = id_param(p, "id").filter(|_| kind == Kind::Char && (p.get("start").is_some() || p.get("end").is_some()));
|
||||
// A Type tool range or whole text objects.
|
||||
let text_range = super::typecmd::TextRange::parse(p, &c)?;
|
||||
let range = id_param(p, "id").filter(|_| text_range.is_some());
|
||||
let ids = match range.or_else(|| id_param(p, "id")) {
|
||||
Some(id) => vec![id],
|
||||
None => text_targets(s, p, &c)?,
|
||||
@@ -376,7 +378,17 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Kind::Para => t.para = para_fn(&t.para)?,
|
||||
Kind::Para => {
|
||||
let mut v = t.paragraph_styles();
|
||||
let span = match (range, text_range) {
|
||||
(Some(_), Some(r)) => r.paras(t),
|
||||
_ => 0..v.len(),
|
||||
};
|
||||
for st in v.iter_mut().take(span.end).skip(span.start) {
|
||||
*st = para_fn(st)?;
|
||||
}
|
||||
t.set_paragraph_styles(v);
|
||||
}
|
||||
}
|
||||
refresh_bounds(t);
|
||||
}
|
||||
@@ -447,7 +459,7 @@ fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
};
|
||||
match kind {
|
||||
Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)),
|
||||
Kind::Para => fix(&mut t.para.style_name),
|
||||
Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -472,7 +484,7 @@ fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
};
|
||||
match kind {
|
||||
Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)),
|
||||
Kind::Para => fix(&mut t.para.style_name),
|
||||
Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -75,14 +75,24 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
});
|
||||
// The story: live frames' runs, plus the text of frames deleted by this edit.
|
||||
let mut story: Vec<TextRun> = vec![];
|
||||
let mut sources: Vec<TextObject> = vec![];
|
||||
let mut later_wins = vec![];
|
||||
let mut live = vec![];
|
||||
for id in &thread {
|
||||
match (text_of(doc, *id), text_of(before, *id)) {
|
||||
(Some(t), _) => {
|
||||
(Some(t), old) => {
|
||||
story.extend(t.runs.iter().cloned());
|
||||
// A frame whose first paragraph was just restyled gives the paragraph it
|
||||
// shares with the frame before it that style.
|
||||
later_wins.push(old.is_some_and(|o| o.para_at(0) != t.para_at(0)));
|
||||
sources.push(t.clone());
|
||||
live.push(*id);
|
||||
}
|
||||
(None, Some(old)) => story.extend(old.runs.iter().cloned()),
|
||||
(None, Some(old)) => {
|
||||
story.extend(old.runs.iter().cloned());
|
||||
later_wins.push(false);
|
||||
sources.push(old.clone());
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
@@ -92,16 +102,33 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let source_refs: Vec<&TextObject> = sources.iter().collect();
|
||||
let paras = vectorcraft_text::thread::story_paras(&source_refs, &later_wins);
|
||||
vectorcraft_text::edit::normalize(&mut story);
|
||||
if live.len() >= 2 {
|
||||
// Threaded frames keep their size: Auto Size is off for them (as in Illustrator).
|
||||
for id in &live {
|
||||
if text_of(doc, *id).is_some_and(|t| t.area.fit == vectorcraft_doc::AreaFit::AutoHeight)
|
||||
&& let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
|
||||
{
|
||||
t.area.fit = vectorcraft_doc::AreaFit::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
|
||||
let refs: Vec<&TextObject> = frames.iter().collect();
|
||||
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story);
|
||||
for (id, runs) in live.iter().zip(parts) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
|
||||
&& t.runs != runs
|
||||
{
|
||||
t.runs = runs;
|
||||
refresh_bounds(t);
|
||||
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, ¶s);
|
||||
for (id, part) in live.iter().zip(parts) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) {
|
||||
let mut next = (**t).clone();
|
||||
next.runs = part.runs;
|
||||
next.set_paragraph_styles(part.paras);
|
||||
if next.runs != t.runs || next.para != t.para || next.paras != t.paras {
|
||||
t.runs = next.runs;
|
||||
t.para = next.para;
|
||||
t.paras = next.paras;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
if live.len() >= 2 {
|
||||
@@ -183,15 +210,17 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let mut story: Vec<TextRun> = vec![];
|
||||
for id in &thread {
|
||||
if let Some(t) = text_of(d, *id) {
|
||||
story.extend(t.runs.iter().cloned());
|
||||
}
|
||||
let sources: Vec<TextObject> = thread.iter().filter_map(|id| text_of(d, *id).cloned()).collect();
|
||||
for t in &sources {
|
||||
story.extend(t.runs.iter().cloned());
|
||||
}
|
||||
let paras = vectorcraft_text::thread::story_paras(&sources.iter().collect::<Vec<_>>(), &[]);
|
||||
let style = story.first().map(|r| r.style.clone()).unwrap_or_default();
|
||||
let first_para = paras.first().cloned().unwrap_or_default();
|
||||
for id in &out {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
t.runs = vec![TextRun { text: String::new(), style: style.clone() }];
|
||||
t.runs = vec![TextRun { text: String::new(), style: style.clone(), inline: None }];
|
||||
t.set_all_paras(first_para.clone());
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
@@ -200,6 +229,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
{
|
||||
vectorcraft_text::edit::normalize(&mut story);
|
||||
t.runs = story;
|
||||
t.set_paragraph_styles(paras);
|
||||
}
|
||||
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
|
||||
d.text_threads[i] = keep;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area Type Options…",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options",
|
||||
"{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, fit?: none|autoHeight|shrinkText, fitMinPercent?: 10..100} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit (none given: query) → the first object's options, plus overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)",
|
||||
has_selection,
|
||||
area_options
|
||||
),
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -97,13 +97,112 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
|
||||
Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Recompute the layout caches (bounds and baselines) after a text edit.
|
||||
/// 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
|
||||
/// step as the edit.
|
||||
pub(crate) fn refresh_bounds(t: &mut TextObject) {
|
||||
// Edits that changed the paragraph count keep one paragraph style per paragraph.
|
||||
t.normalize_paras();
|
||||
auto_height(t);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
t.cached_bounds = Some(lay.bounds);
|
||||
t.cached_baselines = lay.baselines();
|
||||
}
|
||||
|
||||
/// The text-space bounds of area type's frame, when Auto Size can fit it: a rectangle (one
|
||||
/// subpath, as large as its bounds) of horizontal type in one row, with some height.
|
||||
fn auto_height_frame(t: &TextObject) -> Option<vectorcraft_geom::Rect> {
|
||||
use vectorcraft_geom::Shape;
|
||||
let vectorcraft_doc::TextKind::Area { frame } = &t.kind else { return None };
|
||||
if t.area.fit != vectorcraft_doc::AreaFit::AutoHeight || t.vertical || t.area.rows > 1 || frame.subpaths.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let b = frame.bounds()?;
|
||||
let area = frame.to_bezpath().area().abs();
|
||||
let finite = [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite());
|
||||
(finite && b.height() > 1e-6 && b.width() > 1e-6 && (area - b.area()).abs() <= b.area() * 1e-6).then_some(b)
|
||||
}
|
||||
|
||||
/// Auto Size: move the bottom of a rectangular area type frame to just below its last line (plus
|
||||
/// the inset), so the frame's height follows the text. Text in several columns gets the least
|
||||
/// height (to 0.01 pt) at which it fits. True when the frame changed.
|
||||
fn auto_height(t: &mut TextObject) -> bool {
|
||||
use vectorcraft_geom::Shape;
|
||||
let Some(b) = auto_height_frame(t) else { return false };
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Lay out in a frame as tall as the canvas allows: everything the width lets through flows.
|
||||
let tall = (crate::MAX_COORD - b.y0).min(1.0e5);
|
||||
if tall <= 1.0 {
|
||||
return false;
|
||||
}
|
||||
let mut probe = t.clone();
|
||||
let with_size = |probe: &mut TextObject, w: f64, h: f64| {
|
||||
if let vectorcraft_doc::TextKind::Area { frame } = &mut probe.kind {
|
||||
*frame = PathData::from_bezpath(&vectorcraft_geom::Rect::new(b.x0, b.y0, b.x0 + w, b.y0 + h).to_path(0.1));
|
||||
}
|
||||
};
|
||||
// Columns: all the text in one column as wide as each of them is surely tall enough.
|
||||
let cols = t.area.columns.max(1);
|
||||
let col_w = if cols > 1 { ((b.width() - t.area.gutter.max(0.0) * (cols - 1) as f64) / cols as f64).max(1.0) } else { b.width() };
|
||||
with_size(&mut probe, col_w, tall);
|
||||
probe.area.columns = 1;
|
||||
// Measured from the top: aligned in the tall probe the lines would sit far below.
|
||||
probe.area.vertical_align = vectorcraft_doc::VerticalAlign::Top;
|
||||
let lay = vectorcraft_text::layout(db, &probe);
|
||||
let inset = t.area.inset.max(0.0);
|
||||
let Some(bottom) = lay.lines.iter().map(|l| l.baseline + l.descent).filter(|y| y.is_finite()).reduce(f64::max) else { return false };
|
||||
// The layout's fit test is `baseline + descent <= bottom + 0.01`: the last line fits exactly.
|
||||
let mut h = (bottom + inset - b.y0).clamp(1.0, tall);
|
||||
if cols > 1 {
|
||||
// The least height (to 0.01 pt) at which the columns hold the text.
|
||||
probe.area.columns = cols;
|
||||
let fits = |probe: &mut TextObject, h: f64| {
|
||||
with_size(probe, b.width(), h);
|
||||
!vectorcraft_text::layout(db, probe).overflow
|
||||
};
|
||||
if !fits(&mut probe, h) {
|
||||
return false;
|
||||
}
|
||||
let (mut lo, mut hi) = (1.0, h);
|
||||
for _ in 0..24 {
|
||||
if hi - lo <= 0.01 {
|
||||
break;
|
||||
}
|
||||
let mid = (lo + hi) * 0.5;
|
||||
if fits(&mut probe, mid) { hi = mid } else { lo = mid }
|
||||
}
|
||||
h = hi;
|
||||
}
|
||||
if (h - b.height()).abs() <= 1e-6 {
|
||||
return false;
|
||||
}
|
||||
// Scale the frame about its top edge, keeping its anchors (and their order) as they are.
|
||||
let a = Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, h / b.height()) * Affine::translate((0.0, -b.y0));
|
||||
let vectorcraft_doc::TextKind::Area { frame } = &mut t.kind else { return false };
|
||||
let mut next = frame.clone();
|
||||
next.transform(a);
|
||||
if next.bounds().is_none_or(|nb| ![nb.x0, nb.y0, nb.x1, nb.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD)) {
|
||||
return false;
|
||||
}
|
||||
*frame = next;
|
||||
true
|
||||
}
|
||||
|
||||
/// Recompute the layout bounds cache for the text among `ids` (and their descendants) that lacks
|
||||
/// it. Pasted or imported type arrives without the cache (not serialized), so selection boxes, the
|
||||
/// Transform panel and hit testing would fall back to [`TextObject::estimate_bounds`] (a rough
|
||||
/// 0.55 em per character) until the text is edited — the box comes up short and alignment looks off.
|
||||
pub(crate) fn refresh_bounds_of(d: &mut Document, ids: &[NodeId]) {
|
||||
for id in text_ids(d, ids) {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
|
||||
&& t.cached_bounds.is_none()
|
||||
{
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text objects among `ids` and their descendants.
|
||||
fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = vec![];
|
||||
@@ -133,7 +232,16 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let NodeKind::Text(t) = &n.kind else { continue };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let mut children = vec![];
|
||||
for g in &lay.glyphs {
|
||||
for (gi, g) in lay.glyphs.iter().enumerate() {
|
||||
// Inline graphics become instances of their symbols, in place.
|
||||
if let Some(ig) = lay.inlines.iter().find(|i| i.glyph == gi)
|
||||
&& let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref())
|
||||
{
|
||||
let id = d.alloc_id();
|
||||
let xf = t.xf * ig.xf * d.symbol_natural_xf(&art.symbol);
|
||||
children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf })));
|
||||
continue;
|
||||
}
|
||||
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
@@ -174,6 +282,60 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// Optional `start`/`end` byte offsets of a text command: the character range it styles and the
|
||||
/// paragraphs it touches. Without either, the command applies to all the text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct TextRange {
|
||||
start: Option<usize>,
|
||||
end: Option<usize>,
|
||||
}
|
||||
|
||||
impl TextRange {
|
||||
/// `start`/`end` of `p`, if either is given (non-negative integers).
|
||||
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Option<Self>> {
|
||||
let get = |k: &str| -> Result<Option<usize>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => {
|
||||
v.as_u64().map(|n| Some(usize::try_from(n).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, format!("`{k}` must be a byte offset")))
|
||||
}
|
||||
}
|
||||
};
|
||||
let (start, end) = (get("start")?, get("end")?);
|
||||
Ok((start.is_some() || end.is_some()).then_some(Self { start, end }))
|
||||
}
|
||||
/// The byte range in `t`, clamped and ordered.
|
||||
pub(crate) fn bytes(&self, t: &TextObject) -> (usize, usize) {
|
||||
let len = vectorcraft_text::edit::runs_len(&t.runs);
|
||||
let a = self.start.unwrap_or(0).min(len);
|
||||
let b = self.end.unwrap_or(len).min(len);
|
||||
(a.min(b), a.max(b))
|
||||
}
|
||||
/// The paragraphs of `t` the range touches.
|
||||
pub(crate) fn paras(&self, t: &TextObject) -> std::ops::Range<usize> {
|
||||
let (a, b) = self.bytes(t);
|
||||
t.paragraphs_in(a, b)
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraphs of `t` an optional range touches (None: every paragraph).
|
||||
pub(crate) fn para_span(range: Option<TextRange>, t: &TextObject) -> Option<std::ops::Range<usize>> {
|
||||
range.map(|r| r.paras(t))
|
||||
}
|
||||
|
||||
/// Apply `f` to the character styles of `range` of `t` (None: every run).
|
||||
pub(crate) fn style_chars(t: &mut TextObject, range: Option<TextRange>, mut f: impl FnMut(&mut vectorcraft_doc::CharStyle)) {
|
||||
match range {
|
||||
None => t.runs.iter_mut().for_each(|r| f(&mut r.style)),
|
||||
Some(r) => {
|
||||
let (a, b) = r.bytes(t);
|
||||
if a < b {
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, a, b, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string();
|
||||
let ids = text_targets(s, p, "text.setText")?;
|
||||
@@ -181,7 +343,9 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let style = t.first_style();
|
||||
t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style }];
|
||||
// Every paragraph of the new text takes the first paragraph's attributes.
|
||||
t.splice_paras(0, vectorcraft_text::edit::runs_len(&t.runs), &text);
|
||||
t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style, inline: None }];
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
@@ -238,14 +402,14 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let range = TextRange::parse(p, C)?;
|
||||
let ids = text_targets(s, p, C)?;
|
||||
let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some());
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let before = protect.then(|| t.runs.clone());
|
||||
for r in &mut t.runs {
|
||||
let st = &mut r.style;
|
||||
style_chars(t, range, |st| {
|
||||
if let Some(f) = &font {
|
||||
st.font_family = f.clone();
|
||||
}
|
||||
@@ -267,12 +431,13 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = &features {
|
||||
st.features = f.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(before) = before {
|
||||
super::textedit::protect_missing_glyphs(&before, &mut t.runs);
|
||||
}
|
||||
if let Some(j) = justify {
|
||||
t.para.justify = j;
|
||||
let span = para_span(range, t);
|
||||
t.edit_paras(span, |pa| pa.justify = j);
|
||||
}
|
||||
refresh_bounds(t);
|
||||
}
|
||||
@@ -292,10 +457,18 @@ pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObje
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let height = |t: &TextObject| auto_height_frame(t).map(|b| b.height());
|
||||
let was = height(t);
|
||||
if !(f(t) && within(t)) {
|
||||
t.kind = before;
|
||||
return false;
|
||||
}
|
||||
// Setting the height by hand turns Auto Size off (as in Illustrator); a new width keeps it.
|
||||
if let (Some(a), Some(b)) = (was, height(t))
|
||||
&& (a - b).abs() > 1e-6
|
||||
{
|
||||
t.area.fit = vectorcraft_doc::AreaFit::None;
|
||||
}
|
||||
refresh_bounds(t);
|
||||
true
|
||||
}
|
||||
@@ -334,6 +507,60 @@ fn size_area(t: &mut TextObject, w: Option<f64>, h: Option<f64>) -> bool {
|
||||
reshape_area_with(t, |t| t.transform_area(a))
|
||||
}
|
||||
|
||||
/// The fit `p` asks for (`fit`: none|autoHeight|shrinkText or `{"shrinkText": {"minPercent"}}`,
|
||||
/// `fitMinPercent`), starting from `cur`. None when `p` sets neither (or only `fitMinPercent`
|
||||
/// while the fit isn't Shrink Text).
|
||||
pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result<Option<AreaFit>> {
|
||||
let min = p.get("fitMinPercent").and_then(Value::as_f64);
|
||||
let cur_min = match cur {
|
||||
AreaFit::ShrinkText { min_percent } => Some(min_percent),
|
||||
_ => None,
|
||||
};
|
||||
let fit = match p.get("fit") {
|
||||
None | Some(Value::Null) => match (cur, min) {
|
||||
(AreaFit::ShrinkText { .. }, Some(m)) => AreaFit::ShrinkText { min_percent: m },
|
||||
_ => return Ok(None),
|
||||
},
|
||||
Some(Value::String(id)) => {
|
||||
AreaFit::parse(id, min.or(cur_min)).ok_or_else(|| bad(c, format!("fit must be none|autoHeight|shrinkText, got {id}")))?
|
||||
}
|
||||
Some(v) => match serde_json::from_value::<AreaFit>(v.clone()).map_err(|e| bad(c, format!("bad fit: {e}")))? {
|
||||
AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: min.unwrap_or(min_percent) },
|
||||
f => f,
|
||||
},
|
||||
};
|
||||
Ok(Some(match fit {
|
||||
AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: AreaFit::clamp_percent(min_percent) },
|
||||
f => f,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Area type's options as `text.areaOptions` reports them: the Area Type Options with `fit` as
|
||||
/// its id and `fitMinPercent` beside it, the frame's `width` and `height`, and whether the text
|
||||
/// `overflow`s its frame at `fitScale` (Shrink Text to Fit's factor; 1 unshrunk).
|
||||
pub(crate) fn area_options_json(t: &TextObject) -> Result<Value> {
|
||||
let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("fit".into(), json!(t.area.fit.id()));
|
||||
let min = match t.area.fit {
|
||||
AreaFit::ShrinkText { min_percent } => min_percent,
|
||||
_ => AreaFit::DEFAULT_MIN_PERCENT,
|
||||
};
|
||||
o.insert("fitMinPercent".into(), json!(min));
|
||||
if let Some((w, h, _)) = area_size(t) {
|
||||
o.insert("width".into(), json!(w));
|
||||
o.insert("height".into(), json!(h));
|
||||
}
|
||||
o.insert("overflow".into(), json!(lay.overflow));
|
||||
o.insert("fitScale".into(), json!(lay.fit_scale));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// The [`vectorcraft_doc::AreaOptions`] keys `text.areaOptions` merges as they are.
|
||||
const AREA_KEYS: [&str; 7] = ["rows", "columns", "gutter", "inset", "firstBaseline", "firstBaselineMin", "verticalAlign"];
|
||||
|
||||
fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.areaOptions";
|
||||
let ids: Vec<NodeId> = {
|
||||
@@ -343,34 +570,35 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let options = |s: &Session| -> Result<Value> {
|
||||
let t = area_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let (Some(o), Some((w, h, _))) = (v.as_object_mut(), area_size(t)) {
|
||||
o.insert("width".into(), json!(w));
|
||||
o.insert("height".into(), json!(h));
|
||||
}
|
||||
Ok(v)
|
||||
area_options_json(t)
|
||||
};
|
||||
let mut v = options(s)?;
|
||||
let size = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x.clamp(1.0, 100_000.0));
|
||||
let (w, h) = (size("width"), size("height"));
|
||||
let mut changed = w.is_some() || h.is_some();
|
||||
let cur = area_text(s.doc()?.doc.node(first)).map(|t| t.area.clone()).unwrap_or_default();
|
||||
let fit = fit_param(p, cur.fit, C)?;
|
||||
let mut v = serde_json::to_value(&cur).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let size = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(1.0, 100_000.0));
|
||||
let (w, mut h) = (size("width"), size("height"));
|
||||
let mut changed = w.is_some() || h.is_some() || fit.is_some();
|
||||
if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) {
|
||||
for (k, val) in src {
|
||||
if o.contains_key(k) && !matches!(k.as_str(), "ids" | "width" | "height") {
|
||||
o.insert(k.clone(), val.clone());
|
||||
changed = true;
|
||||
}
|
||||
for (k, val) in src.iter().filter(|(k, _)| AREA_KEYS.contains(&k.as_str())) {
|
||||
o.insert(k.clone(), val.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return Ok(v);
|
||||
return options(s);
|
||||
}
|
||||
let mut opts: vectorcraft_doc::AreaOptions = serde_json::from_value(v).map_err(|e| bad(C, e.to_string()))?;
|
||||
opts.rows = opts.rows.clamp(1, 100);
|
||||
opts.columns = opts.columns.clamp(1, 100);
|
||||
opts.gutter = opts.gutter.clamp(0.0, 10_000.0);
|
||||
opts.inset = opts.inset.clamp(0.0, 10_000.0);
|
||||
opts.first_baseline_min = opts.first_baseline_min.clamp(0.0, 10_000.0);
|
||||
let finite = |x: f64| if x.is_finite() { x } else { 0.0 };
|
||||
opts.gutter = finite(opts.gutter).clamp(0.0, 10_000.0);
|
||||
opts.inset = finite(opts.inset).clamp(0.0, 10_000.0);
|
||||
opts.first_baseline_min = finite(opts.first_baseline_min).clamp(0.0, 10_000.0);
|
||||
opts.fit = fit.unwrap_or(cur.fit);
|
||||
if opts.fit == AreaFit::AutoHeight {
|
||||
// Auto Size sets the height (Illustrator's dialog turns the Height field off).
|
||||
h = None;
|
||||
}
|
||||
s.edit("Area Type Options", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
@@ -426,7 +654,7 @@ mod area_tests {
|
||||
NodeKind::Text(t) => t.para.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
// New type: Standard, as in Illustrator.
|
||||
// New type: Standard.
|
||||
assert_eq!(para(&s).burasagari, Burasagari::Standard);
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
assert!(s.execute("text.setFormat", &json!({"burasagari": "strong"})).is_err());
|
||||
@@ -531,7 +759,10 @@ mod area_tests {
|
||||
s.execute("text.setFormat", &json!({"leadingModel": "emBoxTop"})).unwrap();
|
||||
let t = text(&s);
|
||||
assert_eq!(t.para.leading_model, LeadingModel::EmBoxTop);
|
||||
assert_ne!(t.cached_bounds, before, "the first line moves up to the frame's top");
|
||||
// The fallback used without craft-fonts has no glyph metrics to move the first line.
|
||||
if !vectorcraft_text::CRAFT_FONTS.is_empty() {
|
||||
assert_ne!(t.cached_bounds, before, "the first line moves up to the frame's top");
|
||||
}
|
||||
assert_eq!(serde_json::to_value(&t.para).unwrap()["leading_model"], "emBoxTop");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(serde_json::to_value(&text(&s).para).unwrap().get("leading_model").is_none());
|
||||
@@ -561,6 +792,12 @@ mod area_tests {
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "charAlign": "emBoxTop"})).unwrap();
|
||||
let aligns: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.char_align)).collect();
|
||||
assert_eq!(aligns, [("雅".to_string(), CharAlign::RomanBaseline), ("楽".to_string(), CharAlign::EmBoxTop)]);
|
||||
// The ideographic character face's top and bottom.
|
||||
for (key, want) in [("icfTop", CharAlign::IcfTop), ("icfBottom", CharAlign::IcfBottom)] {
|
||||
s.execute("text.setFormat", &json!({"charAlign": key})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.char_align == want));
|
||||
assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["charAlign"], key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -642,7 +879,8 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe);
|
||||
let cell = lay.frames.first()?;
|
||||
let line = lay.lines.first()?;
|
||||
let avail = cell.width() - 2.0 * t.area.inset - t.para.left_indent - t.para.right_indent;
|
||||
let para = t.para_at(0);
|
||||
let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent;
|
||||
Some((line.x1 - line.x0, avail, lay.lines.iter().filter(|l| l.start < para_end).count()))
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,8 @@ fn edit_runs<S: Default>(s: &mut Session, label: &str, ids: &[NodeId], f: impl F
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let mut st = S::default();
|
||||
let mut any = false;
|
||||
for r in &mut t.runs {
|
||||
// Inline graphics are art, not characters to rewrite.
|
||||
for r in t.runs.iter_mut().filter(|r| r.inline.is_none()) {
|
||||
let n = f(&r.text, &mut st);
|
||||
if n != r.text {
|
||||
r.text = n;
|
||||
@@ -272,7 +273,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0);
|
||||
let slack = size * 0.5;
|
||||
// Slack goes where the alignment leaves room, so the text doesn't move or rewrap.
|
||||
let (sl, sr) = match t.para.justify {
|
||||
let (sl, sr) = match t.para_at(0).justify {
|
||||
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
|
||||
Justify::Right | Justify::JustifyRight => (slack, 0.0),
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
|
||||
@@ -315,20 +316,33 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
breaks.sort_unstable();
|
||||
breaks.dedup();
|
||||
for b in breaks.into_iter().rev() {
|
||||
// Each wrapped line becomes a paragraph with its paragraph's attributes.
|
||||
if plain.as_bytes().get(b - 1) == Some(&b' ') {
|
||||
if let Some((ri, bi)) = locate(&t.runs, b - 1) {
|
||||
t.splice_paras(b - 1, b, "\n");
|
||||
t.runs[ri].text.replace_range(bi..bi + 1, "\n");
|
||||
}
|
||||
} else if let Some((ri, bi)) = locate(&t.runs, b) {
|
||||
t.runs[ri].text.insert(bi, '\n');
|
||||
t.splice_paras(b, b, "\n");
|
||||
// Never inside an inline graphic's run: at the end of the run before it, or a
|
||||
// run of its own.
|
||||
match t.runs.get(ri) {
|
||||
Some(r) if r.inline.is_some() => match ri.checked_sub(1).and_then(|p| t.runs.get_mut(p)) {
|
||||
Some(prev) if prev.inline.is_none() => prev.text.push('\n'),
|
||||
_ => {
|
||||
let style = t.runs.get(ri).map(|r| r.style.clone()).unwrap_or_default();
|
||||
t.runs.insert(ri, TextRun::new("\n", style));
|
||||
}
|
||||
},
|
||||
_ => t.runs[ri].text.insert(bi, '\n'),
|
||||
}
|
||||
}
|
||||
}
|
||||
// The point origin sits where the alignment anchors the first line.
|
||||
let (x0, x1) = lay
|
||||
.lines
|
||||
.first()
|
||||
.map_or((fb.x0, fb.x1), |l| (l.avail.0 - t.para.left_indent - t.para.first_line_indent, l.avail.1 + t.para.right_indent));
|
||||
let ox = match t.para.justify {
|
||||
let p0 = t.para_at(0);
|
||||
let (x0, x1) =
|
||||
lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent));
|
||||
let ox = match p0.justify {
|
||||
Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5,
|
||||
Justify::Right | Justify::JustifyRight => x1,
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1,
|
||||
@@ -479,7 +493,7 @@ pub(crate) fn fill_with_placeholder(t: &mut TextObject) {
|
||||
}
|
||||
_ => PLACEHOLDER.split(". ").next().unwrap_or(PLACEHOLDER).to_string() + ".",
|
||||
};
|
||||
t.runs = vec![TextRun { text, style }];
|
||||
t.runs = vec![TextRun { text, style, inline: None }];
|
||||
refresh_bounds(t);
|
||||
}
|
||||
|
||||
@@ -529,8 +543,13 @@ fn insert_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
match t.runs.last_mut() {
|
||||
Some(r) => r.text.push_str(&text),
|
||||
None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default() }),
|
||||
Some(r) if r.inline.is_none() => r.text.push_str(&text),
|
||||
// After an inline graphic: a run of its own, in its style.
|
||||
Some(r) => {
|
||||
let style = r.style.clone();
|
||||
t.runs.push(TextRun::new(text.clone(), style));
|
||||
}
|
||||
None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default(), inline: None }),
|
||||
}
|
||||
refresh_bounds(t);
|
||||
}
|
||||
@@ -645,8 +664,8 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn strip_formatting(n: &mut vectorcraft_doc::Node) {
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
let text = t.plain_text();
|
||||
t.runs = vec![TextRun { text, style: CharStyle::default() }];
|
||||
t.para = Default::default();
|
||||
t.runs = vec![TextRun { text, style: CharStyle::default(), inline: None }];
|
||||
t.set_all_paras(Default::default());
|
||||
refresh_bounds(t);
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
|
||||
Reference in New Issue
Block a user