Merge pull request #28 from storytold/pr/m3-stroke-on-type
M3: stroke on type, Stroke panel units, mixed values and Control bar (M3.40, M3.41)
This commit is contained in:
commit
0d145986e8
|
|
@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
|
|||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Justify {
|
||||
#[default]
|
||||
|
|
@ -64,8 +66,24 @@ pub struct CharStyle {
|
|||
pub overprint_fill: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub overprint_stroke: bool,
|
||||
/// Cap, join, miter limit and dashes of the character stroke (Stroke panel, with type
|
||||
/// selected); defaults as for object strokes.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub stroke_cap: LineCap,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub stroke_join: LineJoin,
|
||||
#[serde(default = "ten", skip_serializing_if = "is_ten")]
|
||||
pub stroke_miter_limit: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stroke_dash: Option<Dash>,
|
||||
}
|
||||
|
||||
fn ten() -> f64 {
|
||||
10.0
|
||||
}
|
||||
fn is_ten(v: &f64) -> bool {
|
||||
*v == 10.0
|
||||
}
|
||||
fn regular() -> String {
|
||||
"Regular".into()
|
||||
}
|
||||
|
|
@ -96,6 +114,10 @@ impl Default for CharStyle {
|
|||
style_name: None,
|
||||
overprint_fill: false,
|
||||
overprint_stroke: false,
|
||||
stroke_cap: LineCap::Butt,
|
||||
stroke_join: LineJoin::Miter,
|
||||
stroke_miter_limit: 10.0,
|
||||
stroke_dash: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -104,19 +126,50 @@ impl CharStyle {
|
|||
pub fn effective_leading(&self) -> f64 {
|
||||
self.leading.unwrap_or(self.size * 1.2)
|
||||
}
|
||||
/// These characters' paint as an object appearance (their outlines'): the fill, plus the
|
||||
/// stroke when it is painted, overprinting as the characters do.
|
||||
pub fn appearance(&self) -> crate::Appearance {
|
||||
let mut fill = crate::FillLayer::new(self.fill.clone());
|
||||
fill.overprint = self.overprint_fill;
|
||||
let mut a = crate::Appearance { items: vec![crate::AppearanceItem::Fill(fill)], ..Default::default() };
|
||||
if !self.stroke.is_none() && self.stroke_width > 0.0 {
|
||||
let mut st = crate::StrokeLayer::new(self.stroke.clone(), self.stroke_width);
|
||||
st.overprint = self.overprint_stroke;
|
||||
a.items.push(crate::AppearanceItem::Stroke(st));
|
||||
/// Do the characters draw a stroke (a paint and a positive weight)?
|
||||
pub fn has_stroke(&self) -> bool {
|
||||
!self.stroke.is_none() && self.stroke_width > 0.0
|
||||
}
|
||||
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes and
|
||||
/// overprint), so type strokes share the object strokes' geometry, rendering and export.
|
||||
pub fn stroke_layer(&self) -> StrokeLayer {
|
||||
StrokeLayer {
|
||||
overprint: self.overprint_stroke,
|
||||
cap: self.stroke_cap,
|
||||
join: self.stroke_join,
|
||||
miter_limit: self.stroke_miter_limit,
|
||||
dash: self.stroke_dash.clone(),
|
||||
..StrokeLayer::new(self.stroke.clone(), self.stroke_width)
|
||||
}
|
||||
}
|
||||
/// Take the paint, weight, cap, join, miter limit and dashes of `st` as the character stroke
|
||||
/// (the options characters have no use for, such as alignment and arrowheads, are dropped).
|
||||
pub fn set_stroke_layer(&mut self, st: &StrokeLayer) {
|
||||
self.stroke = st.paint.clone();
|
||||
self.stroke_width = st.width;
|
||||
self.stroke_cap = st.cap;
|
||||
self.stroke_join = st.join;
|
||||
self.stroke_miter_limit = st.miter_limit;
|
||||
self.stroke_dash = st.dash.clone();
|
||||
}
|
||||
/// The character fill as a fill layer, overprinting as the characters do.
|
||||
fn fill_layer(&self) -> FillLayer {
|
||||
FillLayer { overprint: self.overprint_fill, ..FillLayer::new(self.fill.clone()) }
|
||||
}
|
||||
/// The characters' paint as an object appearance (their outlines'): the fill, plus the
|
||||
/// stroke when it draws one, overprinting as the characters do.
|
||||
pub fn appearance(&self) -> Appearance {
|
||||
let mut a = Appearance { items: vec![AppearanceItem::Fill(self.fill_layer())], ..Default::default() };
|
||||
if self.has_stroke() {
|
||||
a.items.push(AppearanceItem::Stroke(self.stroke_layer()));
|
||||
}
|
||||
a
|
||||
}
|
||||
/// The characters' paint as the basic fill and stroke rows (the stroke row even when it has
|
||||
/// no paint): the appearance the Eyedropper and graphic styles take from type.
|
||||
pub fn basic_appearance(&self) -> Appearance {
|
||||
Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab stop alignment (Tabs panel).
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect, shapes};
|
||||
|
||||
use crate::{GeomContext, apply_one, is_geometry, merged_params};
|
||||
|
|
@ -54,13 +54,7 @@ pub fn outline_text(n: &Node) -> Option<Node> {
|
|||
.iter()
|
||||
.zip(&t.runs)
|
||||
.filter(|(bp, _)| !bp.elements().is_empty())
|
||||
.map(|(bp, run)| {
|
||||
let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], ..Default::default() };
|
||||
if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 {
|
||||
ap.items.push(AppearanceItem::Stroke(StrokeLayer::new(run.style.stroke.clone(), run.style.stroke_width)));
|
||||
}
|
||||
Arc::new(Node::path(n.id, path(bp), ap))
|
||||
})
|
||||
.map(|(bp, run)| Arc::new(Node::path(n.id, path(bp), run.style.appearance())))
|
||||
.collect();
|
||||
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
|
|
|
|||
|
|
@ -637,7 +637,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (appearance, src_box) = match &src.kind {
|
||||
NodeKind::Text(t) if !t.runs.is_empty() => {
|
||||
let r = &t.runs[0];
|
||||
(Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), Some(t.local_bounds()))
|
||||
(r.style.basic_appearance(), Some(t.local_bounds()))
|
||||
}
|
||||
_ => (src.appearance.clone(), src.geometric_bounds()),
|
||||
};
|
||||
|
|
@ -680,10 +680,12 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
let ap = placed(Some(t.local_bounds()));
|
||||
// Characters without a painted stroke get no weight.
|
||||
let stroke =
|
||||
ap.stroke().map_or_else(|| StrokeLayer::new(Paint::None, 0.0), |s| StrokeLayer { width: ap.stroke_width(), ..s.clone() });
|
||||
for r in &mut t.runs {
|
||||
r.style.fill = ap.fill_paint();
|
||||
r.style.stroke = ap.stroke_paint();
|
||||
r.style.stroke_width = ap.stroke_width();
|
||||
r.style.set_stroke_layer(&stroke);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ fn same_in_proxy(a: &Paint, b: &Paint) -> bool {
|
|||
/// The objects whose paints the proxies show for a selected `n`, the ones the paint commands
|
||||
/// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and
|
||||
/// other containers inside them), a compound path for its parts.
|
||||
fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
|
||||
pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
|
||||
let expand = match n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => true,
|
||||
NodeKind::Compound { .. } => false,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
//! The Stroke panel: weight, cap, join, alignment, dashes, arrowheads, width profiles and brushes.
|
||||
//! With type selected it edits the characters' stroke (weight, cap, join, miter limit, dashes).
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, StrokeLayer, Unit, WidthProfile};
|
||||
|
||||
use super::appearance::{edit_items, item_target};
|
||||
use super::appearance::{ItemTarget, edit_items, item_target};
|
||||
use super::paint::painted;
|
||||
use super::*;
|
||||
use crate::DocState;
|
||||
use crate::inspect::{ALIGNS, CAPS, JOINS, StrokeMixed, node_stroke};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
|
@ -14,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?}",
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
|
|
@ -30,13 +34,108 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
]
|
||||
}
|
||||
|
||||
/// The stroke attributes object and character strokes share, parsed from `stroke.set` params
|
||||
/// (also the `strokeOptions` of `text.setRangeStyle`).
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct StrokeChange {
|
||||
pub(crate) weight: Option<f64>,
|
||||
cap: Option<LineCap>,
|
||||
join: Option<LineJoin>,
|
||||
miter_limit: Option<f64>,
|
||||
/// `Some(None)`: solid.
|
||||
dash: Option<Option<Vec<f64>>>,
|
||||
dash_offset: Option<f64>,
|
||||
align_dashes: Option<bool>,
|
||||
}
|
||||
|
||||
/// The value param `key` names in `table` (see [`crate::inspect::CAPS`]); `None` when absent.
|
||||
fn named<T: Copy>(table: &[(T, &str)], p: &Value, key: &str, cmd: &str) -> Result<Option<T>> {
|
||||
let Some(s) = str_param(p, key) else { return Ok(None) };
|
||||
let s = if key == "cap" && s == "projecting" { "square" } else { s };
|
||||
match table.iter().find(|(_, n)| *n == s) {
|
||||
Some((v, _)) => Ok(Some(*v)),
|
||||
None => Err(bad(cmd, format!("{key} must be {}, got {s}", table.iter().map(|(_, n)| *n).collect::<Vec<_>>().join("|")))),
|
||||
}
|
||||
}
|
||||
|
||||
impl StrokeChange {
|
||||
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Self> {
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let dash = match p.get("dash") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Array(a)) => Some(Some(a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()).filter(|d| !d.is_empty())),
|
||||
Some(v) => return Err(bad(cmd, format!("dash must be a list of lengths or null, got {v}"))),
|
||||
};
|
||||
Ok(Self {
|
||||
weight: num("weight").map(|w| w.clamp(0.0, 1000.0)),
|
||||
cap: named(&CAPS, p, "cap", cmd)?,
|
||||
join: named(&JOINS, p, "join", cmd)?,
|
||||
miter_limit: num("miterLimit").map(|m| m.clamp(1.0, 500.0)),
|
||||
dash,
|
||||
dash_offset: num("dashOffset"),
|
||||
align_dashes: p.get("alignDashes").and_then(Value::as_bool),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.weight.is_none()
|
||||
&& self.cap.is_none()
|
||||
&& self.join.is_none()
|
||||
&& self.miter_limit.is_none()
|
||||
&& self.dash.is_none()
|
||||
&& self.dash_offset.is_none()
|
||||
&& self.align_dashes.is_none()
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, st: &mut StrokeLayer) {
|
||||
if let Some(w) = self.weight {
|
||||
st.width = w;
|
||||
}
|
||||
if let Some(c) = self.cap {
|
||||
st.cap = c;
|
||||
}
|
||||
if let Some(j) = self.join {
|
||||
st.join = j;
|
||||
}
|
||||
if let Some(m) = self.miter_limit {
|
||||
st.miter_limit = m;
|
||||
}
|
||||
if let Some(pattern) = &self.dash {
|
||||
// A new pattern keeps the offset and alignment of the one it replaces.
|
||||
st.dash = pattern.clone().map(|pattern| Dash { pattern, ..st.dash.take().unwrap_or_default() });
|
||||
}
|
||||
if let Some(d) = st.dash.as_mut() {
|
||||
if let Some(o) = self.dash_offset {
|
||||
d.offset = o;
|
||||
}
|
||||
if let Some(a) = self.align_dashes {
|
||||
d.align_corners = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::apply`] to a character stroke.
|
||||
pub(crate) fn apply_char(&self, style: &mut CharStyle) {
|
||||
let mut st = style.stroke_layer();
|
||||
self.apply(&mut st);
|
||||
style.set_stroke_layer(&st);
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.set";
|
||||
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
|
||||
s.paint.stroke_width = w.max(0.0);
|
||||
let change = StrokeChange::parse(p, C)?;
|
||||
if let Some(w) = change.weight {
|
||||
s.paint.stroke_width = w;
|
||||
}
|
||||
let item = item_target(s, p, C)?.of_kind(s, false);
|
||||
let ids = item.targets(s, p)?;
|
||||
let mut ids = item.targets(s, p)?;
|
||||
if item == ItemTarget::Top {
|
||||
// Images and symbol instances take no stroke from the panel.
|
||||
let d = &s.doc()?.doc;
|
||||
ids.retain(|id| !matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_) | NodeKind::SymbolInstance { .. })));
|
||||
}
|
||||
let arrow = |k: &str| -> Result<Option<Option<Arrowhead>>> {
|
||||
match p.get(k) {
|
||||
None => Ok(None),
|
||||
|
|
@ -46,6 +145,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
};
|
||||
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
|
||||
let align = named(&ALIGNS, p, "align", C)?;
|
||||
let arrow_align = match str_param(p, "arrowAlign") {
|
||||
None => None,
|
||||
Some("extend") => Some(ArrowAlign::Extend),
|
||||
|
|
@ -58,50 +158,19 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad(C, format!("unknown profile {id}")))?)),
|
||||
};
|
||||
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
t.runs.iter_mut().for_each(|r| change.apply_char(&mut r.style));
|
||||
return Ok(());
|
||||
}
|
||||
if index.is_none() && n.appearance.stroke().is_none() {
|
||||
n.appearance.set_stroke(Paint::solid(Color::BLACK));
|
||||
}
|
||||
let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) };
|
||||
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
|
||||
st.width = w.max(0.0);
|
||||
}
|
||||
match str_param(p, "cap") {
|
||||
Some("round") => st.cap = LineCap::Round,
|
||||
Some("square") | Some("projecting") => st.cap = LineCap::Square,
|
||||
Some("butt") => st.cap = LineCap::Butt,
|
||||
_ => {}
|
||||
}
|
||||
match str_param(p, "join") {
|
||||
Some("round") => st.join = LineJoin::Round,
|
||||
Some("bevel") => st.join = LineJoin::Bevel,
|
||||
Some("miter") => st.join = LineJoin::Miter,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) {
|
||||
st.miter_limit = m.clamp(1.0, 500.0);
|
||||
}
|
||||
match str_param(p, "align") {
|
||||
Some("inside") => st.align = StrokeAlign::Inside,
|
||||
Some("outside") => st.align = StrokeAlign::Outside,
|
||||
Some("center") => st.align = StrokeAlign::Center,
|
||||
_ => {}
|
||||
}
|
||||
match p.get("dash") {
|
||||
Some(Value::Null) => st.dash = None,
|
||||
Some(Value::Array(a)) => {
|
||||
let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
||||
// A new pattern keeps the offset and alignment of the one it replaces.
|
||||
st.dash = (!pattern.is_empty()).then(|| Dash { pattern, ..st.dash.take().unwrap_or_default() });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(d) = st.dash.as_mut() {
|
||||
if let Some(o) = p.get("dashOffset").and_then(Value::as_f64) {
|
||||
d.offset = o;
|
||||
}
|
||||
if let Some(a) = p.get("alignDashes").and_then(Value::as_bool) {
|
||||
d.align_corners = a;
|
||||
}
|
||||
change.apply(st);
|
||||
if let Some(a) = align {
|
||||
st.align = a;
|
||||
}
|
||||
if let Some(a) = sa {
|
||||
st.start_arrow = a;
|
||||
|
|
@ -120,6 +189,76 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
ok()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Units > Stroke (Preferences): the unit stroke weights and dash lengths show in.
|
||||
pub fn stroke_unit(&self) -> Unit {
|
||||
super::edit::parse_unit(&self.prefs.units_stroke).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The stroke the Stroke panel, the Control bar and the Properties panel show, picked as the
|
||||
/// Stroke proxy picks its paint: the Appearance panel's active item when it is a stroke, else
|
||||
/// the first selected object's (a group's first painted object's) top stroke, type's first
|
||||
/// run's character stroke. None without a selection or a stroke.
|
||||
pub fn shown_stroke(&self) -> Option<StrokeLayer> {
|
||||
let st = self.active()?;
|
||||
let first = st.doc.node(*st.selection.objects.first()?)?;
|
||||
if let Some(i) = first.appearance.item_of_kind(self.appearance_item(), false) {
|
||||
return first.appearance.stroke_at(Some(i)).cloned();
|
||||
}
|
||||
let mut nodes = vec![];
|
||||
painted(first, true, &mut nodes);
|
||||
node_stroke(nodes.first()?)
|
||||
}
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
/// Which Stroke panel values the selected objects' strokes (type: every run's) don't share,
|
||||
/// and whether Align Stroke applies to them. One walk of the selection, so callers that ask
|
||||
/// every frame cache it by revision.
|
||||
pub fn stroke_mixed(&self) -> StrokeMixed {
|
||||
let mut nodes = vec![];
|
||||
for id in &self.selection.objects {
|
||||
if let Some(n) = self.doc.node(*id) {
|
||||
painted(n, true, &mut nodes);
|
||||
}
|
||||
}
|
||||
let mut strokes = vec![];
|
||||
let mut can_align = true;
|
||||
for n in &nodes {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
can_align = false;
|
||||
strokes.extend(t.runs.iter().map(|r| r.style.stroke_layer()));
|
||||
}
|
||||
_ => {
|
||||
can_align &= encloses(n);
|
||||
strokes.extend(n.appearance.stroke().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((first, rest)) = strokes.split_first() else { return StrokeMixed { can_align, ..Default::default() } };
|
||||
let differs = |same: fn(&StrokeLayer, &StrokeLayer) -> bool| rest.iter().any(|s| !same(first, s));
|
||||
StrokeMixed {
|
||||
weight: differs(|a, b| a.width == b.width),
|
||||
cap: differs(|a, b| a.cap == b.cap),
|
||||
join: differs(|a, b| a.join == b.join),
|
||||
miter_limit: differs(|a, b| a.miter_limit == b.miter_limit),
|
||||
align: differs(|a, b| a.align == b.align),
|
||||
dash: differs(|a, b| a.dash == b.dash),
|
||||
can_align,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `n` enclose an area a stroke can be aligned inside or outside of (no open path)?
|
||||
fn encloses(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => path.is_closed(),
|
||||
NodeKind::Compound { children, .. } => children.iter().all(|c| encloses(c)),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths).
|
||||
pub(crate) fn flip_profile(p: &WidthProfile, along: bool) -> WidthProfile {
|
||||
let mut pts: Vec<(f64, f64, f64)> =
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ fn captured(n: &Node) -> Appearance {
|
|||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
if let Some(r) = t.runs.first() {
|
||||
ap.items = Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width).items;
|
||||
ap.items = r.style.basic_appearance().items;
|
||||
// Type paints its characters in text space.
|
||||
ap = unitized(ap, Some(t.local_bounds()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind, TextKind, T
|
|||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_text::edit;
|
||||
|
||||
use super::stroke::StrokeChange;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::*;
|
||||
|
||||
|
|
@ -26,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, underline?, strikethrough?, allCaps?: bool, features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}",
|
||||
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
|
|
@ -133,7 +134,8 @@ pub(crate) struct CharChange {
|
|||
rotation: Option<f64>,
|
||||
fill: Option<Paint>,
|
||||
stroke: Option<Paint>,
|
||||
stroke_width: Option<f64>,
|
||||
/// Weight (`strokeWidth`), cap, join, miter limit and dashes of the character stroke.
|
||||
stroke_opts: StrokeChange,
|
||||
underline: Option<bool>,
|
||||
strikethrough: Option<bool>,
|
||||
all_caps: Option<bool>,
|
||||
|
|
@ -170,6 +172,14 @@ impl CharChange {
|
|||
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Self> {
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
let mut stroke_opts = match p.get("strokeOptions") {
|
||||
None | Some(Value::Null) => StrokeChange::default(),
|
||||
Some(o) if o.is_object() => StrokeChange::parse(o, cmd)?,
|
||||
Some(_) => return Err(bad(cmd, "strokeOptions must be an object of stroke.set options")),
|
||||
};
|
||||
if let Some(w) = num("strokeWidth") {
|
||||
stroke_opts.weight = Some(w.clamp(0.0, 1000.0));
|
||||
}
|
||||
let c = Self {
|
||||
font: str_param(p, "font").map(str::to_string),
|
||||
style: str_param(p, "style").map(str::to_string),
|
||||
|
|
@ -183,7 +193,7 @@ impl CharChange {
|
|||
rotation: num("rotation"),
|
||||
fill: paint_param(p, "fill", cmd)?,
|
||||
stroke: paint_param(p, "stroke", cmd)?,
|
||||
stroke_width: num("strokeWidth"),
|
||||
stroke_opts,
|
||||
underline: flag("underline"),
|
||||
strikethrough: flag("strikethrough"),
|
||||
all_caps: flag("allCaps"),
|
||||
|
|
@ -208,7 +218,7 @@ impl CharChange {
|
|||
&& self.rotation.is_none()
|
||||
&& self.fill.is_none()
|
||||
&& self.stroke.is_none()
|
||||
&& self.stroke_width.is_none()
|
||||
&& self.stroke_opts.is_empty()
|
||||
&& self.underline.is_none()
|
||||
&& self.strikethrough.is_none()
|
||||
&& self.all_caps.is_none()
|
||||
|
|
@ -259,8 +269,8 @@ impl CharChange {
|
|||
if let Some(v) = &self.stroke {
|
||||
st.stroke = v.clone();
|
||||
}
|
||||
if let Some(v) = self.stroke_width {
|
||||
st.stroke_width = v.clamp(0.0, 1000.0);
|
||||
if !self.stroke_opts.is_empty() {
|
||||
self.stroke_opts.apply_char(st);
|
||||
}
|
||||
if let Some(v) = self.underline {
|
||||
st.underline = v;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Agent-friendly document summaries and view-models for panels.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_doc::{ArrowAlign, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
|
|
@ -30,6 +30,9 @@ pub fn node_summary(n: &Node) -> Value {
|
|||
v["stroke"] = json!(n.appearance.stroke_paint().label());
|
||||
v["strokeWidth"] = json!(n.appearance.stroke_width());
|
||||
}
|
||||
if let Some(st) = node_stroke(n) {
|
||||
v["strokeOptions"] = stroke_options(&st);
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => {
|
||||
v["anchors"] = json!(path.anchor_count());
|
||||
|
|
@ -68,3 +71,54 @@ pub fn document(s: &Session) -> Value {
|
|||
"paint": {"fill": s.paint.fill.label(), "stroke": s.paint.stroke.label(), "strokeWidth": s.paint.stroke_width, "fillActive": s.fill_active, "appearanceItem": s.appearance_item()},
|
||||
})
|
||||
}
|
||||
|
||||
/// Cap names as `stroke.set` takes them.
|
||||
pub const CAPS: [(LineCap, &str); 3] = [(LineCap::Butt, "butt"), (LineCap::Round, "round"), (LineCap::Square, "square")];
|
||||
/// Join names as `stroke.set` takes them.
|
||||
pub const JOINS: [(LineJoin, &str); 3] = [(LineJoin::Miter, "miter"), (LineJoin::Round, "round"), (LineJoin::Bevel, "bevel")];
|
||||
/// Stroke alignment names as `stroke.set` takes them.
|
||||
pub const ALIGNS: [(StrokeAlign, &str); 3] = [(StrokeAlign::Center, "center"), (StrokeAlign::Inside, "inside"), (StrokeAlign::Outside, "outside")];
|
||||
|
||||
fn name_of<T: PartialEq>(table: &[(T, &'static str)], v: T) -> &'static str {
|
||||
table.iter().find(|(k, _)| *k == v).map_or("", |(_, n)| n)
|
||||
}
|
||||
|
||||
/// The stroke an object's Stroke panel options describe: type's first run's character stroke,
|
||||
/// else the topmost stroke of its own (groups and layers too). None without a stroke.
|
||||
pub fn node_stroke(n: &Node) -> Option<StrokeLayer> {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.runs.first().map_or_else(|| vectorcraft_doc::CharStyle::default().stroke_layer(), |r| r.style.stroke_layer())),
|
||||
_ => n.appearance.stroke().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke's options in `stroke.set` terms: cap, join, miterLimit, align, dash (null when
|
||||
/// solid), dashOffset, alignDashes, startArrow/endArrow (null for none), arrowAlign and profile.
|
||||
pub fn stroke_options(st: &StrokeLayer) -> Value {
|
||||
json!({
|
||||
"cap": name_of(&CAPS, st.cap),
|
||||
"join": name_of(&JOINS, st.join),
|
||||
"miterLimit": st.miter_limit,
|
||||
"align": name_of(&ALIGNS, st.align),
|
||||
"dash": st.dash.as_ref().map(|d| &d.pattern),
|
||||
"dashOffset": st.dash.as_ref().map_or(0.0, |d| d.offset),
|
||||
"alignDashes": st.dash.as_ref().is_some_and(|d| d.align_corners),
|
||||
"startArrow": st.start_arrow,
|
||||
"endArrow": st.end_arrow,
|
||||
"arrowAlign": if st.arrow_align == ArrowAlign::Tip { "tip" } else { "extend" },
|
||||
"profile": WidthProfile::id_of(st.profile.as_ref()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Which Stroke panel values the selected objects don't share (their fields show blank), and
|
||||
/// whether Align Stroke applies to them (no open path or type among them).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct StrokeMixed {
|
||||
pub weight: bool,
|
||||
pub cap: bool,
|
||||
pub join: bool,
|
||||
pub miter_limit: bool,
|
||||
pub align: bool,
|
||||
pub dash: bool,
|
||||
pub can_align: bool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -747,6 +747,8 @@ mod tests_brushsym;
|
|||
#[cfg(test)]
|
||||
mod tests_build;
|
||||
#[cfg(test)]
|
||||
mod tests_charstroke;
|
||||
#[cfg(test)]
|
||||
mod tests_clip;
|
||||
#[cfg(test)]
|
||||
mod tests_cmdsplit;
|
||||
|
|
@ -799,6 +801,8 @@ mod tests_prefs;
|
|||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeux;
|
||||
#[cfg(test)]
|
||||
mod tests_styles;
|
||||
#[cfg(test)]
|
||||
mod tests_swatchcmds;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
//! Stroke on type: the Stroke panel's options on the characters' stroke (every run, or a range
|
||||
//! through `text.setRangeStyle`), and the character stroke kept by the Eyedropper, outlines and
|
||||
//! the native format.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, TextObject};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
/// Selected point type "Stroke" with a 1 pt red character stroke.
|
||||
fn stroked_text(s: &mut Session) -> NodeId {
|
||||
let id = NodeId(run(s, "text.create", json!({"x": 20, "y": 100, "text": "Stroke", "size": 48}))["id"].as_u64().unwrap());
|
||||
run(s, "select.set", json!({ "ids": [id.0] }));
|
||||
run(s, "paint.setStroke", json!({"color": "#ff0000"}));
|
||||
id
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("not type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_options_on_type_style_every_run_and_add_no_object_stroke() {
|
||||
let mut s = session();
|
||||
let id = stroked_text(&mut s);
|
||||
run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 60}));
|
||||
assert_eq!(text(&s, id).runs.len(), 2);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "stroke.set", json!({"weight": 3, "join": "round", "cap": "round", "miterLimit": 4, "dash": [6, 2]}));
|
||||
let t = text(&s, id);
|
||||
for r in &t.runs {
|
||||
let st = &r.style;
|
||||
assert_eq!((st.stroke_width, st.stroke_join, st.stroke_cap, st.stroke_miter_limit), (3.0, LineJoin::Round, LineCap::Round, 4.0));
|
||||
assert_eq!(st.stroke_dash.as_ref().map(|d| d.pattern.clone()), Some(vec![6.0, 2.0]));
|
||||
assert_eq!(st.stroke, Paint::solid(Color::rgb8(255, 0, 0)), "the stroke colour stays");
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().appearance.items.is_empty(), "no object-level stroke is added");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
// The shown stroke (Stroke panel, Control bar) is the first run's.
|
||||
let shown = s.shown_stroke().unwrap();
|
||||
assert_eq!((shown.width, shown.join), (3.0, LineJoin::Round));
|
||||
// Dash options keep working on the character dash; null turns it solid.
|
||||
run(&mut s, "stroke.set", json!({"alignDashes": true, "dashOffset": 1}));
|
||||
assert_eq!(text(&s, id).runs[0].style.stroke_dash, Some(Dash { pattern: vec![6.0, 2.0], offset: 1.0, align_corners: true }));
|
||||
run(&mut s, "stroke.set", json!({"dash": null}));
|
||||
assert!(text(&s, id).runs.iter().all(|r| r.style.stroke_dash.is_none()));
|
||||
assert!(s.execute("stroke.set", &json!({"join": "pointy"})).is_err(), "unknown joins are rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_stroke_on_type_is_still_reached_through_its_item() {
|
||||
let mut s = session();
|
||||
let id = stroked_text(&mut s);
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
let item = s.doc().unwrap().doc.node(id).unwrap().appearance.items.len() - 1;
|
||||
run(&mut s, "stroke.set", json!({"item": item, "weight": 5, "dash": [3, 3]}));
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let st = n.appearance.stroke_at(Some(item)).unwrap();
|
||||
assert_eq!((st.width, st.dash.as_ref().map(|d| d.pattern.clone())), (5.0, Some(vec![3.0, 3.0])));
|
||||
assert_eq!(text(&s, id).runs[0].style.stroke_width, 1.0, "the characters keep their stroke");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_stroke_options_split_runs() {
|
||||
let mut s = session();
|
||||
let id = stroked_text(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"text.setRangeStyle",
|
||||
json!({"id": id.0, "start": 0, "end": 3, "strokeWidth": 2, "strokeOptions": {"join": "bevel", "dash": [1, 1]}}),
|
||||
);
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.runs.len(), 2);
|
||||
assert_eq!((t.runs[0].style.stroke_width, t.runs[0].style.stroke_join), (2.0, LineJoin::Bevel));
|
||||
assert!(t.runs[0].style.stroke_dash.is_some());
|
||||
assert_eq!((t.runs[1].style.stroke_width, t.runs[1].style.stroke_join), (1.0, LineJoin::Miter));
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "strokeOptions": 3})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eyedropper_and_outlines_carry_the_character_stroke() {
|
||||
let mut s = session();
|
||||
let id = stroked_text(&mut s);
|
||||
run(&mut s, "stroke.set", json!({"weight": 2, "join": "round", "dash": [4, 2]}));
|
||||
// Type → path: the path takes the characters' stroke options.
|
||||
let rect = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 150, "width": 50, "height": 50}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "appearance.copyFrom", json!({"source": id.0, "ids": [rect]}));
|
||||
let r = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().appearance.stroke().unwrap().clone();
|
||||
assert_eq!((r.width, r.join, r.dash.map(|d| d.pattern)), (2.0, LineJoin::Round, Some(vec![4.0, 2.0])));
|
||||
// Path → type.
|
||||
run(&mut s, "stroke.set", json!({"ids": [rect], "cap": "square", "dash": null}));
|
||||
run(&mut s, "appearance.copyFrom", json!({"source": rect, "ids": [id.0]}));
|
||||
let st = text(&s, id).runs[0].style.clone();
|
||||
assert_eq!((st.stroke_cap, st.stroke_join, st.stroke_dash), (LineCap::Square, LineJoin::Round, None));
|
||||
// Create Outlines keeps them on every glyph.
|
||||
run(&mut s, "select.set", json!({ "ids": [id.0] }));
|
||||
let g = run(&mut s, "type.createOutlines", json!({}))["ids"][0].as_u64().unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let glyphs = d.node(NodeId(g)).unwrap().children().unwrap().to_vec();
|
||||
assert!(!glyphs.is_empty());
|
||||
assert!(glyphs.iter().all(|c| c.appearance.stroke().is_some_and(|st| st.cap == LineCap::Square && st.join == LineJoin::Round)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_stroke_options_round_trip_natively_and_old_files_load() {
|
||||
let mut s = session();
|
||||
let id = stroked_text(&mut s);
|
||||
run(&mut s, "stroke.set", json!({"cap": "round", "join": "bevel", "miterLimit": 3, "dash": [5, 1]}));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
|
||||
let NodeKind::Text(t) = &back.node(id).unwrap().kind else { panic!("not type") };
|
||||
assert_eq!(t.runs, text(&s, id).runs);
|
||||
// A character style written before stroke options existed takes the defaults.
|
||||
let mut old = serde_json::to_value(CharStyle::default()).unwrap();
|
||||
for k in ["stroke_cap", "stroke_join", "stroke_miter_limit", "stroke_dash"] {
|
||||
old.as_object_mut().unwrap().remove(k);
|
||||
}
|
||||
let st: CharStyle = serde_json::from_value(old).unwrap();
|
||||
assert_eq!((st.stroke_cap, st.stroke_join, st.stroke_miter_limit, st.stroke_dash), (LineCap::Butt, LineJoin::Miter, 10.0, None));
|
||||
assert!(serde_json::to_value(CharStyle::default()).unwrap().get("stroke_miter_limit").is_none(), "defaults aren't written");
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
//! Stroke panel reach: `stroke.set` on groups, what the panel shows for a selection (mixed values,
|
||||
//! Align Stroke), Units > Stroke, and the stroke options `document.inspect` reports.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, Unit};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 40, "height": 40}))["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
run(s, "select.set", json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_set_on_a_group_skips_images_and_symbol_instances() {
|
||||
let mut s = session();
|
||||
let (a, b, c) = (rect(&mut s, 10.0), rect(&mut s, 60.0), rect(&mut s, 110.0));
|
||||
select(&mut s, &[b]);
|
||||
let image = NodeId(run(&mut s, "object.rasterize", json!({}))["id"].as_u64().unwrap());
|
||||
select(&mut s, &[c]);
|
||||
let instance = NodeId(run(&mut s, "symbol.new", json!({"name": "Box"}))["id"].as_u64().unwrap());
|
||||
select(&mut s, &[a, image, instance]);
|
||||
run(&mut s, "object.group", json!({}));
|
||||
run(&mut s, "stroke.set", json!({"weight": 5}));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.node(a).unwrap().appearance.stroke_width(), 5.0);
|
||||
for id in [image, instance] {
|
||||
let n = d.node(id).unwrap();
|
||||
assert!(matches!(n.kind, NodeKind::Image(_) | NodeKind::SymbolInstance { .. }));
|
||||
assert!(n.appearance.stroke().is_none(), "{:?} gains no stroke", n.kind_label());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_values_and_align_stroke_follow_the_selection() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 60.0));
|
||||
run(&mut s, "stroke.set", json!({"ids": [b.0], "weight": 3, "join": "round"}));
|
||||
select(&mut s, &[a, b]);
|
||||
let m = s.active().unwrap().stroke_mixed();
|
||||
assert!(m.weight && m.join && !m.cap && !m.miter_limit && !m.align && !m.dash && m.can_align, "{m:?}");
|
||||
// An open path or type in the selection can't take an inside or outside stroke.
|
||||
let line = NodeId(run(&mut s, "shape.line", json!({"x1": 10, "y1": 100, "x2": 90, "y2": 100}))["id"].as_u64().unwrap());
|
||||
select(&mut s, &[a, line]);
|
||||
assert!(!s.active().unwrap().stroke_mixed().can_align);
|
||||
let text = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 200, "text": "Hi"}))["id"].as_u64().unwrap());
|
||||
select(&mut s, &[text]);
|
||||
assert!(!s.active().unwrap().stroke_mixed().can_align);
|
||||
// Grouped closed paths can.
|
||||
select(&mut s, &[a, b]);
|
||||
run(&mut s, "object.group", json!({}));
|
||||
let m = s.active().unwrap().stroke_mixed();
|
||||
assert!(m.can_align && m.weight);
|
||||
assert_eq!(s.shown_stroke().map(|st| st.width), Some(1.0), "a group shows its first painted object's stroke");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_unit_follows_the_preference() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.stroke_unit(), Unit::Points);
|
||||
s.prefs.units_stroke = "millimeters".into();
|
||||
assert_eq!(s.stroke_unit(), Unit::Millimeters);
|
||||
assert_eq!(s.stroke_unit().format(1.0), "0.353 mm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reports_stroke_options() {
|
||||
let mut s = session();
|
||||
let line = run(&mut s, "shape.line", json!({"x1": 10, "y1": 100, "x2": 90, "y2": 100}))["id"].as_u64().unwrap();
|
||||
run(
|
||||
&mut s,
|
||||
"stroke.set",
|
||||
json!({"ids": [line], "cap": "round", "join": "bevel", "miterLimit": 4, "dash": [6, 3], "alignDashes": true, "endArrow": "Triangle", "arrowAlign": "tip", "profile": "lens"}),
|
||||
);
|
||||
let doc = run(&mut s, "document.inspect", json!({}));
|
||||
let o = &doc["layers"][0]["children"][0]["strokeOptions"];
|
||||
assert_eq!(o["cap"], "round");
|
||||
assert_eq!(o["join"], "bevel");
|
||||
assert_eq!(o["miterLimit"], 4.0);
|
||||
assert_eq!(o["align"], "center");
|
||||
assert_eq!(o["dash"], json!([6.0, 3.0]));
|
||||
assert_eq!(o["alignDashes"], true);
|
||||
assert_eq!((o["startArrow"].clone(), o["endArrow"].clone(), o["arrowAlign"].clone()), (Value::Null, json!("Triangle"), json!("tip")));
|
||||
assert_eq!(o["profile"], "lens");
|
||||
// The options read back into stroke.set unchanged.
|
||||
let mut q = o.as_object().unwrap().clone();
|
||||
q.insert("ids".into(), json!([line]));
|
||||
let before = s.doc().unwrap().doc.node(NodeId(line)).unwrap().appearance.clone();
|
||||
run(&mut s, "stroke.set", Value::Object(q));
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(line)).unwrap().appearance, before);
|
||||
// Type reports its characters' stroke.
|
||||
run(&mut s, "text.create", json!({"x": 10, "y": 200, "text": "Hi"}));
|
||||
run(&mut s, "stroke.set", json!({"join": "round"}));
|
||||
let doc = run(&mut s, "document.inspect", json!({}));
|
||||
let text = doc["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["kind"] == "Text" || c.get("text").is_some()).unwrap().clone();
|
||||
assert_eq!(text["strokeOptions"]["join"], "round", "{text}");
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@ use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, St
|
|||
use krilla::surface::Surface;
|
||||
use kurbo::{PathEl, Shape, Vec2};
|
||||
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
|
||||
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject};
|
||||
use vectorcraft_effects::stroke::{self, WrittenShape};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
|
||||
|
|
@ -494,7 +493,7 @@ impl Exporter<'_> {
|
|||
}
|
||||
self.shape(s, n, &bp, *rule, page);
|
||||
}
|
||||
NodeKind::Text(t) => self.text(s, n, t),
|
||||
NodeKind::Text(t) => self.text(s, n, t, page),
|
||||
NodeKind::Image(im) => self.image(s, im),
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|x| &x.name == symbol) {
|
||||
|
|
@ -562,7 +561,7 @@ impl Exporter<'_> {
|
|||
continue;
|
||||
}
|
||||
self.warn_raster(&st.effects);
|
||||
self.stroke(s, bp, &path, r, st, page);
|
||||
self.stroke(s, bp, &path, r, st, page, bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -570,11 +569,13 @@ impl Exporter<'_> {
|
|||
s.set_stroke(None);
|
||||
}
|
||||
|
||||
fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect) {
|
||||
/// Paint stroke `st` of the shape `bp` (`path`), whose geometric bounds `bounds` place its
|
||||
/// unplaced gradients.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect, bounds: Rect) {
|
||||
if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) {
|
||||
return;
|
||||
}
|
||||
let bounds = bp.bounding_box();
|
||||
let Some(paint) = self.paint(&st.paint, st.paint_bounds(bounds)) else { return };
|
||||
let w = stroke::for_writer(bp, st);
|
||||
let mut pushes = 0;
|
||||
|
|
@ -675,7 +676,7 @@ impl Exporter<'_> {
|
|||
true
|
||||
}
|
||||
|
||||
fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) {
|
||||
fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject, page: Rect) {
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let tb = t.xf.transform_rect_bbox(layout.bounds);
|
||||
// The object's own fills and strokes paint the whole outline: those below the Characters
|
||||
|
|
@ -684,11 +685,11 @@ impl Exporter<'_> {
|
|||
let all = (!n.appearance.items.is_empty()).then(|| {
|
||||
let mut all = layout.to_bezpath();
|
||||
all.apply_affine(t.xf);
|
||||
to_path(&all)
|
||||
to_path(&all).map(|p| (all, p))
|
||||
});
|
||||
let all = all.flatten();
|
||||
if let Some(path) = &all {
|
||||
self.text_items(s, below, path, tb);
|
||||
if let Some((bp, path)) = &all {
|
||||
self.text_items(s, below, bp, path, page, tb);
|
||||
}
|
||||
s.push_transform(&xf(t.xf));
|
||||
for (i, run) in t.runs.iter().enumerate() {
|
||||
|
|
@ -702,24 +703,23 @@ impl Exporter<'_> {
|
|||
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
|
||||
s.draw_path(&path);
|
||||
}
|
||||
if run.style.stroke_width > 0.0
|
||||
&& let Some(paint) = self.paint(&run.style.stroke, stroke_paint_bounds(layout.bounds, run.style.stroke_width))
|
||||
{
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke { paint, width: run.style.stroke_width as f32, ..Default::default() }));
|
||||
s.draw_path(&path);
|
||||
if run.style.has_stroke() {
|
||||
// Character strokes are drawn in text space, with their cap, join and dashes.
|
||||
self.stroke(s, &bp, &path, FillRule::NonZero, &run.style.stroke_layer(), page, layout.bounds);
|
||||
}
|
||||
}
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
s.pop();
|
||||
if let Some(path) = &all {
|
||||
self.text_items(s, above, path, tb);
|
||||
if let Some((bp, path)) = &all {
|
||||
self.text_items(s, above, bp, path, page, tb);
|
||||
}
|
||||
}
|
||||
|
||||
/// A type object's own fills and strokes `items` on its glyph outlines `path` (`tb`: their bounds).
|
||||
fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], path: &Path, tb: Rect) {
|
||||
/// Some of a type object's own fills and strokes (`items`) on its glyph outlines `bp` (`path`;
|
||||
/// `tb`: their bounds). Strokes take every stroke option, as on paths.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], bp: &BezPath, path: &Path, page: Rect, tb: Rect) {
|
||||
for item in items {
|
||||
match item {
|
||||
AppearanceItem::Fill(fl) if fl.visible => {
|
||||
|
|
@ -729,13 +729,7 @@ impl Exporter<'_> {
|
|||
s.draw_path(path);
|
||||
}
|
||||
}
|
||||
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => {
|
||||
if let Some(paint) = self.paint(&st.paint, st.paint_bounds(tb)) {
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() }));
|
||||
s.draw_path(path);
|
||||
}
|
||||
}
|
||||
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => self.stroke(s, bp, path, FillRule::NonZero, st, page, tb),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ mod tests;
|
|||
#[cfg(test)]
|
||||
mod tests_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_charstroke;
|
||||
#[cfg(test)]
|
||||
mod tests_dashalign;
|
||||
#[cfg(test)]
|
||||
mod tests_fx;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
//! Strokes on type in PDF: the characters' and the object's strokes keep their join and dashes.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, CharStyle, Dash, Document, LineJoin, Node, NodeKind, StrokeLayer, TextObject};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// The strokes of a PDF of `d` read back.
|
||||
fn strokes_back(d: &Document) -> Vec<StrokeLayer> {
|
||||
let back = import(&export(d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
let mut out = vec![];
|
||||
back.walk(|n| out.extend(n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None })));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_and_object_strokes_on_type_keep_join_and_dashes() {
|
||||
let dash = Some(Dash { pattern: vec![3.0, 2.0], ..Default::default() });
|
||||
let style = CharStyle {
|
||||
size: 40.0,
|
||||
stroke: Paint::solid(Color::BLACK),
|
||||
stroke_width: 2.0,
|
||||
stroke_join: LineJoin::Round,
|
||||
stroke_dash: dash.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "Type", style))));
|
||||
let mut own = StrokeLayer::new(Paint::solid(Color::rgb8(255, 0, 0)), 1.0);
|
||||
own.join = LineJoin::Bevel;
|
||||
own.dash = Some(Dash { pattern: vec![5.0, 5.0], ..Default::default() });
|
||||
n.appearance.items.push(AppearanceItem::Stroke(own));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let strokes = strokes_back(&d);
|
||||
let pattern = |s: &StrokeLayer| s.dash.as_ref().map(|d| d.pattern.iter().map(|v| (v * 100.0).round() / 100.0).collect::<Vec<_>>());
|
||||
assert!(strokes.iter().any(|s| s.join == LineJoin::Round && pattern(s) == Some(vec![3.0, 2.0])), "character stroke: {strokes:?}");
|
||||
assert!(strokes.iter().any(|s| s.join == LineJoin::Bevel && pattern(s) == Some(vec![5.0, 5.0])), "object stroke: {strokes:?}");
|
||||
}
|
||||
|
|
@ -652,7 +652,7 @@ impl Renderer {
|
|||
if st.brush.is_some() && self.draw_brush(ctx, f, n, ig, st) {
|
||||
return;
|
||||
}
|
||||
self.draw_stroke(ctx, f, ig, rule, st, ib);
|
||||
self.draw_stroke(ctx, f, Affine::IDENTITY, ig, rule, st, ib);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ pub mod proof;
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
|
||||
use vello_cpu::kurbo;
|
||||
|
|
@ -792,7 +791,7 @@ impl Renderer {
|
|||
if st.brush.is_some() && self.draw_brush(ctx, f, n, bp, st) {
|
||||
continue;
|
||||
}
|
||||
self.draw_stroke(ctx, f, bp, rule, st, bounds);
|
||||
self.draw_stroke(ctx, f, Affine::IDENTITY, bp, rule, st, bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -808,7 +807,10 @@ impl Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
|
||||
/// Paint stroke `st` of the shape `bp` (in the space `xf` maps to the document; `bounds`: its
|
||||
/// geometric bounds there) with its alignment, dashes, profile, arrowheads, opacity and blend.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, xf: Affine, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
|
||||
let pieces = effects::stroke::stroke_pieces(bp, st);
|
||||
// The line and its heads overlap: they take the opacity once, in one layer, instead of
|
||||
// each folding it into its paint.
|
||||
|
|
@ -819,10 +821,10 @@ impl Renderer {
|
|||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(st.blend)), Some(opacity), None, None);
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_transform(f.view * xf);
|
||||
let closed = effects::stroke::is_closed(bp);
|
||||
// Keep hairlines visible when zoomed far out (at least ~1 device pixel).
|
||||
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5);
|
||||
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9));
|
||||
let inside = st.align == StrokeAlign::Inside && closed;
|
||||
if inside {
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
|
|
@ -929,7 +931,7 @@ impl Renderer {
|
|||
all
|
||||
});
|
||||
if let Some(all) = &all {
|
||||
self.draw_text_items(ctx, f, below, all, tb);
|
||||
self.draw_text_items(ctx, f, n, below, all, tb);
|
||||
}
|
||||
for (i, run) in t.runs.iter().enumerate() {
|
||||
let Some(path) = g.runs.get(i) else { continue };
|
||||
|
|
@ -943,44 +945,45 @@ impl Renderer {
|
|||
overprint(ctx, run.style.overprint_fill);
|
||||
ctx.fill_path(path);
|
||||
}
|
||||
if !run.style.stroke.is_none()
|
||||
&& run.style.stroke_width > 0.0
|
||||
&& paint::set_paint(ctx, &run.style.stroke, stroke_paint_bounds(g.bounds, run.style.stroke_width), f.doc)
|
||||
{
|
||||
if run.style.has_stroke() {
|
||||
// Character strokes are drawn in text space, with their cap, join and dashes.
|
||||
overprint(ctx, run.style.overprint_stroke);
|
||||
ctx.set_stroke(kurbo::Stroke::new(run.style.stroke_width));
|
||||
ctx.stroke_path(path);
|
||||
self.draw_stroke(ctx, f, t.xf, path, FillRule::NonZero, &run.style.stroke_layer(), g.bounds);
|
||||
}
|
||||
}
|
||||
overprint(ctx, false);
|
||||
if let Some(all) = &all {
|
||||
self.draw_text_items(ctx, f, above, all, tb);
|
||||
self.draw_text_items(ctx, f, n, above, all, tb);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a type object's own fills and strokes `items` on its glyph outlines `all` (document
|
||||
/// space; `tb` their bounds), each with its own opacity and blend mode.
|
||||
fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, items: &[AppearanceItem], all: &BezPath, tb: Rect) {
|
||||
/// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all`
|
||||
/// (document space; `tb` their bounds). Fills composite with their own opacity and blend mode;
|
||||
/// strokes take every stroke option, as on paths.
|
||||
fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, items: &[AppearanceItem], all: &BezPath, tb: Rect) {
|
||||
for item in items.iter().filter(|i| i.visible() && !i.paint().is_none()) {
|
||||
let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal;
|
||||
if layered {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None);
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
match item {
|
||||
AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => {
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
ctx.fill_path(all);
|
||||
AppearanceItem::Fill(fl) => {
|
||||
let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_color::BlendMode::Normal;
|
||||
if layered {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
if paint::set_paint(ctx, &fl.paint, tb, f.doc) {
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
ctx.fill_path(all);
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => {
|
||||
ctx.set_stroke(kurbo::Stroke::new(st.width));
|
||||
ctx.stroke_path(all);
|
||||
AppearanceItem::Stroke(st) if st.width > 0.0 => {
|
||||
if st.brush.is_none() || !self.draw_brush(ctx, f, n, all, st) {
|
||||
self.draw_stroke(ctx, f, Affine::IDENTITY, all, FillRule::NonZero, st, tb);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
AppearanceItem::Stroke(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1152,6 +1155,8 @@ mod tests;
|
|||
#[cfg(test)]
|
||||
mod tests_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_charstroke;
|
||||
#[cfg(test)]
|
||||
mod tests_clip;
|
||||
#[cfg(test)]
|
||||
mod tests_container;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
//! Strokes on type: character strokes take their join, cap and dashes, and object strokes on type
|
||||
//! take every stroke option, as path strokes do.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::text::{CharStyle, TextObject};
|
||||
use vectorcraft_doc::{AppearanceItem, Dash, LineJoin, Node, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::*;
|
||||
|
||||
const SIZE: u32 = 200;
|
||||
|
||||
/// 120 pt `text` with its baseline at (40, 150), on a white 200×200 page.
|
||||
fn render_text(text: &str, style: CharStyle, items: Vec<AppearanceItem>) -> Rendered {
|
||||
let mut d = Document::new(SIZE as f64, SIZE as f64);
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(40.0, 150.0), text, CharStyle { size: 120.0, ..style }))));
|
||||
n.appearance.items = items;
|
||||
n.id = d.alloc_id();
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
Renderer::new().render(&d, SIZE, SIZE, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||||
}
|
||||
|
||||
fn dark(img: &Rendered, x: u32, y: u32) -> bool {
|
||||
let p = img.pixel(x, y);
|
||||
(p[0] as u32 + p[1] as u32 + p[2] as u32) < 384
|
||||
}
|
||||
|
||||
/// The box of the dark pixels: (x0, y0, x1, y1).
|
||||
fn ink(img: &Rendered) -> (u32, u32, u32, u32) {
|
||||
let mut b = (u32::MAX, u32::MAX, 0, 0);
|
||||
for y in 0..SIZE {
|
||||
for x in 0..SIZE {
|
||||
if dark(img, x, y) {
|
||||
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn stroked(join: LineJoin) -> CharStyle {
|
||||
CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 16.0, stroke_join: join, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_joined_character_strokes_have_no_miter_spikes() {
|
||||
// The glyph alone, then stroked 16 pt: a round join reaches at most half the weight past it,
|
||||
// the default miter join spikes out further at the sharp corners of the A.
|
||||
let glyph = ink(&render_text("A", CharStyle::default(), vec![]));
|
||||
let round = ink(&render_text("A", stroked(LineJoin::Round), vec![]));
|
||||
let miter = ink(&render_text("A", stroked(LineJoin::Miter), vec![]));
|
||||
let reach = |b: (u32, u32, u32, u32)| (glyph.0 - b.0).max(glyph.1 - b.1).max(b.2 - glyph.2).max(b.3 - glyph.3);
|
||||
assert!(reach(round) <= 9, "round joins stay within half the weight: {glyph:?} → {round:?}");
|
||||
assert!(reach(miter) > reach(round) + 2, "miter joins spike: {miter:?} vs {round:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_and_object_strokes_on_type_are_dashed() {
|
||||
// An "I" without a fill: its outline drawn with 8/8 dashes breaks up along the left stem edge.
|
||||
let none = CharStyle { fill: Paint::None, ..Default::default() };
|
||||
let dash = Some(Dash { pattern: vec![8.0, 8.0], ..Default::default() });
|
||||
let transitions = |img: &Rendered| {
|
||||
let (x0, y0, _, y1) = ink(img);
|
||||
let col = x0 + 1;
|
||||
(y0..y1).filter(|y| dark(img, col, *y) != dark(img, col, y + 1)).count()
|
||||
};
|
||||
let solid = CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 3.0, ..none.clone() };
|
||||
assert!(transitions(&render_text("I", solid.clone(), vec![])) <= 2, "a solid stroke is continuous");
|
||||
let chars = CharStyle { stroke_dash: dash.clone(), ..solid };
|
||||
assert!(transitions(&render_text("I", chars, vec![])) >= 6, "character dashes");
|
||||
let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
|
||||
st.dash = dash;
|
||||
let object = render_text("I", none, vec![AppearanceItem::Stroke(st.clone())]);
|
||||
assert!(transitions(&object) >= 6, "object dashes");
|
||||
// And its opacity.
|
||||
st.opacity = 0.5;
|
||||
st.dash = None;
|
||||
let half = render_text("I", CharStyle { fill: Paint::None, ..Default::default() }, vec![AppearanceItem::Stroke(st)]);
|
||||
let (x0, y0, _, y1) = ink(&render_text("I", CharStyle::default(), vec![]));
|
||||
let p = half.pixel(x0, (y0 + y1) / 2);
|
||||
assert!(p[0] > 90 && p[0] < 200, "a half-opaque stroke is grey: {p:?}");
|
||||
}
|
||||
|
|
@ -822,8 +822,7 @@ impl Writer<'_> {
|
|||
let d: Vec<String> = paths.iter().map(|p| self.path_d(p, self.xf)).collect();
|
||||
self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds());
|
||||
}
|
||||
NodeKind::Text(t) if self.opts.outline_text => self.text_outlines(n, t),
|
||||
NodeKind::Text(t) => self.text(n, t),
|
||||
NodeKind::Text(t) => self.text_node(n, t),
|
||||
NodeKind::Image(im) => {
|
||||
let href = match self.doc.images.get(&im.key) {
|
||||
Some(b) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)),
|
||||
|
|
@ -965,13 +964,14 @@ impl Writer<'_> {
|
|||
p => self.paint(p, None),
|
||||
};
|
||||
p.push(("fill", fill));
|
||||
if !st.stroke.is_none() && st.stroke_width > 0.0 {
|
||||
let s = match &st.stroke {
|
||||
Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_hex()).unwrap_or_else(|| "none".into()),
|
||||
p => self.paint(p, None),
|
||||
};
|
||||
p.push(("stroke", s));
|
||||
p.push(("stroke-width", self.num(st.stroke_width)));
|
||||
if st.has_stroke() {
|
||||
// Weight, cap, join, miter limit and dashes as for object strokes (a gradient: its
|
||||
// first colour, as for the fill).
|
||||
let mut layer = st.stroke_layer();
|
||||
if let Paint::Gradient(g) = &layer.paint {
|
||||
layer.paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color));
|
||||
}
|
||||
self.stroke_props(&layer, layer.width, None, &mut p);
|
||||
}
|
||||
if st.tracking != 0.0 {
|
||||
p.push(("letter-spacing", self.num(st.tracking / 1000.0 * st.size)));
|
||||
|
|
@ -996,10 +996,43 @@ impl Writer<'_> {
|
|||
p
|
||||
}
|
||||
|
||||
/// Type: its characters (live text, or glyph outlines when text is exported as outlines) and,
|
||||
/// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those
|
||||
/// below the Characters row under the characters, the others over them.
|
||||
fn text_node(&mut self, n: &Node, t: &TextObject) {
|
||||
let chars = |w: &mut Self, n: &Node| if w.opts.outline_text { w.text_outlines(n, t) } else { w.text(n, t) };
|
||||
if !n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) {
|
||||
return chars(self, n);
|
||||
}
|
||||
let id = self.id_attr(n);
|
||||
let a = self.attrs(&Self::node_props(n));
|
||||
self.line(&format!("<g{id}{a}>"));
|
||||
self.depth += 1;
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let pd = PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf);
|
||||
let d = self.path_d(&pd, self.xf);
|
||||
let tb = Some(t.xf.transform_rect_bbox(lay.bounds));
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
let glyphs = |w: &mut Self, items: &[AppearanceItem]| {
|
||||
if !items.is_empty() {
|
||||
let ap = vectorcraft_doc::Appearance { items: items.to_vec(), ..Default::default() };
|
||||
w.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, tb);
|
||||
}
|
||||
};
|
||||
glyphs(self, below);
|
||||
// The group carries the id and the object's transparency.
|
||||
let bare = Node { opacity: 1.0, blend: BlendMode::Normal, isolate: false, ..n.clone() };
|
||||
let anonymous = std::mem::replace(&mut self.anonymous, true);
|
||||
chars(self, &bare);
|
||||
glyphs(self, above);
|
||||
self.anonymous = anonymous;
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
}
|
||||
|
||||
/// Text as glyph outlines: one compound path per run, painted like the run.
|
||||
fn text_outlines(&mut self, n: &Node, t: &TextObject) {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let m = self.xf * t.xf;
|
||||
let mut runs: Vec<(usize, kurbo::BezPath)> = vec![];
|
||||
for g in &lay.glyphs {
|
||||
match runs.last_mut() {
|
||||
|
|
@ -1013,16 +1046,12 @@ impl Writer<'_> {
|
|||
self.depth += 1;
|
||||
for (r, mut bp) in runs {
|
||||
let Some(run) = t.runs.get(r) else { continue };
|
||||
bp.apply_affine(m);
|
||||
bp.apply_affine(t.xf);
|
||||
let pd = PathData::from_bezpath(&bp);
|
||||
let st = &run.style;
|
||||
// An unnamed id: the pieces carry no id attribute (the group has it).
|
||||
let glyphs =
|
||||
Node::path(NodeId(u64::MAX), pd.clone(), vectorcraft_doc::Appearance::basic(st.fill.clone(), st.stroke.clone(), st.stroke_width));
|
||||
let d = self.path_d(&pd, Affine::IDENTITY);
|
||||
let bounds = pd.bounds();
|
||||
// Run strokes are plain: they need no document-space geometry.
|
||||
self.shape(&glyphs, &d, &[], FillRule::NonZero, bounds);
|
||||
let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance());
|
||||
let d = self.path_d(&pd, self.xf);
|
||||
self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, pd.bounds());
|
||||
}
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
|
|
@ -1068,7 +1097,7 @@ impl Writer<'_> {
|
|||
let last = l.pieces.len() - 1;
|
||||
for (k, (ri, text)) in l.pieces.into_iter().enumerate() {
|
||||
let pos = if k == 0 { format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)) } else { String::new() };
|
||||
let diff: Props = self.char_props(&t.runs[ri].style).into_iter().filter(|kv| !base.contains(kv)).collect();
|
||||
let diff = run_diff(&base, self.char_props(&t.runs[ri].style));
|
||||
let a = self.attrs(&diff);
|
||||
let hy = if k == last && l.hyphenated { "-" } else { "" };
|
||||
s.push_str(&format!("<tspan{pos}{a}>{}{hy}</tspan>", xml_escape(text)));
|
||||
|
|
@ -1078,8 +1107,7 @@ impl Writer<'_> {
|
|||
let mut line = 0usize;
|
||||
let mut pending = false;
|
||||
for run in &t.runs {
|
||||
let rp = self.char_props(&run.style);
|
||||
let diff: Props = rp.into_iter().filter(|kv| !base.contains(kv)).collect();
|
||||
let diff = run_diff(&base, self.char_props(&run.style));
|
||||
for (i, piece) in run.text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
line += 1;
|
||||
|
|
@ -1147,6 +1175,29 @@ impl Writer<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// What the `<tspan>` of a run whose properties are `run` sets over its `<text>`'s `base`: the
|
||||
/// properties that differ, and the initial value of each one `base` sets and the run doesn't.
|
||||
fn run_diff(base: &Props, run: Props) -> Props {
|
||||
let reset: Props =
|
||||
base.iter().filter(|(k, _)| !run.iter().any(|(r, _)| r == k)).filter_map(|(k, _)| initial_value(k).map(|v| (*k, v.to_string()))).collect();
|
||||
run.into_iter().filter(|kv| !base.contains(kv)).chain(reset).collect()
|
||||
}
|
||||
|
||||
/// The initial value of an inherited property a run may leave unset (decorations can't be undone
|
||||
/// on a `<tspan>`).
|
||||
fn initial_value(k: &str) -> Option<&'static str> {
|
||||
Some(match k {
|
||||
"font-weight" | "font-style" | "font-feature-settings" => "normal",
|
||||
"stroke" | "stroke-dasharray" => "none",
|
||||
"stroke-width" | "stroke-opacity" => "1",
|
||||
"stroke-linecap" => "butt",
|
||||
"stroke-linejoin" => "miter",
|
||||
"stroke-miterlimit" => "4",
|
||||
"stroke-dashoffset" | "letter-spacing" => "0",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Any visible raster effect (shadow, glow, blur, feather) in `effects`?
|
||||
fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
|
||||
effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id))
|
||||
|
|
|
|||
|
|
@ -539,6 +539,21 @@ fn char_style(ctx: &TextCtx, n: XNode) -> CharStyle {
|
|||
if let Some(s) = ctx.prop(n, "stroke") {
|
||||
st.stroke = parse_color_paint(&s);
|
||||
st.stroke_width = ctx.prop(n, "stroke-width").map(|w| first_number(Some(&w))).unwrap_or(1.0);
|
||||
st.stroke_cap = match ctx.prop(n, "stroke-linecap").as_deref() {
|
||||
Some("round") => LineCap::Round,
|
||||
Some("square") => LineCap::Square,
|
||||
_ => LineCap::Butt,
|
||||
};
|
||||
st.stroke_join = match ctx.prop(n, "stroke-linejoin").as_deref() {
|
||||
Some("round") => LineJoin::Round,
|
||||
Some("bevel") => LineJoin::Bevel,
|
||||
_ => LineJoin::Miter,
|
||||
};
|
||||
st.stroke_miter_limit = ctx.prop(n, "stroke-miterlimit").map_or(4.0, |m| first_number(Some(&m)).max(1.0));
|
||||
let pattern: Vec<f64> =
|
||||
ctx.prop(n, "stroke-dasharray").map_or(vec![], |d| d.split([' ', ',']).filter_map(|v| v.trim().parse().ok()).collect());
|
||||
let offset = ctx.prop(n, "stroke-dashoffset").map_or(0.0, |o| first_number(Some(&o)));
|
||||
st.stroke_dash = Some(Dash { pattern, offset, align_corners: false }).filter(Dash::is_dashed);
|
||||
}
|
||||
if let Some(ls) = ctx.prop(n, "letter-spacing") {
|
||||
let v = first_number(Some(&ls));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
//! Strokes on type in SVG: character stroke options on live text (and back), runs that differ
|
||||
//! from the first, and the object's own strokes drawn over the glyph outlines.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, CharStyle, Dash, Document, LineCap, LineJoin, Node, NodeKind, StrokeLayer, TextObject, TextRun};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_svg::{ExportOptions, export, import};
|
||||
|
||||
fn text_doc(t: TextObject, items: Vec<AppearanceItem>) -> Document {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
n.appearance.items = items;
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn stroked() -> CharStyle {
|
||||
CharStyle {
|
||||
size: 40.0,
|
||||
stroke: Paint::solid(Color::BLACK),
|
||||
stroke_width: 2.0,
|
||||
stroke_cap: LineCap::Round,
|
||||
stroke_join: LineJoin::Round,
|
||||
stroke_dash: Some(Dash { pattern: vec![3.0, 1.0], ..Default::default() }),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_stroke_on_type_is_written_with_its_dashes() {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::rgb8(255, 0, 0)), 3.0);
|
||||
st.dash = Some(Dash { pattern: vec![4.0, 2.0], ..Default::default() });
|
||||
st.opacity = 0.5;
|
||||
let t = TextObject::point(Point::new(20.0, 100.0), "Dashed", CharStyle { size: 40.0, ..Default::default() });
|
||||
for outline_text in [false, true] {
|
||||
let svg = export(&text_doc(t.clone(), vec![AppearanceItem::Stroke(st.clone())]), &ExportOptions { outline_text, ..Default::default() });
|
||||
assert!(svg.contains("stroke-dasharray=\"4 2\""), "{svg}");
|
||||
assert!(svg.contains("stroke-opacity=\"0.5\""), "{svg}");
|
||||
assert_eq!(svg.contains("<text"), !outline_text, "{svg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_stroke_options_round_trip_through_live_text() {
|
||||
let svg = export(&text_doc(TextObject::point(Point::new(20.0, 100.0), "Round", stroked()), vec![]), &ExportOptions::default());
|
||||
for attr in ["stroke-linecap=\"round\"", "stroke-linejoin=\"round\"", "stroke-dasharray=\"3 1\""] {
|
||||
assert!(svg.contains(attr), "{attr}: {svg}");
|
||||
}
|
||||
let back = import(&svg).unwrap();
|
||||
let mut found = None;
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
found = Some(t.runs[0].style.clone());
|
||||
}
|
||||
});
|
||||
let st = found.expect("live text comes back");
|
||||
assert_eq!((st.stroke_cap, st.stroke_join), (LineCap::Round, LineJoin::Round));
|
||||
assert_eq!(st.stroke_dash.map(|d| d.pattern), Some(vec![3.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_without_the_first_runs_stroke_resets_it() {
|
||||
let mut t = TextObject::point(Point::new(20.0, 100.0), "Ab", stroked());
|
||||
t.runs.push(TextRun { text: "cd".into(), style: CharStyle { size: 40.0, ..Default::default() } });
|
||||
let svg = export(&text_doc(t, vec![]), &ExportOptions::default());
|
||||
let second = svg.split("<tspan").nth(2).expect("a tspan per run");
|
||||
assert!(
|
||||
second.contains("stroke=\"none\"") && second.contains("stroke-linejoin=\"miter\"") && second.contains("stroke-dasharray=\"none\""),
|
||||
"{svg}"
|
||||
);
|
||||
// Outlined, each run's glyphs carry their own stroke options.
|
||||
let svg = export(
|
||||
&text_doc(TextObject::point(Point::new(20.0, 100.0), "Ab", stroked()), vec![]),
|
||||
&ExportOptions { outline_text: true, ..Default::default() },
|
||||
);
|
||||
assert!(svg.contains("stroke-linejoin=\"round\"") && svg.contains("stroke-dasharray=\"3 1\""), "{svg}");
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ use serde_json::json;
|
|||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use crate::panels::stroke as stroke_panel;
|
||||
use crate::state::{ZOOM_STOPS, zoom_label};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::widgets::{self, paint_chip};
|
||||
|
|
@ -100,12 +101,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
|
||||
ui.add_space(6.0);
|
||||
let (fill, stroke) = crate::panels::current_paints(app);
|
||||
let (weight, opacity) = match &first {
|
||||
Some(_) => {
|
||||
(crate::panels::current_stroke(app).map_or(0.0, |s| s.width), crate::panels::current_transparency(app).map_or(1.0, |t| t.0))
|
||||
}
|
||||
None => (app.session.paint.stroke_width, 1.0),
|
||||
};
|
||||
let shown_stroke = crate::panels::current_stroke(app);
|
||||
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
|
||||
let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed);
|
||||
let opacity = if first.is_some() { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 };
|
||||
if chip_button(ui, &fill, false, "Fill").clicked() {
|
||||
app.run("paint.toggleActive", json!({})).ok();
|
||||
app.session.fill_active = true;
|
||||
|
|
@ -115,11 +114,14 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
app.session.fill_active = false;
|
||||
app.ui.open_panel = Some("swatches".into());
|
||||
}
|
||||
if ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).clicked() {
|
||||
app.ui.open_panel = Some("stroke".into());
|
||||
}
|
||||
if let Some(w) = widgets::num_field(ui, "cb-stroke", Some(weight), vectorcraft_doc::Unit::Points, 64.0) {
|
||||
app.run("stroke.set", json!({"weight": w})).ok();
|
||||
// The link opens the Stroke panel as a popover under it; then the weight spinner
|
||||
// (with presets) and the width profile.
|
||||
let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options");
|
||||
stroke_panel::popover(app, &link);
|
||||
stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0);
|
||||
let profile = vectorcraft_doc::WidthProfile::id_of(shown_stroke.as_ref().and_then(|s| s.profile.as_ref()));
|
||||
if let Some(id) = stroke_panel::profile_dropdown(ui, profile) {
|
||||
app.run("stroke.set", json!({"profile": id})).ok();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.separator();
|
||||
|
|
|
|||
|
|
@ -42,10 +42,17 @@ fn format(app: &mut VectorcraftApp, p: Value) {
|
|||
/// Apply character attributes: to the selected range while the Type tool edits text (the whole
|
||||
/// object when nothing is selected), else to the selected text objects.
|
||||
fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
|
||||
let Some((id, a, b)) = text_editing(app) else {
|
||||
if text_editing(app).is_none() {
|
||||
app.run(cmd, p).ok();
|
||||
return;
|
||||
};
|
||||
}
|
||||
range_style(app, p);
|
||||
}
|
||||
|
||||
/// `text.setRangeStyle` with `p` on the range the Type tool has selected (the whole text when
|
||||
/// nothing is selected). Does nothing unless the Type tool edits text.
|
||||
pub(crate) fn range_style(app: &mut VectorcraftApp, p: Value) {
|
||||
let Some((id, a, b)) = text_editing(app) else { return };
|
||||
end_typing(app);
|
||||
let mut q = p;
|
||||
q["id"] = json!(id.0);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ use egui::{Rect, Sense, Ui, vec2};
|
|||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Node, StrokeLayer};
|
||||
use vectorcraft_engine::inspect::StrokeMixed;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{Live, dim_label};
|
||||
|
|
@ -205,10 +206,29 @@ pub(crate) fn mixed_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (bool,
|
|||
}
|
||||
}
|
||||
|
||||
/// The stroke the Stroke panel, Control bar and Properties show: the Appearance panel's active
|
||||
/// item when it is a stroke, else the first selected object's top stroke.
|
||||
/// The stroke the Stroke panel, Control bar and Properties show: while the Type tool edits text,
|
||||
/// the selected characters' stroke, else [`vectorcraft_engine::Session::shown_stroke`].
|
||||
pub(crate) fn current_stroke(app: &VectorcraftApp) -> Option<StrokeLayer> {
|
||||
first_node(app)?.appearance.stroke_for(app.session.appearance_item()).cloned()
|
||||
if character::text_editing(app).is_some() {
|
||||
return character::text_style(app).map(|(c, _)| c.stroke_layer());
|
||||
}
|
||||
app.session.shown_stroke()
|
||||
}
|
||||
|
||||
/// Which Stroke panel values the selection doesn't share and whether Align Stroke applies
|
||||
/// ([`vectorcraft_engine::DocState::stroke_mixed`]), cached per document revision. While the Type
|
||||
/// tool edits text, the selected characters' stroke shows as it is.
|
||||
pub(crate) fn stroke_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> StrokeMixed {
|
||||
let Some(st) = app.session.active().filter(|_| character::text_editing(app).is_none()) else { return StrokeMixed::default() };
|
||||
let key = (st.uid, st.revision);
|
||||
match pstate::<Option<((u64, u64), StrokeMixed)>>(ctx, "stroke-mixed") {
|
||||
Some((k, mixed)) if k == key => mixed,
|
||||
_ => {
|
||||
let mixed = st.stroke_mixed();
|
||||
set_pstate(ctx, "stroke-mixed", Some((key, mixed)));
|
||||
mixed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance
|
||||
|
|
@ -361,3 +381,5 @@ mod tests_appearance;
|
|||
mod tests_effectedit;
|
||||
#[cfg(test)]
|
||||
mod tests_stroke;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeux;
|
||||
|
|
|
|||
|
|
@ -266,10 +266,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
let Some(n) = first_selected(app) else { return };
|
||||
section_header(ui, "Appearance");
|
||||
let (fill, stroke) = super::current_paints(app);
|
||||
let weight = match &n.kind {
|
||||
NodeKind::Text(tx) if app.session.appearance_item().is_none() => tx.first_style().stroke_width,
|
||||
_ => super::current_stroke(app).map_or(0.0, |s| s.width),
|
||||
};
|
||||
let weight = super::stroke::shown_weight(app, super::current_stroke(app).as_ref(), &super::stroke_mixed(app, ui.ctx()));
|
||||
for (label, paint, is_fill) in [("Fill", fill, true), ("Stroke", stroke, false)] {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||||
|
|
@ -287,12 +284,11 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
ui.label(egui::RichText::new(label).size(12.0));
|
||||
if !is_fill {
|
||||
ui.add_space(8.0);
|
||||
if let Some(w) = widgets::num_field(ui, "ap-w", Some(weight), Unit::Points, 70.0) {
|
||||
if let Some(w) = widgets::num_field(ui, "ap-w", weight, app.session.stroke_unit(), 70.0) {
|
||||
app.run("stroke.set", json!({"weight": w})).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, "ellipsis", "Stroke options", false, 22.0).clicked() {
|
||||
app.ui.open_panel = Some("stroke".into());
|
||||
}
|
||||
let more = widgets::icon_button(ui, "ellipsis", "Stroke options", false, 22.0);
|
||||
super::stroke::popover(app, &more);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_engine::inspect::StrokeMixed;
|
||||
|
||||
use super::{current_stroke, pstate, set_pstate};
|
||||
use super::{character, current_stroke, pstate, set_pstate, stroke_mixed};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
|
@ -73,14 +74,44 @@ fn arrow_label(a: Option<Arrowhead>) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the
|
||||
/// selected characters' stroke.
|
||||
fn set(app: &mut VectorcraftApp, p: Value) {
|
||||
app.run("stroke.set", p).ok();
|
||||
if character::text_editing(app).is_some() {
|
||||
character::range_style(app, json!({ "strokeOptions": p }));
|
||||
} else {
|
||||
app.run("stroke.set", p).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// The weight the Stroke panel, Control bar and Properties panel show: blank where the selected
|
||||
/// objects differ, the default for new art without a stroke.
|
||||
pub(crate) fn shown_weight(app: &VectorcraftApp, st: Option<&StrokeLayer>, mixed: &StrokeMixed) -> Option<f64> {
|
||||
(!mixed.weight).then(|| st.map_or(app.session.paint.stroke_width, |s| s.width))
|
||||
}
|
||||
|
||||
/// The weight spinner (Stroke panel, Control bar): Units > Stroke, presets, blank when mixed.
|
||||
pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weight: Option<f64>, width: f32) {
|
||||
if let Some(w) = widgets::spin_field(ui, id, weight, app.session.stroke_unit(), width, 1.0, 0.0, &WEIGHT_PRESETS) {
|
||||
set(app, json!({"weight": w}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Stroke panel in a popover anchored to `resp` (the Control bar's and the Properties
|
||||
/// panel's Stroke links), which a click on it toggles.
|
||||
pub(crate) fn popover(app: &mut VectorcraftApp, resp: &egui::Response) {
|
||||
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
ui.set_width(260.0);
|
||||
show(app, ui);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let st = current_stroke(app);
|
||||
let weight = st.as_ref().map(|s| s.width).unwrap_or(app.session.paint.stroke_width);
|
||||
let mixed = stroke_mixed(app, ui.ctx());
|
||||
let unit = app.session.stroke_unit();
|
||||
let weight = shown_weight(app, st.as_ref(), &mixed);
|
||||
let hidden: bool = pstate(ui.ctx(), "stroke-hide-options");
|
||||
let label_w = 64.0;
|
||||
let row_label = |ui: &mut Ui, s: &str| {
|
||||
|
|
@ -88,16 +119,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
};
|
||||
ui.horizontal(|ui| {
|
||||
row_label(ui, "Weight:");
|
||||
if let Some(w) = widgets::spin_field(ui, "stroke-weight", Some(weight), Unit::Points, 120.0, 1.0, 0.0, &WEIGHT_PRESETS) {
|
||||
set(app, json!({"weight": w}));
|
||||
}
|
||||
weight_field(app, ui, "stroke-weight", weight, 120.0);
|
||||
});
|
||||
if hidden {
|
||||
return;
|
||||
}
|
||||
let cap = st.as_ref().map(|s| s.cap).unwrap_or_default();
|
||||
let join = st.as_ref().map(|s| s.join).unwrap_or_default();
|
||||
let align = st.as_ref().map(|s| s.align).unwrap_or_default();
|
||||
// Mixed values light no button.
|
||||
let cap = st.as_ref().map(|s| s.cap).filter(|_| !mixed.cap);
|
||||
let join = st.as_ref().map(|s| s.join).filter(|_| !mixed.join);
|
||||
let align = st.as_ref().map(|s| s.align).filter(|_| !mixed.align);
|
||||
ui.horizontal(|ui| {
|
||||
row_label(ui, "Cap:");
|
||||
for (v, icon, tip, name) in [
|
||||
|
|
@ -105,7 +135,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
(LineCap::Round, "dc-cap-round", "Round Cap", "round"),
|
||||
(LineCap::Square, "dc-cap-square", "Projecting Cap", "square"),
|
||||
] {
|
||||
if widgets::icon_button(ui, icon, tip, cap == v, 24.0).clicked() {
|
||||
if widgets::icon_button(ui, icon, tip, cap == Some(v), 24.0).clicked() {
|
||||
set(app, json!({"cap": name}));
|
||||
}
|
||||
}
|
||||
|
|
@ -117,14 +147,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
(LineJoin::Round, "dc-join-round", "Round Join", "round"),
|
||||
(LineJoin::Bevel, "dc-join-bevel", "Bevel Join", "bevel"),
|
||||
] {
|
||||
if widgets::icon_button(ui, icon, tip, join == v, 24.0).clicked() {
|
||||
if widgets::icon_button(ui, icon, tip, join == Some(v), 24.0).clicked() {
|
||||
set(app, json!({"join": name}));
|
||||
}
|
||||
}
|
||||
widgets::dim_label(ui, "Limit:");
|
||||
let lim = st.as_ref().map(|s| s.miter_limit).unwrap_or(10.0);
|
||||
if join == LineJoin::Miter {
|
||||
if let Some(v) = widgets::plain_field(ui, "stroke-limit", lim, " x", 0, 50.0) {
|
||||
if !matches!(join, Some(LineJoin::Round | LineJoin::Bevel)) {
|
||||
if let Some(v) = widgets::mixed_field(ui, "stroke-limit", (!mixed.miter_limit).then_some(lim), " x", 0, 50.0) {
|
||||
set(app, json!({"miterLimit": v.clamp(1.0, 500.0)}));
|
||||
}
|
||||
} else {
|
||||
|
|
@ -138,7 +168,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
(StrokeAlign::Inside, "dc-stroke-inside", "Align Stroke to Inside", "inside"),
|
||||
(StrokeAlign::Outside, "dc-stroke-outside", "Align Stroke to Outside", "outside"),
|
||||
] {
|
||||
if widgets::icon_button(ui, icon, tip, align == v, 24.0).clicked() {
|
||||
// Only closed paths take a stroke inside or outside (not open paths, not type).
|
||||
let enabled = v == StrokeAlign::Center || mixed.can_align;
|
||||
if widgets::icon_button_enabled(ui, icon, tip, align == Some(v), enabled, 24.0).clicked() {
|
||||
set(app, json!({"align": name}));
|
||||
}
|
||||
}
|
||||
|
|
@ -147,6 +179,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
// Dashed line.
|
||||
let dash = st.as_ref().and_then(|s| s.dash.as_ref());
|
||||
let (fields, align_corners) = dash_state(dash, pstate(ui.ctx(), "stroke-dash-last"));
|
||||
let shown = if mixed.dash { [None; 6] } else { fields };
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::check(ui, "Dashed Line", dash.is_some(), st.is_some()) {
|
||||
if dash.is_some() {
|
||||
|
|
@ -168,10 +201,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 3.0;
|
||||
let fw = ((ui.available_width() - 15.0) / 6.0).clamp(28.0, 40.0);
|
||||
let mut nf = fields;
|
||||
let mut nf = shown;
|
||||
let mut changed = false;
|
||||
for (i, f) in fields.iter().enumerate() {
|
||||
let r = ui.add_enabled_ui(dash.is_some(), |ui| widgets::opt_field(ui, ("dash", i), *f, fw)).inner;
|
||||
for (i, f) in shown.iter().enumerate() {
|
||||
let r = ui.add_enabled_ui(dash.is_some(), |ui| widgets::opt_field(ui, ("dash", i), *f, unit, fw)).inner;
|
||||
if let Some(x) = r {
|
||||
nf[i] = x.map(|v| v.max(0.0));
|
||||
changed = true;
|
||||
|
|
@ -343,7 +376,7 @@ pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color
|
|||
ui.painter().add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> {
|
||||
pub(crate) fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 2, t.input);
|
||||
|
|
@ -450,4 +483,28 @@ mod tests {
|
|||
assert_eq!((st.end_arrow, st.arrow_align), (Some(Arrowhead::ArrowOpen), ArrowAlign::Tip));
|
||||
assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "lens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn while_typing_the_panel_strokes_the_selected_characters() {
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::{Session, ViewInfo};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
app.session.select_tool("type", v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
app.session.pointer(&PointerEvent::new(k, 50.0, 200.0), v).unwrap();
|
||||
}
|
||||
app.session.tool_text("Make this bold", v).unwrap();
|
||||
app.session.set_tool_option("select", &json!({"start": 5, "end": 9}));
|
||||
set(&mut app, json!({"weight": 4, "join": "round"}));
|
||||
let id = app.session.active().unwrap().selection.objects[0];
|
||||
let NodeKind::Text(t) = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("type") };
|
||||
let joins: Vec<(&str, LineJoin, f64)> = t.runs.iter().map(|r| (r.text.as_str(), r.style.stroke_join, r.style.stroke_width)).collect();
|
||||
assert_eq!(joins, [("Make ", LineJoin::Miter, 0.0), ("this", LineJoin::Round, 4.0), (" bold", LineJoin::Miter, 0.0)]);
|
||||
// The panel shows the selected characters' stroke.
|
||||
assert_eq!(current_stroke(&app).map(|s| (s.join, s.width)), Some((LineJoin::Round, 4.0)));
|
||||
frame(&mut app);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
//! Headless frames of the Stroke panel's reach: Units > Stroke in the panel, the Control bar and
|
||||
//! the Properties panel, mixed values shown blank, Align Stroke for open paths and type, and the
|
||||
//! weight presets.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::tests_appearance::{app_with_rect, click, frame_events, run, text_rect};
|
||||
use super::*;
|
||||
|
||||
type Texts = Vec<(String, egui::Rect)>;
|
||||
|
||||
fn texts(ctx: &egui::Context, app: &mut VectorcraftApp, f: impl FnMut(&mut VectorcraftApp, &mut Ui)) -> Texts {
|
||||
frame_events(ctx, app, vec![], f)
|
||||
}
|
||||
|
||||
fn shows(t: &Texts, s: &str) -> bool {
|
||||
t.iter().any(|(x, _)| x == s)
|
||||
}
|
||||
|
||||
/// The centre of the `n`th (0-based) 24 px button right of a Stroke panel row label.
|
||||
fn row_button(ctx: &egui::Context, t: &Texts, label: &str, n: usize) -> egui::Pos2 {
|
||||
let r = text_rect(t, label);
|
||||
let sp = ctx.global_style().spacing.item_spacing.x;
|
||||
egui::pos2(r.right() + sp + n as f32 * (24.0 + sp) + 12.0, r.center().y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weights_and_dashes_show_in_the_stroke_unit_everywhere() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut app = app_with_rect();
|
||||
app.session.prefs.units_stroke = "millimeters".into();
|
||||
run(&mut app, "stroke.set", json!({"dash": [12, 6]}));
|
||||
let mm = vectorcraft_doc::Unit::Millimeters.format(1.0);
|
||||
assert_eq!(mm, "0.353 mm");
|
||||
let t = texts(&ctx, &mut app, stroke::show);
|
||||
assert!(shows(&t, &mm), "Stroke panel: {t:?}");
|
||||
assert!(shows(&t, "4.233") && shows(&t, "2.117"), "dash and gap in mm: {t:?}");
|
||||
assert!(shows(&texts(&ctx, &mut app, properties::show), &mm), "Properties panel");
|
||||
assert!(shows(&texts(&ctx, &mut app, crate::chrome::control_bar), &mm), "Control bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_weights_show_blank() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_rect();
|
||||
let a = app.session.active().unwrap().selection.objects[0];
|
||||
let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap();
|
||||
run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3}));
|
||||
run(&mut app, "select.set", json!({ "ids": [a.0, b] }));
|
||||
let t = texts(&ctx, &mut app, stroke::show);
|
||||
assert!(!shows(&t, "1 pt") && !shows(&t, "3 pt"), "{t:?}");
|
||||
run(&mut app, "select.set", json!({ "ids": [b] }));
|
||||
assert!(shows(&texts(&ctx, &mut app, stroke::show), "3 pt"), "the cache follows the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_and_outside_are_off_for_open_paths_and_type() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_rect();
|
||||
let align = |app: &VectorcraftApp| current_stroke(app).unwrap().align;
|
||||
let at = row_button(&ctx, &texts(&ctx, &mut app, stroke::show), "Align Stroke:", 1);
|
||||
click(&ctx, &mut app, at, stroke::show);
|
||||
assert_eq!(align(&app), vectorcraft_doc::StrokeAlign::Inside, "a closed path takes an inside stroke");
|
||||
run(&mut app, "shape.line", json!({"x1": 10, "y1": 150, "x2": 150, "y2": 150}));
|
||||
click(&ctx, &mut app, at, stroke::show);
|
||||
assert_eq!(align(&app), vectorcraft_doc::StrokeAlign::Center, "an open path doesn't");
|
||||
let id = run(&mut app, "text.create", json!({"x": 10, "y": 190, "text": "Hi"}))["id"].as_u64().unwrap();
|
||||
run(&mut app, "select.set", json!({ "ids": [id] }));
|
||||
run(&mut app, "paint.setStroke", json!({"color": "#000000"}));
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
click(&ctx, &mut app, at, stroke::show);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "type doesn't");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_weight_presets_run_from_a_quarter_point_to_a_hundred() {
|
||||
assert!(stroke::WEIGHT_PRESETS.contains(&0.25) && stroke::WEIGHT_PRESETS.contains(&100.0));
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_rect();
|
||||
// The chevron at the right end of the 120 px weight spinner opens them.
|
||||
let t = texts(&ctx, &mut app, stroke::show);
|
||||
let r = text_rect(&t, "Weight:");
|
||||
let sp = ctx.global_style().spacing.item_spacing.x;
|
||||
click(&ctx, &mut app, egui::pos2(r.right() + sp + 110.0, r.center().y), stroke::show);
|
||||
let t = texts(&ctx, &mut app, stroke::show);
|
||||
assert!(shows(&t, "0.25 pt") && shows(&t, "100 pt"), "{t:?}");
|
||||
}
|
||||
|
|
@ -911,12 +911,13 @@ pub fn menu_item(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool
|
|||
ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(text).size(12.5)).frame(false)).clicked()
|
||||
}
|
||||
|
||||
/// A number field that may be empty (Stroke dash/gap). Returns `Some(new)` on commit, where
|
||||
/// `new` is `None` when the field was cleared.
|
||||
pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<f64>, width: f32) -> Option<Option<f64>> {
|
||||
/// A number field that may be empty (Stroke dash/gap): `value` (points) shows in `unit` without
|
||||
/// its suffix, to fit narrow fields. Returns `Some(new)` (points) on commit, where `new` is `None`
|
||||
/// when the field was cleared; typed units and arithmetic work as in [`num_field`].
|
||||
pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<f64>, unit: Unit, width: f32) -> Option<Option<f64>> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = ui.id().with(id);
|
||||
let shown = value.map(|v| format!("{v}")).unwrap_or_default();
|
||||
let shown = value.map(|v| unit.format(v).trim_end_matches(unit.suffix()).trim_end().to_string()).unwrap_or_default();
|
||||
let editing = ui.memory(|m| m.has_focus(id));
|
||||
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() };
|
||||
let enabled = ui.is_enabled();
|
||||
|
|
@ -938,8 +939,8 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
|||
.inner;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
if resp.lost_focus() && buf != shown {
|
||||
let s = buf.trim().trim_end_matches("pt").trim();
|
||||
if s.is_empty() { Some(None) } else { s.parse::<f64>().ok().map(Some) }
|
||||
let s = buf.trim();
|
||||
if s.is_empty() { Some(None) } else { unit.parse(s).map(Some) }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
|
|
|||
10
docs/mcp.md
10
docs/mcp.md
|
|
@ -324,3 +324,13 @@ as a library (`.vcswatches` keeps colour models, global, spot, gradients and col
|
|||
CSS writes custom properties); without `path` it returns `{data}`, and `user: true` saves into the user library
|
||||
folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? |
|
||||
dataBase64?, name?}` loads a `.vcswatches` or `.gpl` file, or another document's swatches, as a library to add from.
|
||||
|
||||
## Strokes on type
|
||||
|
||||
`stroke.set` without an `item` gives type its characters' stroke: weight, cap, join, miter limit and the dash
|
||||
options go to every run (the object keeps no stroke of its own); an `item` still edits an object-level stroke added
|
||||
with `appearance.addStroke`, which takes every stroke option (dashes, profile, opacity, blend) like a path's and paints under or over the
|
||||
characters as its place relative to the Characters row says. To stroke
|
||||
some characters, use `text.setRangeStyle {id, start, end, strokeOptions: {weight?, cap?, join?, miterLimit?, dash?,
|
||||
dashOffset?, alignDashes?}}`. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first
|
||||
run's) in `stroke.set` terms. `stroke.set` on a group leaves the images and symbol instances in it alone.
|
||||
|
|
|
|||
Loading…
Reference in New Issue