diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6650,6 +6650,7 @@ name = "wordcraft"
 version = "0.11.0"
 dependencies = [
  "arboard",
+ "block2 0.6.2",
  "chrono",
  "clipboard-win",
  "craft-print",
diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml
--- a/apps/wordcraft/Cargo.toml
+++ b/apps/wordcraft/Cargo.toml
@@ -39,12 +39,15 @@ fmv-macos-events = "=0.1.0"
 # NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
 # at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
 # traffic lights, the window, button and view ones.
-objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard", "NSSavePanel", "NSPanel", "NSPopUpButton", "NSMenuItemCell", "NSButtonCell", "NSActionCell", "NSCell", "NSTextField", "NSStackView", "block2", "objc2-core-foundation"] }
 objc2 = "0.6"
 # A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside
 # arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in
 # the build through arboard, at this version; this adds only the NSString bindings.
-objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSArray", "NSURL", "NSGeometry", "objc2-core-foundation"] }
+# Save As's File Format menu (`save_panel`): the Save panel's completion handler is a block.
+# Already in the build through objc2-app-kit, at this version.
+block2 = "0.6"
 # Closing the window must quit through NSApplication rather than by dropping the NSWindow
 # inside the AppKit run loop (see `schedule_macos_terminate`).
 dispatch2 = "0.3"
diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs
--- a/apps/wordcraft/src/file_dialogs.rs
+++ b/apps/wordcraft/src/file_dialogs.rs
@@ -59,6 +59,14 @@ impl Launcher {
     /// keeps them on top of it. Call after every pass that may ask for one.
     pub fn show(&self, parent: &eframe::Frame) {
         while let Ok((req, answer)) = self.queue.try_recv() {
+            // macOS: Save As has Word for Mac's File Format menu, which rfd's panel lacks.
+            #[cfg(target_os = "macos")]
+            if let FileDialogRequest::Save { name, dir } = &req
+                && no_system_dialog().is_none()
+            {
+                crate::save_panel::ask(name, dir.as_deref(), save_filters(name), answer, self.ctx.clone());
+                continue;
+            }
             // Built here, on the UI thread: macOS shows the panel as a sheet on the window now.
             let picking = picker(rfd::AsyncFileDialog::new().set_parent(parent), &req);
             #[cfg(all(unix, not(target_os = "macos")))]
diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs
--- a/apps/wordcraft/src/main.rs
+++ b/apps/wordcraft/src/main.rs
@@ -22,6 +22,8 @@ mod logging;
 mod print;
 mod region;
 #[cfg(target_os = "macos")]
+mod save_panel;
+#[cfg(target_os = "macos")]
 mod traffic_lights;
 
 use wordcraft_engine::Session;
diff --git a/apps/wordcraft/src/save_panel.rs b/apps/wordcraft/src/save_panel.rs
new file mode 100644
--- /dev/null
+++ b/apps/wordcraft/src/save_panel.rs
@@ -0,0 +1,120 @@
+//! Save As on macOS, with Word for Mac's File Format menu below the name.
+//!
+//! rfd gives the Mac panel the save formats as allowed file types only, which shows no menu: saving
+//! as PDF or Rich Text meant knowing to type the extension. These calls are safe in the
+//! `objc2-app-kit` bindings (our workspace forbids unsafe). The menu can't rename the file while it
+//! changes, as Word's does (that needs a target-action or a notification observer, both unsafe);
+//! the format picked applies when Save is clicked ([`chosen_path`]).
+
+use std::path::Path;
+use std::sync::mpsc::Sender;
+
+use block2::RcBlock;
+use objc2::{MainThreadMarker, MainThreadOnly};
+use objc2_app_kit::{NSApplication, NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSStackView, NSTextField, NSView};
+use objc2_foundation::{NSArray, NSEdgeInsets, NSPoint, NSRect, NSSize, NSString, NSURL};
+use wordcraft_ui_egui::FileDialogAnswer;
+
+/// Show the Save panel as a sheet on the app's window, the suggested `name` in `dir`, with a File
+/// Format menu of `formats` (label and extensions, the suggested one first). The path picked, with
+/// the chosen format's extension, goes back through `answer`; nothing, when cancelled.
+pub fn ask(name: &str, dir: Option<&str>, formats: Vec<(String, Vec<String>)>, answer: Sender<FileDialogAnswer>, ctx: egui::Context) {
+    let Some(mtm) = MainThreadMarker::new() else {
+        let _ = answer.send(Err("the Save panel must be shown on the main thread".into()));
+        return;
+    };
+    let panel = NSSavePanel::savePanel(mtm);
+    panel.setNameFieldStringValue(&NSString::from_str(name));
+    if let Some(dir) = dir.filter(|d| Path::new(d).is_dir()) {
+        panel.setDirectoryURL(Some(&NSURL::fileURLWithPath_isDirectory(&NSString::from_str(dir), true)));
+    }
+    panel.setExtensionHidden(false);
+
+    let menu =
+        NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(260.0, 26.0)), false);
+    for (label, _) in &formats {
+        menu.addItemWithTitle(&NSString::from_str(label));
+    }
+    menu.selectItemAtIndex(0);
+    let label = NSTextField::labelWithString(&NSString::from_str(wordcraft_ui_egui::tl!("File Format:")), mtm);
+    let views: [&NSView; 2] = [&label, &menu];
+    let row = NSStackView::stackViewWithViews(&NSArray::from_slice(&views), mtm);
+    row.setSpacing(8.0);
+    row.setEdgeInsets(NSEdgeInsets { top: 10.0, left: 20.0, bottom: 10.0, right: 20.0 });
+    panel.setAccessoryView(Some(&row));
+
+    let shown = panel.clone();
+    let handler = RcBlock::new(move |response: NSModalResponse| {
+        let picked = (response == NSModalResponseOK).then(|| shown.URL().and_then(|u| u.path())).flatten();
+        let Some(path) = picked else {
+            let _ = answer.send(Ok(Vec::new()));
+            ctx.request_repaint();
+            return;
+        };
+        let chosen = usize::try_from(menu.indexOfSelectedItem()).ok().and_then(|i| formats.get(i)).and_then(|(_, e)| e.first());
+        let changed = menu.indexOfSelectedItem() != 0;
+        let path = path.to_string();
+        let file = chosen.map_or_else(|| path.clone(), |ext| chosen_path(&path, ext, changed));
+        // The panel asked about replacing the file it named; a format changed in the menu names
+        // another, which may be there too: ask again at that name, so the panel asks first.
+        if file != path && Path::new(&file).exists() {
+            let (name, dir) = split(&file);
+            let mut formats = formats.clone();
+            let i = formats.iter().position(|(_, e)| e.first() == chosen).unwrap_or(0);
+            let first = formats.remove(i);
+            formats.insert(0, first);
+            let (answer, ctx) = (answer.clone(), ctx.clone());
+            dispatch2::DispatchQueue::main().exec_async(move || ask(&name, dir.as_deref(), formats, answer, ctx));
+            return;
+        }
+        let _ = answer.send(Ok(vec![file]));
+        ctx.request_repaint();
+    });
+    match NSApplication::sharedApplication(mtm).keyWindow().or_else(|| NSApplication::sharedApplication(mtm).mainWindow()) {
+        Some(window) => panel.beginSheetModalForWindow_completionHandler(&window, &handler),
+        None => panel.beginWithCompletionHandler(&handler),
+    }
+}
+
+/// A path's file name and folder.
+fn split(path: &str) -> (String, Option<String>) {
+    let p = Path::new(path);
+    let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+    (name, p.parent().map(|d| d.to_string_lossy().into_owned()))
+}
+
+/// The file to save: `path` from the panel, with the extension of format `ext`. A format changed
+/// in the menu decides: it replaces a save format's extension in the name ("Brief.docx" as PDF is
+/// "Brief.pdf") or is added after any other ("Smith v. Jones" is "Smith v. Jones.pdf"). With the
+/// menu left alone, a name typed with a save format's extension keeps it, and one without gets
+/// the menu's.
+pub fn chosen_path(path: &str, ext: &str, changed: bool) -> String {
+    let p = Path::new(path);
+    let typed = p
+        .extension()
+        .and_then(std::ffi::OsStr::to_str)
+        .map(str::to_ascii_lowercase)
+        .filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()));
+    match typed {
+        Some(_) if !changed => path.to_string(),
+        Some(_) => p.with_extension(ext).to_string_lossy().into_owned(),
+        None => format!("{path}.{ext}"),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::chosen_path;
+
+    /// The File Format menu decides the extension when it was changed; left alone, a typed
+    /// extension stands.
+    #[test]
+    fn the_file_format_menu_picks_the_extension() {
+        assert_eq!(chosen_path("/d/Brief.docx", "pdf", true), "/d/Brief.pdf");
+        assert_eq!(chosen_path("/d/Brief.docx", "rtf", true), "/d/Brief.rtf");
+        assert_eq!(chosen_path("/d/Brief.pdf", "docx", false), "/d/Brief.pdf", "a typed extension stands");
+        assert_eq!(chosen_path("/d/Brief", "docx", false), "/d/Brief.docx");
+        assert_eq!(chosen_path("/d/Smith v. Jones", "pdf", true), "/d/Smith v. Jones.pdf", "a dot in the name is no extension");
+        assert_eq!(chosen_path("/d/Order.PDF", "docx", false), "/d/Order.PDF");
+    }
+}
