diff --git a/src/exec/state.rs b/src/exec/state.rs index aeeeaed54..2b824afe0 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, + /// The specifications for a underline, if any. + pub underline: Option, + /// The specifications for a overline line, if any. + pub overline: Option, } 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, + /// 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, + /// 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, + /// 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, + /// The specifications for a underline, if any. + pub underline: Option, + /// The specifications for a overline line, if any. + pub overline: Option, } /// 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, + /// 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, + /// 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 1cc62332a..da3c9369d 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 516d4bbe7..a55a2a135 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>, diff --git a/src/layout/frame.rs b/src/layout/frame.rs index 6cecc7a34..119aeea65 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 f21778dee..8b3cbf8be 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 { - self.finish_region(); + fn finish(mut self, ctx: &LayoutContext) -> Vec { + 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 14ea86117..232e9fc56 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 000000000..ef9afd37c --- /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 + 'static, +) -> Value { + let color = args.eat_named(ctx, "color"); + let position = args.eat_named(ctx, "position"); + let strength = args.eat_named::(ctx, "strength"); + let extent = args.eat_named(ctx, "extent").unwrap_or_default(); + let body = args.eat::(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 b3b037cd8..a3fe6c136 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 8caddc4c3..553b39e65 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 000000000..1bde2dd4f Binary files /dev/null and b/tests/ref/text/decorations.png differ diff --git a/tests/typ/text/decorations.typ b/tests/typ/text/decorations.typ new file mode 100644 index 000000000..3e298ece9 --- /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 604a82758..90fc60054 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);