diff options
| author | Laurenz <laurmaedje@gmail.com> | 2022-02-28 15:50:48 +0100 |
|---|---|---|
| committer | Laurenz <laurmaedje@gmail.com> | 2022-02-28 23:54:34 +0100 |
| commit | 3ca5b238238e1128aa7bbfbd5db9e632045d8600 (patch) | |
| tree | 2471f4b340a15695b7f4d518c0b39fabaea676c4 /src/library/text | |
| parent | b63c21c91d99a1554a019dc275f955d3e6a34271 (diff) | |
Reorganize library
Diffstat (limited to 'src/library/text')
| -rw-r--r-- | src/library/text/deco.rs | 250 | ||||
| -rw-r--r-- | src/library/text/link.rs | 64 | ||||
| -rw-r--r-- | src/library/text/mod.rs | 409 | ||||
| -rw-r--r-- | src/library/text/par.rs | 617 | ||||
| -rw-r--r-- | src/library/text/raw.rs | 127 | ||||
| -rw-r--r-- | src/library/text/shaping.rs | 552 |
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) +} |
