use std::hash::{Hash, Hasher};
use std::num::NonZeroUsize;
use std::path::Path as FsPath;
use std::sync::{Arc, LazyLock, Mutex};
use lru::LruCache;
use rustybuzz::{ttf_parser, UnicodeBuffer};
use thiserror::Error;
use crate::geometry::{Constraints, Rect, Transform, Vec2};
use crate::placement::{Positioned, VectorPlacement};
use crate::span::{ShapedSpan, Span, SpanContext};
use crate::vector::{
Fill, Group, Node, Paint, Path as VPath, PathCommand, VectorComponent, VectorGraphic,
};
use crate::Keyable;
#[derive(Debug, Error)]
pub enum FontError {
#[error("failed to parse font data")]
Parse,
#[error("failed to read font file: {0}")]
Io(#[from] std::io::Error),
#[error("no system font found matching {name:?}")]
NotFound { name: String },
#[error("fontconfig is not available on this system")]
FontconfigUnavailable,
}
pub static SANS_SERIF: LazyLock<Arc<Font>> =
LazyLock::new(|| Arc::new(Font::sans_serif().expect("resolve system sans-serif font")));
pub static SERIF: LazyLock<Arc<Font>> =
LazyLock::new(|| Arc::new(Font::serif().expect("resolve system serif font")));
pub static MONOSPACE: LazyLock<Arc<Font>> =
LazyLock::new(|| Arc::new(Font::monospace().expect("resolve system monospace font")));
pub static CURSIVE: LazyLock<Arc<Font>> =
LazyLock::new(|| Arc::new(Font::cursive().expect("resolve system cursive font")));
pub static FANTASY: LazyLock<Arc<Font>> =
LazyLock::new(|| Arc::new(Font::fantasy().expect("resolve system fantasy font")));
const SHAPE_CACHE_CAPACITY: usize = 512;
fn default_shaping_features() -> [rustybuzz::Feature; 1] {
[rustybuzz::Feature::new(
ttf_parser::Tag::from_bytes(b"palt"),
1,
..,
)]
}
#[derive(PartialEq, Eq, Hash)]
struct ShapeKey {
weight: u16,
size_bits: u32,
baseline_bits: u32,
text: String,
}
struct ShapedGlyph {
cluster: usize,
advance_end: f32,
commands: Vec<PathCommand>,
}
struct ShapedGlyphs {
glyphs: Vec<ShapedGlyph>,
width: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FontMetrics {
pub ascent: f32,
pub descent: f32,
pub line_gap: f32,
}
struct FontUnitMetrics {
units_per_em: f32,
ascender: f32,
descender: f32,
line_gap: f32,
}
pub struct Font {
data: Arc<Vec<u8>>,
face_index: u32,
unit_metrics: FontUnitMetrics,
shape_cache: Mutex<LruCache<ShapeKey, Arc<ShapedGlyphs>>>,
}
impl Font {
pub fn from_bytes(bytes: impl Into<Arc<Vec<u8>>>) -> Result<Self, FontError> {
Self::from_bytes_indexed(bytes, 0)
}
pub fn from_bytes_indexed(
bytes: impl Into<Arc<Vec<u8>>>,
face_index: u32,
) -> Result<Self, FontError> {
let data: Arc<Vec<u8>> = bytes.into();
let face =
rustybuzz::Face::from_slice(data.as_ref(), face_index).ok_or(FontError::Parse)?;
let unit_metrics = FontUnitMetrics {
units_per_em: face.units_per_em() as f32,
ascender: face.ascender() as f32,
descender: face.descender() as f32,
line_gap: face.line_gap() as f32,
};
Ok(Self {
data,
face_index,
unit_metrics,
shape_cache: Mutex::new(LruCache::new(
NonZeroUsize::new(SHAPE_CACHE_CAPACITY).expect("cache capacity is non-zero"),
)),
})
}
pub fn load_file(path: impl AsRef<FsPath>) -> Result<Self, FontError> {
let bytes = std::fs::read(path)?;
Self::from_bytes(bytes)
}
pub fn sans_serif() -> Result<Self, FontError> {
Self::find_generic("sans-serif")
}
pub fn serif() -> Result<Self, FontError> {
Self::find_generic("serif")
}
pub fn monospace() -> Result<Self, FontError> {
Self::find_generic("monospace")
}
pub fn cursive() -> Result<Self, FontError> {
Self::find_generic("cursive")
}
pub fn fantasy() -> Result<Self, FontError> {
Self::find_generic("fantasy")
}
fn find_generic(family: &str) -> Result<Self, FontError> {
let fc = fontconfig::Fontconfig::new().ok_or(FontError::FontconfigUnavailable)?;
let resolved = fc.find(family, None).ok_or_else(|| FontError::NotFound {
name: family.to_owned(),
})?;
Self::load_file(resolved.path)
}
pub fn find_by_name(name: &str) -> Result<Self, FontError> {
Self::find_by_name_with_weight(name, Weight::NORMAL)
}
pub fn find_by_name_with_weight(name: &str, weight: Weight) -> Result<Self, FontError> {
let mut db = fontdb::Database::new();
db.load_system_fonts();
let query = fontdb::Query {
families: &[fontdb::Family::Name(name)],
weight: fontdb::Weight(weight.0),
..fontdb::Query::default()
};
let not_found = || FontError::NotFound {
name: format!("{} (weight {})", name, weight.0),
};
let id = db.query(&query).ok_or_else(not_found)?;
let face = db.face(id).ok_or_else(not_found)?;
let face_index = face.index;
match &face.source {
fontdb::Source::File(path) => {
let bytes = std::fs::read(path)?;
Self::from_bytes_indexed(bytes, face_index)
}
fontdb::Source::Binary(arc) | fontdb::Source::SharedFile(_, arc) => {
let bytes: Vec<u8> = arc.as_ref().as_ref().to_vec();
Self::from_bytes_indexed(bytes, face_index)
}
}
}
fn face(&self) -> rustybuzz::Face<'_> {
rustybuzz::Face::from_slice(self.data.as_ref(), self.face_index)
.expect("font data validated in Font constructors")
}
pub fn vertical_metrics(&self, size: f32) -> FontMetrics {
let scale = size / self.unit_metrics.units_per_em;
let ascent = self.unit_metrics.ascender * scale;
let descent = -self.unit_metrics.descender * scale;
let line_gap = self.unit_metrics.line_gap * scale;
FontMetrics {
ascent: ascent.max(0.0),
descent: descent.max(0.0),
line_gap: line_gap.max(0.0),
}
}
fn shaped_glyphs(
&self,
weight: Weight,
size: f32,
baseline_y: f32,
text: &str,
) -> Arc<ShapedGlyphs> {
let key = ShapeKey {
weight: weight.0,
size_bits: size.to_bits(),
baseline_bits: baseline_y.to_bits(),
text: text.to_owned(),
};
if let Ok(mut cache) = self.shape_cache.lock() {
if let Some(hit) = cache.get(&key) {
return Arc::clone(hit);
}
}
let shaped = Arc::new(self.shape_uncached(weight, size, baseline_y, text));
if let Ok(mut cache) = self.shape_cache.lock() {
cache.put(key, Arc::clone(&shaped));
}
shaped
}
fn shape_uncached(
&self,
weight: Weight,
size: f32,
baseline_y: f32,
text: &str,
) -> ShapedGlyphs {
let mut face = self.face();
face.set_variations(&[rustybuzz::Variation {
tag: ttf_parser::Tag::from_bytes(b"wght"),
value: weight.0 as f32,
}]);
let upem = face.units_per_em() as f32;
let scale = size / upem;
let mut buffer = UnicodeBuffer::new();
buffer.push_str(text);
let features = default_shaping_features();
let glyph_buffer = rustybuzz::shape(&face, &features, buffer);
let mut glyphs = Vec::new();
let mut pen_x: f32 = 0.0;
for (info, pos) in glyph_buffer
.glyph_infos()
.iter()
.zip(glyph_buffer.glyph_positions().iter())
{
let glyph_id = ttf_parser::GlyphId(info.glyph_id as u16);
let x_off = pos.x_offset as f32 * scale;
let y_off = pos.y_offset as f32 * scale;
let glyph_x = pen_x + x_off;
let glyph_y = baseline_y - y_off;
let mut builder = OutlinePathBuilder {
commands: Vec::new(),
scale,
origin_x: glyph_x,
origin_y: glyph_y,
};
face.outline_glyph(glyph_id, &mut builder);
pen_x += pos.x_advance as f32 * scale;
glyphs.push(ShapedGlyph {
cluster: info.cluster as usize,
advance_end: pen_x,
commands: builder.commands,
});
}
ShapedGlyphs {
glyphs,
width: pen_x,
}
}
}
impl PartialEq for Font {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.data, &other.data) && self.face_index == other.face_index
}
}
impl Eq for Font {}
impl Hash for Font {
fn hash<H: Hasher>(&self, state: &mut H) {
(Arc::as_ptr(&self.data) as usize).hash(state);
self.face_index.hash(state);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Weight(pub u16);
impl Weight {
pub const THIN: Self = Self(100);
pub const EXTRA_LIGHT: Self = Self(200);
pub const LIGHT: Self = Self(300);
pub const NORMAL: Self = Self(400);
pub const MEDIUM: Self = Self(500);
pub const SEMIBOLD: Self = Self(600);
pub const BOLD: Self = Self(700);
pub const EXTRA_BOLD: Self = Self(800);
pub const BLACK: Self = Self(900);
}
impl Default for Weight {
fn default() -> Self {
Self::NORMAL
}
}
#[derive(Default, Clone, Keyable, bon::Builder)]
#[builder(derive(Into))]
pub struct TextSpan {
#[builder(into)]
pub text: String,
#[builder(into)]
pub fill: Option<Paint>,
pub font: Option<Arc<Font>>,
pub size: Option<f32>,
pub weight: Option<Weight>,
pub scale_x: Option<f32>,
pub scale_y: Option<f32>,
}
impl TextSpan {
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
..Self::default()
}
}
}
impl From<&str> for TextSpan {
fn from(text: &str) -> Self {
Self::plain(text)
}
}
impl From<String> for TextSpan {
fn from(text: String) -> Self {
Self::plain(text)
}
}
impl Span for TextSpan {
fn shape(&self, ctx: &SpanContext<'_>) -> ShapedSpan {
let font = self.font.as_ref().unwrap_or(ctx.font);
let size = self.size.unwrap_or(ctx.size);
let weight = self.weight.unwrap_or(ctx.weight);
let fill = self.fill.clone().unwrap_or_else(|| ctx.fill.clone());
let scale_x = self.scale_x.unwrap_or(1.0);
let scale_y = self.scale_y.unwrap_or(1.0);
if self.text.is_empty() {
return ShapedSpan {
width: 0.0,
ascent: 0.0,
descent: 0.0,
paths: Vec::new(),
};
}
let shaped = font.shaped_glyphs(weight, size, 0.0, &self.text);
let paths = shaped
.glyphs
.iter()
.filter(|glyph| !glyph.commands.is_empty())
.map(|glyph| {
let commands = if scale_x == 1.0 && scale_y == 1.0 {
glyph.commands.clone()
} else {
glyph
.commands
.iter()
.copied()
.map(|c| scale_command(c, Vec2(scale_x, scale_y)))
.collect()
};
(commands, fill.clone())
})
.collect();
let metrics = font.vertical_metrics(size);
ShapedSpan {
width: shaped.width * scale_x,
ascent: metrics.ascent * scale_y,
descent: metrics.descent * scale_y,
paths,
}
}
}
impl From<TextSpan> for Box<dyn Span> {
fn from(span: TextSpan) -> Self {
Box::new(span)
}
}
impl From<&str> for Box<dyn Span> {
fn from(text: &str) -> Self {
Box::new(TextSpan::plain(text))
}
}
impl From<String> for Box<dyn Span> {
fn from(text: String) -> Self {
Box::new(TextSpan::plain(text))
}
}
impl<S: text_span_builder::IsComplete> From<TextSpanBuilder<S>> for Box<dyn Span> {
fn from(builder: TextSpanBuilder<S>) -> Self {
Box::new(builder.build())
}
}
#[crate::component(vector)]
#[derive(Clone, Keyable)]
pub struct Text {
#[children(each = span)]
pub spans: Vec<Box<dyn Span>>,
pub font: Arc<Font>,
pub size: f32,
#[builder(default)]
pub weight: Weight,
#[builder(into)]
pub fill: Paint,
}
#[derive(Clone)]
struct ResolvedTextRunStyle {
font: Arc<Font>,
size: f32,
weight: Weight,
scale_x: f32,
scale_y: f32,
}
impl ResolvedTextRunStyle {
fn from_span(span: &TextSpan, ctx: &SpanContext<'_>) -> Self {
Self {
font: span.font.clone().unwrap_or_else(|| ctx.font.clone()),
size: span.size.unwrap_or(ctx.size),
weight: span.weight.unwrap_or(ctx.weight),
scale_x: span.scale_x.unwrap_or(1.0),
scale_y: span.scale_y.unwrap_or(1.0),
}
}
fn matches(&self, other: &Self) -> bool {
self.font == other.font
&& self.size.to_bits() == other.size.to_bits()
&& self.weight == other.weight
&& self.scale_x.to_bits() == other.scale_x.to_bits()
&& self.scale_y.to_bits() == other.scale_y.to_bits()
}
}
struct TextRunPart {
start: usize,
end: usize,
fill: Paint,
}
fn boundary_advance(shaped: &ShapedGlyphs, byte_offset: usize) -> f32 {
shaped
.glyphs
.iter()
.take_while(|glyph| glyph.cluster < byte_offset)
.last()
.map_or(0.0, |glyph| glyph.advance_end)
}
fn shape_text_run(
style: &ResolvedTextRunStyle,
text: &str,
parts: &[TextRunPart],
) -> Vec<ShapedSpan> {
let shaped = style
.font
.shaped_glyphs(style.weight, style.size, 0.0, text);
let metrics = style.font.vertical_metrics(style.size);
let ascent = metrics.ascent * style.scale_y;
let descent = metrics.descent * style.scale_y;
let scale = Vec2(style.scale_x, style.scale_y);
parts
.iter()
.map(|part| {
if part.start == part.end {
return ShapedSpan {
width: 0.0,
ascent: 0.0,
descent: 0.0,
paths: Vec::new(),
};
}
let start_x = boundary_advance(&shaped, part.start) * style.scale_x;
let end_x = boundary_advance(&shaped, part.end) * style.scale_x;
let paths = shaped
.glyphs
.iter()
.filter(|glyph| {
!glyph.commands.is_empty()
&& part.start <= glyph.cluster
&& glyph.cluster < part.end
})
.map(|glyph| {
let commands = if style.scale_x == 1.0 && style.scale_y == 1.0 {
glyph.commands.clone()
} else {
glyph
.commands
.iter()
.copied()
.map(|c| scale_command(c, scale))
.collect()
};
let commands = commands
.into_iter()
.map(|c| translate_command(c, Vec2(-start_x, 0.0)))
.collect();
(commands, part.fill.clone())
})
.collect();
ShapedSpan {
width: end_x - start_x,
ascent,
descent,
paths,
}
})
.collect()
}
impl Text {
fn shape_line_preserving_spans(&self) -> (Vec<(f32, ShapedSpan)>, f32, Vec2) {
let ctx = SpanContext {
font: &self.font,
size: self.size,
weight: self.weight,
fill: &self.fill,
};
let base_metrics = self.font.vertical_metrics(self.size);
let mut line_ascent = base_metrics.ascent;
let mut line_below = base_metrics.descent;
let mut placed: Vec<(f32, ShapedSpan)> = Vec::with_capacity(self.spans.len());
let mut pen_x: f32 = 0.0;
for span in &self.spans {
let shaped = span.shape(&ctx);
line_ascent = line_ascent.max(shaped.ascent);
line_below = line_below.max(shaped.descent);
let start_x = pen_x;
pen_x += shaped.width;
placed.push((start_x, shaped));
}
let size = Vec2(pen_x, line_ascent + line_below);
(placed, line_ascent, size)
}
fn shape_line(&self) -> (Vec<(f32, ShapedSpan)>, f32, Vec2) {
let ctx = SpanContext {
font: &self.font,
size: self.size,
weight: self.weight,
fill: &self.fill,
};
let base_metrics = self.font.vertical_metrics(self.size);
let mut line_ascent = base_metrics.ascent;
let mut line_below = base_metrics.descent;
let mut placed: Vec<(f32, ShapedSpan)> = Vec::with_capacity(self.spans.len());
let mut pen_x: f32 = 0.0;
let mut i = 0;
while i < self.spans.len() {
if let Some(text_span) = self.spans[i].as_ref().as_any().downcast_ref::<TextSpan>() {
let style = ResolvedTextRunStyle::from_span(text_span, &ctx);
let mut text = String::new();
let mut parts = Vec::new();
let mut j = i;
while j < self.spans.len() {
let Some(next_span) =
self.spans[j].as_ref().as_any().downcast_ref::<TextSpan>()
else {
break;
};
let next_style = ResolvedTextRunStyle::from_span(next_span, &ctx);
if !style.matches(&next_style) {
break;
}
let start = text.len();
text.push_str(&next_span.text);
let end = text.len();
parts.push(TextRunPart {
start,
end,
fill: next_span.fill.clone().unwrap_or_else(|| ctx.fill.clone()),
});
j += 1;
}
for shaped in shape_text_run(&style, &text, &parts) {
line_ascent = line_ascent.max(shaped.ascent);
line_below = line_below.max(shaped.descent);
let start_x = pen_x;
pen_x += shaped.width;
placed.push((start_x, shaped));
}
i = j;
continue;
}
#[cfg(feature = "latex")]
if let Some(math_span) = self.spans[i]
.as_ref()
.as_any()
.downcast_ref::<crate::math::MathSpan>()
{
let size = math_span.size.unwrap_or(ctx.size);
let fill = math_span.fill.clone().unwrap_or_else(|| ctx.fill.clone());
let mut source = String::new();
let mut j = i;
while j < self.spans.len() {
let Some(next_span) = self.spans[j]
.as_ref()
.as_any()
.downcast_ref::<crate::math::MathSpan>()
else {
break;
};
let next_size = next_span.size.unwrap_or(ctx.size);
let next_fill = next_span.fill.clone().unwrap_or_else(|| ctx.fill.clone());
if size.to_bits() != next_size.to_bits() || fill != next_fill {
break;
}
source.push_str(&next_span.source);
j += 1;
}
let shaped = crate::math::MathSpan {
source,
size: Some(size),
fill: Some(fill),
}
.shape(&ctx);
line_ascent = line_ascent.max(shaped.ascent);
line_below = line_below.max(shaped.descent);
let start_x = pen_x;
pen_x += shaped.width;
placed.push((start_x, shaped));
i = j;
continue;
}
let shaped = self.spans[i].shape(&ctx);
line_ascent = line_ascent.max(shaped.ascent);
line_below = line_below.max(shaped.descent);
let start_x = pen_x;
pen_x += shaped.width;
placed.push((start_x, shaped));
i += 1;
}
let size = Vec2(pen_x, line_ascent + line_below);
(placed, line_ascent, size)
}
fn shape_and_layout(&self) -> (Vec<(Vec<PathCommand>, Paint)>, Vec2) {
let (placed, baseline_y, size) = self.shape_line();
let mut all_paths: Vec<(Vec<PathCommand>, Paint)> = Vec::new();
for (start_x, shaped) in placed {
let delta = Vec2(start_x, baseline_y);
for (commands, fill) in shaped.paths {
let shifted: Vec<PathCommand> = commands
.into_iter()
.map(|c| translate_command(c, delta))
.collect();
all_paths.push((shifted, fill));
}
}
(all_paths, size)
}
pub fn into_spans(self) -> Vec<Positioned> {
let (placed, baseline_y, size) = self.shape_line_preserving_spans();
let line_height = size.1;
placed
.into_iter()
.map(|(start_x, shaped)| {
let paths = shaped
.paths
.into_iter()
.map(|(commands, fill)| {
let shifted: Vec<PathCommand> = commands
.into_iter()
.map(|c| translate_command(c, Vec2(0.0, baseline_y)))
.collect();
(shifted, fill)
})
.collect();
TextSpanGraphic {
paths,
size: Vec2(shaped.width, line_height),
}
.place_at(Vec2(start_x, 0.0))
})
.collect()
}
}
fn translate_command(cmd: PathCommand, delta: Vec2) -> PathCommand {
match cmd {
PathCommand::MoveTo(p) => PathCommand::MoveTo(p + delta),
PathCommand::LineTo(p) => PathCommand::LineTo(p + delta),
PathCommand::QuadTo { control, to } => PathCommand::QuadTo {
control: control + delta,
to: to + delta,
},
PathCommand::CubicTo { c1, c2, to } => PathCommand::CubicTo {
c1: c1 + delta,
c2: c2 + delta,
to: to + delta,
},
PathCommand::Close => PathCommand::Close,
}
}
fn scale_command(cmd: PathCommand, scale: Vec2) -> PathCommand {
let transform = Transform::scale(scale);
match cmd {
PathCommand::MoveTo(p) => PathCommand::MoveTo(transform.transform_point(p)),
PathCommand::LineTo(p) => PathCommand::LineTo(transform.transform_point(p)),
PathCommand::QuadTo { control, to } => PathCommand::QuadTo {
control: transform.transform_point(control),
to: transform.transform_point(to),
},
PathCommand::CubicTo { c1, c2, to } => PathCommand::CubicTo {
c1: transform.transform_point(c1),
c2: transform.transform_point(c2),
to: transform.transform_point(to),
},
PathCommand::Close => PathCommand::Close,
}
}
#[derive(Clone, PartialEq, Hash)]
pub struct TextSpanGraphic {
paths: Vec<(Vec<PathCommand>, Paint)>,
size: Vec2,
}
impl VectorComponent for TextSpanGraphic {
fn layout(&self, constraints: Constraints) -> Vec2 {
constraints.constrain(self.size)
}
fn render(&self, size: Vec2) -> VectorGraphic {
let children: Vec<Node> = self
.paths
.iter()
.filter(|(_, fill)| fill.is_visible())
.map(|(commands, fill)| {
Node::Path(VPath {
commands: commands.clone(),
fill: Some(Fill {
paint: fill.clone(),
}),
stroke: None,
transform: Transform::IDENTITY,
})
})
.collect();
VectorGraphic {
view_box: Rect {
origin: Vec2::ZERO,
size,
},
root: Node::Group(Group {
transform: Transform::IDENTITY,
opacity: 1.0,
children,
}),
}
}
}
impl VectorComponent for Text {
fn layout(&self, constraints: Constraints) -> Vec2 {
let (_paths, size) = self.shape_and_layout();
constraints.constrain(size)
}
fn render(&self, size: Vec2) -> VectorGraphic {
let (paths, _intrinsic) = self.shape_and_layout();
let nodes: Vec<Node> = paths
.into_iter()
.filter(|(_, fill)| fill.is_visible())
.map(|(commands, fill)| {
Node::Path(VPath {
commands,
fill: Some(Fill { paint: fill }),
stroke: None,
transform: Transform::IDENTITY,
})
})
.collect();
VectorGraphic {
view_box: Rect {
origin: Vec2::ZERO,
size,
},
root: Node::Group(Group {
transform: Transform::IDENTITY,
opacity: 1.0,
children: nodes,
}),
}
}
}
struct OutlinePathBuilder {
commands: Vec<PathCommand>,
scale: f32,
origin_x: f32,
origin_y: f32,
}
impl OutlinePathBuilder {
fn map(&self, x: f32, y: f32) -> Vec2 {
Vec2(
self.origin_x + x * self.scale,
self.origin_y - y * self.scale,
)
}
}
impl ttf_parser::OutlineBuilder for OutlinePathBuilder {
fn move_to(&mut self, x: f32, y: f32) {
self.commands.push(PathCommand::MoveTo(self.map(x, y)));
}
fn line_to(&mut self, x: f32, y: f32) {
self.commands.push(PathCommand::LineTo(self.map(x, y)));
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
self.commands.push(PathCommand::QuadTo {
control: self.map(x1, y1),
to: self.map(x, y),
});
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
self.commands.push(PathCommand::CubicTo {
c1: self.map(x1, y1),
c2: self.map(x2, y2),
to: self.map(x, y),
});
}
fn close(&mut self) {
self.commands.push(PathCommand::Close);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_shaping_enables_proportional_alternates() {
let features = default_shaping_features();
assert_eq!(features[0].tag, ttf_parser::Tag::from_bytes(b"palt"));
assert_eq!(features[0].value, 1);
assert_eq!(features[0].start, 0);
assert_eq!(features[0].end, u32::MAX);
}
#[test]
fn font_vertical_metrics_are_non_negative_and_scale() {
let font = SANS_SERIF.clone();
let small = font.vertical_metrics(24.0);
let large = font.vertical_metrics(48.0);
assert!(small.ascent >= 0.0);
assert!(small.descent >= 0.0);
assert!(small.line_gap >= 0.0);
assert!(small.ascent + small.descent > 0.0);
assert_close(large.ascent, small.ascent * 2.0);
assert_close(large.descent, small.descent * 2.0);
assert_close(large.line_gap, small.line_gap * 2.0);
}
#[test]
fn single_line_text_intrinsic_height_excludes_line_gap() {
let font = SANS_SERIF.clone();
let size = 48.0;
let metrics = font.vertical_metrics(size);
let fill = Paint::Solid(crate::color::Color::rgb_u8(20, 20, 20));
let text = Text::builder()
.font(font)
.size(size)
.fill(fill)
.span("Ag")
.build();
let (_paths, layout_size) = text.shape_and_layout();
let expected_height = metrics.ascent + metrics.descent;
assert_close(layout_size.1, expected_height);
assert!(layout_size.1 <= metrics.ascent + metrics.descent + metrics.line_gap);
if metrics.line_gap > 0.0 {
assert!(layout_size.1 < metrics.ascent + metrics.descent + metrics.line_gap);
}
}
#[test]
fn text_span_scales_glyphs_and_metrics() {
let font = SANS_SERIF.clone();
let fill = Paint::Solid(crate::color::Color::rgb_u8(20, 20, 20));
let ctx = SpanContext {
font: &font,
size: 48.0,
weight: Weight::NORMAL,
fill: &fill,
};
let normal = TextSpan::plain("Scale").shape(&ctx);
let scaled = TextSpan::builder()
.text("Scale")
.scale_x(1.4)
.scale_y(1.25)
.build()
.shape(&ctx);
assert!(normal.width > 0.0);
assert_close(scaled.width, normal.width * 1.4);
assert_close(scaled.ascent, normal.ascent * 1.25);
assert_close(scaled.descent, normal.descent * 1.25);
let (normal_x, normal_y) = first_path_point(&normal);
let (scaled_x, scaled_y) = first_path_point(&scaled);
assert_close(scaled_x, normal_x * 1.4);
assert_close(scaled_y, normal_y * 1.25);
}
#[test]
fn adjacent_text_spans_shape_as_one_run() {
let fill = Paint::Solid(crate::color::Color::rgb_u8(20, 20, 20));
let whole = Text::builder()
.font(SERIF.clone())
.size(72.0)
.fill(fill.clone())
.span("AVAV")
.build();
let split = Text::builder()
.font(SERIF.clone())
.size(72.0)
.fill(fill)
.span("A")
.span("V")
.span("A")
.span("V")
.build();
let (whole_paths, whole_size) = whole.shape_and_layout();
let (split_paths, split_size) = split.shape_and_layout();
assert_eq!(split_size, whole_size);
assert_eq!(split_paths, whole_paths);
}
#[test]
fn differently_colored_text_spans_keep_combined_run_advance() {
let black = Paint::Solid(crate::color::Color::rgb_u8(20, 20, 20));
let red = Paint::Solid(crate::color::Color::rgb_u8(220, 60, 60));
let whole = Text::builder()
.font(SERIF.clone())
.size(72.0)
.fill(black.clone())
.span("AVAV")
.build();
let split = Text::builder()
.font(SERIF.clone())
.size(72.0)
.fill(black)
.span("A")
.span(TextSpan::builder().text("V").fill(red.clone()))
.span("A")
.span(TextSpan::builder().text("V").fill(red))
.build();
let (_whole_paths, whole_size) = whole.shape_and_layout();
let (_split_paths, split_size) = split.shape_and_layout();
assert_eq!(split_size, whole_size);
}
#[cfg(feature = "latex")]
#[test]
fn adjacent_math_spans_shape_as_one_formula_when_style_matches() {
let fill = Paint::Solid(crate::color::Color::rgb_u8(20, 20, 20));
let whole = Text::builder()
.font(SERIF.clone())
.size(72.0)
.fill(fill.clone())
.span(crate::math::MathSpan::new(r"x^2+y"))
.build();
let split = Text::builder()
.font(SERIF.clone())
.size(72.0)
.fill(fill)
.span(crate::math::MathSpan::new(r"x^2"))
.span(crate::math::MathSpan::new(r"+y"))
.build();
let (_whole_paths, whole_size) = whole.shape_and_layout();
let (_split_paths, split_size) = split.shape_and_layout();
assert_eq!(split_size, whole_size);
}
fn first_path_point(span: &ShapedSpan) -> (f32, f32) {
for (commands, _) in &span.paths {
for command in commands {
if let PathCommand::MoveTo(point)
| PathCommand::LineTo(point)
| PathCommand::QuadTo { to: point, .. }
| PathCommand::CubicTo { to: point, .. } = command
{
return (point.0, point.1);
}
}
}
panic!("span should contain at least one path point");
}
fn assert_close(actual: f32, expected: f32) {
let tolerance = 0.001;
assert!(
(actual - expected).abs() <= tolerance,
"expected {actual} to be within {tolerance} of {expected}"
);
}
}