Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ATTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk, assisted by Codex | Contributor-original Ukrainian translation in this repository; terminology checked against VESUM 6.8.6 and ULIF (validation tools/data are not bundled) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Ukrainian interface text |
| `crates/ui-egui/src/i18n/pt-br.tsv` | @RicSchonfelder, AI-assisted | Contributor-original Brazilian Portuguese translation in this repository | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Brazilian Portuguese interface text |

## Bundled through dependencies

Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 14 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Stage: alpha** · next: beta, ~20 points and ~120–210 h away

> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Brazilian Portuguese UI catalog added alongside Ukrainian) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)

DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
Expand Down Expand Up @@ -84,7 +84,11 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b

## Languages

English only today; no string catalog yet (Ukrainian is in review as PR #34). Detail:
English, Ukrainian and Brazilian Portuguese: Preferences offers System language, English,
Українська and Português (Brasil). Matching OS/browser locales select a catalog automatically;
explicit desktop choices persist in `ui.json`. Menus, ribbon, command palette, panels, dialogs and
presenter controls use the interface catalog. Presentation content and command IDs retain their
original values. Detail:
[docs/localization-parity.md](docs/localization-parity.md).

| Language | Code | UI strings | Status | Hours to full |
Expand All @@ -102,8 +106,9 @@ English only today; no string catalog yet (Ukrainian is in review as PR #34). De
| Korean | ko | 0% | none | 4–7 |
| Vietnamese | vi | 0% | none | 3–5 |

Plus 8–14 h once for the catalog infrastructure. Other languages shipped: none. PowerPoint for Mac
ships 26.
Other languages shipped: **Ukrainian (uk) and Brazilian Portuguese (pt-BR)**, with English fallback
and bundled glyph coverage. PowerPoint for Mac ships 26 languages; additional catalogs and native
review remain.

## Upcoming

Expand All @@ -113,7 +118,7 @@ Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
image paste (#53), editor soak tests. 10–20 h.
2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG.
36–64 h.
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
3. **Localization:** additional catalogs for zh-Hans, es, fr, de, ja, native review and IME. 27–46 h.
4. **Output:** vector PDF, printing. 10–16 h.
5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline,
presenter view on the right display, native macOS menu bar, Format Shape pane. 33–56 h.
Expand All @@ -128,6 +133,8 @@ app's JSON control channel (`deckcraft --control PORT`).

| Date | What landed |
|---|---|
| 2026-10-12 | Brazilian Portuguese UI catalog (`pt-br.tsv`) added on the Ukrainian infrastructure |
| 2026-10-10 | Ukrainian UI catalog, system-language detection and persisted language choice (#15, #30); translated placeholder prompts preserve presentation content |
| 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) |
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
Expand All @@ -139,6 +146,8 @@ app's JSON control channel (`deckcraft --control PORT`).

| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Recorded the Brazilian Portuguese catalog in the language summary |
| 2026-10-10 | minor | Added Ukrainian UI and language selection to the reorganized language summary |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
Expand Down
2 changes: 1 addition & 1 deletion apps/deckcraft-web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
web-sys = { version = "0.3", features = ["Window", "Navigator", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
9 changes: 5 additions & 4 deletions apps/deckcraft-web/src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub fn start() {
}
let inbox: Inbox = Inbox::default();
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
app.services.system_locale = web_sys::window().and_then(|w| w.navigator().language());
let q = query();
if !q.contains("blank") {
if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
Expand Down Expand Up @@ -111,10 +112,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
let dialog = match purpose {
"picture" => rfd::AsyncFileDialog::new().add_filter("Pictures", PICTURE_EXTS),
"audio" => rfd::AsyncFileDialog::new().add_filter("Audio", AUDIO_EXTS),
"video" => rfd::AsyncFileDialog::new().add_filter("Video", VIDEO_EXTS),
_ => rfd::AsyncFileDialog::new().add_filter("Presentations", PRESENTATION_EXTS),
"picture" => rfd::AsyncFileDialog::new().add_filter(deckcraft_ui_egui::i18n::tr("Pictures"), PICTURE_EXTS),
"audio" => rfd::AsyncFileDialog::new().add_filter(deckcraft_ui_egui::i18n::tr("Audio"), AUDIO_EXTS),
"video" => rfd::AsyncFileDialog::new().add_filter(deckcraft_ui_egui::i18n::tr("Video"), VIDEO_EXTS),
_ => rfd::AsyncFileDialog::new().add_filter(deckcraft_ui_egui::i18n::tr("Presentations"), PRESENTATION_EXTS),
};
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = dialog.pick_file().await else {
Expand Down
1 change: 1 addition & 0 deletions apps/deckcraft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ default-run = "deckcraft"
workspace = true

[dependencies]
sys-locale = "0.3.2"
deckcraft-engine = { workspace = true }
deckcraft-ui-egui = { workspace = true }
deckcraft-media = { workspace = true }
Expand Down
14 changes: 8 additions & 6 deletions apps/deckcraft/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,20 +91,21 @@ fn save_prefs(app: &SlideApp) {

fn services() -> Services {
Services {
system_locale: sys_locale::get_locale(),
pick_open: Some(Box::new(|purpose: &str| {
let d = rfd::FileDialog::new();
let d = match purpose {
"picture" => d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"]),
"picture" => d.add_filter(deckcraft_ui_egui::i18n::tr("Pictures"), &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"]),
"audio" => d.add_filter(
"Audio",
deckcraft_ui_egui::i18n::tr("Audio"),
&["wav", "mp3", "m4a", "m4b", "aac", "flac", "ogg", "oga", "opus", "aif", "aiff", "aifc", "caf", "wma", "weba", "mka"],
),
"video" => d.add_filter("Video", &["mp4", "m4v", "mov", "webm", "mkv", "wmv"]),
"video" => d.add_filter(deckcraft_ui_egui::i18n::tr("Video"), &["mp4", "m4v", "mov", "webm", "mkv", "wmv"]),
_ => d
.add_filter("Presentations", &["deckcraft", "pptx", "potx", "ppsx"])
.add_filter(deckcraft_ui_egui::i18n::tr("Presentations"), &["deckcraft", "pptx", "potx", "ppsx"])
.add_filter("DeckCraft", &["deckcraft"])
.add_filter("PowerPoint", &["pptx", "potx", "ppsx"])
.add_filter("Outline", &["txt", "md"]),
.add_filter(deckcraft_ui_egui::i18n::tr("Outline"), &["txt", "md"]),
};
d.pick_file().map(|p| p.to_string_lossy().to_string())
})),
Expand Down Expand Up @@ -205,7 +206,8 @@ fn main() -> eframe::Result {
if let Ok(v) = app.session.execute("file.recovery.open", &serde_json::json!({}))
&& let Some(n) = v.get("opened").and_then(|o| o.as_array()).map(Vec::len).filter(|n| *n > 0)
{
app.set_status(format!("Recovered {n} unsaved presentation{} from the last session", if n == 1 { "" } else { "s" }));
deckcraft_ui_egui::i18n::set_current(app.ui.interface_language.resolve(app.services.system_locale.as_deref()));
app.set_status(deckcraft_ui_egui::i18n::recovered(n));
}
}
app.integrated_titlebar = cfg!(target_os = "macos");
Expand Down
9 changes: 6 additions & 3 deletions crates/render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ pub struct RenderOpts<'a> {
pub scale: f64,
/// Editor view: placeholder prompts and dashed placeholder outlines.
pub edit: bool,
/// Optional interface prompt provider; custom layout prompts and document text stay intact.
pub placeholder_prompt: Option<fn(PhType) -> &'static str>,
/// Draw shapes marked hidden (Selection pane) — the editor hides them like the show does.
pub show_hidden: bool,
/// Per-shape animation state.
Expand Down Expand Up @@ -166,6 +168,7 @@ impl Default for RenderOpts<'_> {
RenderOpts {
scale: 1.0,
edit: false,
placeholder_prompt: None,
show_hidden: false,
state: None,
skip: &[],
Expand Down Expand Up @@ -773,7 +776,7 @@ impl Renderer {
draw_layout_with(ctx, &l, tr, m, &st.paras);
} else if f.opts.edit && s.ph.is_some() {
let kind = s.ph_type().unwrap_or(PhType::Body);
let prompt_body = prompt_body(rctx, s, kind);
let prompt_body = prompt_body(rctx, s, kind, f.opts.placeholder_prompt.map(|prompt| prompt(kind)));
if !prompt_body.is_empty() {
let grey = Rgba::rgb(0x59, 0x59, 0x59);
draw_text(ctx, rctx, s, &prompt_body, tr, m, &f.fields, Some(grey));
Expand Down Expand Up @@ -1009,7 +1012,7 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>
}

/// The prompt shown in an empty placeholder, formatted like its content would be.
fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody {
fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType, interface_prompt: Option<&str>) -> TextBody {
// Layout/master prompt text when the layout supplies a custom one.
let (lp, _) = resolve::parents(rctx, s);
let custom = lp.and_then(|l| l.ph.as_ref().filter(|p| p.has_custom_prompt).and(l.text.as_ref())).filter(|t| !t.is_empty());
Expand All @@ -1019,7 +1022,7 @@ fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody {
if matches!(rctx.owner, resolve::Owner::Layout | resolve::Owner::Master) {
return TextBody::default();
}
let txt = kind.prompt();
let txt = interface_prompt.unwrap_or_else(|| kind.prompt());
if txt.is_empty() || kind.is_footer_kind() {
return TextBody::default();
}
Expand Down
22 changes: 22 additions & 0 deletions crates/render/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,3 +523,25 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() {
let own = px(&run_deck(RunProps { link, fill: red.clone(), ..Default::default() }));
assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0);
}

#[test]
fn interface_placeholder_prompts_preserve_custom_prompts_and_document_data() {
let mut p = Presentation::default();
let slide = p.slides.first().unwrap();
let shape = slide.shapes.iter().find(|shape| shape.ph_type() == Some(PhType::Title) || shape.ph_type() == Some(PhType::CtrTitle)).unwrap();
let c = Ctx::for_slide(&p, slide).unwrap();
let before = p.clone();
assert_eq!(prompt_body(&c, shape, PhType::Title, Some("Натисніть, щоб додати заголовок")).text(), "Натисніть, щоб додати заголовок");
assert_eq!(p, before);

let layout_id = slide.layout;
let master = Arc::make_mut(p.masters.first_mut().unwrap());
let layout = master.layouts.iter_mut().find(|layout| layout.id == layout_id).unwrap();
let shape = layout.shapes.iter_mut().find(|shape| shape.ph_type() == Some(PhType::Title) || shape.ph_type() == Some(PhType::CtrTitle)).unwrap();
shape.ph.as_mut().unwrap().has_custom_prompt = true;
shape.text = Some(TextBody::from_text("Click to add title"));
let slide = p.slides.first().unwrap();
let shape = slide.shapes.iter().find(|shape| shape.ph_type() == Some(PhType::Title) || shape.ph_type() == Some(PhType::CtrTitle)).unwrap();
let c = Ctx::for_slide(&p, slide).unwrap();
assert_eq!(prompt_body(&c, shape, PhType::Title, Some("Натисніть, щоб додати заголовок")).text(), "Click to add title");
}
Loading