mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
M1: reconcile tab drag with upstream and document transfer contracts
This commit is contained in:
@@ -11,6 +11,7 @@ use super::clipboard::SwatchChoices;
|
||||
use super::*;
|
||||
use crate::{Clipboard, EngineError, inspect};
|
||||
|
||||
/// Register editing commands, including document-to-document artwork drops and their parameter contracts.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -627,6 +628,7 @@ enum PasteMode {
|
||||
}
|
||||
|
||||
impl PasteMode {
|
||||
/// Return the user-visible action label for this insertion mode.
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
PasteMode::Front => "Paste in Front",
|
||||
@@ -668,6 +670,7 @@ fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert clipboard resources atomically in the active editable context using the requested placement mode.
|
||||
fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> {
|
||||
let off = s.prefs.paste_offset;
|
||||
let st = s.doc()?;
|
||||
|
||||
@@ -125,6 +125,24 @@ fn placed_images_come_back_as_linked_files() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of the drawing and get no
|
||||
/// linked file, and `warnings` holds the note raster exports give, once, without naming either
|
||||
/// image's key.
|
||||
#[test]
|
||||
fn images_that_cant_be_decoded_give_the_raster_note_once() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
|
||||
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||
assert_eq!(keys.len(), 2);
|
||||
let r = s.execute("document.export", &json!({"format": "dxf"})).unwrap();
|
||||
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{w:?}");
|
||||
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}");
|
||||
assert!(entities(&text(&r)).is_empty() && r.get("linked").is_none(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_dxf_options_are_refused() {
|
||||
let mut s = two_boards();
|
||||
|
||||
@@ -156,6 +156,23 @@ fn placed_images_are_written_and_previews_are_black_and_white_on_request() {
|
||||
assert!(l3.contains("/FlateDecode filter >> image"));
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings`
|
||||
/// holds the note raster exports give, once, without naming either image's key.
|
||||
#[test]
|
||||
fn images_that_cant_be_decoded_give_the_raster_note_once() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
|
||||
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||
assert_eq!(keys.len(), 2);
|
||||
let r = s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap();
|
||||
let w = warnings(&r);
|
||||
assert_eq!(w.iter().filter(|w| *w == NOTE).count(), 1, "{w:?}");
|
||||
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}");
|
||||
assert!(!postscript(&b64(&r)).contains("filter >> image"), "no image is written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_eps_options_are_refused_and_formats_list_eps() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
|
||||
@@ -95,6 +95,24 @@ fn raster_effects_are_written_as_images() {
|
||||
assert!(kinds.contains(&114), "the shadow is an image with transparency");
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of an EMF or WMF, and
|
||||
/// `warnings` holds the note raster exports give, once, without naming either image's key.
|
||||
#[test]
|
||||
fn images_that_cant_be_decoded_give_the_raster_note_once() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
|
||||
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||
assert_eq!(keys.len(), 2);
|
||||
for format in ["emf", "wmf"] {
|
||||
let r = s.execute("document.export", &json!({"format": format})).unwrap();
|
||||
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{format}: {w:?}");
|
||||
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{format}: {w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafiles_open_and_place() {
|
||||
let mut s = two_boards();
|
||||
|
||||
@@ -131,6 +131,32 @@ fn options_not_applied_yet_and_document_features_warn() {
|
||||
assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
/// A placed 16-bit PNG is written at 8 bits a sample in PDF 1.3 and 1.4 files (PDF/X-3 and the
|
||||
/// pages of a .ai file too), and keeps 16 bits from PDF 1.5.
|
||||
#[test]
|
||||
fn a_16_bit_png_is_written_at_8_bits_before_pdf_1_5() {
|
||||
let mut s = session(1);
|
||||
let deep = image::ImageBuffer::from_fn(8, 8, |x, y| image::Rgb([x as u16 * 8000, y as u16 * 8000, 40000]));
|
||||
let mut png = vec![];
|
||||
image::DynamicImage::ImageRgb16(deep).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.execute("file.place", &json!({"name": "deep.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [50, 40]})).unwrap();
|
||||
let readable = json!({"compressText": false});
|
||||
for (cmd, p, depth) in [
|
||||
("document.exportPdf", json!({"compatibility": "1.3", "compression": readable}), 8),
|
||||
("document.exportPdf", json!({"compatibility": "1.4", "compression": readable}), 8),
|
||||
("document.exportPdf", json!({"standard": "pdfX3", "compression": readable}), 8),
|
||||
("file.saveCopy", json!({"format": "ai", "compatibility": "1.4", "compress": false}), 8),
|
||||
("document.exportPdf", json!({"compatibility": "1.5", "compression": readable}), 16),
|
||||
("document.exportPdf", json!({"compression": readable}), 16),
|
||||
] {
|
||||
let v = s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
let text = String::from_utf8_lossy(&b64(&v)).into_owned();
|
||||
let (eight, sixteen) = (text.contains("/BitsPerComponent 8"), text.contains("/BitsPerComponent 16"));
|
||||
assert_eq!((eight, sixteen), (depth == 8, depth == 16), "{cmd} {p}");
|
||||
assert!(warnings(&v).iter().all(|w| !w.contains("decoded")), "{cmd} {p}: {}", v["warnings"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes,
|
||||
/// and the rest of the page is written. Each reply that carries the PDF's warnings has the note
|
||||
/// raster exports give, once.
|
||||
|
||||
@@ -10,8 +10,8 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::live::{
|
||||
self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, GradientMesh, MeshAppearance,
|
||||
PreserveShape, Spine,
|
||||
self, BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions,
|
||||
GradientMesh, MeshAppearance, PreserveShape, Spine,
|
||||
};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Blend"],
|
||||
Some("Cmd+Alt+B"),
|
||||
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
|
||||
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
|
||||
has_doc,
|
||||
blend_make
|
||||
),
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Options…",
|
||||
["Object", "Blend"],
|
||||
None,
|
||||
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}",
|
||||
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; a tool setting, not an undo step) → {defaults?: true}",
|
||||
always,
|
||||
blend_options
|
||||
),
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Info",
|
||||
[],
|
||||
None,
|
||||
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
|
||||
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
|
||||
always,
|
||||
blend_info
|
||||
),
|
||||
@@ -451,6 +451,32 @@ fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend panel Type and Ramp, validated before any document mutation.
|
||||
fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result<BlendEase> {
|
||||
let kind = match p.get("easingType").filter(|v| !v.is_null()) {
|
||||
None => current.kind,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("easeIn") => BlendEaseType::In,
|
||||
Some("easeOut") => BlendEaseType::Out,
|
||||
Some("easeInOut") => BlendEaseType::InOut,
|
||||
Some("easeOutIn") => BlendEaseType::OutIn,
|
||||
_ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")),
|
||||
},
|
||||
};
|
||||
Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 })
|
||||
}
|
||||
|
||||
/// Bounded percentage input; missing or null preserves the current value.
|
||||
fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result<f64> {
|
||||
match p.get(key).filter(|v| !v.is_null()) {
|
||||
None => Ok(current),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && (min..=100.0).contains(n))
|
||||
.ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from.
|
||||
fn starts_param(p: &Value, roots: &[NodeId]) -> Result<Vec<(NodeId, u32)>> {
|
||||
const C: &str = "object.blend.make";
|
||||
@@ -533,6 +559,12 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let spacing = spacing_param(p, C, cur.spacing)?;
|
||||
let orientation = orientation_param(p, cur.orientation);
|
||||
let (ease0, color0) = match base.as_ref().map(|n| &n.kind) {
|
||||
Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift),
|
||||
_ => Default::default(),
|
||||
};
|
||||
let ease = ease_param(p, C, ease0)?;
|
||||
let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?;
|
||||
let id = s.edit("Make Blend", |d, sel| {
|
||||
wrap(d, sel, &roots, |id, nodes| {
|
||||
let (children, starts) = merge_keys(nodes, &starts);
|
||||
@@ -543,7 +575,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.knockout = vectorcraft_doc::Knockout::On;
|
||||
n
|
||||
});
|
||||
let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() };
|
||||
let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() };
|
||||
if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind {
|
||||
extend_spine(&mut spec, old, old_spec, &children);
|
||||
}
|
||||
@@ -772,7 +804,10 @@ fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec<NodeId>)> {
|
||||
fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.options";
|
||||
let Some((_, cur, _)) = selected_spec(s) else {
|
||||
// Nothing to set them on: the options new blends start with.
|
||||
if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad(C, "select a blend to set easing or color shift"));
|
||||
}
|
||||
// Nothing to set them on: the options new blends start with (easing is per blend).
|
||||
let d = s.prefs.blend_options.unwrap_or_default();
|
||||
let spacing = spacing_param(p, C, d.spacing)?;
|
||||
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) });
|
||||
@@ -780,9 +815,13 @@ fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let spacing = spacing_param(p, C, cur.spacing)?;
|
||||
let orientation = orientation_param(p, cur.orientation);
|
||||
let ease = ease_param(p, C, cur.ease)?;
|
||||
let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?;
|
||||
edit_blends(s, "Blend Options", |_, spec| {
|
||||
spec.spacing = spacing;
|
||||
spec.orientation = orientation;
|
||||
spec.ease = ease;
|
||||
spec.color_shift = color_shift;
|
||||
})
|
||||
}
|
||||
|
||||
@@ -804,6 +843,9 @@ fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut v = blend_options_json(spec.spacing, spec.orientation);
|
||||
v["target"] = json!("blend");
|
||||
v["id"] = json!(id.0);
|
||||
v["easingType"] = json!(spec.ease.kind.id());
|
||||
v["ramp"] = json!(spec.ease.ramp * 100.0);
|
||||
v["colorShift"] = json!(spec.color_shift);
|
||||
v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>());
|
||||
v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>());
|
||||
if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind)
|
||||
|
||||
@@ -252,8 +252,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{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; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
|
||||
has_selection,
|
||||
"{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; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
|
||||
has_selection_or_guides,
|
||||
align
|
||||
),
|
||||
cmd!(
|
||||
@@ -1361,11 +1361,22 @@ fn align_reference(s: &Session, p: &Value, ids: &[NodeId], preview: bool, own: O
|
||||
Ok((r, true, true))
|
||||
}
|
||||
|
||||
/// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to
|
||||
/// Selection when no objects are selected.
|
||||
fn selected_guides_bounds(st: &crate::DocState) -> Option<Rect> {
|
||||
st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option<Rect>, g| {
|
||||
let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) };
|
||||
Some(acc.map(|a| a.union(r)).unwrap_or(r))
|
||||
})
|
||||
}
|
||||
|
||||
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
|
||||
let st = s.doc()?;
|
||||
match align_to(s, p) {
|
||||
Some("artboard") => {
|
||||
let b = effects::preview::bounds_of(&st.doc, ids, preview).unwrap_or_default();
|
||||
let b = effects::preview::bounds_of(&st.doc, ids, preview)
|
||||
.or_else(|| ids.is_empty().then(|| selected_guides_bounds(st)).flatten())
|
||||
.unwrap_or_default();
|
||||
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
|
||||
// it, or with an index the document doesn't have, it uses the artboard under the center
|
||||
// of the selection, or the first one when no artboard is there.
|
||||
@@ -1377,7 +1388,13 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
|
||||
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
|
||||
effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k))
|
||||
}
|
||||
_ => effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())),
|
||||
_ => {
|
||||
if ids.is_empty() {
|
||||
selected_guides_bounds(st).ok_or_else(|| EngineError::Other("nothing to align".into()))
|
||||
} else {
|
||||
effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1443,15 +1460,22 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
apply_moves(s, "Align", p, &ids, &moves, &guides)
|
||||
}
|
||||
|
||||
/// The selected ruler guides that Align to the key object (or key guide) moves, and where: a
|
||||
/// vertical guide goes to the left edge, centre or right edge of `r` that `h` names, a horizontal
|
||||
/// one to the top, centre or bottom that `v` names. The key guide stays put, and so do guides
|
||||
/// when aligning to the selection or the artboard.
|
||||
/// The selected ruler guides that Align moves, and where: a vertical guide goes to the left edge,
|
||||
/// centre or right edge of `r` that `h` names, a horizontal one to the top, centre or bottom that
|
||||
/// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put.
|
||||
/// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are
|
||||
/// selected; with objects in the selection they stay put.
|
||||
fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(usize, f64)>> {
|
||||
if align_to(s, p) != Some("key") {
|
||||
let st = s.doc()?;
|
||||
let to = align_to(s, p);
|
||||
let move_guides = match to {
|
||||
Some("key" | "artboard") => true,
|
||||
Some("selection") => st.selection.is_empty(),
|
||||
_ => false,
|
||||
};
|
||||
if !move_guides {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let at = |vertical: bool| match (vertical, h, v) {
|
||||
(true, Some("left"), _) => Some(r.x0),
|
||||
(true, Some("center"), _) => Some(r.center().x),
|
||||
@@ -1461,11 +1485,12 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>
|
||||
(false, _, Some("bottom")) => Some(r.y1),
|
||||
_ => None,
|
||||
};
|
||||
let skip_key = to == Some("key");
|
||||
Ok(st
|
||||
.selection
|
||||
.guides
|
||||
.iter()
|
||||
.filter(|&&i| Some(i) != st.selection.key_guide)
|
||||
.filter(|&&i| !skip_key || Some(i) != st.selection.key_guide)
|
||||
.filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -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 (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), 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), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), 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\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
"{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, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), 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), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), 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\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; 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
|
||||
),
|
||||
@@ -327,6 +327,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"direction",
|
||||
"leadingModel",
|
||||
"charAlign",
|
||||
"charDirection",
|
||||
"proportionalMetrics",
|
||||
"tateChuYoko",
|
||||
"noBreak",
|
||||
@@ -350,6 +351,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
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 char_direction = super::textedit::char_direction_param(p, C)?;
|
||||
let digits = super::textedit::digits_param(p, C)?;
|
||||
let font_axes = super::textedit::font_axes_param(p, C)?;
|
||||
let mojikumi = match p.get("mojikumi") {
|
||||
@@ -460,6 +462,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = char_align {
|
||||
st.char_align = v;
|
||||
}
|
||||
if let Some(v) = char_direction {
|
||||
st.char_direction = v;
|
||||
}
|
||||
if let Some(v) = digits {
|
||||
st.digits = v;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), 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\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, 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\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
"{id? (default: the one selected type object), 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\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, 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\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -256,6 +256,7 @@ pub(crate) struct CharChange {
|
||||
position: Option<vectorcraft_doc::CharPosition>,
|
||||
small_caps: Option<Option<f64>>,
|
||||
char_align: Option<vectorcraft_doc::CharAlign>,
|
||||
char_direction: Option<Option<vectorcraft_doc::ParaDirection>>,
|
||||
proportional_metrics: Option<bool>,
|
||||
tate_chu_yoko: Option<bool>,
|
||||
no_break: Option<bool>,
|
||||
@@ -263,6 +264,19 @@ pub(crate) struct CharChange {
|
||||
font_axes: Option<AxisChange>,
|
||||
}
|
||||
|
||||
/// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None`
|
||||
/// inside: Default).
|
||||
pub(crate) fn char_direction_param(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_doc::ParaDirection>>> {
|
||||
use vectorcraft_doc::ParaDirection;
|
||||
let Some(v) = p.get("charDirection").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
Ok(Some(match v.as_str() {
|
||||
Some("default") => None,
|
||||
Some("leftToRight") => Some(ParaDirection::LeftToRight),
|
||||
Some("rightToLeft") => Some(ParaDirection::RightToLeft),
|
||||
_ => return Err(bad(cmd, "`charDirection` must be \"default\", \"leftToRight\" or \"rightToLeft\"")),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in.
|
||||
pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::Digits>> {
|
||||
use vectorcraft_doc::Digits;
|
||||
@@ -416,6 +430,7 @@ impl CharChange {
|
||||
position,
|
||||
small_caps,
|
||||
char_align: char_align_param(p, cmd)?,
|
||||
char_direction: char_direction_param(p, cmd)?,
|
||||
proportional_metrics: flag("proportionalMetrics"),
|
||||
tate_chu_yoko: flag("tateChuYoko"),
|
||||
no_break: flag("noBreak"),
|
||||
@@ -459,6 +474,7 @@ impl CharChange {
|
||||
&& self.position.is_none()
|
||||
&& self.small_caps.is_none()
|
||||
&& self.char_align.is_none()
|
||||
&& self.char_direction.is_none()
|
||||
&& self.proportional_metrics.is_none()
|
||||
&& self.tate_chu_yoko.is_none()
|
||||
&& self.no_break.is_none()
|
||||
@@ -554,6 +570,9 @@ impl CharChange {
|
||||
if let Some(v) = self.char_align {
|
||||
st.char_align = v;
|
||||
}
|
||||
if let Some(v) = self.char_direction {
|
||||
st.char_direction = v;
|
||||
}
|
||||
if let Some(v) = self.proportional_metrics {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
("position", json!(v.position)),
|
||||
("small_caps", json!(v.small_caps)),
|
||||
("charAlign", json!(v.char_align)),
|
||||
("charDirection", json!(v.char_direction)),
|
||||
("proportionalMetrics", json!(v.proportional_metrics)),
|
||||
("tateChuYoko", json!(v.tate_chu_yoko)),
|
||||
("noBreak", json!(v.no_break)),
|
||||
|
||||
@@ -1599,6 +1599,47 @@ mod area_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Character Direction (Character panel menu, Middle Eastern options): a character attribute
|
||||
/// that `text.setFormat` and `text.setRangeStyle` set in one undo step each, saved only when
|
||||
/// set and kept by a native save.
|
||||
#[test]
|
||||
fn character_direction_is_a_character_attribute_saved_only_when_set() {
|
||||
use vectorcraft_doc::ParaDirection;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap();
|
||||
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.runs.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let set = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::<Vec<_>>();
|
||||
assert_eq!(runs(&s)[0].style.char_direction, None);
|
||||
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charDirection").is_none());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
for (key, want) in [("rightToLeft", Some(ParaDirection::RightToLeft)), ("leftToRight", Some(ParaDirection::LeftToRight)), ("default", None)] {
|
||||
s.execute("text.setFormat", &json!({"charDirection": key})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.char_direction == want));
|
||||
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
|
||||
assert_eq!(saved.get("charDirection").and_then(|v| v.as_str()), want.is_some().then_some(key));
|
||||
}
|
||||
let err = s.execute("text.setFormat", &json!({"charDirection": "up"})).unwrap_err().to_string();
|
||||
assert!(err.contains("`charDirection` must be"), "{err}");
|
||||
// Characters selected with the Type tool: only the range takes it (שלום and the space are 9 bytes).
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap();
|
||||
assert_eq!(set(&s), [("שלום ".to_string(), None), ("21 10 2026".to_string(), Some(ParaDirection::LeftToRight))]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.char_direction.is_none()));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
// Saved and reopened as it was.
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect();
|
||||
assert_eq!(reopened, set(&s));
|
||||
// A character style captured from the characters carries it, Default included.
|
||||
assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["charDirection"], "leftToRight");
|
||||
assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style).get("charDirection"), Some(&serde_json::Value::Null));
|
||||
}
|
||||
|
||||
/// Proportional Metrics (#966): a character attribute, saved only when on.
|
||||
#[test]
|
||||
fn proportional_metrics_is_a_character_attribute_saved_only_when_on() {
|
||||
|
||||
Reference in New Issue
Block a user