From 4b09f35c1d6fa50e2d94243a5d65d98fe49558e0 Mon Sep 17 00:00:00 2001 From: hamzaelkachtaf4-star Date: Fri, 9 Oct 2026 15:48:26 +0100 Subject: [PATCH] feat(text, fonts): UAX #9 BiDi and Arabic complex script typography engine (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Tayeb Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 1 + ROADMAP.md | 5 + crates/engine/src/cmd/format.rs | 36 +- crates/engine/src/tests.rs | 25 + crates/fonts/src/fallback.rs | 183 ++++++ crates/fonts/src/fontdb.rs | 6 +- crates/fonts/src/lib.rs | 113 +--- crates/fonts/src/script.rs | 246 +++++++++ crates/fonts/src/shape.rs | 124 +++++ crates/model/src/text.rs | 6 +- crates/model/src/theme.rs | 18 + crates/pptx/src/read/theme.rs | 12 +- crates/pptx/src/write/theme.rs | 3 + crates/pptx/tests/roundtrip.rs | 25 + crates/render/src/placed.rs | 6 +- crates/text/Cargo.toml | 1 + crates/text/src/bidi.rs | 152 +++++ crates/text/src/lib.rs | 950 +++++++++++++++++++++++++------- crates/text/src/tests.rs | 274 +++++++++ crates/ui-egui/src/menus.rs | 2 + 20 files changed, 1904 insertions(+), 284 deletions(-) create mode 100644 crates/fonts/src/fallback.rs create mode 100644 crates/fonts/src/script.rs create mode 100644 crates/fonts/src/shape.rs create mode 100644 crates/text/src/bidi.rs diff --git a/Cargo.lock b/Cargo.lock index 294c8083..3df9f0cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1109,6 +1109,7 @@ dependencies = [ "deckcraft-fonts", "deckcraft-geom", "deckcraft-model", + "unicode-bidi", "unicode-linebreak", "unicode-segmentation", ] diff --git a/ROADMAP.md b/ROADMAP.md index 694da5f8..70b7e1c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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, diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index fc97100d..f02a9a26 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -71,7 +71,24 @@ pub fn specs() -> Vec { 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 { "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 { + 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 { let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. })); let on = on_param(p, Some(cur)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e5b891b1..2218330f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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)); +} diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs new file mode 100644 index 00000000..99eb5baf --- /dev/null +++ b/crates/fonts/src/fallback.rs @@ -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], style: &str) -> Arc { + 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> { + let pick = |db: &FontDb| { + let faces = db.read_faces(); + let mut v: Vec<&Arc> = 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 { + 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 = 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])); + } + } +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 89e85e28..f0a06efe 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -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>> { + pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { 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; } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 17ecfe1f..94e8081f 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -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 { 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 { + 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, bool)> { let mut runs: Vec<(std::ops::Range, bool)> = Vec::new(); @@ -110,72 +123,6 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range, 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 { - 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::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 { - 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) diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs new file mode 100644 index 00000000..3a3ef49a --- /dev/null +++ b/crates/fonts/src/script.rs @@ -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 { + 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 { + 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, FontSlot)> { + let own: Vec<(Option, Option)> = 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 = "سنة 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]); + } + } +} diff --git a/crates/fonts/src/shape.rs b/crates/fonts/src/shape.rs new file mode 100644 index 00000000..5c88055a --- /dev/null +++ b/crates/fonts/src/shape.rs @@ -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, + /// 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, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec { + 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::().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![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()); + } +} diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index c8e0a3a0..addbdff1 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -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, }) diff --git a/crates/model/src/theme.rs b/crates/model/src/theme.rs index 26912ea5..55b416b0 100644 --- a/crates/model/src/theme.rs +++ b/crates/model/src/theme.rs @@ -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, +} + +/// 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)] diff --git a/crates/pptx/src/read/theme.rs b/crates/pptx/src/read/theme.rs index 5c3aa747..7d6e614c 100644 --- a/crates/pptx/src/read/theme.rs +++ b/crates/pptx/src/read/theme.rs @@ -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 { diff --git a/crates/pptx/src/write/theme.rs b/crates/pptx/src/write/theme.rs index 9b1be416..d83e90f9 100644 --- a/crates/pptx/src/write/theme.rs +++ b/crates/pptx/src/write/theme.rs @@ -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); } diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index fbe2b32b..b33f1842 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -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) فقط"); +} diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index aaf325eb..5801a350 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -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 }); } diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index d4bcb49b..6061eb0a 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -15,3 +15,4 @@ deckcraft-color = { workspace = true } deckcraft-fonts = { workspace = true } unicode-linebreak = { workspace = true } unicode-segmentation = { workspace = true } +unicode-bidi = { workspace = true } diff --git a/crates/text/src/bidi.rs b/crates/text/src/bidi.rs new file mode 100644 index 00000000..be4485c8 --- /dev/null +++ b/crates/text/src/bidi.rs @@ -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 { + 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 { + let mut out: Vec = 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 { + let mut order: Vec = (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 { + let chars: Vec = 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 = 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 = 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 = "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]); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 8c5ed32d..7345ab53 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1,21 +1,37 @@ //! Text layout for slide text bodies. //! -//! [`layout`] resolves every run's formatting through the placeholder/style chain, shapes it with -//! font fallback, breaks lines (UAX #14 opportunities, greedy fill), places bullets and numbers, -//! applies indents, spacing and alignment, anchors the block in the shape's text box, and shrinks -//! text on overflow when the body asks for it. The result is positioned glyph runs in shape-local -//! coordinates plus per-line caret positions for editing. +//! [`layout`] resolves every run's formatting through the placeholder/style chain and lays each +//! paragraph out in the order bidirectional text needs: +//! 1. embedding levels for the whole logical paragraph (UAX #9, base level from `a:pPr/@rtl`); +//! 2. itemization by level, script, face (per-cluster fallback through the `a:latin`/`a:ea`/`a:cs` +//! slots and the theme's script fonts) and shaping style, each item shaped with the whole +//! paragraph as context; paint-only attributes (colour, highlight, underline) never split a +//! word, so Arabic joining and ligatures survive them; +//! 3. line breaking in logical order (UAX #14 opportunities at cluster boundaries, greedy fill); +//! 4. per line, rules L1/L2 reorder the characters visually (L4 mirroring is done by the shaper). +//! +//! It then places bullets and numbers from the paragraph's start edge (the right edge for RTL +//! paragraphs), applies indents, spacing and alignment (kashida for `justLow`), anchors the block +//! in the shape's text box, and shrinks text on overflow when the body asks for it. The result is +//! positioned glyph runs in shape-local coordinates plus per-line caret geometry for editing: +//! bidirectional carets with affinity, logical and visual movement, and selection as the union of +//! visual boxes. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod bidi; + +use std::collections::HashMap; use std::sync::Arc; use deckcraft_color::Rgba; -use deckcraft_fonts::{FontDb, FontFace}; +use deckcraft_fonts::script::{self, joins_following, joins_preceding}; +use deckcraft_fonts::{FontDb, FontFace, FontSlot, ScriptTag, ShapeParams}; use deckcraft_geom::{Point, Rect}; use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::text::{Align, Anchor, AutoFit, BodyProps, Bullet, Caps, ParaProps, RunKind, RunProps, Spacing, Strike, TextBody, TextDir}; use deckcraft_model::{Fill, Shape}; +use unicode_segmentation::UnicodeSegmentation; /// A run of glyphs in one face, size and colour. #[derive(Clone)] @@ -68,10 +84,25 @@ pub struct LineInfo { pub top: f64, pub baseline: f64, pub bottom: f64, - /// Caret x for each character boundary start..=end (len = end - start + 1). + /// Caret x for each character boundary start..=end (len = end - start + 1), downstream: the + /// leading edge of the character after the boundary (its right edge if it is right-to-left); + /// the last entry is the trailing edge of the line's last character. pub caret_x: Vec, /// Column index (multi-column bodies). pub column: usize, + /// Right-to-left paragraph. + pub rtl: bool, + /// Bidi level of each character start..end after rule L1 (odd = right-to-left). + pub levels: Vec, + /// Visual box (left, right) of each character start..end. Characters inside a grapheme have + /// empty boxes; a ligature's characters share its width (GDEF ligature carets). + pub edges: Vec<(f64, f64)>, + /// Whether a caret may stand before each character start..end (grapheme boundaries). + pub boundary: Vec, + /// Visual extent (left, right) of the line's text. + pub extent: (f64, f64), + /// Distance from the paragraph's start edge to the line's text (indent, margin, bullet). + pub start_offset: f64, } #[derive(Clone, Debug, Default)] @@ -93,6 +124,9 @@ pub struct TextLayout { pub rotation: f64, /// Per paragraph: (first line index, line count). pub para_lines: Vec<(usize, usize)>, + /// Per paragraph: whether a caret may stand at each character offset 0..=len (grapheme + /// boundaries), for logical caret movement. + pub boundaries: Vec>, } /// Values for fields (`slidenum`, `datetime…`, `footer`). @@ -133,17 +167,41 @@ struct Style { outline: Option<(Rgba, f64)>, link: bool, gradient: Option, + /// "Regular", "Bold"… for the slot and fallback faces. + style_name: &'static str, + /// BCP 47 language (`a:rPr/@lang`), for shaping. + lang: Option, + /// The `a:ea` and `a:cs` families (theme references resolved), if the document names one. + ea: Option, + cs: Option, + /// Heading font (`+mj-…`): the theme's major font set applies. + major: bool, } -/// One shaped character cell of a paragraph. +/// One character of a paragraph after shaping. #[derive(Clone)] struct Cell { ch: char, style: usize, - /// Glyphs anchored at this char (gid, x offset from char start, y offset). - glyphs: Vec<(u32, f64, f64)>, - adv: f64, face: usize, + /// Resolved bidi embedding level (before the line rules). + level: u8, + /// Glyphs of the shaping cluster this cell heads, from the cluster's visual left edge (gid, + /// x, y). Empty for the other cells of a cluster. + glyphs: Vec<(u32, f64, f64)>, + /// This character's advance: its share of the cluster (ligature carets); 0 inside a grapheme. + adv: f64, + /// Index of the cell heading this character's shaping cluster. + head: usize, + /// A grapheme starts here: carets stand and lines break only before such cells. + boundary: bool, +} + +impl Cell { + /// A line may start before this cell: a grapheme that starts a shaping cluster. + fn breakable(&self, i: usize) -> bool { + self.boundary && self.head == i + } } struct ParaShaped { @@ -157,6 +215,8 @@ struct ParaShaped { bullet: Option<(String, Style)>, #[allow(dead_code)] level: u8, + /// Right-to-left paragraph (`a:pPr/@rtl`). + rtl: bool, } fn style_name(bold: bool, italic: bool) -> &'static str { @@ -185,6 +245,14 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> Styl if let Some(p) = prompt { color = p; } + let major = r.font.as_deref().is_some_and(|f| f.starts_with("+mj")); + let theme = &ctx.master.theme; + let slot_family = |explicit: &Option, theme_ref: &str| { + let name = theme.font(explicit.as_deref().unwrap_or(theme_ref)); + (!name.trim().is_empty()).then_some(name) + }; + let ea = slot_family(&r.font_ea, if major { "+mj-ea" } else { "+mn-ea" }); + let cs = slot_family(&r.font_cs, if major { "+mj-cs" } else { "+mn-cs" }); let underline = r.underline.as_deref().filter(|u| *u != "none").map(|_| r.underline_color.as_ref().map(|c| ctx.color(c, None)).unwrap_or(color)); let outline = r.outline.as_ref().and_then(|l| match &l.fill { Some(Fill::Solid { color: c }) => Some((ctx.color(c, None), l.width.unwrap_or(0.75))), @@ -206,9 +274,45 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> Styl outline, link: r.link.is_some(), gradient, + style_name: style_name(bold, italic), + lang: r.lang.clone().filter(|l| !l.is_empty()), + ea, + cs, + major, } } +/// The faces to try for a cluster of `slot`/`script` in style `st`, in order: the slot's +/// typeface, the theme's font for the script (`a:font[@script]`), then the run's Latin face. +fn face_chain(ctx: &Ctx, st: &Style, slot: FontSlot, script: Option) -> Vec> { + let db = FontDb::global(); + let mut chain: Vec> = Vec::with_capacity(3); + let mut push = |family: &str| { + // Only families that exist: `FontDb::face` would substitute the default face. + if db.has_family(family) { + let f = db.face(family, st.style_name); + if !chain.iter().any(|c| Arc::ptr_eq(c, &f)) { + chain.push(f); + } + } + }; + match slot { + FontSlot::EastAsian => st.ea.as_deref().into_iter().for_each(&mut push), + FontSlot::ComplexScript => st.cs.as_deref().into_iter().for_each(&mut push), + FontSlot::Latin => {} + } + if let Some(sc) = script.and_then(|t| std::str::from_utf8(&t).ok().map(String::from)) { + let set = if st.major { &ctx.master.theme.fonts.major } else { &ctx.master.theme.fonts.minor }; + if let Some(f) = set.script(&sc) { + push(f); + } + } + if !chain.iter().any(|c| Arc::ptr_eq(c, &st.face)) { + chain.push(st.face.clone()); + } + chain +} + fn spacing_pts(s: Option, size: f64, reduce: f64) -> f64 { match s { Some(Spacing::Pct(p)) => p * size * 1.2 * (1.0 - reduce), @@ -301,96 +405,31 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, lead_face: face, bullet: None, level: 0, + rtl: false, }; }; let props = resolve::para(ctx, shape, para); + let rtl = props.rtl.unwrap_or(false); let mut styles: Vec