From d1cd814ef8149cbac6e59c81e074aa59c930eed3 Mon Sep 17 00:00:00 2001 From: Birk Tjelmeland Date: Thu, 13 Apr 2023 16:05:56 +0200 Subject: [PATCH] Add support for more complex strokes (#505) --- library/src/math/frac.rs | 1 + library/src/math/root.rs | 7 +- library/src/text/deco.rs | 1 + library/src/visualize/line.rs | 25 ++- library/src/visualize/shape.rs | 18 +- src/doc.rs | 2 + src/eval/ops.rs | 1 + src/export/pdf/page.rs | 60 +++++- src/export/render.rs | 54 +++++- src/geom/sides.rs | 32 ++-- src/geom/stroke.rs | 285 ++++++++++++++++++++++++++++- tests/ref/visualize/stroke.png | Bin 0 -> 14835 bytes tests/typ/visualize/shape-rect.typ | 2 +- tests/typ/visualize/stroke.typ | 71 +++++++ 14 files changed, 526 insertions(+), 33 deletions(-) create mode 100644 tests/ref/visualize/stroke.png create mode 100644 tests/typ/visualize/stroke.typ diff --git a/library/src/math/frac.rs b/library/src/math/frac.rs index 1712a1fc8..7520e10d1 100644 --- a/library/src/math/frac.rs +++ b/library/src/math/frac.rs @@ -135,6 +135,7 @@ fn layout( Geometry::Line(Point::with_x(line_width)).stroked(Stroke { paint: TextElem::fill_in(ctx.styles()), thickness, + ..Stroke::default() }), span, ), diff --git a/library/src/math/root.rs b/library/src/math/root.rs index 037c6ce76..cc48cd747 100644 --- a/library/src/math/root.rs +++ b/library/src/math/root.rs @@ -121,8 +121,11 @@ fn layout( frame.push( line_pos, FrameItem::Shape( - Geometry::Line(Point::with_x(radicand.width())) - .stroked(Stroke { paint: TextElem::fill_in(ctx.styles()), thickness }), + Geometry::Line(Point::with_x(radicand.width())).stroked(Stroke { + paint: TextElem::fill_in(ctx.styles()), + thickness, + ..Stroke::default() + }), span, ), ); diff --git a/library/src/text/deco.rs b/library/src/text/deco.rs index 79917641c..ab89e6b53 100644 --- a/library/src/text/deco.rs +++ b/library/src/text/deco.rs @@ -271,6 +271,7 @@ pub(super) fn decorate( let stroke = deco.stroke.clone().unwrap_or(Stroke { paint: text.fill.clone(), thickness: metrics.thickness.at(text.size), + ..Stroke::default() }); let gap_padding = 0.08 * text.size; diff --git a/library/src/visualize/line.rs b/library/src/visualize/line.rs index 0932a9f17..362f1a891 100644 --- a/library/src/visualize/line.rs +++ b/library/src/visualize/line.rs @@ -40,9 +40,32 @@ pub struct LineElem { /// to `{1pt}`. /// - A stroke combined from color and thickness using the `+` operator as /// in `{2pt + red}`. + /// - A stroke described by a dictionary with any of the following keys: + /// - `color`: the color to use for the stroke + /// - `thickness`: the stroke's thickness + /// - `cap`: one of `"butt"`, `"round"` or `"square"`, the line cap of the stroke + /// - `join`: one of `"miter"`, `"round"` or `"bevel"`, the line join of the stroke + /// - `miter-limit`: the miter limit to use if `join` is `"miter"`, defaults to 4.0 + /// - `dash`: the dash pattern to use. Can be any of the following: + /// - One of the strings `"solid"`, `"dotted"`, `"densely-dotted"`, `"loosely-dotted"`, + /// `"dashed"`, `"densely-dashed"`, `"loosely-dashed"`, `"dashdotted"`, + /// `"densely-dashdotted"` or `"loosely-dashdotted"` + /// - An array with elements that specify the lengths of dashes and gaps, alternating. + /// Elements can also be the string `"dot"` for a length equal to the line thickness. + /// - A dict with the keys `array`, same as the array above, and `phase`, the offset to + /// the start of the first dash. + /// /// /// ```example - /// #line(length: 100%, stroke: 2pt + red) + /// #stack( + /// line(length: 100%, stroke: 2pt + red), + /// v(1em), + /// line(length: 100%, stroke: (color: blue, thickness: 4pt, cap: "round")), + /// v(1em), + /// line(length: 100%, stroke: (color: blue, thickness: 1pt, dash: "dashed")), + /// v(1em), + /// line(length: 100%, stroke: (color: blue, thickness: 1pt, dash: ("dot", 2pt, 4pt, 2pt))), + /// ) /// ``` #[resolve] #[fold] diff --git a/library/src/visualize/shape.rs b/library/src/visualize/shape.rs index 51dbabd80..e0214f035 100644 --- a/library/src/visualize/shape.rs +++ b/library/src/visualize/shape.rs @@ -47,8 +47,22 @@ pub struct RectElem { /// to `{1pt}`. /// - A stroke combined from color and thickness using the `+` operator as /// in `{2pt + red}`. - /// - A dictionary: With a dictionary, the stroke for each side can be set - /// individually. The dictionary can contain the following keys in order + /// - A stroke described by a dictionary with any of the following keys: + /// - `color`: the color to use for the stroke + /// - `thickness`: the stroke's thickness + /// - `cap`: one of `"butt"`, `"round"` or `"square"`, the line cap of the stroke + /// - `join`: one of `"miter"`, `"round"` or `"bevel"`, the line join of the stroke + /// - `miter-limit`: the miter limit to use if `join` is `"miter"`, defaults to 4.0 + /// - `dash`: the dash pattern to use. Can be any of the following: + /// - One of the strings `"solid"`, `"dotted"`, `"densely-dotted"`, `"loosely-dotted"`, + /// `"dashed"`, `"densely-dashed"`, `"loosely-dashed"`, `"dashdotted"`, + /// `"densely-dashdotted"` or `"loosely-dashdotted"` + /// - An array with elements that specify the lengths of dashes and gaps, alternating. + /// Elements can also be the string `"dot"` for a length equal to the line thickness. + /// - A dict with the keys `array`, same as the array above, and `phase`, the offset to + /// the start of the first dash. + /// - Another dictionary describing the stroke for each side inidvidually. + /// The dictionary can contain the following keys in order /// of precedence: /// - `top`: The top stroke. /// - `right`: The right stroke. diff --git a/src/doc.rs b/src/doc.rs index 76d46f4a2..4f0428fb7 100644 --- a/src/doc.rs +++ b/src/doc.rs @@ -359,6 +359,7 @@ impl Frame { Geometry::Line(Point::with_x(self.size.x)).stroked(Stroke { paint: Color::RED.into(), thickness: Abs::pt(1.0), + ..Stroke::default() }), Span::detached(), ), @@ -386,6 +387,7 @@ impl Frame { Geometry::Line(Point::with_x(self.size.x)).stroked(Stroke { paint: Color::GREEN.into(), thickness: Abs::pt(1.0), + ..Stroke::default() }), Span::detached(), ), diff --git a/src/eval/ops.rs b/src/eval/ops.rs index 948243d12..43ee6ceb6 100644 --- a/src/eval/ops.rs +++ b/src/eval/ops.rs @@ -108,6 +108,7 @@ pub fn add(lhs: Value, rhs: Value) -> StrResult { Value::dynamic(PartialStroke { paint: Smart::Custom(color.into()), thickness: Smart::Custom(thickness), + ..PartialStroke::default() }) } diff --git a/src/export/pdf/page.rs b/src/export/pdf/page.rs index 636d42c77..d6ead124d 100644 --- a/src/export/pdf/page.rs +++ b/src/export/pdf/page.rs @@ -1,5 +1,7 @@ use ecow::eco_format; -use pdf_writer::types::{ActionType, AnnotationType, ColorSpaceOperand}; +use pdf_writer::types::{ + ActionType, AnnotationType, ColorSpaceOperand, LineCapStyle, LineJoinStyle, +}; use pdf_writer::writers::ColorSpace; use pdf_writer::{Content, Filter, Finish, Name, Rect, Ref, Str}; @@ -7,8 +9,8 @@ use super::{deflate, AbsExt, EmExt, PdfContext, RefExt, D65_GRAY, SRGB}; use crate::doc::{Destination, Frame, FrameItem, GroupItem, Meta, TextItem}; use crate::font::Font; use crate::geom::{ - self, Abs, Color, Em, Geometry, Numeric, Paint, Point, Ratio, Shape, Size, Stroke, - Transform, + self, Abs, Color, Em, Geometry, LineCap, LineJoin, Numeric, Paint, Point, Ratio, + Shape, Size, Stroke, Transform, }; use crate::image::Image; @@ -250,8 +252,17 @@ impl PageContext<'_, '_> { fn set_stroke(&mut self, stroke: &Stroke) { if self.state.stroke.as_ref() != Some(stroke) { + let Stroke { + paint, + thickness, + line_cap, + line_join, + dash_pattern, + miter_limit, + } = stroke; + let f = |c| c as f32 / 255.0; - let Paint::Solid(color) = stroke.paint; + let Paint::Solid(color) = paint; match color { Color::Luma(c) => { self.set_stroke_color_space(D65_GRAY); @@ -267,7 +278,26 @@ impl PageContext<'_, '_> { } } - self.content.set_line_width(stroke.thickness.to_f32()); + self.content.set_line_width(thickness.to_f32()); + if self.state.stroke.as_ref().map(|s| &s.line_cap) != Some(line_cap) { + self.content.set_line_cap(line_cap.into()); + } + if self.state.stroke.as_ref().map(|s| &s.line_join) != Some(line_join) { + self.content.set_line_join(line_join.into()); + } + if self.state.stroke.as_ref().map(|s| &s.dash_pattern) != Some(dash_pattern) { + if let Some(pattern) = dash_pattern { + self.content.set_dash_pattern( + pattern.array.iter().map(|l| l.to_f32()), + pattern.phase.to_f32(), + ); + } else { + self.content.set_dash_pattern([], 0.0); + } + } + if self.state.stroke.as_ref().map(|s| &s.miter_limit) != Some(miter_limit) { + self.content.set_miter_limit(miter_limit.0 as f32); + } self.state.stroke = Some(stroke.clone()); } } @@ -486,3 +516,23 @@ fn write_link(ctx: &mut PageContext, pos: Point, dest: &Destination, size: Size) ctx.links.push((dest.clone(), rect)); } + +impl From<&LineCap> for LineCapStyle { + fn from(line_cap: &LineCap) -> Self { + match line_cap { + LineCap::Butt => LineCapStyle::ButtCap, + LineCap::Round => LineCapStyle::RoundCap, + LineCap::Square => LineCapStyle::ProjectingSquareCap, + } + } +} + +impl From<&LineJoin> for LineJoinStyle { + fn from(line_join: &LineJoin) -> Self { + match line_join { + LineJoin::Miter => LineJoinStyle::MiterJoin, + LineJoin::Round => LineJoinStyle::RoundJoin, + LineJoin::Bevel => LineJoinStyle::BevelJoin, + } + } +} diff --git a/src/export/render.rs b/src/export/render.rs index 8cee3aa65..f3c72ba0f 100644 --- a/src/export/render.rs +++ b/src/export/render.rs @@ -11,7 +11,8 @@ use usvg::{FitTo, NodeExt}; use crate::doc::{Frame, FrameItem, GroupItem, Meta, TextItem}; use crate::geom::{ - self, Abs, Color, Geometry, Paint, PathItem, Shape, Size, Stroke, Transform, + self, Abs, Color, Geometry, LineCap, LineJoin, Paint, PathItem, Shape, Size, Stroke, + Transform, }; use crate::image::{DecodedImage, Image}; @@ -392,9 +393,36 @@ fn render_shape( canvas.fill_path(&path, &paint, rule, ts, mask); } - if let Some(Stroke { paint, thickness }) = &shape.stroke { + if let Some(Stroke { + paint, + thickness, + line_cap, + line_join, + dash_pattern, + miter_limit, + }) = &shape.stroke + { + let dash = dash_pattern.as_ref().and_then(|pattern| { + // tiny-skia only allows dash patterns with an even number of elements, + // while pdf allows any number. + let len = if pattern.array.len() % 2 == 1 { + pattern.array.len() * 2 + } else { + pattern.array.len() + }; + let dash_array = + pattern.array.iter().map(|l| l.to_f32()).cycle().take(len).collect(); + + sk::StrokeDash::new(dash_array, pattern.phase.to_f32()) + }); let paint = paint.into(); - let stroke = sk::Stroke { width: thickness.to_f32(), ..Default::default() }; + let stroke = sk::Stroke { + width: thickness.to_f32(), + line_cap: line_cap.into(), + line_join: line_join.into(), + dash, + miter_limit: miter_limit.0 as f32, + }; canvas.stroke_path(&path, &paint, &stroke, ts, mask); } @@ -525,6 +553,26 @@ impl From for sk::Color { } } +impl From<&LineCap> for sk::LineCap { + fn from(line_cap: &LineCap) -> Self { + match line_cap { + LineCap::Butt => sk::LineCap::Butt, + LineCap::Round => sk::LineCap::Round, + LineCap::Square => sk::LineCap::Square, + } + } +} + +impl From<&LineJoin> for sk::LineJoin { + fn from(line_join: &LineJoin) -> Self { + match line_join { + LineJoin::Miter => sk::LineJoin::Miter, + LineJoin::Round => sk::LineJoin::Round, + LineJoin::Bevel => sk::LineJoin::Bevel, + } + } +} + /// Allows to build tiny-skia paths from glyph outlines. struct WrappedPathBuilder(sk::PathBuilder); diff --git a/src/geom/sides.rs b/src/geom/sides.rs index 25b1fab50..d9a020da8 100644 --- a/src/geom/sides.rs +++ b/src/geom/sides.rs @@ -188,22 +188,30 @@ where fn cast(mut value: Value) -> StrResult { if let Value::Dict(dict) = &mut value { - let mut take = |key| dict.take(key).ok().map(T::cast).transpose(); + let mut try_cast = || -> StrResult<_> { + let mut take = |key| dict.take(key).ok().map(T::cast).transpose(); - let rest = take("rest")?; - let x = take("x")?.or_else(|| rest.clone()); - let y = take("y")?.or_else(|| rest.clone()); - let sides = Sides { - left: take("left")?.or_else(|| x.clone()), - top: take("top")?.or_else(|| y.clone()), - right: take("right")?.or_else(|| x.clone()), - bottom: take("bottom")?.or_else(|| y.clone()), + let rest = take("rest")?; + let x = take("x")?.or_else(|| rest.clone()); + let y = take("y")?.or_else(|| rest.clone()); + let sides = Sides { + left: take("left")?.or_else(|| x.clone()), + top: take("top")?.or_else(|| y.clone()), + right: take("right")?.or_else(|| x.clone()), + bottom: take("bottom")?.or_else(|| y.clone()), + }; + + dict.finish(&["left", "top", "right", "bottom", "x", "y", "rest"])?; + + Ok(sides) }; - dict.finish(&["left", "top", "right", "bottom", "x", "y", "rest"])?; + if let Ok(res) = try_cast() { + return Ok(res); + } + } - Ok(sides) - } else if T::is(&value) { + if T::is(&value) { Ok(Self::splat(Some(T::cast(value)?))) } else { ::error(value) diff --git a/src/geom/stroke.rs b/src/geom/stroke.rs index 4dba06d9c..344da3c53 100644 --- a/src/geom/stroke.rs +++ b/src/geom/stroke.rs @@ -7,6 +7,14 @@ pub struct Stroke { pub paint: Paint, /// The stroke's thickness. pub thickness: Abs, + /// The stroke's line cap. + pub line_cap: LineCap, + /// The stroke's line join. + pub line_join: LineJoin, + /// The stroke's line dash pattern. + pub dash_pattern: Option>, + /// The miter limit. Defaults to 4.0, same as `tiny-skia`. + pub miter_limit: Scalar, } impl Default for Stroke { @@ -14,6 +22,10 @@ impl Default for Stroke { Self { paint: Paint::Solid(Color::BLACK), thickness: Abs::pt(1.0), + line_cap: LineCap::Butt, + line_join: LineJoin::Miter, + dash_pattern: None, + miter_limit: Scalar(4.0), } } } @@ -29,14 +41,41 @@ pub struct PartialStroke { pub paint: Smart, /// The stroke's thickness. pub thickness: Smart, + /// The stroke's line cap. + pub line_cap: Smart, + /// The stroke's line join. + pub line_join: Smart, + /// The stroke's line dash pattern. + pub dash_pattern: Smart>>, + /// The miter limit. + pub miter_limit: Smart, } impl PartialStroke { /// Unpack the stroke, filling missing fields from the `default`. pub fn unwrap_or(self, default: Stroke) -> Stroke { + let thickness = self.thickness.unwrap_or(default.thickness); + let dash_pattern = self + .dash_pattern + .map(|pattern| { + pattern.map(|pattern| DashPattern { + array: pattern + .array + .into_iter() + .map(|l| l.finish(thickness)) + .collect(), + phase: pattern.phase, + }) + }) + .unwrap_or(default.dash_pattern); + Stroke { paint: self.paint.unwrap_or(default.paint), - thickness: self.thickness.unwrap_or(default.thickness), + thickness, + line_cap: self.line_cap.unwrap_or(default.line_cap), + line_join: self.line_join.unwrap_or(default.line_join), + dash_pattern, + miter_limit: self.miter_limit.unwrap_or(default.miter_limit), } } @@ -48,13 +87,205 @@ impl PartialStroke { impl Debug for PartialStroke { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - match (&self.paint, &self.thickness) { - (Smart::Custom(paint), Smart::Custom(thickness)) => { - write!(f, "{thickness:?} + {paint:?}") + let Self { + paint, + thickness, + line_cap, + line_join, + dash_pattern, + miter_limit, + } = &self; + if line_cap.is_auto() + && line_join.is_auto() + && dash_pattern.is_auto() + && miter_limit.is_auto() + { + match (&self.paint, &self.thickness) { + (Smart::Custom(paint), Smart::Custom(thickness)) => { + write!(f, "{thickness:?} + {paint:?}") + } + (Smart::Custom(paint), Smart::Auto) => paint.fmt(f), + (Smart::Auto, Smart::Custom(thickness)) => thickness.fmt(f), + (Smart::Auto, Smart::Auto) => f.pad(""), } - (Smart::Custom(paint), Smart::Auto) => paint.fmt(f), - (Smart::Auto, Smart::Custom(thickness)) => thickness.fmt(f), - (Smart::Auto, Smart::Auto) => f.pad(""), + } else { + write!(f, "(")?; + let mut sep = ""; + if let Smart::Custom(paint) = &paint { + write!(f, "{}color: {:?}", sep, paint)?; + sep = ", "; + } + if let Smart::Custom(thickness) = &thickness { + write!(f, "{}thickness: {:?}", sep, thickness)?; + sep = ", "; + } + if let Smart::Custom(cap) = &line_cap { + write!(f, "{}cap: {:?}", sep, cap)?; + sep = ", "; + } + if let Smart::Custom(join) = &line_join { + write!(f, "{}join: {:?}", sep, join)?; + sep = ", "; + } + if let Smart::Custom(dash) = &dash_pattern { + write!(f, "{}dash: {:?}", sep, dash)?; + sep = ", "; + } + if let Smart::Custom(miter_limit) = &miter_limit { + write!(f, "{}miter-limit: {:?}", sep, miter_limit)?; + } + write!(f, ")")?; + Ok(()) + } + } +} + +/// The line cap of a stroke +#[derive(Cast, Clone, Eq, PartialEq, Hash)] +pub enum LineCap { + Butt, + Round, + Square, +} + +impl Debug for LineCap { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + LineCap::Butt => write!(f, "\"butt\""), + LineCap::Round => write!(f, "\"round\""), + LineCap::Square => write!(f, "\"square\""), + } + } +} + +/// The line join of a stroke +#[derive(Cast, Clone, Eq, PartialEq, Hash)] +pub enum LineJoin { + Miter, + Round, + Bevel, +} + +impl Debug for LineJoin { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + LineJoin::Miter => write!(f, "\"miter\""), + LineJoin::Round => write!(f, "\"round\""), + LineJoin::Bevel => write!(f, "\"bevel\""), + } + } +} + +/// A line dash pattern +#[derive(Clone, Eq, PartialEq, Hash)] +pub struct DashPattern> { + /// The dash array. + pub array: Vec
, + /// The dash phase. + pub phase: T, +} + +impl Debug for DashPattern { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "(array: (")?; + for (i, elem) in self.array.iter().enumerate() { + if i == 0 { + write!(f, "{:?}", elem)?; + } else { + write!(f, ", {:?}", elem)?; + } + } + write!(f, "), phase: {:?})", self.phase)?; + Ok(()) + } +} + +impl From>> for DashPattern { + fn from(array: Vec>) -> Self { + Self { array, phase: T::default() } + } +} + +/// The length of a dash in a line dash pattern +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub enum DashLength { + LineWidth, + Length(T), +} + +impl From for DashLength { + fn from(l: Abs) -> Self { + DashLength::Length(l.into()) + } +} + +impl DashLength { + fn finish(self, line_width: T) -> T { + match self { + Self::LineWidth => line_width, + Self::Length(l) => l, + } + } +} + +cast_from_value! { + DashLength: "dash length", + "dot" => Self::LineWidth, + l: Length => Self::Length(l), +} + +impl Resolve for DashLength { + type Output = DashLength; + + fn resolve(self, styles: StyleChain) -> Self::Output { + match self { + Self::LineWidth => DashLength::LineWidth, + Self::Length(l) => DashLength::Length(l.resolve(styles)), + } + } +} + +cast_from_value! { + DashPattern: "dash pattern", + // Use same names as tikz: + // https://tex.stackexchange.com/questions/45275/tikz-get-values-for-predefined-dash-patterns + "solid" => Vec::new().into(), + "dotted" => vec![DashLength::LineWidth, Abs::pt(2.0).into()].into(), + "densely-dotted" => vec![DashLength::LineWidth, Abs::pt(1.0).into()].into(), + "loosely-dotted" => vec![DashLength::LineWidth, Abs::pt(4.0).into()].into(), + "dashed" => vec![Abs::pt(3.0).into(), Abs::pt(3.0).into()].into(), + "densely-dashed" => vec![Abs::pt(3.0).into(), Abs::pt(2.0).into()].into(), + "loosely-dashed" => vec![Abs::pt(3.0).into(), Abs::pt(6.0).into()].into(), + "dashdotted" => vec![Abs::pt(3.0).into(), Abs::pt(2.0).into(), DashLength::LineWidth, Abs::pt(2.0).into()].into(), + "densely-dashdotted" => vec![Abs::pt(3.0).into(), Abs::pt(1.0).into(), DashLength::LineWidth, Abs::pt(1.0).into()].into(), + "loosely-dashdotted" => vec![Abs::pt(3.0).into(), Abs::pt(4.0).into(), DashLength::LineWidth, Abs::pt(4.0).into()].into(), + array: Vec => { + Self { + array, + phase: Length::zero(), + } + }, + mut dict: Dict => { + let array: Vec = dict.take("array")?.cast()?; + let phase = dict.take("phase").ok().map(Length::cast) + .transpose()?.unwrap_or(Length::zero()); + + dict.finish(&["array", "phase"])?; + + Self { + array, + phase, + } + }, +} + +impl Resolve for DashPattern { + type Output = DashPattern; + + fn resolve(self, styles: StyleChain) -> Self::Output { + DashPattern { + array: self.array.into_iter().map(|l| l.resolve(styles)).collect(), + phase: self.phase.resolve(styles), } } } @@ -64,10 +295,42 @@ cast_from_value! { thickness: Length => Self { paint: Smart::Auto, thickness: Smart::Custom(thickness), + line_cap: Smart::Auto, + line_join: Smart::Auto, + dash_pattern: Smart::Auto, + miter_limit: Smart::Auto, }, color: Color => Self { paint: Smart::Custom(color.into()), thickness: Smart::Auto, + line_cap: Smart::Auto, + line_join: Smart::Auto, + dash_pattern: Smart::Auto, + miter_limit: Smart::Auto, + }, + mut dict: Dict => { + fn take>(dict: &mut Dict, key: &str) -> StrResult> { + Ok(dict.take(key).ok().map(T::cast) + .transpose()?.map(Smart::Custom).unwrap_or(Smart::Auto)) + } + + let paint = take::(&mut dict, "color")?; + let thickness = take::(&mut dict, "thickness")?; + let line_cap = take::(&mut dict, "cap")?; + let line_join = take::(&mut dict, "join")?; + let dash_pattern = take::>(&mut dict, "dash")?; + let miter_limit = take::(&mut dict, "miter-limit")?; + + dict.finish(&["color", "thickness", "cap", "join", "dash", "miter-limit"])?; + + Self { + paint, + thickness, + line_cap, + line_join, + dash_pattern, + miter_limit: miter_limit.map(Scalar), + } }, } @@ -78,6 +341,10 @@ impl Resolve for PartialStroke { PartialStroke { paint: self.paint, thickness: self.thickness.resolve(styles), + line_cap: self.line_cap, + line_join: self.line_join, + dash_pattern: self.dash_pattern.resolve(styles), + miter_limit: self.miter_limit, } } } @@ -89,6 +356,10 @@ impl Fold for PartialStroke { Self { paint: self.paint.or(outer.paint), thickness: self.thickness.or(outer.thickness), + line_cap: self.line_cap.or(outer.line_cap), + line_join: self.line_join.or(outer.line_join), + dash_pattern: self.dash_pattern.or(outer.dash_pattern), + miter_limit: self.miter_limit, } } } diff --git a/tests/ref/visualize/stroke.png b/tests/ref/visualize/stroke.png new file mode 100644 index 0000000000000000000000000000000000000000..312eabec6cee138becbf888d8cac12d61eb99db4 GIT binary patch literal 14835 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π-*WVq6N^1z?u{W?LFzBJe(aB~ZK?-JMv1fOur}^jU@*>4u*<9| zl|PY-w7(?9F$Ue!eQfTMH7?!ZB5k)M)`jvYC|>^@V?~B3KSsToZ_mEFg#Eo!DPu+F zi2{JuYXI}QjF;9~$Wv%@!B+zM34aV$G$1jRqb2Ucb-G8=c zT%G_rNwxIH07{|-JZFq8(wlo(S{}prnS_u$kLfTUS9^!&bz$kn{vc&Ix{PEm5aPaw zd$B^|w^zI>ZRL;)9Mhh-OJ>@(T6YFwf2IG#Je$_PZEQZgk07cWsvuigOFd1lOi(cq#aw++H?NiAQJ4N0~cJjOcQ3=mEK31 z0|ve-n}XA-M)}GI*Dg3E(g?xTTj zr|^Xzo#;}KJ-)MQrqv32vgDYNju5BsT z$Q28t6z33Di~6#y@J2FoyQ6)o;ryhbtUc#KmiF>!(AN6Le3*QPM4uaj$ST@W$L!}` z<_Y>0?Ijt7?DGkumgh}HJk2XNk3Oq%;7?iVSMYtv6Zwe)#Jgr^0uQODqeHsiR`~Md z+s0up^1x8P8pC_x(z)Ven0P*t-9uN^mPFx=0SfcO2fCOy7mD}fQ)q{z&=;fS+K)V3 zYS0cGYZ!SlR9~lR68Y%hn(FNKt?_5Gxvx=t_*43w_MZ&AyRgyOdo1P)F_*jVh_TvI zPi?nT^2dekf%C6j?3YVUvC=+n4~(Pd32)S@wR5+`%l9(l8Py~2@M)KNj$p?&C3~-9 zcFf+fpQm+y5Dvh{lD97B_>0~y37o038m;aEscLg1If0jCcfZc$W1H_Tnz+-Ip~yw6 z48PjA%_B~}nvqW`ltz1=+k19)`(_W>Oy#}qmqj{KCvmrmXAC2{{p*rVXT4rUc{&wz zpJE=tFlyaic3K1`+F{3%t<0soqNzO;H4{JlV82%HtQCF9G-T*DF23p7LUC5{0flgP z(LPk?4u<+Dp-oG8&iEJ6k!RXVX*2+|2)i@QjRBa(kU5#|eC;{8?s)CP81_f>oARFu zE;+hWebq34BgUql9zdq2TT}KaugSa2k92gKv+CVK3^MldsI@<4|LQ^p9wI$xKwS6U zNVJ*jIQOWocV>|Wx`EjZK9mIOFFM#K0m)|VVo-59Eo*+mC$WQNM@tL&ksn$=J4&J& zqQE5J_;j=l)CDG|#X|g|WQod#0ni~2q+Pp=^V|(j0NRw5!GsQreq}7eq1Sf-j7ggF zvb2<2QnrGIUG||^GrA4DD`;yBFF>msf(6G+_N3u*;jTZ$aJ=9MKam}D`<$1Zc0q18 zE60erd-q?uj6k`sYZ~}iGY}nu1|PBQMmnb73hgJ{Lx7IFa?2qJR1Ek|Ty}5#_m_-t k(f>BWauP(ELrWnBti%ruUGCt&I1z{gdrkDSbsaAMKZdc(asU7T literal 0 HcmV?d00001 diff --git a/tests/typ/visualize/shape-rect.typ b/tests/typ/visualize/shape-rect.typ index a95f2750a..951d5beaa 100644 --- a/tests/typ/visualize/shape-rect.typ +++ b/tests/typ/visualize/shape-rect.typ @@ -51,5 +51,5 @@ #rect(radius: (left: 10pt, cake: 5pt)) --- -// Error: 15-21 expected length, color, stroke, none, dictionary, or auto, found array +// Error: 15-21 expected length, color, dictionary, stroke, none, or auto, found array #rect(stroke: (1, 2)) diff --git a/tests/typ/visualize/stroke.typ b/tests/typ/visualize/stroke.typ new file mode 100644 index 000000000..2443d27a9 --- /dev/null +++ b/tests/typ/visualize/stroke.typ @@ -0,0 +1,71 @@ +// Test lines. + +--- +// Some simple test lines + +#line(length: 60pt, stroke: red) +#v(3pt) +#line(length: 60pt, stroke: 2pt) +#v(3pt) +#line(length: 60pt, stroke: blue + 1.5pt) +#v(3pt) +#line(length: 60pt, stroke: (color: red, thickness: 1pt, dash: "dashed")) +#v(3pt) +#line(length: 60pt, stroke: (color: red, thickness: 4pt, cap: "round")) + +--- +// Set rules with stroke + +#set line(stroke: (color: red, thickness: 1pt, cap: "butt", dash: "dashdotted")) + +#line(length: 60pt) +#v(3pt) +#line(length: 60pt, stroke: blue) +#v(3pt) +#line(length: 60pt, stroke: (dash: none)) + +--- +// Rectangle strokes + +#rect(width: 20pt, height: 20pt, stroke: red) +#v(3pt) +#rect(width: 20pt, height: 20pt, stroke: (rest: red, top: (color: blue, dash: "dashed"))) +#v(3pt) +#rect(width: 20pt, height: 20pt, stroke: (thickness: 5pt, join: "round")) + +--- +// Dashing +#line(length: 60pt, stroke: (color: red, thickness: 1pt, dash: ("dot", 1pt))) +#v(3pt) +#line(length: 60pt, stroke: (color: red, thickness: 1pt, dash: ("dot", 1pt, 4pt, 2pt))) +#v(3pt) +#line(length: 60pt, stroke: (color: red, thickness: 1pt, dash: (array: ("dot", 1pt, 4pt, 2pt), phase: 5pt))) +#v(3pt) +#line(length: 60pt, stroke: (color: red, thickness: 1pt, dash: ())) +#v(3pt) +#line(length: 60pt, stroke: (color: red, thickness: 1pt, dash: (1pt, 3pt, 9pt))) + +--- +// Line joins +#stack(dir: ltr, + polygon(stroke: (thickness: 4pt, color: blue, join: "round"), + (0pt, 20pt), (15pt, 0pt), (0pt, 40pt), (15pt, 45pt)), + h(1em), + polygon(stroke: (thickness: 4pt, color: blue, join: "bevel"), + (0pt, 20pt), (15pt, 0pt), (0pt, 40pt), (15pt, 45pt)), + h(1em), + polygon(stroke: (thickness: 4pt, color: blue, join: "miter"), + (0pt, 20pt), (15pt, 0pt), (0pt, 40pt), (15pt, 45pt)), + h(1em), + polygon(stroke: (thickness: 4pt, color: blue, join: "miter", miter-limit: 20.0), + (0pt, 20pt), (15pt, 0pt), (0pt, 40pt), (15pt, 45pt)), +) +--- + +// Error: 29-56 unexpected key "thicknes", valid keys are "color", "thickness", "cap", "join", "dash", and "miter-limit" +#line(length: 60pt, stroke: (color: red, thicknes: 1pt)) + +--- + +// Error: 29-55 expected "solid", "dotted", "densely-dotted", "loosely-dotted", "dashed", "densely-dashed", "loosely-dashed", "dashdotted", "densely-dashdotted", "loosely-dashdotted", array, dictionary, dash pattern, or none +#line(length: 60pt, stroke: (color: red, dash: "dash"))