summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/exec/state.rs70
-rw-r--r--src/export/pdf.rs23
-rw-r--r--src/font.rs9
-rw-r--r--src/layout/frame.rs4
-rw-r--r--src/layout/par.rs16
-rw-r--r--src/layout/shaping.rs92
-rw-r--r--src/library/decorations.rs84
-rw-r--r--src/library/font.rs2
-rw-r--r--src/library/mod.rs5
-rw-r--r--tests/ref/text/decorations.pngbin0 -> 10184 bytes
-rw-r--r--tests/typ/text/decorations.typ19
-rw-r--r--tests/typeset.rs13
12 files changed, 316 insertions, 21 deletions
diff --git a/src/exec/state.rs b/src/exec/state.rs
index aeeeaed5..2b824afe 100644
--- a/src/exec/state.rs
+++ b/src/exec/state.rs
@@ -125,13 +125,19 @@ pub struct FontState {
/// The bottom end of the text bounding box.
pub bottom_edge: VerticalFontMetric,
/// The glyph fill color / texture.
- pub color: Fill,
+ pub fill: Fill,
/// Whether the strong toggle is active or inactive. This determines
/// whether the next `*` adds or removes font weight.
pub strong: bool,
/// Whether the emphasis toggle is active or inactive. This determines
/// whether the next `_` makes italic or non-italic.
pub emph: bool,
+ /// The specifications for a strikethrough line, if any.
+ pub strikethrough: Option<LineState>,
+ /// The specifications for a underline, if any.
+ pub underline: Option<LineState>,
+ /// The specifications for a overline line, if any.
+ pub overline: Option<LineState>,
}
impl FontState {
@@ -156,13 +162,17 @@ impl FontState {
}
}
+ let size = self.resolve_size();
FontProps {
families: Rc::clone(&self.families),
variant,
- size: self.resolve_size(),
+ size,
top_edge: self.top_edge,
bottom_edge: self.bottom_edge,
- fill: self.color,
+ strikethrough: self.strikethrough.map(|s| s.resolve_props(size, &self.fill)),
+ underline: self.underline.map(|s| s.resolve_props(size, &self.fill)),
+ overline: self.overline.map(|s| s.resolve_props(size, &self.fill)),
+ fill: self.fill,
}
}
@@ -185,9 +195,39 @@ impl Default for FontState {
top_edge: VerticalFontMetric::CapHeight,
bottom_edge: VerticalFontMetric::Baseline,
scale: Linear::one(),
- color: Fill::Color(Color::Rgba(RgbaColor::BLACK)),
+ fill: Fill::Color(Color::Rgba(RgbaColor::BLACK)),
strong: false,
emph: false,
+ strikethrough: None,
+ underline: None,
+ overline: None,
+ }
+ }
+}
+
+/// Describes a line that could be positioned over or under text.
+#[derive(Debug, Copy, Clone, PartialEq, Hash)]
+pub struct LineState {
+ /// Color of the line. Will default to text color if `None`.
+ pub fill: Option<Fill>,
+ /// Thickness of the line's stroke. Calling functions should attempt to
+ /// read this value from the appropriate font tables if this is `None`.
+ pub strength: Option<Linear>,
+ /// Position of the line relative to the baseline. Calling functions should
+ /// attempt to read this value from the appropriate font tables if this is
+ /// `None`.
+ pub position: Option<Linear>,
+ /// Amount that the line will be longer or shorter than its associated text.
+ pub extent: Linear,
+}
+
+impl LineState {
+ pub fn resolve_props(&self, font_size: Length, fill: &Fill) -> LineProps {
+ LineProps {
+ fill: self.fill.unwrap_or_else(|| fill.clone()),
+ strength: self.strength.map(|s| s.resolve(font_size)),
+ position: self.position.map(|p| p.resolve(font_size)),
+ extent: self.extent.resolve(font_size),
}
}
}
@@ -207,6 +247,12 @@ pub struct FontProps {
pub bottom_edge: VerticalFontMetric,
/// The fill color of the text.
pub fill: Fill,
+ /// The specifications for a strikethrough line, if any.
+ pub strikethrough: Option<LineProps>,
+ /// The specifications for a underline, if any.
+ pub underline: Option<LineProps>,
+ /// The specifications for a overline line, if any.
+ pub overline: Option<LineProps>,
}
/// Font family definitions.
@@ -273,3 +319,19 @@ impl Display for FontFamily {
})
}
}
+
+/// Describes a line that could be positioned over or under text.
+#[derive(Debug, Copy, Clone, PartialEq, Hash)]
+pub struct LineProps {
+ /// Color of the line.
+ pub fill: Fill,
+ /// Thickness of the line's stroke. Calling functions should attempt to
+ /// read this value from the appropriate font tables if this is `None`.
+ pub strength: Option<Length>,
+ /// Position of the line relative to the baseline. Calling functions should
+ /// attempt to read this value from the appropriate font tables if this is
+ /// `None`.
+ pub position: Option<Length>,
+ /// Amount that the line will be longer or shorter than its associated text.
+ pub extent: Length,
+}
diff --git a/src/export/pdf.rs b/src/export/pdf.rs
index 1cc62332..da3c9369 100644
--- a/src/export/pdf.rs
+++ b/src/export/pdf.rs
@@ -183,12 +183,17 @@ impl<'a> PdfExporter<'a> {
content.rect(x, y - h, w, h, false, true);
}
}
-
Shape::Ellipse(size) => {
let path = geom::Path::ellipse(size);
write_path(&mut content, x, y, &path, false, true);
}
-
+ Shape::Line(target, stroke) => {
+ write_stroke(&mut content, fill, stroke.to_pt() as f32);
+ content.path(true, false).move_to(x, y).line_to(
+ x + target.x.to_pt() as f32,
+ y - target.y.to_pt() as f32,
+ );
+ }
Shape::Path(ref path) => {
write_path(&mut content, x, y, path, false, true)
}
@@ -371,6 +376,20 @@ fn write_fill(content: &mut Content, fill: Fill) {
}
}
+/// Write a stroke change into a content stream.
+fn write_stroke(content: &mut Content, fill: Fill, thickness: f32) {
+ match fill {
+ Fill::Color(Color::Rgba(c)) => {
+ content.stroke_rgb(
+ c.r as f32 / 255.0,
+ c.g as f32 / 255.0,
+ c.b as f32 / 255.0,
+ );
+ }
+ }
+ content.line_width(thickness);
+}
+
/// Write a path into a content stream.
fn write_path(
content: &mut Content,
diff --git a/src/font.rs b/src/font.rs
index 516d4bbe..a55a2a13 100644
--- a/src/font.rs
+++ b/src/font.rs
@@ -2,6 +2,7 @@
use std::collections::HashMap;
use std::fmt::{self, Debug, Display, Formatter};
+use std::ops::Add;
use serde::{Deserialize, Serialize};
@@ -156,6 +157,14 @@ impl Em {
}
}
+impl Add for Em {
+ type Output = Self;
+
+ fn add(self, other: Self) -> Self {
+ Self(self.0 + other.0)
+ }
+}
+
/// Caches parsed font faces.
pub struct FontCache {
faces: Vec<Option<Face>>,
diff --git a/src/layout/frame.rs b/src/layout/frame.rs
index 6cecc7a3..119aeea6 100644
--- a/src/layout/frame.rs
+++ b/src/layout/frame.rs
@@ -92,12 +92,14 @@ pub enum Shape {
Rect(Size),
/// An ellipse with its origin in the center.
Ellipse(Size),
+ /// A line to a `Point` (relative to its position) with a stroke width.
+ Line(Point, Length),
/// A bezier path.
Path(Path),
}
/// How text and shapes are filled.
-#[derive(Debug, Copy, Clone, PartialEq, Hash, Serialize, Deserialize)]
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum Fill {
/// A solid color.
Color(Color),
diff --git a/src/layout/par.rs b/src/layout/par.rs
index f21778de..8b3cbf8b 100644
--- a/src/layout/par.rs
+++ b/src/layout/par.rs
@@ -190,7 +190,7 @@ impl<'a> ParLayout<'a> {
while !stack.regions.current.height.fits(line.size.height)
&& !stack.regions.in_full_last()
{
- stack.finish_region();
+ stack.finish_region(ctx);
}
// If the line does not fit horizontally or we have a mandatory
@@ -217,7 +217,7 @@ impl<'a> ParLayout<'a> {
stack.push(line);
}
- stack.finish()
+ stack.finish(ctx)
}
/// Find the index of the item whose range contains the `text_offset`.
@@ -302,7 +302,7 @@ impl<'a> LineStack<'a> {
self.lines.push(line);
}
- fn finish_region(&mut self) {
+ fn finish_region(&mut self, ctx: &LayoutContext) {
if self.regions.fixed.horizontal {
self.size.width = self.regions.current.width;
}
@@ -312,7 +312,7 @@ impl<'a> LineStack<'a> {
let mut first = true;
for line in std::mem::take(&mut self.lines) {
- let frame = line.build(self.size.width);
+ let frame = line.build(ctx, self.size.width);
let pos = Point::new(Length::zero(), offset);
if first {
@@ -329,8 +329,8 @@ impl<'a> LineStack<'a> {
self.size = Size::zero();
}
- fn finish(mut self) -> Vec<Frame> {
- self.finish_region();
+ fn finish(mut self, ctx: &LayoutContext) -> Vec<Frame> {
+ self.finish_region(ctx);
self.finished
}
}
@@ -447,7 +447,7 @@ impl<'a> LineLayout<'a> {
}
/// Build the line's frame.
- fn build(&self, width: Length) -> Frame {
+ fn build(&self, ctx: &LayoutContext, width: Length) -> Frame {
let size = Size::new(self.size.width.max(width), self.size.height);
let free = size.width - self.size.width;
@@ -463,7 +463,7 @@ impl<'a> LineLayout<'a> {
}
ParItem::Text(ref shaped, align) => {
ruler = ruler.max(align);
- shaped.build()
+ shaped.build(ctx)
}
ParItem::Frame(ref frame, align) => {
ruler = ruler.max(align);
diff --git a/src/layout/shaping.rs b/src/layout/shaping.rs
index 14ea8611..232e9fc5 100644
--- a/src/layout/shaping.rs
+++ b/src/layout/shaping.rs
@@ -1,13 +1,14 @@
use std::borrow::Cow;
use std::fmt::{self, Debug, Formatter};
-use std::ops::Range;
+use std::ops::{Add, Range};
use rustybuzz::UnicodeBuffer;
use super::{Element, Frame, Glyph, LayoutContext, Text};
use crate::exec::FontProps;
-use crate::font::{Face, FaceId};
+use crate::font::{Em, Face, FaceId, VerticalFontMetric};
use crate::geom::{Dir, Length, Point, Size};
+use crate::layout::Shape;
use crate::util::SliceExt;
/// The result of shaping text.
@@ -59,12 +60,13 @@ enum Side {
impl<'a> ShapedText<'a> {
/// Build the shaped text's frame.
- pub fn build(&self) -> Frame {
+ pub fn build(&self, ctx: &LayoutContext) -> Frame {
let mut frame = Frame::new(self.size, self.baseline);
let mut offset = Length::zero();
for (face_id, group) in self.glyphs.as_ref().group_by_key(|g| g.face_id) {
let pos = Point::new(offset, self.baseline);
+
let mut text = Text {
face_id,
size: self.props.size,
@@ -72,16 +74,20 @@ impl<'a> ShapedText<'a> {
glyphs: vec![],
};
+ let mut width = Length::zero();
for glyph in group {
text.glyphs.push(Glyph {
id: glyph.glyph_id,
x_advance: glyph.x_advance,
x_offset: glyph.x_offset,
});
- offset += glyph.x_advance;
+ width += glyph.x_advance;
}
frame.push(pos, Element::Text(text));
+ decorate(ctx, &mut frame, &self.props, face_id, pos, width);
+
+ offset += width;
}
frame
@@ -364,3 +370,81 @@ fn measure(
(Size::new(width, top + bottom), top)
}
+
+/// Add underline, strikthrough and overline decorations.
+fn decorate(
+ ctx: &LayoutContext,
+ frame: &mut Frame,
+ props: &FontProps,
+ face_id: FaceId,
+ pos: Point,
+ width: Length,
+) {
+ let mut apply = |strength, position, extent, fill| {
+ let pos = Point::new(pos.x - extent, pos.y - position);
+ let target = Point::new(width + 2.0 * extent, Length::zero());
+ frame.push(pos, Element::Geometry(Shape::Line(target, strength), fill));
+ };
+
+ if let Some(strikethrough) = props.strikethrough {
+ let face = ctx.cache.font.get(face_id);
+
+ let strength = strikethrough.strength.unwrap_or_else(|| {
+ face.ttf()
+ .strikeout_metrics()
+ .or_else(|| face.ttf().underline_metrics())
+ .map_or(Em::new(0.06), |m| face.to_em(m.thickness))
+ .to_length(props.size)
+ });
+
+ let position = strikethrough.position.unwrap_or_else(|| {
+ face.ttf()
+ .strikeout_metrics()
+ .map_or(Em::new(0.25), |m| face.to_em(m.position))
+ .to_length(props.size)
+ });
+
+ apply(strength, position, strikethrough.extent, strikethrough.fill);
+ }
+
+ if let Some(underline) = props.underline {
+ let face = ctx.cache.font.get(face_id);
+
+ let strength = underline.strength.unwrap_or_else(|| {
+ face.ttf()
+ .underline_metrics()
+ .or_else(|| face.ttf().strikeout_metrics())
+ .map_or(Em::new(0.06), |m| face.to_em(m.thickness))
+ .to_length(props.size)
+ });
+
+ let position = underline.position.unwrap_or_else(|| {
+ face.ttf()
+ .underline_metrics()
+ .map_or(Em::new(-0.2), |m| face.to_em(m.position))
+ .to_length(props.size)
+ });
+
+ apply(strength, position, underline.extent, underline.fill);
+ }
+
+ if let Some(overline) = props.overline {
+ let face = ctx.cache.font.get(face_id);
+
+ let strength = overline.strength.unwrap_or_else(|| {
+ face.ttf()
+ .underline_metrics()
+ .or_else(|| face.ttf().strikeout_metrics())
+ .map_or(Em::new(0.06), |m| face.to_em(m.thickness))
+ .to_length(props.size)
+ });
+
+ let position = overline.position.unwrap_or_else(|| {
+ face.vertical_metric(VerticalFontMetric::CapHeight)
+ .add(Em::new(0.1))
+ .to_length(props.size)
+ });
+
+ apply(strength, position, overline.extent, overline.fill);
+ }
+}
diff --git a/src/library/decorations.rs b/src/library/decorations.rs
new file mode 100644
index 00000000..ef9afd37
--- /dev/null
+++ b/src/library/decorations.rs
@@ -0,0 +1,84 @@
+use crate::exec::{FontState, LineState};
+use crate::layout::Fill;
+
+use super::*;
+
+/// `strike`: Enable striken-through text.
+///
+/// # Named parameters
+/// - Color: `color`, of type `color`.
+/// - Baseline offset: `position`, of type `linear`.
+/// - Strength: `strength`, of type `linear`.
+/// - Extent that is applied on either end of the line: `extent`, of type
+/// `linear`.
+///
+/// # Return value
+/// A template that enables striken-through text. The effect is scoped to the
+/// body if present.
+pub fn strike(ctx: &mut EvalContext, args: &mut FuncArgs) -> Value {
+ line_impl("strike", ctx, args, |font| &mut font.strikethrough)
+}
+
+/// `underline`: Enable underlined text.
+///
+/// # Named parameters
+/// - Color: `color`, of type `color`.
+/// - Baseline offset: `position`, of type `linear`.
+/// - Strength: `strength`, of type `linear`.
+/// - Extent that is applied on either end of the line: `extent`, of type
+/// `linear`.
+///
+/// # Return value
+/// A template that enables underlined text. The effect is scoped to the body if
+/// present.
+pub fn underline(ctx: &mut EvalContext, args: &mut FuncArgs) -> Value {
+ line_impl("underline", ctx, args, |font| &mut font.underline)
+}
+
+/// `overline`: Add an overline above text.
+///
+/// # Named parameters
+/// - Color: `color`, of type `color`.
+/// - Baseline offset: `position`, of type `linear`.
+/// - Strength: `strength`, of type `linear`.
+/// - Extent that is applied on either end of the line: `extent`, of type
+/// `linear`.
+///
+/// # Return value
+/// A template that adds an overline above text. The effect is scoped to the
+/// body if present.
+pub fn overline(ctx: &mut EvalContext, args: &mut FuncArgs) -> Value {
+ line_impl("overline", ctx, args, |font| &mut font.overline)
+}
+
+fn line_impl(
+ name: &str,
+ ctx: &mut EvalContext,
+ args: &mut FuncArgs,
+ substate: impl Fn(&mut FontState) -> &mut Option<LineState> + 'static,
+) -> Value {
+ let color = args.eat_named(ctx, "color");
+ let position = args.eat_named(ctx, "position");
+ let strength = args.eat_named::<Linear>(ctx, "strength");
+ let extent = args.eat_named(ctx, "extent").unwrap_or_default();
+ let body = args.eat::<TemplateValue>(ctx);
+
+ // Suppress any existing strikethrough if strength is explicitly zero.
+ let state = strength.map_or(true, |s| !s.is_zero()).then(|| LineState {
+ fill: color.map(Fill::Color),
+ strength,
+ position,
+ extent,
+ });
+
+ Value::template(name, move |ctx| {
+ let snapshot = ctx.state.clone();
+
+ *substate(&mut ctx.state.font) = state;
+
+ if let Some(body) = &body {
+ body.exec(ctx);
+ ctx.state = snapshot;
+ }
+ })
+}
diff --git a/src/library/font.rs b/src/library/font.rs
index b3b037cd..a3fe6c13 100644
--- a/src/library/font.rs
+++ b/src/library/font.rs
@@ -99,7 +99,7 @@ pub fn font(ctx: &mut EvalContext, args: &mut FuncArgs) -> Value {
}
if let Some(color) = color {
- ctx.state.font.color = Fill::Color(color);
+ ctx.state.font.fill = Fill::Color(color);
}
if let Some(FontFamilies(serif)) = &serif {
diff --git a/src/library/mod.rs b/src/library/mod.rs
index 8caddc4c..553b39e6 100644
--- a/src/library/mod.rs
+++ b/src/library/mod.rs
@@ -5,6 +5,7 @@
mod align;
mod basic;
+mod decorations;
mod font;
mod grid;
mod image;
@@ -20,6 +21,7 @@ mod stack;
pub use self::image::*;
pub use align::*;
pub use basic::*;
+pub use decorations::*;
pub use font::*;
pub use grid::*;
pub use lang::*;
@@ -55,6 +57,7 @@ pub fn new() -> Scope {
std.def_func("lang", lang);
std.def_func("max", max);
std.def_func("min", min);
+ std.def_func("overline", overline);
std.def_func("pad", pad);
std.def_func("page", page);
std.def_func("pagebreak", pagebreak);
@@ -64,7 +67,9 @@ pub fn new() -> Scope {
std.def_func("rgb", rgb);
std.def_func("square", square);
std.def_func("stack", stack);
+ std.def_func("strike", strike);
std.def_func("type", type_);
+ std.def_func("underline", underline);
std.def_func("v", v);
// Colors.
diff --git a/tests/ref/text/decorations.png b/tests/ref/text/decorations.png
new file mode 100644
index 00000000..1bde2dd4
--- /dev/null
+++ b/tests/ref/text/decorations.png
Binary files differ
diff --git a/tests/typ/text/decorations.typ b/tests/typ/text/decorations.typ
new file mode 100644
index 00000000..3e298ece
--- /dev/null
+++ b/tests/typ/text/decorations.typ
@@ -0,0 +1,19 @@
+// Test text decorations.
+
+---
+#strike[Statements dreamt up by the utterly deranged.]
+
+Sometimes, we work #strike(extent: 5%, strength: 10pt)[in secret].
+There might be #strike(extent: 5%, strength: 10pt, color: #abcdef88)[redacted]
+things.
+
+---
+#underline(color: #fc0030)[Critical information is conveyed here.]
+#underline[
+ Still important, but not #underline(strength: 0pt)[mission ]critical.
+]
+
+#font(color: #fc0030, underline[Change with the wind.])
+
+---
+#overline(underline[Running amongst the wolves.])
diff --git a/tests/typeset.rs b/tests/typeset.rs
index 604a8275..90fc6005 100644
--- a/tests/typeset.rs
+++ b/tests/typeset.rs
@@ -8,7 +8,7 @@ use std::rc::Rc;
use image::{GenericImageView, Rgba};
use tiny_skia::{
Color, ColorU8, FillRule, FilterQuality, Paint, Pattern, Pixmap, Rect, SpreadMode,
- Transform,
+ Stroke, Transform,
};
use ttf_parser::{GlyphId, OutlineBuilder};
use walkdir::WalkDir;
@@ -474,6 +474,17 @@ fn draw_geometry(canvas: &mut Pixmap, ts: Transform, shape: &Shape, fill: Fill)
let path = convert_typst_path(&geom::Path::ellipse(size));
canvas.fill_path(&path, &paint, rule, ts, None);
}
+ Shape::Line(target, thickness) => {
+ let path = {
+ let mut builder = tiny_skia::PathBuilder::new();
+ builder.line_to(target.x.to_pt() as f32, target.y.to_pt() as f32);
+ builder.finish().unwrap()
+ };
+
+ let mut stroke = Stroke::default();
+ stroke.width = thickness.to_pt() as f32;
+ canvas.stroke_path(&path, &paint, &stroke, ts, None);
+ }
Shape::Path(ref path) => {
let path = convert_typst_path(path);
canvas.fill_path(&path, &paint, rule, ts, None);