feat(text, fonts): UAX #9 BiDi and Arabic complex script typography engine (#4)

* Sprint 1: bidi and Arabic typography engine

fonts: ISO 15924 script and a:latin/a:ea/a:cs slot classification;
shape_range shapes an item with the whole paragraph as HarfRust
pre/post context, explicit script/language/direction, glyphs kept in
visual order; per-grapheme-cluster fallback (slot face, theme script
font, OS cascade families, bundled craft-fonts face); GDEF ligature
carets.

text: UAX #9 levels for the whole paragraph (a:pPr/@rtl), itemization
by level, script, face and shaping style (paint-only changes no longer
split words), logical line breaking at cluster boundaries, per-line
L1/L2 reordering (L4 mirroring by the shaper); RTL start edge and
bullets, justLow kashida; caret Affinity, logical/visual movement,
bidi hit testing and selection as the union of visual boxes.

model/pptx: theme a:font script fonts and algn="justLow" round-trip.
engine: format.rtl (Right-to-Left Text Direction), justifyLow align.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fonts: the bundled script face comes before installed fonts; no Microsoft family names

Arabic, Hebrew, Thai and Devanagari clusters try the craft-fonts face first, so every machine
draws the same, then openly licensed or OS-native installed families, then any installed face.
Installed Microsoft faces are still used through that scan, but not named (AGENTS.md §1.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tayeb <tayeb@192.168.11.179>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
hamzaelkachtaf4-star
2026-10-09 10:48:26 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Tayeb Brandon Thomas
parent 57545a99c8
commit 4b09f35c1d
20 changed files with 1904 additions and 284 deletions
Generated
+1
View File
@@ -1109,6 +1109,7 @@ dependencies = [
"deckcraft-fonts",
"deckcraft-geom",
"deckcraft-model",
"unicode-bidi",
"unicode-linebreak",
"unicode-segmentation",
]
+5
View File
@@ -17,6 +17,11 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av
basic SmartArt, groups, connectors, ink, action buttons, WordArt.
- Text: in-place editing with caret, selection, keyboard and mouse; every Home-tab font and paragraph
control; bullets and numbering; levels; autofit; columns; vertical text; fields; hyperlinks.
Right-to-left and Arabic text: UAX #9 bidi (levels per paragraph, L1/L2 per line, mirrored
brackets), contextual HarfRust shaping across style runs, `a:latin`/`a:ea`/`a:cs` slots with
per-cluster fallback through theme script fonts, RTL start edge and bullets, `justLow` kashida,
bidi caret/hit-testing/selection (`format.rtl`). Arrow keys still move logically (visual
movement with caret affinity lands with the keyboard sprint).
- Editing: select, marquee, move, resize, rotate, adjust; smart guides; nudge; duplicate; z-order;
group/ungroup; align/distribute; Format Painter; Selection pane; clipboard incl. images.
- Transitions (fade, push, wipe, split, cover, uncover, zoom, morph…) and animations (entrance,
+35 -1
View File
@@ -71,7 +71,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_shapes,
text_outline
),
cmd!("format.align", "Align", ["Home", "Paragraph"], None, "{align: left|center|right|justify|distributed}", has_text_or_shapes, align),
cmd!(
"format.align",
"Align",
["Home", "Paragraph"],
None,
"{align: left|center|right|justify|justifyLow|distributed}",
has_text_or_shapes,
align
),
cmd!(
"format.rtl",
"Right-to-Left Text Direction",
["Home", "Paragraph"],
None,
"{on?: bool} (off = left-to-right; switching mirrors left/right alignment)",
has_text_or_shapes,
rtl
),
cmd!("format.alignLeft", "Align Left", ["Home", "Paragraph"], Some("Cmd+L"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Left)),
cmd!("format.alignCenter", "Center", ["Home", "Paragraph"], Some("Cmd+E"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Center)),
cmd!("format.alignRight", "Align Right", ["Home", "Paragraph"], Some("Cmd+R"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Right)),
@@ -367,12 +384,29 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
"center" | "centre" | "ctr" => Align::Center,
"right" | "r" => Align::Right,
"justify" | "just" => Align::Justify,
"justifyLow" | "justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
_ => Align::Left,
};
align_to(s, a)
}
/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat
/// at the start edge stays there.
fn rtl(s: &mut Session, p: &Value) -> Result<Value> {
let on = on_param(p, current_para(s).rtl);
format_para(s, &move |para| {
if para.props.rtl.unwrap_or(false) != on {
para.props.align = match para.props.align {
None | Some(Align::Left) => Some(Align::Right),
Some(Align::Right) => Some(Align::Left),
other => other,
};
}
para.props.rtl = Some(on);
})
}
fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. }));
let on = on_param(p, Some(cur));
+25
View File
@@ -400,3 +400,28 @@ fn grown_table_frame_covers_its_drawn_rows() {
let below_stored = deckcraft_geom::Point::new(120.0, 100.0 + drawn - 2.0);
assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. })));
}
#[test]
fn right_to_left_paragraphs() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).unwrap();
s.execute("format.rtl", &json!({})).unwrap();
let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone();
assert_eq!(para(&s).rtl, Some(true));
// Left (the default) becomes right: the text stays at its start edge.
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::Right));
s.execute("format.align", &json!({"align": "justifyLow"})).unwrap();
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::JustLow));
// Toggling back keeps a justified alignment.
s.execute("format.rtl", &json!({})).unwrap();
assert_eq!(para(&s).rtl, Some(false));
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::JustLow));
s.execute("format.rtl", &json!({"on": true})).unwrap();
s.execute("format.alignLeft", &json!({})).unwrap();
s.execute("format.rtl", &json!({"on": false})).unwrap();
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::Right));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(para(&s).rtl, Some(true));
}
+183
View File
@@ -0,0 +1,183 @@
//! Font fallback per grapheme cluster, and ligature caret positions.
//!
//! The cascade for a cluster (a base character with its marks, never split across faces) is:
//! 1. the faces the document asks for, in order (the run's slot typeface, then the theme's script
//! font, `a:font[@script="Arab"]`);
//! 2. for scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the craft-fonts face
//! bundled for the script (Noto Naskh Arabic for Arabic), so every machine draws the same; then
//! openly licensed or OS-native families found by name among the installed fonts (no platform
//! API is called); then any installed face covering the cluster;
//! 3. any loaded face (bundled first: Japanese keeps its craft-fonts faces), then the first
//! requested face (drawn as .notdef).
use std::sync::Arc;
use skrifa::raw::TableProvider;
use crate::script::{ScriptTag, script_of};
use crate::{CRAFT_FONTS, FontDb, FontFace};
/// Characters that never decide a face: they're drawn by whatever face the cluster gets.
fn ignorable(c: char) -> bool {
c.is_whitespace()
|| c.is_control()
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
}
/// Does `face` draw every character of the cluster?
pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool {
cluster.iter().all(|c| ignorable(*c) || face.covers(*c))
}
/// Installed families tried by name for a script after the bundled face, before scanning every
/// installed font. Microsoft font names are not listed (AGENTS.md §1.1); an installed one is
/// still used by the scan when nothing earlier covers the cluster.
fn os_families(script: ScriptTag) -> &'static [&'static str] {
match &script {
b"Arab" => &["Noto Naskh Arabic", "Noto Sans Arabic", "Geeza Pro", "SF Arabic", "DejaVu Sans"],
b"Hebr" => &["Noto Sans Hebrew", "Arial Hebrew", "SF Hebrew", "DejaVu Sans"],
b"Thai" => &["Noto Sans Thai", "Thonburi"],
b"Deva" => &["Noto Sans Devanagari", "Kohinoor Devanagari", "Devanagari Sangam MN"],
_ => &[],
}
}
/// The craft-fonts families for a script (manifest order), e.g. Noto Naskh Arabic for `Arab`.
pub fn bundled_families(script: ScriptTag) -> Vec<&'static str> {
let tag = std::str::from_utf8(&script).unwrap_or("");
let mut v: Vec<&'static str> = CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&tag)).map(|f| f.family).collect();
v.dedup();
v
}
impl FontDb {
/// The face for one grapheme cluster: the first of `chain` that draws all of it, else the
/// bundled face for its script, else an installed face for the script, else any loaded
/// face that draws it, else `chain[0]` (or the default face if `chain` is empty).
pub fn cascade(&self, cluster: &[char], chain: &[Arc<FontFace>], style: &str) -> Arc<FontFace> {
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
return f.clone();
}
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
let script = cluster.iter().find_map(|c| script_of(*c));
// Scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the bundled face,
// then installed ones; the others keep the loaded-faces-first order (Japanese documents
// prefer the bundled craft-fonts faces).
if let Some(s) = script.filter(|s| !os_families(*s).is_empty()) {
let bundled = bundled_families(s);
for fam in &bundled {
let f = self.face(fam, style);
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
return f;
}
}
for fam in os_families(s) {
if bundled.contains(fam) {
continue;
}
if self.is_loaded(fam) || self.has_family(fam) {
let f = self.face(fam, style);
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
return f;
}
}
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(f) = self.system_face_for(key, cluster, &bundled) {
return f;
}
}
let exclude = chain.first().map(|f| f.id()).unwrap_or(u32::MAX);
match self.fallback_for(key, exclude) {
Some(f) if covers_cluster(&f, cluster) => f,
Some(f) if chain.iter().all(|c| !c.covers(key)) => f,
_ => first(),
}
}
/// An installed (non-bundled) face covering the cluster, loading one from the system catalog
/// if no loaded face does.
#[cfg(not(target_arch = "wasm32"))]
fn system_face_for(&self, key: char, cluster: &[char], bundled: &[&str]) -> Option<Arc<FontFace>> {
let pick = |db: &FontDb| {
let faces = db.read_faces();
let mut v: Vec<&Arc<FontFace>> = faces
.iter()
.filter(|f| !bundled.iter().any(|b| f.family.eq_ignore_ascii_case(b)) && !CRAFT_FONTS.iter().any(|c| c.family == f.family))
.filter(|f| covers_cluster(f, cluster))
.collect();
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
v.first().map(|f| (*f).clone())
};
if let Some(f) = pick(self) {
return Some(f);
}
if self.system_fallback(key) { pick(self) } else { None }
}
}
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
let Some(font) = face.skrifa() else { return Vec::new() };
let Ok(gdef) = font.gdef() else { return Vec::new() };
let Some(Ok(list)) = gdef.lig_caret_list() else { return Vec::new() };
let Ok(cov) = list.coverage() else { return Vec::new() };
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
let mut out: Vec<i32> = lig
.caret_values()
.iter()
.filter_map(|cv| cv.ok())
.filter_map(|cv| match cv {
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
skrifa::raw::tables::gdef::CaretValue::Format3(f) => Some(f.coordinate() as i32),
// Format 2 is a contour point index; outlines aren't consulted here.
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
})
.take(64)
.collect();
out.sort_unstable();
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requested_face_wins_when_it_covers() {
let db = FontDb::global();
let inter = db.face(crate::DEFAULT_FAMILY, "Regular");
let f = db.cascade(&['a'], std::slice::from_ref(&inter), "Regular");
assert!(Arc::ptr_eq(&f, &inter));
// Spaces and joiners never force a fallback.
let f = db.cascade(&[' ', '\u{200D}'], std::slice::from_ref(&inter), "Regular");
assert!(Arc::ptr_eq(&f, &inter));
// Nothing to go on: the default face.
assert!(!db.cascade(&[], &[], "Regular").family.is_empty());
}
#[test]
fn arabic_cluster_falls_back_to_an_arabic_face() {
let db = FontDb::global();
let inter = db.face(crate::DEFAULT_FAMILY, "Regular");
let cluster = ['\u{0628}', '\u{064E}']; // beh + fatha: one cluster, one face.
let f = db.cascade(&cluster, std::slice::from_ref(&inter), "Regular");
if covers_cluster(&f, &cluster) {
assert!(f.covers('\u{0628}'));
} else {
eprintln!("skipped: no installed or bundled face covers Arabic");
}
}
#[test]
fn ligature_carets_never_fail() {
let face = FontDb::global().face(crate::DEFAULT_FAMILY, "Regular");
for gid in [0, 1, 100, u32::MAX] {
let c = ligature_carets(&face, gid);
assert!(c.windows(2).all(|w| w[0] <= w[1]));
}
}
}
+3 -3
View File
@@ -557,7 +557,7 @@ impl FontDb {
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
self.faces.read().unwrap_or_else(|e| e.into_inner())
}
@@ -744,7 +744,7 @@ impl FontDb {
false
}
fn is_loaded(&self, family: &str) -> bool {
pub(crate) fn is_loaded(&self, family: &str) -> bool {
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
}
@@ -842,7 +842,7 @@ impl FontDb {
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
#[cfg(not(target_arch = "wasm32"))]
fn system_fallback(&self, c: char) -> bool {
pub(crate) fn system_fallback(&self, c: char) -> bool {
if c.is_control() || c.is_whitespace() {
return false;
}
+30 -83
View File
@@ -1,21 +1,25 @@
//! DeckCraft fonts: the font database (craft-fonts families, user and system fonts, and open
//! substitutes for the Office fonts presentations ask for), vertical metrics, glyph outlines and
//! OpenType shaping.
//! OpenType shaping, script/slot classification and per-cluster fallback.
//!
//! Shaping here is style-agnostic: [`shape`] turns a string in one face into glyph ids, clusters
//! and advances in font units. `deckcraft-text` applies sizes, tracking, scaling and
//! Shaping here is style-agnostic: [`shape`] and [`shape_range`] turn text in one face into glyph
//! ids, clusters and advances in font units. `deckcraft-text` applies sizes, tracking, scaling and
//! justification on top.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod fallback;
mod fontdb;
pub mod script;
mod shape;
pub use fallback::{bundled_families, covers_cluster, ligature_carets};
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
pub use harfrust::Feature;
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
use harfrust::Tag;
pub use kurbo::BezPath;
use skrifa::MetadataProvider;
use skrifa::instance::Size;
pub use script::{FontSlot, ScriptTag};
pub use shape::{ShapeParams, ShapedGlyph, shape_range};
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
@@ -53,17 +57,6 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
/// The default theme's font.
pub const DEFAULT_FAMILY: &str = "Inter";
/// One shaped glyph, in font units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShapedGlyph {
pub gid: u32,
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
pub cluster: usize,
pub x_advance: i32,
pub x_offset: i32,
pub y_offset: i32,
}
/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`.
pub fn feature(tag: &str) -> Option<Feature> {
let (on, t) = match tag.strip_prefix('-') {
@@ -83,6 +76,26 @@ pub fn is_rtl(c: char) -> bool {
matches!(unicode_bidi::bidi_class(c), R | AL)
}
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
/// order: each direction run is shaped with [`shape_range`] (right-to-left runs right to left,
/// with the whole string as context), then its clusters put back in text order (each cluster's
/// glyphs keep the shaper's order). Paragraph layout uses [`shape_range`] directly and keeps the
/// visual order.
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
let mut out = Vec::with_capacity(text.len());
for (r, rtl) in direction_runs(text) {
let mut g = shape_range(face, text, r, &ShapeParams { features, rtl, ..Default::default() }, &map);
if rtl {
// Visual (clusters descending) → logical; the sort is stable, so each cluster's
// glyphs keep the shaper's order.
g.sort_by_key(|x| x.cluster);
}
out.extend(g);
}
out
}
/// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left).
fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
let mut runs: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
@@ -110,72 +123,6 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
runs
}
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
if !text.chars().any(is_rtl) {
return shape_dir(face, text, features, &map, false);
}
let mut out = Vec::with_capacity(text.len());
for (r, rtl) in direction_runs(text) {
let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl);
for x in &mut g {
x.cluster += r.start;
}
if rtl {
// Visual (clusters descending) → logical, cluster by cluster.
let mut groups: Vec<Vec<ShapedGlyph>> = Vec::new();
for x in g {
match groups.last_mut() {
Some(last) if last[0].cluster == x.cluster => last.push(x),
_ => groups.push(vec![x]),
}
}
groups.sort_by_key(|grp| grp[0].cluster);
g = groups.into_iter().flatten().collect();
}
out.extend(g);
}
out
}
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
let mut out = Vec::with_capacity(text.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in text.char_indices() {
buf.add(map(c), i as u32);
}
buf.guess_segment_properties();
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
});
}
});
if shaped.is_none()
&& let Some(f) = face.skrifa()
{
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), face.location());
for (i, c) in text.char_indices() {
let g = cmap.map(map(c)).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
out.push(ShapedGlyph { gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
}
}
out
}
/// Glyph id of the first of `chars` the face has (0 = .notdef).
pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0)
+246
View File
@@ -0,0 +1,246 @@
//! Script and font-slot classification.
//!
//! DrawingML gives every run three typefaces: `a:latin`, `a:ea` (East Asian) and `a:cs` (complex
//! script: Arabic, Hebrew, Thai, Indic…). Which one a character uses follows its Unicode script
//! (UAX #24). Characters with no script of their own (Common and Inherited: spaces, digits,
//! punctuation, combining marks) take the script and slot of the text around them, so a space or
//! a parenthesis between two Arabic words is shaped with them.
/// An ISO 15924 script tag, e.g. `*b"Arab"`.
pub type ScriptTag = [u8; 4];
pub const ARABIC: ScriptTag = *b"Arab";
pub const HEBREW: ScriptTag = *b"Hebr";
pub const LATIN: ScriptTag = *b"Latn";
/// The DrawingML typeface slot a character is drawn with.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum FontSlot {
/// `a:latin`.
#[default]
Latin,
/// `a:ea`.
EastAsian,
/// `a:cs`.
ComplexScript,
}
/// Inclusive code point ranges and their script (a subset of `Scripts.txt` covering the scripts
/// the slot decision and shaping need; everything else is Latin-slot or Common).
const RANGES: &[(u32, u32, ScriptTag)] = &[
(0x0041, 0x005A, *b"Latn"),
(0x0061, 0x007A, *b"Latn"),
(0x00AA, 0x00AA, *b"Latn"),
(0x00BA, 0x00BA, *b"Latn"),
(0x00C0, 0x00D6, *b"Latn"),
(0x00D8, 0x00F6, *b"Latn"),
(0x00F8, 0x024F, *b"Latn"),
(0x0370, 0x03FF, *b"Grek"),
(0x0400, 0x052F, *b"Cyrl"),
(0x0530, 0x058F, *b"Armn"),
(0x0591, 0x05F4, *b"Hebr"),
// Arabic block, minus the Common/Inherited code points handled in `script_of`.
(0x0600, 0x06FF, *b"Arab"),
(0x0700, 0x074F, *b"Syrc"),
(0x0750, 0x077F, *b"Arab"),
(0x0780, 0x07BF, *b"Thaa"),
(0x07C0, 0x07FF, *b"Nkoo"),
(0x0870, 0x08FF, *b"Arab"),
(0x0900, 0x097F, *b"Deva"),
(0x0980, 0x09FF, *b"Beng"),
(0x0A00, 0x0A7F, *b"Guru"),
(0x0A80, 0x0AFF, *b"Gujr"),
(0x0B00, 0x0B7F, *b"Orya"),
(0x0B80, 0x0BFF, *b"Taml"),
(0x0C00, 0x0C7F, *b"Telu"),
(0x0C80, 0x0CFF, *b"Knda"),
(0x0D00, 0x0D7F, *b"Mlym"),
(0x0D80, 0x0DFF, *b"Sinh"),
(0x0E00, 0x0E7F, *b"Thai"),
(0x0E80, 0x0EFF, *b"Laoo"),
(0x0F00, 0x0FFF, *b"Tibt"),
(0x1000, 0x109F, *b"Mymr"),
(0x10A0, 0x10FF, *b"Geor"),
(0x1100, 0x11FF, *b"Hang"),
(0x1200, 0x139F, *b"Ethi"),
(0x1780, 0x17FF, *b"Khmr"),
(0x1E00, 0x1EFF, *b"Latn"),
(0x1F00, 0x1FFF, *b"Grek"),
(0x2C60, 0x2C7F, *b"Latn"),
(0x2E80, 0x2FDF, *b"Hani"),
(0x3040, 0x309F, *b"Hira"),
(0x30A0, 0x30FF, *b"Kana"),
(0x3100, 0x312F, *b"Bopo"),
(0x3130, 0x318F, *b"Hang"),
(0x31F0, 0x31FF, *b"Kana"),
(0x3400, 0x4DBF, *b"Hani"),
(0x4E00, 0x9FFF, *b"Hani"),
(0xA720, 0xA7FF, *b"Latn"),
(0xAC00, 0xD7AF, *b"Hang"),
(0xF900, 0xFAFF, *b"Hani"),
(0xFB00, 0xFB06, *b"Latn"),
(0xFB1D, 0xFB4F, *b"Hebr"),
(0xFB50, 0xFDFF, *b"Arab"),
(0xFE70, 0xFEFF, *b"Arab"),
(0xFF21, 0xFF3A, *b"Latn"),
(0xFF41, 0xFF5A, *b"Latn"),
(0xFF66, 0xFF9F, *b"Kana"),
(0x10E60, 0x10E7F, *b"Arab"),
(0x1EE00, 0x1EEFF, *b"Arab"),
(0x20000, 0x323AF, *b"Hani"),
];
/// Code points inside the Arabic blocks whose script is Common or Inherited: the Arabic comma,
/// semicolon and question mark, tatweel, the harakat (combining vowel marks) and the BOM-like
/// U+FEFF. They take the script of the text around them.
fn arabic_common(cp: u32) -> bool {
matches!(cp, 0x060C | 0x061B | 0x061F | 0x0640 | 0x064B..=0x0655 | 0x0670 | 0xFEFF | 0xFD3E | 0xFD3F)
}
/// The script of `c`, or `None` for Common and Inherited characters (spaces, digits,
/// punctuation, combining marks), which take the script of their neighbours.
pub fn script_of(c: char) -> Option<ScriptTag> {
let cp = c as u32;
if arabic_common(cp) {
return None;
}
// Ranges are sorted and disjoint: binary search on the start.
let i = RANGES.partition_point(|(lo, _, _)| *lo <= cp);
let (lo, hi, tag) = RANGES.get(i.checked_sub(1)?)?;
(cp >= *lo && cp <= *hi).then_some(*tag)
}
/// The slot a script is drawn with.
pub fn slot_of_script(s: ScriptTag) -> FontSlot {
match &s {
b"Hani" | b"Hira" | b"Kana" | b"Hang" | b"Bopo" => FontSlot::EastAsian,
b"Arab" | b"Hebr" | b"Syrc" | b"Thaa" | b"Nkoo" | b"Deva" | b"Beng" | b"Guru" | b"Gujr" | b"Orya" | b"Taml" | b"Telu" | b"Knda" | b"Mlym"
| b"Sinh" | b"Thai" | b"Laoo" | b"Tibt" | b"Mymr" | b"Khmr" => FontSlot::ComplexScript,
_ => FontSlot::Latin,
}
}
/// The slot of `c` on its own: `None` for characters that take their neighbours' slot. CJK
/// punctuation and full-width forms are East Asian although their script is Common.
pub fn slot_of(c: char) -> Option<FontSlot> {
if let Some(s) = script_of(c) {
return Some(slot_of_script(s));
}
let cp = c as u32;
if (0x3000..=0x303F).contains(&cp) || (0xFF00..=0xFFEF).contains(&cp) || (0x3200..=0x33FF).contains(&cp) {
return Some(FontSlot::EastAsian);
}
None
}
/// A default BCP 47 language for a script (shaping picks `locl` forms by language).
pub fn default_language(s: ScriptTag) -> Option<&'static str> {
Some(match &s {
b"Arab" => "ar",
b"Hebr" => "he",
b"Syrc" => "syr",
b"Thaa" => "dv",
b"Thai" => "th",
b"Deva" => "hi",
_ => return None,
})
}
/// Resolve every character's script and slot, giving Common/Inherited characters the value of the
/// preceding character with one (or, at the start, the following one; Latin when there is none).
pub fn resolve(chars: &[char]) -> Vec<(Option<ScriptTag>, FontSlot)> {
let own: Vec<(Option<ScriptTag>, Option<FontSlot>)> = chars.iter().map(|c| (script_of(*c), slot_of(*c))).collect();
let first = own.iter().find_map(|(s, sl)| sl.map(|sl| (*s, sl))).unwrap_or((None, FontSlot::Latin));
let mut prev = first;
own.into_iter()
.map(|(s, sl)| {
if let Some(sl) = sl {
prev = (s, sl);
}
prev
})
.collect()
}
/// Arabic letters that join to the following letter (joining type D, dual-joining): a kashida
/// (tatweel) may be inserted after them. Right-joining (alef, dal, reh, waw…) and non-joining
/// (hamza) letters are excluded.
pub fn joins_following(c: char) -> bool {
let cp = c as u32;
let letter = matches!(cp, 0x0620..=0x064A | 0x066E..=0x06D3 | 0x06FA..=0x06FC | 0x06FF | 0x0750..=0x077F | 0x08A0..=0x08C9);
letter
&& !matches!(
cp,
0x0621..=0x0625 | 0x0627 | 0x0629 | 0x062F..=0x0632 | 0x0648 | 0x0671..=0x0673 | 0x0675..=0x0677 | 0x0688..=0x0699 | 0x06C0
| 0x06C3..=0x06CB | 0x06CD | 0x06CF | 0x06D2 | 0x06D3 | 0x0759..=0x075B | 0x076B | 0x076C | 0x0771 | 0x0773 | 0x0774
| 0x0778 | 0x0779 | 0x08AA..=0x08AC | 0x08AE | 0x08B1 | 0x08B2 | 0x08B9
)
}
/// An Arabic letter that joins to the preceding letter (joining type D or R).
pub fn joins_preceding(c: char) -> bool {
let cp = c as u32;
matches!(cp, 0x0622..=0x064A | 0x066E..=0x06D3 | 0x06FA..=0x06FC | 0x06FF | 0x0750..=0x077F | 0x08A0..=0x08C9) && cp != 0x0621
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scripts_and_slots() {
assert_eq!(script_of('س'), Some(ARABIC));
assert_eq!(script_of('ש'), Some(HEBREW));
assert_eq!(script_of('R'), Some(LATIN));
assert_eq!(script_of('2'), None);
assert_eq!(script_of('('), None);
// Harakat and tatweel are Inherited/Common.
assert_eq!(script_of('\u{064E}'), None);
assert_eq!(script_of('\u{0640}'), None);
assert_eq!(slot_of('س'), Some(FontSlot::ComplexScript));
assert_eq!(slot_of('日'), Some(FontSlot::EastAsian));
assert_eq!(slot_of('、'), Some(FontSlot::EastAsian));
assert_eq!(slot_of('a'), Some(FontSlot::Latin));
assert_eq!(slot_of(' '), None);
assert_eq!(script_of('\u{10FFFF}'), None);
assert_eq!(script_of('\0'), None);
}
#[test]
fn neutrals_take_the_surrounding_slot() {
let chars: Vec<char> = "سنة 2024 (Retrait) فقط".chars().collect();
let r = resolve(&chars);
// "سنة 2024 (" follows Arabic: complex script.
for (i, c) in chars.iter().enumerate().take(10) {
assert_eq!(r[i].1, FontSlot::ComplexScript, "{c:?}");
}
// "Retrait)" is Latin; the space after ")" stays Latin; "فقط" is complex script.
assert_eq!(r[10].1, FontSlot::Latin);
assert_eq!(r[17].1, FontSlot::Latin);
assert_eq!(r[19], (Some(ARABIC), FontSlot::ComplexScript));
// Leading neutrals take the first strong character's slot.
let r = resolve(&['(', 'س']);
assert_eq!(r[0].1, FontSlot::ComplexScript);
assert!(resolve(&[]).is_empty());
assert_eq!(resolve(&['1'])[0], (None, FontSlot::Latin));
}
#[test]
fn joining_classes() {
assert!(joins_following('س'));
assert!(joins_following('ن'));
assert!(!joins_following('ا'));
assert!(!joins_following('د'));
assert!(!joins_following('ء'));
assert!(joins_preceding('ا'));
assert!(!joins_preceding('ء'));
assert!(!joins_following('a'));
}
#[test]
fn ranges_are_sorted_and_disjoint() {
for w in RANGES.windows(2) {
assert!(w[0].1 < w[1].0, "{:x?} {:x?}", w[0], w[1]);
}
}
}
+124
View File
@@ -0,0 +1,124 @@
//! OpenType shaping with HarfRust.
//!
//! [`shape_range`] shapes one item of a paragraph (one face, one direction, one script) with the
//! rest of the paragraph as pre/post context, so Arabic letters at an item boundary (a colour
//! change inside a word, a bold letter) still take their joining forms. Glyphs come back in
//! visual order, exactly as the shaper produced them: right-to-left items are not reversed.
use std::ops::Range;
use harfrust::{Direction, Feature, Language, Script, ShapeOptions, Tag, UnicodeBuffer};
use skrifa::MetadataProvider;
use skrifa::instance::Size;
use crate::FontFace;
use crate::script::ScriptTag;
/// One shaped glyph, in font units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShapedGlyph {
pub gid: u32,
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
pub cluster: usize,
pub x_advance: i32,
pub x_offset: i32,
pub y_offset: i32,
}
/// How to shape an item.
#[derive(Clone, Copy, Debug, Default)]
pub struct ShapeParams<'a> {
pub features: &'a [Feature],
/// Right-to-left (odd bidi level). Mirrored characters (brackets) take their mirrored glyphs.
pub rtl: bool,
/// ISO 15924 script (`*b"Arab"`); `None` lets the shaper guess from the text.
pub script: Option<ScriptTag>,
/// BCP 47 language (`"ar"`, `"ar-MA"`); `None` = the script's default.
pub language: Option<&'a str>,
}
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
return Vec::new();
};
let mut out = Vec::with_capacity(text.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
buf.set_pre_context(pre);
buf.set_post_context(post);
for (i, c) in text.char_indices() {
buf.add(map(c), (range.start + i) as u32);
}
buf.guess_segment_properties();
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
buf.set_script(s);
}
if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
{
buf.set_language(l);
}
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
});
}
});
if shaped.is_none()
&& let Some(f) = face.skrifa()
{
// No shaper for this face: one glyph per character from the cmap, in visual order.
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), face.location());
for (i, c) in text.char_indices() {
let g = cmap.map(map(c)).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
}
if params.rtl {
out.reverse();
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DEFAULT_FAMILY, FontDb};
#[test]
fn rtl_items_come_in_visual_order_and_mirror() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let text = "(ab)";
let p = ShapeParams { rtl: true, ..Default::default() };
let g = shape_range(&face, text, 0..text.len(), &p, |c| c);
// Visual order: last cluster first.
assert_eq!(g.iter().map(|g| g.cluster).collect::<Vec<_>>(), vec![3, 2, 1, 0]);
// L4: in a right-to-left run "(" is drawn with the ")" glyph and vice versa.
if face.covers('(') && face.covers(')') {
assert_eq!(g[3].gid, face.glyph_for(')'));
assert_eq!(g[0].gid, face.glyph_for('('));
}
}
#[test]
fn clusters_point_into_the_whole_paragraph() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let para = "Hello world";
let g = shape_range(&face, para, 6..11, &ShapeParams::default(), |c| c);
assert_eq!(g.first().map(|g| g.cluster), Some(6));
assert!(shape_range(&face, para, 6..99, &ShapeParams::default(), |c| c).is_empty());
// Not a char boundary.
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
}
}
+5 -1
View File
@@ -132,6 +132,8 @@ pub enum Align {
Center,
Right,
Justify,
/// Justified with kashida (tatweel) elongation for Arabic (`justLow`).
JustLow,
Distributed,
}
@@ -142,6 +144,7 @@ impl Align {
Align::Center => "ctr",
Align::Right => "r",
Align::Justify => "just",
Align::JustLow => "justLow",
Align::Distributed => "dist",
}
}
@@ -150,7 +153,8 @@ impl Align {
"l" => Align::Left,
"ctr" => Align::Center,
"r" => Align::Right,
"just" | "justLow" | "thaiDist" => Align::Justify,
"just" | "thaiDist" => Align::Justify,
"justLow" => Align::JustLow,
"dist" => Align::Distributed,
_ => return None,
})
+18
View File
@@ -11,6 +11,24 @@ pub struct FontSet {
pub latin: String,
pub ea: String,
pub cs: String,
/// Per-script typefaces (`a:font script="Arab" typeface="…"`), in document order.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub scripts: Vec<ScriptFont>,
}
/// A theme font for one script (ISO 15924 tag, e.g. `Arab`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ScriptFont {
pub script: String,
pub typeface: String,
}
impl FontSet {
/// The typeface the theme gives `script` (`"Arab"`), if any.
pub fn script(&self, script: &str) -> Option<&str> {
self.scripts.iter().find(|f| f.script.eq_ignore_ascii_case(script) && !f.typeface.is_empty()).map(|f| f.typeface.as_str())
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+10 -2
View File
@@ -2,14 +2,22 @@
use deckcraft_color::{ColorScheme, Rgba, SchemeSlot};
use deckcraft_model::style::{ColorBase, Effects};
use deckcraft_model::theme::{FontScheme, FontSet, FormatScheme, Theme, default_format};
use deckcraft_model::theme::{FontScheme, FontSet, FormatScheme, ScriptFont, Theme, default_format};
use super::{Imp, Part, dml};
use crate::xml::El;
fn font_set(e: Option<&El>) -> FontSet {
let tf = |n: &str| e.and_then(|e| e.child(n)).and_then(|c| c.attr("typeface")).unwrap_or("").to_string();
FontSet { latin: tf("latin"), ea: tf("ea"), cs: tf("cs") }
let scripts = e
.map(|e| {
e.children_named("font")
.filter_map(|f| Some(ScriptFont { script: f.attr("script")?.to_string(), typeface: f.attr("typeface").unwrap_or("").to_string() }))
.take(256)
.collect()
})
.unwrap_or_default();
FontSet { latin: tf("latin"), ea: tf("ea"), cs: tf("cs"), scripts }
}
pub fn read_theme(imp: &mut Imp, part: &Part, root: &El) -> Theme {
+3
View File
@@ -13,6 +13,9 @@ fn font_set(w: &mut W, tag: &str, f: &FontSet) {
w.empty("a:latin", A::new().a("typeface", if f.latin.is_empty() { "Arial" } else { &f.latin }));
w.empty("a:ea", A::new().a("typeface", &f.ea));
w.empty("a:cs", A::new().a("typeface", &f.cs));
for sf in &f.scripts {
w.empty("a:font", A::new().a("script", &sf.script).a("typeface", &sf.typeface));
}
w.close(tag);
}
+25
View File
@@ -686,3 +686,28 @@ fn bisect_slide_variants() {
std::fs::write(format!("{dir}/v-only{k}.pptx"), one(s)).expect("write");
}
}
#[test]
fn rtl_paragraphs_script_fonts_and_just_low_round_trip() {
let mut p = rich_deck();
let fonts = &mut Arc::make_mut(&mut p.masters[0]).theme.fonts;
fonts.minor.cs = "Noto Naskh Arabic".into();
fonts.minor.scripts = vec![
deckcraft_model::theme::ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() },
deckcraft_model::theme::ScriptFont { script: "Hebr".into(), typeface: "Noto Sans Hebrew".into() },
];
let slide = Arc::make_mut(&mut p.slides[0]);
let sh = slide.shapes.iter_mut().find(|s| s.text.is_some()).expect("a text shape");
let body = sh.text.as_mut().expect("text");
body.paragraphs = vec![Paragraph { runs: vec![Run::new("سنة 2024 (Retrait) فقط")], ..Default::default() }];
body.paragraphs[0].props.rtl = Some(true);
body.paragraphs[0].props.align = Some(Align::JustLow);
let name = sh.name.clone();
let q = round(&p);
assert_eq!(q.masters[0].theme.fonts.minor.scripts, p.masters[0].theme.fonts.minor.scripts);
assert_eq!(q.masters[0].theme.fonts.minor.script("arab"), Some("Noto Naskh Arabic"));
let para = &by_name(&q.slides[0], &name).text.as_ref().expect("text").paragraphs[0];
assert_eq!(para.props.rtl, Some(true));
assert_eq!(para.props.align, Some(Align::JustLow));
assert_eq!(para.runs[0].text, "سنة 2024 (Retrait) فقط");
}
+3 -3
View File
@@ -107,9 +107,9 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, bod
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
let x0 = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
let last = run.glyphs.last().map(|g| g.1).unwrap_or(x0);
let x1 = line.caret_x.get(run.chars.1.saturating_sub(line.start)).copied().unwrap_or(last).max(last);
// The run's visual extent (right-to-left runs run from right to left).
let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
let (x0, x1) = line.span(run.chars.0, run.chars.1).unwrap_or((first, first));
let r = Rect::new(x0, line.top, x1, line.bottom);
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link });
}
+1
View File
@@ -15,3 +15,4 @@ deckcraft-color = { workspace = true }
deckcraft-fonts = { workspace = true }
unicode-linebreak = { workspace = true }
unicode-segmentation = { workspace = true }
unicode-bidi = { workspace = true }
+152
View File
@@ -0,0 +1,152 @@
//! Bidirectional text (UAX #9) for paragraph layout.
//!
//! The pipeline is the one the algorithm prescribes: embedding levels are resolved once for the
//! whole logical paragraph (base level from `a:pPr/@rtl`); shaping and line breaking happen in
//! logical order; then each line is reordered on its own ([`line_levels`] applies rule L1,
//! [`visual_order`] rule L2). Rule L4 (mirrored brackets) is applied by the shaper, which draws
//! mirrorable characters in right-to-left items with their mirrored glyphs.
use unicode_bidi::BidiClass::{self, AL, AN, B, BN, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO, S, WS};
use unicode_bidi::{BidiInfo, Level, bidi_class};
/// Does anything in the text need the full algorithm? (Strong right-to-left, Arabic numbers or
/// explicit embeddings.)
fn needs_bidi(chars: &[char]) -> bool {
chars.iter().any(|c| matches!(bidi_class(*c), R | AL | AN | LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI))
}
/// Resolved embedding level of every character of a paragraph (before the line rules), with the
/// paragraph level forced to 1 when `rtl`.
pub(crate) fn levels(text: &str, chars: &[char], rtl: bool) -> Vec<u8> {
let base = u8::from(rtl);
if !rtl && !needs_bidi(chars) {
return vec![0; chars.len()];
}
let info = BidiInfo::new(text, Some(if rtl { Level::rtl() } else { Level::ltr() }));
// `levels` is per byte; take each character's first byte.
text.char_indices().map(|(b, _)| info.levels.get(b).map(|l| l.number()).unwrap_or(base)).collect()
}
/// Whitespace-like for rule L1: reset to the paragraph level when trailing a line or preceding a
/// segment/paragraph separator.
fn l1_whitespace(c: BidiClass) -> bool {
matches!(c, WS | FSI | LRI | RLI | PDI | BN | LRE | RLE | LRO | RLO | PDF)
}
/// Rule L1 for one line: segment and paragraph separators, any whitespace run before them, and
/// whitespace at the end of the line take the paragraph level.
pub(crate) fn line_levels(chars: &[char], levels: &[u8], para_level: u8) -> Vec<u8> {
let mut out: Vec<u8> = levels.to_vec();
out.resize(chars.len(), para_level);
// Walk backwards: `trailing` is true while every character after this one (up to the line end
// or a separator) is whitespace.
let mut trailing = true;
for (i, c) in chars.iter().enumerate().rev() {
let class = bidi_class(*c);
if matches!(class, S | B) {
if let Some(l) = out.get_mut(i) {
*l = para_level;
}
trailing = true;
} else if l1_whitespace(class) {
if trailing && let Some(l) = out.get_mut(i) {
*l = para_level;
}
} else {
trailing = false;
}
}
out
}
/// Rule L2: the visual order of a line's characters (indices into `levels`, left to right). From
/// the highest level down to the lowest odd level, every maximal run at that level or higher is
/// reversed.
pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> {
let mut order: Vec<usize> = (0..levels.len()).collect();
let Some(&max) = levels.iter().max() else { return order };
let min_odd = levels.iter().copied().filter(|l| l % 2 == 1).min().unwrap_or(max.saturating_add(1));
let mut lvl = max;
while lvl >= min_odd && lvl > 0 {
let mut i = 0;
while i < order.len() {
let at = |k: usize| order.get(k).and_then(|&j| levels.get(j)).copied().unwrap_or(0);
if at(i) >= lvl {
let start = i;
while i < order.len() && at(i) >= lvl {
i += 1;
}
if let Some(run) = order.get_mut(start..i) {
run.reverse();
}
} else {
i += 1;
}
}
lvl -= 1;
}
order
}
#[cfg(test)]
mod tests {
use super::*;
fn lv(s: &str, rtl: bool) -> Vec<u8> {
let chars: Vec<char> = s.chars().collect();
levels(s, &chars, rtl)
}
#[test]
fn mixed_arabic_latin_numbers_and_brackets() {
let s = "سنة 2024 (Retrait) فقط";
let l = lv(s, true);
let chars: Vec<char> = s.chars().collect();
assert_eq!(l.len(), chars.len());
// Arabic letters at the paragraph level 1; European digits at 2; Latin at 2.
assert_eq!(l[0], 1);
assert_eq!(&l[4..8], &[2, 2, 2, 2]);
assert_eq!(&l[10..17], &[2; 7]);
assert_eq!(l[19], 1);
// Brackets around Latin inside an RTL paragraph resolve to the embedding direction (N0).
assert_eq!(l[9] % 2, 1, "( is right-to-left");
assert_eq!(l[17] % 2, 1, ") is right-to-left");
// Visual order, left to right: the Arabic words reversed, Latin and digits kept, the
// bracket characters swapped (their glyphs are mirrored back by the shaper, rule L4).
let order = visual_order(&line_levels(&chars, &l, 1));
let vis: String = order.iter().map(|&i| chars[i]).collect();
assert_eq!(vis, "طقف )Retrait( 2024 ةنس");
}
#[test]
fn ltr_paragraph_fast_path_and_embedded_rtl() {
assert_eq!(lv("Hello 123", false), vec![0; 9]);
let s = "ab سن cd";
let chars: Vec<char> = s.chars().collect();
let l = lv(s, false);
assert_eq!(l, vec![0, 0, 0, 1, 1, 0, 0, 0]);
let vis: String = visual_order(&l).iter().map(|&i| chars[i]).collect();
assert_eq!(vis, "ab نس cd");
// An RTL paragraph of Latin text: the text stays left to right inside it.
assert_eq!(lv("ab", true), vec![2, 2]);
}
#[test]
fn l1_resets_trailing_whitespace_and_tabs() {
let chars: Vec<char> = "ab\tcd ".chars().collect();
let l = line_levels(&chars, &[2, 2, 2, 2, 2, 2, 2], 1);
assert_eq!(l, vec![2, 2, 1, 2, 2, 1, 1]);
// Short level slices never panic.
assert_eq!(line_levels(&['a', 'b'], &[], 1), vec![1, 1]);
}
#[test]
fn visual_order_edge_cases() {
assert!(visual_order(&[]).is_empty());
assert_eq!(visual_order(&[0, 0]), vec![0, 1]);
assert_eq!(visual_order(&[1, 1, 1]), vec![2, 1, 0]);
// Numbers (level 2) inside RTL keep their order.
assert_eq!(visual_order(&[1, 2, 2, 1]), vec![3, 1, 2, 0]);
assert_eq!(visual_order(&[125, 125]), vec![1, 0]);
}
}
+759 -191
View File
File diff suppressed because it is too large Load Diff
+274
View File
@@ -1,4 +1,5 @@
use super::*;
use deckcraft_model::Fill;
use deckcraft_model::defaults;
use deckcraft_model::text::{Paragraph, Run};
use deckcraft_model::{LayoutType, Presentation, Xfrm};
@@ -121,3 +122,276 @@ fn numbered_list_counts() {
let l = lay(&p, &sh, 400.0, 300.0);
assert!(l.runs.len() >= 6);
}
// ---------- Bidirectional text and Arabic typography ----------
const MIXED: &str = "سنة 2024 (Retrait) فقط";
/// A one-paragraph body with no bullet, `rtl` and `align` set.
fn bidi_setup(runs: Vec<Run>, w: f64, rtl: bool, align: Align) -> (Presentation, Shape) {
let (p, mut sh) = setup("x", w, 300.0);
let t = sh.text.as_mut().unwrap();
t.paragraphs = vec![Paragraph { runs, ..Default::default() }];
let pp = &mut t.paragraphs[0].props;
pp.rtl = Some(rtl);
pp.align = Some(align);
pp.bullet = Some(Bullet::None);
pp.margin_left = Some(0.0);
pp.indent = Some(0.0);
(p, sh)
}
/// Is any face able to draw Arabic here (installed or bundled)? Shaping assertions that need
/// real Arabic glyphs are skipped without one.
fn arabic_face() -> Option<Arc<FontFace>> {
let db = FontDb::global();
let f = db.cascade(&['\u{0633}'], &[db.face("Inter", "Regular")], "Regular");
f.covers('\u{0633}').then_some(f)
}
#[test]
fn rtl_mixed_string_is_reordered_per_line() {
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
let l = lay(&p, &sh, 2000.0, 300.0);
assert_eq!(l.lines.len(), 1);
let line = &l.lines[0];
assert!(line.rtl);
let n = MIXED.chars().count();
assert_eq!(line.edges.len(), n);
assert_eq!(line.caret_x.len(), n + 1);
let mid = |k: usize| (line.edges[k].0 + line.edges[k].1) / 2.0;
// Visual order right to left: سنة | 2024 | (Retrait) | فقط.
assert!(mid(0) > mid(4), "first Arabic word right of the number");
assert!(mid(4) > mid(10), "number right of the Latin word");
assert!(mid(10) > mid(19), "Latin right of the last Arabic word");
// Inside the Arabic words, logical order runs right to left; digits and Latin left to right.
assert!(mid(0) > mid(1) && mid(1) > mid(2));
assert!(mid(4) < mid(5) && mid(5) < mid(7));
assert!(mid(10) < mid(16));
// The brackets sit outside "Retrait": "(" (logically first) on its right.
assert!(mid(9) > mid(16) && mid(17) < mid(10));
// Right-aligned at the start edge S = W - rIns; the downstream caret at 0 is that edge.
let right_edge = l.inner.x1;
assert!((line.extent.1 - right_edge).abs() < 0.5, "{} vs {right_edge}", line.extent.1);
assert!((line.caret_x[0] - line.extent.1).abs() < 0.01);
// Levels after L1: Arabic odd, digits and Latin even.
assert_eq!(line.levels[0], 1);
assert_eq!(line.levels[4], 2);
assert_eq!(line.levels[10], 2);
}
#[test]
fn rtl_paragraph_starts_at_the_right_with_the_bullet_outside() {
let (p, mut sh) = bidi_setup(vec![Run::new("مرحبا بكم")], 400.0, true, Align::Right);
let pp = &mut sh.text.as_mut().unwrap().paragraphs[0].props;
pp.bullet = Some(Bullet::Char { char: "•".into() });
pp.margin_left = Some(36.0);
pp.indent = Some(-36.0);
let l = lay(&p, &sh, 400.0, 300.0);
let line = &l.lines[0];
// Text starts marL from the right edge; the bullet hangs at S - (marL + indent) = S.
let s_edge = l.inner.x1;
assert!((line.extent.1 - (s_edge - 36.0)).abs() < 0.5, "text right {} expected {}", line.extent.1, s_edge - 36.0);
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
let bx = bullet.glyphs[0].1;
assert!(bx > line.extent.1 - 0.5, "bullet {bx} right of the text {}", line.extent.1);
assert!(bx < s_edge, "bullet inside the box");
// An LTR paragraph keeps the bullet on the left.
sh.text.as_mut().unwrap().paragraphs[0].props.rtl = Some(false);
sh.text.as_mut().unwrap().paragraphs[0].props.align = Some(Align::Left);
let l = lay(&p, &sh, 400.0, 300.0);
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
assert!(bullet.glyphs[0].1 < l.lines[0].extent.0);
}
#[test]
fn rtl_left_and_center_alignment() {
let (p, sh) = bidi_setup(vec![Run::new("سلام")], 400.0, true, Align::Left);
let l = lay(&p, &sh, 400.0, 300.0);
assert!((l.lines[0].extent.0 - l.inner.x0).abs() < 0.5, "algn=l is flush left in RTL too");
let (p, sh) = bidi_setup(vec![Run::new("سلام")], 400.0, true, Align::Center);
let l = lay(&p, &sh, 400.0, 300.0);
let c = (l.lines[0].extent.0 + l.lines[0].extent.1) / 2.0;
assert!((c - (l.inner.x0 + l.inner.x1) / 2.0).abs() < 0.5);
}
#[test]
fn bidi_caret_stops_round_trip_through_hit_testing() {
for rtl in [true, false] {
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, rtl, if rtl { Align::Right } else { Align::Left });
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
for (x, ch, aff) in line.visual_stops() {
let (cx, ..) = l.caret_at(Pos { para: 0, ch }, aff).unwrap();
assert!((cx - x).abs() < 0.01, "stop {ch} {aff:?}: caret {cx} vs stop {x}");
}
// Clicking just inside either side of each grapheme puts the caret on that side.
for (a, b) in line.graphemes() {
let Some((x0, x1)) = line.grapheme_box(a, b) else { continue };
if x1 - x0 < 1.0 {
continue;
}
let y = (line.top + line.bottom) / 2.0;
for x in [x0 + 0.2, x1 - 0.2] {
let (pos, aff) = l.hit_affinity(Point::new(x, y));
let (cx, ..) = l.caret_at(pos, aff).unwrap();
let side = if x < (x0 + x1) / 2.0 { x0 } else { x1 };
assert!((cx - side).abs() < 0.01, "rtl={rtl} grapheme {a}: hit {x} -> {pos:?} {aff:?} caret {cx}, expected {side}");
}
}
}
}
#[test]
fn visual_movement_walks_the_line_left_to_right() {
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
let l = lay(&p, &sh, 2000.0, 300.0);
let stops = l.lines[0].visual_stops();
let (x0, ch0, aff0) = stops[0];
let (mut pos, mut aff) = (Pos { para: 0, ch: ch0 }, aff0);
let mut xs = vec![x0];
for _ in 0..200 {
let (np, na) = l.move_caret(pos, aff, true, MoveMode::Visual);
if (np, na) == (pos, aff) {
break;
}
(pos, aff) = (np, na);
xs.push(l.caret_at(pos, aff).unwrap().0);
}
assert!(xs.windows(2).all(|w| w[1] > w[0]), "moving right always moves right: {xs:?}");
let right = l.lines[0].extent.1;
assert!((xs.last().unwrap() - right).abs() < 0.01, "reaches the right edge");
// Right arrow at the right edge of an RTL paragraph's only line goes nowhere.
assert_eq!(l.move_caret(pos, aff, true, MoveMode::Visual).0, pos);
// Moving left from the right edge in an RTL paragraph is logically forward.
let (p1, _) = l.move_caret(Pos { para: 0, ch: 0 }, Affinity::Downstream, false, MoveMode::Visual);
assert_eq!(p1.ch, 1);
}
#[test]
fn logical_movement_steps_over_harakat() {
// beh + fatha + teh: the fatha belongs to the beh's grapheme.
let (p, sh) = bidi_setup(vec![Run::new("\u{0628}\u{064E}\u{062A}")], 400.0, true, Align::Right);
let l = lay(&p, &sh, 400.0, 300.0);
let (n1, _) = l.move_caret(Pos { para: 0, ch: 0 }, Affinity::Downstream, true, MoveMode::Logical);
assert_eq!(n1.ch, 2);
let (n2, _) = l.move_caret(n1, Affinity::Downstream, false, MoveMode::Logical);
assert_eq!(n2.ch, 0);
// No caret stop inside a grapheme.
assert!(l.lines[0].visual_stops().iter().all(|s| s.1 != 1));
}
#[test]
fn selection_is_the_union_of_visual_boxes() {
// LTR paragraph with an Arabic word: visually "ab ةنس cd".
let (p, sh) = bidi_setup(vec![Run::new("ab سنة cd")], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
// Logical [1, 5) = "b", " ", "س", "ن": two separate boxes on screen.
let r = l.selection_rects(Pos { para: 0, ch: 1 }, Pos { para: 0, ch: 5 });
assert_eq!(r.len(), 2, "{r:?}");
// The whole line is one box.
assert_eq!(l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 9 }).len(), 1);
}
#[test]
fn paint_only_style_changes_keep_arabic_joining() {
let Some(_) = arabic_face() else {
eprintln!("skipped: no Arabic face available");
return;
};
let word = "سنة";
let glyphs = |runs: Vec<Run>| {
let (p, sh) = bidi_setup(runs, 400.0, true, Align::Right);
let l = lay(&p, &sh, 400.0, 300.0);
let mut g: Vec<(u32, f64)> = l.runs.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.0, g.1))).collect();
g.sort_by(|a, b| a.1.total_cmp(&b.1));
g.into_iter().map(|g| g.0).collect::<Vec<_>>()
};
let whole = glyphs(vec![Run::new(word)]);
let mut red = Run::new("سن");
red.props.fill = Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(Rgba::from_hex("C00000").unwrap())));
let split = glyphs(vec![red, Run::new("ة")]);
assert_eq!(whole, split, "a colour change inside a word keeps the joined forms");
// And the joined forms differ from the isolated letters.
let isolated: Vec<u32> = word.chars().map(|c| arabic_face().unwrap().glyph_for(c)).collect();
assert_ne!(whole.iter().rev().copied().collect::<Vec<_>>(), isolated);
}
#[test]
fn just_low_stretches_arabic_with_kashida() {
if arabic_face().is_none() {
eprintln!("skipped: no Arabic face available");
return;
}
// Tatweel glyphs drawn, counted in each run's own face (the one the layout picked).
let tatweels = |l: &TextLayout| {
l.runs
.iter()
.map(|r| {
let tg = r.face.glyph_for('\u{0640}');
if tg == 0 { 0 } else { r.glyphs.iter().filter(|g| g.0 == tg).count() }
})
.sum::<usize>()
};
let text = "بسم الله الرحمن الرحيم نستعين به ونستغفره ونتوب إليه";
let (p, sh) = bidi_setup(vec![Run::new(text)], 220.0, true, Align::JustLow);
let l = lay(&p, &sh, 220.0, 600.0);
assert!(l.lines.len() >= 2);
let first = &l.lines[0];
let w = l.inner.width();
assert!((first.extent.1 - first.extent.0 - w).abs() < 0.5, "first line fills the box: {:?} vs {w}", first.extent);
if l.runs.iter().all(|r| r.face.glyph_for('\u{0640}') == 0) {
eprintln!("skipped: the Arabic face has no tatweel");
return;
}
assert!(tatweels(&l) > 0, "kashida drawn");
// The last line isn't stretched and sits at the start (right) edge.
let last = l.lines.last().unwrap();
assert!((last.extent.1 - l.inner.x1).abs() < 0.5);
// Plain justify uses spaces: no tatweel.
let (p, sh) = bidi_setup(vec![Run::new(text)], 220.0, true, Align::Justify);
let l = lay(&p, &sh, 220.0, 600.0);
assert_eq!(tatweels(&l), 0);
}
#[test]
fn wrapped_rtl_lines_break_in_logical_order() {
let text = "واحد اثنان ثلاثة أربعة خمسة ستة سبعة ثمانية تسعة عشرة";
let (p, sh) = bidi_setup(vec![Run::new(text)], 160.0, true, Align::Right);
let l = lay(&p, &sh, 160.0, 600.0);
assert!(l.lines.len() >= 2);
// Lines cover the text in logical order: the first line holds the first words.
assert_eq!(l.lines[0].start, 0);
for w in l.lines.windows(2) {
assert!(w[1].start >= w[0].end);
}
for line in &l.lines {
assert!(line.extent.0 >= l.inner.x0 - 0.5, "line fits: {:?}", line.extent);
assert!((line.extent.1 - l.inner.x1).abs() < 0.5, "flush right");
}
// Upstream affinity at a wrap keeps the caret on the earlier line.
let end0 = l.lines[0].end;
assert_eq!(l.line_of_affine(Pos { para: 0, ch: end0 }, Affinity::Upstream), Some(0));
}
#[test]
fn hostile_bidi_input_never_panics() {
let nasty = format!("{}\t\u{b}\u{202E}abc\u{2069}{}(]))[ ١٢٣ \u{0640}\u{064E}\u{064E}\u{200D}", "\u{202B}".repeat(200), "\u{2067}".repeat(130));
for rtl in [true, false] {
for align in [Align::Left, Align::Right, Align::Center, Align::Justify, Align::JustLow, Align::Distributed] {
let (p, sh) = bidi_setup(vec![Run::new(nasty.clone()), Run::new(MIXED)], 60.0, rtl, align);
let l = lay(&p, &sh, 60.0, 60.0);
for line in &l.lines {
assert_eq!(line.caret_x.len(), line.end - line.start + 1);
assert_eq!(line.edges.len(), line.end - line.start);
}
let _ = l.hit_affinity(Point::new(-1e9, 1e9));
let _ = l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 9999 });
let mut pos = (Pos::default(), Affinity::Downstream);
for i in 0..50 {
pos = l.move_caret(pos.0, pos.1, i % 3 != 0, if i % 2 == 0 { MoveMode::Visual } else { MoveMode::Logical });
}
let _ = l.caret_at(Pos { para: 7, ch: 7 }, Affinity::Upstream);
}
}
}
+2
View File
@@ -210,6 +210,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
("Center", "format.alignCenter"),
("Align Right", "format.alignRight"),
("Justify", "format.justify"),
("-", ""),
("Right-to-Left Text Direction", "format.rtl"),
],
),
(