mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Align Stroke inside or outside on live type (#2294)
The characters' stroke takes Align Stroke like an object stroke: CharStyle keeps stroke_align (written only when not centred), stroke.set and text.setRangeStyle strokeOptions apply align to type, and the Stroke panel enables the buttons for type. Canvas, PDF and EPS draw it around the glyphs through stroke_layer(); SVG keeps the text live and writes the aligned stroke on the glyph outlines, clipped or masked. Type's visual bounds grow with its character strokes.
This commit is contained in:
@@ -1217,6 +1217,13 @@ impl Node {
|
||||
let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol));
|
||||
self.projected(Some(b)).map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
// The characters' strokes paint around the glyphs, scaled with the type.
|
||||
let k = Scaling::factor(t.xf).unwrap_or(1.0);
|
||||
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
|
||||
let o = self.appearance.outset().max(chars);
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
_ => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
@@ -2336,6 +2343,25 @@ mod tests {
|
||||
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
|
||||
}
|
||||
|
||||
/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the
|
||||
/// weight centred, the whole weight outside, nothing inside; scaled with the type.
|
||||
#[test]
|
||||
fn type_visual_bounds_grow_with_the_character_stroke() {
|
||||
use crate::appearance::StrokeAlign;
|
||||
use crate::text::{CharStyle, TextObject};
|
||||
let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() };
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style);
|
||||
t.xf = Affine::scale(2.0) * t.xf;
|
||||
let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())));
|
||||
let g = node(&t).geometric_bounds().unwrap();
|
||||
for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] {
|
||||
t.runs[0].style.stroke_align = align;
|
||||
assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}");
|
||||
}
|
||||
t.runs[0].style.stroke = vectorcraft_color::Paint::None;
|
||||
assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth");
|
||||
}
|
||||
|
||||
/// A group holding a path, a compound, an image and a nested group, as a file writes it.
|
||||
fn nested_group() -> Node {
|
||||
let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
|
||||
+26
-5
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Justify {
|
||||
@@ -84,6 +84,10 @@ pub struct CharStyle {
|
||||
pub stroke_miter_limit: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stroke_dash: Option<Dash>,
|
||||
/// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the
|
||||
/// glyph outlines, inside or outside them, as for object strokes; the type stays live.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub stroke_align: StrokeAlign,
|
||||
/// Superscript or subscript (Character panel).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub position: CharPosition,
|
||||
@@ -249,6 +253,7 @@ impl Default for CharStyle {
|
||||
stroke_join: LineJoin::Miter,
|
||||
stroke_miter_limit: 10.0,
|
||||
stroke_dash: None,
|
||||
stroke_align: StrokeAlign::Center,
|
||||
position: CharPosition::Normal,
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
proportional_metrics: false,
|
||||
@@ -266,8 +271,9 @@ impl CharStyle {
|
||||
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.
|
||||
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes,
|
||||
/// alignment and overprint), so type strokes share the object strokes' geometry, rendering
|
||||
/// and export.
|
||||
pub fn stroke_layer(&self) -> StrokeLayer {
|
||||
StrokeLayer {
|
||||
overprint: self.overprint_stroke,
|
||||
@@ -275,11 +281,13 @@ impl CharStyle {
|
||||
join: self.stroke_join,
|
||||
miter_limit: self.stroke_miter_limit,
|
||||
dash: self.stroke_dash.clone(),
|
||||
align: self.stroke_align,
|
||||
..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).
|
||||
/// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the
|
||||
/// character stroke (the options characters have no use for, such as arrowheads and width
|
||||
/// profiles, are dropped).
|
||||
pub fn set_stroke_layer(&mut self, st: &StrokeLayer) {
|
||||
self.stroke = st.paint.clone();
|
||||
self.stroke_width = st.width;
|
||||
@@ -287,6 +295,19 @@ impl CharStyle {
|
||||
self.stroke_join = st.join;
|
||||
self.stroke_miter_limit = st.miter_limit;
|
||||
self.stroke_dash = st.dash.clone();
|
||||
self.stroke_align = st.align;
|
||||
}
|
||||
/// How far the character stroke paints past the glyph outlines (text space): half the weight
|
||||
/// centred, the whole weight outside, nothing inside (miter spikes aside).
|
||||
pub fn stroke_outset(&self) -> f64 {
|
||||
if !self.has_stroke() {
|
||||
return 0.0;
|
||||
}
|
||||
match self.stroke_align {
|
||||
StrokeAlign::Center => self.stroke_width / 2.0,
|
||||
StrokeAlign::Outside => self.stroke_width,
|
||||
StrokeAlign::Inside => 0.0,
|
||||
}
|
||||
}
|
||||
/// The character fill as a fill layer, overprinting as the characters do.
|
||||
fn fill_layer(&self) -> FillLayer {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeLayer, Unit, WidthProfile};
|
||||
use vectorcraft_doc::{
|
||||
ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeAlign, StrokeLayer, Unit, WidthProfile,
|
||||
};
|
||||
|
||||
use super::appearance::{ItemTarget, edit_items, item_target};
|
||||
use super::paint::painted;
|
||||
@@ -18,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{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|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), 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?|id?, target?: \"object\"|\"contents\"} 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. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)",
|
||||
"{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|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), 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?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit, align and the dash options as its characters' stroke (align: around the glyph outlines, the type staying live) (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
@@ -82,6 +84,8 @@ pub(crate) struct StrokeChange {
|
||||
dash: Option<Option<Vec<f64>>>,
|
||||
dash_offset: Option<f64>,
|
||||
align_dashes: Option<bool>,
|
||||
/// Align Stroke: centred, inside or outside the shape (or the glyphs, on type).
|
||||
align: Option<StrokeAlign>,
|
||||
}
|
||||
|
||||
/// The value param `key` names in `table` (see [`crate::inspect::CAPS`]); `None` when absent.
|
||||
@@ -116,6 +120,7 @@ impl StrokeChange {
|
||||
dash,
|
||||
dash_offset: num("dashOffset"),
|
||||
align_dashes: p.get("alignDashes").and_then(Value::as_bool),
|
||||
align: named(&ALIGNS, p, "align", cmd)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,12 +132,16 @@ impl StrokeChange {
|
||||
&& self.dash.is_none()
|
||||
&& self.dash_offset.is_none()
|
||||
&& self.align_dashes.is_none()
|
||||
&& self.align.is_none()
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, st: &mut StrokeLayer) {
|
||||
if let Some(w) = self.weight {
|
||||
st.width = w;
|
||||
}
|
||||
if let Some(a) = self.align {
|
||||
st.align = a;
|
||||
}
|
||||
if let Some(c) = self.cap {
|
||||
st.cap = c;
|
||||
}
|
||||
@@ -189,7 +198,6 @@ 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),
|
||||
@@ -202,9 +210,6 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let set = |st: &mut StrokeLayer| {
|
||||
change.apply(st);
|
||||
if let Some(a) = align {
|
||||
st.align = a;
|
||||
}
|
||||
if let Some(a) = sa {
|
||||
st.start_arrow = a;
|
||||
}
|
||||
@@ -270,7 +275,7 @@ impl Session {
|
||||
|
||||
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
|
||||
/// and whether Align Stroke applies to them (no open path among 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![];
|
||||
@@ -284,7 +289,7 @@ impl DocState {
|
||||
for n in &nodes {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
can_align = false;
|
||||
// The characters' stroke aligns to the glyph outlines, which are closed.
|
||||
strokes.extend(t.runs.iter().map(|r| r.style.stroke_layer()));
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, StrokeAlign, TextObject};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -133,3 +133,65 @@ fn character_stroke_options_round_trip_natively_and_old_files_load() {
|
||||
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");
|
||||
}
|
||||
|
||||
/// Align Stroke on live type (Reddit redd.it/1dwhos6): the characters' stroke goes inside or
|
||||
/// outside the glyphs without creating outlines, for every run or a range, and is kept by the
|
||||
/// native format, the Eyedropper and Create Outlines.
|
||||
#[test]
|
||||
fn align_stroke_reaches_the_characters_stroke() {
|
||||
let mut s = session();
|
||||
let id = stroked_text(&mut s);
|
||||
assert!(s.active().unwrap().stroke_mixed().can_align, "type can take an inside or outside stroke");
|
||||
run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 60}));
|
||||
run(&mut s, "stroke.set", json!({"align": "outside"}));
|
||||
let t = text(&s, id);
|
||||
assert!(t.runs.iter().all(|r| r.style.stroke_align == StrokeAlign::Outside), "every run");
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().appearance.items.is_empty(), "no object-level stroke is added");
|
||||
assert_eq!(s.shown_stroke().unwrap().align, StrokeAlign::Outside, "the Stroke panel shows it");
|
||||
let m = s.active().unwrap().stroke_mixed();
|
||||
assert!(!m.align && m.can_align, "{m:?}");
|
||||
// A range takes its own alignment through strokeOptions; the runs then differ.
|
||||
run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "strokeOptions": {"align": "inside"}}));
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.runs[0].style.stroke_align, StrokeAlign::Inside);
|
||||
assert_eq!(t.runs.last().unwrap().style.stroke_align, StrokeAlign::Outside);
|
||||
assert!(s.active().unwrap().stroke_mixed().align, "mixed alignments light no button");
|
||||
// document.inspect reports it, as for paths.
|
||||
let doc = run(&mut s, "document.inspect", json!({}));
|
||||
assert_eq!(doc["layers"][0]["children"][0]["strokeOptions"]["align"], "inside", "{doc}");
|
||||
// Saved and reopened.
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
|
||||
let NodeKind::Text(b) = &back.node(id).unwrap().kind else { panic!("not type") };
|
||||
assert_eq!(b.runs, text(&s, id).runs);
|
||||
// Create Outlines keeps it on the glyphs.
|
||||
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 mut aligns = vec![];
|
||||
d.node(NodeId(g)).unwrap().walk(&mut |n: &vectorcraft_doc::Node| aligns.extend(n.appearance.stroke().map(|st| st.align)));
|
||||
assert!(aligns.contains(&StrokeAlign::Inside) && aligns.contains(&StrokeAlign::Outside), "{aligns:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eyedropper_carries_the_characters_stroke_alignment() {
|
||||
let mut s = session();
|
||||
let id = stroked_text(&mut s);
|
||||
run(&mut s, "stroke.set", json!({"align": "inside"}));
|
||||
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]}));
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(rect)).unwrap().appearance.stroke().unwrap().align, StrokeAlign::Inside);
|
||||
run(&mut s, "stroke.set", json!({"ids": [rect], "align": "outside"}));
|
||||
run(&mut s, "appearance.copyFrom", json!({"source": rect, "ids": [id.0]}));
|
||||
assert_eq!(text(&s, id).runs[0].style.stroke_align, StrokeAlign::Outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_centred_character_stroke_writes_no_alignment() {
|
||||
assert!(serde_json::to_value(CharStyle::default()).unwrap().get("stroke_align").is_none(), "the default isn't written");
|
||||
let mut old = serde_json::to_value(CharStyle { stroke_align: StrokeAlign::Outside, ..Default::default() }).unwrap();
|
||||
assert_eq!(old["stroke_align"], "Outside");
|
||||
old.as_object_mut().unwrap().remove("stroke_align");
|
||||
let st: CharStyle = serde_json::from_value(old).unwrap();
|
||||
assert_eq!(st.stroke_align, StrokeAlign::Center, "files written before it existed stroke centred");
|
||||
}
|
||||
|
||||
@@ -52,12 +52,15 @@ fn mixed_values_and_align_stroke_follow_the_selection() {
|
||||
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.
|
||||
// An open path in the selection can't take an inside or outside stroke; type can (its
|
||||
// characters' stroke goes around the glyphs).
|
||||
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);
|
||||
select(&mut s, &[text, line]);
|
||||
assert!(!s.active().unwrap().stroke_mixed().can_align);
|
||||
// Grouped closed paths can.
|
||||
select(&mut s, &[a, b]);
|
||||
|
||||
@@ -37,3 +37,21 @@ fn text_strokes_keep_cap_join_miter_dashes_and_alignment() {
|
||||
// An inside stroke is drawn twice as wide, clipped to the glyphs.
|
||||
assert!(strokes.iter().any(|s| s.dash.is_none() && (s.width - 6.0).abs() < 1e-3), "{strokes:?}");
|
||||
}
|
||||
|
||||
/// Align Stroke on live type: the characters' stroke drawn twice as wide, clipped inside or
|
||||
/// outside the glyphs, as on the canvas; the type stays text.
|
||||
#[test]
|
||||
fn an_aligned_character_stroke_is_clipped_to_its_side_of_the_glyphs() {
|
||||
for (align, clip) in [(StrokeAlign::Inside, false), (StrokeAlign::Outside, true)] {
|
||||
let style = CharStyle { size: 48.0, stroke: Paint::solid(Color::BLACK), stroke_width: 1.5, stroke_align: align, ..Default::default() };
|
||||
let mut d = Document::new(240.0, 100.0);
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 70.0), "Wave", style))));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let pdf = String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned();
|
||||
let ops: Vec<&str> = pdf.split_ascii_whitespace().collect();
|
||||
assert!(ops.windows(2).any(|w| w == ["3", "w"]), "{align:?}: twice the weight");
|
||||
assert!(ops.contains(&"BT"), "{align:?}: the type stays text");
|
||||
assert!(ops.contains(&if clip { "W*" } else { "W" }), "{align:?}: clipped to its side");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,3 +81,25 @@ fn character_and_object_strokes_on_type_are_dashed() {
|
||||
let p = half.pixel(x0, (y0 + y1) / 2);
|
||||
assert!(p[0] > 90 && p[0] < 200, "a half-opaque stroke is grey: {p:?}");
|
||||
}
|
||||
|
||||
/// Align Stroke on live type: an outside character stroke paints its whole weight around the
|
||||
/// glyphs and leaves them unpainted, an inside one stays within them, as on paths.
|
||||
#[test]
|
||||
fn aligned_character_strokes_keep_to_their_side_of_the_glyphs() {
|
||||
use vectorcraft_doc::StrokeAlign;
|
||||
let glyph = ink(&render_text("I", CharStyle::default(), vec![]));
|
||||
let reach = |b: (u32, u32, u32, u32)| {
|
||||
let d = |a: u32, b: u32| a as i64 - b as i64;
|
||||
d(glyph.0, b.0).max(d(glyph.1, b.1)).max(d(b.2, glyph.2)).max(d(b.3, glyph.3))
|
||||
};
|
||||
let style = |align| CharStyle { fill: Paint::None, stroke_align: align, ..stroked(LineJoin::Round) };
|
||||
let centre = render_text("I", style(StrokeAlign::Center), vec![]);
|
||||
let outside = render_text("I", style(StrokeAlign::Outside), vec![]);
|
||||
let inside = render_text("I", style(StrokeAlign::Inside), vec![]);
|
||||
assert!((7..=9).contains(&reach(ink(¢re))), "centred: half the weight past the glyph, {:?}", ink(¢re));
|
||||
assert!((15..=17).contains(&reach(ink(&outside))), "outside: the whole weight past the glyph, {:?}", ink(&outside));
|
||||
assert!(reach(ink(&inside)) <= 0, "inside: within the glyph, {:?}", ink(&inside));
|
||||
let mid = ((glyph.0 + glyph.2) / 2, (glyph.1 + glyph.3) / 2);
|
||||
assert!(!dark(&outside, mid.0, mid.1), "an outside stroke leaves the glyph itself unpainted");
|
||||
assert!(dark(&inside, mid.0, mid.1), "an inside stroke paints into the glyph");
|
||||
}
|
||||
|
||||
@@ -1716,7 +1716,9 @@ impl Writer<'_> {
|
||||
let saved = std::mem::replace(&mut self.xf, to_user);
|
||||
let fill = self.paint(&st.fill, Some(bounds));
|
||||
p.push(("fill", fill));
|
||||
if st.has_stroke() {
|
||||
// An inside or outside stroke is drawn on the glyph outlines instead
|
||||
// ([`Self::aligned_char_strokes`]): SVG strokes text centred only.
|
||||
if st.has_stroke() && !aligned(st) {
|
||||
// Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans
|
||||
// the text grown by half the weight, as on the canvas.
|
||||
let layer = st.stroke_layer();
|
||||
@@ -1744,7 +1746,10 @@ impl Writer<'_> {
|
||||
let outlined = self.opts.outline_text || t.vertical || (inline && matches!(t.kind, TextKind::OnPath { .. }));
|
||||
let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) };
|
||||
let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none());
|
||||
if !painted && !inline {
|
||||
// Live type with an inside or outside character stroke writes that stroke apart from the
|
||||
// text, in the group.
|
||||
let apart = !outlined && t.runs.iter().any(|r| aligned(&r.style));
|
||||
if !painted && !inline && !apart {
|
||||
return chars(self, n);
|
||||
}
|
||||
let id = self.id_attr(n);
|
||||
@@ -1898,6 +1903,40 @@ impl Writer<'_> {
|
||||
}
|
||||
s.push_str("</text>");
|
||||
self.line(&s);
|
||||
self.aligned_char_strokes(t);
|
||||
}
|
||||
|
||||
/// The inside and outside character strokes of live type `t`, over its text (which goes
|
||||
/// without them, see [`Self::char_props`]): each such run's stroke on its glyph outlines, drawn
|
||||
/// twice as wide and clipped or masked to its side as aligned path strokes are
|
||||
/// ([`Self::side_attr`]). The text stays live; only the stroke follows the outlines.
|
||||
fn aligned_char_strokes(&mut self, t: &TextObject) {
|
||||
if !t.runs.iter().any(|r| aligned(&r.style)) {
|
||||
return;
|
||||
}
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let lay = vectorcraft_text::layout(db, t);
|
||||
let bars = vectorcraft_text::decorations(&lay, db, t);
|
||||
// The glyphs stay in text space; the text's transform joins the document's meanwhile.
|
||||
let saved = self.xf;
|
||||
self.xf = saved * t.xf;
|
||||
for (i, run) in t.runs.iter().enumerate().filter(|(_, r)| aligned(&r.style)) {
|
||||
let mut bp = BezPath::new();
|
||||
for g in lay.glyphs.iter().filter(|g| g.run == i) {
|
||||
bp.extend(g.outline.iter());
|
||||
}
|
||||
for (_, bar) in bars.iter().filter(|(r, _)| *r == i) {
|
||||
bp.extend(bar.iter());
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let pd = PathData::from_bezpath(&bp);
|
||||
let ap = vectorcraft_doc::Appearance { items: vec![AppearanceItem::Stroke(run.style.stroke_layer())], ..Default::default() };
|
||||
let d = self.path_d(&pd, self.xf);
|
||||
self.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, Some(lay.bounds));
|
||||
}
|
||||
self.xf = saved;
|
||||
}
|
||||
|
||||
/// One line of laid-out text starting at `start`. Default: a positioned `<tspan>` per segment
|
||||
@@ -2046,9 +2085,15 @@ impl Writer<'_> {
|
||||
}
|
||||
s.push_str("</textPath></text>");
|
||||
self.line(&s);
|
||||
self.aligned_char_strokes(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a character style stroke inside or outside its glyphs (not centred)?
|
||||
fn aligned(st: &CharStyle) -> bool {
|
||||
st.has_stroke() && st.stroke_align != StrokeAlign::Center
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -78,3 +78,27 @@ fn a_run_without_the_first_runs_stroke_resets_it() {
|
||||
);
|
||||
assert!(svg.contains("stroke-linejoin=\"round\"") && svg.contains("stroke-dasharray=\"3 1\""), "{svg}");
|
||||
}
|
||||
|
||||
/// Align Stroke on live type: the text stays live (filled, without the stroke) and the aligned
|
||||
/// stroke is drawn on the glyph outlines, twice as wide and clipped (inside) or masked (outside),
|
||||
/// as aligned strokes on paths are; outlined type carries it on its glyphs.
|
||||
#[test]
|
||||
fn an_aligned_character_stroke_keeps_the_text_live() {
|
||||
use vectorcraft_doc::StrokeAlign;
|
||||
for (align, side) in [(StrokeAlign::Outside, "mask=\"url(#"), (StrokeAlign::Inside, "clip-path=\"url(#")] {
|
||||
let style = CharStyle { size: 40.0, stroke: Paint::solid(Color::BLACK), stroke_width: 2.0, stroke_align: align, ..Default::default() };
|
||||
let mut t = TextObject::point(Point::new(20.0, 100.0), "Live", style);
|
||||
t.runs.push(TextRun { text: " plain".into(), style: CharStyle { size: 40.0, ..stroked() }, inline: None });
|
||||
for outline_text in [false, true] {
|
||||
let svg = export(&text_doc(t.clone(), vec![]), &ExportOptions { outline_text, ..Default::default() });
|
||||
assert_eq!(svg.contains("<text"), !outline_text, "{svg}");
|
||||
assert!(svg.contains("stroke-width=\"4\"") && svg.contains(side), "{align:?} {outline_text}: {svg}");
|
||||
if !outline_text {
|
||||
let text = &svg[svg.find("<text").unwrap()..svg.find("</text>").unwrap()];
|
||||
assert!(!text.contains("stroke-width=\"4\""), "the live text doesn't stroke the aligned run: {text}");
|
||||
assert!(text.contains("stroke-width=\"2\""), "a centred run keeps its stroke on the text: {text}");
|
||||
assert!(svg[svg.find("</text>").unwrap()..].contains("stroke-width=\"4\""), "the aligned stroke lies over the text");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,9 +319,12 @@ pub(crate) fn current_stroke(app: &VectorcraftApp) -> Option<StrokeLayer> {
|
||||
|
||||
/// 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.
|
||||
/// tool edits text, the selected characters' stroke shows as it is, and aligns to their glyphs.
|
||||
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() };
|
||||
if character::text_editing(app).is_some() {
|
||||
return StrokeMixed { can_align: true, ..Default::default() };
|
||||
}
|
||||
let Some(st) = app.session.active() 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,
|
||||
|
||||
Reference in New Issue
Block a user