Merge pull request: land #612, #610, #611

Land #612 (fix #593), #610 (Shaper groups), #611 (per-paragraph attributes)
This commit is contained in:
BFlat 2026-10-09 05:25:17 +07:00 committed by GitHub
commit 106abe639f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 2881 additions and 309 deletions

File diff suppressed because one or more lines are too long

View File

@ -44,6 +44,8 @@ impl Hit {
/// Options for hit testing.
#[derive(Clone, Copy, Debug)]
pub struct HitOptions {
/// The isolated construction container, whose retained Shaper sources are hittable.
pub scope: Option<NodeId>,
/// Tolerance in document units (screen tolerance / zoom).
pub tol: f64,
/// Outline mode: only outlines are hittable.
@ -58,7 +60,7 @@ pub struct HitOptions {
impl Default for HitOptions {
fn default() -> Self {
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false }
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, scope: None }
}
}
@ -70,6 +72,7 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
let mut tops: Vec<NodeId> = vec![];
// Each round leaves out the objects found so far (and everything inside them).
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id)).map(|h| h.top_object(scope)) {
@ -131,13 +134,20 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId)
{
return None;
}
for c in children.iter().rev() {
for (i, c) in children.iter().enumerate().rev() {
if parent.shaper.is_some() {
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
if (i == 0) != construction {
continue;
}
}
// Hidden, locked and template sublayers block clicks like top-level layers do.
if !c.visible || c.locked || c.is_template() || skip(c.id) {
continue;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
if !edits_contents(c)
if !(edits_contents(c)
|| c.shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
&& let Some(b) = c.reach_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{

View File

@ -33,6 +33,7 @@ mod reach;
pub mod recolor;
pub mod selection;
pub mod setup;
pub mod shaper;
pub mod slices;
pub mod style_libs;
pub mod swatches;

View File

@ -472,6 +472,9 @@ pub struct Node {
/// Graph object: the group's children are generated from this spec (Object → Graph).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<Box<crate::graph::GraphSpec>>,
/// Editable Shaper composition; the original art is retained in its source child.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
pub kind: NodeKind,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
/// that style's look: editing its appearance or transparency breaks the link.
@ -536,6 +539,7 @@ impl Node {
trace: None,
wrap: None,
graph: None,
shaper: None,
kind,
graphic_style: None,
attrs: None,
@ -707,6 +711,7 @@ impl Node {
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
.iter()
.skip(usize::from(self.shaper.is_some()))
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
NodeKind::Text(t) => self.projected(t.bounds()),
@ -738,7 +743,11 @@ impl Node {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
// The container's own strokes paint around its members.
let o = self.appearance.outset();
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())).map(|b| b.inflate(o, o))
children
.iter()
.skip(usize::from(self.shaper.is_some()))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
.map(|b| b.inflate(o, o))
}
NodeKind::Blend { children, spec } => {
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()));
@ -965,7 +974,7 @@ impl Node {
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children.iter().filter(|c| c.visible) {
for c in children.iter().skip(usize::from(self.shaper.is_some())).filter(|c| c.visible) {
c.push_clip_shapes(text, out);
}
}

41
crates/doc/src/shaper.rs Normal file
View File

@ -0,0 +1,41 @@
//! Persistent Shaper edits over retained source paths. Source indices (not document ids) keep
//! recipes valid when a group is copied. Selector anchors use source-relative bounding boxes,
//! so the intended face or stroke follows source art when it moves or resizes.
use crate::Appearance;
use serde::{Deserialize, Serialize};
use vectorcraft_geom::Point;
pub const SOURCES: &str = "Shaper Sources";
pub const FILL_PREFIX: &str = "Shaper Fill ";
pub const STROKE_PREFIX: &str = "Shaper Stroke ";
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ShaperSpec {
pub faces: Vec<FaceEdit>,
pub edges: Vec<EdgeEdit>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FaceSelector {
pub sources: Vec<usize>,
pub anchor: Point,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FaceEdit {
pub selector: FaceSelector,
pub erase: bool,
pub erase_edges: bool,
pub paint_source: Option<usize>,
pub merge: Option<u64>,
pub paint: Option<Appearance>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EdgeEdit {
pub source: usize,
pub sides: [Vec<usize>; 2],
pub anchor: Point,
pub erase: bool,
pub paint: Option<Appearance>,
}

View File

@ -812,6 +812,11 @@ fn reverse_from_end(path: &mut PathData) {
}
/// A text object. `runs` split into paragraphs at `\n`.
///
/// Paragraph attributes: `para` is paragraph 0's (and, while `paras` is empty, every
/// paragraph's). Invariant (kept by [`TextObject::normalize_paras`]): `paras` is either empty —
/// every paragraph uses `para` — or holds one style per paragraph with `paras[0] == para` and at
/// least two different styles. Readers that only know `para` see the first paragraph's style.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextObject {
/// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents.
@ -823,6 +828,9 @@ pub struct TextObject {
pub runs: Vec<TextRun>,
#[serde(default)]
pub para: ParaStyle,
/// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub paras: Vec<ParaStyle>,
/// Area Type Options (area type only).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub area: AreaOptions,
@ -857,6 +865,7 @@ impl TextObject {
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun { text: text.into(), style }],
para: ParaStyle::default(),
paras: Vec::new(),
area: AreaOptions::default(),
path_effect: PathEffect::default(),
path_align: PathAlign::default(),
@ -869,6 +878,103 @@ impl TextObject {
pub fn plain_text(&self) -> String {
self.runs.iter().map(|r| r.text.as_str()).collect()
}
/// Number of paragraphs (`\n`-separated; empty text has one).
pub fn paragraph_count(&self) -> usize {
1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::<usize>()
}
/// Attributes of paragraph `i` (the last paragraph's past the end).
pub fn para_at(&self, i: usize) -> &ParaStyle {
self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para)
}
/// Every paragraph's attributes, one per paragraph.
pub fn paragraph_styles(&self) -> Vec<ParaStyle> {
(0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect()
}
/// Set every paragraph's attributes from `styles` (one per paragraph; a short list repeats its
/// last entry, a long one is cut).
pub fn set_paragraph_styles(&mut self, styles: Vec<ParaStyle>) {
if let Some(first) = styles.first() {
self.para = first.clone();
}
self.paras = styles;
self.normalize_paras();
}
/// The same attributes for every paragraph.
pub fn set_all_paras(&mut self, style: ParaStyle) {
self.para = style;
self.paras.clear();
}
/// Re-establish the `paras` invariant: one entry per paragraph (cut, or extended with the
/// last), `paras[0] == para`, and empty when every paragraph is alike.
pub fn normalize_paras(&mut self) {
if self.paras.is_empty() {
return;
}
let n = self.paragraph_count();
let last = self.paras.last().cloned().unwrap_or_default();
self.paras.resize(n, last);
if let Some(first) = self.paras.first_mut() {
first.clone_from(&self.para);
}
if self.paras.iter().all(|p| *p == self.para) {
self.paras.clear();
}
}
/// Indices of the paragraphs that byte range `start..end` of the plain text touches (a caret
/// touches its paragraph).
pub fn paragraphs_in(&self, start: usize, end: usize) -> std::ops::Range<usize> {
let text = self.plain_text();
let (a, b) = (start.min(end).min(text.len()), start.max(end).min(text.len()));
let index = |byte: usize| text.as_bytes().get(..byte).map_or(0, |s| s.iter().filter(|&&c| c == b'\n').count());
index(a)..index(b) + 1
}
/// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph).
pub fn edit_paras(&mut self, range: Option<std::ops::Range<usize>>, mut f: impl FnMut(&mut ParaStyle)) {
match range {
None => {
f(&mut self.para);
for p in &mut self.paras {
f(p);
}
}
Some(r) => {
let mut v = self.paragraph_styles();
for p in v.iter_mut().take(r.end).skip(r.start) {
f(p);
}
self.set_paragraph_styles(v);
}
}
self.normalize_paras();
}
/// Every stored paragraph style (`para` and the per-paragraph ones), for scans and renames.
pub fn para_styles_mut(&mut self) -> impl Iterator<Item = &mut ParaStyle> {
std::iter::once(&mut self.para).chain(self.paras.iter_mut())
}
/// Every stored paragraph style (`para` and the per-paragraph ones).
pub fn para_styles(&self) -> impl Iterator<Item = &ParaStyle> {
std::iter::once(&self.para).chain(self.paras.iter())
}
/// Update the paragraph styles for replacing bytes `start..end` of the current plain text by
/// `insert` (call before changing the runs). The paragraph the range starts in keeps its
/// style (merging paragraphs: the first one's wins); paragraphs the insertion starts take that
/// style too (Return continues the paragraph it splits); later paragraphs keep theirs.
pub fn splice_paras(&mut self, start: usize, end: usize, insert: &str) {
if self.paras.is_empty() {
return;
}
let r = self.paragraphs_in(start, end);
let v = self.paragraph_styles();
let Some(keep) = v.get(r.start).cloned() else { return };
let added = insert.bytes().filter(|&b| b == b'\n').count();
let mut out: Vec<ParaStyle> = v.iter().take(r.start + 1).cloned().collect();
out.extend(std::iter::repeat_n(keep, added));
out.extend(v.iter().skip(r.end).cloned());
self.paras = out;
if let Some(first) = self.paras.first() {
self.para = first.clone();
}
}
pub fn first_style(&self) -> CharStyle {
self.runs.first().map(|r| r.style.clone()).unwrap_or_default()
}
@ -996,6 +1102,56 @@ mod tests {
assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999);
}
fn justified(j: Justify) -> ParaStyle {
ParaStyle { justify: j, ..ParaStyle::default() }
}
#[test]
fn paragraph_styles_split_merge_and_normalize() {
let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle::default());
assert_eq!((t.paragraph_count(), t.paras.len()), (3, 0), "one style for all: nothing stored");
assert_eq!(t.paragraphs_in(0, 0), 0..1);
assert_eq!(t.paragraphs_in(4, 4), 1..2);
assert_eq!(t.paragraphs_in(2, 9), 0..3);
assert_eq!(t.paragraphs_in(99, 99), 2..3, "clamped to the text");
t.edit_paras(Some(1..2), |p| p.justify = Justify::Center);
let js = |t: &TextObject| t.paragraph_styles().iter().map(|p| p.justify).collect::<Vec<_>>();
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]);
// Return inside "two": the new paragraph continues its style.
t.splice_paras(5, 5, "\n");
t.runs[0].text.insert(5, '\n');
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Left]);
// Deleting the break between "one" and "t": the first paragraph's style wins.
t.splice_paras(3, 4, "");
t.runs[0].text.remove(3);
assert_eq!(t.plain_text(), "onet\nwo\nthree");
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]);
// An edit that skips the splice is repaired by normalizing (the last style continues).
t.runs[0].text.push_str("\nfour");
t.normalize_paras();
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left, Justify::Left]);
// `para` is paragraph 0's; all alike collapses to `para` alone.
t.edit_paras(Some(0..1), |p| p.justify = Justify::Right);
assert_eq!(t.para.justify, Justify::Right);
t.edit_paras(None, |p| p.justify = Justify::Right);
assert!(t.paras.is_empty() && t.para.justify == Justify::Right);
// A short list repeats its last entry, a long one is cut.
t.set_paragraph_styles(vec![justified(Justify::Left), justified(Justify::Center)]);
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Center]);
t.set_paragraph_styles(vec![justified(Justify::Center); 9]);
assert!(t.paras.is_empty() && t.para.justify == Justify::Center);
// Old documents (no `paras`) load; new ones keep paragraph 0 in `para`.
t.edit_paras(Some(3..4), |p| p.space_before = 4.0);
let json = serde_json::to_value(&t).unwrap();
assert_eq!(json["para"]["justify"], json["paras"][0]["justify"]);
let mut old = json.clone();
old.as_object_mut().unwrap().remove("paras");
let back: TextObject = serde_json::from_value(old).unwrap();
assert!(back.paras.is_empty() && back.para_at(3).justify == Justify::Center);
let back: TextObject = serde_json::from_value(json).unwrap();
assert_eq!(back.para_at(3).space_before, 4.0);
}
/// 120 × 40 area type at (40, 40), its text drawn at twice its size.
fn area_type() -> TextObject {
let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default());

View File

@ -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;

View File

@ -122,7 +122,7 @@ 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));
}
}
@ -440,8 +440,10 @@ 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),

View File

@ -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!(
@ -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)
}

View File

@ -59,6 +59,7 @@ pub mod rasterfx;
mod recolor;
pub mod recovery;
mod select;
pub(crate) mod shaper;
pub(crate) mod slices;
mod stroke;
mod style;
@ -231,6 +232,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());

View File

@ -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,7 +733,8 @@ 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()

View File

@ -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\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default)}",
"{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\" (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> = {
@ -268,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;
}
@ -306,41 +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;
}
if let Some(v) = composer {
para.composer = 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(())

View File

@ -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(&current);
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}))
}

View File

@ -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) }))

View File

@ -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),
@ -463,6 +470,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
xf: Affine::IDENTITY,
runs: vec![TextRun { text, style }],
para: super::create::new_type_para(),
paras: Vec::new(),
area: Default::default(),
path_effect: Default::default(),
path_align: Default::default(),
@ -600,6 +608,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(),

View File

@ -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(())

View File

@ -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,6 +102,8 @@ 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).
@ -105,13 +117,18 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
}
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, &paras);
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 {
@ -193,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.set_all_paras(first_para.clone());
refresh_bounds(t);
}
}
@ -210,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;

View File

@ -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
),
@ -102,6 +102,8 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
/// ([`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);
@ -255,6 +257,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")?;
@ -262,6 +318,8 @@ 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();
// 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 }];
refresh_bounds(t);
}
@ -319,14 +377,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();
}
@ -348,12 +406,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);
}
@ -788,7 +847,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()))
}

View File

@ -272,7 +272,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 +315,22 @@ 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.splice_paras(b, b, "\n");
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,
@ -646,7 +648,7 @@ 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.set_all_paras(Default::default());
refresh_bounds(t);
}
if let Some(ch) = n.children_mut() {

View File

@ -1100,27 +1100,30 @@ impl Session {
let before_sel = st.selection.clone();
let doc = Arc::make_mut(&mut st.doc);
let result = match f(doc, &mut st.selection) {
Ok(v) => {
// Text Wrap: area type follows its wrap objects; then threads re-flow.
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
// Opacity-mask editing: the mask follows its art on the editing layer.
if st.doc.mask_edit.is_some() {
cmd::maskedit::sync(Arc::make_mut(&mut st.doc));
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
Err(e) => Err(e),
Ok(()) => {
// Text Wrap: area type follows its wrap objects; then threads re-flow.
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
// Opacity-mask editing: the mask follows its art on the editing layer.
if st.doc.mask_edit.is_some() {
cmd::maskedit::sync(Arc::make_mut(&mut st.doc));
}
// Threaded text re-flows when any of its frames changed.
if !st.doc.text_threads.is_empty() {
cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc));
}
// Asset Export: assets let go of deleted art.
if !st.doc.assets.is_empty() {
Arc::make_mut(&mut st.doc).prune_assets();
}
if doc_sane(&st.doc, &st.selection) {
Ok(v)
} else {
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
}
}
// Threaded text re-flows when any of its frames changed.
if !st.doc.text_threads.is_empty() {
cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc));
}
// Asset Export: assets let go of deleted art.
if !st.doc.assets.is_empty() {
Arc::make_mut(&mut st.doc).prune_assets();
}
if doc_sane(&st.doc, &st.selection) {
Ok(v)
} else {
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
}
}
},
Err(e) => Err(e),
};
match result {
@ -1431,6 +1434,8 @@ mod tests_paintproxy;
#[cfg(test)]
mod tests_panelcmds;
#[cfg(test)]
mod tests_paragraphs;
#[cfg(test)]
mod tests_pathops;
#[cfg(test)]
mod tests_pathtype;
@ -1493,6 +1498,8 @@ mod tests_saveoptions;
#[cfg(test)]
mod tests_scalestrokes;
#[cfg(test)]
mod tests_shaper;
#[cfg(test)]
mod tests_slices;
#[cfg(test)]
mod tests_smartguides;

View File

@ -1,6 +1,7 @@
use serde_json::json;
use super::*;
use vectorcraft_geom::Point;
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
fn session() -> Session {
@ -695,7 +696,7 @@ fn snap_to_pixel_rounds_drawing_and_moves() {
}
#[test]
fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() {
fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_punch_fills() {
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
@ -725,10 +726,86 @@ fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() {
"{:?}",
n.kind_label()
);
// A zig-zag over it deletes it.
// A zig-zag inside it clears its fill while retaining the editable original and stroke.
let zig: Vec<(f64, f64)> = (0..30).map(|i| (150.0 + (i % 2) as f64 * 80.0, 120.0 + i as f64 * 2.0)).collect();
stroke(&mut s, &zig);
assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &n);
let group = s.doc().unwrap().doc.node(s.doc().unwrap().selection.objects[0]).unwrap();
assert!(group.shaper.is_some());
assert!(group.children().unwrap().iter().skip(1).all(|n| n.appearance.fill_paint().is_none()));
assert!(group.children().unwrap().iter().skip(1).any(|n| !n.appearance.stroke_paint().is_none()));
}
#[test]
fn shaper_square_started_mid_edge_closes_on_pointer_release_and_undoes_once() {
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
let undo = s.doc().unwrap().history.undo.len();
for (kind, x, y) in [
(PointerKind::Down, 150.0, 100.0),
(PointerKind::Drag, 200.0, 105.0),
(PointerKind::Drag, 194.0, 200.0),
(PointerKind::Drag, 110.0, 191.0),
(PointerKind::Drag, 100.0, 100.0),
(PointerKind::Up, 150.0, 100.0),
] {
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
}
let id = *s.doc().unwrap().selection.objects.first().expect("recognized square");
assert!(matches!(
s.doc().unwrap().doc.node(id).unwrap().kind,
vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. }
));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_none());
s.execute("edit.redo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_some());
}
#[test]
fn shaper_creates_upright_or_inverted_live_triangles_with_one_undo_step() {
let v = ViewInfo { smart_guides: false, ..Default::default() };
for direction in [0.0_f64, 180.0] {
let mut s = session();
s.select_tool("shaper", v).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let corners: Vec<Point> = (0..3)
.map(|i| {
let angle = (direction + 7.0 - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 3.0;
Point::new(200.0 + 70.0 * angle.cos(), 200.0 + 70.0 * angle.sin())
})
.collect();
let mut pts = vec![];
for i in 0..3 {
for j in 0..20 {
pts.push(corners[i] + (corners[(i + 1) % 3] - corners[i]) * (j as f64 / 20.0));
}
}
// Begin midway along an edge; the release closes the final segment.
pts.rotate_left(7);
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y), v).unwrap();
for p in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x, p.y), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, pts[0].x, pts[0].y), v).unwrap();
let id = *s.doc().unwrap().selection.objects.first().expect("recognized triangle");
let node = s.doc().unwrap().doc.node(id).unwrap().clone();
let vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Polygon { sides: 3, xf, .. }), .. } = &node.kind else {
panic!("expected live triangle, got {:?}", node.kind_label());
};
let [a, b, c, d, _, _] = xf.as_coeffs();
let (sin, cos) = direction.to_radians().sin_cos();
for (actual, expected) in [(a, cos), (b, sin), (c, -sin), (d, cos)] {
assert!((actual - expected).abs() < 1e-12, "direction {direction}, transform {xf:?}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_none());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &node);
}
}
#[test]

View File

@ -0,0 +1,207 @@
//! Paragraph attributes per paragraph: ranged Paragraph panel commands, paragraph styles on one
//! paragraph, splitting and merging paragraphs while editing, undo and threaded text.
use serde_json::json;
use vectorcraft_doc::{Justify, NodeId, NodeKind, TextObject};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn area(s: &mut Session, text: &str) -> u64 {
s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 300}})).unwrap()["id"].as_u64().unwrap()
}
fn text(s: &Session, id: u64) -> TextObject {
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn justs(s: &Session, id: u64) -> Vec<Justify> {
text(s, id).paragraph_styles().iter().map(|p| p.justify).collect()
}
#[test]
fn ranged_paragraph_commands_touch_only_their_paragraphs() {
let mut s = session();
let id = area(&mut s, "one\ntwo\nthree");
// A caret in "two" (byte 5): only the second paragraph.
s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]);
// A selection from "one" into "two": both.
s.execute("text.setFormat", &json!({"ids": [id], "spaceBefore": 9, "leftIndent": 4, "start": 1, "end": 5})).unwrap();
let t = text(&s, id);
let sb: Vec<f64> = t.paragraph_styles().iter().map(|p| p.space_before).collect();
assert_eq!(sb, [9.0, 9.0, 0.0]);
assert_eq!(t.para.left_indent, 4.0, "paragraph 0 is `para`");
// Character attributes of setFormat follow the range too.
s.execute("text.setFormat", &json!({"ids": [id], "underline": true, "start": 4, "end": 7})).unwrap();
let t = text(&s, id);
assert!(t.runs.iter().any(|r| r.text == "two" && r.style.underline));
assert!(t.runs.iter().any(|r| r.text.starts_with("one") && !r.style.underline));
// The layout sees each paragraph's alignment.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(lay.lines[1].x0 > lay.lines[0].x0 + 20.0, "the centred line sits right of the left one");
// No range: every paragraph (as before).
s.execute("text.setStyle", &json!({"id": id, "justify": "right"})).unwrap();
assert_eq!(justs(&s, id), [Justify::Right; 3]);
// Undo steps back through the ranged edits.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]);
// Tabs per paragraph; a query reads the paragraph at `start`.
s.execute("text.tabs.set", &json!({"ids": [id], "stops": [{"position": 40}], "start": 9, "end": 9})).unwrap();
assert_eq!(s.execute("text.tabs.get", &json!({"ids": [id], "start": 9})).unwrap()["stops"].as_array().unwrap().len(), 1);
assert_eq!(s.execute("text.tabs.get", &json!({"ids": [id]})).unwrap()["stops"], json!([]));
// Junk ranges are errors, not panics.
assert!(s.execute("text.setFormat", &json!({"ids": [id], "spaceAfter": 1, "start": -3})).is_err());
assert!(s.execute("text.setStyle", &json!({"id": id, "justify": "left", "end": "x"})).is_err());
s.execute("text.setFormat", &json!({"ids": [id], "spaceAfter": 1, "start": 1_000_000, "end": u64::MAX})).unwrap();
assert_eq!(text(&s, id).para_at(2).space_after, 1.0, "past the end: the last paragraph");
}
#[test]
fn editing_splits_and_merges_paragraph_styles() {
let mut s = session();
let id = area(&mut s, "one\ntwo\nthree");
s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap();
// A break inside "two": both halves keep its style.
s.execute("text.editRange", &json!({"id": id, "start": 5, "end": 5, "insert": "\n"})).unwrap();
assert_eq!(text(&s, id).plain_text(), "one\nt\nwo\nthree");
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Auto]);
// Deleting the break after "one": the first paragraph's style wins.
s.execute("text.editRange", &json!({"id": id, "start": 3, "end": 4})).unwrap();
assert_eq!(text(&s, id).plain_text(), "onet\nwo\nthree");
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]);
// Pasting styled runs with breaks in "wo": the new paragraphs continue it.
let runs = json!([{"text": "A\nB\n", "style": text(&s, id).runs[0].style}]);
s.execute("text.editRange", &json!({"id": id, "start": 6, "end": 6, "runs": runs})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Center, Justify::Auto]);
// Undo restores the styles with the text.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Auto]);
// Replacing all the text: every paragraph takes the first one's attributes.
s.execute("text.setText", &json!({"ids": [id], "text": "x\ny"})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Auto]);
assert!(text(&s, id).paras.is_empty());
}
#[test]
fn return_in_the_type_tool_continues_the_paragraph_it_splits() {
let mut s = session();
let v = ViewInfo::default();
let id = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "one\ntwo", "size": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setStyle", &json!({"id": id, "justify": "right", "start": 0, "end": 0})).unwrap();
s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap();
s.select_tool("type", v).unwrap();
// Click into the first line (right-aligned: it ends at the origin), then End and Return.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
let l0 = &lay.lines[0];
let (x, y) = (100.0 + (l0.x0 + l0.x1) / 2.0, 100.0 + l0.baseline - 5.0);
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
s.tool_key(ToolKey::End, Mods::default(), v).unwrap();
s.tool_key(ToolKey::Enter, Mods::default(), v).unwrap();
s.tool_text("new", v).unwrap();
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(text(&s, id).plain_text(), "one\nnew\ntwo");
assert_eq!(justs(&s, id), [Justify::Right, Justify::Right, Justify::Center]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(justs(&s, id), [Justify::Right, Justify::Center]);
}
#[test]
fn paragraph_style_applies_to_one_paragraph() {
let mut s = session();
let id = area(&mut s, "Title\nBody text");
// The second paragraph keeps new type's attributes, whatever they are.
let body = text(&s, id).para_at(1).clone();
s.execute("paraStyle.new", &json!({"name": "Head", "attrs": {"justify": "Center", "space_after": 8.0}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Head", "id": id, "start": 2, "end": 2})).unwrap();
let t = text(&s, id);
assert_eq!(t.para_at(0).style_name.as_deref(), Some("Head"));
assert_eq!((t.para_at(0).justify, t.para_at(0).space_after), (Justify::Center, 8.0));
assert_eq!(t.para_at(1), &body, "new type's");
let list = s.execute("paraStyle.list", &json!({})).unwrap();
assert_eq!(list["styles"][1]["uses"], 1, "one paragraph uses it");
assert_eq!(list["styles"][0]["uses"], 1, "the other is Normal");
// Redefining updates that paragraph only; renaming follows it.
s.execute("paraStyle.setAttrs", &json!({"name": "Head", "attrs": {"justify": "Right"}})).unwrap();
assert_eq!(justs(&s, id), [Justify::Right, Justify::Auto]);
s.execute("paraStyle.rename", &json!({"name": "Head", "to": "Heading"})).unwrap();
assert_eq!(text(&s, id).para_at(0).style_name.as_deref(), Some("Heading"));
// New from the selection reads the caret's paragraph.
s.execute("paraStyle.new", &json!({"name": "From body", "id": id, "start": 8})).unwrap();
let defs = &s.doc().unwrap().doc.para_styles;
assert_eq!(defs.iter().find(|d| d.name == "From body").unwrap().attrs["justify"], "Auto");
}
#[test]
fn threaded_frames_keep_each_paragraphs_style() {
let mut s = session();
let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(3) + "\nSecond paragraph.\nThird.";
let a =
s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
let joined = |s: &Session| format!("{}{}", text(s, a).plain_text(), text(s, b).plain_text());
assert_eq!(joined(&s), story);
// Centre the second paragraph (in frame b) and right-align the third.
let tb = text(&s, b).plain_text();
let second = tb.find("Second").unwrap();
s.execute("text.setStyle", &json!({"id": b, "justify": "center", "start": second, "end": second})).unwrap();
let third = text(&s, b).plain_text().find("Third").unwrap();
s.execute("text.setStyle", &json!({"id": b, "justify": "right", "start": third, "end": third})).unwrap();
assert_eq!(justs(&s, b), [Justify::Auto, Justify::Center, Justify::Right]);
// Typing in the first frame re-flows the story: the styles move with their paragraphs.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "More words at the start of the story. "})).unwrap();
let (ja, jb) = (justs(&s, a), justs(&s, b));
assert_eq!(ja, [Justify::Auto]);
assert_eq!(jb, [Justify::Auto, Justify::Center, Justify::Right]);
// A break in the first frame makes a new paragraph there; the later ones keep their styles.
s.execute("text.editRange", &json!({"id": a, "start": 4, "end": 4, "insert": "\n"})).unwrap();
let all: Vec<Justify> = [justs(&s, a), justs(&s, b)].concat();
assert_eq!(all.iter().filter(|j| **j == Justify::Center).count(), 1);
assert_eq!(all.last(), Some(&Justify::Right));
// Restyling the paragraph that continues into frame b (its first) sticks after the re-flow.
s.execute("text.setStyle", &json!({"id": b, "justify": "justifyAll", "start": 0, "end": 0})).unwrap();
assert_eq!(justs(&s, b)[0], Justify::JustifyAll);
assert_eq!(*justs(&s, a).last().unwrap(), Justify::JustifyAll, "the paragraph's part in frame a follows");
}
/// Upstream's paragraph attributes (direction, mojikumi, leading model) follow a range like the
/// others: only the touched paragraphs change, and the layout reads each paragraph's own.
#[test]
fn ranged_set_format_sets_direction_mojikumi_and_leading_model_per_paragraph() {
use vectorcraft_doc::{LeadingModel, Mojikumi, ParaDirection};
let mut s = session();
let id = area(&mut s, "Hello.\nHello.");
// A caret in the second paragraph (byte 9).
s.execute(
"text.setFormat",
&json!({"ids": [id], "direction": "rightToLeft", "leadingModel": "emBoxTop", "mojikumi": "none", "start": 9, "end": 9}),
)
.unwrap();
let t = text(&s, id);
let (p0, p1) = (t.para_at(0).clone(), t.para_at(1).clone());
assert_eq!((p0.direction, p0.leading_model, p0.mojikumi), (None, LeadingModel::RomanBaseline, Mojikumi::LineEndHalf));
assert_eq!((p1.direction, p1.leading_model, p1.mojikumi), (Some(ParaDirection::RightToLeft), LeadingModel::EmBoxTop, Mojikumi::None));
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(!lay.lines[0].rtl && lay.lines[1].rtl, "each paragraph's direction");
// Return in the right-to-left paragraph continues it.
s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": "\nX"})).unwrap();
let t = text(&s, id);
assert_eq!(t.para_at(2).direction, Some(ParaDirection::RightToLeft));
// No range: every paragraph.
s.execute("text.setFormat", &json!({"ids": [id], "direction": "auto"})).unwrap();
assert!(text(&s, id).paragraph_styles().iter().all(|p| p.direction.is_none()));
}

View File

@ -0,0 +1,347 @@
use super::*;
use serde_json::json;
use vectorcraft_doc::shaper::{FILL_PREFIX, SOURCES, STROKE_PREFIX};
use vectorcraft_doc::{LiveShape, Node, NodeKind};
use vectorcraft_geom::{Point, Shape as _};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap();
s
}
fn rectangle(s: &mut Session, x: f64, fill: &str) -> NodeId {
s.execute("paint.setFill", &json!({"color": fill, "ids": []})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x":x,"y":0,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap();
NodeId(id)
}
fn scribble(s: &mut Session, pts: &[[f64; 2]]) -> NodeId {
NodeId(s.execute("shaper.scribble", &json!({"points":pts})).unwrap()["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> &Node {
s.doc().unwrap().doc.node(id).unwrap()
}
fn fill(s: &Session, id: NodeId, x: f64, y: f64) -> Option<String> {
node(s, id)
.children()
.unwrap()
.iter()
.filter(|n| n.name.as_deref() != Some(SOURCES))
.rev()
.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::new(x, y)) != 0)
})
.map(|n| n.appearance.fill_paint().label())
}
fn stroke(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
node(s, id).children().unwrap().iter().filter(|n| n.name.as_deref() != Some(SOURCES)).any(|n| {
n.name.as_deref().is_some_and(|n| n.starts_with(STROKE_PREFIX))
&& !n.appearance.stroke_paint().is_none()
&& n.path_data().and_then(|p| p.nearest(Point::new(x, y))).is_some_and(|(_, _, _, _, d)| d < 0.1)
})
}
#[test]
fn shaper_merges_with_origin_color_keeps_live_originals_and_undoes_once() {
for reverse in [false, true] {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
let b = rectangle(&mut s, 50.0, "#0000ff");
let originals = [node(&s, a).clone(), node(&s, b).clone()];
let undo = s.doc().unwrap().history.undo.len();
let mut pts = vec![[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0], [75.0, 80.0], [125.0, 85.0]];
if reverse {
pts.reverse();
}
let g = scribble(&mut s, &pts);
let color = if reverse { "#0000ff" } else { "#ff0000" };
for x in [25.0, 75.0, 125.0] {
assert_eq!(fill(&s, g, x, 50.0).as_deref(), Some(color));
}
for n in &originals {
assert_eq!(node(&s, n.id), n);
}
assert_eq!(node(&s, g).children().unwrap()[0].name.as_deref(), Some(SOURCES));
assert!(!stroke(&s, g, 50.0, 50.0));
assert!(stroke(&s, g, 0.0, 50.0));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(g).is_none());
for n in &originals {
assert_eq!(node(&s, n.id), n);
}
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some(color));
}
}
#[test]
fn shaper_interior_punch_removes_the_whole_visible_front_face() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
let b = rectangle(&mut s, 50.0, "#0000ff");
let originals = [node(&s, a).clone(), node(&s, b).clone()];
let g = scribble(&mut s, &[[65.0, 20.0], [85.0, 40.0], [65.0, 60.0], [85.0, 80.0]]);
assert_eq!(fill(&s, g, 75.0, 50.0), None);
assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#ff0000"));
assert_eq!(fill(&s, g, 125.0, 50.0), None);
assert!(stroke(&s, g, 50.0, 50.0) && stroke(&s, g, 150.0, 50.0));
for n in &originals {
assert_eq!(node(&s, n.id), n);
}
}
#[test]
fn shaper_exiting_scribble_erases_only_touched_fill_and_boundary_strokes() {
let mut s = session();
rectangle(&mut s, 0.0, "#ff0000");
rectangle(&mut s, 50.0, "#0000ff");
let g = scribble(&mut s, &[[110.0, 20.0], [140.0, 35.0], [110.0, 50.0], [140.0, 65.0], [200.0, 70.0]]);
assert_eq!(fill(&s, g, 125.0, 50.0), None);
assert!(!stroke(&s, g, 150.0, 50.0));
assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#ff0000"));
assert_eq!(fill(&s, g, 75.0, 50.0), None);
assert!(stroke(&s, g, 0.0, 50.0));
}
#[test]
fn shaper_erases_only_protruding_stroke_pieces_keeps_fill_and_original_line() {
let mut s = session();
s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap();
let circle = NodeId(s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap());
let line = NodeId(s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap()["id"].as_u64().unwrap());
let original_line = node(&s, line).clone();
let g = scribble(&mut s, &[[60.0, 140.0], [70.0, 160.0], [80.0, 140.0], [90.0, 160.0]]);
assert!(!stroke(&s, g, 75.0, 150.0));
assert!(stroke(&s, g, 150.0, 150.0));
assert!(stroke(&s, g, 225.0, 150.0));
assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00"));
assert_eq!(node(&s, line), &original_line);
assert!(matches!(node(&s, circle).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. }));
let g = scribble(&mut s, &[[210.0, 140.0], [220.0, 160.0], [230.0, 140.0], [240.0, 160.0]]);
assert!(!stroke(&s, g, 225.0, 150.0));
assert!(stroke(&s, g, 150.0, 150.0));
assert_eq!(node(&s, line), &original_line);
}
#[test]
fn shaper_source_move_resize_native_roundtrip_and_release_preserve_live_art() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
let b = rectangle(&mut s, 50.0, "#0000ff");
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
s.execute("object.move", &json!({"ids":[a.0],"dx":20,"dy":10})).unwrap();
assert_eq!(fill(&s, g, 30.0, 15.0).as_deref(), Some("#ff0000"));
assert_eq!(node(&s, a).geometric_bounds().unwrap().x0, 20.0);
s.execute("select.set", &json!({"ids":[a.0]})).unwrap();
s.execute("object.setBounds", &json!({"width":120.0,"height":110.0,"reference":0})).unwrap();
assert!(matches!(node(&s, a).kind, NodeKind::Path { live: Some(LiveShape::Rectangle { .. }), .. }));
let doc = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
assert_eq!(back.layers, doc.layers);
let originals = [node(&s, a).clone(), node(&s, b).clone()];
s.execute("shaper.release", &json!({"id":g.0})).unwrap();
assert!(s.doc().unwrap().doc.node(g).is_none());
for n in &originals {
assert_eq!(node(&s, n.id), n);
}
}
#[test]
fn shaper_face_paint_survives_source_edits_and_expand_discards_only_sources() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
rectangle(&mut s, 50.0, "#0000ff");
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
s.execute("shaper.select", &json!({"point":[75,50]})).unwrap();
s.execute("paint.setFill", &json!({"color":"#00ff00"})).unwrap();
s.execute("object.move", &json!({"ids":[a.0],"dx":5,"dy":0})).unwrap();
assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00"));
s.execute("shaper.expand", &json!({"id":g.0})).unwrap();
assert!(node(&s, g).shaper.is_none());
assert!(s.doc().unwrap().doc.node(a).is_none());
assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00"));
}
#[test]
fn shaper_double_click_selects_an_original_for_construction_editing() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
rectangle(&mut s, 50.0, "#0000ff");
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
let requests = s.pointer(&PointerEvent::new(PointerKind::DoubleClick, 25.0, 50.0), v).unwrap();
assert!(requests.iter().any(|r| matches!(r, UiRequest::SwitchTool(tool) if tool == "selection")));
s.select_tool("selection", v).unwrap();
assert_eq!(s.tool_id(), "selection");
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.doc().unwrap().isolation, Some(node(&s, g).children().unwrap()[0].id));
assert!(s.doc().unwrap().doc.is_editable(a));
s.execute("object.move", &json!({"dx":10,"dy":0})).unwrap();
assert_eq!(fill(&s, g, 15.0, 50.0).as_deref(), Some("#ff0000"));
s.execute("object.exitIsolation", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
}
#[test]
fn shaper_lines_keep_free_angles_with_or_without_shift() {
for shift in [false, true] {
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
for (kind, x, y) in [(PointerKind::Down, 30.0, 40.0), (PointerKind::Drag, 130.0, 70.0), (PointerKind::Up, 230.0, 100.0)] {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods { shift, ..Default::default() }), v).unwrap();
}
let n = node(&s, s.doc().unwrap().selection.objects[0]);
assert!(matches!(n.kind,NodeKind::Path{live:Some(LiveShape::Line{a,b}),..} if a==Point::new(30.0,40.0) && b==Point::new(230.0,100.0)));
}
}
#[test]
fn shaper_rejects_oversized_or_invalid_gestures_without_changing_art() {
let mut s = session();
rectangle(&mut s, 0.0, "#ff0000");
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
for params in [json!({"points":vec![[1.0,1.0];4097]}), json!({"points":[[1,1],[2,2]],"tolerance":0}), json!({"points":[[1e100,0]]})] {
assert!(s.execute("shaper.scribble", &params).is_err());
assert_eq!(s.doc().unwrap().doc.layers, before.layers);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
}
#[test]
fn shaper_tool_trims_both_diagonal_ends_and_only_construction_can_pick_them() {
use vectorcraft_doc::hit::{HitOptions, hit_test};
let mut s = session();
s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap();
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
let rotate = |x: f64, y: f64| {
let (x, y) = (x - 150.0, y - 150.0);
Point::new(150.0 + (x + y) / 2.0f64.sqrt(), 150.0 + (y - x) / 2.0f64.sqrt())
};
let (a, b) = (rotate(50.0, 150.0), rotate(250.0, 150.0));
let line = NodeId(s.execute("shape.line", &json!({"x1":a.x,"y1":a.y,"x2":b.x,"y2":b.y})).unwrap()["id"].as_u64().unwrap());
let original = node(&s, line).clone();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
for offset in [0.0, 150.0] {
let points = [(60.0, 140.0), (70.0, 160.0), (80.0, 140.0), (90.0, 160.0)];
for (i, (x, y)) in points.into_iter().enumerate() {
let p = rotate(x + offset, y);
let kind = if i == 0 {
PointerKind::Down
} else if i == 3 {
PointerKind::Up
} else {
PointerKind::Drag
};
s.pointer(&PointerEvent::new(kind, p.x, p.y), v).unwrap();
}
}
let g = s.doc().unwrap().selection.objects[0];
assert!(node(&s, g).shaper.is_some());
assert_eq!(node(&s, line), &original);
assert!(stroke(&s, g, 150.0, 150.0));
assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00"));
let sources = node(&s, g).children().unwrap()[0].id;
let doc = &s.doc().unwrap().doc;
for x in [75.0, 225.0] {
let p = rotate(x, 150.0);
assert!(!stroke(&s, g, p.x, p.y));
assert!(!node(&s, g).geometric_bounds().unwrap().contains(p));
assert!(!node(&s, g).visual_bounds().unwrap().contains(p));
assert!(hit_test(doc, p, HitOptions { tol: 0.5, ..Default::default() }).is_none());
assert_eq!(hit_test(doc, p, HitOptions { tol: 0.5, scope: Some(sources), ..Default::default() }).unwrap().leaf, line);
}
let p = rotate(75.0, 150.0);
s.execute("shaper.select", &json!({"point":[p.x,p.y],"source":true})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![line]);
}
#[test]
fn shaper_three_circles_erase_a_visible_fill_and_keep_the_complete_shared_curve() {
for exits in [false, true] {
let mut s = session();
let mut originals = vec![];
for (x, y, fill) in [(100.0, 20.0, "#ff0000"), (20.0, 130.0, "#ffffff"), (170.0, 130.0, "#ffff00")] {
s.execute("paint.setFill", &json!({"color":fill,"ids":[]})).unwrap();
let id = NodeId(s.execute("shape.ellipse", &json!({"x":x,"y":y,"width":200,"height":200})).unwrap()["id"].as_u64().unwrap());
originals.push(node(&s, id).clone());
}
let mut points = vec![[290.0, 210.0], [330.0, 225.0], [290.0, 240.0], [330.0, 255.0]];
if exits {
points.push([410.0, 260.0]);
}
let g = scribble(&mut s, &points);
for (x, y) in [(330.0, 230.0), (190.0, 230.0), (250.0, 180.0)] {
assert_eq!(fill(&s, g, x, y), None, "all of the visible yellow face is erased, including covered intersections");
}
assert_eq!(stroke(&s, g, 370.0, 230.0), !exits);
for angle in [145.0_f64, 160.0, 180.0, 200.0, 220.0, 240.0, 260.0, 280.0] {
let (sin, cos) = angle.to_radians().sin_cos();
assert!(stroke(&s, g, 270.0 + 100.0 * cos, 230.0 + 100.0 * sin), "shared curve remains complete at {angle}°, exiting {exits}");
}
if exits {
// The two new corners must be actual path joins, rather than meeting butt caps.
for corner in [Point::new(298.975, 134.288), Point::new(195.0, 296.144)] {
assert!(
node(&s, g)
.children()
.unwrap()
.iter()
.filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX)))
.flat_map(|n| n.path_data().unwrap().subpaths.iter())
.flat_map(|sp| &sp.anchors)
.any(|a| a.p.distance(corner) < 0.1 && a.has_in() && a.has_out()),
"joined corner at {corner:?}"
);
}
}
assert_eq!(fill(&s, g, 110.0, 230.0).as_deref(), Some("#ffffff"));
assert_eq!(fill(&s, g, 200.0, 80.0).as_deref(), Some("#ff0000"));
for n in originals {
assert_eq!(node(&s, n.id), &n);
}
}
}
#[test]
fn shaper_visible_dividing_stroke_keeps_fill_erasure_on_one_side() {
let mut s = session();
s.execute("paint.setFill", &json!({"color":"#ffff00","ids":[]})).unwrap();
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
s.execute("shape.line", &json!({"x1":100,"y1":150,"x2":200,"y2":150})).unwrap();
let g = scribble(&mut s, &[[140.0, 125.0], [160.0, 135.0], [140.0, 140.0], [160.0, 145.0]]);
assert_eq!(fill(&s, g, 150.0, 130.0), None);
assert_eq!(fill(&s, g, 150.0, 170.0).as_deref(), Some("#ffff00"));
assert!(stroke(&s, g, 150.0, 150.0));
}
#[test]
fn shaper_joined_outline_keeps_its_stroke_paint_after_source_edits() {
let mut s = session();
let mut ids = vec![];
for (x, color) in [(0, "#ff0000"), (50, "#ffff00")] {
s.execute("paint.setFill", &json!({"color":color,"ids":[]})).unwrap();
ids.push(NodeId(s.execute("shape.ellipse", &json!({"x":x,"y":0,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap()));
}
let g = scribble(&mut s, &[[20.0, 40.0], [70.0, 45.0], [30.0, 50.0], [125.0, 55.0], [75.0, 60.0], [125.0, 65.0]]);
let outlines: Vec<_> =
node(&s, g).children().unwrap().iter().filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX))).collect();
assert_eq!(outlines.len(), 1);
assert!(outlines[0].path_data().unwrap().subpaths[0].closed);
let outline = outlines[0].id;
s.execute("paint.setStroke", &json!({"color":"#00ff00","ids":[outline.0]})).unwrap();
s.execute("object.move", &json!({"ids":[ids[0].0],"dx":10,"dy":0})).unwrap();
let outlines: Vec<_> =
node(&s, g).children().unwrap().iter().filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX))).collect();
assert_eq!(outlines.len(), 1);
assert_eq!(outlines[0].appearance.stroke_paint().label(), "#00ff00");
assert!(outlines[0].path_data().unwrap().subpaths[0].closed);
for id in ids {
assert_ne!(node(&s, id).appearance.stroke_paint().label(), "#00ff00");
}
}

View File

@ -205,6 +205,7 @@ impl Session {
slices_locked: self.menu.slices_locked,
auto_add_delete: !self.prefs.disable_auto_add_delete,
selection_tolerance: self.prefs.selection_tolerance,
anchor_size: self.prefs.anchor_size,
path_only: self.prefs.object_selection_by_path_only,
type_path_only: self.prefs.type_selection_by_path_only,
double_click_isolate: self.prefs.double_click_to_isolate,

View File

@ -274,6 +274,15 @@ fn structured_junk() {
("text.create", json!({"x": 0, "y": 0, "text": "\u{0}\u{FFFF}\u{1F600}", "size": -1})),
("text.create", json!({"x": 0, "y": 0, "text": "a", "size": 1e308, "area": {"width": -1, "height": 0}})),
("text.setStyle", json!({"size": 0, "leading": -1, "tracking": 1e308})),
("text.setStyle", json!({"justify": "center", "start": u64::MAX, "end": 0})),
("text.setStyle", json!({"justify": "right", "start": -1, "end": "x"})),
("text.setFormat", json!({"spaceBefore": 1e308, "leftIndent": -1e308, "start": 3, "end": u64::MAX})),
("text.setFormat", json!({"hyphenate": true, "underline": true, "start": 1.5, "end": null})),
("text.setFormat", json!({"ids": [u64::MAX, 2], "firstLineIndent": 5, "start": 0, "end": 0})),
("text.tabs.set", json!({"stops": [{"position": 10}], "start": u64::MAX, "end": u64::MAX})),
("text.tabs.get", json!({"start": -5})),
("paraStyle.apply", json!({"name": "[Normal Paragraph Style]", "id": 2, "start": u64::MAX, "end": 1})),
("paraStyle.new", json!({"id": 2, "start": u64::MAX})),
("document.setUnits", json!({"units": "Parsecs"})),
("document.open", json!({"name": "x.svg", "dataBase64": "!!!"})),
("document.open", json!({"name": "x.svg", "dataBase64": "PHN2Zz4="})),

View File

@ -1,11 +1,11 @@
//! Shape recognition for the Shaper tool: a rough freehand stroke becomes a clean shape.
//!
//! The stroke is classified by how closed it is, how many corners survive a coarse polyline
//! simplification (Douglas–Peucker at a fraction of the stroke's size) and how well it fills its
//! bounding box. A zig-zag stroke with several reversals is a scribble (the Shaper deletes what it
//! covers).
//! simplification independent of where drawing began, its edge/angle fit and how well it fills
//! its bounding box. Samples are spaced by distance so pointer speed does not bias the fit.
//! A zig-zag stroke with several reversals is a scribble (the Shaper deletes what it covers).
use crate::{Point, Rect, Vec2};
use crate::{Affine, Point, Rect, Vec2};
/// What the Shaper recognised.
#[derive(Clone, Debug, PartialEq)]
@ -14,10 +14,16 @@ pub enum Recognized {
a: Point,
b: Point,
},
/// Axis-aligned rectangle (squares snap when the sides are within 10%).
Rectangle(Rect),
/// Axis-aligned ellipse in `rect` (circles snap when the axes are within 10%).
Ellipse(Rect),
/// Rectangle at a 45° step (squares snap when the sides are within 10%).
Rectangle {
rect: Rect,
rotation: f64,
},
/// Ellipse at a 45° step (circles snap when the axes are within 10%).
Ellipse {
rect: Rect,
rotation: f64,
},
/// Regular polygon (triangle, pentagon, hexagon…): centre, radius, side count and the rotation
/// (degrees) of the first vertex from straight up.
Polygon {
@ -48,30 +54,174 @@ fn dist_to_segment(p: Point, a: Point, b: Point) -> f64 {
p.distance(a + ab * t)
}
/// Douglas–Peucker simplification.
/// Douglas–Peucker simplification with bounded stack use, even for noisy strokes.
fn simplify(pts: &[Point], tol: f64) -> Vec<Point> {
if pts.len() < 3 {
return pts.to_vec();
}
let (a, b) = (pts[0], pts[pts.len() - 1]);
let (i, d) = pts[1..pts.len() - 1]
.iter()
.enumerate()
.map(|(i, p)| (i + 1, dist_to_segment(*p, a, b)))
.fold((0, 0.0), |m, x| if x.1 > m.1 { x } else { m });
if d <= tol {
return vec![a, b];
let mut keep = vec![false; pts.len()];
if let Some(first) = keep.first_mut() {
*first = true;
}
let mut left = simplify(&pts[..=i], tol);
let right = simplify(&pts[i..], tol);
left.pop();
left.extend(right);
left
if let Some(last) = keep.last_mut() {
*last = true;
}
let mut pending = vec![(0, pts.len() - 1)];
while let Some((start, end)) = pending.pop() {
let Some(window) = pts.get(start..=end) else { continue };
let (Some(&a), Some(&b)) = (window.first(), window.last()) else { continue };
let (offset, deviation) = window
.iter()
.enumerate()
.skip(1)
.take(window.len().saturating_sub(2))
.map(|(i, p)| (i, dist_to_segment(*p, a, b)))
.fold((0, 0.0), |best, candidate| if candidate.1 > best.1 { candidate } else { best });
if deviation > tol {
let split = start + offset;
if let Some(k) = keep.get_mut(split) {
*k = true;
}
if split > start + 1 {
pending.push((start, split));
}
if end > split + 1 {
pending.push((split, end));
}
}
}
pts.iter().zip(keep).filter_map(|(p, keep)| keep.then_some(*p)).collect()
}
/// Sample by distance, so pointer speed/density cannot bias the shape fit. Bounds and length
/// still come from the complete stroke, but simplification works on at most 256 samples.
fn resample(pts: &[Point], length: f64) -> Vec<Point> {
const SAMPLES: usize = 256;
let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return Vec::new() };
let mut out = vec![first];
let mut walked = 0.0;
for (&a, &b) in pts.iter().zip(pts.iter().skip(1)) {
let segment = a.distance(b);
if segment <= 0.0 {
continue;
}
while out.len() < SAMPLES - 1 {
let target = length * (out.len() as f64 / (SAMPLES - 1) as f64);
if target > walked + segment {
break;
}
out.push(a + (b - a) * ((target - walked) / segment).clamp(0.0, 1.0));
}
walked += segment;
}
out.push(last);
out
}
/// A closed stroke has no special starting corner. Split at distant points and simplify both
/// halves, then discard collinear seam points instead of counting them as polygon vertices.
fn closed_corners(pts: &[Point], tol: f64) -> Vec<Point> {
let Some(&first) = pts.first() else { return Vec::new() };
let start = pts.iter().enumerate().max_by(|(_, a), (_, b)| a.distance(first).total_cmp(&b.distance(first))).map_or(0, |(i, _)| i);
let ring: Vec<Point> = pts.iter().skip(start).chain(pts.iter().take(start)).copied().collect();
let Some(&first) = ring.first() else { return Vec::new() };
let split = ring.iter().enumerate().max_by(|(_, a), (_, b)| a.distance(first).total_cmp(&b.distance(first))).map_or(0, |(i, _)| i);
let (Some(left), Some(right)) = (ring.get(..=split), ring.get(split..)) else { return Vec::new() };
let mut corners = simplify(left, tol);
corners.pop();
let other: Vec<Point> = right.iter().copied().chain(std::iter::once(first)).collect();
let mut other = simplify(&other, tol);
other.pop();
corners.extend(other);
while corners.len() > 3 {
let n = corners.len();
let redundant = corners.iter().enumerate().find_map(|(i, p)| {
let (&a, &b) = (corners.get((i + n - 1) % n)?, corners.get((i + 1) % n)?);
(dist_to_segment(*p, a, b) <= tol).then_some(i)
});
let Some(i) = redundant else { break };
corners.remove(i);
}
corners
}
fn area(poly: &[Point]) -> f64 {
let n = poly.len();
(0..n).map(|i| poly[i].to_vec2().cross(poly[(i + 1) % n].to_vec2())).sum::<f64>().abs() / 2.0
let Some(&origin) = poly.first() else { return 0.0 };
// Translating to the first point avoids cancellation far from the document origin.
poly.iter().zip(poly.iter().skip(1)).map(|(&a, &b)| (a - origin).cross(b - origin)).sum::<f64>().abs() / 2.0
}
/// Four substantial, approximately perpendicular edges, even with uneven sides and corners.
fn is_rectangle(pts: &[Point], corners: &[Point], size: f64) -> bool {
if corners.len() != 4 || !is_polygonal(pts, corners, size, 0.055) {
return false;
}
right_angles(corners, size)
}
fn right_angles(corners: &[Point], size: f64) -> bool {
let around: Vec<Point> = corners.iter().cycle().take(corners.len() + 2).copied().collect();
around.windows(3).all(|w| {
let [a, b, c] = w else { return false };
let (u, v) = (*b - *a, *c - *b);
let (lu, lv) = (u.hypot(), v.hypot());
lu > 0.03 * size && lv > 0.03 * size && (u.dot(v) / (lu * lv)).abs() < 0.45
})
}
/// Fit all vertices, so neither the starting point nor drawing direction picks the orientation.
fn polygon_rotation(corners: &[Point], step: f64) -> f64 {
let Some(&origin) = corners.first() else { return 0.0 };
let n = corners.len() as f64;
let center = origin + corners.iter().fold(Vec2::ZERO, |sum, p| sum + (*p - origin) / n);
let period = 360.0 / n;
let error = |rotation: f64| {
corners
.iter()
.map(|p| {
let angle = (*p - center).atan2().to_degrees() + 90.0;
let delta = (angle - rotation).rem_euclid(period);
delta.min(period - delta).powi(2)
})
.sum::<f64>()
};
[0.0, 90.0, 180.0, 270.0].into_iter().filter(|r| r % step == 0.0).min_by(|a, b| error(*a).total_cmp(&error(*b))).unwrap_or(0.0)
}
fn rectangle_rotation(corners: &[Point]) -> f64 {
let (sin, cos) = corners.iter().zip(corners.iter().cycle().skip(1)).fold((0.0, 0.0), |(sin, cos), (&a, &b)| {
let angle = (b - a).atan2() * 4.0;
(sin + angle.sin(), cos + angle.cos())
});
(sin.atan2(cos).to_degrees() / 4.0 / 45.0).round().mul_add(45.0, 0.0).rem_euclid(180.0)
}
fn ellipse_rotation(pts: &[Point]) -> f64 {
let Some(&origin) = pts.first() else { return 0.0 };
let n = pts.len() as f64;
let center = origin + pts.iter().fold(Vec2::ZERO, |sum, p| sum + (*p - origin) / n);
let (xx, yy, xy) = pts.iter().fold((0.0, 0.0, 0.0), |(xx, yy, xy), p| {
let v = *p - center;
(xx + v.x * v.x, yy + v.y * v.y, xy + v.x * v.y)
});
// A near-circle has no useful orientation; keep it upright rather than following noise.
if (xx - yy).hypot(2.0 * xy) < 0.1 * (xx + yy) {
return 0.0;
}
((2.0 * xy).atan2(xx - yy).to_degrees() / 2.0 / 45.0).round().mul_add(45.0, 0.0).rem_euclid(180.0)
}
/// Dimensions in the fitted frame, but the centre stays in document coordinates.
fn oriented_bounds(pts: &[Point], rotation: f64) -> Rect {
if rotation == 0.0 {
return bbox(pts);
}
let origin = pts.first().copied().unwrap_or(Point::ZERO);
let rotate = Affine::rotate(rotation.to_radians());
let inverse = Affine::rotate(-rotation.to_radians());
let local: Vec<Point> = pts.iter().map(|p| inverse * (*p - origin).to_point()).collect();
let b = bbox(&local);
Rect::from_center_size(origin + (rotate * b.center()).to_vec2(), (b.width(), b.height()))
}
/// Number of sharp direction reversals (turns of more than 120°) along the stroke.
@ -87,22 +237,35 @@ fn reversals(pts: &[Point], min_seg: f64) -> usize {
/// Recognise a freehand stroke (document points, in drawing order).
pub fn recognize(pts: &[Point]) -> Option<Recognized> {
if pts.len() < 2 {
if pts.len() < 2 || pts.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return None;
}
let b = bbox(pts);
let size = b.width().max(b.height());
if size < 2.0 {
if size < 2.0 || !size.is_finite() {
return None;
}
let len = path_len(pts);
let (first, last) = (pts[0], pts[pts.len() - 1]);
if !len.is_finite() || len <= 0.0 {
return None;
}
let original = pts;
let samples = resample(pts, len);
let pts = samples.as_slice();
let (&first, &last) = (pts.first()?, pts.last()?);
let chord = first.distance(last);
// Scribble: several sharp reversals packed into a small area relative to the stroke length.
if reversals(pts, size * 0.08) >= 3 && len > 3.0 * size {
return Some(Recognized::Scribble(b));
if reversals(pts, size * 0.08) >= 2 && len > 1.5 * size {
// An equilateral triangle turns 120° at its vertices: drawing noise can make all
// three turns just exceed the reversal threshold. Its closed, straight edges and
// substantial area distinguish it from a deletion scribble.
let corners = closed_corners(pts, size * 0.12);
let triangle = chord <= 0.25 * size && corners.len() == 3 && area(pts) > 0.2 * size * size && is_polygonal(pts, &corners, size, 0.055);
if !triangle {
return Some(Recognized::Scribble(b));
}
}
// Line: every point near the chord.
let chord = first.distance(last);
let max_dev = pts.iter().map(|p| dist_to_segment(*p, first, last)).fold(0.0, f64::max);
if chord > 0.5 * size && max_dev < 0.08 * chord.max(1.0) && len < 1.3 * chord {
return Some(Recognized::Line { a: first, b: last });
@ -111,13 +274,11 @@ pub fn recognize(pts: &[Point]) -> Option<Recognized> {
if chord > 0.25 * size {
return None;
}
let mut closed: Vec<Point> = pts.to_vec();
closed.push(first);
let mut corners = simplify(&closed, size * 0.12);
if corners.len() > 1 && corners[0].distance(corners[corners.len() - 1]) < size * 0.15 {
corners.pop();
}
let corners = closed_corners(pts, size * 0.12);
let fill = area(pts) / (b.width() * b.height()).max(1e-9);
if !fill.is_finite() {
return None;
}
let center = b.center();
let square = |r: Rect| {
let (w, h) = (r.width(), r.height());
@ -131,33 +292,36 @@ pub fn recognize(pts: &[Point]) -> Option<Recognized> {
match corners.len() {
3 => {
let radius = corners.iter().map(|p| p.distance(center)).sum::<f64>() / 3.0;
// Rotation of the vertex nearest straight up (y grows downward).
let top = corners.iter().copied().min_by(|a, b| a.y.total_cmp(&b.y)).unwrap_or(center);
let rot = (top - center).atan2().to_degrees() + 90.0;
Some(Recognized::Polygon { center, radius, sides: 3, rotation: rot })
Some(Recognized::Polygon { center, radius, sides: 3, rotation: polygon_rotation(&corners, 180.0) })
}
4 if fill > 0.86 => Some(Recognized::Rectangle(square(b))),
// A diamond: a square standing on a corner.
4 if (0.4..0.62).contains(&fill) => {
let radius = corners.iter().map(|p| p.distance(center)).sum::<f64>() / 4.0;
Some(Recognized::Polygon { center, radius, sides: 4, rotation: 0.0 })
4 if is_rectangle(pts, &corners, size) => {
let rotation = rectangle_rotation(&corners);
Some(Recognized::Rectangle { rect: square(oriented_bounds(original, rotation)), rotation })
}
n @ 5..=8 if fill > 0.6 && fill < 0.85 && is_polygonal(pts, &corners, size) => {
n @ 5..=8 if fill > 0.6 && fill < 0.85 && is_polygonal(pts, &corners, size, 0.02) => {
let radius = corners.iter().map(|p| p.distance(center)).sum::<f64>() / n as f64;
Some(Recognized::Polygon { center, radius, sides: n as u32, rotation: 0.0 })
Some(Recognized::Polygon { center, radius, sides: n as u32, rotation: if n == 6 { polygon_rotation(&corners, 90.0) } else { 0.0 } })
}
_ => {
let rotation = ellipse_rotation(pts);
let rect = oriented_bounds(original, rotation);
(area(pts) / (rect.width() * rect.height()).max(1e-9) > 0.6).then(|| Recognized::Ellipse { rect: square(rect), rotation })
}
_ if fill > 0.6 => Some(Recognized::Ellipse(square(b))),
_ => None,
}
}
/// Do the stroke's points hug the straight edges between `corners` (rather than bulging like an
/// ellipse)?
fn is_polygonal(pts: &[Point], corners: &[Point], size: f64) -> bool {
let n = corners.len();
let dev: f64 = pts.iter().map(|p| (0..n).map(|i| dist_to_segment(*p, corners[i], corners[(i + 1) % n])).fold(f64::MAX, f64::min)).sum::<f64>()
fn is_polygonal(pts: &[Point], corners: &[Point], size: f64, tolerance: f64) -> bool {
if pts.is_empty() || corners.len() < 3 {
return false;
}
let dev: f64 = pts
.iter()
.map(|p| corners.iter().zip(corners.iter().cycle().skip(1)).map(|(&a, &b)| dist_to_segment(*p, a, b)).fold(f64::MAX, f64::min))
.sum::<f64>()
/ pts.len() as f64;
dev < 0.02 * size
dev < tolerance * size
}
#[cfg(test)]
@ -192,20 +356,45 @@ mod tests {
#[test]
fn rough_rectangle_and_square() {
let r = wobbly(&[Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 100.0), Point::new(0.0, 100.0)], 20, 2.0);
let Some(Recognized::Rectangle(b)) = recognize(&r) else { panic!("{:?}", recognize(&r)) };
let Some(Recognized::Rectangle { rect: b, .. }) = recognize(&r) else { panic!("{:?}", recognize(&r)) };
assert!((b.width() - 200.0).abs() < 8.0 && (b.height() - 100.0).abs() < 8.0);
let s = wobbly(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 95.0), Point::new(0.0, 95.0)], 20, 1.0);
let Some(Recognized::Rectangle(b)) = recognize(&s) else { panic!() };
let Some(Recognized::Rectangle { rect: b, .. }) = recognize(&s) else { panic!() };
assert!((b.width() - b.height()).abs() < 1e-9, "near-squares snap to squares");
}
#[test]
fn rough_squares_are_rectangles_from_any_starting_point() {
let cases = [
[Point::new(0.0, 0.0), Point::new(100.0, 8.0), Point::new(94.0, 100.0), Point::new(10.0, 91.0)],
[Point::new(0.0, 8.0), Point::new(94.0, 0.0), Point::new(100.0, 95.0), Point::new(7.0, 100.0)],
];
for vertices in cases {
for wobble in [0.5, 4.0] {
let mut stroke = wobbly(&vertices, 24, wobble);
stroke.pop();
for start in [0, 5, 12, 20, 30, 47, 60, 79] {
stroke.rotate_left(start);
let mut closed = stroke.clone();
closed.push(stroke[0] + Vec2::new(2.0, -1.0));
assert!(
matches!(recognize(&closed), Some(Recognized::Rectangle { .. })),
"wobble {wobble}, start {start}: {:?}",
recognize(&closed)
);
stroke.rotate_right(start);
}
}
}
}
#[test]
fn ellipse_circle_triangle_hexagon_line() {
assert!(
matches!(recognize(&circle(Point::new(100.0, 100.0), 80.0, 40.0, 72)), Some(Recognized::Ellipse(r)) if r.width() > 150.0 && r.height() < 100.0)
matches!(recognize(&circle(Point::new(100.0, 100.0), 80.0, 40.0, 72)), Some(Recognized::Ellipse { rect: r, .. }) if r.width() > 150.0 && r.height() < 100.0)
);
assert!(
matches!(recognize(&circle(Point::new(100.0, 100.0), 50.0, 48.0, 72)), Some(Recognized::Ellipse(r)) if (r.width() - r.height()).abs() < 1e-9)
matches!(recognize(&circle(Point::new(100.0, 100.0), 50.0, 48.0, 72)), Some(Recognized::Ellipse { rect: r, .. }) if (r.width() - r.height()).abs() < 1e-9)
);
let tri = wobbly(&[Point::new(100.0, 0.0), Point::new(200.0, 170.0), Point::new(0.0, 170.0)], 25, 1.5);
assert!(matches!(recognize(&tri), Some(Recognized::Polygon { sides: 3, rotation, .. }) if rotation.abs() < 5.0), "{:?}", recognize(&tri));
@ -220,6 +409,151 @@ mod tests {
assert!(matches!(recognize(&line), Some(Recognized::Line { .. })));
}
#[test]
fn circles_and_real_polygons_keep_their_identity_across_stroke_seams() {
for (rx, ry) in [(50.0, 50.0), (80.0, 40.0), (50.0, 40.0)] {
let mut stroke = circle(Point::new(100.0, 100.0), rx, ry, 192);
stroke.pop();
for start in [0, 13, 37, 62, 109] {
stroke.rotate_left(start);
let mut closed = stroke.clone();
closed.push(stroke[0]);
assert!(matches!(recognize(&closed), Some(Recognized::Ellipse { .. })), "ellipse {rx}/{ry}, seam {start}: {:?}", recognize(&closed));
stroke.rotate_right(start);
}
}
for sides in [3, 5, 6, 8] {
let corners: Vec<Point> = (0..sides)
.map(|i| {
let a = std::f64::consts::TAU * i as f64 / sides as f64;
Point::new(100.0 + 80.0 * a.cos(), 100.0 + 80.0 * a.sin())
})
.collect();
let mut stroke = wobbly(&corners, 24, 1.0);
stroke.pop();
for start in [0, 9, 17, 29, 45] {
stroke.rotate_left(start);
let mut closed = stroke.clone();
closed.push(stroke[0]);
assert!(
matches!(recognize(&closed), Some(Recognized::Polygon { sides: n, .. }) if n == sides),
"{sides} sides, seam {start}: {:?}",
recognize(&closed)
);
stroke.rotate_right(start);
}
}
}
#[test]
fn rough_triangles_snap_upright_or_upside_down() {
for direction in [0.0_f64, 180.0] {
for tilt in [-12.0_f64, -3.0, 4.0, 11.0] {
let corners: Vec<Point> = (0..3)
.map(|i| {
let a = (direction + tilt - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 3.0;
Point::new(200.0 + 80.0 * a.cos(), 300.0 + 80.0 * a.sin())
})
.collect();
for reversed in [false, true] {
let mut stroke = wobbly(&corners, 24, 1.5);
stroke.pop();
if reversed {
stroke.reverse();
}
for seam in [0, 9, 28, 51] {
stroke.rotate_left(seam);
let mut closed = stroke.clone();
closed.push(stroke[0]);
let Some(Recognized::Polygon { sides: 3, rotation, .. }) = recognize(&closed) else {
panic!("direction {direction}, tilt {tilt}, seam {seam}: {:?}", recognize(&closed));
};
assert_eq!(rotation, direction, "tilt {tilt}, seam {seam}, reversed {reversed}");
stroke.rotate_right(seam);
}
}
}
}
}
#[test]
fn recognized_lines_keep_their_free_angle() {
for (dx, dy) in [(120.0, 7.0), (-130.0, -9.0), (8.0, 90.0), (-6.0, -140.0), (60.0, 50.0), (-50.0, -60.0)] {
let first = Point::new(200.0, 300.0);
let last = first + Vec2::new(dx, dy);
let stroke: Vec<Point> = (0..=30).map(|i| first + (last - first) * (i as f64 / 30.0)).collect();
assert_eq!(recognize(&stroke), Some(Recognized::Line { a: first, b: last }));
}
}
#[test]
fn tilted_rectangles_ellipses_and_hexagons_snap_to_their_angle_steps() {
for direction in [0.0_f64, 45.0, 90.0, 135.0] {
for tilt in [-7.0_f64, 7.0] {
let xf = Affine::translate((200.0, 300.0)) * Affine::rotate((direction + tilt).to_radians());
let corners: Vec<Point> =
[(-90.0, -40.0), (90.0, -40.0), (90.0, 40.0), (-90.0, 40.0)].into_iter().map(|p| xf * Point::from(p)).collect();
let stroke = wobbly(&corners, 24, 1.0);
let Some(Recognized::Rectangle { rect, rotation }) = recognize(&stroke) else {
panic!("rectangle {direction}: {:?}", recognize(&stroke))
};
assert_eq!(rotation % 45.0, 0.0);
assert!(rect.center().distance(Point::new(200.0, 300.0)) < 4.0);
assert!((rect.width().max(rect.height()) - 180.0).abs() < 15.0);
let ellipse: Vec<Point> = circle(Point::ZERO, 90.0, 40.0, 192).into_iter().map(|p| xf * p).collect();
let Some(Recognized::Ellipse { rect, rotation }) = recognize(&ellipse) else {
panic!("ellipse {direction}: {:?}", recognize(&ellipse))
};
assert_eq!(rotation, direction);
assert!((rect.width() - 180.0).abs() < 4.0 && (rect.height() - 80.0).abs() < 4.0);
}
}
for direction in [0.0_f64, 90.0] {
for tilt in [-7.0_f64, 7.0] {
let corners: Vec<Point> = (0..6)
.map(|i| {
let a = (direction + tilt - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 6.0;
Point::new(200.0 + 70.0 * a.cos(), 300.0 + 70.0 * a.sin())
})
.collect();
let stroke = wobbly(&corners, 24, 0.5);
let Some(Recognized::Polygon { sides: 6, rotation, .. }) = recognize(&stroke) else { panic!("hexagon: {:?}", recognize(&stroke)) };
assert_eq!(rotation, direction);
}
}
// Even sideways input can only produce an upright or inverted triangle.
for direction in [83.0_f64, 97.0, 263.0, 277.0] {
let corners: Vec<Point> = (0..3)
.map(|i| {
let a = (direction - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 3.0;
Point::new(200.0 + 70.0 * a.cos(), 300.0 + 70.0 * a.sin())
})
.collect();
assert!(matches!(recognize(&wobbly(&corners, 24, 0.5)), Some(Recognized::Polygon { sides: 3, rotation: 0.0 | 180.0, .. })));
}
}
#[test]
fn square_fit_ignores_sampling_density_and_document_offset() {
let mut stroke = wobbly(&[Point::new(0.0, 0.0), Point::new(100.0, 8.0), Point::new(94.0, 100.0), Point::new(10.0, 91.0)], 24, 4.0);
for scale in [0.25, 1.0, 20.0] {
let shifted: Vec<Point> = stroke.iter().map(|p| Point::new(1e9 + p.x * scale, -1e9 + p.y * scale)).collect();
assert!(matches!(recognize(&shifted), Some(Recognized::Rectangle { .. })), "scale {scale}: {:?}", recognize(&shifted));
}
let dense: Vec<Point> = stroke.iter().enumerate().flat_map(|(i, p)| std::iter::repeat_n(*p, if i < 24 { 100 } else { 1 })).collect();
assert_eq!(recognize(&stroke), recognize(&dense));
stroke.extend(std::iter::repeat_n(*stroke.last().unwrap(), 10_000));
assert!(matches!(recognize(&stroke), Some(Recognized::Rectangle { .. })));
}
#[test]
fn non_finite_and_overflowing_strokes_are_not_shapes() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(recognize(&[Point::new(0.0, 0.0), Point::new(bad, 100.0)]), None);
}
assert_eq!(recognize(&[Point::new(-f64::MAX, 0.0), Point::new(f64::MAX, 100.0)]), None);
}
#[test]
fn scribble_and_noise() {
let z: Vec<Point> = (0..40).map(|i| Point::new(100.0 + (i % 2) as f64 * 60.0, 100.0 + i as f64 * 2.0)).collect();

View File

@ -549,6 +549,10 @@ impl Rules<'_> {
if let Some(l) = st.leading {
art.props.push(("line-height", self.len(l)));
}
// One element: the first paragraph's alignment.
if (1..t.paragraph_count()).any(|i| t.para_at(i).justify != t.para.justify) {
art.unsupported("paragraphs aligned differently");
}
let align = match t.para.justify {
Justify::Auto | Justify::Left => None,
Justify::Center => Some("center"),

View File

@ -1683,18 +1683,27 @@ impl Writer<'_> {
}
self.note_fonts(t, &lay);
let lines = text_lines(t, &lay, self.opts.fewer_tspans);
// A tab starts a new chunk at its stop: only lines without tabs can be anchored.
let anchor = match t.para.justify {
Justify::Center => Some(("middle", 0.5)),
Justify::Right => Some(("end", 1.0)),
_ => None,
}
.filter(|_| !lines.iter().flatten().any(|s| s.brk));
// Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its
// stop: lines with tabs are never anchored.
let anchors: Vec<Option<(&str, f64)>> = lines
.iter()
.map(|l| {
match t.para_at(l.para).justify {
Justify::Center => Some(("middle", 0.5)),
Justify::Right => Some(("end", 1.0)),
_ => None,
}
.filter(|_| !l.segs.iter().any(|s| s.brk))
})
.collect();
// Every line anchored alike (the text's only paragraph style, typically): the <text>
// carries the anchor. Otherwise each anchored line is a <tspan> carrying its own.
let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten();
// The <text> element's user space is text space.
let space = (lay.bounds, Affine::IDENTITY);
let base = TextBase { props: self.char_props(&t.first_style(), space), space, anchored: anchor.is_some() };
let base = TextBase { props: self.char_props(&t.first_style(), space), space, anchored: uniform.is_some() };
let mut props = base.props.clone();
if let Some((a, _)) = anchor {
if let Some((a, _)) = uniform {
props.push(("text-anchor", a.into()));
}
props.extend(css::transparency(n));
@ -1702,7 +1711,7 @@ impl Writer<'_> {
let a = self.attrs(&props);
let m = self.xf * t.xf;
let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) };
let starts: Vec<Option<(f64, f64)>> = lines.iter().map(|l| line_start(l, anchor.map(|a| a.1))).collect();
let starts: Vec<Option<(f64, f64)>> = lines.iter().zip(&anchors).map(|(l, a)| line_start(&l.segs, a.map(|a| a.1))).collect();
// The `<text>` carries the first line's position too, so readers that place text by its
// own x/y start where the first line does.
let at = match starts.iter().flatten().next() {
@ -1710,9 +1719,17 @@ impl Writer<'_> {
None => String::new(),
};
let mut s = format!("<text{id}{tr}{at} xml:space=\"preserve\"{a}>");
for (line, start) in lines.iter().zip(starts) {
if let Some(start) = start {
self.text_line(&mut s, t, line, start, &base);
for ((line, start), anchor) in lines.iter().zip(starts).zip(&anchors) {
let Some(start) = start else { continue };
match anchor.filter(|_| uniform.is_none()) {
// A line anchored on its own: one positioned <tspan> with the anchor around it.
Some((a, _)) => {
s.push_str(&format!("<tspan x=\"{}\" y=\"{}\" text-anchor=\"{a}\">", self.num(start.0), self.num(start.1)));
let inner = TextBase { anchored: true, ..base.clone() };
self.text_line(&mut s, t, &line.segs, start, &inner, false);
s.push_str("</tspan>");
}
None => self.text_line(&mut s, t, &line.segs, start, &base, true),
}
}
s.push_str("</text>");
@ -1723,12 +1740,13 @@ impl Writer<'_> {
/// (only the first of an anchored line). Fewer tspans: one positioned `<tspan>` per line with
/// the style changes nested in it, positioned again only after tabs and justified word spaces.
/// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted.
fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase) {
let fewer = self.opts.fewer_tspans;
/// `positioned`: false when the caller already positioned the line (an outer `<tspan>`).
fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) {
let fewer = self.opts.fewer_tspans && positioned;
if fewer {
s.push_str(&format!("<tspan x=\"{}\" y=\"{}\">", self.num(start.0), self.num(start.1)));
}
let mut place = !fewer;
let mut place = !fewer && positioned;
let mut first = true;
// The baseline shift the current text position carries.
let mut shift = 0.0;
@ -1746,7 +1764,7 @@ impl Writer<'_> {
shift = st.baseline_shift;
}
// After a tab or a justified word space the next segment is placed.
place = seg.brk || (!fewer && !base.anchored);
place = seg.brk || (!fewer && !base.anchored && positioned);
if (st.h_scale - st.v_scale).abs() > 1e-9 {
attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance)));
}
@ -1848,7 +1866,7 @@ impl Writer<'_> {
let space = (lay.bounds, self.xf * t.xf);
let base = self.char_props(&t.first_style(), space);
let mut props = base.clone();
match t.para.justify {
match t.para_at(0).justify {
Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())),
Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())),
_ => {}
@ -1917,6 +1935,7 @@ fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
}
/// What the lines of one `<text>` share.
#[derive(Clone)]
struct TextBase {
/// The `<text>` element's character properties, which each `<tspan>` differs from.
props: Props,
@ -1926,6 +1945,12 @@ struct TextBase {
anchored: bool,
}
/// One laid-out line: its paragraph (index) and its segments.
struct Line {
para: usize,
segs: Vec<Segment>,
}
/// Characters of one style on one line, written as one `<tspan>`.
struct Segment {
/// Index into `TextObject::runs`.
@ -1944,11 +1969,12 @@ struct Segment {
/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters
/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all
/// caps are upper case.
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Vec<Segment>> {
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Line> {
let plain = t.plain_text();
let split_words = !fewer && !matches!(t.para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
let mut lines = Vec::with_capacity(lay.lines.len());
for line in &lay.lines {
let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count());
let split_words = !fewer && !matches!(t.para_at(para).justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
let mut segs: Vec<Segment> = Vec::new();
let mut cluster = None;
let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") };
@ -1983,7 +2009,7 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
}
segs.retain(|s| !s.text.is_empty());
if !segs.is_empty() {
lines.push(segs);
lines.push(Line { para, segs });
}
}
lines

View File

@ -30,7 +30,7 @@ use std::str::FromStr;
use usvg::roxmltree;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, StrokeLayer, TextKind, TextObject, TextRun};
use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, ParaStyle, StrokeLayer, TextKind, TextObject, TextRun};
use vectorcraft_geom::kurbo::ParamCurveArclen;
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect};
use vectorcraft_text::{FontDb, TextLayout};
@ -556,11 +556,12 @@ impl Ctx {
.collect();
let under = stroke_under(&mut cells, label, warnings);
let anchor = match self.css.prop(cs.chars[0].1, "text-anchor").as_deref() {
let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.css.prop(c.1, "text-anchor")).as_deref() {
Some("middle") => 0.5,
Some("end") => 1.0,
_ => 0.0,
};
let anchor = anchor_of(0);
let path = cs.path.and_then(|tp| match self.text_path(xml, tp) {
Some(p) => Some((tp, p)),
None => {
@ -571,6 +572,7 @@ impl Ctx {
let vertical = self.css.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical"));
let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default());
let mut breaks: Vec<Break> = vec![];
let mut para_styles: Vec<ParaStyle> = vec![];
if let Some((tp, bp)) = path {
// Along the path: dx is extra advance, dy shifts off the path.
let mut off = 0.0;
@ -610,18 +612,38 @@ impl Ctx {
obj.xf = Affine::IDENTITY;
} else {
let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]);
let (justify, left) = lines(&mut cells, &x, &y, &dx, &dy, anchor, origin.x, &mut breaks);
let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, anchor, origin.x, &mut breaks);
obj.para.justify = justify;
obj.xf = Affine::translate((finite(left), finite(origin.y)));
// Lines anchored differently (paragraphs aligned differently): each line's paragraph
// takes its alignment, and indents that put it where it was.
if let Some(starts) = starts.filter(|st| st.iter().any(|(i, _)| anchor_of(*i) != anchor)) {
for (k, &(i, ax)) in starts.iter().enumerate() {
let a = anchor_of(i);
let d = finite(ax - left);
let mut pa = ParaStyle { justify: justify_of(a), ..ParaStyle::default() };
match pa.justify {
Justify::Center if d >= 0.0 => pa.left_indent = 2.0 * d,
Justify::Center => pa.right_indent = -2.0 * d,
Justify::Right => pa.right_indent = -d,
_ => pa.left_indent = d,
}
let count = if k == 0 { 1 } else { breaks.get(k - 1).map_or(1, |b| b.count) };
para_styles.extend(std::iter::repeat_n(pa, count));
}
}
}
let (runs, servers) = assemble(&cells, &breaks).0;
obj.runs = runs;
if !para_styles.is_empty() {
obj.set_paragraph_styles(para_styles);
}
// SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first
// strong character: pinned when that would read as right to left.
// strong character: pinned (on every paragraph) when that would read as right to left.
if self.css.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") {
obj.para.direction = Some(ParaDirection::RightToLeft);
obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft));
} else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) {
obj.para.direction = Some(ParaDirection::LeftToRight);
obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight));
}
// The placeholder covers the text (laid out when a paint server needs its bounding box).
let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default();
@ -708,8 +730,18 @@ fn finite(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
/// The alignment of an SVG text chunk anchored at `anchor` (0 start, 0.5 middle, 1 end).
fn justify_of(anchor: f64) -> Justify {
match anchor {
a if a >= 1.0 => Justify::Right,
a if a > 0.0 => Justify::Center,
_ => Justify::Left,
}
}
/// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the
/// justification and the x of the first line's left (or anchor) edge.
/// justification, the x of the first line's left (or anchor) edge and, when no character is
/// placed on its own, each line's first character and anchor x.
#[allow(clippy::too_many_arguments)]
fn lines(
cells: &mut [Cell],
@ -720,7 +752,7 @@ fn lines(
anchor: f64,
x0: f64,
breaks: &mut Vec<Break>,
) -> (Justify, f64) {
) -> (Justify, f64, Option<Vec<(usize, f64)>>) {
let n = cells.len();
let y0 = y[0].unwrap_or(0.0) + dy[0];
let (mut base, mut cur) = (y0, y0);
@ -758,12 +790,7 @@ fn lines(
}
}
if targets.is_empty() {
let justify = match anchor {
a if a >= 1.0 => Justify::Right,
a if a > 0.0 => Justify::Center,
_ => Justify::Left,
};
return (justify, x0);
return (justify_of(anchor), x0, Some(lines));
}
// Absolutely placed characters: lay the text out left-aligned and kern each one onto its x.
// SVG anchors every absolutely placed chunk on its own, so with a middle/end anchor each
@ -819,7 +846,7 @@ fn lines(
break;
}
}
(Justify::Left, left)
(Justify::Left, left, None)
}
/// A left-aligned point text of `runs` at the origin (for measuring).

View File

@ -200,3 +200,37 @@ fn a_viewer_draws_the_text_where_the_canvas_does() {
}
}
}
#[test]
fn paragraphs_aligned_differently_keep_their_alignment() {
let mut t = TextObject::point(Point::new(20.0, 20.0), "Left words here\nCentred line\nRight line", style(14.0));
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 160.0)) };
let para = |j| vectorcraft_doc::ParaStyle { justify: j, ..Default::default() };
t.set_paragraph_styles(vec![para(Justify::Left), para(Justify::Center), para(Justify::Right)]);
let mut d = Document::new(300.0, 200.0);
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
for fewer_tspans in [false, true] {
let svg = export(&d, &ExportOptions { fewer_tspans, ..Default::default() });
// Each anchored line carries its own anchor (the <text> has none).
assert!(svg.contains("text-anchor=\"middle\"") && svg.contains("text-anchor=\"end\""), "{svg}");
assert!(!svg.contains("<text text-anchor"), "{svg}");
// A viewer draws each line where the canvas does.
let mut viewer = Document::new(300.0, 200.0);
let vl = viewer.layers[0].id;
for bp in viewer_outlines(&svg) {
let ap = vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let n = Node::path(viewer.alloc_id(), PathData::from_bezpath(&bp), ap);
viewer.insert(Some(vl), usize::MAX, n).unwrap();
}
assert_similar(&render_artboard(&d), &render_artboard(&viewer), 40.0, 0.01);
// Read back: one paragraph per line, each with its alignment, drawn where it was.
let back = vectorcraft_svg::import(&svg).unwrap();
let text =
back.layers[0].children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.clone()) } else { None }).unwrap();
let js: Vec<Justify> = text.paragraph_styles().iter().map(|p| p.justify).collect();
assert_eq!(js, [Justify::Left, Justify::Center, Justify::Right], "{svg}");
assert_similar(&render_artboard(&d), &render_artboard(&back), 40.0, 0.01);
}
}

View File

@ -129,6 +129,15 @@ pub fn rich_session() -> Session {
json!({"x": 300, "y": 330, "size": 14, "text": "Area type shrinks its text to fit the frame.",
"area": {"width": 150, "height": 40, "fit": "shrinkText", "fitMinPercent": 40}}),
));
// Area type whose paragraphs differ (alignment, spacing, tabs).
let para = id_of(&exec(
&mut s,
"text.create",
json!({"x": 20, "y": 360, "text": "Heading\nBody one\tx\nBody two", "size": 9, "area": {"width": 160, "height": 36}}),
));
exec(&mut s, "text.setStyle", json!({"id": para, "justify": "center", "start": 0, "end": 0}));
exec(&mut s, "text.setFormat", json!({"ids": [para], "spaceBefore": 3, "firstLineIndent": 6, "start": 9, "end": 9}));
exec(&mut s, "text.tabs.set", json!({"ids": [para], "stops": [{"position": 80, "align": "right"}], "start": 9, "end": 9}));
exec(&mut s, "layer.new", json!({"name": "Top"}));
let d = rect(&mut s, 350.0, 200.0, 80.0, 80.0);
let e = ellipse(&mut s, 380.0, 230.0, 80.0, 80.0);

View File

@ -250,7 +250,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout
_ => QUARTER_TURN,
};
match &t.kind {
TextKind::Point => flow(&mut cx, &paras, &t.para, None),
TextKind::Point => flow(&mut cx, &paras, t, None),
TextKind::Area { frame } => {
let to_lines = line_xf.inverse();
let wrap: Vec<vectorcraft_doc::WrapShape> = if vertical {
@ -260,9 +260,9 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout
};
let mut regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap);
cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect();
flow(&mut cx, &paras, &t.para, Some(&regions));
flow(&mut cx, &paras, t, Some(&regions));
if opts.vertical_align != VerticalAlign::Top {
align_vertically(&mut cx, &paras, &t.para, &mut regions);
align_vertically(&mut cx, &paras, t, &mut regions);
}
}
TextKind::OnPath { path, .. } => on_path(&mut cx, &paras, t, path),
@ -526,7 +526,7 @@ fn cell_space(out: &TextLayout, regions: &[Region]) -> Vec<Option<(f64, f64, usi
/// Other frames give lines different widths at different heights, so the text flows again with
/// each cell's lines started lower (or spaced wider) until it settles, at most eight passes; a
/// pass that would push text out of the frame is undone and retried with half the step.
fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: &mut [Region]) {
fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: &mut [Region]) {
let align = cx.opts.vertical_align;
let plain = regions.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty());
if plain {
@ -596,7 +596,7 @@ fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle,
}
let before = laid_out(&cx.out);
let prev = (std::mem::take(&mut cx.out.glyphs), std::mem::take(&mut cx.out.lines), std::mem::replace(&mut cx.out.overflow, false));
flow(cx, paras, para, Some(regions));
flow(cx, paras, t, Some(regions));
if laid_out(&cx.out) < before {
// Text no longer fits (lines got narrower, or flow around a wrap object): undo the
// pass and try a smaller step.
@ -1291,7 +1291,9 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt
compose(sg, &width, &start_credit, &cands, &params(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, &params(loose)))
}
fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Option<&[Region]>) {
/// Flow paragraphs `paras` (byte ranges) of `t`, each with its own paragraph attributes
/// (alignment, indents, spacing, direction, mojikumi, leading model…).
fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Option<&[Region]>) {
let mut pen = Pen {
regions,
ri: 0,
@ -1300,10 +1302,14 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
fb: cx.opts.first_baseline,
fb_min: cx.opts.first_baseline_min,
queued: vec![],
model: para.leading_model,
model: t.para_at(0).leading_model,
next_top: None,
};
'paras: for (pi, pr) in paras.iter().enumerate() {
let para = t.para_at(pi);
// The leading model is a paragraph attribute: this paragraph's lines (and the space to
// its first line) follow its own.
pen.model = para.leading_model;
let text = cx.text;
let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction);
let rtl = is_rtl(bidi.as_ref());
@ -1568,7 +1574,8 @@ fn path_steps(ap: &ArcPath, glyphs: &[SGlyph], from: f64, spacing: f64) -> Vec<f
/// Lay type on a path out along `path` (text space), between its brackets.
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &PathData) {
cx.out.on_path = true;
let para = &t.para;
// Type on a path is one line: the first paragraph's attributes align it.
let para = t.para_at(0);
let mut sg = Vec::new();
// The first paragraph's direction aligns the line (Auto) and sets the caret's.
let mut rtl = None;

View File

@ -209,6 +209,27 @@ fn indents_and_paragraph_spacing() {
assert!((l.lines[1].baseline - l.lines[0].baseline - 18.0).abs() < 1e-9);
}
#[test]
fn each_paragraph_has_its_own_attributes() {
let frame = Rect::new(0.0, 0.0, 200.0, 400.0);
let mut t = area("left\ncentred\nright", style(10.0), frame, Justify::Left);
let centred = vectorcraft_doc::ParaStyle { justify: Justify::Center, space_before: 20.0, ..Default::default() };
let right = vectorcraft_doc::ParaStyle { justify: Justify::Right, left_indent: 7.0, ..Default::default() };
t.set_paragraph_styles(vec![t.para.clone(), centred, right]);
let l = layout(db(), &t);
assert_eq!(l.lines.len(), 3);
assert!(l.lines[0].x0.abs() < 1e-9, "the first paragraph stays left");
let mid = (l.lines[1].x0 + l.lines[1].x1) / 2.0;
assert!((mid - 100.0).abs() < 1e-6, "the second is centred: {mid}");
assert!((l.lines[2].x1 - 200.0).abs() < 1e-6, "the third is right-aligned");
// Space before applies to the second paragraph only (leading 12 + 20).
assert!((l.lines[1].baseline - l.lines[0].baseline - 32.0).abs() < 1e-9);
assert!((l.lines[2].baseline - l.lines[1].baseline - 12.0).abs() < 1e-9);
// Paragraph 0's style is `para` (what readers that predate per-paragraph styles see).
assert_eq!(t.paras.len(), 3);
assert_eq!(t.para_at(0), &t.para);
}
#[test]
fn non_rect_frame_narrows_lines() {
// Triangle pointing up: lines get wider towards the bottom.
@ -558,6 +579,46 @@ fn em_box_top_leading_hangs_lines_from_the_line_above() {
);
}
/// The leading model is a paragraph attribute: in "大 / 小 / 大 / 小" (40 pt over 20 pt) with
/// only the second paragraph top-to-top, its line hangs from the big line's em box (the big
/// line's leading, below it) while the fourth, baseline to baseline, takes its own leading above
/// it; in area type a top-to-top first paragraph puts its em box on the frame's top.
#[test]
fn leading_model_is_per_paragraph() {
use vectorcraft_doc::{LeadingModel, ParaStyle, TextRun};
let st = |size: f64| CharStyle { size, leading: Some(size * 1.5), ..style(size) };
let model = |m: LeadingModel| ParaStyle { leading_model: m, ..ParaStyle::default() };
let top = |size: f64| 0.88 * size;
let mut t = point("", st(40.0));
t.runs = vec![
TextRun { text: "大\n".into(), style: st(40.0) },
TextRun { text: "小\n".into(), style: st(20.0) },
TextRun { text: "大\n".into(), style: st(40.0) },
TextRun { text: "小".into(), style: st(20.0) },
];
t.set_paragraph_styles(vec![
model(LeadingModel::RomanBaseline),
model(LeadingModel::EmBoxTop),
model(LeadingModel::RomanBaseline),
model(LeadingModel::RomanBaseline),
]);
assert_eq!(t.paras.len(), 4);
let l = layout(db(), &t);
let gap = |i: usize| l.lines[i].baseline - l.lines[i - 1].baseline;
let em = -top(40.0) + 60.0 + top(20.0);
assert!((gap(1) - em).abs() < 0.01, "top to top: {} vs {em}", gap(1));
assert!((gap(3) - 30.0).abs() < 0.01, "baseline to baseline: {}", gap(3));
// Area type: only a top-to-top first paragraph moves the first line up to the frame's top.
let mut a = area("一\n二", st(20.0), Rect::new(0.0, 0.0, 300.0, 300.0), Justify::Left);
a.set_paragraph_styles(vec![model(LeadingModel::RomanBaseline), model(LeadingModel::EmBoxTop)]);
let roman_first = layout(db(), &a).lines[0].baseline;
a.set_paragraph_styles(vec![model(LeadingModel::EmBoxTop), model(LeadingModel::RomanBaseline)]);
let em_first = layout(db(), &a);
assert!((em_first.lines[0].baseline - top(20.0)).abs() < 0.01, "{}", em_first.lines[0].baseline);
assert!((roman_first - em_first.lines[0].baseline).abs() > 0.5);
assert!((em_first.lines[1].baseline - em_first.lines[0].baseline - 30.0).abs() < 0.01);
}
/// Character Alignment: a 20 pt character next to a 40 pt one lines its em box top, centre or
/// bottom up with the big one's (the em box from 0.12 em below the baseline to 0.88 em above it,
/// in fonts without vertical metrics), or stays on the baseline; in vertical type the same across

View File

@ -10,10 +10,16 @@ fn text_layout(s: &str) -> TextLayout {
fn arabic_test_face(test: &str) -> Option<Arc<FontFace>> {
let db = FontDb::global();
// OpenType (GSUB) shaping only: AAT fonts such as macOS's Geeza Pro shape with `morx`, which
// takes no context from outside the run, so their joining across a style boundary differs.
let opentype = |face: &FontFace| {
use skrifa::raw::TableProvider;
face.skrifa().is_some_and(|f| f.gsub().is_ok())
};
let face = ["Geeza Pro", "Arial", "Noto Sans Arabic"]
.into_iter()
.filter_map(|name| db.face(name, "Regular"))
.find(|face| face.covers('ب') && face.covers('ل') && face.covers('ا'));
.find(|face| face.covers('ب') && face.covers('ل') && face.covers('ا') && opentype(face));
if let Some(face) = &face {
let path = face.path().map_or_else(|| "bundled font".to_string(), |p| p.display().to_string());
eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {} ({path})", face.family, face.style);
@ -312,3 +318,27 @@ fn visual_text_comes_back_in_logical_order() {
assert_eq!(shown, visual);
}
}
/// Paragraph Direction is a paragraph attribute: in one text object a right-to-left paragraph
/// reads and aligns (Auto) right to left while the next, left to right, starts on the left.
#[test]
fn paragraph_direction_is_per_paragraph() {
use vectorcraft_doc::{Justify, ParaDirection, ParaStyle};
let s = "Hello.\nHello.";
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
let dir = |direction| ParaStyle { justify: Justify::Auto, direction, ..ParaStyle::default() };
t.set_paragraph_styles(vec![dir(Some(ParaDirection::RightToLeft)), dir(Some(ParaDirection::LeftToRight))]);
assert_eq!(t.paras.len(), 2, "stored per paragraph");
let l = layout(FontDb::global(), &t);
assert_eq!(l.lines.len(), 2);
let shown = |line: &crate::LineInfo| -> String {
let mut gs: Vec<_> = l.glyphs.iter().filter(|g| g.byte >= line.start && g.byte < line.end).collect();
gs.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x));
gs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).filter(|c| *c != '\n').collect()
};
assert!(l.lines[0].rtl && !l.lines[1].rtl);
assert_eq!(shown(&l.lines[0]), ".Hello", "the first paragraph runs right to left");
assert_eq!(shown(&l.lines[1]), "Hello.", "the second runs left to right");
assert!(l.lines[0].x1.abs() < 1e-6, "Auto aligns the right-to-left paragraph right: {}", l.lines[0].x1);
assert!(l.lines[1].x0.abs() < 1e-6, "…and the left-to-right one left: {}", l.lines[1].x0);
}

View File

@ -4,7 +4,7 @@
//! testing need nothing special; after an edit the engine joins the slices and re-distributes them
//! with [`distribute`].
use vectorcraft_doc::{TextObject, TextRun};
use vectorcraft_doc::{ParaStyle, TextObject, TextRun};
use crate::edit::{normalize, runs_len, slice_runs, style_at};
use crate::{FontDb, layout};
@ -16,6 +16,36 @@ pub fn story(frames: &[&TextObject]) -> Vec<TextRun> {
runs
}
/// The story's paragraph styles, one per story paragraph. A frame whose slice ends inside a
/// paragraph shares that paragraph with the next frame (its first one): the earlier frame's style
/// wins unless `later_wins[k]` says frame `k`'s first paragraph should (it was just restyled), or
/// the earlier frame's part of the paragraph is empty (its slice ends with a paragraph break).
pub fn story_paras(frames: &[&TextObject], later_wins: &[bool]) -> Vec<ParaStyle> {
let mut out: Vec<ParaStyle> = vec![];
let mut prev_empty_tail = true;
for (k, f) in frames.iter().enumerate() {
let mut styles = f.paragraph_styles().into_iter();
if k > 0
&& let Some(first) = styles.next()
&& (prev_empty_tail || later_wins.get(k).copied().unwrap_or(false))
&& let Some(last) = out.last_mut()
{
*last = first;
}
out.extend(styles);
let text = f.plain_text();
prev_empty_tail = text.is_empty() || text.ends_with('\n') || out.is_empty();
}
out
}
/// Paragraph styles of the slice `runs` of a story starting at story paragraph `first`.
fn slice_paras(paras: &[ParaStyle], first: usize, runs: &[TextRun]) -> Vec<ParaStyle> {
let n = 1 + runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::<usize>();
let fallback = paras.last().cloned().unwrap_or_default();
(first..first + n).map(|i| paras.get(i).cloned().unwrap_or_else(|| fallback.clone())).collect()
}
/// How many bytes of `runs` fit in `frame` (whole lines; a following break space or paragraph
/// break stays with the line that ends there).
fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
@ -37,21 +67,43 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
end
}
/// The story `runs` split across `frames` in order (the last frame takes any overflow). Empty
/// slices keep one empty run carrying the style at the split, so typing there has a style.
pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun]) -> Vec<Vec<TextRun>> {
/// A frame's share of a story: its runs and its paragraphs' styles (one per paragraph).
#[derive(Clone, Debug, PartialEq)]
pub struct Slice {
pub runs: Vec<TextRun>,
pub paras: Vec<ParaStyle>,
}
/// The story `runs` (with paragraph styles `paras`, one per story paragraph; empty = each
/// frame's own) split across `frames` in order (the last frame takes any overflow). Empty slices
/// keep one empty run carrying the style at the split, so typing there has a style. A paragraph
/// split between frames has its style in both.
pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle]) -> Vec<Slice> {
let mut rest = runs.to_vec();
let mut out = Vec::with_capacity(frames.len());
let mut next_para = 0;
for (i, f) in frames.iter().enumerate() {
let first_para = next_para;
let len = runs_len(&rest);
let n = if i + 1 == frames.len() { len } else { fit(db, f, &rest) };
let own = [f.para.clone()];
let (paras, first_para) = if paras.is_empty() { (&own[..], 0) } else { (paras, first_para) };
let n = if i + 1 == frames.len() {
len
} else {
let mut probe = (*f).clone();
probe.runs = rest.clone();
probe.set_paragraph_styles(slice_paras(paras, first_para, &rest));
fit(db, &probe, &rest)
};
let mut head = slice_runs(&rest, 0, n);
if head.is_empty() {
head.push(TextRun { text: String::new(), style: style_at(&rest, n) });
}
let tail = slice_runs(&rest, n, len);
rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len) }] } else { tail };
out.push(head);
let head_paras = slice_paras(paras, first_para, &head);
next_para += head_paras.len().saturating_sub(1);
out.push(Slice { runs: head, paras: head_paras });
}
out
}
@ -59,7 +111,7 @@ pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun]) -> Vec<
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::{CharStyle, TextKind};
use vectorcraft_doc::{CharStyle, Justify, TextKind};
use vectorcraft_geom::{Affine, Rect, shapes};
fn frame(x: f64) -> TextObject {
@ -75,7 +127,7 @@ mod tests {
let (a, b, c) = (frame(0.0), frame(200.0), frame(400.0));
let text = "The quick brown fox jumps over the lazy dog. ".repeat(6) + "\nSecond paragraph here.";
let story = vec![TextRun { text: text.clone(), style: CharStyle::default() }];
let parts = distribute(db, &[&a, &b, &c], &story);
let parts: Vec<Vec<TextRun>> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect();
let lens: Vec<usize> = parts.iter().map(|p| runs_len(p)).collect();
assert!(lens[0] > 0 && lens[1] > 0, "{lens:?}");
let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect();
@ -90,12 +142,43 @@ mod tests {
assert!(!parts[1][0].text.starts_with(' '));
}
#[test]
fn paragraph_styles_flow_with_their_paragraphs() {
let db = FontDb::global();
let (a, b) = (frame(0.0), frame(200.0));
let text = "The quick brown fox jumps over the lazy dog. ".repeat(4) + "\nSecond paragraph.\nThird.";
let story = vec![TextRun { text, style: CharStyle::default() }];
let style = |j| ParaStyle { justify: j, ..Default::default() };
use vectorcraft_doc::Justify::*;
let paras = vec![style(Left), style(Center), style(Right)];
let parts = distribute(db, &[&a, &b], &story, &paras);
assert!(!parts[0].runs.is_empty() && !parts[1].runs.is_empty());
// The first paragraph overflows into the second frame: it is in both, with its style.
let pa: Vec<Justify> = parts[0].paras.iter().map(|p| p.justify).collect();
let pb: Vec<Justify> = parts[1].paras.iter().map(|p| p.justify).collect();
assert_eq!(pa, [Left]);
assert_eq!(pb, [Left, Center, Right]);
// Joined again, the shared paragraph counts once.
let mut fa = a.clone();
fa.runs = parts[0].runs.clone();
fa.set_paragraph_styles(parts[0].paras.clone());
let mut fb = b.clone();
fb.runs = parts[1].runs.clone();
fb.set_paragraph_styles(parts[1].paras.clone());
assert_eq!(story_paras(&[&fa, &fb], &[]), paras);
// A frame whose shared first paragraph was restyled wins.
fb.edit_paras(Some(0..1), |p| p.justify = JustifyAll);
assert_eq!(story_paras(&[&fa, &fb], &[false, true])[0].justify, JustifyAll);
assert_eq!(story_paras(&[&fa, &fb], &[false, false])[0].justify, Left);
}
#[test]
fn short_story_leaves_later_frames_empty_with_a_style() {
let db = FontDb::global();
let (a, b) = (frame(0.0), frame(200.0));
let st = CharStyle { size: 20.0, ..Default::default() };
let parts = distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st }]);
let parts: Vec<Vec<TextRun>> =
distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st }], &[]).into_iter().map(|s| s.runs).collect();
assert_eq!(parts[0][0].text, "Hi");
assert_eq!((parts[1][0].text.as_str(), parts[1][0].style.size), ("", 20.0));
}

View File

@ -105,6 +105,12 @@ pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) {
if !n.visible {
return;
}
if n.shaper.is_some() {
for c in n.children().into_iter().flatten().skip(1) {
leaf_shapes(c, out);
}
return;
}
match &n.kind {
NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {}
NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n),
@ -256,7 +262,7 @@ impl BuilderMap {
}
/// The regions just either side of the middle of `piece` (None: outside every region).
fn sides(&self, piece: &po::Shape) -> [Option<usize>; 2] {
pub fn sides(&self, piece: &po::Shape) -> [Option<usize>; 2] {
let Some(sp) = piece.path.subpaths.first() else { return [None; 2] };
let n = sp.segment_count();
if n == 0 {

View File

@ -201,15 +201,21 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
v
}
/// Anchor of any visible path or area type frame under `p`, topmost first: (id, si, ai, where it is).
/// The anchor within `tol` of `p` nearest to it, of the topmost visible path or area type frame
/// that has one: (id, si, ai, where it is).
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, Point)> {
let owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n));
owners.into_iter().find_map(|id| {
let pd = cx.doc.node(id).and_then(editable_path)?;
pd.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(si, ai, a)| (id, si, ai, a.p))
nearest_anchor(&pd, p, tol).map(|(si, ai, a)| (id, si, ai, a))
})
}
/// The anchor of `pd` within `tol` of `p` nearest to it: (subpath, anchor, where it is).
fn nearest_anchor(pd: &PathData, p: Point, tol: f64) -> Option<(usize, usize, Point)> {
pd.anchors().map(|(si, ai, a)| (si, ai, a.p)).filter(|(.., a)| a.distance(p) <= tol).min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p)))
}
/// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors).
fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec<AnchorRef>)> {
anchor_owners(cx, is_area_type).into_iter().find_map(|id| {
@ -290,7 +296,8 @@ impl Tool for DirectSelectionTool {
return out;
}
let p = ev.pos;
let tol = cx.pick_tol();
// Anchors and handle ends are picked from a little further than segments and objects.
let (tol, point) = (cx.pick_tol(), cx.point_tol());
match (ev.kind, self.state.clone()) {
(PointerKind::Down, _) if self.group => {
if let Some(out) = self.guide.press(cx, ev) {
@ -332,7 +339,7 @@ impl Tool for DirectSelectionTool {
return vec![];
}
if let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id))
&& let Some(out) = hit_spine_handle(cx, sel, p, tol)
&& let Some(out) = hit_spine_handle(cx, sel, p, point)
{
self.state = State::SpineHandle { id: sel.0, anchor: sel.1, out };
return vec![Action::Begin("Reshape Spine".into())];
@ -343,17 +350,17 @@ impl Tool for DirectSelectionTool {
return self.mesh.press(g);
}
self.mesh.unfocus();
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
if let Some((id, si, ai, out)) = hit_handle(cx, p, point) {
self.state = State::Handle { id, si, ai, out };
self.handle_snap = HandleSnap::default();
return vec![Action::Begin("Reshape".into())];
}
if let Some((id, anchor, from)) = hit_spine_point(cx, p, tol) {
if let Some((id, anchor, from)) = hit_spine_point(cx, p, point) {
self.spine = Some((id, anchor));
self.state = State::SpinePoint { id, anchor, from, start: p, began: false };
return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
}
if let Some((id, si, ai, grab)) = hit_anchor(cx, p, tol) {
if let Some((id, si, ai, grab)) = hit_anchor(cx, p, point) {
if cx.doc.node(id).is_some_and(is_area_type) {
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
}
@ -608,14 +615,14 @@ impl Tool for DirectSelectionTool {
}
}
/// The anchor within the selection tolerance of `p`, of a selected path or the path under `p`:
/// what Highlight anchors on mouse over marks (a press there picks it).
/// The anchor within a press's reach of `p` ([`ToolContext::point_tol`]), of a selected path or the
/// path under `p`: what Highlight anchors on mouse over marks (a press there picks it).
fn hovered_anchor(cx: &ToolContext, p: Point) -> Option<(NodeId, Point)> {
let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.leaf);
let tol = cx.pick_tol();
let tol = cx.point_tol();
cx.selection.objects.iter().copied().chain(under).find_map(|id| {
let path = cx.doc.node(id).and_then(editable_path)?;
path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(_, _, a)| (id, a.p))
nearest_anchor(&path, p, tol).map(|(.., a)| (id, a))
})
}
@ -685,6 +692,29 @@ mod tests {
assert_eq!(down(&mut t, 100.0), vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]);
}
/// An anchor is picked from 2 px past its drawn square even where its segment is nearer, and
/// further with a larger anchor Size or Tolerance (#593); away from it the segment is.
#[test]
fn anchors_are_picked_before_the_segments_through_them() {
let (d, id) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let corner = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))];
let press = |cx: &ToolContext, x: f64, y: f64| DirectSelectionTool::new(false).pointer(cx, &PointerEvent::new(PointerKind::Down, x, y));
let c = cx(&d, &s, &p);
// 4.3 px from the corner, 0.5 px from the top edge.
assert_eq!(press(&c, 104.2, 100.5), corner);
assert_ne!(press(&c, 110.0, 100.5), corner, "the segment, away from the anchor");
assert_ne!(press(&c, 105.0, 100.5), corner, "past the default reach (4.5 px)");
let large = ToolContext { anchor_size: 7, ..cx(&d, &s, &p) };
assert_eq!(press(&large, 106.0, 100.5), corner, "Size 7 draws them 9 px wide");
let loose = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) };
assert_eq!(press(&loose, 107.5, 100.5), corner);
// On screen: at 200% the reach is half as far in the document.
let zoomed = ToolContext { zoom: 2.0, ..cx(&d, &s, &p) };
assert_eq!(press(&zoomed, 102.1, 100.2), corner);
assert_ne!(press(&zoomed, 102.6, 100.2), corner);
}
/// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected.
fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) {
let (mut d, _) = doc_with_rect();

View File

@ -48,7 +48,7 @@ impl AnchorTool {
}
fn delete(cx: &ToolContext, p: Point) -> Vec<Action> {
hit_anchor(cx, p, cx.pick_tol()).map(remove_anchor).into_iter().collect()
hit_anchor(cx, p, cx.point_tol()).map(remove_anchor).into_iter().collect()
}
}
@ -61,7 +61,8 @@ impl Tool for AnchorTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let tol = cx.pick_tol();
// Anchors and handle ends are picked from a little further than segments.
let (tol, point) = (cx.pick_tol(), cx.point_tol());
match (self.id, ev.kind) {
("addAnchor", PointerKind::Down) => {
if ev.mods.alt {
@ -78,7 +79,7 @@ impl Tool for AnchorTool {
}
}
("scissors", PointerKind::Down) => {
if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
if let Some((id, si, ai)) = hit_anchor(cx, p, point) {
return vec![Action::Exec("path.split".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))];
}
match hit_segment(cx, p, tol) {
@ -87,10 +88,10 @@ impl Tool for AnchorTool {
}
}
("anchorPoint", PointerKind::Down) => {
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
if let Some((id, si, ai, out)) = hit_handle(cx, p, point) {
self.state = State::Handle { id, si, ai, out, began: false };
self.snap = HandleSnap::default();
} else if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
} else if let Some((id, si, ai)) = hit_anchor(cx, p, point) {
self.state = State::Convert { id, si, ai, start: p, began: false };
} else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) {
self.state = State::Reshape { id, si, seg, t, start: p, began: false };

View File

@ -238,43 +238,42 @@ impl Tool for ShaperTool {
vec![]
}
PointerKind::Drag => {
if !self.points.is_empty() && self.points.last().is_none_or(|l| l.distance(ev.pos) >= cx.tol(1.5)) {
if !self.points.is_empty() && self.points.len() < 4095 && self.points.last().is_none_or(|l| l.distance(ev.pos) >= cx.tol(1.5)) {
self.points.push(ev.pos);
}
vec![]
}
PointerKind::Up => {
// A fast final segment may arrive only on release; it still closes the stroke.
if !self.points.is_empty() && self.points.last().is_none_or(|p| *p != ev.pos) {
self.points.push(ev.pos);
}
let pts = std::mem::take(&mut self.points);
let r = |x: vectorcraft_geom::Rect| json!({ "x": x.x0, "y": x.y0, "width": x.width(), "height": x.height() });
if pts.first().is_some_and(|first| pts.iter().all(|p| p.distance(*first) < cx.tol(3.0))) {
return vec![Action::Exec("shaper.select".into(), json!({"point": [ev.pos.x, ev.pos.y]}))];
}
let r = |x: vectorcraft_geom::Rect, rotation: f64| json!({ "x": x.x0, "y": x.y0, "width": x.width(), "height": x.height(), "rotation": rotation });
match recognize(&pts) {
Some(Recognized::Line { a, b }) => vec![Action::Exec("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))],
Some(Recognized::Rectangle(x)) => vec![Action::Exec("shape.rectangle".into(), r(x))],
Some(Recognized::Ellipse(x)) => vec![Action::Exec("shape.ellipse".into(), r(x))],
Some(Recognized::Rectangle { rect, rotation }) => vec![Action::Exec("shape.rectangle".into(), r(rect, rotation))],
Some(Recognized::Ellipse { rect, rotation }) => vec![Action::Exec("shape.ellipse".into(), r(rect, rotation))],
Some(Recognized::Polygon { center, radius, sides, rotation }) => {
vec![Action::Exec(
"shape.polygon".into(),
json!({ "cx": center.x, "cy": center.y, "radius": radius, "sides": sides, "rotation": rotation }),
)]
}
Some(Recognized::Scribble(_)) => {
// Delete the topmost objects the scribble crosses.
let mut ids: Vec<u64> = vec![];
for p in &pts {
if let Some(h) = vectorcraft_doc::hit::hit_test(cx.doc, *p, cx.hit_options()) {
let id = h.top_object(cx.isolation).0;
if !ids.contains(&id) {
ids.push(id);
}
}
}
if ids.is_empty() {
return vec![];
}
vec![Action::Exec("select.set".into(), json!({ "ids": ids })), Action::Exec("edit.clear".into(), json!({ "ids": ids }))]
}
Some(Recognized::Scribble(_)) => vec![Action::Exec(
"shaper.scribble".into(),
json!({"points": pts.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>(), "tolerance": cx.tol(4.0)}),
)],
None => vec![],
}
}
PointerKind::DoubleClick => vec![
Action::Exec("shaper.select".into(), json!({"point": [ev.pos.x, ev.pos.y], "source": true})),
Action::SwitchTool("selection".into()),
],
_ => vec![],
}
}

View File

@ -274,6 +274,8 @@ pub struct ToolContext<'a> {
/// Selection & Anchor Display → Tolerance: how near (screen pixels) a click must be to a path
/// or an anchor to pick it.
pub selection_tolerance: f64,
/// Selection & Anchor Display → Size (1–7, default 3): how big anchors and handles are drawn.
pub anchor_size: u32,
/// Selection & Anchor Display → Object Selection by Path Only: a click inside a filled path
/// doesn't pick it, only one on its path does.
pub path_only: bool,
@ -362,12 +364,21 @@ impl ToolContext<'_> {
pub fn pick_tol(&self) -> f64 {
self.tol(self.selection_tolerance)
}
/// How near (document units) a press picks an anchor or a direction handle's end: the
/// selection tolerance, but at least 2 px past the drawn point, so a point is easy to catch
/// and wins over the segments through it, which are tested after it (#593).
pub fn point_tol(&self) -> f64 {
// Anchors are drawn 5 px wide at the default Size 3, a pixel more or less per step.
let half = (5.0 + f64::from(self.anchor_size.clamp(1, 7)) - 3.0) / 2.0;
self.tol(self.selection_tolerance.max(half + 2.0))
}
pub fn hit_options(&self) -> vectorcraft_doc::hit::HitOptions {
vectorcraft_doc::hit::HitOptions {
tol: self.pick_tol(),
outline: self.outline,
path_only: self.path_only,
type_path_only: self.type_path_only,
scope: self.isolation,
}
}
}
@ -666,6 +677,7 @@ pub(crate) mod testutil {
slices_locked: false,
auto_add_delete: true,
selection_tolerance: 3.0,
anchor_size: 3,
path_only: false,
type_path_only: false,
double_click_isolate: true,

View File

@ -91,11 +91,11 @@ fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> {
/// Alt held over a handle end, an anchor or a segment of a selected path: the Anchor Point tool's
/// gesture, within its tolerance.
fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
let tol = cx.pick_tol();
let point = cx.point_tol();
m.alt
&& (hit_handle(cx, p, tol).is_some()
|| crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).is_some()
|| crate::draw2::segment_in(cx, editable_paths(cx), p, tol).is_some())
&& (hit_handle(cx, p, point).is_some()
|| crate::draw2::anchor_in(cx, editable_paths(cx), p, point).is_some()
|| crate::draw2::segment_in(cx, editable_paths(cx), p, cx.pick_tol()).is_some())
}
/// The selected paths the pen edits (not guides, nor locked or hidden ones).
@ -123,7 +123,7 @@ impl Tool for PenTool {
self.drag.is_some() || self.handle.is_some() || self.convert.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let tol = cx.tol(5.0);
let tol = cx.tol(5.0).max(cx.point_tol());
// The path a press began is the selected one by the next event.
if self.drawing && self.path.is_none() {
self.path = active_path(cx).map(|a| a.0);
@ -297,7 +297,7 @@ impl Tool for PenTool {
o
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let tol = cx.tol(5.0);
let tol = cx.tol(5.0).max(cx.point_tol());
if let Some((id, first, last, _)) = self.active(cx) {
let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0);
if p.distance(first) <= tol && !one {

View File

@ -364,6 +364,13 @@ impl Tool for SelectionTool {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = pathtype::overlays(cx);
if let Some(source) = cx.isolation.and_then(|id| cx.doc.node(id)).filter(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES)) {
for n in source.children().into_iter().flatten() {
if let Some(path) = n.path_data() {
o.push(Overlay::Path { path: path.to_bezpath(), color: [0x80, 0x80, 0x80], width: 1.0, dashed: true });
}
}
}
match &self.state {
State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
State::Corner(c) => o.extend(c.overlays(cx)),

View File

@ -44,6 +44,10 @@ enum Mode {
struct Typing {
base: Vec<TextRun>,
cur: Vec<TextRun>,
/// The first byte any edit of the session touched (the text before it is unchanged): the
/// previewed span starts no later, so Return at a paragraph's end splits that paragraph (its
/// style continues), not the next one.
lo: usize,
}
/// IME marked text inside the typing session, not yet committed.
@ -174,9 +178,10 @@ impl TypeTool {
if self.typing.is_none() {
out.push(Action::Begin("Typing".into()));
}
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone() });
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX });
let len = edit::runs_len(&ty.cur);
let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len));
ty.lo = ty.lo.min(a);
let caret = match &styled {
Some(r) => edit::replace_range_styled(&mut ty.cur, a, b, r),
None => edit::replace_range(&mut ty.cur, a, b, insert),
@ -187,7 +192,7 @@ impl TypeTool {
// One preview for the whole session: the changed span of base → cur, with its runs.
let base: String = ty.base.iter().map(|r| r.text.as_str()).collect();
let cur: String = ty.cur.iter().map(|r| r.text.as_str()).collect();
let (p, s) = common_affixes(&base, &cur);
let (p, s) = common_affixes(&base, &cur, ty.lo);
let runs = edit::slice_runs(&ty.cur, p, cur.len() - s);
let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) };
out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs})));
@ -332,11 +337,12 @@ fn char_range_to_bytes(s: &str, r: Range<usize>) -> Option<Range<usize>> {
(a <= b).then_some(a..b)
}
/// Lengths of the common prefix and suffix of `a` and `b` (on char boundaries, not overlapping).
fn common_affixes(a: &str, b: &str) -> (usize, usize) {
/// Lengths of the common prefix (at most `max_prefix`) and suffix of `a` and `b` (on char
/// boundaries, not overlapping).
fn common_affixes(a: &str, b: &str, max_prefix: usize) -> (usize, usize) {
let mut p = 0;
for ((i, x), y) in a.char_indices().zip(b.chars()) {
if x != y {
if x != y || i + x.len_utf8() > max_prefix {
break;
}
p = i + x.len_utf8();

View File

@ -221,10 +221,13 @@ fn select_all_and_styled_paste() {
#[test]
fn common_affixes_are_char_safe() {
assert_eq!(common_affixes("hello", "help"), (3, 0));
assert_eq!(common_affixes("aXb", "ab"), (1, 1));
assert_eq!(common_affixes("aaa", "aaaa"), (3, 0));
assert_eq!(common_affixes("é1", "è1"), (0, 1));
assert_eq!(common_affixes("hello", "help", usize::MAX), (3, 0));
assert_eq!(common_affixes("aXb", "ab", usize::MAX), (1, 1));
assert_eq!(common_affixes("aaa", "aaaa", usize::MAX), (3, 0));
assert_eq!(common_affixes("é1", "è1", usize::MAX), (0, 1));
// Return at the end of a paragraph: the span starts where the edit did (before the old break).
assert_eq!(common_affixes("abc\ndef", "abc\n\ndef", 3), (3, 4));
assert_eq!(common_affixes("abc\ndef", "abc\n\ndef", 4), (4, 3));
}
#[test]

View File

@ -1072,7 +1072,8 @@ fn too_many_anchors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> bool {
None => return true,
}
}
!matches!(n.kind, NodeKind::Envelope { .. }) && n.children().into_iter().flatten().any(|c| over(c, left))
!matches!(n.kind, NodeKind::Envelope { .. })
&& n.children().into_iter().flatten().skip(usize::from(n.shaper.is_some())).any(|c| over(c, left))
}
let mut left = OVERLAY_MAX_ANCHORS;
nodes.into_iter().any(|n| over(n, &mut left))
@ -1085,7 +1086,7 @@ fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
if matches!(n.kind, NodeKind::Envelope { .. }) {
return;
}
for c in n.children().into_iter().flatten() {
for c in n.children().into_iter().flatten().skip(usize::from(n.shaper.is_some())) {
walk_drawn(c, f);
}
}
@ -1100,6 +1101,7 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::
outline: app.ui.view.outline,
path_only: prefs.object_selection_by_path_only,
type_path_only: prefs.type_selection_by_path_only,
scope: app.session.active().and_then(|st| st.isolation),
};
vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt)
}
@ -1837,6 +1839,24 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
mod tests {
use super::*;
use vectorcraft_engine::Session;
use vectorcraft_geom::Shape as _;
#[test]
fn shaper_selection_highlights_only_the_visible_result() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
let line = vectorcraft_doc::NodeId(s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap()["id"].as_u64().unwrap());
for x in [0.0, 150.0] {
s.execute("shaper.scribble", &json!({"points":[[60.0+x,140],[70.0+x,160],[80.0+x,140],[90.0+x,160]]})).unwrap();
}
let st = s.active().unwrap();
let g = st.doc.node(st.selection.objects[0]).unwrap();
assert!(g.shaper.is_some());
let bounds = node_outline(g).bounding_box();
assert!((bounds.x0 - 100.0).abs() < 0.01 && (bounds.x1 - 200.0).abs() < 0.01, "{bounds:?}");
assert_eq!(node_outline(st.doc.node(line).unwrap()).bounding_box().x0, 50.0, "an isolated original still shows its full outline");
}
/// One headless canvas frame on an 800 × 600 window.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {

View File

@ -635,3 +635,7 @@
Appearance of Black Vzhled černé
Devices Zařízení
A file dialog is open: choose there first Je otevřené dialogové okno souboru: nejprve vyberte v něm
Shaper Shaper
Expand Shaper Group Rozšířit skupinu Shaper
Release Shaper Group Uvolnit skupinu Shaper
Select Shaper Face Vybrat oblast Shaper

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

View File

@ -3599,3 +3599,7 @@ axis Both Ambos
Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo.
VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo.
A file dialog is open: choose there first Hay un cuadro de diálogo de archivos abierto: elija primero en él
Shaper Shaper
Expand Shaper Group Expandir grupo Shaper
Release Shaper Group Soltar grupo Shaper
Select Shaper Face Seleccionar cara Shaper

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

View File

@ -3599,3 +3599,7 @@ axis Both Entrambi
Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto.
VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia.
A file dialog is open: choose there first È aperta una finestra di dialogo dei file: scegli prima lì
Shaper Shaper
Expand Shaper Group Espandi gruppo Shaper
Release Shaper Group Rilascia gruppo Shaper
Select Shaper Face Seleziona faccia Shaper

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

View File

@ -3061,3 +3061,7 @@ theme Appearance 外観
Images: link to the file instead of only embedding it. 画像:埋め込むだけでなく、ファイルにリンクします。
VectorCraft documents: one locked object, updated when the file changes. VectorCraft ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。
A file dialog is open: choose there first ファイルダイアログが開いています。先にそちらで選択してください
Shaper Shaper
Expand Shaper Group Shaper グループを拡張
Release Shaper Group Shaper グループを解除
Select Shaper Face Shaper の面を選択

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

View File

@ -2649,3 +2649,7 @@
Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo.
VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda.
A file dialog is open: choose there first Há uma caixa de diálogo de arquivos aberta: escolha nela primeiro
Shaper Formador
Expand Shaper Group Expandir Grupo Formador
Release Shaper Group Liberar Grupo Formador
Select Shaper Face Selecionar Face do Formador

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

View File

@ -3597,3 +3597,7 @@ axis Both Оба
Images: link to the file instead of only embedding it. Изображения: связывать с файлом вместо простого внедрения.
VectorCraft documents: one locked object, updated when the file changes. Документы VectorCraft: один заблокированный объект, обновляемый при изменении файла.
A file dialog is open: choose there first Открыто окно выбора файла: сначала сделайте выбор в нём
Shaper Shaper
Expand Shaper Group Разобрать группу Shaper
Release Shaper Group Освободить группу Shaper
Select Shaper Face Выбрать область Shaper

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

View File

@ -3316,3 +3316,6 @@ axis Both 两者
Images: link to the file instead of only embedding it. 图像:链接到文件,而不只是嵌入。
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文档:一个锁定对象,文件更改时随之更新。
A file dialog is open: choose there first 文件对话框已打开:请先在其中选择
Expand Shaper Group 扩展 Shaper 组
Release Shaper Group 释放 Shaper 组
Select Shaper Face 选择 Shaper 区域

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

View File

@ -3091,3 +3091,7 @@ axis Both 兩者
Images: link to the file instead of only embedding it. 影像:連結至檔案,而不只是嵌入。
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文件:單一鎖定物件,檔案變更時隨之更新。
A file dialog is open: choose there first 檔案對話方塊已開啟:請先在其中選擇
Shaper Shaper
Expand Shaper Group 展開 Shaper 群組
Release Shaper Group 釋放 Shaper 群組
Select Shaper Face 選取 Shaper 區域

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

View File

@ -21,16 +21,17 @@ pub const TRACKING_PRESETS: [f64; 14] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.
pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0];
/// The character style shown by the panel: the selected range's (or the caret's) while the Type
/// tool edits text, else the first selected text object's first run.
/// tool edits text, else the first selected text object's first run. With it, the paragraph
/// attributes of the paragraph the selection starts in (else of the first paragraph).
pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft_doc::ParaStyle)> {
if let Some((id, a, b)) = text_editing(app)
&& let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind)
{
return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para.clone()));
return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para_at(t.paragraphs_in(a, a).start).clone()));
}
let n = first_selected(app)?;
match &n.kind {
NodeKind::Text(t) => Some((t.first_style(), t.para.clone())),
NodeKind::Text(t) => Some((t.first_style(), t.para_at(0).clone())),
_ => None,
}
}

View File

@ -22,15 +22,18 @@ pub const ALIGNMENTS: [(Justify, &str, &str, &str); 7] = [
(Justify::JustifyAll, "dc-para-justify-all", "Justify all lines", "justifyAll"),
];
/// Paragraph attributes apply to the whole text object (ending a Type tool typing session first).
/// Paragraph attributes apply to the selected text objects' paragraphs or, while the Type tool
/// edits text, to the paragraphs its selection (or caret) touches (ending its typing session).
fn format(app: &mut VectorcraftApp, p: Value) {
para_cmd(app, "text.setFormat", p);
}
fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
if let Some((id, _, _)) = super::character::text_editing(app) {
if let Some((id, a, b)) = super::character::text_editing(app) {
super::character::end_typing(app);
p["ids"] = json!([id.0]);
p["start"] = json!(a);
p["end"] = json!(b);
}
app.run(cmd, p).ok();
}

View File

@ -26,15 +26,21 @@ fn align_id(a: TabAlign) -> &'static str {
}
}
/// The first selected text object's stops and the ruler span in points (the frame width for area type).
/// The stops of the caret's paragraph while the Type tool edits text (else the first selected text
/// object's first paragraph) and the ruler span in points (the frame width for area type).
fn current(app: &VectorcraftApp) -> Option<(Vec<TabStop>, f64)> {
let n = first_selected(app)?;
let editing = super::character::text_editing(app);
let n = match editing {
Some((id, _, _)) => app.session.active()?.doc.node(id)?.clone(),
None => first_selected(app)?,
};
let NodeKind::Text(t) = &n.kind else { return None };
let para = editing.map_or(0, |(_, a, _)| t.paragraphs_in(a, a).start);
let span = match &t.kind {
vectorcraft_doc::TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()),
_ => 360.0,
};
Some((t.para.tabs.clone(), span.max(72.0)))
Some((t.para_at(para).tabs.clone(), span.max(72.0)))
}
fn stops_json(stops: &[TabStop]) -> Value {
@ -47,9 +53,9 @@ fn stops_json(stops: &[TabStop]) -> Value {
}
fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) {
if let Some((id, _, _)) = super::character::text_editing(app) {
if let Some((id, a, b)) = super::character::text_editing(app) {
super::character::end_typing(app);
app.run("text.tabs.set", json!({"stops": stops_json(stops), "ids": [id.0]})).ok();
app.run("text.tabs.set", json!({"stops": stops_json(stops), "ids": [id.0], "start": a, "end": b})).ok();
} else {
app.run("text.tabs.set", json!({"stops": stops_json(stops)})).ok();
}
@ -209,7 +215,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
};
let sel: usize = pstate(ui.ctx(), "tabs-sel");
if menu_item(ui, tl!("Clear All Tabs"), false, !stops.is_empty()) {
app.run("text.tabs.clear", json!({})).ok();
apply(app, &[]);
}
if menu_item(ui, tl!("Delete Tab"), false, sel < stops.len()) {
stops.remove(sel);

View File

@ -67,11 +67,12 @@ fn current(app: &VectorcraftApp, kind: Kind) -> Option<(Option<String>, Map<Stri
Some((name, m))
}
/// Parameters targeting the selection: the Type tool's selected characters, or the selected objects.
/// Parameters targeting the selection: the Type tool's selected characters (paragraph styles: the
/// paragraphs its selection or caret touches), or the selected objects.
fn target(app: &VectorcraftApp, kind: Kind, mut p: Value) -> Value {
if let Some((id, a, b)) = text_editing(app) {
p["id"] = json!(id.0);
if kind == Kind::Char && b > a {
if kind == Kind::Para || b > a {
p["start"] = json!(a);
p["end"] = json!(b);
}

View File

@ -2306,6 +2306,32 @@ begins or ends, dragging the centre bracket slides the type along its path and,
{"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}}
```
## Paragraph attributes per paragraph
Each paragraph (text split at `\n`) has its own alignment, indents, space before and after, hyphenation,
punctuation spacing (`mojikumi`), hanging punctuation (`burasagari`), direction, leading model, tab stops and
paragraph style. `text.setStyle {justify}`, `text.setFormat {leftIndent, rightIndent, firstLineIndent,
spaceBefore, spaceAfter, hyphenate, mojikumi, burasagari, direction, leadingModel}`, `text.tabs.set` / `text.tabs.clear` and `paraStyle.apply` take optional
`start` / `end` byte offsets of the plain text (as `text.setRangeStyle` does): they change the paragraphs the range
touches (a caret, `start == end`, touches its paragraph). Without a range they change every paragraph of the
targeted objects, as before. With a range, the character attributes of `text.setStyle` and `text.setFormat` style
that range of characters. `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the
paragraph at `start`; `paraStyle.list` counts paragraphs as uses. A Return the Type tool types (or `text.editRange`
inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style;
`text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as
the story re-flows (a paragraph split between frames has them in both).
In the native file a text object's `para` holds the first paragraph's attributes and `paras` (left out when every
paragraph is alike) one entry per paragraph, so older readers keep the first paragraph's. SVG export anchors each
centred or right-aligned line on its own (`<tspan text-anchor>`), and SVG import gives each line's paragraph its
alignment back, with indents that keep it in place.
```json
{"name":"run_command","arguments":{"command":"text.setStyle","params":{"id":42,"justify":"center","start":0,"end":0}}}
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"ids":[42],"spaceBefore":6,"start":12,"end":30}}}
{"name":"run_command","arguments":{"command":"paraStyle.apply","params":{"name":"Heading","id":42,"start":0,"end":0}}}
```
## Constrain proportions
The link between W and H in the Transform panel, the Properties panel and the Control bar is the

18
docs/shaper.md Normal file
View File

@ -0,0 +1,18 @@
# Shaper
Sketch a basic shape with the Shaper tool (Shift+N). Rectangles, squares and ellipses snap to 45° rotation steps; hexagons to 90°. Triangles point up or down; sideways sketches snap to one of those orientations. Lines keep the angle you draw, including while Shift is held.
Scribble over overlapping paths to make an editable Shaper Group:
- Start in a filled region and cross into other regions to merge the regions touched. The result uses the starting region’s fill.
- Scribble within a region to remove its fill and keep its stroke. Hidden overlap boundaries are part of the same visible face; visible dividing strokes create separate faces.
- End the scribble outside the artwork to remove the touched fills and exposed boundary strokes. Shared edges along surviving filled areas remain, closing their contours. Compatible stroke pieces join at the new corners, so the selected stroke’s join style applies there.
- Scribble over an unfilled stroke segment to remove that piece. A line passing through a shape is split at the intersections, so its protruding ends can be removed separately.
Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result.
Object › Shaper › Release restores the original paths. Expand keeps the visible result as ordinary paths. Each scribble and source edit records one undo step. Sources in a composition must share a parent layer/group; Shaper limits a composition to 64 source paths and 4,096 segments.
The construction-mode arrow widget and finer gesture heuristics remain to be implemented.
Behavior reference: [public Shaper documentation](https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html). Rotation steps and independent fill/stroke cases follow the contributor’s supplied requirements. No reference images or product assets are included.