From c28708aa196eaca247cdab6b5e8af9751b4f1dad Mon Sep 17 00:00:00 2001 From: Martin Haug Date: Thu, 10 Jun 2021 23:08:52 +0200 Subject: [PATCH] Text decorations --- src/exec/state.rs | 70 +++++++++++++++++++++++-- src/export/pdf.rs | 23 ++++++++- src/font.rs | 9 ++++ src/layout/frame.rs | 4 +- src/layout/par.rs | 16 +++--- src/layout/shaping.rs | 92 +++++++++++++++++++++++++++++++-- src/library/decorations.rs | 84 ++++++++++++++++++++++++++++++ src/library/font.rs | 2 +- src/library/mod.rs | 5 ++ tests/ref/text/decorations.png | Bin 0 -> 10184 bytes tests/typ/text/decorations.typ | 19 +++++++ tests/typeset.rs | 13 ++++- 12 files changed, 316 insertions(+), 21 deletions(-) create mode 100644 src/library/decorations.rs create mode 100644 tests/ref/text/decorations.png create mode 100644 tests/typ/text/decorations.typ 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 0000000000000000000000000000000000000000..1bde2dd4f7a087ca7e1cf120019c58e0b12c52aa GIT binary patch literal 10184 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 { + 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);