typst_library/visualize/
stroke.rs

1use ecow::EcoString;
2use typst_utils::{Numeric, Scalar};
3
4use crate::diag::{HintedStrResult, SourceResult};
5use crate::foundations::{
6    cast, dict, func, scope, ty, Args, Cast, Dict, Fold, FromValue, NoneValue, Repr,
7    Resolve, Smart, StyleChain, Value,
8};
9use crate::layout::{Abs, Length};
10use crate::visualize::{Color, Gradient, Paint, Tiling};
11
12/// Defines how to draw a line.
13///
14/// A stroke has a _paint_ (a solid color or gradient), a _thickness,_ a line
15/// _cap,_ a line _join,_ a _miter limit,_ and a _dash_ pattern. All of these
16/// values are optional and have sensible defaults.
17///
18/// # Example
19/// ```example
20/// #set line(length: 100%)
21/// #stack(
22///   spacing: 1em,
23///   line(stroke: 2pt + red),
24///   line(stroke: (paint: blue, thickness: 4pt, cap: "round")),
25///   line(stroke: (paint: blue, thickness: 1pt, dash: "dashed")),
26///   line(stroke: 2pt + gradient.linear(..color.map.rainbow)),
27/// )
28/// ```
29///
30/// # Simple strokes
31/// You can create a simple solid stroke from a color, a thickness, or a
32/// combination of the two. Specifically, wherever a stroke is expected you can
33/// pass any of the following values:
34///
35/// - A length specifying the stroke's thickness. The color is inherited,
36///   defaulting to black.
37/// - A color to use for the stroke. The thickness is inherited, defaulting to
38///   `{1pt}`.
39/// - A stroke combined from color and thickness using the `+` operator as in
40///   `{2pt + red}`.
41///
42/// For full control, you can also provide a [dictionary] or a `{stroke}` object
43/// to any function that expects a stroke. The dictionary's keys may include any
44/// of the parameters for the constructor function, shown below.
45///
46/// # Fields
47/// On a stroke object, you can access any of the fields listed in the
48/// constructor function. For example, `{(2pt + blue).thickness}` is `{2pt}`.
49/// Meanwhile, `{stroke(red).cap}` is `{auto}` because it's unspecified. Fields
50/// set to `{auto}` are inherited.
51#[ty(scope, cast)]
52#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
53pub struct Stroke<T: Numeric = Length> {
54    /// The stroke's paint.
55    pub paint: Smart<Paint>,
56    /// The stroke's thickness.
57    pub thickness: Smart<T>,
58    /// The stroke's line cap.
59    pub cap: Smart<LineCap>,
60    /// The stroke's line join.
61    pub join: Smart<LineJoin>,
62    /// The stroke's line dash pattern.
63    pub dash: Smart<Option<DashPattern<T>>>,
64    /// The miter limit.
65    pub miter_limit: Smart<Scalar>,
66}
67
68impl Stroke {
69    /// Create a stroke from a paint and a thickness.
70    pub fn from_pair(paint: impl Into<Paint>, thickness: Length) -> Self {
71        Self {
72            paint: Smart::Custom(paint.into()),
73            thickness: Smart::Custom(thickness),
74            ..Default::default()
75        }
76    }
77}
78
79#[scope]
80impl Stroke {
81    /// Converts a value to a stroke or constructs a stroke with the given
82    /// parameters.
83    ///
84    /// Note that in most cases you do not need to convert values to strokes in
85    /// order to use them, as they will be converted automatically. However,
86    /// this constructor can be useful to ensure a value has all the fields of a
87    /// stroke.
88    ///
89    /// ```example
90    /// #let my-func(x) = {
91    ///     x = stroke(x) // Convert to a stroke
92    ///     [Stroke has thickness #x.thickness.]
93    /// }
94    /// #my-func(3pt) \
95    /// #my-func(red) \
96    /// #my-func(stroke(cap: "round", thickness: 1pt))
97    /// ```
98    #[func(constructor)]
99    pub fn construct(
100        args: &mut Args,
101
102        /// The color or gradient to use for the stroke.
103        ///
104        /// If set to `{auto}`, the value is inherited, defaulting to `{black}`.
105        #[external]
106        paint: Smart<Paint>,
107
108        /// The stroke's thickness.
109        ///
110        /// If set to `{auto}`, the value is inherited, defaulting to `{1pt}`.
111        #[external]
112        thickness: Smart<Length>,
113
114        /// How the ends of the stroke are rendered.
115        ///
116        /// If set to `{auto}`, the value is inherited, defaulting to `{"butt"}`.
117        #[external]
118        cap: Smart<LineCap>,
119
120        /// How sharp turns are rendered.
121        ///
122        /// If set to `{auto}`, the value is inherited, defaulting to `{"miter"}`.
123        #[external]
124        join: Smart<LineJoin>,
125
126        /// The dash pattern to use. This can be:
127        ///
128        /// - One of the predefined patterns:
129        ///   - `{"solid"}` or `{none}`
130        ///   - `{"dotted"}`
131        ///   - `{"densely-dotted"}`
132        ///   - `{"loosely-dotted"}`
133        ///   - `{"dashed"}`
134        ///   - `{"densely-dashed"}`
135        ///   - `{"loosely-dashed"}`
136        ///   - `{"dash-dotted"}`
137        ///   - `{"densely-dash-dotted"}`
138        ///   - `{"loosely-dash-dotted"}`
139        /// - An [array] with alternating lengths for dashes and gaps. You can
140        ///   also use the string `{"dot"}` for a length equal to the line
141        ///   thickness.
142        /// - A [dictionary] with the keys `array` (same as the array above),
143        ///   and `phase` (of type [length]), which defines where in the pattern
144        ///   to start drawing.
145        ///
146        /// If set to `{auto}`, the value is inherited, defaulting to `{none}`.
147        ///
148        /// ```example
149        /// #set line(length: 100%, stroke: 2pt)
150        /// #stack(
151        ///   spacing: 1em,
152        ///   line(stroke: (dash: "dashed")),
153        ///   line(stroke: (dash: (10pt, 5pt, "dot", 5pt))),
154        ///   line(stroke: (dash: (array: (10pt, 5pt, "dot", 5pt), phase: 10pt))),
155        /// )
156        /// ```
157        #[external]
158        dash: Smart<Option<DashPattern>>,
159
160        /// Number at which protruding sharp bends are rendered with a bevel
161        /// instead or a miter join. The higher the number, the sharper an angle
162        /// can be before it is bevelled. Only applicable if `join` is
163        /// `{"miter"}`.
164        ///
165        /// Specifically, the miter limit is the maximum ratio between the
166        /// corner's protrusion length and the stroke's thickness.
167        ///
168        /// If set to `{auto}`, the value is inherited, defaulting to `{4.0}`.
169        ///
170        /// ```example
171        /// #let items = (
172        ///   curve.move((15pt, 0pt)),
173        ///   curve.line((0pt, 30pt)),
174        ///   curve.line((30pt, 30pt)),
175        ///   curve.line((10pt, 20pt)),
176        /// )
177        ///
178        /// #set curve(stroke: 6pt + blue)
179        /// #stack(
180        ///   dir: ltr,
181        ///   spacing: 1cm,
182        ///   curve(stroke: (miter-limit: 1), ..items),
183        ///   curve(stroke: (miter-limit: 4), ..items),
184        ///   curve(stroke: (miter-limit: 5), ..items),
185        /// )
186        /// ```
187        #[external]
188        miter_limit: Smart<f64>,
189    ) -> SourceResult<Stroke> {
190        if let Some(stroke) = args.eat::<Stroke>()? {
191            return Ok(stroke);
192        }
193
194        fn take<T: FromValue>(args: &mut Args, arg: &str) -> SourceResult<Smart<T>> {
195            Ok(args.named::<Smart<T>>(arg)?.unwrap_or(Smart::Auto))
196        }
197
198        let paint = take::<Paint>(args, "paint")?;
199        let thickness = take::<Length>(args, "thickness")?;
200        let cap = take::<LineCap>(args, "cap")?;
201        let join = take::<LineJoin>(args, "join")?;
202        let dash = take::<Option<DashPattern>>(args, "dash")?;
203        let miter_limit = take::<f64>(args, "miter-limit")?.map(Scalar::new);
204
205        Ok(Self { paint, thickness, cap, join, dash, miter_limit })
206    }
207}
208
209impl<T: Numeric> Stroke<T> {
210    /// Map the contained lengths with `f`.
211    pub fn map<F, U: Numeric>(self, f: F) -> Stroke<U>
212    where
213        F: Fn(T) -> U,
214    {
215        Stroke {
216            paint: self.paint,
217            thickness: self.thickness.map(&f),
218            cap: self.cap,
219            join: self.join,
220            dash: self.dash.map(|dash| {
221                dash.map(|dash| DashPattern {
222                    array: dash
223                        .array
224                        .into_iter()
225                        .map(|l| match l {
226                            DashLength::Length(v) => DashLength::Length(f(v)),
227                            DashLength::LineWidth => DashLength::LineWidth,
228                        })
229                        .collect(),
230                    phase: f(dash.phase),
231                })
232            }),
233            miter_limit: self.miter_limit,
234        }
235    }
236}
237
238impl Stroke<Abs> {
239    /// Unpack the stroke, filling missing fields from the `default`.
240    pub fn unwrap_or(self, default: FixedStroke) -> FixedStroke {
241        let thickness = self.thickness.unwrap_or(default.thickness);
242        let dash = self
243            .dash
244            .map(|dash| {
245                dash.map(|dash| DashPattern {
246                    array: dash.array.into_iter().map(|l| l.finish(thickness)).collect(),
247                    phase: dash.phase,
248                })
249            })
250            .unwrap_or(default.dash);
251
252        FixedStroke {
253            paint: self.paint.unwrap_or(default.paint),
254            thickness,
255            cap: self.cap.unwrap_or(default.cap),
256            join: self.join.unwrap_or(default.join),
257            dash,
258            miter_limit: self.miter_limit.unwrap_or(default.miter_limit),
259        }
260    }
261
262    /// Unpack the stroke, filling missing fields with the default values.
263    pub fn unwrap_or_default(self) -> FixedStroke {
264        // we want to do this; the Clippy lint is not type-aware
265        #[allow(clippy::unwrap_or_default)]
266        self.unwrap_or(FixedStroke::default())
267    }
268}
269
270impl<T: Numeric + Repr> Repr for Stroke<T> {
271    fn repr(&self) -> EcoString {
272        let mut r = EcoString::new();
273        let Self { paint, thickness, cap, join, dash, miter_limit } = &self;
274        if cap.is_auto() && join.is_auto() && dash.is_auto() && miter_limit.is_auto() {
275            match (&self.paint, &self.thickness) {
276                (Smart::Custom(paint), Smart::Custom(thickness)) => {
277                    r.push_str(&thickness.repr());
278                    r.push_str(" + ");
279                    r.push_str(&paint.repr());
280                }
281                (Smart::Custom(paint), Smart::Auto) => r.push_str(&paint.repr()),
282                (Smart::Auto, Smart::Custom(thickness)) => r.push_str(&thickness.repr()),
283                (Smart::Auto, Smart::Auto) => r.push_str("1pt + black"),
284            }
285        } else {
286            r.push('(');
287            let mut sep = "";
288            if let Smart::Custom(paint) = &paint {
289                r.push_str(sep);
290                r.push_str("paint: ");
291                r.push_str(&paint.repr());
292                sep = ", ";
293            }
294            if let Smart::Custom(thickness) = &thickness {
295                r.push_str(sep);
296                r.push_str("thickness: ");
297                r.push_str(&thickness.repr());
298                sep = ", ";
299            }
300            if let Smart::Custom(cap) = &cap {
301                r.push_str(sep);
302                r.push_str("cap: ");
303                r.push_str(&cap.repr());
304                sep = ", ";
305            }
306            if let Smart::Custom(join) = &join {
307                r.push_str(sep);
308                r.push_str("join: ");
309                r.push_str(&join.repr());
310                sep = ", ";
311            }
312            if let Smart::Custom(dash) = &dash {
313                r.push_str(sep);
314                r.push_str("dash: ");
315                if let Some(dash) = dash {
316                    r.push_str(&dash.repr());
317                } else {
318                    r.push_str(&NoneValue.repr());
319                }
320                sep = ", ";
321            }
322            if let Smart::Custom(miter_limit) = &miter_limit {
323                r.push_str(sep);
324                r.push_str("miter-limit: ");
325                r.push_str(&miter_limit.get().repr());
326            }
327            r.push(')');
328        }
329        r
330    }
331}
332
333impl<T: Numeric + Fold> Fold for Stroke<T> {
334    fn fold(self, outer: Self) -> Self {
335        Self {
336            paint: self.paint.or(outer.paint),
337            thickness: self.thickness.or(outer.thickness),
338            cap: self.cap.or(outer.cap),
339            join: self.join.or(outer.join),
340            dash: self.dash.or(outer.dash),
341            miter_limit: self.miter_limit.or(outer.miter_limit),
342        }
343    }
344}
345
346impl Resolve for Stroke {
347    type Output = Stroke<Abs>;
348
349    fn resolve(self, styles: StyleChain) -> Self::Output {
350        Stroke {
351            paint: self.paint,
352            thickness: self.thickness.resolve(styles),
353            cap: self.cap,
354            join: self.join,
355            dash: self.dash.resolve(styles),
356            miter_limit: self.miter_limit,
357        }
358    }
359}
360
361cast! {
362    type Stroke,
363    thickness: Length => Self {
364        thickness: Smart::Custom(thickness),
365        ..Default::default()
366    },
367    color: Color => Self {
368        paint: Smart::Custom(color.into()),
369        ..Default::default()
370    },
371    gradient: Gradient => Self {
372        paint: Smart::Custom(gradient.into()),
373        ..Default::default()
374    },
375    tiling: Tiling => Self {
376        paint: Smart::Custom(tiling.into()),
377        ..Default::default()
378    },
379    mut dict: Dict => {
380        // Get a value by key, accepting either Auto or something convertible to type T.
381        fn take<T: FromValue>(dict: &mut Dict, key: &str) -> HintedStrResult<Smart<T>> {
382            Ok(dict.take(key).ok().map(Smart::<T>::from_value)
383                .transpose()?.unwrap_or(Smart::Auto))
384        }
385
386        let paint = take::<Paint>(&mut dict, "paint")?;
387        let thickness = take::<Length>(&mut dict, "thickness")?;
388        let cap = take::<LineCap>(&mut dict, "cap")?;
389        let join = take::<LineJoin>(&mut dict, "join")?;
390        let dash = take::<Option<DashPattern>>(&mut dict, "dash")?;
391        let miter_limit = take::<f64>(&mut dict, "miter-limit")?;
392        dict.finish(&["paint", "thickness", "cap", "join", "dash", "miter-limit"])?;
393
394        Self {
395            paint,
396            thickness,
397            cap,
398            join,
399            dash,
400            miter_limit: miter_limit.map(Scalar::new),
401        }
402    },
403}
404
405cast! {
406    Stroke<Abs>,
407    self => self.map(Length::from).into_value(),
408}
409
410/// The line cap of a stroke
411#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
412pub enum LineCap {
413    /// Square stroke cap with the edge at the stroke's end point.
414    Butt,
415    /// Circular stroke cap centered at the stroke's end point.
416    Round,
417    /// Square stroke cap centered at the stroke's end point.
418    Square,
419}
420
421impl Repr for LineCap {
422    fn repr(&self) -> EcoString {
423        match self {
424            Self::Butt => "butt".repr(),
425            Self::Round => "round".repr(),
426            Self::Square => "square".repr(),
427        }
428    }
429}
430
431/// The line join of a stroke
432#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
433pub enum LineJoin {
434    /// Segments are joined with sharp edges. Sharp bends exceeding the miter
435    /// limit are bevelled instead.
436    Miter,
437    /// Segments are joined with circular corners.
438    Round,
439    /// Segments are joined with a bevel (a straight edge connecting the butts
440    /// of the joined segments).
441    Bevel,
442}
443
444impl Repr for LineJoin {
445    fn repr(&self) -> EcoString {
446        match self {
447            Self::Miter => "miter".repr(),
448            Self::Round => "round".repr(),
449            Self::Bevel => "bevel".repr(),
450        }
451    }
452}
453
454/// A line dash pattern.
455#[derive(Debug, Clone, Eq, PartialEq, Hash)]
456pub struct DashPattern<T: Numeric = Length, DT = DashLength<T>> {
457    /// The dash array.
458    pub array: Vec<DT>,
459    /// The dash phase.
460    pub phase: T,
461}
462
463impl<T: Numeric + Repr, DT: Repr> Repr for DashPattern<T, DT> {
464    fn repr(&self) -> EcoString {
465        let mut r = EcoString::from("(array: (");
466        for (i, elem) in self.array.iter().enumerate() {
467            if i != 0 {
468                r.push_str(", ")
469            }
470            r.push_str(&elem.repr())
471        }
472        r.push_str("), phase: ");
473        r.push_str(&self.phase.repr());
474        r.push(')');
475        r
476    }
477}
478
479impl<T: Numeric + Default> From<Vec<DashLength<T>>> for DashPattern<T> {
480    fn from(array: Vec<DashLength<T>>) -> Self {
481        Self { array, phase: T::default() }
482    }
483}
484
485impl Resolve for DashPattern {
486    type Output = DashPattern<Abs>;
487
488    fn resolve(self, styles: StyleChain) -> Self::Output {
489        DashPattern {
490            array: self.array.into_iter().map(|l| l.resolve(styles)).collect(),
491            phase: self.phase.resolve(styles),
492        }
493    }
494}
495
496// Same names as tikz:
497// https://tex.stackexchange.com/questions/45275/tikz-get-values-for-predefined-dash-patterns
498cast! {
499    DashPattern,
500    self => dict! { "array" => self.array, "phase" => self.phase }.into_value(),
501
502    "solid" => Vec::new().into(),
503    "dotted" => vec![DashLength::LineWidth, Abs::pt(2.0).into()].into(),
504    "densely-dotted" => vec![DashLength::LineWidth, Abs::pt(1.0).into()].into(),
505    "loosely-dotted" => vec![DashLength::LineWidth, Abs::pt(4.0).into()].into(),
506    "dashed" => vec![Abs::pt(3.0).into(), Abs::pt(3.0).into()].into(),
507    "densely-dashed" => vec![Abs::pt(3.0).into(), Abs::pt(2.0).into()].into(),
508    "loosely-dashed" => vec![Abs::pt(3.0).into(), Abs::pt(6.0).into()].into(),
509    "dash-dotted" => vec![Abs::pt(3.0).into(), Abs::pt(2.0).into(), DashLength::LineWidth, Abs::pt(2.0).into()].into(),
510    "densely-dash-dotted" => vec![Abs::pt(3.0).into(), Abs::pt(1.0).into(), DashLength::LineWidth, Abs::pt(1.0).into()].into(),
511    "loosely-dash-dotted" => vec![Abs::pt(3.0).into(), Abs::pt(4.0).into(), DashLength::LineWidth, Abs::pt(4.0).into()].into(),
512
513    array: Vec<DashLength> => Self { array, phase: Length::zero() },
514    mut dict: Dict => {
515        let array: Vec<DashLength> = dict.take("array")?.cast()?;
516        let phase = dict.take("phase").ok().map(Value::cast)
517            .transpose()?.unwrap_or(Length::zero());
518        dict.finish(&["array", "phase"])?;
519        Self {
520            array,
521            phase,
522        }
523    },
524}
525
526/// The length of a dash in a line dash pattern.
527#[derive(Debug, Clone, Eq, PartialEq, Hash)]
528pub enum DashLength<T: Numeric = Length> {
529    LineWidth,
530    Length(T),
531}
532
533impl<T: Numeric> DashLength<T> {
534    fn finish(self, line_width: T) -> T {
535        match self {
536            Self::LineWidth => line_width,
537            Self::Length(l) => l,
538        }
539    }
540}
541
542impl<T: Numeric + Repr> Repr for DashLength<T> {
543    fn repr(&self) -> EcoString {
544        match self {
545            Self::LineWidth => "dot".repr(),
546            Self::Length(v) => v.repr(),
547        }
548    }
549}
550
551impl Resolve for DashLength {
552    type Output = DashLength<Abs>;
553
554    fn resolve(self, styles: StyleChain) -> Self::Output {
555        match self {
556            Self::LineWidth => DashLength::LineWidth,
557            Self::Length(v) => DashLength::Length(v.resolve(styles)),
558        }
559    }
560}
561
562impl From<Abs> for DashLength {
563    fn from(l: Abs) -> Self {
564        DashLength::Length(l.into())
565    }
566}
567
568cast! {
569    DashLength,
570    self => match self {
571        Self::LineWidth => "dot".into_value(),
572        Self::Length(v) => v.into_value(),
573    },
574    "dot" => Self::LineWidth,
575    v: Length => Self::Length(v),
576}
577
578/// A fully specified stroke of a geometric shape.
579#[derive(Debug, Clone, Eq, PartialEq, Hash)]
580pub struct FixedStroke {
581    /// The stroke's paint.
582    pub paint: Paint,
583    /// The stroke's thickness.
584    pub thickness: Abs,
585    /// The stroke's line cap.
586    pub cap: LineCap,
587    /// The stroke's line join.
588    pub join: LineJoin,
589    /// The stroke's line dash pattern.
590    pub dash: Option<DashPattern<Abs, Abs>>,
591    /// The miter limit. Defaults to 4.0, same as `tiny-skia`.
592    pub miter_limit: Scalar,
593}
594
595impl FixedStroke {
596    /// Create a stroke from a paint and a thickness.
597    pub fn from_pair(paint: impl Into<Paint>, thickness: Abs) -> Self {
598        Self {
599            paint: paint.into(),
600            thickness,
601            ..Default::default()
602        }
603    }
604}
605
606impl Default for FixedStroke {
607    fn default() -> Self {
608        Self {
609            paint: Paint::Solid(Color::BLACK),
610            thickness: Abs::pt(1.0),
611            cap: LineCap::Butt,
612            join: LineJoin::Miter,
613            dash: None,
614            miter_limit: Scalar::new(4.0),
615        }
616    }
617}