mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 12:24:54 +00:00
186 lines
8.2 KiB
Rust
186 lines
8.2 KiB
Rust
//! Paragraph panel › Insert Kashidas: justified lines lengthen their Arabic words with kashidas.
|
||
//!
|
||
//! [`plan`] picks, in each Arabic word of a justified line, the place between its last pair of
|
||
//! joined letters, and shares the line's room among those places, each kashida up to its
|
||
//! [`Kashida::max_em`]. The layout draws each kashida as tatweels (U+0640) in the face of the
|
||
//! letter after it, and the word spaces take the room the kashidas leave.
|
||
//!
|
||
//! Behavior reference (public documentation):
|
||
//! <https://web.archive.org/web/20260906105741/https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html>
|
||
//! and <https://web.archive.org/web/20250816144754/https://helpx.adobe.com/illustrator/using/arabic-hebrew.html>.
|
||
|
||
use std::ops::Range;
|
||
|
||
use vectorcraft_doc::text::Kashida;
|
||
|
||
use crate::shape::SGlyph;
|
||
|
||
/// The tatweel (kashida) character.
|
||
pub(crate) const TATWEEL: char = '\u{0640}';
|
||
|
||
/// How a character joins its neighbors in cursive Arabic, from the Unicode Character Database's
|
||
/// `ArabicShaping.txt` for the Arabic block (U+0600 to U+06FF). Other characters do not join,
|
||
/// except the zero width joiner, and the nonspacing and enclosing marks and format characters
|
||
/// that the file does not list, which joining goes across.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum Joining {
|
||
/// Joins the letters on both sides.
|
||
Dual,
|
||
/// Joins the letter before it only.
|
||
Right,
|
||
/// Joins both sides without changing its own form (the tatweel, the zero width joiner).
|
||
Causing,
|
||
/// Skipped over: joining goes on across it (nonspacing and enclosing marks, format
|
||
/// characters).
|
||
Transparent,
|
||
None,
|
||
}
|
||
|
||
fn joining(c: char) -> Joining {
|
||
use unicode_general_category::{GeneralCategory, get_general_category};
|
||
match c {
|
||
'\u{0620}'
|
||
| '\u{0626}'
|
||
| '\u{0628}'
|
||
| '\u{062A}'..='\u{062E}'
|
||
| '\u{0633}'..='\u{063F}'
|
||
| '\u{0641}'..='\u{0647}'
|
||
| '\u{0649}'
|
||
| '\u{064A}' => Joining::Dual,
|
||
'\u{066E}' | '\u{066F}' | '\u{0678}'..='\u{0687}' | '\u{069A}'..='\u{06BF}' | '\u{06C1}' | '\u{06C2}' | '\u{06CC}' | '\u{06CE}' => {
|
||
Joining::Dual
|
||
}
|
||
'\u{06D0}' | '\u{06D1}' | '\u{06FA}'..='\u{06FC}' | '\u{06FF}' => Joining::Dual,
|
||
'\u{0622}'..='\u{0625}'
|
||
| '\u{0627}'
|
||
| '\u{0629}'
|
||
| '\u{062F}'..='\u{0632}'
|
||
| '\u{0648}'
|
||
| '\u{0671}'..='\u{0673}'
|
||
| '\u{0675}'..='\u{0677}' => Joining::Right,
|
||
'\u{0688}'..='\u{0699}'
|
||
| '\u{06C0}'
|
||
| '\u{06C3}'..='\u{06CB}'
|
||
| '\u{06CD}'
|
||
| '\u{06CF}'
|
||
| '\u{06D2}'
|
||
| '\u{06D3}'
|
||
| '\u{06D5}'
|
||
| '\u{06EE}'
|
||
| '\u{06EF}' => Joining::Right,
|
||
TATWEEL | '\u{200D}' => Joining::Causing,
|
||
'\u{0600}'..='\u{0605}' | '\u{06DD}' | '\u{200C}' => Joining::None,
|
||
_ if matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::EnclosingMark | GeneralCategory::Format) => {
|
||
Joining::Transparent
|
||
}
|
||
_ => Joining::None,
|
||
}
|
||
}
|
||
|
||
/// Is the cluster of glyph `a` joined to the cluster of glyph `b` that follows it in `text`: both
|
||
/// right to left in one face, `a`'s last character other than a mark joining the letter after it,
|
||
/// and `b` starting with a letter that joins the one before it?
|
||
fn joined(text: &str, a: &SGlyph, b: &SGlyph) -> bool {
|
||
if a.face.id() != b.face.id() || a.level != b.level || !a.level.is_rtl() || a.byte + a.len != b.byte {
|
||
return false;
|
||
}
|
||
let last = text.get(a.byte..a.byte + a.len).and_then(|s| s.chars().rev().find(|&c| joining(c) != Joining::Transparent));
|
||
let first = text.get(b.byte..b.byte + b.len).and_then(|s| s.chars().next());
|
||
matches!(last.map(joining), Some(Joining::Dual | Joining::Causing))
|
||
&& matches!(first.map(joining), Some(Joining::Dual | Joining::Right | Joining::Causing))
|
||
}
|
||
|
||
/// The tatweel of `g`'s face and its advance in points at `g`'s scale; None when the face has
|
||
/// no tatweel.
|
||
pub(crate) fn tatweel(g: &SGlyph) -> Option<(u32, f64)> {
|
||
let gid = g.face.glyph_for(TATWEEL);
|
||
let advance = g.face.advance(gid) * g.sx;
|
||
(gid != 0 && advance.is_finite() && advance > 0.0).then_some((gid, advance))
|
||
}
|
||
|
||
/// The most tatweels one Stylistic kashida is drawn with.
|
||
pub(crate) const MAX_TATWEELS: f64 = 64.0;
|
||
|
||
/// The kashidas of the glyphs `line` (indices into `sg`, a paragraph's glyphs in logical order;
|
||
/// `text` is the plain text) of a justified line with `room` points to fill: for each Arabic
|
||
/// word with joined letters, at its last pair of them, the byte offset of the cluster after the kashida and
|
||
/// the kashida's length, in text order. Words end at spaces, inline graphics, and characters other than letters
|
||
/// and marks (punctuation, digits, symbols). Every kashida takes an equal share of `room`, up to
|
||
/// `kind`'s longest in ems of the letter after it (as wide as its horizontal scale draws it); a
|
||
/// Stylistic kashida is a whole number of tatweels, at most [`MAX_TATWEELS`], and a word whose
|
||
/// share is shorter than one tatweel gets none. A line of words without joined letters, or whose
|
||
/// font has no tatweel, gets none.
|
||
pub(crate) fn plan(text: &str, sg: &[SGlyph], line: Range<usize>, kind: Kashida, room: f64, allows: impl Fn(usize) -> bool) -> Vec<(usize, f64)> {
|
||
if kind == Kashida::None || !room.is_finite() || room <= 0.0 {
|
||
return vec![];
|
||
}
|
||
// The first glyph of the cluster after each word's last joint.
|
||
let mut picks: Vec<usize> = Vec::new();
|
||
let mut word: Option<usize> = None;
|
||
let mut prev: Option<usize> = None;
|
||
let mut j = line.start;
|
||
while j < line.end {
|
||
let Some(g) = sg.get(j) else { break };
|
||
let end = (j + 1..line.end).find(|&e| sg.get(e).is_none_or(|n| n.byte != g.byte)).unwrap_or(line.end);
|
||
let first = text.get(g.byte..g.byte + g.len).and_then(|s| s.chars().next());
|
||
let not_a_letter = first.is_some_and(|c| !c.is_alphabetic() && joining(c) != Joining::Transparent);
|
||
if g.is_space() || g.inline.is_some() || not_a_letter {
|
||
picks.extend(word.take());
|
||
prev = None;
|
||
} else {
|
||
if prev.and_then(|p| sg.get(p)).is_some_and(|a| joined(text, a, g) && allows(a.byte) && allows(g.byte)) && tatweel(g).is_some() {
|
||
word = Some(j);
|
||
}
|
||
prev = Some(j);
|
||
}
|
||
j = end;
|
||
}
|
||
picks.extend(word);
|
||
if picks.is_empty() {
|
||
return vec![];
|
||
}
|
||
let share = room / picks.len() as f64;
|
||
let mut out = Vec::with_capacity(picks.len());
|
||
for g in picks.iter().filter_map(|&k| sg.get(k)) {
|
||
let Some((_, t)) = tatweel(g) else { continue };
|
||
let longest = kind.max_em() * g.face.units_per_em() * g.sx;
|
||
let mut len = share.min(longest);
|
||
if kind == Kashida::Stylistic {
|
||
len = (len / t).floor().min(MAX_TATWEELS) * t;
|
||
}
|
||
if len.is_finite() && len > 1e-6 {
|
||
out.push((g.byte, len));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Beh joins on both sides, alef and waw only to the letter before them, hamza to neither;
|
||
/// marks and the format characters the file does not list are skipped, the number signs and
|
||
/// the end of ayah it lists join neither side, and the tatweel and the zero width joiner join
|
||
/// both sides.
|
||
#[test]
|
||
fn arabic_letters_join_as_arabic_shaping_lists_them() {
|
||
assert_eq!(joining('ب'), Joining::Dual);
|
||
assert_eq!(joining('ي'), Joining::Dual);
|
||
assert_eq!(joining('ک'), Joining::Dual);
|
||
assert_eq!(joining('ا'), Joining::Right);
|
||
assert_eq!(joining('و'), Joining::Right);
|
||
assert_eq!(joining('ة'), Joining::Right);
|
||
assert_eq!(joining('ء'), Joining::None);
|
||
assert_eq!(joining('\u{064E}'), Joining::Transparent);
|
||
assert_eq!(joining('\u{061C}'), Joining::Transparent);
|
||
assert_eq!(joining('\u{0600}'), Joining::None);
|
||
assert_eq!(joining('\u{0605}'), Joining::None);
|
||
assert_eq!(joining('\u{06DD}'), Joining::None);
|
||
assert_eq!(joining(TATWEEL), Joining::Causing);
|
||
assert_eq!(joining('\u{200D}'), Joining::Causing);
|
||
assert_eq!(joining('\u{200C}'), Joining::None);
|
||
assert_eq!(joining('a'), Joining::None);
|
||
}
|
||
}
|