diff --git a/Cargo.lock b/Cargo.lock index dca2dd2484..da91dbc52e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4150,6 +4150,9 @@ dependencies = [ "neomacs-layout-engine", "neomacs-test-fonts", "neomacs-video", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-text", "png", "pollster", "resvg", @@ -4776,11 +4779,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.1", + "block2", "dispatch2", "libc", "objc2", "objc2-core-foundation", "objc2-io-surface", + "objc2-metal", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bd9561c7d2..58db8d17ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -342,6 +342,7 @@ gstreamer-allocators = "0.25" gstreamer-pbutils = "0.25" objc2-av-foundation = "0.3.2" objc2-core-foundation = "0.3.2" +objc2-core-graphics = "0.3.2" objc2-core-text = "0.3.2" objc2-core-media = "0.3.2" objc2-core-video = "0.3.2" diff --git a/crates/neomacs-layout-engine/src/font/subpixel.rs b/crates/neomacs-layout-engine/src/font/subpixel.rs index 6067ecf81e..a9bf0bfc84 100644 --- a/crates/neomacs-layout-engine/src/font/subpixel.rs +++ b/crates/neomacs-layout-engine/src/font/subpixel.rs @@ -26,8 +26,19 @@ pub fn default_subpixel_order() -> FontconfigSubpixelOrder { { crate::font::fontconfig::default_subpixel_order() } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "macos")] + { + // macOS text uses grayscale antialiasing. Unknown is an LCD/RGB + // fallback elsewhere; using it here enables Swash's three-channel + // masks even on Retina displays. + FontconfigSubpixelOrder::None + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] { FontconfigSubpixelOrder::Unknown } } + +#[cfg(all(test, target_os = "macos"))] +#[path = "tests/subpixel_test.rs"] +mod subpixel_test; diff --git a/crates/neomacs-layout-engine/src/font/tests/subpixel_test.rs b/crates/neomacs-layout-engine/src/font/tests/subpixel_test.rs new file mode 100644 index 0000000000..2264e040be --- /dev/null +++ b/crates/neomacs-layout-engine/src/font/tests/subpixel_test.rs @@ -0,0 +1,4 @@ +#[test] +fn default_rasterization_does_not_assume_an_rgb_lcd() { + assert!(!super::default_subpixel_order().allows_horizontal_subpixel()); +} diff --git a/crates/neomacs-renderer-wgpu/Cargo.toml b/crates/neomacs-renderer-wgpu/Cargo.toml index aa240adda1..1a5ece2d33 100644 --- a/crates/neomacs-renderer-wgpu/Cargo.toml +++ b/crates/neomacs-renderer-wgpu/Cargo.toml @@ -42,6 +42,11 @@ neomacs-font-materializer.workspace = true neomacs-layout-engine.workspace = true neomacs-video = { workspace = true, optional = true } +[target.'cfg(target_os = "macos")'.dependencies] +objc2-core-foundation.workspace = true +objc2-core-graphics.workspace = true +objc2-core-text.workspace = true + [dev-dependencies] neomacs-infra.workspace = true neomacs-test-fonts.workspace = true diff --git a/crates/neomacs-renderer-wgpu/src/glyph_atlas/macos.rs b/crates/neomacs-renderer-wgpu/src/glyph_atlas/macos.rs new file mode 100644 index 0000000000..d5f34dd2be --- /dev/null +++ b/crates/neomacs-renderer-wgpu/src/glyph_atlas/macos.rs @@ -0,0 +1,275 @@ +//! Native outline masks, replaying the exact font bytes already pinned by layout. +//! +//! Core Graphics font smoothing affects stroke weight even for grayscale text. +//! Swash's outline coverage is not a substitute for that macOS drawing policy. +//! Keep the native objects on the atlas owner and upload ordinary grayscale +//! masks, so WGPU composition and layout advances remain shared. + +use super::{RasterContent, RasterGlyphImage}; +use cosmic_text::{CacheKey, CacheKeyFlags, Font}; +use objc2_core_foundation::{CFArray, CFData, CFRetained, CGPoint}; +use objc2_core_graphics::{ + CGBitmapContextCreate, CGColorSpace, CGContext, CGImageAlphaInfo, CGImageByteOrderInfo, +}; +use objc2_core_text::{ + CTFont, CTFontDescriptor, CTFontManagerCreateFontDescriptorsFromData, CTFontOrientation, +}; +use rustc_hash::{FxHashMap, FxHasher}; +use std::hash::Hasher; +use std::ptr::{self, NonNull}; +use swash::TableProvider; + +#[derive(Default)] +pub(super) struct NativeRasterizer { + // Independently pinned collection faces can map the same file at different + // addresses. Intern by content, comparing bytes within hash buckets so a + // collision can never substitute another font. `clear()` drops the backing + // together with the fonts, which retain their own reference meanwhile. + backing: FxHashMap>>, + // fontdb IDs are generation-local, so `clear()` drops these caches when + // the font system is replaced. Sizes are physical font sizes (the backing + // scale is already multiplied in), so a DPI change adds entries instead + // of invalidating existing ones. Failures are cached alongside fonts. + sources: FxHashMap>>, + fonts: FxHashMap<(fontdb::ID, u32), CFRetained>, +} + +impl NativeRasterizer { + pub(super) fn clear(&mut self) { + self.fonts.clear(); + self.sources.clear(); + self.backing.clear(); + } + + pub(super) fn rasterize( + &mut self, + source: &Font, + postscript_name: &str, + key: CacheKey, + ) -> Option { + let face = source.as_swash(); + // Keep variable instances, synthetic styles and embedded strikes on + // their existing paths until the native adapter can replay their full + // contract. Color artwork has already been handled by glyph_image. + if face.variations().next().is_some() + || key.flags.contains(CacheKeyFlags::FAKE_ITALIC) + || key.flags.contains(CacheKeyFlags::PIXEL_FONT) + || [*b"sbix", *b"CBDT", *b"EBDT", *b"COLR", *b"SVG "] + .iter() + .any(|tag| face.table_by_tag(u32::from_be_bytes(*tag)).is_some()) + { + return None; + } + let size = f32::from_bits(key.font_size_bits); + if !size.is_finite() || size <= 0.0 { + return None; + } + // Hash/compare only when a face is first opened, never per glyph or + // size. Separate mappings of identical bytes share the native copy. + let backing = &mut self.backing; + let base = self + .sources + .entry(key.font_id) + .or_insert_with(|| { + let data = intern_backing(backing, source.data()); + open_exact_font(&data, postscript_name, 1.0) + }) + .as_ref()?; + let font = self + .fonts + .entry((key.font_id, key.font_size_bits)) + .or_insert_with(|| { + // SAFETY: null preserves the base matrix; no attributes are + // overridden, so the copied font retains the same exact source. + unsafe { base.copy_with_attributes(size.into(), ptr::null(), None) } + }); + rasterize(font, key) + } +} + +fn backing_hash(bytes: &[u8]) -> u64 { + let mut hash = FxHasher::default(); + hash.write(bytes); + hash.finish() +} + +fn intern_backing( + backing: &mut FxHashMap>>, + bytes: &[u8], +) -> CFRetained { + let candidates = backing.entry(backing_hash(bytes)).or_default(); + for data in candidates.iter() { + // SAFETY: these are immutable CFData copies created below; no mutable + // alias is exposed or passed to CoreText. + if unsafe { data.as_bytes_unchecked() } == bytes { + return data.clone(); + } + } + let data = CFData::from_bytes(bytes); + candidates.push(data.clone()); + data +} + +fn open_exact_font(data: &CFData, postscript_name: &str, size: f32) -> Option> { + // SAFETY: CoreText returns an array of CTFontDescriptor objects, retained + // here and by each typed get. Descriptors refer to these bytes, not to a + // system family lookup (which could select another installed font). + let descriptors = unsafe { + CFRetained::cast_unchecked::>( + CTFontManagerCreateFontDescriptorsFromData(data), + ) + }; + for index in 0..descriptors.len() { + let descriptor = descriptors.get(index)?; + // SAFETY: null requests the identity transform; the descriptor is live. + let font = unsafe { CTFont::with_font_descriptor(&descriptor, size.into(), ptr::null()) }; + // Select the exact collection member. Never substitute a best-match + // family when the selected face is missing from these bytes. + if unsafe { font.post_script_name() }.to_string() == postscript_name { + return Some(font); + } + } + None +} + +fn rasterize(font: &CTFont, key: CacheKey) -> Option { + let mut glyph = key.glyph_id; + // SAFETY: one live glyph, no per-glyph output array, count is one. + let bounds = unsafe { + font.bounding_rects_for_glyphs( + CTFontOrientation::Horizontal, + NonNull::from(&mut glyph), + ptr::null_mut(), + 1, + ) + }; + let x_offset = f64::from(key.x_bin.as_float()); + // Match the Swash placement path this replaces: a positive atlas y bin + // shifts ink upwards, and Core Graphics y also increases upwards. + let y_offset = f64::from(key.y_bin.as_float()); + if bounds.size.width <= 0.0 || bounds.size.height <= 0.0 { + return Some(RasterGlyphImage { + left: 0, + top: 0, + width: 0, + height: 0, + data: Vec::new(), + content: RasterContent::Mask, + }); + } + // Leave room for native smoothing beyond the mathematical outline bounds. + let left = (bounds.origin.x + x_offset).floor() - 2.0; + let bottom = (bounds.origin.y + y_offset).floor() - 2.0; + let right = (bounds.origin.x + bounds.size.width + x_offset).ceil() + 2.0; + let top = (bounds.origin.y + bounds.size.height + y_offset).ceil() + 2.0; + let width = right - left; + let height = top - bottom; + if ![left, bottom, right, top, width, height] + .iter() + .all(|v| v.is_finite()) + || width <= 0.0 + || height <= 0.0 + || width > 8192.0 + || height > 8192.0 + || left < i32::MIN as f64 + || left > i32::MAX as f64 + || top < i32::MIN as f64 + || top > i32::MAX as f64 + { + return None; + } + let width = width as usize; + let height = height as usize; + // A corrupt or oversized font metric must not turn one glyph into a + // multi-hundred-megabyte allocation: 4 Mi pixels is 16 MiB of RGBA. + let pixel_count = width.checked_mul(height)?; + if pixel_count > 4 * 1024 * 1024 { + return None; + } + let mut pixels = vec![0u8; pixel_count.checked_mul(4)?]; + let colorspace = CGColorSpace::new_device_rgb()?; + // An opaque RGB bitmap enables native font smoothing. Alpha-only contexts + // disable that treatment and recreate the thin strokes this adapter fixes. + // SAFETY: pixels owns width*height*4 writable bytes and outlives the context. + let context = unsafe { + CGBitmapContextCreate( + pixels.as_mut_ptr().cast(), + width, + height, + 8, + width * 4, + Some(&colorspace), + CGImageByteOrderInfo::Order32Big.0 | CGImageAlphaInfo::NoneSkipLast.0, + ) + }?; + CGContext::set_should_antialias(Some(&context), true); + CGContext::set_allows_font_smoothing(Some(&context), true); + CGContext::set_should_smooth_fonts(Some(&context), true); + CGContext::set_allows_font_subpixel_positioning(Some(&context), true); + CGContext::set_should_subpixel_position_fonts(Some(&context), true); + CGContext::set_should_subpixel_quantize_fonts(Some(&context), false); + CGContext::set_rgb_fill_color(Some(&context), 1.0, 1.0, 1.0, 1.0); + // Match GNU macfont's ordinary-outline drawing operation. The font size + // already includes backing scale; do not scale the context a second time. + let graphics_font = unsafe { font.graphics_font(ptr::null_mut()) }; + CGContext::set_font(Some(&context), Some(&graphics_font)); + CGContext::set_font_size(Some(&context), f32::from_bits(key.font_size_bits).into()); + let position = CGPoint { + x: x_offset - left, + y: y_offset - bottom, + }; + // SAFETY: both pointers address one live element for this synchronous call. + unsafe { CGContext::show_glyphs_at_positions(Some(&context), &glyph, &position, 1) }; + drop(context); + // Bitmap rows are top-down. Collapse any OS-dependent channel differences + // to grayscale instead of assuming a physical LCD component order. + let data: Vec = pixels + .chunks_exact(4) + .map(|p| ((u16::from(p[0]) + u16::from(p[1]) + u16::from(p[2]) + 1) / 3) as u8) + .collect(); + // Published bitmap bounds are also used as ink bounds by clipping and + // overlap diagnostics. Remove the transparent smoothing guard band while + // retaining every nonzero antialiased pixel. + let mut min_x = width; + let mut min_y = height; + let mut max_x = 0; + let mut max_y = 0; + for (index, &coverage) in data.iter().enumerate() { + if coverage != 0 { + let x = index % width; + let y = index / width; + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + } + if min_x == width { + return Some(RasterGlyphImage { + left: 0, + top: 0, + width: 0, + height: 0, + data: Vec::new(), + content: RasterContent::Mask, + }); + } + let ink_width = max_x - min_x + 1; + let ink_height = max_y - min_y + 1; + let mut ink = Vec::with_capacity(ink_width * ink_height); + for y in min_y..=max_y { + ink.extend_from_slice(&data[y * width + min_x..y * width + max_x + 1]); + } + Some(RasterGlyphImage { + left: left as i32 + min_x as i32, + top: top as i32 - min_y as i32, + width: ink_width as u32, + height: ink_height as u32, + data: ink, + content: RasterContent::Mask, + }) +} + +#[cfg(test)] +#[path = "tests/macos_test.rs"] +mod tests; diff --git a/crates/neomacs-renderer-wgpu/src/glyph_atlas/mod.rs b/crates/neomacs-renderer-wgpu/src/glyph_atlas/mod.rs index 7111d7f180..d553e7a542 100644 --- a/crates/neomacs-renderer-wgpu/src/glyph_atlas/mod.rs +++ b/crates/neomacs-renderer-wgpu/src/glyph_atlas/mod.rs @@ -4,6 +4,8 @@ pub mod allocator; mod bitmap_fonts; +#[cfg(target_os = "macos")] +mod macos; pub mod pages; pub mod types; @@ -567,6 +569,8 @@ pub struct WgpuGlyphAtlas { atlas_config: GlyphAtlasConfig, font_system: FontSystem, scale_context: ScaleContext, + #[cfg(target_os = "macos")] + native_rasterizer: macos::NativeRasterizer, bind_group_layout: wgpu::BindGroupLayout, linear_sampler: wgpu::Sampler, nearest_sampler: wgpu::Sampler, @@ -712,6 +716,8 @@ impl WgpuGlyphAtlas { fontdb::Database::new(), ), scale_context: ScaleContext::new(), + #[cfg(target_os = "macos")] + native_rasterizer: macos::NativeRasterizer::default(), bind_group_layout, linear_sampler, nearest_sampler, @@ -1394,6 +1400,19 @@ impl WgpuGlyphAtlas { .font_system .get_font(cache_key.font_id, cache_key.font_weight)?; + // The native macOS adapter publishes a grayscale coverage mask. Keep + // it on the Alpha path; a future subpixel mode must stay on Swash (or + // grow a subpixel mask of its own). + #[cfg(target_os = "macos")] + if render_mode == GlyphRenderMode::Alpha + && let Some(info) = self.font_system.db().face(cache_key.font_id) + && let Some(image) = + self.native_rasterizer + .rasterize(&font, &info.post_script_name, cache_key) + { + return Some(image); + } + let variable_width = font .as_swash() .variations() @@ -2033,6 +2052,8 @@ impl WgpuGlyphAtlas { self.resolved_fonts_by_fontdb.clear(); self.color_font_assets.clear(); self.color_glyph_rasterizer.clear(); + #[cfg(target_os = "macos")] + self.native_rasterizer.clear(); self.color_font_ids.clear(); // A missing/replaced platform font can become available after the // render-side generation reset. Keep successful fontdb registrations, diff --git a/crates/neomacs-renderer-wgpu/src/glyph_atlas/tests/glyph_atlas_test.rs b/crates/neomacs-renderer-wgpu/src/glyph_atlas/tests/glyph_atlas_test.rs index ea80fd0945..0b498c2a17 100644 --- a/crates/neomacs-renderer-wgpu/src/glyph_atlas/tests/glyph_atlas_test.rs +++ b/crates/neomacs-renderer-wgpu/src/glyph_atlas/tests/glyph_atlas_test.rs @@ -1,9 +1,9 @@ use super::{ - AnyAtlasEntry, BitmapFontReplayCache, ComposedGlyphKey, FontconfigSubpixelOrder, - GlyphAtlasError, GlyphKey, GlyphPixelKind, RasterizeResult, SampledSubGlyph, SingleCharGlyph, - SubGlyph, SubpixelBin, SubpixelRequest, WgpuGlyphAtlas, effective_font_size, - frame_font_bindings_identity, glyph_font_identity, key_uses_default_font_metrics, - normalize_subpixel_mask, rasterize_missing_glyph_box, resolved_glyph_stream_identity, + AnyAtlasEntry, ComposedGlyphKey, FontconfigSubpixelOrder, GlyphAtlasError, GlyphKey, + GlyphPixelKind, RasterizeResult, SampledSubGlyph, SingleCharGlyph, SubGlyph, SubpixelBin, + SubpixelRequest, WgpuGlyphAtlas, effective_font_size, frame_font_bindings_identity, + glyph_font_identity, key_uses_default_font_metrics, normalize_subpixel_mask, + rasterize_missing_glyph_box, resolved_glyph_stream_identity, }; use neomacs_display_protocol::face::Face; use neomacs_display_protocol::font::{ @@ -12,6 +12,9 @@ use neomacs_display_protocol::font::{ }; use neomacs_display_protocol::types::FaceId; +#[cfg(target_os = "linux")] +use super::BitmapFontReplayCache; + fn test_font_path(path: std::path::PathBuf) -> String { path.to_string_lossy().into_owned() } @@ -429,6 +432,177 @@ fn try_test_atlas() -> Option { }) } +#[cfg(target_os = "macos")] +#[test] +fn macos_atlas_uses_native_monaco_masks() { + let Some(mut atlas) = try_test_atlas() else { + eprintln!("SKIP: no Metal device available for native atlas routing test"); + return; + }; + assert!(!atlas.subpixel_enabled()); + let id = atlas + .font_system + .db() + .query(&fontdb::Query { + families: &[fontdb::Family::Name("Monaco")], + ..Default::default() + }) + .expect("macOS supplies Monaco"); + let name = atlas + .font_system + .db() + .face(id) + .unwrap() + .post_script_name + .clone(); + let font = atlas + .font_system + .get_font(id, fontdb::Weight::NORMAL) + .unwrap(); + let mut reference = super::macos::NativeRasterizer::default(); + for size in [16.0f32, 32.0] { + let key = cosmic_text::CacheKey { + font_id: id, + glyph_id: font.as_swash().charmap().map('t'), + font_size_bits: size.to_bits(), + x_bin: SubpixelBin::Zero, + y_bin: SubpixelBin::Zero, + font_weight: fontdb::Weight::NORMAL, + flags: cosmic_text::CacheKeyFlags::empty(), + }; + let expected = reference.rasterize(&font, &name, key).unwrap(); + let actual = atlas.render_cache_key_image(key, false).unwrap(); + assert_eq!(actual.content, super::RasterContent::Mask); + assert!( + !actual.data.is_empty() && actual.data.iter().any(|&p| p > 0), + "native routing must produce ink, not an empty mask" + ); + assert_eq!((actual.left, actual.top), (expected.left, expected.top)); + assert_eq!( + (actual.width, actual.height), + (expected.width, expected.height) + ); + assert_eq!(actual.data, expected.data); + + // Synthetic styles must decline the native path and keep Swash's skew; + // otherwise the fallback silently renders an upright glyph. + let mut synthetic = key; + synthetic.flags = cosmic_text::CacheKeyFlags::FAKE_ITALIC; + let slanted = atlas + .render_cache_key_image(synthetic, false) + .expect("fake italic still renders through Swash"); + assert!(slanted.data.iter().any(|&p| p > 0)); + assert_ne!( + slanted.data, actual.data, + "native path must not serve the upright mask for a fake-italic key" + ); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_resolved_cluster_applies_backing_scale() { + use neomacs_display_protocol::font::FontSlantKind; + + let Some(mut atlas) = try_test_atlas() else { + eprintln!("SKIP: no Metal device available for backing-scale test"); + return; + }; + let source_id = atlas + .font_system + .db() + .query(&fontdb::Query { + families: &[fontdb::Family::Name("Monaco")], + ..Default::default() + }) + .expect("macOS supplies Monaco"); + let (path, face_index, post_script_name) = { + let source_face = atlas.font_system.db().face(source_id).unwrap(); + let path = match &source_face.source { + fontdb::Source::File(path) | fontdb::Source::SharedFile(path, _) => path.clone(), + fontdb::Source::Binary(_) => panic!("Monaco is a file-backed face"), + }; + ( + path, + source_face.index, + source_face.post_script_name.clone(), + ) + }; + let identity = ResolvedFontIdentity::from_file(&path.to_string_lossy(), face_index, None); + let id = ResolvedFontId(91); + let font = ResolvedFont { + id, + identity: identity.clone(), + replay: file_replay_for(&identity), + family: "Monaco".to_owned(), + full_name: None, + postscript_name: Some(post_script_name), + weight: 400, + slant: FontSlantKind::Normal, + width: 5, + pixel_size: 16.0, + ascent_px: 12.0, + descent_px: 4.0, + space_advance_px: 8.0, + glyph_advance: Default::default(), + }; + atlas.install_frame_fonts( + &Default::default(), + &[(id, font.clone())].into_iter().collect(), + &Default::default(), + &Default::default(), + ); + let local_id = atlas + .local_fontdb_id_for(id) + .expect("renderer installs the exact Monaco face"); + let glyph_id = atlas + .font_system + .get_font(local_id, fontdb::Weight::NORMAL) + .expect("installed Monaco face is reachable") + .as_swash() + .charmap() + .map('H'); + let glyph = ResolvedGlyph { + resolved_font_id: id, + glyph_id: ResolvedGlyphId::new(u32::from(glyph_id)), + x: 0.0, + y: 0.0, + x_advance: 8.0, + cluster_start: 0, + cluster_end: 1, + }; + let rasterize = |atlas: &mut WgpuGlyphAtlas| { + atlas + .rasterize_resolved_cluster( + std::slice::from_ref(&glyph), + None, + 16.0, + SubpixelBin::Zero, + SubpixelBin::Zero, + false, + ) + .and_then(|mut parts| (parts.len() == 1).then(|| parts.remove(0))) + .expect("rasterize the installed Monaco glyph") + }; + let at_one = rasterize(&mut atlas); + atlas.set_scale_factor(2.0); + let at_two = rasterize(&mut atlas); + assert!(at_one.width > 0 && at_one.height > 0); + assert!( + at_two.width.abs_diff(at_one.width * 2) <= 2, + "2x scale must double the physical mask width: {} -> {}", + at_one.width, + at_two.width + ); + assert!( + at_two.height.abs_diff(at_one.height * 2) <= 2, + "2x scale must double the physical mask height: {} -> {}", + at_one.height, + at_two.height + ); + assert!((at_two.advance_width - at_one.advance_width * 2.0).abs() < 0.01); +} + #[test] fn frame_font_installation_invalidates_atlas_on_catalog_generation_change() { let Some((_, _, mut atlas)) = try_test_device_and_atlas() else { diff --git a/crates/neomacs-renderer-wgpu/src/glyph_atlas/tests/macos_test.rs b/crates/neomacs-renderer-wgpu/src/glyph_atlas/tests/macos_test.rs new file mode 100644 index 0000000000..13409ab7cb --- /dev/null +++ b/crates/neomacs-renderer-wgpu/src/glyph_atlas/tests/macos_test.rs @@ -0,0 +1,321 @@ +use super::*; +use cosmic_text::{FontSystem, SubpixelBin}; +use objc2_core_foundation::CFData; + +fn monaco() -> (std::sync::Arc, String) { + let mut system = FontSystem::new(); + let id = system + .db() + .query(&fontdb::Query { + families: &[fontdb::Family::Name("Monaco")], + ..Default::default() + }) + .expect("macOS supplies Monaco"); + let name = system.db().face(id).unwrap().post_script_name.clone(); + (system.get_font(id, fontdb::Weight::NORMAL).unwrap(), name) +} + +fn key(font: &Font, ch: char, size: f32) -> CacheKey { + CacheKey { + font_id: font.id(), + glyph_id: font.as_swash().charmap().map(ch), + font_size_bits: size.to_bits(), + x_bin: SubpixelBin::Zero, + y_bin: SubpixelBin::Zero, + font_weight: fontdb::Weight::NORMAL, + flags: CacheKeyFlags::empty(), + } +} + +#[test] +fn macos_native_masks_preserve_baseline_scale_and_grayscale() { + let (font, name) = monaco(); + let mut native = NativeRasterizer::default(); + for size in [16.0, 32.0] { + for ch in ['t', 'H', 'm', 'i', '0', '9', 'g'] { + let mask = native + .rasterize(&font, &name, key(&font, ch, size)) + .unwrap(); + assert_eq!(mask.content, RasterContent::Mask); + assert_eq!(mask.data.len(), (mask.width * mask.height) as usize); + assert!(mask.data.iter().any(|&a| a > 0)); + assert!(mask.data.iter().any(|&a| a > 0 && a < 255)); + assert!(mask.top > 0); + assert!(mask.width < (size * 2.0) as u32); + assert!(mask.height < (size * 2.0) as u32); + // Bounds must describe ink, not transparent padding that would + // make adjacent rows falsely report overlapping glyphs. + let width = mask.width as usize; + assert!(mask.data[..width].iter().any(|&a| a != 0)); + assert!(mask.data[mask.data.len() - width..].iter().any(|&a| a != 0)); + assert!(mask.data.chunks_exact(width).any(|row| row[0] != 0)); + assert!(mask.data.chunks_exact(width).any(|row| row[width - 1] != 0)); + } + } + assert_eq!(native.fonts.len(), 2, "reuse native fonts across glyphs"); + let small = native + .rasterize(&font, &name, key(&font, 'H', 16.0)) + .unwrap(); + let large = native + .rasterize(&font, &name, key(&font, 'H', 32.0)) + .unwrap(); + assert!((large.top - 2 * small.top).abs() <= 3); + native.clear(); + assert!(native.fonts.is_empty()); + assert!(native.sources.is_empty()); + assert!(native.backing.is_empty()); +} + +/// The ink centroid in pen-relative pixels, with y increasing upwards from the +/// baseline (the same frame `RasterGlyphImage::left/top` describe). +fn ink_centroid(mask: &RasterGlyphImage) -> (f32, f32) { + let width = mask.width as usize; + let mut total = 0u64; + let mut sum_x = 0u64; + let mut sum_y = 0u64; + for (row, pixels) in mask.data.chunks_exact(width).enumerate() { + for (col, &coverage) in pixels.iter().enumerate() { + let coverage = u64::from(coverage); + total += coverage; + sum_x += coverage * col as u64; + sum_y += coverage * row as u64; + } + } + assert!(total > 0, "centroid of an empty mask"); + let x = mask.left as f32 + sum_x as f32 / total as f32 + 0.5; + let y = mask.top as f32 - (sum_y as f32 / total as f32 + 0.5); + (x, y) +} + +/// A y subpixel bin must move native ink in the same direction, and by the +/// same distance, as the Swash placement path it replaces. The two rasterizers +/// otherwise disagree by 2*y_bin; production currently snaps bins to Zero, so +/// only a direct comparison catches a regression here. +#[test] +fn macos_native_subpixel_placement_matches_swash() { + let (font, name) = monaco(); + let mut native = NativeRasterizer::default(); + let mut scale_context = swash::scale::ScaleContext::new(); + let mut zero_native = None; + let mut zero_swash = None; + for bin in [ + SubpixelBin::Zero, + SubpixelBin::One, + SubpixelBin::Two, + SubpixelBin::Three, + ] { + let mut request = key(&font, 'H', 32.0); + request.y_bin = bin; + let native_image = native.rasterize(&font, &name, request).unwrap(); + let mut scaler = scale_context + .builder(font.as_swash()) + .size(32.0) + .hint(true) + .build(); + let swash_image = swash::scale::Render::new(&[swash::scale::Source::Outline]) + .format(swash::zeno::Format::Alpha) + .offset(swash::zeno::Vector::new(0.0, bin.as_float())) + .render(&mut scaler, request.glyph_id) + .map(RasterGlyphImage::from_swash) + .expect("Monaco H has an outline"); + let (_, native_y) = ink_centroid(&native_image); + let (_, swash_y) = ink_centroid(&swash_image); + if bin == SubpixelBin::Zero { + zero_native = Some(native_y); + zero_swash = Some(swash_y); + continue; + } + let native_shift = native_y - zero_native.expect("zero bin runs first"); + let swash_shift = swash_y - zero_swash.expect("zero bin runs first"); + assert!( + native_shift.abs() > 0.05, + "{bin:?}: native ink did not move" + ); + assert!( + (native_shift - swash_shift).abs() < 0.35, + "{bin:?}: native shift {native_shift} vs Swash shift {swash_shift}" + ); + } + let (zero_native, zero_swash) = (zero_native.unwrap(), zero_swash.unwrap()); + assert!( + (zero_native - zero_swash).abs() < 1.0, + "zero-bin baseline differs: native {zero_native} vs Swash {zero_swash}" + ); +} + +/// One backing-file copy serves every raster size, and the atlas keeps one +/// exact face per font id. +#[test] +fn macos_native_shares_one_backing_copy_across_sizes() { + let (font, name) = monaco(); + let file = backing_hash(font.data()); + let mut native = NativeRasterizer::default(); + let small = native + .rasterize(&font, &name, key(&font, 'H', 16.0)) + .unwrap(); + assert!(!small.data.is_empty()); + let first = &*native.backing[&file][0] as *const CFData; + let large = native + .rasterize(&font, &name, key(&font, 'H', 32.0)) + .unwrap(); + assert!(!large.data.is_empty()); + assert_eq!(native.sources.len(), 1, "one exact face per font id"); + assert_eq!(native.fonts.len(), 2, "one physical-size font per size"); + assert_eq!(native.backing.len(), 1, "one copy of the backing file"); + assert_eq!(native.backing[&file].len(), 1); + assert_eq!( + &*native.backing[&file][0] as *const CFData, first, + "the same CFData is reused across sizes" + ); +} + +#[test] +fn macos_native_replay_never_substitutes_a_different_font() { + let (font, name) = monaco(); + let data = CFData::from_bytes(font.data()); + assert!(open_exact_font(&data, "Not-The-Selected-Font", 16.0).is_none()); + let opened = open_exact_font(&data, &name, 16.0).unwrap(); + assert_eq!(unsafe { opened.post_script_name() }.to_string(), name); + let mut native = NativeRasterizer::default(); + let space = native + .rasterize(&font, &name, key(&font, ' ', 16.0)) + .unwrap(); + assert!(space.data.is_empty()); + for size in [16.0, 32.0] { + let mask = native + .rasterize(&font, &name, key(&font, 't', size)) + .unwrap(); + assert!(mask.data.iter().any(|&coverage| coverage > 0)); + let sized = &native.fonts[&(font.id(), size.to_bits())]; + assert_eq!(unsafe { sized.post_script_name() }.to_string(), name); + } + let mut synthetic = key(&font, 't', 16.0); + synthetic.flags = CacheKeyFlags::FAKE_ITALIC; + assert!(native.rasterize(&font, &name, synthetic).is_none()); +} + +#[test] +fn macos_native_shares_backing_across_separately_pinned_collection_faces() { + let mut system = FontSystem::new(); + let mut files = neomacs_font_materializer::FontFileCache::new(); + let mut native = NativeRasterizer::default(); + let mut fonts = Vec::new(); + // Load the first face before registering the second, as redisplay does + // when another style from the same collection is first encountered. + for index in [0, 1] { + let pin = files + .pin_exact_face(&mut system, "/System/Library/Fonts/Menlo.ttc", index) + .unwrap(); + let id = pin.fontdb_id(); + let name = system.db().face(id).unwrap().post_script_name.clone(); + let font = system.get_font(id, fontdb::Weight::NORMAL).unwrap(); + let mask = native + .rasterize(&font, &name, key(&font, 'H', 16.0)) + .unwrap(); + assert!(mask.data.iter().any(|&p| p > 0)); + assert_eq!( + unsafe { native.sources[&id].as_ref().unwrap().post_script_name() }.to_string(), + name, + "backing reuse must preserve the selected collection member" + ); + fonts.push(font); + } + assert_ne!(fonts[0].data().as_ptr(), fonts[1].data().as_ptr()); + assert_eq!(fonts[0].data(), fonts[1].data()); + assert_eq!(native.sources.len(), 2); + assert_eq!( + native.backing.len(), + 1, + "one copy across independent mappings" + ); + assert_eq!(native.backing.values().next().unwrap().len(), 1); +} + +#[test] +fn macos_native_backing_compares_bytes_on_hash_collision() { + let mut backing = FxHashMap::default(); + // Force a collision with different, equally sized contents. A hash is + // only an accelerator, never the authority for selecting font bytes. + backing.insert(backing_hash(b"new"), vec![CFData::from_bytes(b"old")]); + let data = intern_backing(&mut backing, b"new"); + assert_eq!(data.to_vec(), b"new"); + assert_eq!(backing[&backing_hash(b"new")].len(), 2); + let again = intern_backing(&mut backing, &b"new".to_vec()); + assert!(std::ptr::eq(&*data, &*again)); + assert_eq!(backing[&backing_hash(b"new")].len(), 2); +} + +/// Inspect the same exact Monaco glyphs with Swash (upper row) and Core +/// Graphics (lower row), on dark and light backgrounds. This isolates native +/// smoothing from WGPU, font selection and layout. No screenshot permissions +/// or running editor are needed. +#[test] +#[ignore = "manual raster comparison; set NEOMACS_FONT_PROBE_PNG"] +fn macos_native_visual_probe() { + let path = std::env::var("NEOMACS_FONT_PROBE_PNG").expect("output PNG path"); + let (font, name) = monaco(); + let mut native = NativeRasterizer::default(); + let mut swash = swash::scale::ScaleContext::new(); + let mut sheet = image::RgbImage::new(1120, 240); + for (i, pixel) in sheet.pixels_mut().enumerate() { + *pixel = image::Rgb(if i / 1120 < 120 { + [40, 44, 52] + } else { + [250, 250, 250] + }); + } + for (row, baseline) in [45, 100, 165, 220].into_iter().enumerate() { + let mut pen = 20.0f32; + let mut ink = 0u64; + for ch in "Monaco Regular 16: t H m i 0123456789 gyp".chars() { + let request = key(&font, ch, 32.0); + let mask = if row % 2 == 1 { + native.rasterize(&font, &name, request).unwrap() + } else { + let mut scaler = swash.builder(font.as_swash()).size(32.0).hint(true).build(); + let Some(mask) = swash::scale::Render::new(&[swash::scale::Source::Outline]) + .format(swash::zeno::Format::Alpha) + .render(&mut scaler, request.glyph_id) + else { + pen += font + .as_swash() + .glyph_metrics(&[]) + .scale(32.0) + .advance_width(request.glyph_id); + continue; + }; + RasterGlyphImage::from_swash(mask) + }; + for y in 0..mask.height { + for x in 0..mask.width { + let coverage = mask.data[(y * mask.width + x) as usize]; + ink += u64::from(coverage); + let dx = pen.round() as i32 + mask.left + x as i32; + let dy = baseline - mask.top + y as i32; + if dx < 0 || dy < 0 || dx >= sheet.width() as i32 || dy >= sheet.height() as i32 + { + continue; + } + let pixel = sheet.get_pixel_mut(dx as u32, dy as u32); + let fg = if row < 2 { + [190u16, 195, 205] + } else { + [40, 44, 52] + }; + for (out, fg) in pixel.0.iter_mut().zip(fg) { + *out = ((u16::from(*out) * (255 - u16::from(coverage)) + + fg * u16::from(coverage)) + / 255) as u8; + } + } + } + pen += font + .as_swash() + .glyph_metrics(&[]) + .scale(32.0) + .advance_width(request.glyph_id); + } + eprintln!("row {row}: total coverage {ink}"); + } + sheet.save(path).unwrap(); +}