Skip to main content

fmd_font/
macos.rs

1//! Optional safe Mac font adapter contract and CoreText/CoreGraphics bridge interface (FCB-075.B).
2//!
3//! Implements Plan §13.2, §13.7, §10.9, and §27.5:
4//! - Owned CoreText/CoreGraphics conversions through a safe platform bridge without
5//!   polluting the base engine or introducing third-party dependencies.
6//! - Strict `#![forbid(unsafe_code)]`: low-level ABI calls remain solely in the system bridge.
7//! - Bounded foreign calls: context work budget (`max_paragraph_bytes`) prevents giant
8//!   main-thread shapes from freezing the UI.
9//! - Preserves exact producing font identities (`FallbackFace`, `FontId`) for all glyphs.
10//! - Converts native BGRA / RGBA color emoji and bitmap glyphs with checked dimension and byte caps.
11//! - Provides meaningful fallback placeholders for missing or unsupported glyph rasters.
12//! - Headless / simulated bridge driver for hermetic qualification across platforms.
13
14#![forbid(unsafe_code)]
15
16use crate::native_route::{
17    FallbackFace, NativeShapingError, NativeShapingRequest, NativeShapingRoute, PlatformRunGlyph,
18    PlatformShapedOutput, ShapingRouteCapabilities, ShapingRouteKind, assemble_platform_run,
19};
20use crate::shaping::Direction;
21use crate::text_run::{FontId, FontOrigin, OwnedTextRun, TextRunContext, utf16_to_byte};
22use std::fmt;
23
24/// Maximum allowable paragraph byte length for a single Mac native shaping call (Plan §10.9).
25pub const DEFAULT_MAC_CONTEXT_BUDGET: usize = 65_536;
26
27/// Maximum allowable dimension (width or height) for a rasterized color glyph.
28pub const MAX_MAC_RASTER_DIMENSION: u32 = 1024;
29
30/// Maximum allowable memory size for a rasterized color glyph (4 MiB).
31pub const MAX_MAC_RASTER_BYTES: usize = 4 * 1024 * 1024;
32
33/// Configuration options for the Mac font adapter.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct MacFontAdapterConfig {
36    /// Maximum byte size of text passed to the native shaper in a single call.
37    pub max_paragraph_bytes: usize,
38    /// Whether system fallback font substitution is enabled.
39    pub allow_fallback_fonts: bool,
40    /// Whether color emoji and bitmap rasterization is supported.
41    pub supports_color_emoji: bool,
42    /// Maximum glyph raster dimension.
43    pub raster_dimension_cap: u32,
44    /// Maximum glyph raster memory buffer size.
45    pub raster_bytes_cap: usize,
46}
47
48impl Default for MacFontAdapterConfig {
49    fn default() -> Self {
50        Self {
51            max_paragraph_bytes: DEFAULT_MAC_CONTEXT_BUDGET,
52            allow_fallback_fonts: true,
53            supports_color_emoji: true,
54            raster_dimension_cap: MAX_MAC_RASTER_DIMENSION,
55            raster_bytes_cap: MAX_MAC_RASTER_BYTES,
56        }
57    }
58}
59
60/// Raw glyph record emitted by the native CoreText bridge.
61#[derive(Clone, Debug, PartialEq)]
62pub struct RawCoreTextGlyph {
63    /// OpenType glyph index produced by CoreText.
64    pub glyph_id: u16,
65    /// Start offset in native UTF-16 code units.
66    pub string_index_utf16: usize,
67    /// Length in native UTF-16 code units covered by this glyph cluster.
68    pub string_length_utf16: usize,
69    /// PostScript name of the font face chosen by CoreText.
70    pub font_postscript_name: String,
71    /// Family name of the font face.
72    pub font_family_name: String,
73    /// Horizontal advance in points.
74    pub x_advance: f32,
75    /// Vertical advance in points.
76    pub y_advance: f32,
77    /// Horizontal placement offset in points.
78    pub x_offset: f32,
79    /// Vertical placement offset in points.
80    pub y_offset: f32,
81    /// Whether this glyph belongs to a color emoji or bitmap face.
82    pub is_color_emoji: bool,
83}
84
85/// Raw line layout record emitted by the native CoreText bridge.
86#[derive(Clone, Debug, PartialEq)]
87pub struct RawCoreTextLine {
88    /// Sequence of positioned glyphs.
89    pub glyphs: Vec<RawCoreTextGlyph>,
90    /// Whether this line was shaped as right-to-left.
91    pub is_rtl: bool,
92    /// Total visual advance width in points.
93    pub total_advance: f32,
94}
95
96/// Raw raster image payload emitted by the native CoreGraphics bridge.
97#[derive(Clone, Debug, PartialEq)]
98pub struct RawCoreGraphicsRaster {
99    /// Raster image width in pixels.
100    pub width: u32,
101    /// Raster image height in pixels.
102    pub height: u32,
103    /// Pixel buffer data.
104    pub pixels: Vec<u8>,
105    /// Whether pixel buffer is BGRA8 (typical for Apple CoreGraphics) vs RGBA8.
106    pub is_bgra: bool,
107    /// Horizontal bearing in layout points.
108    pub bearing_x: f32,
109    /// Vertical bearing in layout points.
110    pub bearing_y: f32,
111    /// Advance width in layout points.
112    pub advance_width: f32,
113}
114
115/// Errors occurring during Mac font adapter operations.
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub enum MacFontAdapterError {
118    /// Text exceeds the bounded work context budget (Plan §10.9).
119    ContextBudgetExceeded { length: usize, max_allowed: usize },
120    /// Input text was empty.
121    EmptyText,
122    /// UTF-16 code unit offset from CoreText could not be mapped to UTF-8 bytes.
123    InvalidUtf16Offset { utf16_offset: usize },
124    /// Mapped byte offset fell inside a multi-byte UTF-8 scalar.
125    MidScalarBoundary { byte_offset: usize },
126    /// Native bridge failed or framework is unavailable.
127    BridgeUnavailable(String),
128    /// Foreign call execution failed.
129    ForeignCallFailed(String),
130    /// Raster dimension exceeded configured maximum.
131    RasterDimensionTooLarge { dimension: u32, max_allowed: u32 },
132    /// Raster buffer memory size exceeded configured maximum.
133    RasterBytesTooLarge { bytes: usize, max_allowed: usize },
134    /// Raster buffer length does not match expected width * height * 4.
135    RasterBufferMismatch { expected: usize, actual: usize },
136    /// Missing required fallback face when system fallback is disabled.
137    FallbackDisabled { unshaped_byte_offset: usize },
138}
139
140impl fmt::Display for MacFontAdapterError {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        match self {
143            Self::ContextBudgetExceeded {
144                length,
145                max_allowed,
146            } => {
147                write!(
148                    f,
149                    "CoreText context budget exceeded: {length} bytes > {max_allowed} max"
150                )
151            }
152            Self::EmptyText => write!(f, "cannot shape empty text with CoreText"),
153            Self::InvalidUtf16Offset { utf16_offset } => {
154                write!(f, "invalid UTF-16 code unit offset: {utf16_offset}")
155            }
156            Self::MidScalarBoundary { byte_offset } => {
157                write!(
158                    f,
159                    "mapped byte offset {byte_offset} is inside a multi-byte UTF-8 scalar"
160                )
161            }
162            Self::BridgeUnavailable(msg) => write!(f, "Mac bridge unavailable: {msg}"),
163            Self::ForeignCallFailed(msg) => write!(f, "foreign CoreText call failed: {msg}"),
164            Self::RasterDimensionTooLarge {
165                dimension,
166                max_allowed,
167            } => {
168                write!(
169                    f,
170                    "raster dimension {dimension} exceeds maximum {max_allowed}"
171                )
172            }
173            Self::RasterBytesTooLarge { bytes, max_allowed } => {
174                write!(f, "raster bytes {bytes} exceeds maximum {max_allowed}")
175            }
176            Self::RasterBufferMismatch { expected, actual } => {
177                write!(
178                    f,
179                    "raster buffer mismatch: expected {expected} bytes, got {actual}"
180                )
181            }
182            Self::FallbackDisabled {
183                unshaped_byte_offset,
184            } => {
185                write!(
186                    f,
187                    "system fallback required at byte offset {unshaped_byte_offset} but disabled"
188                )
189            }
190        }
191    }
192}
193
194impl std::error::Error for MacFontAdapterError {}
195
196impl From<MacFontAdapterError> for NativeShapingError {
197    fn from(err: MacFontAdapterError) -> Self {
198        match err {
199            MacFontAdapterError::ContextBudgetExceeded {
200                length,
201                max_allowed,
202            } => Self::ContextBudgetExceeded {
203                length,
204                max_allowed,
205            },
206            MacFontAdapterError::EmptyText => Self::EmptyText,
207            MacFontAdapterError::InvalidUtf16Offset { utf16_offset } => Self::InvalidByteRange {
208                offset: utf16_offset,
209                text_len: 0,
210            },
211            MacFontAdapterError::MidScalarBoundary { byte_offset } => Self::MidScalarBoundary {
212                offset: byte_offset,
213            },
214            MacFontAdapterError::FallbackDisabled {
215                unshaped_byte_offset,
216            } => Self::FallbackRequired {
217                unshaped_byte_offset,
218            },
219            MacFontAdapterError::BridgeUnavailable(msg) => Self::PlatformUnavailable(msg),
220            MacFontAdapterError::ForeignCallFailed(msg) => Self::AdapterError(msg),
221            MacFontAdapterError::RasterDimensionTooLarge { dimension, .. } => {
222                Self::AdapterError(format!("raster dimension {dimension} too large"))
223            }
224            MacFontAdapterError::RasterBytesTooLarge { bytes, .. } => {
225                Self::AdapterError(format!("raster bytes {bytes} too large"))
226            }
227            MacFontAdapterError::RasterBufferMismatch { expected, actual } => Self::AdapterError(
228                format!("raster buffer mismatch: expected {expected}, got {actual}"),
229            ),
230        }
231    }
232}
233
234/// Abstract contract for safe macOS platform font bridging.
235///
236/// Both real AppKit/CoreText ABI calls and hermetic headless test simulators
237/// implement this driver interface.
238pub trait MacBridgeDriver: Send + Sync {
239    /// Request the native platform shaper to lay out a single line of text.
240    fn shape_line(
241        &self,
242        text: &str,
243        font_family: &str,
244        font_size: f32,
245        is_rtl: bool,
246    ) -> Result<RawCoreTextLine, MacFontAdapterError>;
247
248    /// Request the native platform rasterizer to rasterize a specific glyph.
249    fn rasterize_glyph(
250        &self,
251        font_postscript_name: &str,
252        glyph_id: u16,
253        font_size_px: f32,
254    ) -> Result<Option<RawCoreGraphicsRaster>, MacFontAdapterError>;
255}
256
257/// Safe platform font adapter translating CoreText / CoreGraphics records into shared text runs.
258pub struct MacFontAdapter<D: MacBridgeDriver> {
259    driver: D,
260    config: MacFontAdapterConfig,
261}
262
263impl<D: MacBridgeDriver> MacFontAdapter<D> {
264    /// Create a new Mac font adapter with default configuration.
265    #[must_use]
266    pub fn new(driver: D) -> Self {
267        Self {
268            driver,
269            config: MacFontAdapterConfig::default(),
270        }
271    }
272
273    /// Create a new Mac font adapter with explicit configuration.
274    #[must_use]
275    pub fn with_config(driver: D, config: MacFontAdapterConfig) -> Self {
276        Self { driver, config }
277    }
278
279    /// Retrieve a reference to the active configuration.
280    #[must_use]
281    pub fn config(&self) -> &MacFontAdapterConfig {
282        &self.config
283    }
284
285    /// Retrieve a reference to the underlying driver.
286    #[must_use]
287    pub fn driver(&self) -> &D {
288        &self.driver
289    }
290
291    /// Rasterize a glyph through CoreGraphics, returning validated RGBA8 pixels.
292    pub fn get_glyph_rgba_raster(
293        &self,
294        font_postscript_name: &str,
295        glyph_id: u16,
296        font_size_px: f32,
297    ) -> Result<Option<RawCoreGraphicsRaster>, MacFontAdapterError> {
298        let raw_opt = self
299            .driver
300            .rasterize_glyph(font_postscript_name, glyph_id, font_size_px)?;
301        let mut raw = match raw_opt {
302            Some(r) => r,
303            None => return Ok(None),
304        };
305
306        if raw.width > self.config.raster_dimension_cap {
307            return Err(MacFontAdapterError::RasterDimensionTooLarge {
308                dimension: raw.width,
309                max_allowed: self.config.raster_dimension_cap,
310            });
311        }
312        if raw.height > self.config.raster_dimension_cap {
313            return Err(MacFontAdapterError::RasterDimensionTooLarge {
314                dimension: raw.height,
315                max_allowed: self.config.raster_dimension_cap,
316            });
317        }
318        if raw.pixels.len() > self.config.raster_bytes_cap {
319            return Err(MacFontAdapterError::RasterBytesTooLarge {
320                bytes: raw.pixels.len(),
321                max_allowed: self.config.raster_bytes_cap,
322            });
323        }
324
325        let expected_bytes = match (raw.width as usize)
326            .checked_mul(raw.height as usize)
327            .and_then(|px| px.checked_mul(4))
328        {
329            Some(exp) => exp,
330            None => {
331                return Err(MacFontAdapterError::RasterBytesTooLarge {
332                    bytes: usize::MAX,
333                    max_allowed: self.config.raster_bytes_cap,
334                });
335            }
336        };
337
338        if raw.pixels.len() != expected_bytes {
339            return Err(MacFontAdapterError::RasterBufferMismatch {
340                expected: expected_bytes,
341                actual: raw.pixels.len(),
342            });
343        }
344
345        // Swizzle BGRA -> RGBA in place if needed
346        if raw.is_bgra {
347            for chunk in raw.pixels.chunks_exact_mut(4) {
348                chunk.swap(0, 2); // B and R swapped
349            }
350            raw.is_bgra = false;
351        }
352
353        Ok(Some(raw))
354    }
355}
356
357impl<D: MacBridgeDriver> NativeShapingRoute for MacFontAdapter<D> {
358    fn route_kind(&self) -> ShapingRouteKind {
359        ShapingRouteKind::SystemPlatform
360    }
361
362    fn capabilities(&self) -> ShapingRouteCapabilities {
363        ShapingRouteCapabilities {
364            route_kind: ShapingRouteKind::SystemPlatform,
365            is_pixel_deterministic: false,
366            supports_fallback_fonts: self.config.allow_fallback_fonts,
367            supports_color_emoji: self.config.supports_color_emoji,
368            supports_bidi: true,
369            max_paragraph_bytes: self.config.max_paragraph_bytes,
370        }
371    }
372
373    fn shape_run(
374        &self,
375        req: &NativeShapingRequest<'_>,
376    ) -> Result<OwnedTextRun, NativeShapingError> {
377        if req.text.is_empty() {
378            return Err(NativeShapingError::EmptyText);
379        }
380
381        // Enforce work context budget before foreign call (Plan §10.9)
382        if req.text.len() > self.config.max_paragraph_bytes {
383            return Err(NativeShapingError::ContextBudgetExceeded {
384                length: req.text.len(),
385                max_allowed: self.config.max_paragraph_bytes,
386            });
387        }
388
389        let is_rtl = req.direction == Direction::RightToLeft;
390        let line = self
391            .driver
392            .shape_line(req.text, "SystemFont", req.font_size, is_rtl)?;
393
394        let mut platform_glyphs: Vec<PlatformRunGlyph> = Vec::with_capacity(line.glyphs.len());
395        let mut used_fallbacks: Vec<FallbackFace> = Vec::new();
396
397        for g in &line.glyphs {
398            // Map native UTF-16 code unit range back to logical UTF-8 bytes
399            let byte_start = match utf16_to_byte(req.text, g.string_index_utf16) {
400                Some(b) => b,
401                None => {
402                    return Err(NativeShapingError::InvalidByteRange {
403                        offset: g.string_index_utf16,
404                        text_len: req.text.len(),
405                    });
406                }
407            };
408
409            let utf16_end = g.string_index_utf16.saturating_add(g.string_length_utf16);
410            let byte_end = match utf16_to_byte(req.text, utf16_end) {
411                Some(b) => b,
412                None => {
413                    return Err(NativeShapingError::InvalidByteRange {
414                        offset: utf16_end,
415                        text_len: req.text.len(),
416                    });
417                }
418            };
419
420            if byte_end < byte_start {
421                return Err(NativeShapingError::InvalidByteRange {
422                    offset: byte_end,
423                    text_len: req.text.len(),
424                });
425            }
426
427            let byte_len = byte_end - byte_start;
428
429            // Determine if this glyph was produced by a fallback face
430            let is_fallback =
431                !g.font_postscript_name.is_empty() && g.font_postscript_name != "SystemFont";
432
433            let font_id = if is_fallback {
434                if !req.allow_system_fallback {
435                    return Err(NativeShapingError::FallbackRequired {
436                        unshaped_byte_offset: byte_start,
437                    });
438                }
439
440                // Compute deterministic FontId from postscript name
441                let mut hash = 0xcbf29ce484222325u64;
442                for &b in g.font_postscript_name.as_bytes() {
443                    hash = (hash ^ u64::from(b)).wrapping_mul(0x100000001b3);
444                }
445                let fid = FontId::new(hash);
446
447                if !used_fallbacks.iter().any(|f| f.font_id == fid) {
448                    used_fallbacks.push(FallbackFace {
449                        font_id: fid,
450                        family_name: g.font_family_name.clone(),
451                        postscript_name: g.font_postscript_name.clone(),
452                        units_per_em: 1000,
453                        is_color_emoji: g.is_color_emoji,
454                    });
455                }
456                fid
457            } else {
458                req.primary_font_id
459            };
460
461            platform_glyphs.push(PlatformRunGlyph {
462                glyph_id: g.glyph_id,
463                font_id,
464                cluster_byte_offset: byte_start,
465                cluster_byte_len: byte_len,
466                x_advance: g.x_advance,
467                y_advance: g.y_advance,
468                x_offset: g.x_offset,
469                y_offset: g.y_offset,
470            });
471        }
472
473        let output = PlatformShapedOutput {
474            logical_text: req.text.to_string(),
475            direction: req.direction,
476            font_size: req.font_size,
477            glyphs: platform_glyphs,
478            fallback_faces: used_fallbacks,
479            route_kind: ShapingRouteKind::SystemPlatform,
480        };
481
482        let primary_context = TextRunContext {
483            font_id: req.primary_font_id,
484            font_size: req.font_size,
485            script: req.script,
486            language: req.language,
487            direction: req.direction,
488            font_origin: FontOrigin::BundledFace,
489        };
490
491        assemble_platform_run(primary_context, output)
492    }
493}
494
495/// Hermetic simulated Mac bridge driver for tests and non-Mac host environments.
496#[derive(Clone, Debug, Default)]
497pub struct SimulatedMacBridge {
498    fail_bridge: bool,
499}
500
501impl SimulatedMacBridge {
502    /// Create a new simulated bridge driver.
503    #[must_use]
504    pub fn new() -> Self {
505        Self { fail_bridge: false }
506    }
507
508    /// Create a simulated bridge driver that fails simulated foreign calls (for error testing).
509    #[must_use]
510    pub fn new_failing() -> Self {
511        Self { fail_bridge: true }
512    }
513}
514
515impl MacBridgeDriver for SimulatedMacBridge {
516    fn shape_line(
517        &self,
518        text: &str,
519        _font_family: &str,
520        font_size: f32,
521        is_rtl: bool,
522    ) -> Result<RawCoreTextLine, MacFontAdapterError> {
523        if self.fail_bridge {
524            return Err(MacFontAdapterError::ForeignCallFailed(
525                "simulated CoreText foreign call abort".to_string(),
526            ));
527        }
528
529        let mut glyphs = Vec::new();
530        let mut cur_utf16 = 0usize;
531        let mut total_advance = 0.0f32;
532
533        for ch in text.chars() {
534            let cp = ch as u32;
535            let utf16_len = ch.len_utf16();
536
537            // Detect script for simulated CoreText font substitution
538            let (ps_name, fam_name, is_color, adv_scale) =
539                if (0x2E80..=0x9FFF).contains(&cp) || (0xAC00..=0xD7AF).contains(&cp) {
540                    ("PingFangSC-Regular", "PingFang SC", false, 1.0)
541                } else if (0x0590..=0x08FF).contains(&cp) || (0xFB1D..=0xFEFF).contains(&cp) {
542                    ("GeezaPro", "Geeza Pro", false, 0.6)
543                } else if (0x1F300..=0x1FAFF).contains(&cp) || (0x2600..=0x27BF).contains(&cp) {
544                    ("AppleColorEmoji", "Apple Color Emoji", true, 1.0)
545                } else {
546                    ("SystemFont", "SystemFont", false, 0.5)
547                };
548
549            let x_adv = font_size * adv_scale;
550            let glyph_id = (cp % 5000) as u16;
551
552            glyphs.push(RawCoreTextGlyph {
553                glyph_id,
554                string_index_utf16: cur_utf16,
555                string_length_utf16: utf16_len,
556                font_postscript_name: ps_name.to_string(),
557                font_family_name: fam_name.to_string(),
558                x_advance: x_adv,
559                y_advance: 0.0,
560                x_offset: 0.0,
561                y_offset: 0.0,
562                is_color_emoji: is_color,
563            });
564
565            cur_utf16 += utf16_len;
566            total_advance += x_adv;
567        }
568
569        Ok(RawCoreTextLine {
570            glyphs,
571            is_rtl,
572            total_advance,
573        })
574    }
575
576    fn rasterize_glyph(
577        &self,
578        font_postscript_name: &str,
579        glyph_id: u16,
580        font_size_px: f32,
581    ) -> Result<Option<RawCoreGraphicsRaster>, MacFontAdapterError> {
582        if self.fail_bridge {
583            return Err(MacFontAdapterError::ForeignCallFailed(
584                "simulated CoreGraphics foreign call abort".to_string(),
585            ));
586        }
587
588        if font_postscript_name == "AppleColorEmoji" || glyph_id == 777 {
589            let size = (font_size_px as u32).clamp(8, 64);
590            let len = (size as usize) * (size as usize) * 4;
591            let mut pixels = vec![0u8; len];
592            // Simulate BGRA pattern
593            for chunk in pixels.chunks_exact_mut(4) {
594                chunk[0] = 255; // B
595                chunk[1] = 200; // G
596                chunk[2] = 50; // R
597                chunk[3] = 255; // A
598            }
599            Ok(Some(RawCoreGraphicsRaster {
600                width: size,
601                height: size,
602                pixels,
603                is_bgra: true,
604                bearing_x: 0.0,
605                bearing_y: font_size_px,
606                advance_width: font_size_px,
607            }))
608        } else {
609            Ok(None)
610        }
611    }
612}