diff --git a/vl-convert-canvas2d-deno/src/lib.rs b/vl-convert-canvas2d-deno/src/lib.rs index d7a3973f..d579395a 100644 --- a/vl-convert-canvas2d-deno/src/lib.rs +++ b/vl-convert-canvas2d-deno/src/lib.rs @@ -19,12 +19,21 @@ pub use resource::{CanvasResource, Path2DResource}; /// Wraps a pre-built [`ResolvedFontConfig`](::vl_convert_canvas2d::ResolvedFontConfig) /// so that canvas contexts clone the cached font database instead of rebuilding /// it (and re-scanning system fonts) on every creation. +/// +/// Includes a version counter so that existing canvas contexts can detect when +/// the font database has changed and refresh their local copy. #[derive(Clone)] -pub struct SharedFontConfig(pub Arc<::vl_convert_canvas2d::ResolvedFontConfig>); +pub struct SharedFontConfig { + pub resolved: Arc<::vl_convert_canvas2d::ResolvedFontConfig>, + pub version: u64, +} impl SharedFontConfig { - pub fn new(resolved: ::vl_convert_canvas2d::ResolvedFontConfig) -> Self { - Self(Arc::new(resolved)) + pub fn new(resolved: ::vl_convert_canvas2d::ResolvedFontConfig, version: u64) -> Self { + Self { + resolved: Arc::new(resolved), + version, + } } } diff --git a/vl-convert-canvas2d-deno/src/ops/mod.rs b/vl-convert-canvas2d-deno/src/ops/mod.rs index 4d77b31d..b9af051f 100644 --- a/vl-convert-canvas2d-deno/src/ops/mod.rs +++ b/vl-convert-canvas2d-deno/src/ops/mod.rs @@ -29,15 +29,19 @@ use vl_convert_canvas2d::Canvas2dContext; /// If a SharedFontConfig is available in OpState, it will be used for the canvas. #[op2(fast)] pub fn op_canvas_create(state: &mut OpState, width: u32, height: u32) -> Result { + let font_config_version = state + .try_borrow::() + .map(|c| c.version) + .unwrap_or(0); let ctx = if let Some(shared_config) = state.try_borrow::() { - Canvas2dContext::with_resolved(width, height, &shared_config.0) + Canvas2dContext::with_resolved(width, height, &shared_config.resolved) .map_err(|e| JsErrorBox::generic(format!("Failed to create canvas: {}", e)))? } else { Canvas2dContext::new(width, height) .map_err(|e| JsErrorBox::generic(format!("Failed to create canvas: {}", e)))? }; - let resource = CanvasResource::new(ctx); + let resource = CanvasResource::new(ctx, font_config_version); let rid = state.resource_table.add(resource); Ok(rid) } diff --git a/vl-convert-canvas2d-deno/src/ops/text.rs b/vl-convert-canvas2d-deno/src/ops/text.rs index f0fa7b83..09d58b01 100644 --- a/vl-convert-canvas2d-deno/src/ops/text.rs +++ b/vl-convert-canvas2d-deno/src/ops/text.rs @@ -1,11 +1,26 @@ //! Text rendering operations: font, alignment, baseline, measure, fill/stroke text. -use crate::CanvasResource; +use crate::{CanvasResource, SharedFontConfig}; use deno_core::op2; use deno_core::{OpState, ResourceId}; use deno_error::JsErrorBox; use vl_convert_canvas2d::{FontStretch, TextAlign, TextBaseline}; +/// If the shared font configuration has been updated since this canvas was +/// created (or last refreshed), update the canvas context's font database so +/// that newly-registered fonts are available for text measurement / rendering. +fn refresh_canvas_fonts_if_needed(state: &OpState, resource: &CanvasResource) { + if let Some(shared_config) = state.try_borrow::() { + if shared_config.version != resource.font_config_version.get() { + resource + .ctx + .borrow_mut() + .update_font_database(&shared_config.resolved); + resource.font_config_version.set(shared_config.version); + } + } +} + /// Set the font from a CSS font string. #[op2(fast)] pub fn op_canvas_set_font( @@ -18,6 +33,8 @@ pub fn op_canvas_set_font( .get::(ResourceId::from(rid)) .map_err(|e| JsErrorBox::generic(format!("Invalid canvas resource: {}", e)))?; + refresh_canvas_fonts_if_needed(state, &resource); + resource .ctx .borrow_mut() @@ -169,6 +186,8 @@ pub fn op_canvas_measure_text( .get::(ResourceId::from(rid)) .map_err(|e| JsErrorBox::generic(format!("Invalid canvas resource: {}", e)))?; + refresh_canvas_fonts_if_needed(state, &resource); + let metrics = resource .ctx .borrow_mut() @@ -192,6 +211,8 @@ pub fn op_canvas_fill_text( .get::(ResourceId::from(rid)) .map_err(|e| JsErrorBox::generic(format!("Invalid canvas resource: {}", e)))?; + refresh_canvas_fonts_if_needed(state, &resource); + resource .ctx .borrow_mut() @@ -213,6 +234,8 @@ pub fn op_canvas_stroke_text( .get::(ResourceId::from(rid)) .map_err(|e| JsErrorBox::generic(format!("Invalid canvas resource: {}", e)))?; + refresh_canvas_fonts_if_needed(state, &resource); + resource .ctx .borrow_mut() @@ -235,6 +258,8 @@ pub fn op_canvas_fill_text_max_width( .get::(ResourceId::from(rid)) .map_err(|e| JsErrorBox::generic(format!("Invalid canvas resource: {}", e)))?; + refresh_canvas_fonts_if_needed(state, &resource); + resource .ctx .borrow_mut() @@ -257,6 +282,8 @@ pub fn op_canvas_stroke_text_max_width( .get::(ResourceId::from(rid)) .map_err(|e| JsErrorBox::generic(format!("Invalid canvas resource: {}", e)))?; + refresh_canvas_fonts_if_needed(state, &resource); + resource .ctx .borrow_mut() diff --git a/vl-convert-canvas2d-deno/src/resource.rs b/vl-convert-canvas2d-deno/src/resource.rs index 0414954f..00f8a271 100644 --- a/vl-convert-canvas2d-deno/src/resource.rs +++ b/vl-convert-canvas2d-deno/src/resource.rs @@ -19,6 +19,8 @@ pub struct CanvasResource { next_gradient_id: Cell, /// Next pattern ID to assign next_pattern_id: Cell, + /// Font config version this canvas was created with (or last updated to). + pub font_config_version: Cell, } /// Resource wrapper for Path2D objects to be stored in Deno's resource table. @@ -28,13 +30,14 @@ pub struct Path2DResource { } impl CanvasResource { - pub fn new(ctx: Canvas2dContext) -> Self { + pub fn new(ctx: Canvas2dContext, font_config_version: u64) -> Self { Self { ctx: RefCell::new(ctx), gradients: RefCell::new(HashMap::new()), patterns: RefCell::new(HashMap::new()), next_gradient_id: Cell::new(1), next_pattern_id: Cell::new(1), + font_config_version: Cell::new(font_config_version), } } diff --git a/vl-convert-canvas2d/src/context/mod.rs b/vl-convert-canvas2d/src/context/mod.rs index 94045b1f..d4edc0f0 100644 --- a/vl-convert-canvas2d/src/context/mod.rs +++ b/vl-convert-canvas2d/src/context/mod.rs @@ -137,6 +137,18 @@ impl Canvas2dContext { }) } + /// Replace the font database used for text measurement and rendering. + /// + /// Call this when the shared font configuration has changed (e.g., new font + /// directories were registered) so that existing canvas contexts pick up the + /// updated fonts. + pub fn update_font_database(&mut self, resolved: &ResolvedFontConfig) { + self.font_system = + FontSystem::new_with_locale_and_db("en".to_string(), resolved.fontdb.clone()); + self.swash_cache = SwashCache::new(); + self.hinting_enabled = resolved.hinting_enabled; + } + /// Get canvas width. pub fn width(&self) -> u32 { self.width diff --git a/vl-convert-rs/src/converter.rs b/vl-convert-rs/src/converter.rs index 7f07b94a..c4c5a22b 100644 --- a/vl-convert-rs/src/converter.rs +++ b/vl-convert-rs/src/converter.rs @@ -36,7 +36,8 @@ use image::codecs::jpeg::JpegEncoder; use image::ImageReader; use resvg::render; -use crate::text::{FONT_CONFIG, USVG_OPTIONS}; +use crate::text::{FONT_CONFIG, FONT_CONFIG_VERSION, USVG_OPTIONS}; +use std::sync::atomic::Ordering; // Extension with our custom ops - MainWorker provides all Web APIs (URL, fetch, etc.) // Canvas 2D ops are now in the separate vl_convert_canvas2d extension from vl-convert-canvas2d-deno @@ -56,6 +57,11 @@ deno_core::extension!( // Arguments are passed to V8 as JSON strings via Deno ops and parsed in JS. // Scenegraph results are returned as MessagePack byte buffers via ops, // avoiding JSON serialization overhead for large payloads. +struct VlConverterRuntime { + sender: Sender, + handle: JoinHandle>, +} + lazy_static! { pub static ref TOKIO_RUNTIME: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread() @@ -68,6 +74,137 @@ lazy_static! { static ref NEXT_ID: Arc> = Arc::new(Mutex::new(0)); } +static VL_CONVERTER_RUNTIME: Mutex> = Mutex::new(None); + +fn spawn_worker_thread() -> VlConverterRuntime { + let (sender, mut receiver) = mpsc::channel::(32); + let handle = thread::spawn(move || { + TOKIO_RUNTIME.block_on(async { + let mut inner = InnerVlConverter::try_new().await?; + while let Some(cmd) = receiver.next().await { + if let Err(e) = inner.refresh_font_config_if_needed() { + cmd.send_error(e); + continue; + } + match cmd { + VlConvertCommand::VlToVg { + vl_spec, + vl_opts, + responder, + } => { + let vega_spec = inner.vegalite_to_vega(vl_spec, vl_opts).await; + responder.send(vega_spec).ok(); + } + VlConvertCommand::VgToSvg { + vg_spec, + vg_opts, + responder, + } => { + let svg_result = inner.vega_to_svg(vg_spec, vg_opts).await; + responder.send(svg_result).ok(); + } + VlConvertCommand::VgToSg { + vg_spec, + vg_opts, + responder, + } => { + let sg_result = inner.vega_to_scenegraph(vg_spec, vg_opts).await; + responder.send(sg_result).ok(); + } + VlConvertCommand::VgToSgMsgpack { + vg_spec, + vg_opts, + responder, + } => { + let sg_result = inner.vega_to_scenegraph_msgpack(vg_spec, vg_opts).await; + responder.send(sg_result).ok(); + } + VlConvertCommand::VlToSvg { + vl_spec, + vl_opts, + responder, + } => { + let svg_result = inner.vegalite_to_svg(vl_spec, vl_opts).await; + responder.send(svg_result).ok(); + } + VlConvertCommand::VlToSg { + vl_spec, + vl_opts, + responder, + } => { + let sg_result = inner.vegalite_to_scenegraph(vl_spec, vl_opts).await; + responder.send(sg_result).ok(); + } + VlConvertCommand::VlToSgMsgpack { + vl_spec, + vl_opts, + responder, + } => { + let sg_result = + inner.vegalite_to_scenegraph_msgpack(vl_spec, vl_opts).await; + responder.send(sg_result).ok(); + } + VlConvertCommand::VgToPng { + vg_spec, + vg_opts, + scale, + ppi, + responder, + } => { + let png_result = match vg_spec.to_value() { + Ok(v) => inner.vega_to_png(&v, vg_opts, scale, ppi).await, + Err(e) => Err(e), + }; + responder.send(png_result).ok(); + } + VlConvertCommand::VlToPng { + vl_spec, + vl_opts, + scale, + ppi, + responder, + } => { + let png_result = match vl_spec.to_value() { + Ok(v) => inner.vegalite_to_png(&v, vl_opts, scale, ppi).await, + Err(e) => Err(e), + }; + responder.send(png_result).ok(); + } + VlConvertCommand::GetLocalTz { responder } => { + let local_tz = inner.get_local_tz().await; + responder.send(local_tz).ok(); + } + VlConvertCommand::GetThemes { responder } => { + let themes = inner.get_themes().await; + responder.send(themes).ok(); + } + } + } + Ok::<(), AnyError>(()) + })?; + Ok(()) + }); + VlConverterRuntime { sender, handle } +} + +/// Get a sender to the worker thread, respawning it if it has exited. +fn get_or_spawn_sender() -> Result, AnyError> { + let mut guard = VL_CONVERTER_RUNTIME + .lock() + .map_err(|e| anyhow!("Failed to lock worker runtime: {}", e))?; + + if let Some(ref runtime) = *guard { + if !runtime.handle.is_finished() { + return Ok(runtime.sender.clone()); + } + } + + let runtime = spawn_worker_thread(); + let sender = runtime.sender.clone(); + *guard = Some(runtime); + Ok(sender) +} + /// A JSON value that may already be serialized to a string. /// When the caller already has a JSON string (e.g. from Python), this avoids /// a redundant parse→Value→serialize round-trip. @@ -342,9 +479,30 @@ struct InnerVlConverter { worker: MainWorker, initialized_vl_versions: HashSet, vega_initialized: bool, + font_config_version: u64, } impl InnerVlConverter { + /// Refresh the SharedFontConfig in OpState if fonts have been registered + /// since the worker was created (or since the last refresh). + fn refresh_font_config_if_needed(&mut self) -> Result<(), AnyError> { + let current = FONT_CONFIG_VERSION.load(Ordering::Acquire); + if current != self.font_config_version { + let font_config = FONT_CONFIG + .lock() + .map_err(|e| anyhow!("Failed to acquire FONT_CONFIG lock: {}", e))?; + let resolved = font_config.resolve(); + let shared_config = vl_convert_canvas2d_deno::SharedFontConfig::new(resolved, current); + self.worker + .js_runtime + .op_state() + .borrow_mut() + .put(shared_config); + self.font_config_version = current; + } + Ok(()) + } + async fn init_vega(&mut self) -> Result<(), AnyError> { if !self.vega_initialized { // ops are now exposed on globalThis by the extension ESM bootstrap @@ -744,12 +902,14 @@ function vegaLiteToCanvas_{ver_name}(vlSpec, config, theme, warnings, allowedBas // Add shared font config to OpState so canvas contexts use the same fonts as SVG rendering. // We resolve the FontConfig into a fontdb once here; each canvas context then clones // the cached database instead of re-scanning system fonts. + let initial_font_version = FONT_CONFIG_VERSION.load(Ordering::Acquire); { let font_config = FONT_CONFIG .lock() .map_err(|e| anyhow!("Failed to acquire FONT_CONFIG lock: {}", e))?; let resolved = font_config.resolve(); - let shared_config = vl_convert_canvas2d_deno::SharedFontConfig::new(resolved); + let shared_config = + vl_convert_canvas2d_deno::SharedFontConfig::new(resolved, initial_font_version); worker.js_runtime.op_state().borrow_mut().put(shared_config); } @@ -757,6 +917,7 @@ function vegaLiteToCanvas_{ver_name}(vlSpec, config, theme, warnings, allowedBas worker, initialized_vl_versions: Default::default(), vega_initialized: false, + font_config_version: initial_font_version, }; Ok(this) @@ -1339,6 +1500,47 @@ pub enum VlConvertCommand { }, } +impl VlConvertCommand { + /// Send an error to the command's responder, consuming the command. + fn send_error(self, err: AnyError) { + match self { + Self::VlToVg { responder, .. } => { + responder.send(Err(err)).ok(); + } + Self::VgToSvg { responder, .. } => { + responder.send(Err(err)).ok(); + } + Self::VgToSg { responder, .. } => { + responder.send(Err(err)).ok(); + } + Self::VgToSgMsgpack { responder, .. } => { + responder.send(Err(err)).ok(); + } + Self::VlToSvg { responder, .. } => { + responder.send(Err(err)).ok(); + } + Self::VlToSg { responder, .. } => { + responder.send(Err(err)).ok(); + } + Self::VlToSgMsgpack { responder, .. } => { + responder.send(Err(err)).ok(); + } + Self::VgToPng { responder, .. } => { + responder.send(Err(err)).ok(); + } + Self::VlToPng { responder, .. } => { + responder.send(Err(err)).ok(); + } + Self::GetLocalTz { responder } => { + responder.send(Err(err)).ok(); + } + Self::GetThemes { responder } => { + responder.send(Err(err)).ok(); + } + } + } +} + /// Struct for performing Vega-Lite to Vega conversions using the Deno v8 Runtime /// /// # Examples @@ -1375,8 +1577,6 @@ pub enum VlConvertCommand { /// ``` #[derive(Clone)] pub struct VlConverter { - sender: Sender, - _handle: Arc>>, _vegaembed_bundles: HashMap, } @@ -1390,116 +1590,7 @@ impl VlConverter { .try_init() .ok(); - let (sender, mut receiver) = mpsc::channel::(32); - - let handle = Arc::new(thread::spawn(move || { - TOKIO_RUNTIME.block_on(async { - let mut inner = InnerVlConverter::try_new().await?; - while let Some(cmd) = receiver.next().await { - match cmd { - VlConvertCommand::VlToVg { - vl_spec, - vl_opts, - responder, - } => { - let vega_spec = inner.vegalite_to_vega(vl_spec, vl_opts).await; - responder.send(vega_spec).ok(); - } - VlConvertCommand::VgToSvg { - vg_spec, - vg_opts, - responder, - } => { - let svg_result = inner.vega_to_svg(vg_spec, vg_opts).await; - responder.send(svg_result).ok(); - } - VlConvertCommand::VgToSg { - vg_spec, - vg_opts, - responder, - } => { - let sg_result = inner.vega_to_scenegraph(vg_spec, vg_opts).await; - responder.send(sg_result).ok(); - } - VlConvertCommand::VgToSgMsgpack { - vg_spec, - vg_opts, - responder, - } => { - let sg_result = - inner.vega_to_scenegraph_msgpack(vg_spec, vg_opts).await; - responder.send(sg_result).ok(); - } - VlConvertCommand::VlToSvg { - vl_spec, - vl_opts, - responder, - } => { - let svg_result = inner.vegalite_to_svg(vl_spec, vl_opts).await; - responder.send(svg_result).ok(); - } - VlConvertCommand::VlToSg { - vl_spec, - vl_opts, - responder, - } => { - let sg_result = inner.vegalite_to_scenegraph(vl_spec, vl_opts).await; - responder.send(sg_result).ok(); - } - VlConvertCommand::VlToSgMsgpack { - vl_spec, - vl_opts, - responder, - } => { - let sg_result = - inner.vegalite_to_scenegraph_msgpack(vl_spec, vl_opts).await; - responder.send(sg_result).ok(); - } - VlConvertCommand::VgToPng { - vg_spec, - vg_opts, - scale, - ppi, - responder, - } => { - let png_result = match vg_spec.to_value() { - Ok(v) => inner.vega_to_png(&v, vg_opts, scale, ppi).await, - Err(e) => Err(e), - }; - responder.send(png_result).ok(); - } - VlConvertCommand::VlToPng { - vl_spec, - vl_opts, - scale, - ppi, - responder, - } => { - let png_result = match vl_spec.to_value() { - Ok(v) => inner.vegalite_to_png(&v, vl_opts, scale, ppi).await, - Err(e) => Err(e), - }; - responder.send(png_result).ok(); - } - VlConvertCommand::GetLocalTz { responder } => { - let local_tz = inner.get_local_tz().await; - responder.send(local_tz).ok(); - } - VlConvertCommand::GetThemes { responder } => { - let themes = inner.get_themes().await; - responder.send(themes).ok(); - } - } - } - Ok::<(), AnyError>(()) - })?; - - Ok(()) - })); - Self { - sender, - _handle: handle, _vegaembed_bundles: Default::default(), } } @@ -1518,7 +1609,7 @@ impl VlConverter { }; // Send request - match self.sender.send(cmd).await { + match get_or_spawn_sender()?.send(cmd).await { Ok(_) => { // All good } @@ -1548,7 +1639,7 @@ impl VlConverter { }; // Send request - match self.sender.send(cmd).await { + match get_or_spawn_sender()?.send(cmd).await { Ok(_) => { // All good } @@ -1578,7 +1669,7 @@ impl VlConverter { }; // Send request - match self.sender.send(cmd).await { + match get_or_spawn_sender()?.send(cmd).await { Ok(_) => { // All good } @@ -1608,7 +1699,7 @@ impl VlConverter { }; // Send request - match self.sender.send(cmd).await { + match get_or_spawn_sender()?.send(cmd).await { Ok(_) => { // All good } @@ -1638,7 +1729,7 @@ impl VlConverter { }; // Send request - match self.sender.send(cmd).await { + match get_or_spawn_sender()?.send(cmd).await { Ok(_) => { // All good } @@ -1668,7 +1759,7 @@ impl VlConverter { }; // Send request - match self.sender.send(cmd).await { + match get_or_spawn_sender()?.send(cmd).await { Ok(_) => { // All good } @@ -1698,7 +1789,7 @@ impl VlConverter { }; // Send request - match self.sender.send(cmd).await { + match get_or_spawn_sender()?.send(cmd).await { Ok(_) => { // All good } @@ -1736,7 +1827,7 @@ impl VlConverter { }; // Send request - match self.sender.send(cmd).await { + match get_or_spawn_sender()?.send(cmd).await { Ok(_) => { // All good } @@ -1774,7 +1865,7 @@ impl VlConverter { }; // Send request - match self.sender.send(cmd).await { + match get_or_spawn_sender()?.send(cmd).await { Ok(_) => { // All good } @@ -1933,7 +2024,7 @@ impl VlConverter { let cmd = VlConvertCommand::GetLocalTz { responder: resp_tx }; // Send request - match self.sender.send(cmd).await { + match get_or_spawn_sender()?.send(cmd).await { Ok(_) => { // All good } @@ -1954,7 +2045,7 @@ impl VlConverter { let cmd = VlConvertCommand::GetThemes { responder: resp_tx }; // Send request - match self.sender.send(cmd).await { + match get_or_spawn_sender()?.send(cmd).await { Ok(_) => { // All good } @@ -2477,4 +2568,56 @@ try { ); assert_eq!(url, expected); } + + #[tokio::test] + async fn test_font_version_propagation() { + use crate::text::{register_font_directory, FONT_CONFIG_VERSION}; + use std::sync::atomic::Ordering; + + let version_before = FONT_CONFIG_VERSION.load(Ordering::Acquire); + + // Do an initial conversion to ensure the worker is running + let mut ctx = VlConverter::new(); + let vl_spec: serde_json::Value = serde_json::from_str( + r#"{ + "data": {"values": [{"a": 1}]}, + "mark": "point", + "encoding": {"x": {"field": "a", "type": "quantitative"}} + }"#, + ) + .unwrap(); + ctx.vegalite_to_vega( + vl_spec.clone(), + VlOpts { + vl_version: VlVersion::v5_16, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Register a font directory (re-registers the built-in fonts, which is harmless) + let font_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/fonts/liberation-sans"); + register_font_directory(font_dir).unwrap(); + + let version_after = FONT_CONFIG_VERSION.load(Ordering::Acquire); + assert_eq!( + version_after, + version_before + 1, + "FONT_CONFIG_VERSION should increment after register_font_directory" + ); + + // A subsequent conversion should still succeed, confirming the worker + // picked up the font config change without dying + let mut ctx2 = VlConverter::new(); + ctx2.vegalite_to_vega( + vl_spec, + VlOpts { + vl_version: VlVersion::v5_16, + ..Default::default() + }, + ) + .await + .unwrap(); + } } diff --git a/vl-convert-rs/src/text.rs b/vl-convert-rs/src/text.rs index 734d385d..5447a127 100644 --- a/vl-convert-rs/src/text.rs +++ b/vl-convert-rs/src/text.rs @@ -3,6 +3,7 @@ use crate::anyhow::anyhow; use crate::image_loading::custom_string_resolver; use std::collections::HashSet; use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use usvg::fontdb::Database; use usvg::{ @@ -11,6 +12,10 @@ use usvg::{ }; use vl_convert_canvas2d::font_config::{font_config_to_fontdb, CustomFont, FontConfig}; +/// Monotonically increasing version counter for font configuration changes. +/// Incremented each time `register_font_directory` is called. +pub static FONT_CONFIG_VERSION: AtomicU64 = AtomicU64::new(0); + lazy_static! { pub static ref USVG_OPTIONS: Mutex> = Mutex::new(init_usvg_options()); pub static ref FONT_CONFIG: Mutex = Mutex::new(build_default_font_config()); @@ -281,5 +286,8 @@ pub fn register_font_directory(dir: &str) -> Result<(), anyhow::Error> { setup_default_fonts(font_db); } + // Bump version so the shared worker knows to refresh its cached SharedFontConfig + FONT_CONFIG_VERSION.fetch_add(1, Ordering::Release); + Ok(()) }