mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M3.40: stroke on type — the Stroke panel edits the characters' stroke
With type selected, stroke.set (without an item) gives every run the weight, cap, join, miter limit and dashes instead of adding an object stroke; while the Type tool edits text, the Stroke panel styles the selected characters through text.setRangeStyle `strokeOptions`, and the panel reads the first run (or the selection's style). CharStyle gains stroke_cap, stroke_join, stroke_miter_limit and stroke_dash (defaulted, skipped when default). Character strokes and object strokes on type now draw through the shared stroke geometry on the canvas, in PDF and in SVG (live text writes cap/join/miter/dash and tspans reset what the first run sets; object strokes are drawn on the glyph outlines), so every stroke option, opacity and blend applies. Outlines, the Eyedropper and graphic styles keep the options. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3a79e6d922
commit
323b178ea7
23 files changed
+777
-161
No files matched your search
+63
-10
@@ -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,
|
||||
|
||||
+129
-44
@@ -1,11 +1,14 @@
|
||||
//! 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, NodeKind, StrokeLayer, Unit, WidthProfile};
|
||||
|
||||
use super::appearance::{edit_items, item_target};
|
||||
use super::paint::painted;
|
||||
use super::*;
|
||||
use crate::inspect::{ALIGNS, CAPS, JOINS, node_stroke};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -14,7 +17,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)",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
@@ -30,10 +33,100 @@ 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(Clone, 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)?;
|
||||
@@ -46,6 +139,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 +152,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 +183,28 @@ 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()?)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::{LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -30,6 +30,7 @@ pub fn node_summary(n: &Node) -> Value {
|
||||
v["stroke"] = json!(n.appearance.stroke_paint().label());
|
||||
v["strokeWidth"] = json!(n.appearance.stroke_width());
|
||||
}
|
||||
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => {
|
||||
v["anchors"] = json!(path.anchor_count());
|
||||
@@ -68,3 +69,20 @@ 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")];
|
||||
|
||||
/// The stroke an object's Stroke panel options describe: type's first run's character stroke,
|
||||
/// else the topmost stroke. None for containers and objects 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())),
|
||||
_ if n.is_container() => None,
|
||||
_ => n.appearance.stroke().cloned(),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+20
-26
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
-32
@@ -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:?}");
|
||||
}
|
||||
+72
-21
@@ -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}");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -205,10 +205,13 @@ 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()
|
||||
}
|
||||
|
||||
/// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 super::{current_stroke, pstate, set_pstate};
|
||||
use super::{character, current_stroke, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
@@ -73,8 +73,14 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
@@ -450,4 +456,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);
|
||||
}
|
||||
}
|
||||
@@ -324,3 +324,12 @@ 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?}}`.
|
||||
Reference in new issue
Block a user