use skia_safe::shaper::run_handler::{Buffer, RunInfo};
use skia_safe::shaper::{RunHandler, Shaper as SkShaper};
use skia_safe::shapers;
use skia_safe::{Font, GlyphId, Point, Typeface};
use thiserror::Error;
use unicode_joining_type::{get_joining_type, JoiningType};
use crate::i18n::bidi::BidiLevel;
use crate::render::dimension::Pt;
pub fn needs_shaping(text: &str) -> bool {
text.chars().any(|c| {
matches!(
get_joining_type(c),
JoiningType::DualJoining | JoiningType::LeftJoining | JoiningType::RightJoining
)
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RunDirection {
#[default]
LeftToRight,
RightToLeft,
}
impl From<BidiLevel> for RunDirection {
fn from(level: BidiLevel) -> Self {
if level.is_rtl() {
Self::RightToLeft
} else {
Self::LeftToRight
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShapedGlyph {
pub id: GlyphId,
pub x: Pt,
pub y: Pt,
}
#[derive(Clone, Debug)]
pub struct ShapedRun {
pub glyphs: Vec<ShapedGlyph>,
pub total_advance: Pt,
}
#[derive(Debug, Error)]
pub enum ShapeError {
#[error("skia was built without a HarfBuzz shaper")]
ShaperUnavailable,
#[error("shaping produced no glyphs")]
NoGlyphs,
}
pub struct Shaper {
shaper: SkShaper,
}
impl Shaper {
pub fn new() -> Result<Self, ShapeError> {
shapers::hb::shape_dont_wrap_or_reorder(None)
.map(|shaper| Self { shaper })
.ok_or(ShapeError::ShaperUnavailable)
}
pub fn shape(
&self,
typeface: &Typeface,
text: &str,
size_px: f32,
direction: RunDirection,
) -> Result<ShapedRun, ShapeError> {
let font = Font::from_typeface(typeface.clone(), size_px);
let mut collector = Collector::default();
self.shaper.shape(
text,
&font,
direction == RunDirection::LeftToRight,
f32::MAX,
&mut collector,
);
if collector.glyphs.is_empty() {
return Err(ShapeError::NoGlyphs);
}
let glyphs = collector
.glyphs
.iter()
.zip(collector.positions.iter())
.map(|(&id, p)| ShapedGlyph {
id,
x: Pt::new(p.x),
y: Pt::new(p.y),
})
.collect();
Ok(ShapedRun {
glyphs,
total_advance: Pt::new(collector.advance_x),
})
}
}
#[derive(Default)]
struct Collector {
glyphs: Vec<GlyphId>,
positions: Vec<Point>,
advance_x: f32,
}
impl RunHandler for Collector {
fn begin_line(&mut self) {}
fn run_info(&mut self, _info: &RunInfo) {}
fn commit_run_info(&mut self) {}
fn run_buffer<'a>(&'a mut self, info: &RunInfo) -> Buffer<'a> {
let base = self.glyphs.len();
let origin = Point::new(self.advance_x, 0.0);
self.glyphs.resize(base + info.glyph_count, 0);
self.positions
.resize(base + info.glyph_count, Point::new(0.0, 0.0));
self.advance_x += info.advance.x;
Buffer::new(
&mut self.glyphs[base..],
&mut self.positions[base..],
origin,
)
}
fn commit_run_buffer(&mut self, _info: &RunInfo) {}
fn commit_line(&mut self) {}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::emoji::resolve::EmojiFamily;
use skia_safe::{FontMgr, FontStyle};
fn emoji_typeface() -> Option<Typeface> {
let mgr = FontMgr::new();
EmojiFamily::host_default().iter().find_map(|f| {
mgr.match_family_style(f.family_name(), FontStyle::normal())
.filter(|tf| tf.family_name().eq_ignore_ascii_case(f.family_name()))
})
}
fn any_typeface() -> Option<Typeface> {
FontMgr::new().legacy_make_typeface(None::<&str>, FontStyle::normal())
}
#[test]
fn text_without_positional_forms_is_not_shaped() {
for text in [
"Nicht gefunden",
"S.I.G.M.A. Technik Service GmbH",
"Türöffner-Gerät",
"e\u{301}coute",
"Привет",
"日本語の文章",
"ภาษาไทย",
"שלום עולם",
"",
] {
assert!(!needs_shaping(text), "{text:?} must stay on the cmap path");
}
}
#[test]
fn joining_scripts_are_shaped() {
for (script, text) in [
("Arabic", "مرحبا"),
("Syriac", "\u{0710}\u{0712}"),
("N'Ko", "\u{07CA}\u{07D9}"),
("Mongolian", "\u{1820}\u{1821}"),
("Adlam", "\u{1E922}\u{1E923}"),
("Hanifi Rohingya", "\u{10D00}\u{10D01}"),
] {
assert!(
needs_shaping(text),
"{script} letters have positional forms"
);
}
}
#[test]
fn a_right_to_left_script_without_cursive_joining_is_not_shaped() {
assert!(!needs_shaping("\u{0780}\u{0783}"));
}
#[test]
fn a_stray_zero_width_joiner_does_not_pull_latin_into_the_shaper() {
assert!(!needs_shaping("a\u{200D}b"));
}
#[test]
fn mixed_text_containing_a_joining_script_is_shaped() {
assert!(needs_shaping("page مرحبا here"));
}
#[test]
fn shaper_constructs() {
assert!(
Shaper::new().is_ok(),
"skia must expose a HarfBuzz shaper — the `textlayout` feature is \
what lets this module shape without serializing the font"
);
}
#[test]
fn ascii_shapes_one_glyph_per_char_advancing_rightwards() {
let Some(tf) = any_typeface() else { return };
let shaper = Shaper::new().expect("shaper");
let run = shaper
.shape(&tf, "abc", 20.0, RunDirection::LeftToRight)
.expect("shape");
assert_eq!(run.glyphs.len(), 3, "ASCII must not ligate");
assert_eq!(run.glyphs[0].x, Pt::ZERO, "run origin is the first glyph");
assert!(
run.glyphs[1].x > run.glyphs[0].x && run.glyphs[2].x > run.glyphs[1].x,
"positions are absolute and strictly increasing, not per-glyph advances"
);
assert!(run.total_advance > Pt::ZERO);
}
#[test]
fn zwj_sequence_ligates_to_one_glyph() {
let Some(tf) = emoji_typeface() else { return };
let shaper = Shaper::new().expect("shaper");
let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
assert_eq!(family.chars().count(), 5);
let run = shaper
.shape(&tf, family, 44.0, RunDirection::LeftToRight)
.expect("shape");
assert!(
run.glyphs.len() < 5,
"shaping must be GSUB/cluster-aware, not cmap-only mapping \
(which would yield 5 for this 5-codepoint sequence); got {}",
run.glyphs.len()
);
assert!(run.total_advance > Pt::ZERO);
}
#[test]
fn modifier_and_keycap_sequences_ligate() {
let Some(tf) = emoji_typeface() else { return };
let shaper = Shaper::new().expect("shaper");
const SIZE: f32 = 44.0;
for (label, text) in [
("skin-tone modifier", "\u{1F44D}\u{1F3FF}"),
("keycap", "1\u{FE0F}\u{20E3}"),
] {
let run = shaper
.shape(&tf, text, SIZE, RunDirection::LeftToRight)
.expect("shape");
let cells = f32::from(run.total_advance) / SIZE;
assert!(
(0.5..=1.5).contains(&cells),
"{label} must occupy one cell, got {cells:.2} ({:?} at size {SIZE})",
run.total_advance,
);
assert!(
run.glyphs.len() < text.chars().count(),
"{label}: {} glyphs for {} codepoints is no ligation at all",
run.glyphs.len(),
text.chars().count(),
);
}
}
#[test]
fn empty_text_reports_no_glyphs() {
let Some(tf) = any_typeface() else { return };
let shaper = Shaper::new().expect("shaper");
assert!(matches!(
shaper.shape(&tf, "", 20.0, RunDirection::LeftToRight),
Err(ShapeError::NoGlyphs)
));
}
#[test]
fn glyph_ids_are_valid_for_the_same_typeface() {
let Some(tf) = any_typeface() else { return };
let shaper = Shaper::new().expect("shaper");
let run = shaper
.shape(&tf, "abc", 24.0, RunDirection::LeftToRight)
.expect("shape");
let font = Font::from_typeface(tf, 24.0);
let ids: Vec<GlyphId> = run.glyphs.iter().map(|g| g.id).collect();
let mut widths = vec![0.0f32; ids.len()];
font.get_widths(&ids, &mut widths);
assert!(
widths.iter().all(|w| *w > 0.0),
"every shaped glyph id must have a width in the same font: {widths:?}"
);
}
#[test]
fn shaping_does_not_materialize_the_font() {
let Some(tf) = emoji_typeface() else { return };
let shaper = Shaper::new().expect("shaper");
let Some(before) = resident_bytes() else {
return; };
for _ in 0..64 {
let _ = shaper.shape(&tf, "\u{1F44D}", 176.0, RunDirection::LeftToRight);
}
let Some(after) = resident_bytes() else {
return;
};
let growth = after.saturating_sub(before);
assert!(
growth < 64 * 1024 * 1024,
"shaping grew RSS by {} MB — the font was probably serialized",
growth / 1024 / 1024
);
}
fn resident_bytes() -> Option<usize> {
std::process::Command::new("ps")
.args(["-o", "rss=", "-p", &std::process::id().to_string()])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse::<usize>().ok())
.map(|kb| kb * 1024)
}
}