use std::ops::Range;
use super::arabic;
use super::bidi;
use super::font::FaceEntry;
use super::sdf::{SdfCoverage, SDF_EM_PX};
pub(crate) struct ShapedGlyph {
pub font: usize,
pub glyph_id: u16,
pub x_advance: f32,
pub x_offset: f32,
pub y_offset: f32,
pub char_ix: usize,
pub scale: f32,
pub section: u16,
pub level: u8,
}
pub(crate) struct ShapeSection<'a> {
pub text: &'a str,
pub fonts: Range<usize>,
pub scale: f32,
}
pub(crate) struct ShapedText {
pub glyphs: Vec<ShapedGlyph>,
pub chars: Vec<char>,
pub covered: Vec<bool>,
pub dropped: usize,
pub missing_range: usize,
}
struct Run {
font: usize,
text: String,
draw: Vec<Option<char>>,
char_start: usize,
level: u8,
}
pub(crate) fn shape_sections(
sections: &[ShapeSection<'_>],
fonts: &[FaceEntry<'_>],
letter_spacing_em: f32,
) -> ShapedText {
let mut glyphs = Vec::new();
let mut chars: Vec<char> = Vec::new();
let mut covered: Vec<bool> = Vec::new();
let mut dropped = 0usize;
let mut missing_range = 0usize;
let joined: String = sections.iter().map(|s| s.text).collect();
let all_levels = bidi::levels(&joined);
let mut level_base = 0usize;
for (sec_ix, sec) in sections.iter().enumerate() {
let base = sec.fonts.start;
let sub = &fonts[sec.fonts.clone()];
let sec_levels = &all_levels[level_base..level_base + sec.text.chars().count()];
level_base += sec_levels.len();
let it = itemize(sec.text, sub, sec_levels);
let char_base = chars.len();
chars.extend(it.chars);
covered.extend(it.covered);
dropped += it.dropped;
missing_range += it.missing_range;
let runs = it.runs;
for run in &runs {
shape_run(
run,
&fonts[base + run.font],
base,
sec.scale,
sec_ix as u16,
char_base,
letter_spacing_em,
&mut glyphs,
);
}
}
ShapedText {
glyphs,
chars,
covered,
dropped,
missing_range,
}
}
#[allow(clippy::too_many_arguments)]
fn shape_run(
run: &Run,
entry: &FaceEntry<'_>,
base: usize,
scale: f32,
section: u16,
char_base: usize,
letter_spacing_em: f32,
glyphs: &mut Vec<ShapedGlyph>,
) {
let font_ix = base + run.font;
let rtl = run.level % 2 == 1;
let spacing = if run.text.chars().all(allows_letter_spacing) {
letter_spacing_em
} else {
0.0
};
match entry {
FaceEntry::Outline { font, face } => {
let units = 1.0 / font.units_per_em();
let first = glyphs.len();
let char_of_byte: Vec<usize> = {
let mut map = vec![0usize; run.text.len() + 1];
for (char_ix, (byte_ix, _)) in run.text.char_indices().enumerate() {
map[byte_ix] = char_base + run.char_start + char_ix;
}
map
};
let mut buffer = rustybuzz::UnicodeBuffer::new();
buffer.push_str(&run.text);
buffer.guess_segment_properties();
buffer.set_direction(if rtl {
rustybuzz::Direction::RightToLeft
} else {
rustybuzz::Direction::LeftToRight
});
let shaped = rustybuzz::shape(face, &[], buffer);
for (info, pos) in shaped
.glyph_infos()
.iter()
.zip(shaped.glyph_positions().iter())
{
glyphs.push(ShapedGlyph {
font: font_ix,
glyph_id: info.glyph_id as u16,
x_advance: pos.x_advance as f32 * units * scale + spacing,
x_offset: pos.x_offset as f32 * units * scale,
y_offset: pos.y_offset as f32 * units * scale,
char_ix: char_of_byte[info.cluster as usize],
scale,
section,
level: run.level,
});
}
if rtl {
glyphs[first..].reverse();
}
}
FaceEntry::Sdf(stack) => {
for (char_ix, draw) in run.draw.iter().enumerate() {
let Some(c) = *draw else {
continue;
};
let Some(glyph) = stack.glyph(c) else {
continue;
};
glyphs.push(ShapedGlyph {
font: font_ix,
glyph_id: c as u16,
x_advance: glyph.advance as f32 / SDF_EM_PX * scale + spacing,
x_offset: 0.0,
y_offset: 0.0,
char_ix: char_base + run.char_start + char_ix,
scale,
section,
level: run.level,
});
}
}
}
}
struct Itemized {
runs: Vec<Run>,
chars: Vec<char>,
covered: Vec<bool>,
dropped: usize,
missing_range: usize,
}
type Resolved = Option<(usize, Option<char>)>;
fn itemize(text: &str, fonts: &[FaceEntry<'_>], levels: &[u8]) -> Itemized {
let all: Vec<char> = text.chars().collect();
let resolved = resolve(&all, fonts);
let mut runs: Vec<Run> = Vec::new();
let mut chars: Vec<char> = Vec::new();
let mut covered: Vec<bool> = Vec::new();
let mut dropped = 0usize;
let mut missing_range = 0usize;
let mut run_end = 0usize;
for (ix, &c) in all.iter().enumerate() {
let Some((font, draw)) = resolved[ix] else {
dropped += 1;
if fonts.iter().any(|f| {
matches!(f, FaceEntry::Sdf(s) if s.coverage(c) == SdfCoverage::RangeUnavailable)
}) {
missing_range += 1;
}
chars.push(c);
covered.push(false);
continue;
};
let level = levels[ix];
match runs.last_mut() {
Some(run) if run.font == font && run.level == level && run_end == chars.len() => {
run.text.push(c);
run.draw.push(draw);
}
_ => runs.push(Run {
font,
text: c.to_string(),
draw: vec![draw],
char_start: chars.len(),
level,
}),
}
chars.push(c);
covered.push(true);
run_end = chars.len();
}
Itemized {
runs,
chars,
covered,
dropped,
missing_range,
}
}
fn resolve(all: &[char], fonts: &[FaceEntry<'_>]) -> Vec<Resolved> {
let mut out: Vec<Resolved> = vec![None; all.len()];
let mut prev_font: Option<usize> = None;
let mut ligated = vec![false; all.len()];
let mut wanted: Vec<char> = Vec::new();
for ix in 0..all.len() {
let c = all[ix];
if ligated[ix] {
out[ix] = prev_font.map(|f| (f, None));
continue;
}
let (from_previous, to_next) = joining_context(all, ix);
wanted.clear();
let ligature = if arabic::is_lam(c) {
next_joinable(all, ix).and_then(|n| arabic::lam_alef(all[n], from_previous))
} else {
None
};
wanted.extend(ligature);
arabic::shaped_forms(c, from_previous, to_next, &mut wanted);
wanted.push(c);
let mark_font = if is_mark(c) { prev_font } else { None };
let order = mark_font.into_iter().chain(0..fonts.len());
let picked = order
.filter_map(|i| draws(&fonts[i], c, &wanted).map(|cp| (i, cp)))
.next();
let Some((font, cp)) = picked else {
prev_font = None;
continue;
};
if Some(cp) == ligature {
if let Some(n) = next_joinable(all, ix) {
ligated[n] = true;
}
}
out[ix] = Some((font, Some(cp)));
prev_font = Some(font);
}
out
}
fn draws(entry: &FaceEntry<'_>, c: char, wanted: &[char]) -> Option<char> {
match entry {
FaceEntry::Outline { .. } => entry.covers(c).then_some(c),
FaceEntry::Sdf(_) => wanted.iter().copied().find(|&cp| entry.covers(cp)),
}
}
fn joining_context(all: &[char], ix: usize) -> (bool, bool) {
let previous = all[..ix]
.iter()
.rev()
.find(|c| !arabic::is_transparent(**c))
.copied();
let next = next_joinable(all, ix).map(|n| all[n]);
arabic::joined_sides(previous, all[ix], next)
}
fn next_joinable(all: &[char], ix: usize) -> Option<usize> {
all.iter()
.enumerate()
.skip(ix + 1)
.find(|(_, c)| !arabic::is_transparent(**c))
.map(|(i, _)| i)
}
fn allows_letter_spacing(c: char) -> bool {
use unicode_script::{Script, UnicodeScript};
!matches!(
c.script(),
Script::Adlam
| Script::Arabic
| Script::Chorasmian
| Script::Duployan
| Script::Hanifi_Rohingya
| Script::Mandaic
| Script::Manichaean
| Script::Mongolian
| Script::Nko
| Script::Old_Uyghur
| Script::Phags_Pa
| Script::Psalter_Pahlavi
| Script::Sogdian
| Script::Syriac
)
}
fn is_mark(c: char) -> bool {
arabic::is_transparent(c) || is_combining_mark(c)
}
fn is_combining_mark(c: char) -> bool {
matches!(
c as u32,
0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F )
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn letter_spacing_is_refused_across_the_cursive_scripts() {
for c in [
'\u{0644}', '\u{0710}', '\u{1820}', '\u{07CA}', '\u{1E921}', ] {
assert!(!allows_letter_spacing(c), "{c:?} is cursive");
}
for c in ['a', '\u{05D0}', '\u{3042}', ' '] {
assert!(allows_letter_spacing(c), "{c:?} is not cursive");
}
}
}