IME composition text uses document fallback fonts instead of boxes (#565) (#578)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
MarekSpodBiedry 2026-10-12 04:12:39 +02:00 committed by GitHub
parent aaaf68c6c7
commit e9da654903
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 83 additions and 2 deletions

View File

@ -37,6 +37,8 @@ pub struct CanvasState {
pub focused: bool,
pub render_ms: f64,
pub ime_preedit: String,
/// Document faces added to the interface fonts to draw the input method's composition (#565).
pub(crate) ime_faces: Vec<std::sync::Arc<wordcraft_fonts::FontFace>>,
pub want_focus: bool,
pub context_issue: Option<serde_json::Value>,
pub context_synonyms: Option<serde_json::Value>,
@ -109,6 +111,7 @@ impl Default for CanvasState {
focused: false,
render_ms: 0.0,
ime_preedit: String::new(),
ime_faces: Vec::new(),
want_focus: true,
context_issue: None,
context_synonyms: None,
@ -676,6 +679,19 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
});
}
if !app.canvas.ime_preedit.is_empty() {
// The composition is interface text, but it may be in a script the interface fonts lack
// (Bangla, Thai ...): borrow the document fonts' fallback faces, or it draws as boxes (#565).
let new = crate::theme::faces_for_preedit(
&app.canvas.ime_preedit,
&app.canvas.ime_faces,
|c| (c as u32) < 0x250,
|c| wordcraft_fonts::FontDb::global().fallback_for(c, u32::MAX),
);
if !new.is_empty() {
app.canvas.ime_faces.extend(new);
crate::theme::install_fonts_extra(ui.ctx(), app.fonts_hans, app.fonts_system_cjk, &app.canvas.ime_faces);
ui.ctx().request_repaint();
}
let g = painter.text(pos2(x, y1), egui::Align2::LEFT_BOTTOM, &app.canvas.ime_preedit, regular(c.height * scale * 0.8), caret_color);
painter.line_segment([pos2(g.min.x, g.max.y), pos2(g.max.x, g.max.y)], Stroke::new(1.0, caret_color));
}

View File

@ -945,7 +945,7 @@ impl WordApp {
// interface that doesn't open the language list.
let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk();
if !self.styled || lang.prefers_hans() != self.fonts_hans || system_cjk != self.fonts_system_cjk {
theme::install_fonts_with(ctx, lang.prefers_hans(), system_cjk);
theme::install_fonts_extra(ctx, lang.prefers_hans(), system_cjk, &self.canvas.ime_faces);
self.fonts_hans = lang.prefers_hans();
self.fonts_system_cjk = system_cjk;
// Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing);

View File

@ -275,7 +275,52 @@ pub fn install_fonts_for(ctx: &egui::Context, prefer_hans: bool) {
/// a glyph (#241, #486). Reading that font is a large file read, so callers ask for it only when
/// CJK text is on screen: a CJK interface language, or the language names in Options.
pub fn install_fonts_with(ctx: &egui::Context, prefer_hans: bool, system_cjk: bool) {
ctx.set_fonts(font_definitions(prefer_hans, system_cjk));
install_fonts_extra(ctx, prefer_hans, system_cjk, &[]);
}
/// [`install_fonts_with`], also adding document-font `faces` as last-resort fallbacks (#565): the
/// faces that cover the text an input method is composing.
pub fn install_fonts_extra(ctx: &egui::Context, prefer_hans: bool, system_cjk: bool, faces: &[Arc<wordcraft_fonts::FontFace>]) {
let mut fonts = font_definitions(prefer_hans, system_cjk);
add_fallback_faces(&mut fonts, faces);
ctx.set_fonts(fonts);
}
/// Add `faces` after every other font of the interface families.
pub fn add_fallback_faces(fonts: &mut FontDefinitions, faces: &[Arc<wordcraft_fonts::FontFace>]) {
for f in faces {
let name = format!("fallback {} {} {}", f.family, f.style, f.id());
let mut data = FontData::from_owned(f.data().to_vec());
data.index = f.index();
fonts.font_data.insert(name.clone(), Arc::new(data));
for fam in [FontFamily::Proportional, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] {
if let Some(v) = fonts.families.get_mut(&fam) {
v.push(name.clone());
}
}
}
}
/// Document faces needed to draw `text` (the input method's composition), one for each character
/// the interface fonts cannot draw, skipping faces in `have` (#565). Characters of the embedded
/// interface fonts are not looked up; very large faces (system CJK collections) are left to the CJK
/// fallback.
pub fn faces_for_preedit(
text: &str,
have: &[Arc<wordcraft_fonts::FontFace>],
covered: impl Fn(char) -> bool,
lookup: impl Fn(char) -> Option<Arc<wordcraft_fonts::FontFace>>,
) -> Vec<Arc<wordcraft_fonts::FontFace>> {
let mut out: Vec<Arc<wordcraft_fonts::FontFace>> = Vec::new();
for c in text.chars().filter(|c| !c.is_ascii() && !c.is_whitespace() && !covered(*c)) {
if have.iter().chain(&out).any(|f| f.covers(c)) {
continue;
}
if let Some(f) = lookup(c).filter(|f| f.data().len() <= 16 << 20 && !have.iter().chain(&out).any(|h| h.id() == f.id())) {
out.push(f);
}
}
out
}
/// The interface fonts; see [`install_fonts_with`].
@ -510,6 +555,26 @@ pub fn c32(c: wordcraft_doc::Rgb) -> Color32 {
mod tests {
use std::collections::BTreeSet;
/// #565: the composition of an input method (Bangla through ibus) drew as boxes because only the
/// interface fonts were used. Faces found for the uncovered characters are added as fallbacks,
/// once, and not for characters the interface fonts draw.
#[test]
fn preedit_gets_document_fallback_faces() {
let face = wordcraft_fonts::FontDb::global().face("Source Sans 3", "Regular");
let lookup = |_: char| Some(face.clone());
let bangla = "\u{0986}\u{09AE}\u{09BF}";
let got = super::faces_for_preedit(bangla, &[], |_| false, lookup);
assert_eq!(got.len(), 1, "one face, not one per character");
assert!(super::faces_for_preedit(bangla, &got, |_| false, lookup).is_empty(), "already added");
assert!(super::faces_for_preedit(bangla, &[], |_| true, lookup).is_empty(), "the interface fonts draw it");
assert!(super::faces_for_preedit("abc", &[], |_| false, lookup).is_empty(), "ASCII never needs one");
let mut defs = super::font_definitions(false, false);
let before = defs.font_data.len();
super::add_fallback_faces(&mut defs, &got);
assert_eq!(defs.font_data.len(), before + 1);
assert!(defs.families[&egui::FontFamily::Proportional].last().is_some_and(|n| n.starts_with("fallback ")));
}
/// Symbols written as text in the interface code must exist in the interface fonts (Inter and
/// egui's emoji fallbacks); a missing glyph draws as an empty box, as the panes' "✕" close
/// button once did. Letters are the catalogs' business (see the i18n font tests).