Skip to main content

typst_library/visualize/
stroke.rs

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