oxitext_shape/backend.rs
1//! Swappable text shaping backends.
2//!
3//! Defines the [`ShapeBackend`] trait so consumers can choose between the
4//! default swash-based backend and the optional rustybuzz backend without
5//! changing any other part of the pipeline.
6
7use oxitext_core::ShapedGlyph;
8use std::sync::Arc;
9
10/// Trait for swappable text shaping backends.
11///
12/// Implementors must be `Send + Sync` so they can be shared across threads.
13///
14/// All methods receive `face_data: &Arc<[u8]>` rather than `&[u8]` so that the
15/// pointer address is preserved across calls. This allows [`crate::ShapeCache`]
16/// (keyed on `Arc::as_ptr`) to produce cache hits when the same allocation is
17/// reused.
18pub trait ShapeBackend: Send + Sync {
19 /// Shape UTF-8 `text` using font bytes `face_data` at `px_size`
20 /// pixels-per-em, returning one [`ShapedGlyph`] per output glyph.
21 fn shape(&self, face_data: &Arc<[u8]>, text: &str, px_size: f32) -> Vec<ShapedGlyph>;
22
23 /// Shape UTF-8 `text` with an explicit direction hint.
24 ///
25 /// When `rtl` is `false` this is identical to [`Self::shape`].
26 ///
27 /// When `rtl` is `true` the backend shapes in the right-to-left direction
28 /// and returns glyphs sorted in **ascending `cluster` order** (logical
29 /// source order). Visual reordering is the caller's responsibility.
30 ///
31 /// Backends that do not override this method fall back to [`Self::shape`]
32 /// for LTR and perform a post-sort for RTL, which is correct but may not
33 /// select the best glyph forms for bidirectional scripts.
34 fn shape_with_direction(
35 &self,
36 face_data: &Arc<[u8]>,
37 text: &str,
38 px_size: f32,
39 rtl: bool,
40 ) -> Vec<ShapedGlyph> {
41 if !rtl {
42 return self.shape(face_data, text, px_size);
43 }
44 let mut glyphs = self.shape(face_data, text, px_size);
45 glyphs.sort_by_key(|g| g.cluster);
46 glyphs
47 }
48
49 /// Shape UTF-8 `text` with an explicit set of OpenType feature overrides.
50 ///
51 /// The default implementation ignores the `features` slice and delegates
52 /// to [`Self::shape`]. Backends that support OpenType feature control
53 /// should override this method to apply the requested features.
54 fn shape_with_features(
55 &self,
56 face_data: &Arc<[u8]>,
57 text: &str,
58 px_size: f32,
59 features: &[crate::ShapeFeature],
60 ) -> Vec<ShapedGlyph> {
61 let _ = features;
62 self.shape(face_data, text, px_size)
63 }
64
65 /// Shape text with extended options.
66 ///
67 /// The default implementation delegates to [`Self::shape_with_features`]
68 /// when `features` is non-empty, or [`Self::shape`] otherwise, ignoring
69 /// any options not covered by those methods. Backends that need to honour
70 /// additional fields from `options` (script, language, direction, etc.)
71 /// should override this method.
72 fn shape_with_options(
73 &self,
74 face_data: &Arc<[u8]>,
75 text: &str,
76 px_size: f32,
77 rtl: bool,
78 features: &[crate::ShapeFeature],
79 _options: &crate::ShapeRequest<'_>,
80 ) -> Vec<ShapedGlyph> {
81 if features.is_empty() {
82 self.shape_with_direction(face_data, text, px_size, rtl)
83 } else {
84 let mut glyphs = self.shape_with_features(face_data, text, px_size, features);
85 if rtl {
86 glyphs.sort_by_key(|g| g.cluster);
87 }
88 glyphs
89 }
90 }
91
92 /// Check if the font has shaping support for a given OpenType script tag.
93 ///
94 /// Returns `true` if the font likely covers the script, `false` if a
95 /// sentinel character from the script's Unicode range returns no glyph.
96 /// Unknown scripts (not in the built-in table) return `true` by default
97 /// so callers that do not pass script tags are not affected.
98 fn supports_script(&self, font_data: &Arc<[u8]>, script: [u8; 4]) -> bool {
99 /// Sentinel characters to check per well-known script tag.
100 fn sentinel_char(script: [u8; 4]) -> Option<char> {
101 match &script {
102 b"latn" => Some('A'),
103 b"arab" => Some('\u{0627}'), // Arabic letter Alef
104 b"hani" => Some('\u{4E00}'), // CJK ideograph
105 b"cyrl" => Some('\u{0410}'), // Cyrillic capital A
106 b"grek" => Some('\u{0391}'), // Greek capital Alpha
107 b"hebr" => Some('\u{05D0}'), // Hebrew Alef
108 b"deva" => Some('\u{0905}'), // Devanagari A
109 b"thai" => Some('\u{0E01}'), // Thai Ko Kai
110 _ => None,
111 }
112 }
113
114 let Some(ch) = sentinel_char(script) else {
115 // Unknown script — permissive default.
116 return true;
117 };
118
119 ttf_parser::Face::parse(font_data.as_ref(), 0)
120 .map(|face| face.glyph_index(ch).is_some())
121 .unwrap_or(true) // If parsing fails, assume support.
122 }
123}
124
125/// Swash-based shaper — delegates to [`crate::SwashShaper`].
126///
127/// This adapter wraps the existing [`crate::SwashShaper`] (which has its own
128/// internal LRU cache) and exposes it via the [`ShapeBackend`] trait.
129///
130/// Uses [`std::sync::RwLock`] instead of `Mutex` so that read-only operations
131/// (e.g. [`ShapeBackend::supports_script`]) can proceed concurrently. Shape
132/// calls still acquire the write lock because the underlying [`crate::SwashShaper`]
133/// mutates its internal context on every call.
134pub struct SwashShaperBackend {
135 inner: std::sync::Arc<std::sync::RwLock<crate::SwashShaper>>,
136}
137
138impl SwashShaperBackend {
139 /// Creates a new [`SwashShaperBackend`].
140 pub fn new() -> Self {
141 Self {
142 inner: std::sync::Arc::new(std::sync::RwLock::new(crate::SwashShaper::new())),
143 }
144 }
145}
146
147impl Default for SwashShaperBackend {
148 fn default() -> Self {
149 Self::new()
150 }
151}
152
153impl ShapeBackend for SwashShaperBackend {
154 fn shape(&self, face_data: &Arc<[u8]>, text: &str, px_size: f32) -> Vec<ShapedGlyph> {
155 let mut guard = match self.inner.write() {
156 Ok(g) => g,
157 Err(_) => return Vec::new(),
158 };
159 match guard.shape(text, Arc::clone(face_data), px_size) {
160 Ok(run) => run.glyphs.into_vec(),
161 Err(_) => Vec::new(),
162 }
163 }
164
165 fn shape_with_direction(
166 &self,
167 face_data: &Arc<[u8]>,
168 text: &str,
169 px_size: f32,
170 rtl: bool,
171 ) -> Vec<ShapedGlyph> {
172 let mut guard = match self.inner.write() {
173 Ok(g) => g,
174 Err(_) => return Vec::new(),
175 };
176 match guard.shape_with_direction(text, Arc::clone(face_data), px_size, rtl) {
177 Ok(run) => run.glyphs.into_vec(),
178 Err(_) => Vec::new(),
179 }
180 }
181}
182
183/// rustybuzz-based shaper.
184///
185/// Enabled by the `rustybuzz-backend` feature.
186#[cfg(feature = "rustybuzz-backend")]
187pub struct RustybuzzShaper;
188
189#[cfg(feature = "rustybuzz-backend")]
190impl Default for RustybuzzShaper {
191 fn default() -> Self {
192 Self
193 }
194}
195
196#[cfg(feature = "rustybuzz-backend")]
197impl RustybuzzShaper {
198 /// Internal helper: shapes `text` into glyphs using the given rustybuzz direction.
199 ///
200 /// Returns glyphs sorted to ascending cluster (logical source) order.
201 fn shape_internal(
202 &self,
203 face_data: &Arc<[u8]>,
204 text: &str,
205 px_size: f32,
206 direction: rustybuzz::Direction,
207 ) -> Vec<ShapedGlyph> {
208 use rustybuzz::{Face, UnicodeBuffer};
209
210 let face = match Face::from_slice(face_data.as_ref(), 0) {
211 Some(f) => f,
212 None => return Vec::new(),
213 };
214
215 let upem = face.units_per_em() as f32;
216 let scale = if upem > 0.0 { px_size / upem } else { 1.0 };
217
218 let mut buf = UnicodeBuffer::new();
219 buf.push_str(text);
220 buf.set_direction(direction);
221
222 let shaped = rustybuzz::shape(&face, &[], buf);
223 let infos = shaped.glyph_infos();
224 let positions = shaped.glyph_positions();
225
226 let mut glyphs: Vec<ShapedGlyph> = infos
227 .iter()
228 .zip(positions.iter())
229 .map(|(info, pos)| {
230 let is_ws = text
231 .get(info.cluster as usize..)
232 .and_then(|s| s.chars().next())
233 .map(|c| c.is_whitespace())
234 .unwrap_or(false);
235 ShapedGlyph {
236 gid: info.glyph_id as u16,
237 cluster: info.cluster,
238 x_advance: pos.x_advance as f32 * scale,
239 y_advance: pos.y_advance as f32 * scale,
240 x_offset: pos.x_offset as f32 * scale,
241 y_offset: pos.y_offset as f32 * scale,
242 is_whitespace: is_ws,
243 // rustybuzz exposes UNSAFE_TO_BREAK in glyph flags; we
244 // conservatively leave it false here (single-pass shaping).
245 unsafe_to_break: false,
246 }
247 })
248 .collect();
249
250 // Guarantee ascending cluster (logical source) order regardless of
251 // what rustybuzz emits for RTL runs.
252 glyphs.sort_by_key(|g| g.cluster);
253 glyphs
254 }
255}
256
257#[cfg(feature = "rustybuzz-backend")]
258impl ShapeBackend for RustybuzzShaper {
259 fn shape(&self, face_data: &Arc<[u8]>, text: &str, px_size: f32) -> Vec<ShapedGlyph> {
260 self.shape_internal(face_data, text, px_size, rustybuzz::Direction::LeftToRight)
261 }
262
263 fn shape_with_direction(
264 &self,
265 face_data: &Arc<[u8]>,
266 text: &str,
267 px_size: f32,
268 rtl: bool,
269 ) -> Vec<ShapedGlyph> {
270 let direction = if rtl {
271 rustybuzz::Direction::RightToLeft
272 } else {
273 rustybuzz::Direction::LeftToRight
274 };
275 self.shape_internal(face_data, text, px_size, direction)
276 }
277}