Skip to content

Commit d1cd814

Browse files
authored
Add support for more complex strokes (typst#505)
1 parent 46ce9c9 commit d1cd814

File tree

14 files changed

+527
-34
lines changed

14 files changed

+527
-34
lines changed

library/src/math/frac.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ fn layout(
135135
Geometry::Line(Point::with_x(line_width)).stroked(Stroke {
136136
paint: TextElem::fill_in(ctx.styles()),
137137
thickness,
138+
..Stroke::default()
138139
}),
139140
span,
140141
),

library/src/math/root.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,11 @@ fn layout(
121121
frame.push(
122122
line_pos,
123123
FrameItem::Shape(
124-
Geometry::Line(Point::with_x(radicand.width()))
125-
.stroked(Stroke { paint: TextElem::fill_in(ctx.styles()), thickness }),
124+
Geometry::Line(Point::with_x(radicand.width())).stroked(Stroke {
125+
paint: TextElem::fill_in(ctx.styles()),
126+
thickness,
127+
..Stroke::default()
128+
}),
126129
span,
127130
),
128131
);

library/src/text/deco.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ pub(super) fn decorate(
271271
let stroke = deco.stroke.clone().unwrap_or(Stroke {
272272
paint: text.fill.clone(),
273273
thickness: metrics.thickness.at(text.size),
274+
..Stroke::default()
274275
});
275276

276277
let gap_padding = 0.08 * text.size;

library/src/visualize/line.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,32 @@ pub struct LineElem {
4040
/// to `{1pt}`.
4141
/// - A stroke combined from color and thickness using the `+` operator as
4242
/// in `{2pt + red}`.
43+
/// - A stroke described by a dictionary with any of the following keys:
44+
/// - `color`: the color to use for the stroke
45+
/// - `thickness`: the stroke's thickness
46+
/// - `cap`: one of `"butt"`, `"round"` or `"square"`, the line cap of the stroke
47+
/// - `join`: one of `"miter"`, `"round"` or `"bevel"`, the line join of the stroke
48+
/// - `miter-limit`: the miter limit to use if `join` is `"miter"`, defaults to 4.0
49+
/// - `dash`: the dash pattern to use. Can be any of the following:
50+
/// - One of the strings `"solid"`, `"dotted"`, `"densely-dotted"`, `"loosely-dotted"`,
51+
/// `"dashed"`, `"densely-dashed"`, `"loosely-dashed"`, `"dashdotted"`,
52+
/// `"densely-dashdotted"` or `"loosely-dashdotted"`
53+
/// - An array with elements that specify the lengths of dashes and gaps, alternating.
54+
/// Elements can also be the string `"dot"` for a length equal to the line thickness.
55+
/// - A dict with the keys `array`, same as the array above, and `phase`, the offset to
56+
/// the start of the first dash.
57+
///
4358
///
4459
/// ```example
45-
/// #line(length: 100%, stroke: 2pt + red)
60+
/// #stack(
61+
/// line(length: 100%, stroke: 2pt + red),
62+
/// v(1em),
63+
/// line(length: 100%, stroke: (color: blue, thickness: 4pt, cap: "round")),
64+
/// v(1em),
65+
/// line(length: 100%, stroke: (color: blue, thickness: 1pt, dash: "dashed")),
66+
/// v(1em),
67+
/// line(length: 100%, stroke: (color: blue, thickness: 1pt, dash: ("dot", 2pt, 4pt, 2pt))),
68+
/// )
4669
/// ```
4770
#[resolve]
4871
#[fold]

library/src/visualize/shape.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,22 @@ pub struct RectElem {
4747
/// to `{1pt}`.
4848
/// - A stroke combined from color and thickness using the `+` operator as
4949
/// in `{2pt + red}`.
50-
/// - A dictionary: With a dictionary, the stroke for each side can be set
51-
/// individually. The dictionary can contain the following keys in order
50+
/// - A stroke described by a dictionary with any of the following keys:
51+
/// - `color`: the color to use for the stroke
52+
/// - `thickness`: the stroke's thickness
53+
/// - `cap`: one of `"butt"`, `"round"` or `"square"`, the line cap of the stroke
54+
/// - `join`: one of `"miter"`, `"round"` or `"bevel"`, the line join of the stroke
55+
/// - `miter-limit`: the miter limit to use if `join` is `"miter"`, defaults to 4.0
56+
/// - `dash`: the dash pattern to use. Can be any of the following:
57+
/// - One of the strings `"solid"`, `"dotted"`, `"densely-dotted"`, `"loosely-dotted"`,
58+
/// `"dashed"`, `"densely-dashed"`, `"loosely-dashed"`, `"dashdotted"`,
59+
/// `"densely-dashdotted"` or `"loosely-dashdotted"`
60+
/// - An array with elements that specify the lengths of dashes and gaps, alternating.
61+
/// Elements can also be the string `"dot"` for a length equal to the line thickness.
62+
/// - A dict with the keys `array`, same as the array above, and `phase`, the offset to
63+
/// the start of the first dash.
64+
/// - Another dictionary describing the stroke for each side inidvidually.
65+
/// The dictionary can contain the following keys in order
5266
/// of precedence:
5367
/// - `top`: The top stroke.
5468
/// - `right`: The right stroke.

src/doc.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ impl Frame {
359359
Geometry::Line(Point::with_x(self.size.x)).stroked(Stroke {
360360
paint: Color::RED.into(),
361361
thickness: Abs::pt(1.0),
362+
..Stroke::default()
362363
}),
363364
Span::detached(),
364365
),
@@ -386,6 +387,7 @@ impl Frame {
386387
Geometry::Line(Point::with_x(self.size.x)).stroked(Stroke {
387388
paint: Color::GREEN.into(),
388389
thickness: Abs::pt(1.0),
390+
..Stroke::default()
389391
}),
390392
Span::detached(),
391393
),

src/eval/ops.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ pub fn add(lhs: Value, rhs: Value) -> StrResult<Value> {
108108
Value::dynamic(PartialStroke {
109109
paint: Smart::Custom(color.into()),
110110
thickness: Smart::Custom(thickness),
111+
..PartialStroke::default()
111112
})
112113
}
113114

src/export/pdf/page.rs

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
use ecow::eco_format;
2-
use pdf_writer::types::{ActionType, AnnotationType, ColorSpaceOperand};
2+
use pdf_writer::types::{
3+
ActionType, AnnotationType, ColorSpaceOperand, LineCapStyle, LineJoinStyle,
4+
};
35
use pdf_writer::writers::ColorSpace;
46
use pdf_writer::{Content, Filter, Finish, Name, Rect, Ref, Str};
57

68
use super::{deflate, AbsExt, EmExt, PdfContext, RefExt, D65_GRAY, SRGB};
79
use crate::doc::{Destination, Frame, FrameItem, GroupItem, Meta, TextItem};
810
use crate::font::Font;
911
use crate::geom::{
10-
self, Abs, Color, Em, Geometry, Numeric, Paint, Point, Ratio, Shape, Size, Stroke,
11-
Transform,
12+
self, Abs, Color, Em, Geometry, LineCap, LineJoin, Numeric, Paint, Point, Ratio,
13+
Shape, Size, Stroke, Transform,
1214
};
1315
use crate::image::Image;
1416

@@ -250,8 +252,17 @@ impl PageContext<'_, '_> {
250252

251253
fn set_stroke(&mut self, stroke: &Stroke) {
252254
if self.state.stroke.as_ref() != Some(stroke) {
255+
let Stroke {
256+
paint,
257+
thickness,
258+
line_cap,
259+
line_join,
260+
dash_pattern,
261+
miter_limit,
262+
} = stroke;
263+
253264
let f = |c| c as f32 / 255.0;
254-
let Paint::Solid(color) = stroke.paint;
265+
let Paint::Solid(color) = paint;
255266
match color {
256267
Color::Luma(c) => {
257268
self.set_stroke_color_space(D65_GRAY);
@@ -267,7 +278,26 @@ impl PageContext<'_, '_> {
267278
}
268279
}
269280

270-
self.content.set_line_width(stroke.thickness.to_f32());
281+
self.content.set_line_width(thickness.to_f32());
282+
if self.state.stroke.as_ref().map(|s| &s.line_cap) != Some(line_cap) {
283+
self.content.set_line_cap(line_cap.into());
284+
}
285+
if self.state.stroke.as_ref().map(|s| &s.line_join) != Some(line_join) {
286+
self.content.set_line_join(line_join.into());
287+
}
288+
if self.state.stroke.as_ref().map(|s| &s.dash_pattern) != Some(dash_pattern) {
289+
if let Some(pattern) = dash_pattern {
290+
self.content.set_dash_pattern(
291+
pattern.array.iter().map(|l| l.to_f32()),
292+
pattern.phase.to_f32(),
293+
);
294+
} else {
295+
self.content.set_dash_pattern([], 0.0);
296+
}
297+
}
298+
if self.state.stroke.as_ref().map(|s| &s.miter_limit) != Some(miter_limit) {
299+
self.content.set_miter_limit(miter_limit.0 as f32);
300+
}
271301
self.state.stroke = Some(stroke.clone());
272302
}
273303
}
@@ -486,3 +516,23 @@ fn write_link(ctx: &mut PageContext, pos: Point, dest: &Destination, size: Size)
486516

487517
ctx.links.push((dest.clone(), rect));
488518
}
519+
520+
impl From<&LineCap> for LineCapStyle {
521+
fn from(line_cap: &LineCap) -> Self {
522+
match line_cap {
523+
LineCap::Butt => LineCapStyle::ButtCap,
524+
LineCap::Round => LineCapStyle::RoundCap,
525+
LineCap::Square => LineCapStyle::ProjectingSquareCap,
526+
}
527+
}
528+
}
529+
530+
impl From<&LineJoin> for LineJoinStyle {
531+
fn from(line_join: &LineJoin) -> Self {
532+
match line_join {
533+
LineJoin::Miter => LineJoinStyle::MiterJoin,
534+
LineJoin::Round => LineJoinStyle::RoundJoin,
535+
LineJoin::Bevel => LineJoinStyle::BevelJoin,
536+
}
537+
}
538+
}

src/export/render.rs

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ use usvg::{FitTo, NodeExt};
1111

1212
use crate::doc::{Frame, FrameItem, GroupItem, Meta, TextItem};
1313
use crate::geom::{
14-
self, Abs, Color, Geometry, Paint, PathItem, Shape, Size, Stroke, Transform,
14+
self, Abs, Color, Geometry, LineCap, LineJoin, Paint, PathItem, Shape, Size, Stroke,
15+
Transform,
1516
};
1617
use crate::image::{DecodedImage, Image};
1718

@@ -392,9 +393,36 @@ fn render_shape(
392393
canvas.fill_path(&path, &paint, rule, ts, mask);
393394
}
394395

395-
if let Some(Stroke { paint, thickness }) = &shape.stroke {
396+
if let Some(Stroke {
397+
paint,
398+
thickness,
399+
line_cap,
400+
line_join,
401+
dash_pattern,
402+
miter_limit,
403+
}) = &shape.stroke
404+
{
405+
let dash = dash_pattern.as_ref().and_then(|pattern| {
406+
// tiny-skia only allows dash patterns with an even number of elements,
407+
// while pdf allows any number.
408+
let len = if pattern.array.len() % 2 == 1 {
409+
pattern.array.len() * 2
410+
} else {
411+
pattern.array.len()
412+
};
413+
let dash_array =
414+
pattern.array.iter().map(|l| l.to_f32()).cycle().take(len).collect();
415+
416+
sk::StrokeDash::new(dash_array, pattern.phase.to_f32())
417+
});
396418
let paint = paint.into();
397-
let stroke = sk::Stroke { width: thickness.to_f32(), ..Default::default() };
419+
let stroke = sk::Stroke {
420+
width: thickness.to_f32(),
421+
line_cap: line_cap.into(),
422+
line_join: line_join.into(),
423+
dash,
424+
miter_limit: miter_limit.0 as f32,
425+
};
398426
canvas.stroke_path(&path, &paint, &stroke, ts, mask);
399427
}
400428

@@ -525,6 +553,26 @@ impl From<Color> for sk::Color {
525553
}
526554
}
527555

556+
impl From<&LineCap> for sk::LineCap {
557+
fn from(line_cap: &LineCap) -> Self {
558+
match line_cap {
559+
LineCap::Butt => sk::LineCap::Butt,
560+
LineCap::Round => sk::LineCap::Round,
561+
LineCap::Square => sk::LineCap::Square,
562+
}
563+
}
564+
}
565+
566+
impl From<&LineJoin> for sk::LineJoin {
567+
fn from(line_join: &LineJoin) -> Self {
568+
match line_join {
569+
LineJoin::Miter => sk::LineJoin::Miter,
570+
LineJoin::Round => sk::LineJoin::Round,
571+
LineJoin::Bevel => sk::LineJoin::Bevel,
572+
}
573+
}
574+
}
575+
528576
/// Allows to build tiny-skia paths from glyph outlines.
529577
struct WrappedPathBuilder(sk::PathBuilder);
530578

src/geom/sides.rs

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -188,22 +188,30 @@ where
188188

189189
fn cast(mut value: Value) -> StrResult<Self> {
190190
if let Value::Dict(dict) = &mut value {
191-
let mut take = |key| dict.take(key).ok().map(T::cast).transpose();
192-
193-
let rest = take("rest")?;
194-
let x = take("x")?.or_else(|| rest.clone());
195-
let y = take("y")?.or_else(|| rest.clone());
196-
let sides = Sides {
197-
left: take("left")?.or_else(|| x.clone()),
198-
top: take("top")?.or_else(|| y.clone()),
199-
right: take("right")?.or_else(|| x.clone()),
200-
bottom: take("bottom")?.or_else(|| y.clone()),
191+
let mut try_cast = || -> StrResult<_> {
192+
let mut take = |key| dict.take(key).ok().map(T::cast).transpose();
193+
194+
let rest = take("rest")?;
195+
let x = take("x")?.or_else(|| rest.clone());
196+
let y = take("y")?.or_else(|| rest.clone());
197+
let sides = Sides {
198+
left: take("left")?.or_else(|| x.clone()),
199+
top: take("top")?.or_else(|| y.clone()),
200+
right: take("right")?.or_else(|| x.clone()),
201+
bottom: take("bottom")?.or_else(|| y.clone()),
202+
};
203+
204+
dict.finish(&["left", "top", "right", "bottom", "x", "y", "rest"])?;
205+
206+
Ok(sides)
201207
};
202208

203-
dict.finish(&["left", "top", "right", "bottom", "x", "y", "rest"])?;
209+
if let Ok(res) = try_cast() {
210+
return Ok(res);
211+
}
212+
}
204213

205-
Ok(sides)
206-
} else if T::is(&value) {
214+
if T::is(&value) {
207215
Ok(Self::splat(Some(T::cast(value)?)))
208216
} else {
209217
<Self as Cast>::error(value)

0 commit comments

Comments
 (0)