UI: Korean, Japanese and Chinese input and interface text (#101)

The slide turns the input method on while it takes typing, with the candidate window at the
caret, and forwards egui Ime events to text.compose / text.commit, so a Korean keyboard composes
syllables instead of separate jamo. Interface text in scripts the interface fonts lack (Hangul,
Simplified Chinese, Thai...) borrows faces from the font database (installed fonts such as Malgun
Gothic; nothing bundled): file names, titles, notes, outline, sections, comments and typed text.
Ported and widened from WordCraft's #565/#840 fallback. The notes pane keeps text typed before
focus leaves in the same frame (Escape, another slide). Control channel: ui.ime.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-12 22:01:49 -04:00
1 parent 128b93738e
commit d91be2e6d7
12 files changed
+604 -18

No files matched your search

+2
View File
@@ -129,6 +129,8 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
+49 -3
View File
@@ -164,6 +164,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
}
pointer(app, ui, &resp, xf);
overlays(app, ui, &painter, xf, &t);
ime_output(app, ui, xf);
media_bar(app, ui, xf);
gif_buttons(app, ui, xf, &gifs);
context_menu(app, &resp);
@@ -185,6 +186,47 @@ fn visible_region(avail: Rect, slide: Rect, pixel: egui::Vec2) -> deckcraft_rend
}
}
/// Where an input method composes for the slide (#101), on screen: at the insertion point while
/// editing text, or over the one selected shape (typing on it starts editing). `None` when there is
/// nothing to type into.
pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
let st = app.session.active()?;
if let Some(ts) = &st.selection.text {
if ts.notes {
return None;
}
if let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
return Some(Rect::from_two_pos(xf.to_screen(top), xf.to_screen(bot)).expand2(vec2(1.0, 0.0)));
}
let sh = st.shape(ts.shape)?;
return Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)));
}
if st.selection.shapes.len() != 1 {
return None;
}
let sh = st.shape(*st.selection.shapes.first()?)?;
if sh.is_line() {
return None;
}
Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)))
}
/// Turn the input method on for the slide while it takes typing, with its candidate window at
/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
let ctx = ui.ctx();
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.home_open() {
return;
}
let Some(r) = ime_area(app, xf) else { return };
let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default();
let r = to_global * r;
ctx.output_mut(|o| {
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
});
}
/// Play/pause on an animated GIF under the pointer or selected on its own.
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
let Some(d) = app.session.active() else { return };
@@ -453,12 +495,16 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE));
}
// An input method's composition: underlined in place, thicker under the part being converted.
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret));
}
if !ts.is_range() {
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
if blink && let Some((cx, top, bot)) = l.caret(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }) {
let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot)));
painter.line_segment([p0, p1], Stroke::new(1.5, t.caret));
// An empty placeholder shows the caret where typing begins too.
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret));
}
}
}
+17
View File
@@ -147,6 +147,23 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
let Some(t) = s("text") else { return err("missing `text`") };
wrap(app.session.type_text(t).map_err(|e| e.to_string()))
}
"ui.ime" => {
// An input method's event, through egui like a real one: it reaches the slide's text
// or a focused text field (#101).
let ev = if let Some(t) = s("commit") {
egui::ImeEvent::Commit(t.into())
} else if let Some(t) = s("preedit") {
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
(x <= y).then_some(x..y)
});
egui::ImeEvent::Preedit { text: t.into(), active_range_chars: active }
} else {
return err("missing `preedit` or `commit`");
};
app.synthetic.push(egui::Event::Ime(ev));
ok(json!({"queued": true}))
}
"ui.tool.select" => {
let Some(name) = s("tool") else { return err("missing `tool`") };
match tool_kind(name, s("preset")) {
+217
View File
@@ -0,0 +1,217 @@
//! #101: Korean, Japanese and Chinese in the interface: input method composition on the slide and
//! in the notes pane (egui `Ime` events, as egui-winit sends them), and interface text drawn with
//! real glyphs rather than boxes.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use egui::{Event, FontId, ImeEvent};
use serde_json::json;
use crate::{Services, SlideApp};
use deckcraft_engine::Session;
fn harness(session: Session) -> egui_kittest::Harness<'static, SlideApp> {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(session, Services::default()),
);
// The app keeps requesting repaints, so step frames instead of run().
h.run_steps(3);
h
}
fn preedit(text: &str) -> Event {
Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: None })
}
fn title_text(app: &SlideApp) -> String {
let sh = &app.session.active().unwrap().doc.slides[0].shapes[0];
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
}
fn undo_len(app: &SlideApp) -> usize {
app.session.active().unwrap().history.undo.len()
}
/// Typing 한글 with the Korean input method in the title: each preedit replaces the last in the
/// text box (not ㅎ ㅏ ㄴ …), is underlined, and the commit is one undo step. The input method
/// is on while the slide's text is edited, with its window at the insertion point.
#[test]
fn korean_composes_in_a_slide_text_box() {
let mut h = harness(Session::with_new());
assert!(h.output().platform_output.ime.is_none(), "nothing to type into: the input method stays off");
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
h.run_steps(2);
let ime = h.output().platform_output.ime.expect("the input method is on while editing");
let slide = h.state().slide_rect.unwrap();
assert!(slide.contains(ime.rect.center()), "candidate window at the insertion point {:?}, on the slide {slide:?}", ime.rect);
assert!(ime.rect.width() < 8.0 && ime.rect.height() > 4.0, "a caret, not the box: {:?}", ime.rect);
let caret0 = ime.rect;
let undo0 = undo_len(h.state());
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
h.event(preedit(pre));
h.run_steps(1);
assert_eq!(title_text(h.state()), pre, "the preedit is shown in place, replacing the last");
assert_eq!(undo_len(h.state()), undo0, "no undo step while composing");
let st = h.state().session.active().unwrap();
assert!(!deckcraft_engine::cmd::text::composition_underlines(st).is_empty(), "underlined while composing");
}
h.run_steps(1);
let ime = h.output().platform_output.ime.unwrap();
assert!(ime.rect.center().x > caret0.center().x, "the candidate window follows the insertion point");
h.event(Event::Ime(ImeEvent::Commit("한글".into())));
h.run_steps(2);
assert_eq!(title_text(h.state()), "한글");
assert!(h.state().session.active().unwrap().composition.is_none());
assert!(deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap()).is_empty());
assert_eq!(undo_len(h.state()), undo0 + 1, "the composition is one undo step");
// Escape cancels a composition: an empty preedit, and the text is as it was.
h.event(preedit("ㅁ"));
h.event(preedit(""));
h.run_steps(2);
assert_eq!(title_text(h.state()), "한글");
assert_eq!(undo_len(h.state()), undo0 + 1);
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(1);
assert_eq!(title_text(h.state()), "", "one undo removes the whole word");
}
/// Japanese conversion: the part being converted is drawn with a thicker line; a key settles a
/// composition the input method left open.
#[test]
fn japanese_conversion_shows_the_active_part() {
let mut h = harness(Session::with_new());
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
h.event(Event::Ime(ImeEvent::Preedit { text: "日本ご".into(), active_range_chars: Some(2..3) }));
h.run_steps(2);
let u = deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap());
assert_eq!(u.iter().filter(|x| x.2).count(), 1, "{u:?}");
let undo0 = undo_len(h.state());
h.key_press(egui::Key::End);
h.run_steps(1);
assert_eq!(title_text(h.state()), "日本ご");
assert!(h.state().session.active().unwrap().composition.is_none());
assert_eq!(undo_len(h.state()), undo0 + 1);
}
/// Typing on a selected (not edited) shape with an input method replaces its text, as typing does;
/// the input method is on for it.
#[test]
fn composing_on_a_selected_shape_starts_editing_it() {
let mut h = harness(Session::with_new());
let r = h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
let id = r["id"].as_u64().unwrap();
h.state_mut().run("text.exit", json!({})).unwrap();
h.run_steps(2);
assert!(h.state().session.active().unwrap().selection.text.is_none());
assert!(h.output().platform_output.ime.is_some(), "a selected shape takes typing");
h.event(preedit("新"));
h.event(Event::Ime(ImeEvent::Commit("新しい".into())));
h.run_steps(2);
let v = h.state_mut().session.execute("text.get", &json!({"id": id})).unwrap();
assert_eq!(v["text"], "新しい");
}
/// The notes pane is an egui text field: composition shows in it and replaces each preedit, the
/// candidate window is at its caret, and the notes are one undo step when it loses focus.
#[test]
fn korean_composes_in_the_notes_pane() {
let mut h = harness(Session::with_new());
let id = egui::Id::new("notes_edit");
h.ctx.memory_mut(|m| m.request_focus(id));
h.run_steps(2);
assert!(h.ctx.memory(|m| m.has_focus(id)));
let undo0 = undo_len(h.state());
for pre in ["ㄴ", "노", "놑", "노트"] {
h.event(preedit(pre));
h.run_steps(1);
assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one");
}
let ime = h.output().platform_output.ime.expect("the notes field turns the input method on");
let slide = h.state().slide_rect.unwrap();
assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide");
h.event(Event::Ime(ImeEvent::Commit("노트".into())));
h.run_steps(1);
assert_eq!(h.state().notes_buf.2, "노트");
// Focus leaves the field (Escape): the notes are committed as one step.
h.key_press(egui::Key::Escape);
h.run_steps(2);
let notes = h.state().session.active().unwrap().doc.slides[0].notes_text();
assert_eq!(notes, "노트");
assert_eq!(undo_len(h.state()), undo0 + 1);
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), "노트")) || !system_has("노트"));
// Composing, then going to another slide: the notes stay with the slide they were typed for.
h.state_mut().run("slide.new", json!({})).unwrap();
h.state_mut().run("slide.go", json!({"index": 0})).unwrap();
h.run_steps(2);
h.ctx.memory_mut(|m| m.request_focus(id));
h.run_steps(2);
h.event(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.event(preedit("ㅇ"));
h.event(Event::Ime(ImeEvent::Commit("요".into())));
h.run_steps(1);
h.state_mut().run("slide.go", json!({"index": 1})).unwrap();
h.run_steps(2);
let d = h.state().session.active().unwrap();
assert_eq!(d.doc.slides[0].notes_text(), "노트요");
assert_eq!(d.doc.slides[1].notes_text(), "");
}
/// Does some font on this machine (embedded or installed) draw every letter of `s`? Machines
/// without a Korean or Chinese font (a bare Linux runner) can't draw it in the interface either.
fn system_has(s: &str) -> bool {
s.chars().filter(|c| !c.is_whitespace()).all(|c| deckcraft_fonts::FontDb::global().fallback_for(c, u32::MAX).is_some_and(|f| !f.is_color()))
}
/// #101: a Korean file name, title, notes and comments, a Japanese slide and a Chinese section are
/// drawn with glyphs in the interface (title bar, outline, notes pane, comments), not boxes: faces
/// that draw them are borrowed from the font database. A Latin presentation borrows nothing.
#[test]
fn cjk_text_in_the_interface_is_drawn_with_real_glyphs() {
let h = harness(Session::with_new());
assert!(h.state().ui_fonts.faces.is_empty(), "nothing to borrow for {:?}", h.state().session.active().unwrap().title());
let mut s = Session::with_new();
let korean = "한국어 발표 자료";
let japanese = "日本語のスライド";
let chinese = "简体中文演示文稿";
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.set", &json!({"id": title, "text": japanese})).unwrap();
s.execute("slide.notes", &json!({"text": "발표자 노트입니다"})).unwrap();
s.execute("section.add", &json!({"name": chinese})).unwrap();
s.doc_mut().unwrap().path = Some(format!("{korean}.pptx"));
let mut h = harness(s);
h.state_mut().run("view.outline", json!({})).unwrap();
h.run_steps(3);
assert!(h.state().session.active().unwrap().title().contains(korean));
let mut checked = 0;
for text in [korean, japanese, chinese, "발표자 노트입니다"] {
if !system_has(text) {
eprintln!("skipped {text:?}: no font on this machine draws it");
continue;
}
for family in [FontId::proportional(13.0), crate::theme::bold(13.0)] {
let missing: String = text.chars().filter(|c| !c.is_whitespace() && !h.ctx.fonts_mut(|f| f.has_glyph(&family, *c))).collect();
assert!(missing.is_empty(), "{family:?} draws {missing:?} of {text:?} as boxes");
}
checked += 1;
}
if cfg!(windows) || cfg!(target_os = "macos") {
assert_eq!(checked, 4, "Windows and macOS have Korean and Chinese fonts");
}
// Text typed later (a dialog field, a new comment) is looked at too.
let typed = "ไทย";
h.event(Event::Text(typed.into()));
h.run_steps(2);
if system_has(typed) {
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), typed)));
}
}
+58 -1
View File
@@ -23,6 +23,7 @@ pub mod textures;
pub mod theme;
pub mod thumbs;
pub mod toast;
pub mod uifonts;
pub mod widgets;
use std::sync::mpsc::{Receiver, Sender};
@@ -214,6 +215,8 @@ pub struct SlideApp {
/// Quit was confirmed (changes saved or discarded): let the window close.
pub quit_confirmed: bool,
pub(crate) notes_buf: (u64, usize, String),
/// Faces borrowed from the font database for text the interface fonts can't draw (#101).
pub ui_fonts: uifonts::UiFonts,
/// Thumbnail pane drag (slide index, current drop index).
pub(crate) thumb_drag: Option<(usize, usize)>,
}
@@ -253,6 +256,7 @@ impl SlideApp {
last_recovery: 0.0,
quit_confirmed: false,
notes_buf: (0, usize::MAX, String::new()),
ui_fonts: uifonts::UiFonts::default(),
thumb_drag: None,
}
}
@@ -545,7 +549,7 @@ impl SlideApp {
ctx.set_zoom_factor(scale);
}
if !self.styled {
theme::install_fonts(ctx);
theme::install_fonts_with(ctx, &self.ui_fonts.faces);
self.styled = true;
self.restyle = true;
} else {
@@ -568,6 +572,9 @@ impl SlideApp {
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
if self.fonts_ready {
self.note_ui_text(ctx);
}
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
// Closing the window with unsaved changes asks first.
@@ -599,6 +606,52 @@ impl SlideApp {
}
}
/// Borrow faces for interface text in scripts the interface fonts lack (#101): the open
/// presentations' names and text, recent files, and what is typed or composed this frame.
fn note_ui_text(&mut self, ctx: &egui::Context) {
let typed: String = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()),
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()),
_ => None,
})
.collect()
});
self.ui_fonts.note(ctx, &typed);
for d in self.session.documents() {
self.ui_fonts.note(ctx, &d.title());
}
for r in &self.ui.recent {
self.ui_fonts.note(ctx, r);
}
if let Some(st) = self.session.active() {
self.ui_fonts.note_doc(ctx, st);
}
self.ui_fonts.apply(ctx);
}
/// An input method's event for the slide's text (#101): the preedit is shown in the text and
/// replaced on each update; the commit replaces it as one undo step.
fn ime_event(&mut self, e: &egui::ImeEvent) {
let r = match e {
egui::ImeEvent::Preedit { text, active_range_chars } => {
self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end)))
}
egui::ImeEvent::Commit(text) => self.session.commit_text(text),
#[allow(deprecated)]
egui::ImeEvent::Disabled => {
self.session.end_composition();
Ok(Value::Null)
}
_ => Ok(Value::Null),
};
if let Err(e) = r {
self.set_status(e.to_string());
}
}
/// Inject synthetic events (one pointer event per frame).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if self.synthetic.is_empty() {
@@ -646,6 +699,7 @@ impl SlideApp {
self.set_status(e.to_string());
}
}
egui::Event::Ime(ime) => self.ime_event(&ime),
egui::Event::Copy => {
if let Ok(v) = self.session.execute("edit.copy", &json!({}))
&& let Some(t) = v.get("text").and_then(Value::as_str)
@@ -889,6 +943,9 @@ fn js_now() -> f64 {
js_sys::Date::now()
}
#[cfg(test)]
mod ime_tests;
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
+11 -4
View File
@@ -129,12 +129,19 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
ui.painter()
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
// Keep a local buffer per slide; commit when it changes and the field loses focus.
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
app.notes_buf = (uid, idx, current.clone());
}
let id = egui::Id::new("notes_edit");
let focused = ui.ctx().memory(|m| m.has_focus(id));
if !focused && app.notes_buf.2 != current {
// Focus can leave in the frame before the field is drawn (Escape, a click on a
// thumbnail): what was typed is still committed, to the slide it was typed for.
let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id));
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone()));
let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text());
if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) {
let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx}));
}
}
if !focused && !was_focused && app.notes_buf.2 != current {
app.notes_buf.2 = current.clone();
}
let mut buf = app.notes_buf.2.clone();
+30 -1
View File
@@ -155,6 +155,18 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
/// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's.
pub fn install_fonts(ctx: &egui::Context) {
install_fonts_with(ctx, &[]);
}
/// [`install_fonts`], with `extra` faces from the font database after every interface font: the
/// installed or embedded faces that draw text the interface faces lack (Hangul, Simplified
/// Chinese, Devanagari…, #101). See [`crate::uifonts`].
pub fn install_fonts_with(ctx: &egui::Context, extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
ctx.set_fonts(font_definitions(extra));
}
/// The interface font definitions; see [`install_fonts_with`].
pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) -> FontDefinitions {
let mut defs = FontDefinitions::default();
let mut names = vec![];
for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() {
@@ -178,7 +190,24 @@ pub fn install_fonts(ctx: &egui::Context) {
let mut bold: Vec<String> = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect();
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
defs.families.insert(FontFamily::Name("bold".into()), bold);
ctx.set_fonts(defs);
add_fallback_faces(&mut defs, extra);
defs
}
/// Add `faces` after every other font of each interface family (the last fallbacks).
pub fn add_fallback_faces(defs: &mut FontDefinitions, faces: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
for f in faces {
let name = format!("fallback {} {} {}", f.family, f.style, f.id());
if defs.font_data.contains_key(&name) {
continue;
}
let mut data = FontData::from_owned(f.data().to_vec());
data.index = f.index();
defs.font_data.insert(name.clone(), std::sync::Arc::new(data));
for list in defs.families.values_mut() {
list.push(name.clone());
}
}
}
pub fn bold(size: f32) -> egui::FontId {
+202
View File
@@ -0,0 +1,202 @@
//! Interface fonts for text in any script (#101). The interface faces (Inter, BIZ UDPGothic,
//! Noto Sans Arabic and egui's own) leave Hangul, Simplified Chinese, Thai, Devanagari… without
//! glyphs, so a Korean file name, title, speaker note or comment would be drawn as boxes. For each
//! character on screen that they lack, a face that has it is borrowed from the font database (the
//! craft-fonts faces, then installed fonts such as Malgun Gothic, Apple SD Gothic Neo or Noto
//! Sans CJK) and added after the interface faces. Nothing is bundled for it: on the web, where
//! there are no installed fonts, only the embedded faces are used.
//!
//! Ported from WordCraft's IME composition and window-caption fallback (#565, #840), widened from
//! the composition and caption to every text the interface draws from the presentation.
use std::collections::HashSet;
use std::sync::Arc;
use deckcraft_fonts::{FontDb, FontFace};
/// Faces larger than this are left out (egui keeps a copy of each face it is given).
const MAX_FACE_BYTES: usize = 48 << 20;
/// Faces borrowed for the interface, and the characters already looked at.
#[derive(Default)]
pub struct UiFonts {
/// Faces added after the interface fonts, in the order they were found.
pub faces: Vec<Arc<FontFace>>,
/// Characters already looked at (drawn, given a face, or found in no face).
seen: HashSet<char>,
/// The presentation last looked through: (uid, revision).
doc: Option<(u64, u64)>,
/// Faces were added since the fonts were last installed.
changed: bool,
}
/// Characters worth a lookup: not ASCII, a space, a control or a private-use character.
fn wanted(c: char) -> bool {
!(c.is_ascii() || c.is_whitespace() || c.is_control() || matches!(c, '\u{E000}'..='\u{F8FF}' | '\u{F0000}'..))
}
/// The faces needed to draw `text`, beyond `have`: one for each character that `covered` says the
/// interface fonts can't draw, found with `lookup`, skipping characters already `seen`. Colour and
/// very large faces are skipped (egui draws neither well).
pub fn faces_for_text(
text: &str,
have: &[Arc<FontFace>],
seen: &mut HashSet<char>,
mut covered: impl FnMut(char) -> bool,
lookup: impl Fn(char) -> Option<Arc<FontFace>>,
) -> Vec<Arc<FontFace>> {
let mut out: Vec<Arc<FontFace>> = Vec::new();
for c in text.chars().filter(|c| wanted(*c)) {
if !seen.insert(c) || covered(c) || have.iter().chain(&out).any(|f| f.covers(c)) {
continue;
}
let Some(f) = lookup(c) else { continue };
if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) {
continue;
}
out.push(f);
}
out
}
impl UiFonts {
/// Look at `text` the interface is about to draw; borrow faces for what it can't draw.
pub fn note(&mut self, ctx: &egui::Context, text: &str) {
if !text.chars().any(|c| wanted(c) && !self.seen.contains(&c)) {
return;
}
let font = egui::FontId::proportional(13.0);
let new = ctx.fonts_mut(|f| {
faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX))
});
if !new.is_empty() {
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
self.faces.extend(new);
self.changed = true;
}
}
/// Look through the text of a presentation the interface shows (once per revision): its
/// title, slide text and names, notes, comments, sections and layout names.
pub fn note_doc(&mut self, ctx: &egui::Context, st: &deckcraft_engine::DocState) {
if self.doc == Some((st.uid, st.revision)) {
return;
}
self.doc = Some((st.uid, st.revision));
let text = ui_text(st);
self.note(ctx, &text);
}
/// Install the interface fonts again when faces were added. True if they were.
pub fn apply(&mut self, ctx: &egui::Context) -> bool {
if !std::mem::take(&mut self.changed) {
return false;
}
crate::theme::install_fonts_with(ctx, &self.faces);
ctx.request_repaint();
true
}
}
/// The text of a presentation that the interface can show (not the slides' pictures of it).
pub fn ui_text(st: &deckcraft_engine::DocState) -> String {
let mut out = st.title();
let mut add = |s: &str| {
if s.chars().any(wanted) {
out.push('\n');
out.push_str(s);
}
};
let body = |b: &deckcraft_model::text::TextBody, add: &mut dyn FnMut(&str)| {
for p in &b.paragraphs {
for r in &p.runs {
add(&r.text);
}
}
};
let doc = &st.doc;
add(&doc.props.title);
for sec in &doc.sections {
add(&sec.name);
}
let shapes = |list: &[deckcraft_model::Shape], add: &mut dyn FnMut(&str)| {
deckcraft_model::walk(list, &mut |sh, _| {
add(&sh.name);
add(&sh.title);
add(&sh.descr);
if let Some(t) = &sh.text {
body(t, add);
}
if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
for row in &tb.rows {
for cell in &row.cells {
body(&cell.text, add);
}
}
}
});
};
for s in &doc.slides {
add(&s.name);
shapes(&s.shapes, &mut add);
body(&s.notes, &mut add);
let mut stack: Vec<&deckcraft_model::Comment> = s.comments.iter().collect();
while let Some(c) = stack.pop() {
add(&c.author);
add(&c.text);
stack.extend(c.replies.iter());
}
}
for m in &doc.masters {
add(&m.name);
for l in &m.layouts {
add(&l.name);
}
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn face(family: &str) -> Arc<FontFace> {
FontDb::global().face(family, "Regular")
}
/// One face for a whole string, looked up once per character, never for characters the
/// interface already draws, and never twice.
#[test]
fn one_face_per_script_and_each_character_once() {
let inter = face("Inter");
let looked = std::cell::Cell::new(0);
let lookup = |_: char| {
looked.set(looked.get() + 1);
Some(inter.clone())
};
let mut seen = HashSet::new();
let got = faces_for_text("한국어 발표", &[], &mut seen, |_| false, lookup);
assert_eq!(got.len(), 1, "one face, not one per character");
// Inter doesn't cover Hangul, so every character was looked up once.
assert_eq!(looked.get(), 5);
assert!(faces_for_text("한국어 발표", &got, &mut seen, |_| false, lookup).is_empty(), "already seen");
assert_eq!(looked.get(), 5);
let mut seen = HashSet::new();
assert!(faces_for_text("한국어", &[], &mut seen, |_| true, lookup).is_empty(), "the interface draws it");
assert!(faces_for_text("abc \n\t\u{E001}", &[], &mut HashSet::new(), |_| false, lookup).is_empty(), "ASCII, spaces, private use");
assert!(faces_for_text("한", std::slice::from_ref(&inter), &mut HashSet::new(), |_| false, lookup).is_empty(), "already borrowed");
}
#[test]
fn interface_text_of_a_presentation() {
let mut s = deckcraft_engine::Session::with_new();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.set", &serde_json::json!({"id": title, "text": "한국어 제목"})).unwrap();
s.execute("slide.notes", &serde_json::json!({"text": "発表者ノート"})).unwrap();
let t = ui_text(s.doc().unwrap());
assert!(t.contains("한국어 제목") && t.contains("発表者ノート"), "{t}");
assert!(t.starts_with(&s.doc().unwrap().title()));
}
}
+1
View File
@@ -19,6 +19,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
| `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels |
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane |
| `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) |
| `ui.dialog.open` / `ui.dialog.close` | `{id, fields?}` | Open or close a dialog by id; `fields` pre-fills its fields (About: `{"tab": "contributors"}` or `"models"`) |
+7 -2
View File
@@ -1,6 +1,6 @@
# Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
@@ -225,7 +225,11 @@ Detail in [localization-parity.md](localization-parity.md).
- **English only, no string catalog** (#15, #30; Ukrainian PR #34 open). PowerPoint for Mac ships
26 languages. Infrastructure 8–14 h, then 3–16 h per language plus native review. Beta needs the
infrastructure and at least Simplified Chinese, Spanish, French, German and Japanese (~30–45 h).
- **No IME composition in text editing** (CJK input). 3–6 h.
- **Hangul in the interface needs an installed Korean font** (#101). craft-fonts has no Korean face
(Noto CJK is excluded by the asset policy), so the web build draws Korean file names, notes and
outline text as boxes; desktop borrows Malgun Gothic, Apple SD Gothic Neo or Noto Sans CJK.
Adding an openly licensed Korean face (e.g. Nanum Gothic, OFL) to craft-fonts: 1–2 h.
- **Table cells don't underline an IME composition** (the text is composed in place, #101). 1 h.
- **UI isn't mirrored for right-to-left languages.** 6–10 h.
- **Browser build draws Arabic and Hebrew as missing-glyph boxes in exports** (#81). 1–2 h.
@@ -251,6 +255,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40%
ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too
@@ -46,7 +46,7 @@ because it mostly follows from hardware use.
| Pinch to zoom, two-finger gestures | yes | no |
| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width |
| Touch screens (Windows) | yes | basic pointer only |
| IME input methods for CJK text | yes | no (see [localization-parity.md](localization-parity.md)) |
| IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) |
| Dictation | yes (macOS) | no |
## Performance
@@ -71,4 +71,5 @@ outlines every frame (#67, fix open), and the decoders run in software.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | IME input methods (#101) |
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |
+7 -5
View File
@@ -1,6 +1,6 @@
# Localization parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text, #101; previous: major, created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft's interface is English only today: strings are literals in the UI and engine code, with
no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34
@@ -25,16 +25,16 @@ support partly there), **55–90 h** to parity plus native-speaker review.
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support (slide text) | Native review | Status | PowerPoint for Mac UI | Hours to full |
|---|---|---|---|---|---|---|---|---|
| English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 |
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; **no IME composition**; no true vertical layout | no | none | yes | 6–10 (incl. IME) |
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; IME composition (#101); no true vertical layout | no | none | yes | 6–10 (incl. IME) |
| Spanish | es | 0 (0%) | no | full (Latin) | no | none | yes (es, es-MX) | 3–5 |
| Hindi | hi | 0 (0%) | no | Devanagari shaping through HarfRust, untested; no bundled Devanagari font | no | none | **no** (Windows only) | 4–7 |
| Arabic | ar | 0 (0%) | no | bidi, contextual shaping, kashida justification, RTL bullets and caret (#4); **UI not mirrored**; browser export shows boxes (#81) | no | none | yes | 10–16 (incl. UI mirroring) |
| French | fr | 0 (0%) | no | full (Latin) | no | none | yes (fr, fr-CA) | 3–5 |
| Portuguese | pt | 0 (0%) | no | full (Latin) | no | none | yes (pt-BR, pt-PT) | 3–5 |
| Indonesian | id | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; no IME composition; vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; IME composition (#101); vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
| German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
| Korean | ko | 0 (0%) | no | system Hangul fonts; no IME composition | no | none | yes | 4–7 |
| Korean | ko | 0 (0%) | no | system Hangul fonts, in the interface too; IME composition (#101) | no | none | yes | 4–7 |
| Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 |
Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more
@@ -44,7 +44,8 @@ Polish, Russian, Slovak, Swedish, Thai, Turkish and Traditional Chinese.
Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a
sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows
the system (#15), plural and number formatting, and an `xtask` check that every key has a
translation. IME composition (3–6 h) is counted once, under Simplified Chinese.
translation. IME composition landed with #101 (slide text, notes and text fields; one undo step
per composition); the hours that included it are kept until the next re-measure.
## Proofing languages
@@ -56,4 +57,5 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) |
| 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle |