summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorBirk Tjelmeland <git@birktj.no>2023-04-13 16:05:56 +0200
committerGitHub <noreply@github.com>2023-04-13 16:05:56 +0200
commitd1cd814ef8149cbac6e59c81e074aa59c930eed3 (patch)
tree02b9a8afed4d121b34d89669452f91cda19df8e2 /src
parent46ce9c94e3f615751989d3cba5aa1599e0ba5913 (diff)
Add support for more complex strokes (#505)
Diffstat (limited to 'src')
-rw-r--r--src/doc.rs2
-rw-r--r--src/eval/ops.rs1
-rw-r--r--src/export/pdf/page.rs60
-rw-r--r--src/export/render.rs54
-rw-r--r--src/geom/sides.rs34
-rw-r--r--src/geom/stroke.rs285
6 files changed, 408 insertions, 28 deletions
diff --git a/src/doc.rs b/src/doc.rs
index 76d46f4a..4f0428fb 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 948243d1..43ee6ceb 100644
--- a/src/eval/ops.rs
+++ b/src/eval/ops.rs
@@ -108,6 +108,7 @@ pub fn add(lhs: Value, rhs: Value) -> StrResult<Value> {
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 636d42c7..d6ead124 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 8cee3aa6..f3c72ba0 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<Color> 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 25b1fab5..d9a020da 100644
--- a/src/geom/sides.rs
+++ b/src/geom/sides.rs
@@ -188,22 +188,30 @@ where
fn cast(mut value: Value) -> StrResult<Self> {
if let Value::Dict(dict) = &mut value {
- 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 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()),
+ };
+
+ 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 {
<Self as Cast>::error(value)
diff --git a/src/geom/stroke.rs b/src/geom/stroke.rs
index 4dba06d9..344da3c5 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<DashPattern<Abs, Abs>>,
+ /// 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<T = Length> {
pub paint: Smart<Paint>,
/// The stroke's thickness.
pub thickness: Smart<T>,
+ /// The stroke's line cap.
+ pub line_cap: Smart<LineCap>,
+ /// The stroke's line join.
+ pub line_join: Smart<LineJoin>,
+ /// The stroke's line dash pattern.
+ pub dash_pattern: Smart<Option<DashPattern<T>>>,
+ /// The miter limit.
+ pub miter_limit: Smart<Scalar>,
}
impl PartialStroke<Abs> {
/// 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<Abs> {
impl<T: Debug> Debug for PartialStroke<T> {
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("<stroke>"),
+ }
+ } 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<T = Length, DT = DashLength<T>> {
+ /// The dash array.
+ pub array: Vec<DT>,
+ /// The dash phase.
+ pub phase: T,
+}
+
+impl<T: Debug, DT: Debug> Debug for DashPattern<T, DT> {
+ 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)?;
}
- (Smart::Custom(paint), Smart::Auto) => paint.fmt(f),
- (Smart::Auto, Smart::Custom(thickness)) => thickness.fmt(f),
- (Smart::Auto, Smart::Auto) => f.pad("<stroke>"),
+ }
+ write!(f, "), phase: {:?})", self.phase)?;
+ Ok(())
+ }
+}
+
+impl<T: Default> From<Vec<DashLength<T>>> for DashPattern<T> {
+ fn from(array: Vec<DashLength<T>>) -> 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<T = Length> {
+ LineWidth,
+ Length(T),
+}
+
+impl From<Abs> for DashLength {
+ fn from(l: Abs) -> Self {
+ DashLength::Length(l.into())
+ }
+}
+
+impl<T> DashLength<T> {
+ 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<Abs>;
+
+ 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<DashLength> => {
+ Self {
+ array,
+ phase: Length::zero(),
+ }
+ },
+ mut dict: Dict => {
+ let array: Vec<DashLength> = 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<Abs>;
+
+ 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<T: Cast<Value>>(dict: &mut Dict, key: &str) -> StrResult<Smart<T>> {
+ Ok(dict.take(key).ok().map(T::cast)
+ .transpose()?.map(Smart::Custom).unwrap_or(Smart::Auto))
+ }
+
+ let paint = take::<Paint>(&mut dict, "color")?;
+ let thickness = take::<Length>(&mut dict, "thickness")?;
+ let line_cap = take::<LineCap>(&mut dict, "cap")?;
+ let line_join = take::<LineJoin>(&mut dict, "join")?;
+ let dash_pattern = take::<Option<DashPattern>>(&mut dict, "dash")?;
+ let miter_limit = take::<f64>(&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<Abs> {
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,
}
}
}