Add stroke for text (#2970)

This commit is contained in:
Wenzhuo Liu 2023-12-19 17:36:18 +08:00 committed by GitHub
parent 111a69f6aa
commit 81ff34d80d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 151 additions and 42 deletions

View File

@ -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);

View File

@ -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());

View File

@ -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());

View File

@ -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());

View File

@ -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<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);

View File

@ -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(())

View File

@ -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,
};

View File

@ -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),

View File

@ -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.

View File

@ -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

BIN
tests/ref/text/stroke.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

21
tests/typ/text/stroke.typ Normal file
View File

@ -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