Skip to main content

fmd_font/
native_route.rs

1//! Safe native shaping route contract and platform adapter interface (FCB-016.B).
2//!
3//! Implements Plan §13.2, §13.7, §10.9, and §27.5:
4//! - Optional native platform adapter translates owned platform results into the
5//!   shared [`OwnedTextRun`] representation without polluting the base engine or
6//!   introducing foreign runtime dependencies.
7//! - Capability records distinguish deterministic bundled-face runs from
8//!   system-shaped fallback runs (`is_pixel_deterministic`).
9//! - Preserves exact fallback font identity: a native glyph ID has meaning only
10//!   with the actual fallback font that produced it (Plan §13.7).
11//! - Bounded preparation pass enforcing context limits (`max_paragraph_bytes`),
12//!   preventing unbounded foreign calls on interactive threads (Plan §10.9).
13//! - Headless contract builds and runs without AppKit/CoreText runtimes.
14
15use crate::shaping::Direction;
16use crate::text_run::{
17    FontId, FontOrigin, OwnedTextRun, RunGlyph, TextCluster, TextRunContext, byte_to_utf16,
18};
19use std::fmt;
20use std::ops::Range;
21
22/// The architectural kind of shaping route.
23///
24/// Plan §13.2: "The capability record distinguishes deterministic bundled-face
25/// runs from system-shaped fallback runs. Full cross-machine pixel determinism
26/// is not promised for the latter."
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
28pub enum ShapingRouteKind {
29    /// Internal deterministic shaper using bundled font faces.
30    BundledShaper,
31    /// Native system platform route (CoreText on macOS via safe system bridge).
32    SystemPlatform,
33    /// Headless simulated platform route for testing and portable qualification.
34    SimulatedPlatform,
35}
36
37impl ShapingRouteKind {
38    /// Whether this route kind guarantees cross-machine bit-identical pixel results.
39    #[must_use]
40    pub const fn is_pixel_deterministic(self) -> bool {
41        matches!(self, Self::BundledShaper)
42    }
43}
44
45/// Declared capabilities and operating boundaries of a shaping route.
46///
47/// Plan §10.9 & §13.2: Honest capability reporting and context bounds.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct ShapingRouteCapabilities {
50    /// Route classification.
51    pub route_kind: ShapingRouteKind,
52    /// Whether runs from this route are bit-identical across machines/OS versions.
53    pub is_pixel_deterministic: bool,
54    /// Whether this route can resolve and substitute platform fallback fonts.
55    pub supports_fallback_fonts: bool,
56    /// Whether this route supports color emoji or bitmap glyphs.
57    pub supports_color_emoji: bool,
58    /// Whether this route handles bidirectional text (LTR and RTL).
59    pub supports_bidi: bool,
60    /// Maximum byte length allowed for a single shaping invocation (Plan §10.9).
61    ///
62    /// Exceeding this budget yields [`NativeShapingError::ContextBudgetExceeded`].
63    pub max_paragraph_bytes: usize,
64}
65
66impl Default for ShapingRouteCapabilities {
67    fn default() -> Self {
68        Self {
69            route_kind: ShapingRouteKind::SimulatedPlatform,
70            is_pixel_deterministic: false,
71            supports_fallback_fonts: true,
72            supports_color_emoji: true,
73            supports_bidi: true,
74            max_paragraph_bytes: 64 * 1024, // 64 KiB context boundary
75        }
76    }
77}
78
79/// Fallback font identity metadata retained from a native system platform pass.
80///
81/// Plan §13.7: "A native glyph ID has meaning only with the actual fallback font/run
82/// that produced it. The native adapter retains or converts that font identity..."
83#[derive(Clone, Debug, PartialEq, Eq, Hash)]
84pub struct FallbackFace {
85    /// Unique deterministic identifier for the fallback font.
86    pub font_id: FontId,
87    /// Font family name (e.g. `"PingFang SC"`, `"Apple Color Emoji"`).
88    pub family_name: String,
89    /// PostScript name of the font face.
90    pub postscript_name: String,
91    /// Font design units per em.
92    pub units_per_em: u16,
93    /// Whether this face is a color emoji or bitmap font.
94    pub is_color_emoji: bool,
95}
96
97/// A raw glyph produced by a platform shaper before assembly into a run.
98#[derive(Clone, Copy, Debug, PartialEq)]
99pub struct PlatformRunGlyph {
100    /// Platform glyph index.
101    pub glyph_id: u16,
102    /// Identifier of the specific font face (primary or fallback) that produced this glyph.
103    pub font_id: FontId,
104    /// Start offset in the original logical UTF-8 bytes.
105    pub cluster_byte_offset: usize,
106    /// Length in original logical UTF-8 bytes covered by this cluster.
107    pub cluster_byte_len: usize,
108    /// Horizontal advance in layout points.
109    pub x_advance: f32,
110    /// Vertical advance in layout points.
111    pub y_advance: f32,
112    /// Horizontal placement offset in layout points.
113    pub x_offset: f32,
114    /// Vertical placement offset in layout points.
115    pub y_offset: f32,
116}
117
118/// Raw output produced by a native platform shaper or safe bridge.
119#[derive(Clone, Debug, PartialEq)]
120pub struct PlatformShapedOutput {
121    /// Original logical text shaped by the platform.
122    pub logical_text: String,
123    /// Direction of the shaped text.
124    pub direction: Direction,
125    /// Font size in points.
126    pub font_size: f32,
127    /// Sequence of platform-positioned glyphs.
128    pub glyphs: Vec<PlatformRunGlyph>,
129    /// Fallback font faces utilized during shaping (empty if primary font sufficed).
130    pub fallback_faces: Vec<FallbackFace>,
131    /// Route kind that generated this output.
132    pub route_kind: ShapingRouteKind,
133}
134
135/// Request parameters submitted to a native shaping route.
136#[derive(Clone, Debug, PartialEq)]
137pub struct NativeShapingRequest<'a> {
138    /// The logical text slice to shape.
139    pub text: &'a str,
140    /// The requested primary font identity.
141    pub primary_font_id: FontId,
142    /// Layout font size in points.
143    pub font_size: f32,
144    /// Text direction.
145    pub direction: Direction,
146    /// OpenType script tag (e.g. `*b"latn"`, `*b"hani"`).
147    pub script: [u8; 4],
148    /// OpenType language tag (e.g. `*b"dflt"`).
149    pub language: [u8; 4],
150    /// Whether the shaper is permitted to substitute platform fallback faces
151    /// for characters missing from the primary font.
152    pub allow_system_fallback: bool,
153}
154
155/// Errors arising from native shaping operations.
156#[derive(Clone, Debug, PartialEq, Eq)]
157pub enum NativeShapingError {
158    /// The input text exceeds the maximum allowable context budget for a single call (Plan §10.9).
159    ContextBudgetExceeded {
160        /// Actual byte length of the input.
161        length: usize,
162        /// Maximum allowed bytes under the active work budget.
163        max_allowed: usize,
164    },
165    /// The input text was empty.
166    EmptyText,
167    /// An offset was out of bounds for the input text.
168    InvalidByteRange {
169        /// Out of bounds offset.
170        offset: usize,
171        /// Total text length.
172        text_len: usize,
173    },
174    /// An offset fell within a UTF-8 scalar sequence rather than on a scalar boundary.
175    MidScalarBoundary {
176        /// Invalid offset.
177        offset: usize,
178    },
179    /// The text contains characters missing from the primary font, but system fallback was disabled.
180    FallbackRequired {
181        /// Offset of the unshaped character in the original bytes.
182        unshaped_byte_offset: usize,
183    },
184    /// The native platform bridge or required system framework is unavailable.
185    PlatformUnavailable(String),
186    /// The platform shaper reported an internal error during layout.
187    AdapterError(String),
188}
189
190impl fmt::Display for NativeShapingError {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        match self {
193            Self::ContextBudgetExceeded {
194                length,
195                max_allowed,
196            } => {
197                write!(
198                    f,
199                    "text context length ({length} bytes) exceeds maximum budget ({max_allowed} bytes)"
200                )
201            }
202            Self::EmptyText => write!(f, "cannot shape empty text"),
203            Self::InvalidByteRange { offset, text_len } => {
204                write!(
205                    f,
206                    "byte offset {offset} is out of bounds for text of length {text_len}"
207                )
208            }
209            Self::MidScalarBoundary { offset } => {
210                write!(
211                    f,
212                    "byte offset {offset} is inside a multi-byte UTF-8 scalar"
213                )
214            }
215            Self::FallbackRequired {
216                unshaped_byte_offset,
217            } => {
218                write!(
219                    f,
220                    "system fallback required at byte offset {unshaped_byte_offset} but fallback was disabled"
221                )
222            }
223            Self::PlatformUnavailable(msg) => write!(f, "platform shaper unavailable: {msg}"),
224            Self::AdapterError(msg) => write!(f, "platform adapter error: {msg}"),
225        }
226    }
227}
228
229impl std::error::Error for NativeShapingError {}
230
231/// Safely assemble raw platform output into an authoritative [`OwnedTextRun`].
232///
233/// Validates cluster boundaries, maps UTF-16 code unit positions, preserves fallback
234/// font identities per glyph and cluster, and establishes accurate hit-testing metrics.
235pub fn assemble_platform_run(
236    primary_context: TextRunContext,
237    output: PlatformShapedOutput,
238) -> Result<OwnedTextRun, NativeShapingError> {
239    if output.logical_text.is_empty() {
240        return Err(NativeShapingError::EmptyText);
241    }
242
243    let text = &output.logical_text;
244    let text_len = text.len();
245    let is_rtl = output.direction == Direction::RightToLeft;
246
247    let mut clusters: Vec<TextCluster> = Vec::new();
248    let mut run_glyphs: Vec<RunGlyph> = Vec::with_capacity(output.glyphs.len());
249
250    let mut cur_x = 0.0f32;
251    let mut i = 0;
252
253    while i < output.glyphs.len() {
254        let first = &output.glyphs[i];
255        let byte_start = first.cluster_byte_offset;
256        let byte_len = first.cluster_byte_len;
257        let byte_end = byte_start.saturating_add(byte_len);
258
259        // Validation 1: Range bounds
260        if byte_end > text_len {
261            return Err(NativeShapingError::InvalidByteRange {
262                offset: byte_end,
263                text_len,
264            });
265        }
266
267        // Validation 2: Scalar boundary check
268        if !text.is_char_boundary(byte_start) {
269            return Err(NativeShapingError::MidScalarBoundary { offset: byte_start });
270        }
271        if !text.is_char_boundary(byte_end) {
272            return Err(NativeShapingError::MidScalarBoundary { offset: byte_end });
273        }
274
275        let cluster_start_glyph = run_glyphs.len();
276        let cluster_x_start = cur_x;
277        let cluster_font_id = first.font_id;
278
279        // Group consecutive glyphs sharing this cluster byte range
280        while i < output.glyphs.len() {
281            let g = &output.glyphs[i];
282            if g.cluster_byte_offset != byte_start || g.cluster_byte_len != byte_len {
283                break;
284            }
285
286            run_glyphs.push(RunGlyph {
287                glyph_id: g.glyph_id,
288                font_id: g.font_id,
289                cluster_index: clusters.len(),
290                x_advance: g.x_advance,
291                y_advance: g.y_advance,
292                x_offset: g.x_offset,
293                y_offset: g.y_offset,
294            });
295
296            cur_x += g.x_advance;
297            i += 1;
298        }
299
300        let cluster_x_end = cur_x;
301
302        let utf16_start = match byte_to_utf16(text, byte_start) {
303            Some(o) => o,
304            None => return Err(NativeShapingError::MidScalarBoundary { offset: byte_start }),
305        };
306        let utf16_end = match byte_to_utf16(text, byte_end) {
307            Some(o) => o,
308            None => return Err(NativeShapingError::MidScalarBoundary { offset: byte_end }),
309        };
310
311        clusters.push(TextCluster {
312            cluster_index: clusters.len(),
313            byte_range: byte_start..byte_end,
314            utf16_range: utf16_start..utf16_end,
315            glyph_range: cluster_start_glyph..run_glyphs.len(),
316            x_start: cluster_x_start,
317            x_end: cluster_x_end,
318            font_id: cluster_font_id,
319        });
320    }
321
322    let total_advance = cur_x;
323
324    // Flip visual coordinate bounds if RTL
325    if is_rtl {
326        for cluster in &mut clusters {
327            let old_start = cluster.x_start;
328            let old_end = cluster.x_end;
329            cluster.x_start = (total_advance - old_end).max(0.0);
330            cluster.x_end = (total_advance - old_start).max(0.0);
331        }
332    }
333
334    // Honest origin classification: if any fallback face was involved, mark as SystemFallbackFace
335    let font_origin = if output.fallback_faces.is_empty() {
336        primary_context.font_origin
337    } else {
338        FontOrigin::SystemFallbackFace
339    };
340
341    let context = TextRunContext {
342        font_id: primary_context.font_id,
343        font_size: output.font_size,
344        script: primary_context.script,
345        language: primary_context.language,
346        direction: output.direction,
347        font_origin,
348    };
349
350    Ok(OwnedTextRun {
351        context,
352        logical_text: output.logical_text,
353        clusters,
354        glyphs: run_glyphs,
355        total_advance,
356    })
357}
358
359/// Abstract contract for safe native shaping routes.
360///
361/// Both real system bridges (CoreText on macOS) and headless simulated routes
362/// implement this trait.
363pub trait NativeShapingRoute: Send + Sync {
364    /// Return the route classification.
365    fn route_kind(&self) -> ShapingRouteKind;
366
367    /// Return declared capability parameters and operational limits.
368    fn capabilities(&self) -> ShapingRouteCapabilities;
369
370    /// Shape a slice of text into an owned text run.
371    fn shape_run(&self, req: &NativeShapingRequest<'_>)
372    -> Result<OwnedTextRun, NativeShapingError>;
373}
374
375/// Headless simulated native shaping route for test qualification.
376///
377/// Implements [`NativeShapingRoute`] entirely in safe Rust with zero foreign or
378/// platform runtime dependencies, enabling full qualification of fallback font
379/// preservation, context limits, and cluster mapping.
380pub struct SimulatedNativeRoute {
381    capabilities: ShapingRouteCapabilities,
382    fallback_rules: Vec<SimulatedFallbackRule>,
383}
384
385/// Rule defining simulated fallback behavior for a Unicode scalar range.
386#[derive(Clone, Debug)]
387pub struct SimulatedFallbackRule {
388    /// Unicode scalar range that triggers this fallback rule.
389    pub range: Range<u32>,
390    /// Fallback font identity.
391    pub fallback_face: FallbackFace,
392    /// Default glyph advance for this fallback font in em units.
393    pub advance_per_em: f32,
394}
395
396impl SimulatedNativeRoute {
397    /// Create a new simulated native route with default capabilities.
398    #[must_use]
399    pub fn new() -> Self {
400        Self {
401            capabilities: ShapingRouteCapabilities::default(),
402            fallback_rules: Vec::new(),
403        }
404    }
405
406    /// Create a simulated native route with explicit capabilities.
407    #[must_use]
408    pub fn with_capabilities(capabilities: ShapingRouteCapabilities) -> Self {
409        Self {
410            capabilities,
411            fallback_rules: Vec::new(),
412        }
413    }
414
415    /// Register a fallback rule for characters falling within `range`.
416    pub fn register_fallback(&mut self, rule: SimulatedFallbackRule) {
417        self.fallback_rules.push(rule);
418    }
419}
420
421impl Default for SimulatedNativeRoute {
422    fn default() -> Self {
423        Self::new()
424    }
425}
426
427impl NativeShapingRoute for SimulatedNativeRoute {
428    fn route_kind(&self) -> ShapingRouteKind {
429        self.capabilities.route_kind
430    }
431
432    fn capabilities(&self) -> ShapingRouteCapabilities {
433        self.capabilities.clone()
434    }
435
436    fn shape_run(
437        &self,
438        req: &NativeShapingRequest<'_>,
439    ) -> Result<OwnedTextRun, NativeShapingError> {
440        // Enforce empty text guard
441        if req.text.is_empty() {
442            return Err(NativeShapingError::EmptyText);
443        }
444
445        // Enforce context work budget (Plan §10.9)
446        if req.text.len() > self.capabilities.max_paragraph_bytes {
447            return Err(NativeShapingError::ContextBudgetExceeded {
448                length: req.text.len(),
449                max_allowed: self.capabilities.max_paragraph_bytes,
450            });
451        }
452
453        let mut platform_glyphs: Vec<PlatformRunGlyph> = Vec::new();
454        let mut used_fallbacks: Vec<FallbackFace> = Vec::new();
455
456        let primary_advance = (req.font_size * 0.5).max(1.0); // 0.5 em default advance
457
458        for (byte_idx, ch) in req.text.char_indices() {
459            let cp = ch as u32;
460            let ch_len = ch.len_utf8();
461
462            // Check if this character matches any registered fallback rule
463            let matched_fallback = self.fallback_rules.iter().find(|r| r.range.contains(&cp));
464
465            if let Some(rule) = matched_fallback {
466                if !req.allow_system_fallback {
467                    return Err(NativeShapingError::FallbackRequired {
468                        unshaped_byte_offset: byte_idx,
469                    });
470                }
471
472                if !used_fallbacks
473                    .iter()
474                    .any(|f| f.font_id == rule.fallback_face.font_id)
475                {
476                    used_fallbacks.push(rule.fallback_face.clone());
477                }
478
479                let adv = req.font_size * rule.advance_per_em;
480                let glyph_id = (cp % 1000) as u16;
481
482                platform_glyphs.push(PlatformRunGlyph {
483                    glyph_id,
484                    font_id: rule.fallback_face.font_id,
485                    cluster_byte_offset: byte_idx,
486                    cluster_byte_len: ch_len,
487                    x_advance: adv,
488                    y_advance: 0.0,
489                    x_offset: 0.0,
490                    y_offset: 0.0,
491                });
492            } else {
493                // Primary font mapping
494                let glyph_id = (cp % 500) as u16;
495                platform_glyphs.push(PlatformRunGlyph {
496                    glyph_id,
497                    font_id: req.primary_font_id,
498                    cluster_byte_offset: byte_idx,
499                    cluster_byte_len: ch_len,
500                    x_advance: primary_advance,
501                    y_advance: 0.0,
502                    x_offset: 0.0,
503                    y_offset: 0.0,
504                });
505            }
506        }
507
508        let output = PlatformShapedOutput {
509            logical_text: req.text.to_string(),
510            direction: req.direction,
511            font_size: req.font_size,
512            glyphs: platform_glyphs,
513            fallback_faces: used_fallbacks,
514            route_kind: self.capabilities.route_kind,
515        };
516
517        let primary_context = TextRunContext {
518            font_id: req.primary_font_id,
519            font_size: req.font_size,
520            script: req.script,
521            language: req.language,
522            direction: req.direction,
523            font_origin: FontOrigin::BundledFace,
524        };
525
526        assemble_platform_run(primary_context, output)
527    }
528}