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
@@ -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());@@ -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| {