diff --git a/crates/typst-pdf/src/color.rs b/crates/typst-pdf/src/color.rs index 3d90926f9..f0d483cf3 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 0882a70ee..523d67b90 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 05501d2c0..56d3fd832 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 ea7d48e01..0829ef32d 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 393f86cf8..e8633f55e 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 paint = to_sk_paint( - &text.fill, - state.pre_concat(sk::Transform::from_scale(scale, -scale)), - Size::zero(), - true, - None, - &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_ts, + Size::zero(), + true, + None, + &mut pixmap, + None, + ); canvas.fill_path(&path, &paint, rule, ts, state.mask); + + 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, + }; + + 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) -> Option { + // 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 92168fc69..b81431220 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 b715f6648..07be8c68c 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 d176dd962..6131f663d 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 49ed78517..44d8e63a1 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, /// 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 dfbf328fc..06e347a35 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, + /// 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 000000000..d6d85c28e Binary files /dev/null and b/tests/ref/text/stroke.png differ diff --git a/tests/typ/text/stroke.typ b/tests/typ/text/stroke.typ new file mode 100644 index 000000000..713bbe2f4 --- /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