Japanese support: IME input in the Type tool and CJK glyphs in the UI (#101)

Japanese could not be typed or displayed in the UI.

- Type tool: handle egui IME events (Preedit/Commit). The preedit text is
  written into the layer and replaced on every update, underlined while
  uncommitted, and the caret rect is reported through output.ime so the OS
  enables the IME and positions its candidate window. Without output.ime,
  egui-winit never calls set_ime_allowed, which is why only paste worked.
- UI fonts: append the first installed OS Japanese font (Hiragino, Yu Gothic,
  Meiryo, Noto Sans CJK JP, IPA...) to the end of every egui font family, so
  Latin text keeps Inter / JetBrains Mono and only missing glyphs fall
  through. No font is bundled; a missing font is not an error. Native only.
- Text engine: try Japanese fallback families before the SC ones.

Not covered: the web build has no CJK font, UI strings are not translated,
and text layers do not set a "ja" locale yet.

Tested: IME preedit/commit/cancel/stale-range unit test, font fallback unit
test, ui-egui and text crate tests, clippy -D warnings, cargo xtask layers.
Verified by hand on macOS (UI glyphs, IME composition, candidate window).

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Kentaro Tsutsumi 2026-10-05 14:02:52 +09:00 committed by GitHub
parent ab0fa25897
commit 08512df255
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 180 additions and 3 deletions

View File

@ -33,9 +33,16 @@ const FALLBACK_CANDIDATES: &[&str] = &[
"Arial Hebrew",
"Noto Sans Arabic",
"Noto Sans Hebrew",
// Japanese first, so shared Han characters get Japanese forms before the SC fonts are tried.
"Hiragino Sans",
"Hiragino Kaku Gothic ProN",
"Yu Gothic",
"Meiryo",
"Noto Sans CJK JP",
"Noto Sans JP",
"IPAexGothic",
"Noto Sans CJK SC",
"PingFang SC",
"Hiragino Sans",
"Apple Color Emoji",
"Segoe UI Emoji",
"Noto Color Emoji",

View File

@ -476,6 +476,10 @@ pub struct TextEdit {
pub created: bool,
#[serde(skip)]
pub dragging: bool,
/// IME composition in progress: (start, length) in characters. The preedit text lives in the
/// layer so it lays out like typed text; each IME update replaces it.
#[serde(skip)]
pub preedit: Option<(usize, usize)>,
}
/// View-menu overlays.

View File

@ -290,9 +290,65 @@ pub fn install_fonts(ctx: &egui::Context) {
stack.extend(fallback.iter().cloned());
fonts.families.insert(FontFamily::Name(fam.into()), stack);
}
#[cfg(not(target_arch = "wasm32"))]
add_cjk_fallback(&mut fonts, &cjk_font_candidates());
ctx.set_fonts(fonts);
}
/// Name under which the OS Japanese/CJK font is registered.
const CJK_FONT: &str = "system-cjk";
/// Skip absurdly large files (a corrupt or non-font path must not eat memory).
const CJK_MAX_BYTES: u64 = 128 << 20;
/// OS-installed fonts with Japanese coverage, most preferred first: (path, face index in a collection).
#[cfg(not(target_arch = "wasm32"))]
fn cjk_font_candidates() -> Vec<(std::path::PathBuf, u32)> {
use std::path::PathBuf;
let list: &[(&str, u32)] = if cfg!(target_os = "macos") {
&[
("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", 0),
("/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc", 0),
("/Library/Fonts/Arial Unicode.ttf", 0),
("/System/Library/Fonts/Hiragino Sans GB.ttc", 0),
]
} else if cfg!(target_os = "windows") {
&[("C:\\Windows\\Fonts\\YuGothM.ttc", 0), ("C:\\Windows\\Fonts\\YuGothR.ttc", 0), ("C:\\Windows\\Fonts\\meiryo.ttc", 0), ("C:\\Windows\\Fonts\\msgothic.ttc", 0)]
} else {
&[
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 0),
("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", 0),
("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", 0),
("/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", 0),
("/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf", 0),
("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", 0),
("/usr/share/fonts/truetype/takao-gothic/TakaoPGothic.ttf", 0),
]
};
list.iter().map(|(p, i)| (PathBuf::from(p), *i)).collect()
}
/// Appends the first readable font from `candidates` to the end of every family's stack, so
/// Latin text keeps using Inter / JetBrains Mono and only missing glyphs (Japanese) fall through.
/// Without it egui draws CJK as tofu. Returns whether a font was added; none found is not an error.
#[cfg(not(target_arch = "wasm32"))]
fn add_cjk_fallback(fonts: &mut FontDefinitions, candidates: &[(std::path::PathBuf, u32)]) -> bool {
for (path, index) in candidates {
let Ok(meta) = std::fs::metadata(path) else { continue };
if !meta.is_file() || meta.len() == 0 || meta.len() > CJK_MAX_BYTES {
continue;
}
let Ok(bytes) = std::fs::read(path) else { continue };
let mut data = FontData::from_owned(bytes);
data.index = *index;
fonts.font_data.insert(CJK_FONT.to_owned(), Arc::new(data));
for stack in fonts.families.values_mut() {
stack.push(CJK_FONT.to_owned());
}
return true;
}
false
}
pub fn medium(size: f32) -> FontId {
FontId::new(size, FontFamily::Name("medium".into()))
}
@ -535,3 +591,32 @@ pub mod live {
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod cjk_tests {
use super::*;
#[test]
fn fallback_is_appended_to_every_family_and_missing_files_are_skipped() {
let dir = std::env::temp_dir().join(format!("photocraft-cjk-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let font = dir.join("f.ttf");
std::fs::write(&font, include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
let empty = dir.join("empty.ttf");
std::fs::write(&empty, b"").unwrap();
let mut fonts = FontDefinitions::default();
let before: Vec<usize> = fonts.families.values().map(Vec::len).collect();
assert!(add_cjk_fallback(&mut fonts, &[(dir.join("missing.ttc"), 0), (empty, 0), (dir.join("nope"), 0), (font, 0)]));
assert!(fonts.font_data.contains_key(CJK_FONT));
for (stack, n) in fonts.families.values().zip(before) {
assert_eq!(stack.len(), n + 1);
assert_eq!(stack.last().map(String::as_str), Some(CJK_FONT), "must be last so Latin keeps its own font");
}
// Nothing usable: untouched.
let mut fonts = FontDefinitions::default();
assert!(!add_cjk_fallback(&mut fonts, &[(dir.join("missing.ttc"), 0)]));
assert!(!fonts.font_data.contains_key(CJK_FONT));
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -100,7 +100,7 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) -> boo
if let Some(id) = hit_layer(app, x, y) {
let _ = app.session.select_layer(id);
let off = hit_offset(app, id, x, y);
app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: session_key(app), created: false, dragging: true });
app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: session_key(app), created: false, dragging: true, preedit: None });
return true;
}
false
@ -149,7 +149,7 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
}
// Like Photoshop: the placeholder is selected, so typing replaces it.
let n = PLACEHOLDER.chars().count();
app.ui.text_edit = Some(TextEdit { layer: id, caret: n, anchor: 0, session: key, created: true, dragging: false });
app.ui.text_edit = Some(TextEdit { layer: id, caret: n, anchor: 0, session: key, created: true, dragging: false, preedit: None });
}
}
@ -177,6 +177,33 @@ fn insert(app: &mut PhotocraftApp, s: &str) {
}
}
/// IME composition. The preedit text is written into the layer (so it lays out and reflows like
/// typed text) and replaced by every update; `commit` makes the result final.
fn ime_update(app: &mut PhotocraftApp, s: &str, commit: bool) {
let Some(ed) = app.ui.text_edit.clone() else { return };
let Some(text) = current_text(app, LayerId(ed.layer)) else { return };
let n = text.chars().count();
let s = s.replace("\r\n", "\n").replace('\r', "\n");
let (start, end) = match ed.preedit {
Some((p, l)) if p.saturating_add(l) <= n => (p, p + l),
_ => (ed.caret.min(ed.anchor).min(n), ed.caret.max(ed.anchor).min(n)),
};
if start == end && s.is_empty() {
if let Some(e) = app.ui.text_edit.as_mut() {
e.preedit = None;
}
return;
}
let len = s.chars().count();
if app.run("type.edit", json!({"layer": ed.layer, "replace": {"start": start, "end": end, "text": s}, "coalesce": ed.session})).is_ok()
&& let Some(e) = app.ui.text_edit.as_mut()
{
e.caret = start + len;
e.anchor = e.caret;
e.preedit = if commit || len == 0 { None } else { Some((start, len)) };
}
}
/// Character index of the previous / next word boundary.
fn word_boundary(text: &str, from: usize, forward: bool) -> usize {
let chars: Vec<char> = text.chars().collect();
@ -274,6 +301,11 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
handled[k] = true;
match ev {
egui::Event::Text(s) | egui::Event::Paste(s) => insert(app, s),
// A bare line break from the IME is the Enter key, which the key arm handles.
egui::Event::Ime(egui::ImeEvent::Preedit { text: s, .. } | egui::ImeEvent::Commit(s)) if s == "\n" || s == "\r" => handled[k] = false,
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => ime_update(app, text, false),
egui::Event::Ime(egui::ImeEvent::Commit(s)) => ime_update(app, s, true),
egui::Event::Ime(_) => {}
egui::Event::Copy | egui::Event::Cut => {
if a < b {
ctx.copy_text(text.chars().skip(a).take(b - a).collect());
@ -385,6 +417,15 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
let p = aff.apply(Point::new(x as f64, y as f64));
xf.to_screen(p.x as f32, p.y as f32)
};
// Tell the OS where the caret is: this is what enables the IME and places its candidate window.
{
let (x, top, bot) = l.caret(byte_of(&text, ed.caret));
let (x, top, bot) = if l.lines.is_empty() { (0.0, -(12.0 * l.px_per_pt.max(1.0)), 3.0) } else { (x, top, bot) };
let r = egui::Rect::from_two_pos(scr(x, top), scr(x, bot)).expand2(egui::vec2(1.0, 0.0));
painter.ctx().output_mut(|o| {
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
});
}
// Frame: paragraph text shows its box with handles; point text an underline per line.
let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape));
let frame = Stroke::new(1.0, t.accent);
@ -406,6 +447,19 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
}
}
}
// Uncommitted IME text is underlined.
if let Some((ps, pl)) = ed.preedit {
let (a, b) = (byte_of(&text, ps), byte_of(&text, ps + pl));
for (li, ln) in l.lines.iter().enumerate() {
let xs: Vec<(f32, f32)> =
l.clusters.iter().filter(|c| c.line == li && c.range.start >= a && c.range.end <= b).map(|c| (c.x, c.x + c.advance)).collect();
let (x0, x1) = xs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (p, q)| (lo.min(*p), hi.max(*q)));
if x0 < x1 {
let y = ln.baseline + ln.descent * 0.5;
painter.line_segment([scr(x0, y), scr(x1, y)], Stroke::new(1.5, t.accent));
}
}
}
// Selection: per line, the clusters inside [a, b).
let (a, b) = (byte_of(&text, ed.caret.min(ed.anchor)), byte_of(&text, ed.caret.max(ed.anchor)));
if a < b {
@ -945,6 +999,33 @@ mod tests {
assert!(app.ui.text_edit.is_none());
}
#[test]
fn ime_preedit_is_replaced_by_each_update_and_commit_finalises() {
let mut app = app();
pointer_up(&mut app, [50.0, 100.0], [50.0, 100.0]);
// Each preedit update replaces the previous one (and the placeholder selection first).
ime_update(&mut app, "に", false);
assert_eq!(layer_text(&app), "に");
ime_update(&mut app, "にほん", false);
ime_update(&mut app, "にほんご", false);
assert_eq!(layer_text(&app), "にほんご");
assert_eq!(app.ui.text_edit.as_ref().unwrap().preedit, Some((0, 4)));
ime_update(&mut app, "日本語", true);
assert_eq!(layer_text(&app), "日本語");
let ed = app.ui.text_edit.clone().unwrap();
assert_eq!((ed.caret, ed.anchor, ed.preedit), (3, 3, None));
// The next composition starts after the committed text; cancelling removes it again.
ime_update(&mut app, "あ", false);
assert_eq!(layer_text(&app), "日本語あ");
ime_update(&mut app, "", false);
assert_eq!(layer_text(&app), "日本語");
assert_eq!(app.ui.text_edit.as_ref().unwrap().preedit, None);
// A stale preedit range (text shortened by undo) must not panic.
app.ui.text_edit.as_mut().unwrap().preedit = Some((10, 5));
ime_update(&mut app, "x", true);
assert_eq!(layer_text(&app), "日本語x");
}
#[test]
fn empty_new_layer_is_deleted_on_commit() {
let mut app = app();