summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/typst-pdf/src/color.rs17
-rw-r--r--crates/typst-pdf/src/gradient.rs9
-rw-r--r--crates/typst-pdf/src/page.rs28
-rw-r--r--crates/typst-pdf/src/pattern.rs9
-rw-r--r--crates/typst-render/src/lib.rs78
-rw-r--r--crates/typst-svg/src/lib.rs7
-rw-r--r--crates/typst/src/layout/inline/shaping.rs2
-rw-r--r--crates/typst/src/math/fragment.rs1
-rw-r--r--crates/typst/src/text/item.rs4
-rw-r--r--crates/typst/src/text/mod.rs11
-rw-r--r--tests/ref/text/stroke.pngbin0 -> 97009 bytes
-rw-r--r--tests/typ/text/stroke.typ21
12 files changed, 148 insertions, 39 deletions
diff --git a/crates/typst-pdf/src/color.rs b/crates/typst-pdf/src/color.rs
index 3d90926f..f0d483cf 100644
--- a/crates/typst-pdf/src/color.rs
+++ b/crates/typst-pdf/src/color.rs
@@ -205,7 +205,7 @@ pub(super) trait PaintEncode {
fn set_as_fill(&self, ctx: &mut PageContext, on_text: bool, transforms: Transforms);
/// Set the paint as the stroke color.
- fn set_as_stroke(&self, ctx: &mut PageContext, transforms: Transforms);
+ fn set_as_stroke(&self, ctx: &mut PageContext, on_text: bool, transforms: Transforms);
}
impl PaintEncode for Paint {
@@ -217,11 +217,16 @@ impl PaintEncode for Paint {
}
}
- fn set_as_stroke(&self, ctx: &mut PageContext, transforms: Transforms) {
+ fn set_as_stroke(
+ &self,
+ ctx: &mut PageContext,
+ on_text: bool,
+ transforms: Transforms,
+ ) {
match self {
- Self::Solid(c) => c.set_as_stroke(ctx, transforms),
- Self::Gradient(gradient) => gradient.set_as_stroke(ctx, transforms),
- Self::Pattern(pattern) => pattern.set_as_stroke(ctx, transforms),
+ Self::Solid(c) => c.set_as_stroke(ctx, on_text, transforms),
+ Self::Gradient(gradient) => gradient.set_as_stroke(ctx, on_text, transforms),
+ Self::Pattern(pattern) => pattern.set_as_stroke(ctx, on_text, transforms),
}
}
}
@@ -267,7 +272,7 @@ impl PaintEncode for Color {
}
}
- fn set_as_stroke(&self, ctx: &mut PageContext, _: Transforms) {
+ fn set_as_stroke(&self, ctx: &mut PageContext, _: bool, _: Transforms) {
match self {
Color::Luma(_) => {
ctx.parent.colors.d65_gray(&mut ctx.parent.alloc);
diff --git a/crates/typst-pdf/src/gradient.rs b/crates/typst-pdf/src/gradient.rs
index 0882a70e..523d67b9 100644
--- a/crates/typst-pdf/src/gradient.rs
+++ b/crates/typst-pdf/src/gradient.rs
@@ -225,10 +225,15 @@ impl PaintEncode for Gradient {
.insert(PageResource::new(ResourceKind::Gradient, id), index);
}
- fn set_as_stroke(&self, ctx: &mut PageContext, transforms: Transforms) {
+ fn set_as_stroke(
+ &self,
+ ctx: &mut PageContext,
+ on_text: bool,
+ transforms: Transforms,
+ ) {
ctx.reset_stroke_color_space();
- let index = register_gradient(ctx, self, false, transforms);
+ let index = register_gradient(ctx, self, on_text, transforms);
let id = eco_format!("Gr{index}");
let name = Name(id.as_bytes());
diff --git a/crates/typst-pdf/src/page.rs b/crates/typst-pdf/src/page.rs
index 05501d2c..56d3fd83 100644
--- a/crates/typst-pdf/src/page.rs
+++ b/crates/typst-pdf/src/page.rs
@@ -504,7 +504,12 @@ impl PageContext<'_, '_> {
self.state.fill_space = None;
}
- fn set_stroke(&mut self, stroke: &FixedStroke, transforms: Transforms) {
+ fn set_stroke(
+ &mut self,
+ stroke: &FixedStroke,
+ on_text: bool,
+ transforms: Transforms,
+ ) {
if self.state.stroke.as_ref() != Some(stroke)
|| matches!(
self.state.stroke.as_ref().map(|s| &s.paint),
@@ -520,7 +525,7 @@ impl PageContext<'_, '_> {
miter_limit,
} = stroke;
- paint.set_as_stroke(self, transforms);
+ paint.set_as_stroke(self, on_text, transforms);
self.content.set_line_width(thickness.to_f32());
if self.state.stroke.as_ref().map(|s| &s.line_cap) != Some(line_cap) {
@@ -620,13 +625,18 @@ fn write_text(ctx: &mut PageContext, pos: Point, text: &TextItem) {
let segment = &text.text[g.range()];
glyph_set.entry(g.id).or_insert_with(|| segment.into());
}
-
- ctx.set_fill(&text.fill, true, ctx.state.transforms(Size::zero(), pos));
+ let fill_transform = ctx.state.transforms(Size::zero(), pos);
+ ctx.set_fill(&text.fill, true, fill_transform);
+ if let Some(stroke) = &text.stroke {
+ ctx.set_stroke(stroke, true, fill_transform);
+ ctx.content
+ .set_text_rendering_mode(pdf_writer::types::TextRenderingMode::FillStroke);
+ }
ctx.set_font(&text.font, text.size);
- ctx.set_opacities(None, Some(&text.fill));
+ ctx.set_opacities(text.stroke.as_ref(), Some(&text.fill));
ctx.content.begin_text();
- // Positiosn the text.
+ // Position the text.
ctx.content.set_text_matrix([1.0, 0.0, 0.0, -1.0, x, y]);
let mut positioned = ctx.content.show_positioned();
@@ -690,7 +700,11 @@ fn write_shape(ctx: &mut PageContext, pos: Point, shape: &Shape) {
}
if let Some(stroke) = stroke {
- ctx.set_stroke(stroke, ctx.state.transforms(shape.geometry.bbox_size(), pos));
+ ctx.set_stroke(
+ stroke,
+ false,
+ ctx.state.transforms(shape.geometry.bbox_size(), pos),
+ );
}
ctx.set_opacities(stroke, shape.fill.as_ref());
diff --git a/crates/typst-pdf/src/pattern.rs b/crates/typst-pdf/src/pattern.rs
index ea7d48e0..0829ef32 100644
--- a/crates/typst-pdf/src/pattern.rs
+++ b/crates/typst-pdf/src/pattern.rs
@@ -140,10 +140,15 @@ impl PaintEncode for Pattern {
.insert(PageResource::new(ResourceKind::Pattern, id), index);
}
- fn set_as_stroke(&self, ctx: &mut PageContext, transforms: Transforms) {
+ fn set_as_stroke(
+ &self,
+ ctx: &mut PageContext,
+ on_text: bool,
+ transforms: Transforms,
+ ) {
ctx.reset_stroke_color_space();
- let index = register_pattern(ctx, self, false, transforms);
+ let index = register_pattern(ctx, self, on_text, transforms);
let id = eco_format!("P{index}");
let name = Name(id.as_bytes());
diff --git a/crates/typst-render/src/lib.rs b/crates/typst-render/src/lib.rs
index 393f86cf..e8633f55 100644
--- a/crates/typst-render/src/lib.rs
+++ b/crates/typst-render/src/lib.rs
@@ -15,8 +15,8 @@ use typst::layout::{
};
use typst::text::{Font, TextItem};
use typst::visualize::{
- Color, FixedStroke, Geometry, Gradient, Image, ImageKind, LineCap, LineJoin, Paint,
- Path, PathItem, Pattern, RasterFormat, RelativeTo, Shape,
+ Color, DashPattern, FixedStroke, Geometry, Gradient, Image, ImageKind, LineCap,
+ LineJoin, Paint, Path, PathItem, Pattern, RasterFormat, RelativeTo, Shape,
};
use usvg::{NodeExt, TreeParsing};
@@ -377,7 +377,12 @@ fn render_outline_glyph(
// Render a glyph directly as a path. This only happens when the fast glyph
// rasterization can't be used due to very large text size or weird
// scale/skewing transforms.
- if ppem > 100.0 || ts.kx != 0.0 || ts.ky != 0.0 || ts.sx != ts.sy {
+ if ppem > 100.0
+ || ts.kx != 0.0
+ || ts.ky != 0.0
+ || ts.sx != ts.sy
+ || text.stroke.is_some()
+ {
let path = {
let mut builder = WrappedPathBuilder(sk::PathBuilder::new());
text.font.ttf().outline_glyph(id, &mut builder)?;
@@ -387,22 +392,56 @@ fn render_outline_glyph(
let scale = text.size.to_f32() / text.font.units_per_em() as f32;
let mut pixmap = None;
+
+ let rule = sk::FillRule::default();
+
+ // Flip vertically because font design coordinate
+ // system is Y-up.
+ let ts = ts.pre_scale(scale, -scale);
+ let state_ts = state.pre_concat(sk::Transform::from_scale(scale, -scale));
let paint = to_sk_paint(
&text.fill,
- state.pre_concat(sk::Transform::from_scale(scale, -scale)),
+ state_ts,
Size::zero(),
true,
None,
&mut pixmap,
None,
);
+ canvas.fill_path(&path, &paint, rule, ts, state.mask);
- let rule = sk::FillRule::default();
+ if let Some(FixedStroke {
+ paint,
+ thickness,
+ line_cap,
+ line_join,
+ dash_pattern,
+ miter_limit,
+ }) = &text.stroke
+ {
+ if thickness.to_f32() > 0.0 {
+ let dash = dash_pattern.as_ref().and_then(to_sk_dash_pattern);
+
+ let paint = to_sk_paint(
+ paint,
+ state_ts,
+ Size::zero(),
+ true,
+ None,
+ &mut pixmap,
+ None,
+ );
+ let stroke = sk::Stroke {
+ width: thickness.to_f32() / scale, // When we scale the path, we need to scale the stroke width, too.
+ line_cap: to_sk_line_cap(*line_cap),
+ line_join: to_sk_line_join(*line_join),
+ dash,
+ miter_limit: miter_limit.get() as f32,
+ };
- // Flip vertically because font design coordinate
- // system is Y-up.
- let ts = ts.pre_scale(scale, -scale);
- canvas.fill_path(&path, &paint, rule, ts, state.mask);
+ canvas.stroke_path(&path, &paint, &stroke, ts, state.mask);
+ }
+ }
return Some(());
}
@@ -581,17 +620,7 @@ fn render_shape(canvas: &mut sk::Pixmap, state: State, shape: &Shape) -> Option<
// Don't draw zero-pt stroke.
if width > 0.0 {
- 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 pattern_len = pattern.array.len();
- let len =
- if pattern_len % 2 == 1 { 2 * pattern_len } else { pattern_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 dash = dash_pattern.as_ref().and_then(to_sk_dash_pattern);
let bbox = shape.geometry.bbox_size();
let offset_bbox = (!matches!(shape.geometry, Geometry::Line(..)))
@@ -1045,6 +1074,15 @@ fn to_sk_transform(transform: &Transform) -> sk::Transform {
)
}
+fn to_sk_dash_pattern(pattern: &DashPattern<Abs, Abs>) -> Option<sk::StrokeDash> {
+ // tiny-skia only allows dash patterns with an even number of elements,
+ // while pdf allows any number.
+ let pattern_len = pattern.array.len();
+ let len = if pattern_len % 2 == 1 { 2 * pattern_len } else { pattern_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())
+}
+
/// Allows to build tiny-skia paths from glyph outlines.
struct WrappedPathBuilder(sk::PathBuilder);
diff --git a/crates/typst-svg/src/lib.rs b/crates/typst-svg/src/lib.rs
index 92168fc6..b8143122 100644
--- a/crates/typst-svg/src/lib.rs
+++ b/crates/typst-svg/src/lib.rs
@@ -452,6 +452,13 @@ impl SVGRenderer {
Size::new(Abs::pt(width), Abs::pt(height)),
self.text_paint_transform(state, &text.fill),
);
+ if let Some(stroke) = &text.stroke {
+ self.write_stroke(
+ stroke,
+ Size::new(Abs::pt(width), Abs::pt(height)),
+ self.text_paint_transform(state, &stroke.paint),
+ );
+ }
self.xml.end_element();
Some(())
diff --git a/crates/typst/src/layout/inline/shaping.rs b/crates/typst/src/layout/inline/shaping.rs
index b715f664..07be8c68 100644
--- a/crates/typst/src/layout/inline/shaping.rs
+++ b/crates/typst/src/layout/inline/shaping.rs
@@ -230,6 +230,7 @@ impl<'a> ShapedText<'a> {
let lang = TextElem::lang_in(self.styles);
let decos = TextElem::deco_in(self.styles);
let fill = TextElem::fill_in(self.styles);
+ let stroke = TextElem::stroke_in(self.styles);
for ((font, y_offset), group) in
self.glyphs.as_ref().group_by_key(|g| (g.font.clone(), g.y_offset))
@@ -302,6 +303,7 @@ impl<'a> ShapedText<'a> {
size: self.size,
lang,
fill: fill.clone(),
+ stroke: stroke.clone().map(|s| s.unwrap_or_default()),
text: self.text[range.start - self.base..range.end - self.base].into(),
glyphs,
};
diff --git a/crates/typst/src/math/fragment.rs b/crates/typst/src/math/fragment.rs
index d176dd96..6131f663 100644
--- a/crates/typst/src/math/fragment.rs
+++ b/crates/typst/src/math/fragment.rs
@@ -308,6 +308,7 @@ impl GlyphFragment {
fill: self.fill,
lang: self.lang,
text: self.c.into(),
+ stroke: None,
glyphs: vec![Glyph {
id: self.id.0,
x_advance: Em::from_length(self.width, self.font_size),
diff --git a/crates/typst/src/text/item.rs b/crates/typst/src/text/item.rs
index 49ed7851..44d8e63a 100644
--- a/crates/typst/src/text/item.rs
+++ b/crates/typst/src/text/item.rs
@@ -6,7 +6,7 @@ use ecow::EcoString;
use crate::layout::{Abs, Em};
use crate::syntax::Span;
use crate::text::{Font, Lang};
-use crate::visualize::Paint;
+use crate::visualize::{FixedStroke, Paint};
/// A run of shaped text.
#[derive(Clone, Eq, PartialEq, Hash)]
@@ -17,6 +17,8 @@ pub struct TextItem {
pub size: Abs,
/// Glyph color.
pub fill: Paint,
+ /// Glyph stroke.
+ pub stroke: Option<FixedStroke>,
/// The natural language of the text.
pub lang: Lang,
/// The item's plain text.
diff --git a/crates/typst/src/text/mod.rs b/crates/typst/src/text/mod.rs
index dfbf328f..06e347a3 100644
--- a/crates/typst/src/text/mod.rs
+++ b/crates/typst/src/text/mod.rs
@@ -43,7 +43,7 @@ use crate::foundations::{
use crate::layout::{Abs, Axis, Dir, Length, Rel};
use crate::model::ParElem;
use crate::syntax::Spanned;
-use crate::visualize::{Color, Paint, RelativeTo};
+use crate::visualize::{Color, Paint, RelativeTo, Stroke};
/// Text styling.
///
@@ -240,6 +240,15 @@ pub struct TextElem {
#[ghost]
pub fill: Paint,
+ /// How to stroke the text.
+ ///
+ /// ```example
+ /// #text(stroke: 0.5pt + red)[Stroked]
+ /// ```
+ #[resolve]
+ #[ghost]
+ pub stroke: Option<Stroke>,
+
/// The amount of space that should be added between characters.
///
/// ```example
diff --git a/tests/ref/text/stroke.png b/tests/ref/text/stroke.png
new file mode 100644
index 00000000..d6d85c28
--- /dev/null
+++ b/tests/ref/text/stroke.png
Binary files differ
diff --git a/tests/typ/text/stroke.typ b/tests/typ/text/stroke.typ
new file mode 100644
index 00000000..713bbe2f
--- /dev/null
+++ b/tests/typ/text/stroke.typ
@@ -0,0 +1,21 @@
+#set text(size: 20pt)
+#set page(width: auto)
+测试字体 #lorem(5)
+
+#text(stroke: 0.3pt + red)[测试字体#lorem(5)]
+
+#text(stroke: 0.5pt + red)[测试字体#lorem(5)]
+
+#text(stroke: 0.7pt + red)[测试字体#lorem(5)]
+
+#text(stroke: 1pt + red)[测试字体#lorem(5)]
+
+#text(stroke: 2pt + red)[测试字体#lorem(5)]
+
+#text(stroke: 5pt + red)[测试字体#lorem(5)]
+
+#text(stroke: 7pt + red)[测试字体#lorem(5)]
+
+#text(stroke: (paint: blue, thickness: 1pt, dash: "dashed"))[测试字体#lorem(5)]
+
+#text(stroke: 1pt + gradient.linear(..color.map.rainbow))[测试字体#lorem(5)] // gradient doesn't work now