summaryrefslogtreecommitdiff
path: root/src/library/text
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2022-02-28 15:50:48 +0100
committerLaurenz <laurmaedje@gmail.com>2022-02-28 23:54:34 +0100
commit3ca5b238238e1128aa7bbfbd5db9e632045d8600 (patch)
tree2471f4b340a15695b7f4d518c0b39fabaea676c4 /src/library/text
parentb63c21c91d99a1554a019dc275f955d3e6a34271 (diff)
Reorganize library
Diffstat (limited to 'src/library/text')
-rw-r--r--src/library/text/deco.rs250
-rw-r--r--src/library/text/link.rs64
-rw-r--r--src/library/text/mod.rs409
-rw-r--r--src/library/text/par.rs617
-rw-r--r--src/library/text/raw.rs127
-rw-r--r--src/library/text/shaping.rs552
6 files changed, 2019 insertions, 0 deletions
diff --git a/src/library/text/deco.rs b/src/library/text/deco.rs
new file mode 100644
index 00000000..a288c995
--- /dev/null
+++ b/src/library/text/deco.rs
@@ -0,0 +1,250 @@
+use kurbo::{BezPath, Line, ParamCurve};
+use ttf_parser::{GlyphId, OutlineBuilder};
+
+use super::TextNode;
+use crate::font::FontStore;
+use crate::library::prelude::*;
+
+/// Typeset underline, stricken-through or overlined text.
+#[derive(Debug, Hash)]
+pub struct DecoNode<const L: DecoLine>(pub Template);
+
+/// Typeset underlined text.
+pub type UnderlineNode = DecoNode<UNDERLINE>;
+
+/// Typeset stricken-through text.
+pub type StrikethroughNode = DecoNode<STRIKETHROUGH>;
+
+/// Typeset overlined text.
+pub type OverlineNode = DecoNode<OVERLINE>;
+
+#[class]
+impl<const L: DecoLine> DecoNode<L> {
+ /// Stroke color of the line, defaults to the text color if `None`.
+ #[shorthand]
+ pub const STROKE: Option<Paint> = None;
+ /// Thickness of the line's strokes (dependent on scaled font size), read
+ /// from the font tables if `None`.
+ #[shorthand]
+ pub const THICKNESS: Option<Linear> = None;
+ /// Position of the line relative to the baseline (dependent on scaled font
+ /// size), read from the font tables if `None`.
+ pub const OFFSET: Option<Linear> = None;
+ /// Amount that the line will be longer or shorter than its associated text
+ /// (dependent on scaled font size).
+ pub const EXTENT: Linear = Linear::zero();
+ /// Whether the line skips sections in which it would collide
+ /// with the glyphs. Does not apply to strikethrough.
+ pub const EVADE: bool = true;
+
+ fn construct(_: &mut Context, args: &mut Args) -> TypResult<Template> {
+ Ok(Template::show(Self(args.expect::<Template>("body")?)))
+ }
+}
+
+impl<const L: DecoLine> Show for DecoNode<L> {
+ fn show(&self, ctx: &mut Context, styles: StyleChain) -> TypResult<Template> {
+ Ok(styles
+ .show(self, ctx, [Value::Template(self.0.clone())])?
+ .unwrap_or_else(|| {
+ self.0.clone().styled(TextNode::LINES, vec![Decoration {
+ line: L,
+ stroke: styles.get(Self::STROKE),
+ thickness: styles.get(Self::THICKNESS),
+ offset: styles.get(Self::OFFSET),
+ extent: styles.get(Self::EXTENT),
+ evade: styles.get(Self::EVADE),
+ }])
+ }))
+ }
+}
+
+/// Defines a line that is positioned over, under or on top of text.
+///
+/// For more details, see [`DecoNode`].
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct Decoration {
+ pub line: DecoLine,
+ pub stroke: Option<Paint>,
+ pub thickness: Option<Linear>,
+ pub offset: Option<Linear>,
+ pub extent: Linear,
+ pub evade: bool,
+}
+
+/// A kind of decorative line.
+pub type DecoLine = usize;
+
+/// A line under text.
+pub const UNDERLINE: DecoLine = 0;
+
+/// A line through text.
+pub const STRIKETHROUGH: DecoLine = 1;
+
+/// A line over text.
+pub const OVERLINE: DecoLine = 2;
+
+/// Add line decorations to a single run of shaped text.
+pub fn decorate(
+ frame: &mut Frame,
+ deco: &Decoration,
+ fonts: &FontStore,
+ text: &Text,
+ pos: Point,
+ width: Length,
+) {
+ let face = fonts.get(text.face_id);
+ let metrics = match deco.line {
+ STRIKETHROUGH => face.strikethrough,
+ OVERLINE => face.overline,
+ UNDERLINE | _ => face.underline,
+ };
+
+ let evade = deco.evade && deco.line != STRIKETHROUGH;
+ let extent = deco.extent.resolve(text.size);
+ let offset = deco
+ .offset
+ .map(|s| s.resolve(text.size))
+ .unwrap_or(-metrics.position.resolve(text.size));
+
+ let stroke = Stroke {
+ paint: deco.stroke.unwrap_or(text.fill),
+ thickness: deco
+ .thickness
+ .map(|s| s.resolve(text.size))
+ .unwrap_or(metrics.thickness.resolve(text.size)),
+ };
+
+ let gap_padding = 0.08 * text.size;
+ let min_width = 0.162 * text.size;
+
+ let mut start = pos.x - extent;
+ let end = pos.x + (width + 2.0 * extent);
+
+ let mut push_segment = |from: Length, to: Length| {
+ let origin = Point::new(from, pos.y + offset);
+ let target = Point::new(to - from, Length::zero());
+
+ if target.x >= min_width || !evade {
+ let shape = Shape::stroked(Geometry::Line(target), stroke);
+ frame.push(origin, Element::Shape(shape));
+ }
+ };
+
+ if !evade {
+ push_segment(start, end);
+ return;
+ }
+
+ let line = Line::new(
+ kurbo::Point::new(pos.x.to_raw(), offset.to_raw()),
+ kurbo::Point::new((pos.x + width).to_raw(), offset.to_raw()),
+ );
+
+ let mut x = pos.x;
+ let mut intersections = vec![];
+
+ for glyph in text.glyphs.iter() {
+ let dx = glyph.x_offset.resolve(text.size) + x;
+ let mut builder = BezPathBuilder::new(face.units_per_em, text.size, dx.to_raw());
+
+ let bbox = face.ttf().outline_glyph(GlyphId(glyph.id), &mut builder);
+ let path = builder.finish();
+
+ x += glyph.x_advance.resolve(text.size);
+
+ // Only do the costly segments intersection test if the line
+ // intersects the bounding box.
+ if bbox.map_or(false, |bbox| {
+ let y_min = -face.to_em(bbox.y_max).resolve(text.size);
+ let y_max = -face.to_em(bbox.y_min).resolve(text.size);
+
+ offset >= y_min && offset <= y_max
+ }) {
+ // Find all intersections of segments with the line.
+ intersections.extend(
+ path.segments()
+ .flat_map(|seg| seg.intersect_line(line))
+ .map(|is| Length::raw(line.eval(is.line_t).x)),
+ );
+ }
+ }
+
+ // When emitting the decorative line segments, we move from left to
+ // right. The intersections are not necessarily in this order, yet.
+ intersections.sort();
+
+ for gap in intersections.chunks_exact(2) {
+ let l = gap[0] - gap_padding;
+ let r = gap[1] + gap_padding;
+
+ if start >= end {
+ break;
+ }
+
+ if start >= l {
+ start = r;
+ continue;
+ }
+
+ push_segment(start, l);
+ start = r;
+ }
+
+ if start < end {
+ push_segment(start, end);
+ }
+}
+
+/// Builds a kurbo [`BezPath`] for a glyph.
+struct BezPathBuilder {
+ path: BezPath,
+ units_per_em: f64,
+ font_size: Length,
+ x_offset: f64,
+}
+
+impl BezPathBuilder {
+ fn new(units_per_em: f64, font_size: Length, x_offset: f64) -> Self {
+ Self {
+ path: BezPath::new(),
+ units_per_em,
+ font_size,
+ x_offset,
+ }
+ }
+
+ fn finish(self) -> BezPath {
+ self.path
+ }
+
+ fn p(&self, x: f32, y: f32) -> kurbo::Point {
+ kurbo::Point::new(self.s(x) + self.x_offset, -self.s(y))
+ }
+
+ fn s(&self, v: f32) -> f64 {
+ Em::from_units(v, self.units_per_em).resolve(self.font_size).to_raw()
+ }
+}
+
+impl OutlineBuilder for BezPathBuilder {
+ fn move_to(&mut self, x: f32, y: f32) {
+ self.path.move_to(self.p(x, y));
+ }
+
+ fn line_to(&mut self, x: f32, y: f32) {
+ self.path.line_to(self.p(x, y));
+ }
+
+ fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
+ self.path.quad_to(self.p(x1, y1), self.p(x, y));
+ }
+
+ fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
+ self.path.curve_to(self.p(x1, y1), self.p(x2, y2), self.p(x, y));
+ }
+
+ fn close(&mut self) {
+ self.path.close_path();
+ }
+}
diff --git a/src/library/text/link.rs b/src/library/text/link.rs
new file mode 100644
index 00000000..29f41927
--- /dev/null
+++ b/src/library/text/link.rs
@@ -0,0 +1,64 @@
+use super::TextNode;
+use crate::library::prelude::*;
+use crate::util::EcoString;
+
+/// Link text and other elements to an URL.
+#[derive(Debug, Hash)]
+pub struct LinkNode {
+ /// The url the link points to.
+ pub url: EcoString,
+ /// How the link is represented.
+ pub body: Option<Template>,
+}
+
+#[class]
+impl LinkNode {
+ /// The fill color of text in the link. Just the surrounding text color
+ /// if `auto`.
+ pub const FILL: Smart<Paint> = Smart::Auto;
+ /// Whether to underline link.
+ pub const UNDERLINE: bool = true;
+
+ fn construct(_: &mut Context, args: &mut Args) -> TypResult<Template> {
+ Ok(Template::show(Self {
+ url: args.expect::<EcoString>("url")?,
+ body: args.find()?,
+ }))
+ }
+}
+
+impl Show for LinkNode {
+ fn show(&self, ctx: &mut Context, styles: StyleChain) -> TypResult<Template> {
+ let mut body = styles
+ .show(self, ctx, [
+ Value::Str(self.url.clone()),
+ match &self.body {
+ Some(body) => Value::Template(body.clone()),
+ None => Value::None,
+ },
+ ])?
+ .or_else(|| self.body.clone())
+ .unwrap_or_else(|| {
+ let url = &self.url;
+ let mut text = url.as_str();
+ for prefix in ["mailto:", "tel:"] {
+ text = text.trim_start_matches(prefix);
+ }
+ let shorter = text.len() < url.len();
+ Template::Text(if shorter { text.into() } else { url.clone() })
+ });
+
+ let mut map = StyleMap::new();
+ map.set(TextNode::LINK, Some(self.url.clone()));
+
+ if let Smart::Custom(fill) = styles.get(Self::FILL) {
+ map.set(TextNode::FILL, fill);
+ }
+
+ if styles.get(Self::UNDERLINE) {
+ body = body.underlined();
+ }
+
+ Ok(body.styled_with_map(map))
+ }
+}
diff --git a/src/library/text/mod.rs b/src/library/text/mod.rs
new file mode 100644
index 00000000..0df59007
--- /dev/null
+++ b/src/library/text/mod.rs
@@ -0,0 +1,409 @@
+mod deco;
+mod link;
+mod par;
+mod raw;
+mod shaping;
+
+pub use deco::*;
+pub use link::*;
+pub use par::*;
+pub use raw::*;
+pub use shaping::*;
+
+use std::borrow::Cow;
+use std::ops::BitXor;
+
+use ttf_parser::Tag;
+
+use crate::font::{Face, FontStretch, FontStyle, FontWeight, VerticalFontMetric};
+use crate::library::prelude::*;
+use crate::util::EcoString;
+
+/// A single run of text with the same style.
+#[derive(Hash)]
+pub struct TextNode;
+
+#[class]
+impl TextNode {
+ /// A prioritized sequence of font families.
+ #[variadic]
+ pub const FAMILY: Vec<FontFamily> = vec![FontFamily::SansSerif];
+ /// The serif font family/families.
+ pub const SERIF: Vec<NamedFamily> = vec![NamedFamily::new("IBM Plex Serif")];
+ /// The sans-serif font family/families.
+ pub const SANS_SERIF: Vec<NamedFamily> = vec![NamedFamily::new("IBM Plex Sans")];
+ /// The monospace font family/families.
+ pub const MONOSPACE: Vec<NamedFamily> = vec![NamedFamily::new("IBM Plex Mono")];
+ /// Whether to allow font fallback when the primary font list contains no
+ /// match.
+ pub const FALLBACK: bool = true;
+
+ /// How the font is styled.
+ pub const STYLE: FontStyle = FontStyle::Normal;
+ /// The boldness / thickness of the font's glyphs.
+ pub const WEIGHT: FontWeight = FontWeight::REGULAR;
+ /// The width of the glyphs.
+ pub const STRETCH: FontStretch = FontStretch::NORMAL;
+ /// The glyph fill color.
+ #[shorthand]
+ pub const FILL: Paint = Color::BLACK.into();
+
+ /// The size of the glyphs.
+ #[shorthand]
+ #[fold(Linear::compose)]
+ pub const SIZE: Linear = Length::pt(11.0).into();
+ /// The amount of space that should be added between characters.
+ pub const TRACKING: Em = Em::zero();
+ /// The top end of the text bounding box.
+ pub const TOP_EDGE: VerticalFontMetric = VerticalFontMetric::CapHeight;
+ /// The bottom end of the text bounding box.
+ pub const BOTTOM_EDGE: VerticalFontMetric = VerticalFontMetric::Baseline;
+
+ /// Whether to apply kerning ("kern").
+ pub const KERNING: bool = true;
+ /// Whether small capital glyphs should be used. ("smcp")
+ pub const SMALLCAPS: bool = false;
+ /// Whether to apply stylistic alternates. ("salt")
+ pub const ALTERNATES: bool = false;
+ /// Which stylistic set to apply. ("ss01" - "ss20")
+ pub const STYLISTIC_SET: Option<StylisticSet> = None;
+ /// Whether standard ligatures are active. ("liga", "clig")
+ pub const LIGATURES: bool = true;
+ /// Whether ligatures that should be used sparingly are active. ("dlig")
+ pub const DISCRETIONARY_LIGATURES: bool = false;
+ /// Whether historical ligatures are active. ("hlig")
+ pub const HISTORICAL_LIGATURES: bool = false;
+ /// Which kind of numbers / figures to select.
+ pub const NUMBER_TYPE: Smart<NumberType> = Smart::Auto;
+ /// The width of numbers / figures.
+ pub const NUMBER_WIDTH: Smart<NumberWidth> = Smart::Auto;
+ /// How to position numbers.
+ pub const NUMBER_POSITION: NumberPosition = NumberPosition::Normal;
+ /// Whether to have a slash through the zero glyph. ("zero")
+ pub const SLASHED_ZERO: bool = false;
+ /// Whether to convert fractions. ("frac")
+ pub const FRACTIONS: bool = false;
+ /// Raw OpenType features to apply.
+ pub const FEATURES: Vec<(Tag, u32)> = vec![];
+
+ /// Whether the font weight should be increased by 300.
+ #[skip]
+ #[fold(bool::bitxor)]
+ pub const STRONG: bool = false;
+ /// Whether the the font style should be inverted.
+ #[skip]
+ #[fold(bool::bitxor)]
+ pub const EMPH: bool = false;
+ /// Whether a monospace font should be preferred.
+ #[skip]
+ pub const MONOSPACED: bool = false;
+ /// The case transformation that should be applied to the next.
+ #[skip]
+ pub const CASE: Option<Case> = None;
+ /// Decorative lines.
+ #[skip]
+ #[fold(|a, b| a.into_iter().chain(b).collect())]
+ pub const LINES: Vec<Decoration> = vec![];
+ /// An URL the text should link to.
+ #[skip]
+ pub const LINK: Option<EcoString> = None;
+
+ fn construct(_: &mut Context, args: &mut Args) -> TypResult<Template> {
+ // The text constructor is special: It doesn't create a text node.
+ // Instead, it leaves the passed argument structurally unchanged, but
+ // styles all text in it.
+ args.expect("body")
+ }
+}
+
+/// Strong text, rendered in boldface.
+#[derive(Debug, Hash)]
+pub struct StrongNode(pub Template);
+
+#[class]
+impl StrongNode {
+ fn construct(_: &mut Context, args: &mut Args) -> TypResult<Template> {
+ Ok(Template::show(Self(args.expect("body")?)))
+ }
+}
+
+impl Show for StrongNode {
+ fn show(&self, ctx: &mut Context, styles: StyleChain) -> TypResult<Template> {
+ Ok(styles
+ .show(self, ctx, [Value::Template(self.0.clone())])?
+ .unwrap_or_else(|| self.0.clone().styled(TextNode::STRONG, true)))
+ }
+}
+
+/// Emphasized text, rendered with an italic face.
+#[derive(Debug, Hash)]
+pub struct EmphNode(pub Template);
+
+#[class]
+impl EmphNode {
+ fn construct(_: &mut Context, args: &mut Args) -> TypResult<Template> {
+ Ok(Template::show(Self(args.expect("body")?)))
+ }
+}
+
+impl Show for EmphNode {
+ fn show(&self, ctx: &mut Context, styles: StyleChain) -> TypResult<Template> {
+ Ok(styles
+ .show(self, ctx, [Value::Template(self.0.clone())])?
+ .unwrap_or_else(|| self.0.clone().styled(TextNode::EMPH, true)))
+ }
+}
+
+/// A generic or named font family.
+#[derive(Clone, Eq, PartialEq, Hash)]
+pub enum FontFamily {
+ /// A family that has "serifs", small strokes attached to letters.
+ Serif,
+ /// A family in which glyphs do not have "serifs", small attached strokes.
+ SansSerif,
+ /// A family in which (almost) all glyphs are of equal width.
+ Monospace,
+ /// A specific font family like "Arial".
+ Named(NamedFamily),
+}
+
+impl Debug for FontFamily {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Serif => f.pad("serif"),
+ Self::SansSerif => f.pad("sans-serif"),
+ Self::Monospace => f.pad("monospace"),
+ Self::Named(s) => s.fmt(f),
+ }
+ }
+}
+
+dynamic! {
+ FontFamily: "font family",
+ Value::Str(string) => Self::Named(NamedFamily::new(&string)),
+}
+
+castable! {
+ Vec<FontFamily>,
+ Expected: "string, generic family or array thereof",
+ Value::Str(string) => vec![FontFamily::Named(NamedFamily::new(&string))],
+ Value::Array(values) => {
+ values.into_iter().filter_map(|v| v.cast().ok()).collect()
+ },
+ @family: FontFamily => vec![family.clone()],
+}
+
+/// A specific font family like "Arial".
+#[derive(Clone, Eq, PartialEq, Hash)]
+pub struct NamedFamily(EcoString);
+
+impl NamedFamily {
+ /// Create a named font family variant.
+ pub fn new(string: &str) -> Self {
+ Self(string.to_lowercase().into())
+ }
+
+ /// The lowercased family name.
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl Debug for NamedFamily {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+castable! {
+ Vec<NamedFamily>,
+ Expected: "string or array of strings",
+ Value::Str(string) => vec![NamedFamily::new(&string)],
+ Value::Array(values) => values
+ .into_iter()
+ .filter_map(|v| v.cast().ok())
+ .map(|string: EcoString| NamedFamily::new(&string))
+ .collect(),
+}
+
+castable! {
+ FontStyle,
+ Expected: "string",
+ Value::Str(string) => match string.as_str() {
+ "normal" => Self::Normal,
+ "italic" => Self::Italic,
+ "oblique" => Self::Oblique,
+ _ => Err(r#"expected "normal", "italic" or "oblique""#)?,
+ },
+}
+
+castable! {
+ FontWeight,
+ Expected: "integer or string",
+ Value::Int(v) => Value::Int(v)
+ .cast::<usize>()?
+ .try_into()
+ .map_or(Self::BLACK, Self::from_number),
+ Value::Str(string) => match string.as_str() {
+ "thin" => Self::THIN,
+ "extralight" => Self::EXTRALIGHT,
+ "light" => Self::LIGHT,
+ "regular" => Self::REGULAR,
+ "medium" => Self::MEDIUM,
+ "semibold" => Self::SEMIBOLD,
+ "bold" => Self::BOLD,
+ "extrabold" => Self::EXTRABOLD,
+ "black" => Self::BLACK,
+ _ => Err("unknown font weight")?,
+ },
+}
+
+castable! {
+ FontStretch,
+ Expected: "relative",
+ Value::Relative(v) => Self::from_ratio(v.get() as f32),
+}
+
+castable! {
+ Em,
+ Expected: "float",
+ Value::Float(v) => Self::new(v),
+}
+
+castable! {
+ VerticalFontMetric,
+ Expected: "linear or string",
+ Value::Length(v) => Self::Linear(v.into()),
+ Value::Relative(v) => Self::Linear(v.into()),
+ Value::Linear(v) => Self::Linear(v),
+ Value::Str(string) => match string.as_str() {
+ "ascender" => Self::Ascender,
+ "cap-height" => Self::CapHeight,
+ "x-height" => Self::XHeight,
+ "baseline" => Self::Baseline,
+ "descender" => Self::Descender,
+ _ => Err("unknown font metric")?,
+ },
+}
+
+/// A stylistic set in a font face.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct StylisticSet(u8);
+
+impl StylisticSet {
+ /// Creates a new set, clamping to 1-20.
+ pub fn new(index: u8) -> Self {
+ Self(index.clamp(1, 20))
+ }
+
+ /// Get the value, guaranteed to be 1-20.
+ pub fn get(self) -> u8 {
+ self.0
+ }
+}
+
+castable! {
+ StylisticSet,
+ Expected: "integer",
+ Value::Int(v) => match v {
+ 1 ..= 20 => Self::new(v as u8),
+ _ => Err("must be between 1 and 20")?,
+ },
+}
+
+/// Which kind of numbers / figures to select.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum NumberType {
+ /// Numbers that fit well with capital text. ("lnum")
+ Lining,
+ /// Numbers that fit well into flow of upper- and lowercase text. ("onum")
+ OldStyle,
+}
+
+castable! {
+ NumberType,
+ Expected: "string",
+ Value::Str(string) => match string.as_str() {
+ "lining" => Self::Lining,
+ "old-style" => Self::OldStyle,
+ _ => Err(r#"expected "lining" or "old-style""#)?,
+ },
+}
+
+/// The width of numbers / figures.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum NumberWidth {
+ /// Number widths are glyph specific. ("pnum")
+ Proportional,
+ /// All numbers are of equal width / monospaced. ("tnum")
+ Tabular,
+}
+
+castable! {
+ NumberWidth,
+ Expected: "string",
+ Value::Str(string) => match string.as_str() {
+ "proportional" => Self::Proportional,
+ "tabular" => Self::Tabular,
+ _ => Err(r#"expected "proportional" or "tabular""#)?,
+ },
+}
+
+/// How to position numbers.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum NumberPosition {
+ /// Numbers are positioned on the same baseline as text.
+ Normal,
+ /// Numbers are smaller and placed at the bottom. ("subs")
+ Subscript,
+ /// Numbers are smaller and placed at the top. ("sups")
+ Superscript,
+}
+
+castable! {
+ NumberPosition,
+ Expected: "string",
+ Value::Str(string) => match string.as_str() {
+ "normal" => Self::Normal,
+ "subscript" => Self::Subscript,
+ "superscript" => Self::Superscript,
+ _ => Err(r#"expected "normal", "subscript" or "superscript""#)?,
+ },
+}
+
+castable! {
+ Vec<(Tag, u32)>,
+ Expected: "array of strings or dictionary mapping tags to integers",
+ Value::Array(values) => values
+ .into_iter()
+ .filter_map(|v| v.cast().ok())
+ .map(|string: EcoString| (Tag::from_bytes_lossy(string.as_bytes()), 1))
+ .collect(),
+ Value::Dict(values) => values
+ .into_iter()
+ .filter_map(|(k, v)| {
+ let tag = Tag::from_bytes_lossy(k.as_bytes());
+ let num = v.cast::<i64>().ok()?.try_into().ok()?;
+ Some((tag, num))
+ })
+ .collect(),
+}
+
+/// A case transformation on text.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum Case {
+ /// Everything is uppercased.
+ Upper,
+ /// Everything is lowercased.
+ Lower,
+}
+
+impl Case {
+ /// Apply the case to a string of text.
+ pub fn apply(self, text: &str) -> String {
+ match self {
+ Self::Upper => text.to_uppercase(),
+ Self::Lower => text.to_lowercase(),
+ }
+ }
+}
diff --git a/src/library/text/par.rs b/src/library/text/par.rs
new file mode 100644
index 00000000..812231c2
--- /dev/null
+++ b/src/library/text/par.rs
@@ -0,0 +1,617 @@
+use std::sync::Arc;
+
+use either::Either;
+use unicode_bidi::{BidiInfo, Level};
+use xi_unicode::LineBreakIterator;
+
+use super::{shape, ShapedText, TextNode};
+use crate::font::FontStore;
+use crate::library::layout::SpacingKind;
+use crate::library::prelude::*;
+use crate::util::{ArcExt, EcoString, RangeExt, SliceExt};
+
+/// Arrange text, spacing and inline-level nodes into a paragraph.
+#[derive(Hash)]
+pub struct ParNode(pub StyleVec<ParChild>);
+
+/// A uniformly styled atomic piece of a paragraph.
+#[derive(Hash)]
+pub enum ParChild {
+ /// A chunk of text.
+ Text(EcoString),
+ /// Horizontal spacing between other children.
+ Spacing(SpacingKind),
+ /// An arbitrary inline-level node.
+ Node(LayoutNode),
+}
+
+#[class]
+impl ParNode {
+ /// The direction for text and inline objects.
+ pub const DIR: Dir = Dir::LTR;
+ /// How to align text and inline objects in their line.
+ pub const ALIGN: Align = Align::Left;
+ /// Whether to justify text in its line.
+ pub const JUSTIFY: bool = false;
+ /// The spacing between lines (dependent on scaled font size).
+ pub const LEADING: Linear = Relative::new(0.65).into();
+ /// The extra spacing between paragraphs (dependent on scaled font size).
+ pub const SPACING: Linear = Relative::new(0.55).into();
+ /// The indent the first line of a consecutive paragraph should have.
+ pub const INDENT: Linear = Linear::zero();
+
+ fn construct(_: &mut Context, args: &mut Args) -> TypResult<Template> {
+ // The paragraph constructor is special: It doesn't create a paragraph
+ // since that happens automatically through markup. Instead, it just
+ // lifts the passed body to the block level so that it won't merge with
+ // adjacent stuff and it styles the contained paragraphs.
+ Ok(Template::Block(args.expect("body")?))
+ }
+
+ fn set(args: &mut Args, styles: &mut StyleMap) -> TypResult<()> {
+ let mut dir =
+ args.named("lang")?
+ .map(|iso: EcoString| match iso.to_lowercase().as_str() {
+ "ar" | "he" | "fa" | "ur" | "ps" | "yi" => Dir::RTL,
+ "en" | "fr" | "de" => Dir::LTR,
+ _ => Dir::LTR,
+ });
+
+ if let Some(Spanned { v, span }) = args.named::<Spanned<Dir>>("dir")? {
+ if v.axis() != SpecAxis::Horizontal {
+ bail!(span, "must be horizontal");
+ }
+ dir = Some(v);
+ }
+
+ let align =
+ if let Some(Spanned { v, span }) = args.named::<Spanned<Align>>("align")? {
+ if v.axis() != SpecAxis::Horizontal {
+ bail!(span, "must be horizontal");
+ }
+ Some(v)
+ } else {
+ dir.map(|dir| dir.start().into())
+ };
+
+ styles.set_opt(Self::DIR, dir);
+ styles.set_opt(Self::ALIGN, align);
+ styles.set_opt(Self::JUSTIFY, args.named("justify")?);
+ styles.set_opt(Self::LEADING, args.named("leading")?);
+ styles.set_opt(Self::SPACING, args.named("spacing")?);
+ styles.set_opt(Self::INDENT, args.named("indent")?);
+
+ Ok(())
+ }
+}
+
+impl ParNode {
+ /// Concatenate all text in the paragraph into one string, replacing spacing
+ /// with a space character and other non-text nodes with the object
+ /// replacement character.
+ fn collect_text(&self) -> String {
+ let mut text = String::new();
+ for string in self.strings() {
+ text.push_str(string);
+ }
+ text
+ }
+
+ /// The range of each item in the collected text.
+ fn ranges(&self) -> impl Iterator<Item = Range> + '_ {
+ let mut cursor = 0;
+ self.strings().map(move |string| {
+ let start = cursor;
+ cursor += string.len();
+ start .. cursor
+ })
+ }
+
+ /// The string representation of each child.
+ fn strings(&self) -> impl Iterator<Item = &str> {
+ self.0.items().map(|child| match child {
+ ParChild::Text(text) => text,
+ ParChild::Spacing(_) => " ",
+ ParChild::Node(_) => "\u{FFFC}",
+ })
+ }
+}
+
+impl Layout for ParNode {
+ fn layout(
+ &self,
+ ctx: &mut Context,
+ regions: &Regions,
+ styles: StyleChain,
+ ) -> TypResult<Vec<Arc<Frame>>> {
+ // Collect all text into one string and perform BiDi analysis.
+ let text = self.collect_text();
+ let bidi = BidiInfo::new(&text, match styles.get(Self::DIR) {
+ Dir::LTR => Some(Level::ltr()),
+ Dir::RTL => Some(Level::rtl()),
+ _ => None,
+ });
+
+ // Prepare paragraph layout by building a representation on which we can
+ // do line breaking without layouting each and every line from scratch.
+ let par = ParLayout::new(ctx, self, bidi, regions, &styles)?;
+
+ // Break the paragraph into lines.
+ let lines = break_into_lines(&mut ctx.fonts, &par, regions.first.x);
+
+ // Stack the lines into one frame per region.
+ Ok(stack_lines(&ctx.fonts, lines, regions, styles))
+ }
+}
+
+impl Debug for ParNode {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.write_str("Par ")?;
+ self.0.fmt(f)
+ }
+}
+
+impl Debug for ParChild {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self {
+ Self::Text(text) => write!(f, "Text({:?})", text),
+ Self::Spacing(kind) => write!(f, "{:?}", kind),
+ Self::Node(node) => node.fmt(f),
+ }
+ }
+}
+
+impl Merge for ParChild {
+ fn merge(&mut self, next: &Self) -> bool {
+ if let (Self::Text(left), Self::Text(right)) = (self, next) {
+ left.push_str(right);
+ true
+ } else {
+ false
+ }
+ }
+}
+
+/// A paragraph break.
+pub struct ParbreakNode;
+
+#[class]
+impl ParbreakNode {
+ fn construct(_: &mut Context, _: &mut Args) -> TypResult<Template> {
+ Ok(Template::Parbreak)
+ }
+}
+
+/// A line break.
+pub struct LinebreakNode;
+
+#[class]
+impl LinebreakNode {
+ fn construct(_: &mut Context, _: &mut Args) -> TypResult<Template> {
+ Ok(Template::Linebreak)
+ }
+}
+
+/// A paragraph representation in which children are already layouted and text
+/// is already preshaped.
+struct ParLayout<'a> {
+ /// Bidirectional text embedding levels for the paragraph.
+ bidi: BidiInfo<'a>,
+ /// Spacing, separated text runs and layouted nodes.
+ items: Vec<ParItem<'a>>,
+ /// The ranges of the items in `bidi.text`.
+ ranges: Vec<Range>,
+}
+
+/// Range of a substring of text.
+type Range = std::ops::Range<usize>;
+
+/// A prepared item in a paragraph layout.
+enum ParItem<'a> {
+ /// Absolute spacing between other items.
+ Absolute(Length),
+ /// Fractional spacing between other items.
+ Fractional(Fractional),
+ /// A shaped text run with consistent direction.
+ Text(ShapedText<'a>),
+ /// A layouted child node.
+ Frame(Frame),
+}
+
+impl<'a> ParLayout<'a> {
+ /// Prepare initial shaped text and layouted children.
+ fn new(
+ ctx: &mut Context,
+ par: &'a ParNode,
+ bidi: BidiInfo<'a>,
+ regions: &Regions,
+ styles: &'a StyleChain<'a>,
+ ) -> TypResult<Self> {
+ let mut items = vec![];
+ let mut ranges = vec![];
+
+ // Layout the children and collect them into items.
+ for (range, (child, map)) in par.ranges().zip(par.0.iter()) {
+ let styles = map.chain(styles);
+ match child {
+ ParChild::Text(_) => {
+ // TODO: Also split by language and script.
+ let mut cursor = range.start;
+ for (level, count) in bidi.levels[range].group() {
+ let start = cursor;
+ cursor += count;
+ let subrange = start .. cursor;
+ let text = &bidi.text[subrange.clone()];
+ let dir = if level.is_ltr() { Dir::LTR } else { Dir::RTL };
+ let shaped = shape(&mut ctx.fonts, text, styles, dir);
+ items.push(ParItem::Text(shaped));
+ ranges.push(subrange);
+ }
+ }
+ ParChild::Spacing(kind) => match *kind {
+ SpacingKind::Linear(v) => {
+ let resolved = v.resolve(regions.first.x);
+ items.push(ParItem::Absolute(resolved));
+ ranges.push(range);
+ }
+ SpacingKind::Fractional(v) => {
+ items.push(ParItem::Fractional(v));
+ ranges.push(range);
+ }
+ },
+ ParChild::Node(node) => {
+ let size = Size::new(regions.first.x, regions.base.y);
+ let pod = Regions::one(size, regions.base, Spec::splat(false));
+ let frame = node.layout(ctx, &pod, styles)?.remove(0);
+ items.push(ParItem::Frame(Arc::take(frame)));
+ ranges.push(range);
+ }
+ }
+ }
+
+ Ok(Self { bidi, items, ranges })
+ }
+
+ /// Create a line which spans the given range.
+ fn line(
+ &'a self,
+ fonts: &mut FontStore,
+ mut range: Range,
+ mandatory: bool,
+ ) -> LineLayout<'a> {
+ // Find the items which bound the text range.
+ let last_idx = self.find(range.end.saturating_sub(1)).unwrap();
+ let first_idx = if range.is_empty() {
+ last_idx
+ } else {
+ self.find(range.start).unwrap()
+ };
+
+ // Slice out the relevant items and ranges.
+ let mut items = &self.items[first_idx ..= last_idx];
+ let ranges = &self.ranges[first_idx ..= last_idx];
+
+ // Reshape the last item if it's split in half.
+ let mut last = None;
+ if let Some((ParItem::Text(shaped), rest)) = items.split_last() {
+ // Compute the range we want to shape, trimming whitespace at the
+ // end of the line.
+ let base = self.ranges[last_idx].start;
+ let start = range.start.max(base);
+ let end = start + self.bidi.text[start .. range.end].trim_end().len();
+ let shifted = start - base .. end - base;
+
+ // Reshape if necessary.
+ if shifted.len() < shaped.text.len() {
+ // If start == end and the rest is empty, then we have an empty
+ // line. To make that line have the appropriate height, we shape the
+ // empty string.
+ if !shifted.is_empty() || rest.is_empty() {
+ // Reshape that part.
+ let reshaped = shaped.reshape(fonts, shifted);
+ last = Some(ParItem::Text(reshaped));
+ }
+
+ items = rest;
+ range.end = end;
+ }
+ }
+
+ // Reshape the start item if it's split in half.
+ let mut first = None;
+ if let Some((ParItem::Text(shaped), rest)) = items.split_first() {
+ // Compute the range we want to shape.
+ let Range { start: base, end: first_end } = self.ranges[first_idx];
+ let start = range.start;
+ let end = range.end.min(first_end);
+ let shifted = start - base .. end - base;
+
+ // Reshape if necessary.
+ if shifted.len() < shaped.text.len() {
+ if !shifted.is_empty() {
+ let reshaped = shaped.reshape(fonts, shifted);
+ first = Some(ParItem::Text(reshaped));
+ }
+
+ items = rest;
+ }
+ }
+
+ let mut width = Length::zero();
+ let mut top = Length::zero();
+ let mut bottom = Length::zero();
+ let mut fr = Fractional::zero();
+
+ // Measure the size of the line.
+ for item in first.iter().chain(items).chain(&last) {
+ match item {
+ ParItem::Absolute(v) => width += *v,
+ ParItem::Fractional(v) => fr += *v,
+ ParItem::Text(shaped) => {
+ width += shaped.size.x;
+ top.set_max(shaped.baseline);
+ bottom.set_max(shaped.size.y - shaped.baseline);
+ }
+ ParItem::Frame(frame) => {
+ width += frame.size.x;
+ top.set_max(frame.baseline());
+ bottom.set_max(frame.size.y - frame.baseline());
+ }
+ }
+ }
+
+ LineLayout {
+ bidi: &self.bidi,
+ range,
+ first,
+ items,
+ last,
+ ranges,
+ size: Size::new(width, top + bottom),
+ baseline: top,
+ fr,
+ mandatory,
+ }
+ }
+
+
+ /// Find the index of the item whose range contains the `text_offset`.
+ fn find(&self, text_offset: usize) -> Option<usize> {
+ self.ranges.binary_search_by(|r| r.locate(text_offset)).ok()
+ }
+}
+
+/// A lightweight representation of a line that spans a specific range in a
+/// paragraph's text. This type enables you to cheaply measure the size of a
+/// line in a range before comitting to building the line's frame.
+struct LineLayout<'a> {
+ /// Bidi information about the paragraph.
+ bidi: &'a BidiInfo<'a>,
+ /// The range the line spans in the paragraph.
+ range: Range,
+ /// A reshaped text item if the line sliced up a text item at the start.
+ first: Option<ParItem<'a>>,
+ /// Middle items which don't need to be reprocessed.
+ items: &'a [ParItem<'a>],
+ /// A reshaped text item if the line sliced up a text item at the end. If
+ /// there is only one text item, this takes precedence over `first`.
+ last: Option<ParItem<'a>>,
+ /// The ranges, indexed as `[first, ..items, last]`. The ranges for `first`
+ /// and `last` aren't trimmed to the line, but it doesn't matter because
+ /// we're just checking which range an index falls into.
+ ranges: &'a [Range],
+ /// The size of the line.
+ size: Size,
+ /// The baseline of the line.
+ baseline: Length,
+ /// The sum of fractional ratios in the line.
+ fr: Fractional,
+ /// Whether the line ends at a mandatory break.
+ mandatory: bool,
+}
+
+impl<'a> LineLayout<'a> {
+ /// Build the line's frame.
+ fn build(
+ &self,
+ fonts: &FontStore,
+ width: Length,
+ align: Align,
+ justify: bool,
+ ) -> Frame {
+ let size = Size::new(width, self.size.y);
+
+ let mut remaining = width - self.size.x;
+ let mut offset = Length::zero();
+ let mut output = Frame::new(size);
+ output.baseline = Some(self.baseline);
+
+ let mut justification = Length::zero();
+ if justify
+ && !self.mandatory
+ && self.range.end < self.bidi.text.len()
+ && self.fr.is_zero()
+ {
+ justification = remaining / self.spaces() as f64;
+ remaining = Length::zero();
+ }
+
+ for item in self.reordered() {
+ let mut position = |frame: Frame| {
+ let x = offset + align.resolve(remaining);
+ let y = self.baseline - frame.baseline();
+ offset += frame.size.x;
+ output.merge_frame(Point::new(x, y), frame);
+ };
+
+ match item {
+ ParItem::Absolute(v) => offset += *v,
+ ParItem::Fractional(v) => offset += v.resolve(self.fr, remaining),
+ ParItem::Text(shaped) => position(shaped.build(fonts, justification)),
+ ParItem::Frame(frame) => position(frame.clone()),
+ }
+ }
+
+ output
+ }
+
+ /// The number of spaces in the line.
+ fn spaces(&self) -> usize {
+ self.shapeds().map(ShapedText::spaces).sum()
+ }
+
+ /// Iterate through the line's items in visual order.
+ fn reordered(&self) -> impl Iterator<Item = &ParItem<'a>> {
+ // The bidi crate doesn't like empty lines.
+ let (levels, runs) = if !self.range.is_empty() {
+ // Find the paragraph that contains the line.
+ let para = self
+ .bidi
+ .paragraphs
+ .iter()
+ .find(|para| para.range.contains(&self.range.start))
+ .unwrap();
+
+ // Compute the reordered ranges in visual order (left to right).
+ self.bidi.visual_runs(para, self.range.clone())
+ } else {
+ (vec![], vec![])
+ };
+
+ runs.into_iter()
+ .flat_map(move |run| {
+ let first_idx = self.find(run.start).unwrap();
+ let last_idx = self.find(run.end - 1).unwrap();
+ let range = first_idx ..= last_idx;
+
+ // Provide the items forwards or backwards depending on the run's
+ // direction.
+ if levels[run.start].is_ltr() {
+ Either::Left(range)
+ } else {
+ Either::Right(range.rev())
+ }
+ })
+ .map(move |idx| self.get(idx).unwrap())
+ }
+
+ /// Iterate over the line's items.
+ fn items(&self) -> impl Iterator<Item = &ParItem<'a>> {
+ self.first.iter().chain(self.items).chain(&self.last)
+ }
+
+ /// Iterate through the line's text items.
+ fn shapeds(&self) -> impl Iterator<Item = &ShapedText<'a>> {
+ self.items().filter_map(|item| match item {
+ ParItem::Text(shaped) => Some(shaped),
+ _ => None,
+ })
+ }
+
+ /// Find the index of the item whose range contains the `text_offset`.
+ fn find(&self, text_offset: usize) -> Option<usize> {
+ self.ranges.binary_search_by(|r| r.locate(text_offset)).ok()
+ }
+
+ /// Get the item at the index.
+ fn get(&self, index: usize) -> Option<&ParItem<'a>> {
+ self.items().nth(index)
+ }
+}
+
+/// Perform line breaking.
+fn break_into_lines<'a>(
+ fonts: &mut FontStore,
+ par: &'a ParLayout<'a>,
+ width: Length,
+) -> Vec<LineLayout<'a>> {
+ // The already determined lines and the current line attempt.
+ let mut lines = vec![];
+ let mut start = 0;
+ let mut last = None;
+
+ // Find suitable line breaks.
+ for (end, mandatory) in LineBreakIterator::new(&par.bidi.text) {
+ // Compute the line and its size.
+ let mut line = par.line(fonts, start .. end, mandatory);
+
+ // If the line doesn't fit anymore, we push the last fitting attempt
+ // into the stack and rebuild the line from its end. The resulting
+ // line cannot be broken up further.
+ if !width.fits(line.size.x) {
+ if let Some((last_line, last_end)) = last.take() {
+ lines.push(last_line);
+ start = last_end;
+ line = par.line(fonts, start .. end, mandatory);
+ }
+ }
+
+ // Finish the current line if there is a mandatory line break (i.e.
+ // due to "\n") or if the line doesn't fit horizontally already
+ // since then no shorter line will be possible.
+ if mandatory || !width.fits(line.size.x) {
+ lines.push(line);
+ start = end;
+ last = None;
+ } else {
+ last = Some((line, end));
+ }
+ }
+
+ if let Some((line, _)) = last {
+ lines.push(line);
+ }
+
+ lines
+}
+
+/// Combine the lines into one frame per region.
+fn stack_lines(
+ fonts: &FontStore,
+ lines: Vec<LineLayout>,
+ regions: &Regions,
+ styles: StyleChain,
+) -> Vec<Arc<Frame>> {
+ let em = styles.get(TextNode::SIZE).abs;
+ let leading = styles.get(ParNode::LEADING).resolve(em);
+ let align = styles.get(ParNode::ALIGN);
+ let justify = styles.get(ParNode::JUSTIFY);
+
+ // Determine the paragraph's width: Full width of the region if we
+ // should expand or there's fractional spacing, fit-to-width otherwise.
+ let mut width = regions.first.x;
+ if !regions.expand.x && lines.iter().all(|line| line.fr.is_zero()) {
+ width = lines.iter().map(|line| line.size.x).max().unwrap_or_default();
+ }
+
+ // State for final frame building.
+ let mut regions = regions.clone();
+ let mut finished = vec![];
+ let mut first = true;
+ let mut output = Frame::new(Size::with_x(width));
+
+ // Stack the lines into one frame per region.
+ for line in lines {
+ while !regions.first.y.fits(line.size.y) && !regions.in_last() {
+ finished.push(Arc::new(output));
+ output = Frame::new(Size::with_x(width));
+ regions.next();
+ first = true;
+ }
+
+ if !first {
+ output.size.y += leading;
+ }
+
+ let frame = line.build(fonts, width, align, justify);
+ let pos = Point::with_y(output.size.y);
+ output.size.y += frame.size.y;
+ output.merge_frame(pos, frame);
+
+ regions.first.y -= line.size.y + leading;
+ first = false;
+ }
+
+ finished.push(Arc::new(output));
+ finished
+}
diff --git a/src/library/text/raw.rs b/src/library/text/raw.rs
new file mode 100644
index 00000000..97857f11
--- /dev/null
+++ b/src/library/text/raw.rs
@@ -0,0 +1,127 @@
+use once_cell::sync::Lazy;
+use syntect::easy::HighlightLines;
+use syntect::highlighting::{FontStyle, Highlighter, Style, Theme, ThemeSet};
+use syntect::parsing::SyntaxSet;
+
+use crate::library::prelude::*;
+use crate::library::text::TextNode;
+use crate::source::SourceId;
+use crate::syntax::{self, RedNode};
+
+/// The lazily-loaded theme used for syntax highlighting.
+static THEME: Lazy<Theme> =
+ Lazy::new(|| ThemeSet::load_defaults().themes.remove("InspiredGitHub").unwrap());
+
+/// The lazily-loaded syntect syntax definitions.
+static SYNTAXES: Lazy<SyntaxSet> = Lazy::new(|| SyntaxSet::load_defaults_newlines());
+
+/// Monospaced text with optional syntax highlighting.
+#[derive(Debug, Hash)]
+pub struct RawNode {
+ /// The raw text.
+ pub text: EcoString,
+ /// Whether the node is block-level.
+ pub block: bool,
+}
+
+#[class]
+impl RawNode {
+ /// The language to syntax-highlight in.
+ pub const LANG: Option<EcoString> = None;
+
+ fn construct(_: &mut Context, args: &mut Args) -> TypResult<Template> {
+ Ok(Template::show(Self {
+ text: args.expect("text")?,
+ block: args.named("block")?.unwrap_or(false),
+ }))
+ }
+}
+
+impl Show for RawNode {
+ fn show(&self, ctx: &mut Context, styles: StyleChain) -> TypResult<Template> {
+ let lang = styles.get_ref(Self::LANG).as_ref();
+
+ if let Some(template) = styles.show(self, ctx, [
+ Value::Str(self.text.clone()),
+ match lang {
+ Some(lang) => Value::Str(lang.clone()),
+ None => Value::None,
+ },
+ Value::Bool(self.block),
+ ])? {
+ return Ok(template);
+ }
+
+ let foreground = THEME
+ .settings
+ .foreground
+ .map(Color::from)
+ .unwrap_or(Color::BLACK)
+ .into();
+
+ let mut template = if matches!(
+ lang.map(|s| s.to_lowercase()).as_deref(),
+ Some("typ" | "typst")
+ ) {
+ let mut seq = vec![];
+ let green = crate::parse::parse(&self.text);
+ let red = RedNode::from_root(green, SourceId::from_raw(0));
+ let highlighter = Highlighter::new(&THEME);
+
+ syntax::highlight_syntect(red.as_ref(), &highlighter, &mut |range, style| {
+ seq.push(styled(&self.text[range], foreground, style));
+ });
+
+ Template::sequence(seq)
+ } else if let Some(syntax) =
+ lang.and_then(|token| SYNTAXES.find_syntax_by_token(&token))
+ {
+ let mut seq = vec![];
+ let mut highlighter = HighlightLines::new(syntax, &THEME);
+ for (i, line) in self.text.lines().enumerate() {
+ if i != 0 {
+ seq.push(Template::Linebreak);
+ }
+
+ for (style, piece) in highlighter.highlight(line, &SYNTAXES) {
+ seq.push(styled(piece, foreground, style));
+ }
+ }
+
+ Template::sequence(seq)
+ } else {
+ Template::Text(self.text.clone())
+ };
+
+ if self.block {
+ template = Template::Block(template.pack());
+ }
+
+ Ok(template.monospaced())
+ }
+}
+
+/// Style a piece of text with a syntect style.
+fn styled(piece: &str, foreground: Paint, style: Style) -> Template {
+ let mut styles = StyleMap::new();
+ let mut body = Template::Text(piece.into());
+
+ let paint = style.foreground.into();
+ if paint != foreground {
+ styles.set(TextNode::FILL, paint);
+ }
+
+ if style.font_style.contains(FontStyle::BOLD) {
+ styles.set(TextNode::STRONG, true);
+ }
+
+ if style.font_style.contains(FontStyle::ITALIC) {
+ styles.set(TextNode::EMPH, true);
+ }
+
+ if style.font_style.contains(FontStyle::UNDERLINE) {
+ body = body.underlined();
+ }
+
+ body.styled_with_map(styles)
+}
diff --git a/src/library/text/shaping.rs b/src/library/text/shaping.rs
new file mode 100644
index 00000000..26c8daf3
--- /dev/null
+++ b/src/library/text/shaping.rs
@@ -0,0 +1,552 @@
+use std::ops::Range;
+
+use rustybuzz::{Feature, UnicodeBuffer};
+
+use super::*;
+use crate::font::{FaceId, FontStore, FontVariant};
+use crate::library::prelude::*;
+use crate::util::SliceExt;
+
+/// The result of shaping text.
+///
+/// This type contains owned or borrowed shaped text runs, which can be
+/// measured, used to reshape substrings more quickly and converted into a
+/// frame.
+#[derive(Debug, Clone)]
+pub struct ShapedText<'a> {
+ /// The text that was shaped.
+ pub text: Cow<'a, str>,
+ /// The text direction.
+ pub dir: Dir,
+ /// The text's style properties.
+ pub styles: StyleChain<'a>,
+ /// The size of the text's bounding box.
+ pub size: Size,
+ /// The baseline from the top of the frame.
+ pub baseline: Length,
+ /// The shaped glyphs.
+ pub glyphs: Cow<'a, [ShapedGlyph]>,
+}
+
+/// A single glyph resulting from shaping.
+#[derive(Debug, Copy, Clone)]
+pub struct ShapedGlyph {
+ /// The font face the glyph is contained in.
+ pub face_id: FaceId,
+ /// The glyph's index in the face.
+ pub glyph_id: u16,
+ /// The advance width of the glyph.
+ pub x_advance: Em,
+ /// The horizontal offset of the glyph.
+ pub x_offset: Em,
+ /// The start index of the glyph in the source text.
+ pub text_index: usize,
+ /// Whether splitting the shaping result before this glyph would yield the
+ /// same results as shaping the parts to both sides of `text_index`
+ /// separately.
+ pub safe_to_break: bool,
+ /// Whether this glyph represents a space.
+ pub is_space: bool,
+}
+
+/// A side you can go toward.
+enum Side {
+ /// Go toward the west.
+ Left,
+ /// Go toward the east.
+ Right,
+}
+
+impl<'a> ShapedText<'a> {
+ /// Build the shaped text's frame.
+ ///
+ /// The `justification` defines how much extra advance width each
+ /// [space glyph](ShapedGlyph::is_space) will get.
+ pub fn build(&self, fonts: &FontStore, justification: Length) -> Frame {
+ let mut offset = Length::zero();
+ let mut frame = Frame::new(self.size);
+ frame.baseline = Some(self.baseline);
+
+ for (face_id, group) in self.glyphs.as_ref().group_by_key(|g| g.face_id) {
+ let pos = Point::new(offset, self.baseline);
+
+ let size = self.styles.get(TextNode::SIZE).abs;
+ let fill = self.styles.get(TextNode::FILL);
+ let glyphs = group
+ .iter()
+ .map(|glyph| Glyph {
+ id: glyph.glyph_id,
+ x_advance: glyph.x_advance
+ + if glyph.is_space {
+ frame.size.x += justification;
+ Em::from_length(justification, size)
+ } else {
+ Em::zero()
+ },
+ x_offset: glyph.x_offset,
+ })
+ .collect();
+
+ let text = Text { face_id, size, fill, glyphs };
+ let text_layer = frame.layer();
+ let width = text.width();
+
+ // Apply line decorations.
+ for deco in self.styles.get_cloned(TextNode::LINES) {
+ decorate(&mut frame, &deco, fonts, &text, pos, width);
+ }
+
+ frame.insert(text_layer, pos, Element::Text(text));
+ offset += width;
+ }
+
+ // Apply link if it exists.
+ if let Some(url) = self.styles.get_ref(TextNode::LINK) {
+ frame.link(url);
+ }
+
+ frame
+ }
+
+ /// How many spaces the text contains.
+ pub fn spaces(&self) -> usize {
+ self.glyphs.iter().filter(|g| g.is_space).count()
+ }
+
+ /// Reshape a range of the shaped text, reusing information from this
+ /// shaping process if possible.
+ pub fn reshape(
+ &'a self,
+ fonts: &mut FontStore,
+ text_range: Range<usize>,
+ ) -> ShapedText<'a> {
+ if let Some(glyphs) = self.slice_safe_to_break(text_range.clone()) {
+ let (size, baseline) = measure(fonts, glyphs, self.styles);
+ Self {
+ text: Cow::Borrowed(&self.text[text_range]),
+ dir: self.dir,
+ styles: self.styles.clone(),
+ size,
+ baseline,
+ glyphs: Cow::Borrowed(glyphs),
+ }
+ } else {
+ shape(fonts, &self.text[text_range], self.styles.clone(), self.dir)
+ }
+ }
+
+ /// Find the subslice of glyphs that represent the given text range if both
+ /// sides are safe to break.
+ fn slice_safe_to_break(&self, text_range: Range<usize>) -> Option<&[ShapedGlyph]> {
+ let Range { mut start, mut end } = text_range;
+ if !self.dir.is_positive() {
+ std::mem::swap(&mut start, &mut end);
+ }
+
+ let left = self.find_safe_to_break(start, Side::Left)?;
+ let right = self.find_safe_to_break(end, Side::Right)?;
+ Some(&self.glyphs[left .. right])
+ }
+
+ /// Find the glyph offset matching the text index that is most towards the
+ /// given side and safe-to-break.
+ fn find_safe_to_break(&self, text_index: usize, towards: Side) -> Option<usize> {
+ let ltr = self.dir.is_positive();
+
+ // Handle edge cases.
+ let len = self.glyphs.len();
+ if text_index == 0 {
+ return Some(if ltr { 0 } else { len });
+ } else if text_index == self.text.len() {
+ return Some(if ltr { len } else { 0 });
+ }
+
+ // Find any glyph with the text index.
+ let mut idx = self
+ .glyphs
+ .binary_search_by(|g| {
+ let ordering = g.text_index.cmp(&text_index);
+ if ltr { ordering } else { ordering.reverse() }
+ })
+ .ok()?;
+
+ let next = match towards {
+ Side::Left => usize::checked_sub,
+ Side::Right => usize::checked_add,
+ };
+
+ // Search for the outermost glyph with the text index.
+ while let Some(next) = next(idx, 1) {
+ if self.glyphs.get(next).map_or(true, |g| g.text_index != text_index) {
+ break;
+ }
+ idx = next;
+ }
+
+ // RTL needs offset one because the left side of the range should be
+ // exclusive and the right side inclusive, contrary to the normal
+ // behaviour of ranges.
+ if !ltr {
+ idx += 1;
+ }
+
+ self.glyphs[idx].safe_to_break.then(|| idx)
+ }
+}
+
+/// Shape text into [`ShapedText`].
+pub fn shape<'a>(
+ fonts: &mut FontStore,
+ text: &'a str,
+ styles: StyleChain<'a>,
+ dir: Dir,
+) -> ShapedText<'a> {
+ let text = match styles.get(TextNode::CASE) {
+ Some(case) => Cow::Owned(case.apply(text)),
+ None => Cow::Borrowed(text),
+ };
+
+ let mut glyphs = vec![];
+ if !text.is_empty() {
+ shape_segment(
+ fonts,
+ &mut glyphs,
+ 0,
+ &text,
+ variant(styles),
+ families(styles),
+ None,
+ dir,
+ &tags(styles),
+ );
+ }
+
+ track(&mut glyphs, styles.get(TextNode::TRACKING));
+ let (size, baseline) = measure(fonts, &glyphs, styles);
+
+ ShapedText {
+ text,
+ dir,
+ styles,
+ size,
+ baseline,
+ glyphs: Cow::Owned(glyphs),
+ }
+}
+
+/// Resolve the font variant with `STRONG` and `EMPH` factored in.
+fn variant(styles: StyleChain) -> FontVariant {
+ let mut variant = FontVariant::new(
+ styles.get(TextNode::STYLE),
+ styles.get(TextNode::WEIGHT),
+ styles.get(TextNode::STRETCH),
+ );
+
+ if styles.get(TextNode::STRONG) {
+ variant.weight = variant.weight.thicken(300);
+ }
+
+ if styles.get(TextNode::EMPH) {
+ variant.style = match variant.style {
+ FontStyle::Normal => FontStyle::Italic,
+ FontStyle::Italic => FontStyle::Normal,
+ FontStyle::Oblique => FontStyle::Normal,
+ }
+ }
+
+ variant
+}
+
+/// Resolve a prioritized iterator over the font families.
+fn families(styles: StyleChain) -> impl Iterator<Item = &str> + Clone {
+ let head = if styles.get(TextNode::MONOSPACED) {
+ styles.get_ref(TextNode::MONOSPACE).as_slice()
+ } else {
+ &[]
+ };
+
+ let core = styles.get_ref(TextNode::FAMILY).iter().flat_map(move |family| {
+ match family {
+ FontFamily::Named(name) => std::slice::from_ref(name),
+ FontFamily::Serif => styles.get_ref(TextNode::SERIF),
+ FontFamily::SansSerif => styles.get_ref(TextNode::SANS_SERIF),
+ FontFamily::Monospace => styles.get_ref(TextNode::MONOSPACE),
+ }
+ });
+
+ let tail: &[&str] = if styles.get(TextNode::FALLBACK) {
+ &["ibm plex sans", "latin modern math", "twitter color emoji"]
+ } else {
+ &[]
+ };
+
+ head.iter()
+ .chain(core)
+ .map(|named| named.as_str())
+ .chain(tail.iter().copied())
+}
+
+/// Collect the tags of the OpenType features to apply.
+fn tags(styles: StyleChain) -> Vec<Feature> {
+ let mut tags = vec![];
+ let mut feat = |tag, value| {
+ tags.push(Feature::new(Tag::from_bytes(tag), value, ..));
+ };
+
+ // Features that are on by default in Harfbuzz are only added if disabled.
+ if !styles.get(TextNode::KERNING) {
+ feat(b"kern", 0);
+ }
+
+ // Features that are off by default in Harfbuzz are only added if enabled.
+ if styles.get(TextNode::SMALLCAPS) {
+ feat(b"smcp", 1);
+ }
+
+ if styles.get(TextNode::ALTERNATES) {
+ feat(b"salt", 1);
+ }
+
+ let storage;
+ if let Some(set) = styles.get(TextNode::STYLISTIC_SET) {
+ storage = [b's', b's', b'0' + set.get() / 10, b'0' + set.get() % 10];
+ feat(&storage, 1);
+ }
+
+ if !styles.get(TextNode::LIGATURES) {
+ feat(b"liga", 0);
+ feat(b"clig", 0);
+ }
+
+ if styles.get(TextNode::DISCRETIONARY_LIGATURES) {
+ feat(b"dlig", 1);
+ }
+
+ if styles.get(TextNode::HISTORICAL_LIGATURES) {
+ feat(b"hilg", 1);
+ }
+
+ match styles.get(TextNode::NUMBER_TYPE) {
+ Smart::Auto => {}
+ Smart::Custom(NumberType::Lining) => feat(b"lnum", 1),
+ Smart::Custom(NumberType::OldStyle) => feat(b"onum", 1),
+ }
+
+ match styles.get(TextNode::NUMBER_WIDTH) {
+ Smart::Auto => {}
+ Smart::Custom(NumberWidth::Proportional) => feat(b"pnum", 1),
+ Smart::Custom(NumberWidth::Tabular) => feat(b"tnum", 1),
+ }
+
+ match styles.get(TextNode::NUMBER_POSITION) {
+ NumberPosition::Normal => {}
+ NumberPosition::Subscript => feat(b"subs", 1),
+ NumberPosition::Superscript => feat(b"sups", 1),
+ }
+
+ if styles.get(TextNode::SLASHED_ZERO) {
+ feat(b"zero", 1);
+ }
+
+ if styles.get(TextNode::FRACTIONS) {
+ feat(b"frac", 1);
+ }
+
+ for &(tag, value) in styles.get_ref(TextNode::FEATURES).iter() {
+ tags.push(Feature::new(tag, value, ..))
+ }
+
+ tags
+}
+
+/// Shape text with font fallback using the `families` iterator.
+fn shape_segment<'a>(
+ fonts: &mut FontStore,
+ glyphs: &mut Vec<ShapedGlyph>,
+ base: usize,
+ text: &str,
+ variant: FontVariant,
+ mut families: impl Iterator<Item = &'a str> + Clone,
+ mut first_face: Option<FaceId>,
+ dir: Dir,
+ tags: &[rustybuzz::Feature],
+) {
+ // No font has newlines.
+ if text.chars().all(|c| c == '\n') {
+ return;
+ }
+
+ // Select the font family.
+ let (face_id, fallback) = loop {
+ // Try to load the next available font family.
+ match families.next() {
+ Some(family) => {
+ if let Some(id) = fonts.select(family, variant) {
+ break (id, true);
+ }
+ }
+ // We're out of families, so we don't do any more fallback and just
+ // shape the tofus with the first face we originally used.
+ None => match first_face {
+ Some(id) => break (id, false),
+ None => return,
+ },
+ }
+ };
+
+ // Remember the id if this the first available face since we use that one to
+ // shape tofus.
+ first_face.get_or_insert(face_id);
+
+ // Fill the buffer with our text.
+ let mut buffer = UnicodeBuffer::new();
+ buffer.push_str(text);
+ buffer.set_direction(match dir {
+ Dir::LTR => rustybuzz::Direction::LeftToRight,
+ Dir::RTL => rustybuzz::Direction::RightToLeft,
+ _ => unimplemented!(),
+ });
+
+ // Shape!
+ let mut face = fonts.get(face_id);
+ let buffer = rustybuzz::shape(face.ttf(), tags, buffer);
+ let infos = buffer.glyph_infos();
+ let pos = buffer.glyph_positions();
+
+ // Collect the shaped glyphs, doing fallback and shaping parts again with
+ // the next font if necessary.
+ let mut i = 0;
+ while i < infos.len() {
+ let info = &infos[i];
+ let cluster = info.cluster as usize;
+
+ if info.glyph_id != 0 || !fallback {
+ // Add the glyph to the shaped output.
+ // TODO: Don't ignore y_advance and y_offset.
+ glyphs.push(ShapedGlyph {
+ face_id,
+ glyph_id: info.glyph_id as u16,
+ x_advance: face.to_em(pos[i].x_advance),
+ x_offset: face.to_em(pos[i].x_offset),
+ text_index: base + cluster,
+ safe_to_break: !info.unsafe_to_break(),
+ is_space: text[cluster ..].chars().next() == Some(' '),
+ });
+ } else {
+ // Determine the source text range for the tofu sequence.
+ let range = {
+ // First, search for the end of the tofu sequence.
+ let k = i;
+ while infos.get(i + 1).map_or(false, |info| info.glyph_id == 0) {
+ i += 1;
+ }
+
+ // Then, determine the start and end text index.
+ //
+ // Examples:
+ // Everything is shown in visual order. Tofus are written as "_".
+ // We want to find out that the tofus span the text `2..6`.
+ // Note that the clusters are longer than 1 char.
+ //
+ // Left-to-right:
+ // Text: h a l i h a l l o
+ // Glyphs: A _ _ C E
+ // Clusters: 0 2 4 6 8
+ // k=1 i=2
+ //
+ // Right-to-left:
+ // Text: O L L A H I L A H
+ // Glyphs: E C _ _ A
+ // Clusters: 8 6 4 2 0
+ // k=2 i=3
+ let ltr = dir.is_positive();
+ let first = if ltr { k } else { i };
+ let start = infos[first].cluster as usize;
+ let last = if ltr { i.checked_add(1) } else { k.checked_sub(1) };
+ let end = last
+ .and_then(|last| infos.get(last))
+ .map_or(text.len(), |info| info.cluster as usize);
+
+ start .. end
+ };
+
+ // Recursively shape the tofu sequence with the next family.
+ shape_segment(
+ fonts,
+ glyphs,
+ base + range.start,
+ &text[range],
+ variant,
+ families.clone(),
+ first_face,
+ dir,
+ tags,
+ );
+
+ face = fonts.get(face_id);
+ }
+
+ i += 1;
+ }
+}
+
+/// Apply tracking to a slice of shaped glyphs.
+fn track(glyphs: &mut [ShapedGlyph], tracking: Em) {
+ if tracking.is_zero() {
+ return;
+ }
+
+ let mut glyphs = glyphs.iter_mut().peekable();
+ while let Some(glyph) = glyphs.next() {
+ if glyphs
+ .peek()
+ .map_or(false, |next| glyph.text_index != next.text_index)
+ {
+ glyph.x_advance += tracking;
+ }
+ }
+}
+
+/// Measure the size and baseline of a run of shaped glyphs with the given
+/// properties.
+fn measure(
+ fonts: &mut FontStore,
+ glyphs: &[ShapedGlyph],
+ styles: StyleChain,
+) -> (Size, Length) {
+ let mut width = Length::zero();
+ let mut top = Length::zero();
+ let mut bottom = Length::zero();
+
+ let size = styles.get(TextNode::SIZE).abs;
+ let top_edge = styles.get(TextNode::TOP_EDGE);
+ let bottom_edge = styles.get(TextNode::BOTTOM_EDGE);
+
+ // Expand top and bottom by reading the face's vertical metrics.
+ let mut expand = |face: &Face| {
+ top.set_max(face.vertical_metric(top_edge, size));
+ bottom.set_max(-face.vertical_metric(bottom_edge, size));
+ };
+
+ if glyphs.is_empty() {
+ // When there are no glyphs, we just use the vertical metrics of the
+ // first available font.
+ for family in families(styles) {
+ if let Some(face_id) = fonts.select(family, variant(styles)) {
+ expand(fonts.get(face_id));
+ break;
+ }
+ }
+ } else {
+ for (face_id, group) in glyphs.group_by_key(|g| g.face_id) {
+ let face = fonts.get(face_id);
+ expand(face);
+
+ for glyph in group {
+ width += glyph.x_advance.resolve(size);
+ }
+ }
+ }
+
+ (Size::new(width, top + bottom), top)
+}