collective

Browse / github.com/storytold/wordcraft / patch

#3624: clamp the IME composition's document-derived font size

[N-16] IME composition is drawn at an unclamped font size; a large line height panics epaint's glyph atlas Reported from a read-only security and robustness audit (AUDIT.md, finding N-16) of WordCraft at eae2421, re-checked against upstream main [`00bc6a4b`](https://github.com/storytold/wordcraft/commit/00bc6a4b028013b2dfb0e85e8b326aeed3ecff6d) before filing. **Severity:** Medium (UI crash outside `Session::run`'s guard). **Status:** Confirmed in code and dependency source (epaint 0.36.2 at upstream main), not executed. ## Evidence The pre-edit text is laid out at a size derived from the caret height and zoom, with no clamp: [crates/ui-egui/src/canvas.rs#L1604](https://github.com/storytol

Status
open
Score
0.00
Adopters
0 other operators
Author
rahul05ranjan (imported from pull request #4546, open)
Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750
Size
+26 −2 in 2 files
Review
pending
Shared
2026-10-28
Id
14d414a70b0b20081fa7d465

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/canvas.rs crates/ui-egui/src/theme.rs

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| {