diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -12,7 +12,7 @@ use wordcraft_layout::display::CommentDisplay;
 use wordcraft_layout::{DocLayout, Page, Placed};
 
 use crate::WordApp;
-use crate::theme::{Tokens, regular, semibold};
+use crate::theme::{Tokens, regular, semibold, ui_font_size};
 
 mod balloons;
 mod context_menu;
@@ -1136,7 +1136,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
                     ui.ctx().request_repaint();
                 }
                 let color = caret_color(app);
-                let g = painter.layout_no_wrap(app.canvas.ime_preedit.clone(), regular(c.height * scale * 0.8), Color32::PLACEHOLDER);
+                // The composition is drawn at a document-derived size: clamp it, or a very
+                // large line height (or zoom) panics epaint's glyph atlas (#3624).
+                let g = painter.layout_no_wrap(app.canvas.ime_preedit.clone(), regular(ui_font_size(c.height * scale * 0.8)), Color32::PLACEHOLDER);
                 let at = preedit_cursor_chars(&app.canvas.ime_preedit, app.canvas.ime_preedit_range.as_ref());
                 ime_dx = g.pos_from_cursor(egui::text::CCursor::new(at)).min.x;
                 let r = Rect::from_min_size(pos2(x, y1 - g.size().y), g.size());
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -632,6 +632,13 @@ pub fn regular(size: f32) -> FontId {
     FontId::proportional(size)
 }
 
+/// A document-derived size used for interface text: finite and small enough for epaint's glyph
+/// atlas (an unclamped caret height at a large line spacing and zoom panics the atlas, N-16 /
+/// #3624), and never too small to read.
+pub fn ui_font_size(px: f32) -> f32 {
+    if px.is_finite() { px.clamp(6.0, 160.0) } else { 12.0 }
+}
+
 /// A UI type rung: size, weight pairing, and size-specific letter-spacing (large text is
 /// tightened, small text opened). Use these instead of raw `regular(size)`/`semibold(size)`.
 #[derive(Clone, Copy, Debug, PartialEq)]
@@ -821,6 +828,21 @@ pub fn c32(c: wordcraft_doc::Rgb) -> Color32 {
 mod tests {
     use std::collections::BTreeSet;
 
+    #[test]
+    fn ui_font_size_is_always_in_range() {
+        // N-16 / #3624: an interface font size derived from document layout must stay finite
+        // and inside the glyph atlas, whatever the paragraph's spacing and the zoom say.
+        for px in [0.0, -3.0, 5.0, 12.5, 160.0, 1.0e9, f32::INFINITY, f32::NAN] {
+            let s = super::ui_font_size(px);
+            assert!(s.is_finite() && (6.0..=160.0).contains(&s), "{px} -> {s}");
+        }
+        // The composition's size is the caret height times the zoom times 0.8, and the caret
+        // height covers the line height: a 1638 pt font at the largest Multiple spacing (132)
+        // on a 500 % zoom. That is the document the issue names, and it must come out at the
+        // cap rather than at the raw value.
+        assert_eq!(super::ui_font_size(1638.0 * 132.0 * 5.0 * 0.8), 160.0);
+    }
+
     /// WCAG contrast ratio between two opaque colours.
     pub(super) fn contrast(a: egui::Color32, b: egui::Color32) -> f32 {
         let lum = |c: egui::Color32| {
