diff --git a/crates/typst-library/src/math/mod.rs b/crates/typst-library/src/math/mod.rs index 40df524fb..5d32af64b 100644 --- a/crates/typst-library/src/math/mod.rs +++ b/crates/typst-library/src/math/mod.rs @@ -289,10 +289,11 @@ impl Layout for EquationElem { frame.push_frame(Point::new(x, y), counter) } } else { + let font_size = TextElem::size_in(styles); let slack = ParElem::leading_in(styles) * 0.7; - let top_edge = TextElem::top_edge_in(styles).resolve(styles, &font, None); + let top_edge = TextElem::top_edge_in(styles).resolve(font_size, &font, None); let bottom_edge = - -TextElem::bottom_edge_in(styles).resolve(styles, &font, None); + -TextElem::bottom_edge_in(styles).resolve(font_size, &font, None); let ascent = top_edge.max(frame.ascent() - slack); let descent = bottom_edge.max(frame.descent() - slack); diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs index 9ec4ca329..c97ef325b 100644 --- a/crates/typst-library/src/text/deco.rs +++ b/crates/typst-library/src/text/deco.rs @@ -1,7 +1,7 @@ use kurbo::{BezPath, Line, ParamCurve}; use ttf_parser::{GlyphId, OutlineBuilder}; -use super::TextElem; +use super::{BottomEdge, BottomEdgeMetric, TextElem, TopEdge, TopEdgeMetric}; use crate::prelude::*; /// Underlines text. @@ -73,11 +73,12 @@ impl Show for UnderlineElem { #[tracing::instrument(name = "UnderlineElem::show", skip_all)] fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult { Ok(self.body().styled(TextElem::set_deco(Decoration { - line: DecoLine::Underline, - stroke: self.stroke(styles).unwrap_or_default(), - offset: self.offset(styles), + line: DecoLine::Underline { + stroke: self.stroke(styles).unwrap_or_default(), + offset: self.offset(styles), + evade: self.evade(styles), + }, extent: self.extent(styles), - evade: self.evade(styles), }))) } } @@ -157,11 +158,12 @@ impl Show for OverlineElem { #[tracing::instrument(name = "OverlineElem::show", skip_all)] fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult { Ok(self.body().styled(TextElem::set_deco(Decoration { - line: DecoLine::Overline, - stroke: self.stroke(styles).unwrap_or_default(), - offset: self.offset(styles), + line: DecoLine::Overline { + stroke: self.stroke(styles).unwrap_or_default(), + offset: self.offset(styles), + evade: self.evade(styles), + }, extent: self.extent(styles), - evade: self.evade(styles), }))) } } @@ -226,23 +228,98 @@ impl Show for StrikeElem { #[tracing::instrument(name = "StrikeElem::show", skip_all)] fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult { Ok(self.body().styled(TextElem::set_deco(Decoration { - line: DecoLine::Strikethrough, - stroke: self.stroke(styles).unwrap_or_default(), - offset: self.offset(styles), + // Note that we do not support evade option for strikethrough. + line: DecoLine::Strikethrough { + stroke: self.stroke(styles).unwrap_or_default(), + offset: self.offset(styles), + }, extent: self.extent(styles), - evade: false, }))) } } -/// Defines a line that is positioned over, under or on top of text. +/// Highlights text with a background color. +/// +/// ## Example { #example } +/// ```example +/// This is #highlight[important]. +/// ``` +/// +/// Display: Highlight +/// Category: text +#[element(Show)] +pub struct HighlightElem { + /// The color to highlight the text with. + /// (Default: 0xffff5f) + /// + /// ```example + /// This is #highlight(fill: blue)[with blue]. + /// ``` + #[default(Color::Rgba(RgbaColor::new(0xFF, 0xFF, 0x5F, 0xFF)).into())] + pub fill: Paint, + + /// The top end of the background rectangle. Note that top edge will update + /// to be always higher than the glyph's bounding box. + /// (default: "ascender") + /// + /// ```example + /// #set highlight(top-edge: "ascender") + /// #highlight[a] #highlight[aib] + /// + /// #set highlight(top-edge: "x-height") + /// #highlight[a] #highlight[aib] + /// ``` + #[default(TopEdge::Metric(TopEdgeMetric::Ascender))] + pub top_edge: TopEdge, + + /// The bottom end of the background rectangle. Note that top edge will update + /// to be always lower than the glyph's bounding box. + /// (default: "descender") + /// + /// ```example + /// #set highlight(bottom-edge: "descender") + /// #highlight[a] #highlight[ap] + /// + /// #set highlight(bottom-edge: "baseline") + /// #highlight[a] #highlight[ap] + /// ``` + #[default(BottomEdge::Metric(BottomEdgeMetric::Descender))] + pub bottom_edge: BottomEdge, + + /// The amount by which to extend the background to the sides beyond + /// (or within if negative) the content. + /// + /// ```example + /// A long #highlight(extent: 4pt)[background]. + /// ``` + #[resolve] + pub extent: Length, + + /// The content that should be highlighted. + #[required] + pub body: Content, +} + +impl Show for HighlightElem { + #[tracing::instrument(name = "HighlightElem::show", skip_all)] + fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult { + Ok(self.body().styled(TextElem::set_deco(Decoration { + line: DecoLine::Highlight { + fill: self.fill(styles), + top_edge: self.top_edge(styles), + bottom_edge: self.bottom_edge(styles), + }, + extent: self.extent(styles), + }))) + } +} + +/// Defines a line-based decoration that is positioned over, under or on top of text, +/// or highlights the text with a background. #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct Decoration { - pub line: DecoLine, - pub stroke: PartialStroke, - pub offset: Smart, - pub extent: Abs, - pub evade: bool, + line: DecoLine, + extent: Abs, } impl Fold for Decoration { @@ -259,11 +336,12 @@ cast! { } /// A kind of decorative line. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -pub enum DecoLine { - Underline, - Strikethrough, - Overline, +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +enum DecoLine { + Underline { stroke: PartialStroke, offset: Smart, evade: bool }, + Strikethrough { stroke: PartialStroke, offset: Smart }, + Overline { stroke: PartialStroke, offset: Smart, evade: bool }, + Highlight { fill: Paint, top_edge: TopEdge, bottom_edge: BottomEdge }, } /// Add line decorations to a single run of shaped text. @@ -271,19 +349,36 @@ pub(super) fn decorate( frame: &mut Frame, deco: &Decoration, text: &TextItem, + width: Abs, shift: Abs, pos: Point, - width: Abs, ) { let font_metrics = text.font.metrics(); - let metrics = match deco.line { - DecoLine::Strikethrough => font_metrics.strikethrough, - DecoLine::Overline => font_metrics.overline, - DecoLine::Underline => font_metrics.underline, + + if let DecoLine::Highlight { fill, top_edge, bottom_edge } = &deco.line { + let (top, bottom) = determine_edges(text, *top_edge, *bottom_edge); + let rect = Geometry::Rect(Size::new(width + 2.0 * deco.extent, top - bottom)) + .filled(fill.clone()); + let origin = Point::new(pos.x - deco.extent, pos.y - top - shift); + frame.prepend(origin, FrameItem::Shape(rect, Span::detached())); + return; + } + + let (stroke, metrics, offset, evade) = match &deco.line { + DecoLine::Strikethrough { stroke, offset } => { + (stroke, font_metrics.strikethrough, offset, false) + } + DecoLine::Overline { stroke, offset, evade } => { + (stroke, font_metrics.overline, offset, *evade) + } + DecoLine::Underline { stroke, offset, evade } => { + (stroke, font_metrics.underline, offset, *evade) + } + _ => return, }; - let offset = deco.offset.unwrap_or(-metrics.position.at(text.size)) - shift; - let stroke = deco.stroke.clone().unwrap_or(Stroke { + let offset = offset.unwrap_or(-metrics.position.at(text.size)) - shift; + let stroke = stroke.clone().unwrap_or(Stroke { paint: text.fill.clone(), thickness: metrics.thickness.at(text.size), ..Stroke::default() @@ -299,13 +394,13 @@ pub(super) fn decorate( let origin = Point::new(from, pos.y + offset); let target = Point::new(to - from, Abs::zero()); - if target.x >= min_width || !deco.evade { + if target.x >= min_width || !evade { let shape = Geometry::Line(target).stroked(stroke.clone()); frame.push(origin, FrameItem::Shape(shape, Span::detached())); } }; - if !deco.evade { + if !evade { push_segment(start, end); return; } @@ -366,6 +461,31 @@ pub(super) fn decorate( } } +// Return the top/bottom edge of the text given the metric of the font. +fn determine_edges( + text: &TextItem, + top_edge: TopEdge, + bottom_edge: BottomEdge, +) -> (Abs, Abs) { + let mut bbox = None; + if top_edge.is_bounds() || bottom_edge.is_bounds() { + let ttf = text.font.ttf(); + bbox = text + .glyphs + .iter() + .filter_map(|g| ttf.glyph_bounding_box(ttf_parser::GlyphId(g.id))) + .reduce(|a, b| ttf_parser::Rect { + y_max: a.y_max.max(b.y_max), + y_min: a.y_min.min(b.y_min), + ..a + }); + } + + let top = top_edge.resolve(text.size, &text.font, bbox); + let bottom = bottom_edge.resolve(text.size, &text.font, bbox); + (top, bottom) +} + /// Builds a kurbo [`BezPath`] for a glyph. struct BezPathBuilder { path: BezPath, diff --git a/crates/typst-library/src/text/mod.rs b/crates/typst-library/src/text/mod.rs index 9ab18ad96..4f3c15919 100644 --- a/crates/typst-library/src/text/mod.rs +++ b/crates/typst-library/src/text/mod.rs @@ -35,6 +35,7 @@ pub(super) fn define(global: &mut Scope) { global.define("super", SuperElem::func()); global.define("underline", UnderlineElem::func()); global.define("strike", StrikeElem::func()); + global.define("highlight", HighlightElem::func()); global.define("overline", OverlineElem::func()); global.define("raw", RawElem::func()); global.define("lorem", lorem_func()); @@ -652,17 +653,17 @@ impl TopEdge { } /// Resolve the value of the text edge given a font's metrics. - pub fn resolve(self, styles: StyleChain, font: &Font, bbox: Option) -> Abs { + pub fn resolve(self, font_size: Abs, font: &Font, bbox: Option) -> Abs { match self { TopEdge::Metric(metric) => { if let Ok(metric) = metric.try_into() { - font.metrics().vertical(metric).resolve(styles) + font.metrics().vertical(metric).at(font_size) } else { - bbox.map(|bbox| (font.to_em(bbox.y_max)).resolve(styles)) + bbox.map(|bbox| (font.to_em(bbox.y_max)).at(font_size)) .unwrap_or_default() } } - TopEdge::Length(length) => length.resolve(styles), + TopEdge::Length(length) => length.at(font_size), } } } @@ -722,17 +723,17 @@ impl BottomEdge { } /// Resolve the value of the text edge given a font's metrics. - pub fn resolve(self, styles: StyleChain, font: &Font, bbox: Option) -> Abs { + pub fn resolve(self, font_size: Abs, font: &Font, bbox: Option) -> Abs { match self { BottomEdge::Metric(metric) => { if let Ok(metric) = metric.try_into() { - font.metrics().vertical(metric).resolve(styles) + font.metrics().vertical(metric).at(font_size) } else { - bbox.map(|bbox| (font.to_em(bbox.y_min)).resolve(styles)) + bbox.map(|bbox| (font.to_em(bbox.y_min)).at(font_size)) .unwrap_or_default() } } - BottomEdge::Length(length) => length.resolve(styles), + BottomEdge::Length(length) => length.at(font_size), } } } diff --git a/crates/typst-library/src/text/shaping.rs b/crates/typst-library/src/text/shaping.rs index 53289e263..21331a78d 100644 --- a/crates/typst-library/src/text/shaping.rs +++ b/crates/typst-library/src/text/shaping.rs @@ -293,15 +293,17 @@ impl<'a> ShapedText<'a> { glyphs, }; - let layer = frame.layer(); let width = item.width(); - - // Apply line decorations. - for deco in &decos { - decorate(&mut frame, deco, &item, shift, pos, width); + if decos.is_empty() { + frame.push(pos, FrameItem::Text(item)); + } else { + // Apply line decorations. + frame.push(pos, FrameItem::Text(item.clone())); + for deco in &decos { + decorate(&mut frame, deco, &item, width, shift, pos); + } } - frame.insert(layer, pos, FrameItem::Text(item)); offset += width; } @@ -321,8 +323,8 @@ impl<'a> ShapedText<'a> { // Expand top and bottom by reading the font's vertical metrics. let mut expand = |font: &Font, bbox: Option| { - top.set_max(top_edge.resolve(self.styles, font, bbox)); - bottom.set_max(-bottom_edge.resolve(self.styles, font, bbox)); + top.set_max(top_edge.resolve(self.size, font, bbox)); + bottom.set_max(-bottom_edge.resolve(self.size, font, bbox)); }; if self.glyphs.is_empty() { diff --git a/crates/typst/src/geom/length.rs b/crates/typst/src/geom/length.rs index 7d0a9841b..ccd5362ce 100644 --- a/crates/typst/src/geom/length.rs +++ b/crates/typst/src/geom/length.rs @@ -34,6 +34,11 @@ impl Length { None } } + + /// Convert to an absolute length at the given font size. + pub fn at(self, font_size: Abs) -> Abs { + self.abs + self.em.at(font_size) + } } impl Debug for Length { diff --git a/tests/ref/text/deco.png b/tests/ref/text/deco.png index 7e3195cad..006c969fb 100644 Binary files a/tests/ref/text/deco.png and b/tests/ref/text/deco.png differ diff --git a/tests/typ/text/deco.typ b/tests/typ/text/deco.typ index f3be17e72..b79b80b22 100644 --- a/tests/typ/text/deco.typ +++ b/tests/typ/text/deco.typ @@ -20,14 +20,40 @@ --- #let redact = strike.with(stroke: 10pt, extent: 0.05em) -#let highlight = strike.with(stroke: 10pt + rgb("abcdef88"), extent: 0.05em) +#let highlight-custom = strike.with(stroke: 10pt + rgb("abcdef88"), extent: 0.05em) // Abuse thickness and transparency for redacting and highlighting stuff. Sometimes, we work #redact[in secret]. -There might be #highlight[redacted] things. - underline() +There might be #highlight-custom[redacted] things. --- // Test stroke folding. #set underline(stroke: 2pt, offset: 2pt) #underline(text(red, [DANGER!])) + +--- +// Test highlight. +This is the built-in #highlight[highlight with default color]. +We can also specify a customized value +#highlight(fill: green.lighten(80%))[to highlight]. + +--- +// Test default highlight bounds. +#highlight[ace], +#highlight[base], +#highlight[super], +#highlight[phone #sym.integral] + +--- +// Test a tighter highlight. +#set highlight(top-edge: "x-height", bottom-edge: "baseline") +#highlight[ace], +#highlight[base], +#highlight[super], +#highlight[phone #sym.integral] + +--- +// Test a bounds highlight. +#set highlight(top-edge: "bounds", bottom-edge: "bounds") +#highlight[abc] +#highlight[abc #sym.integral]