diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index c99c3cbb..208cee01 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -869,6 +869,15 @@ impl Default for TypePrefs { } } +impl TypePrefs { + /// Preferences ▸ Type ▸ Missing Glyph Protection: with it on (the default, as in Photoshop), + /// glyphs the chosen face lacks fall back through the coverage fonts (#1909). The text engine + /// applies this in `Session::apply_prefs`. + pub fn missing_glyph_protection(&self) -> bool { + self.missing_glyph_protection + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct EnhancedControls { @@ -1049,7 +1058,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "plugIns.showExtensionPanels", "plugIns.allowScriptsToConnect", "plugIns.generatorEnabled", - "type.missingGlyphProtection", "type.textEngine", "type.recentFonts", "enhancedControls.scrubbySliderAcceleration", @@ -1484,8 +1492,8 @@ impl Session { r } - /// Push engine-relevant preferences into live state: every document's history limit and - /// the layer-effect cache budget. + /// Push engine-relevant preferences into live state: every document's history limit, the + /// layer-effect cache budget and the text engine's missing-glyph protection. pub fn apply_prefs(&mut self) { let n = self.prefs().performance.history_states.max(1) as usize; let bytes = self.prefs().performance.history_budget_bytes(); @@ -1496,6 +1504,9 @@ impl Session { st.history.trim(&st.doc); } photocraft_compose::set_effect_cache_budget(budget << 20); + if let Ok(mut engine) = photocraft_text::shared().lock() { + engine.fonts.set_missing_glyph_protection(self.prefs().type_.missing_glyph_protection()); + } crate::plugin_cmds::sync_prefs(self); } diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 1d728f14..21fc1a92 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -140,6 +140,9 @@ pub struct FontDb { /// PostScript name → (family, weight, italic), filled lazily. ps_cache: HashMap>, fallbacks: Vec, + /// Preferences ▸ Type ▸ Missing Glyph Protection: route glyphs the chosen face lacks through + /// [`Self::fallback_stack`] (#1909). Off, only the default family shapes the text. + missing_glyph_protection: bool, face_cache: HashMap>, face_aliases: HashMap<(String, usize), String>, } @@ -159,6 +162,7 @@ impl FontDb { system_loaded: false, ps_cache: HashMap::new(), fallbacks: Vec::new(), + missing_glyph_protection: true, face_cache: HashMap::new(), face_aliases: HashMap::new(), }; @@ -272,8 +276,20 @@ impl FontDb { } /// Families available after the requested one (bundled default + installed coverage fonts). + /// With missing glyph protection off (Preferences ▸ Type), only the default family shapes. pub(crate) fn fallback_stack(&self) -> impl Iterator { - std::iter::once(DEFAULT_FAMILY).chain(self.fallbacks.iter().map(String::as_str)) + let fallbacks = self.missing_glyph_protection.then_some(self.fallbacks.iter().map(String::as_str)); + std::iter::once(DEFAULT_FAMILY).chain(fallbacks.into_iter().flatten()) + } + + /// Preferences ▸ Type ▸ Missing Glyph Protection, applied by the engine's `apply_prefs`. + pub fn set_missing_glyph_protection(&mut self, on: bool) { + self.missing_glyph_protection = on; + } + + /// Is missing glyph protection on? + pub fn missing_glyph_protection(&self) -> bool { + self.missing_glyph_protection } /// All family names, sorted. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index b1a1949c..dc8df9e5 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1580,6 +1580,25 @@ fn a_served_script_fallback_joins_the_fallback_stack_once_it_arrives() { assert!(db.fallback_stack().any(|f| f == "Cairo"), "{:?}", db.fallback_stack().collect::>()); } +/// Preferences ▸ Type ▸ Missing Glyph Protection: off, only the default family is in the +/// fallback stack, so a face without the glyph shows the missing-glyph box instead of a +/// fallback's glyph (#1909). +#[test] +fn missing_glyph_protection_can_turn_the_fallback_stack_off() { + let Some(cairo) = crate::CRAFT_FONTS.iter().find(|f| f.family == "Cairo") else { + eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)"); + return; + }; + let mut db = fonts::FontDb::new(); + db.register_font_data(cairo.bytes.to_vec()); + assert!(db.missing_glyph_protection(), "on by default, as in Photoshop"); + assert!(db.fallback_stack().any(|f| f == "Cairo")); + db.set_missing_glyph_protection(false); + assert_eq!(db.fallback_stack().collect::>(), [fonts::DEFAULT_FAMILY], "only the default family remains"); + db.set_missing_glyph_protection(true); + assert!(db.fallback_stack().any(|f| f == "Cairo"), "turning it back on restores the coverage fonts"); +} + #[test] fn translate_tysh_moves_only_the_translation() { let mut t = point("Hello!", 26.0); diff --git a/docs/scorecard.md b/docs/scorecard.md index 80cb52fa..01296e37 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 30 of 155 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 29 of 155 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 4 | 2 | 5 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 30 of 155** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 29 of 155** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---|