fix(ui): list X.PDF in file pickers on Linux and the BSDs (#5481)

rfd 0.17 gives each filter extension to the XDG portal, GTK and Zenity as
the glob `*.<ext>`, which those match case-sensitively, so a "PDF" filter
of `pdf` hid `Report.PDF`. `AddFilterAnyCase::add_filter_any_case` passes
each letter as a bracket class there (`pdf` -> `[pP][dD][fF]`) and the
extension unchanged on Windows, macOS and the web, which already ignore
case. Every picker filter in ui-egui now goes through it; the imports
used only by native pickers are gated off wasm.

Fixes #5481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76andClaude Opus 5.5 committed 2026-10-30 18:07:38 -04:00
1 parent fa5f9f8c3b
commit 9ca051ac06
16 files changed
+116 -37

No files matched your search

+9 -4
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::file_filter::AddFilterAnyCase as _;
/// Whether `a` and `b` name the same file: equal, or equal once made absolute against the
/// current directory (links are not resolved, as in `last_session`).
@@ -607,7 +608,9 @@ impl PdfCraftApp {
/// The File ▸ Open picker: PDFs, and images and text that become PDFs.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn open_picker() -> rfd::AsyncFileDialog {
rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE)
rfd::AsyncFileDialog::new()
.add_filter_any_case("PDF", &["pdf"])
.add_filter_any_case(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE)
}
pub fn open_dialog(&mut self) {
@@ -615,7 +618,9 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
self.pick(
pickers::PickFor::Open,
rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE),
rfd::AsyncFileDialog::new()
.add_filter_any_case("PDF", &["pdf"])
.add_filter_any_case(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE),
true,
);
// Browsers pick files asynchronously; the bytes arrive through `inbox`.
@@ -625,8 +630,8 @@ impl PdfCraftApp {
let ctx = self.ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
if let Some(h) = rfd::AsyncFileDialog::new()
.add_filter("PDF", &["pdf"])
.add_filter(tl!("Images and text"), &create_ui::CONVERTIBLE)
.add_filter_any_case("PDF", &["pdf"])
.add_filter_any_case(tl!("Images and text"), &create_ui::CONVERTIBLE)
.pick_file()
.await
{
+3 -1
View File
@@ -3,6 +3,8 @@
//! page the range selects, with the marks laid out on it. "Update" opens the same dialog in
//! replace mode.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use std::collections::{HashMap, HashSet};
use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2};
@@ -777,7 +779,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M
#[cfg(not(target_arch = "wasm32"))]
if browse {
let dialog = rfd::AsyncFileDialog::new()
.add_filter(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]);
.add_filter_any_case(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]);
let epoch = app.dialog_epoch();
app.ask_one(crate::pickers::Ask::File(dialog), None, move |app, p| {
// Only into the dialog that asked: not one closed meanwhile, or opened again since.
+3 -1
View File
@@ -1,4 +1,6 @@
use super::{PdfCraftApp, StagingName, staging_suffixes};
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
/// A save held back by [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged): the file
/// on disk is another version than the document came from (#2166).
@@ -58,7 +60,7 @@ impl PdfCraftApp {
(SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) => p,
_ => {
let name = if name.to_ascii_lowercase().ends_with(".pdf") { name } else { format!("{name}.pdf") };
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name);
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(name);
let dialog = in_document_folder(dialog, doc_path.as_deref());
// The bytes are taken once the user has chosen, so edits made meanwhile are saved too.
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| {
+1
View File
@@ -119,6 +119,7 @@ pub use persist::units;
#[cfg(not(target_arch = "wasm32"))]
pub use persist::window_state;
use platform::drag_pointer;
use platform::file_filter;
#[cfg(not(target_arch = "wasm32"))]
pub use platform::file_watch;
#[cfg(target_os = "macos")]
+1
View File
@@ -1,4 +1,5 @@
pub(super) mod drag_pointer;
pub(super) mod file_filter;
#[cfg(not(target_arch = "wasm32"))]
pub mod file_watch;
#[cfg(target_os = "macos")]
@@ -0,0 +1,50 @@
//! Picker filters that list a file whatever the letter case of its extension.
//!
//! On Linux and the BSDs, rfd gives each extension to the XDG portal, GTK or Zenity as the glob
//! `*.<ext>`, and those match it case-sensitively: a "PDF" filter of `pdf` hid `Report.PDF`.
//! Windows, macOS and the browser already ignore case, so there the extension passes unchanged.
/// Whether the platform's pickers match filter globs case-sensitively.
const CASE_SENSITIVE_GLOBS: bool = cfg!(all(unix, not(target_os = "macos")));
/// [`rfd::AsyncFileDialog::add_filter`], with each extension matched in any letter case.
pub(crate) trait AddFilterAnyCase {
#[must_use]
fn add_filter_any_case(self, name: impl Into<String>, extensions: &[impl AsRef<str>]) -> Self;
}
impl AddFilterAnyCase for rfd::AsyncFileDialog {
fn add_filter_any_case(self, name: impl Into<String>, extensions: &[impl AsRef<str>]) -> Self {
let globs: Vec<String> = extensions.iter().map(|e| any_case(e.as_ref(), CASE_SENSITIVE_GLOBS)).collect();
self.add_filter(name, &globs)
}
}
/// `ext` as a glob fragment matching any letter case: `pdf` becomes `[pP][dD][fF]`.
fn any_case(ext: &str, case_sensitive: bool) -> String {
if !case_sensitive {
return ext.to_owned();
}
ext.chars()
.map(|c| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() })
.collect()
}
#[cfg(test)]
mod tests {
use super::any_case;
#[test]
fn a_case_sensitive_picker_gets_a_glob_matching_every_case() {
assert_eq!(any_case("pdf", true), "[pP][dD][fF]");
assert_eq!(any_case("PDF", true), "[pP][dD][fF]");
assert_eq!(any_case("jp2", true), "[jJ][pP]2");
assert_eq!(any_case("*", true), "*");
}
#[test]
fn a_case_blind_picker_gets_the_extension_unchanged() {
assert_eq!(any_case("pdf", false), "pdf");
assert_eq!(any_case("*", false), "*");
}
}
+3 -1
View File
@@ -3,6 +3,8 @@
//! (images keep their proportions), double-click text to retype it, format it from the panel,
//! Delete to remove it. Each change is one undoable engine edit.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
use pdfcraft_engine::{Added, AddedContent, AddedText, Edit};
use pdfcraft_render::DocInfo;
@@ -849,7 +851,7 @@ impl crate::PdfCraftApp {
Some(_) => {}
None => {
let dialog = rfd::AsyncFileDialog::new()
.add_filter(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"])
.add_filter_any_case(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"])
.set_title(title);
let target = self.active_ids().map(|(_, id)| id);
self.ask_one(crate::pickers::Ask::File(dialog), target, read);
+2 -1
View File
@@ -3,6 +3,7 @@
//! signature. Everything is an annotation (typewriter text, PdfCraft-drawn stamps, ink), so it
//! can be moved, deleted and undone like any comment.
use crate::file_filter::AddFilterAnyCase as _;
use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2};
use pdfcraft_engine::signature_scan::{DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, SCAN_MAX_SIDE, ScanError};
use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, SignatureImage, SignaturePhoto, Style, Turn};
@@ -780,7 +781,7 @@ impl crate::PdfCraftApp {
let epoch = self.dialog_epoch();
let initials = self.signature_draft.initials;
let scan = self.signature_draft.scan_mode;
let dialog = rfd::AsyncFileDialog::new().add_filter(tl!(if scan { "Photo" } else { "Image" }), &["png", "jpg", "jpeg"]);
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case(tl!(if scan { "Photo" } else { "Image" }), &["png", "jpg", "jpeg"]);
#[cfg(not(target_arch = "wasm32"))]
self.ask_one(crate::pickers::Ask::File(dialog), None, move |app, path| {
if app.dialog_epoch() != epoch
+3 -1
View File
@@ -1,4 +1,6 @@
//! Measure objects: page-space clicks, live readings and persistent drawing scales.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, page_cursor::Layer, theme::Tokens, widgets};
use egui::{Color32, Pos2, Stroke};
use pdfcraft_engine::{
@@ -613,7 +615,7 @@ fn export_csv(app: &mut PdfCraftApp) {
match app.export_dir_override.as_ref().map(|d| std::path::PathBuf::from(d).join("measurements.csv")) {
Some(path) => write(app, path),
None => {
let dialog = rfd::AsyncFileDialog::new().set_file_name("measurements.csv").add_filter("CSV", &["csv"]);
let dialog = rfd::AsyncFileDialog::new().set_file_name("measurements.csv").add_filter_any_case("CSV", &["csv"]);
app.ask_one(crate::pickers::Ask::Save(dialog), None, write);
}
}
+4 -2
View File
@@ -2,6 +2,8 @@
//! click an empty signature field), the Sign with a Digital ID / Configure New Digital ID /
//! Sign as dialogs, the Signatures panel, and the signature message bar.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use std::path::PathBuf;
use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2};
@@ -501,7 +503,7 @@ impl PdfCraftApp {
Some(p) => sign_and_save(self, PathBuf::from(p)),
#[cfg(not(target_arch = "wasm32"))]
None => {
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem}_signed.pdf"));
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(format!("{stem}_signed.pdf"));
let dialog = crate::editing::in_document_folder(dialog, folder.as_deref());
// The signature's page, rectangle and field refer to the document as it is now:
// the pick is dropped (with a notice) if it is edited or switched away from
@@ -533,7 +535,7 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn pick_sign_graphic_image(&mut self) {
let epoch = self.dialog_epoch();
let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Image"), &["png", "jpg", "jpeg"]);
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case(tl!("Image"), &["png", "jpg", "jpeg"]);
self.ask_one(crate::pickers::Ask::File(dialog), None, move |app, path| {
if app.dialog_epoch() != epoch || app.dialog != Some(crate::Dialog::Sign) {
return;
+3 -1
View File
@@ -2,6 +2,8 @@ use super::{
Align, Appearance, AppearanceLabels, Color32, CornerRadius, KEY_ALGORITHMS, Layout, NewIdDraft, PdfCraftApp, Rect, SavedSig, SignGraphic,
SignStep, Stroke, Tokens, icons, sign, theme, vec2, widgets,
};
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
/// Draw the signing dialogs; returns `true` when the dialog should close.
pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
@@ -233,7 +235,7 @@ fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
// The picker answers on a later frame, into the draft if the same dialog is still open.
#[cfg(not(target_arch = "wasm32"))]
if browse {
let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Digital ID"), &["p12", "pfx"]);
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case(tl!("Digital ID"), &["p12", "pfx"]);
let epoch = app.dialog_epoch();
app.ask_one(crate::pickers::Ask::File(dialog), None, move |app, p| {
// Not one closed meanwhile, or opened again since.
+3 -1
View File
@@ -1,6 +1,8 @@
//! Custom stamps (Add a stamp ▸ Custom stamps ▸ Create): a PDF page or an image saved in the
//! user's stamp library under a category and a name, then placed like any stamp.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use std::sync::Arc;
use egui::{Align, Layout};
@@ -145,7 +147,7 @@ impl PdfCraftApp {
Some(_) => {}
None => {
let dialog = rfd::AsyncFileDialog::new()
.add_filter(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"])
.add_filter_any_case(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"])
.set_title(tl!("Select a file for the stamp"));
self.ask_one(crate::pickers::Ask::File(dialog), None, read);
}
+3 -1
View File
@@ -1,6 +1,8 @@
//! Action Wizard: the actions (PdfCraft's and your own), running one over files in the
//! background, and creating or editing an action's steps. Your actions are remembered.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use std::sync::{Arc, Mutex};
use egui::{Align, Layout};
@@ -186,7 +188,7 @@ impl PdfCraftApp {
match self.action_files_override.clone() {
Some(p) => run(self, p.into_iter().map(std::path::PathBuf::from).collect()),
None => {
let dialog = rfd::AsyncFileDialog::new().set_title(title).add_filter("PDF", &["pdf"]);
let dialog = rfd::AsyncFileDialog::new().set_title(title).add_filter_any_case("PDF", &["pdf"]);
self.ask(crate::pickers::Ask::Files(dialog), None, run);
}
}
+4 -2
View File
@@ -1,6 +1,8 @@
//! Create a PDF (blank, from images, from text) and Reduce File Size (execution plan M10.2,
//! M11.1). Opening an image or a text file converts it to a new, unsaved PDF, as Acrobat does.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use std::sync::Arc;
use crate::PdfCraftApp;
@@ -154,7 +156,7 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
{
let dialog = rfd::AsyncFileDialog::new()
.add_filter(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"])
.add_filter_any_case(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"])
.set_title(tl!("Choose images"));
self.ask(crate::pickers::Ask::Files(dialog), None, |app, files| {
let mut images = Vec::new();
@@ -285,7 +287,7 @@ impl PdfCraftApp {
match self.save_override.clone() {
Some(p) => write(self, p),
None => {
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(&name);
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(&name);
let dialog = crate::editing::in_document_folder(dialog, folder.as_deref());
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, p| write(app, p.to_string_lossy().into_owned()));
}
+21 -20
View File
@@ -3,6 +3,7 @@
//! Files are picked asynchronously and used on a later frame: desktop builds through `pickers`
//! (a blocking picker crashes the app on macOS), browsers through `requests`.
use crate::file_filter::AddFilterAnyCase as _;
use std::sync::Arc;
use pdfcraft_engine::{DocId, Edit, SplitBy};
@@ -61,16 +62,16 @@ pub(crate) fn installed_font_files(ctx: &egui::Context) -> Option<Vec<pdfcraft_e
fn files_picker(purpose: FilePurpose) -> rfd::AsyncFileDialog {
let dialog = rfd::AsyncFileDialog::new();
if matches!(purpose, FilePurpose::TextFont { .. }) {
return dialog.add_filter("TrueType / OpenType font", &["ttf", "otf"]);
return dialog.add_filter_any_case("TrueType / OpenType font", &["ttf", "otf"]);
}
if purpose == FilePurpose::RedactWords {
return dialog.add_filter(tl!("Text"), &["txt"]);
return dialog.add_filter_any_case(tl!("Text"), &["txt"]);
}
if !matches!(purpose, FilePurpose::CreateMultiple | FilePurpose::CreateMultipleSeparate | FilePurpose::InsertPages | FilePurpose::Combine) {
return dialog.add_filter("PDF", &["pdf"]);
return dialog.add_filter_any_case("PDF", &["pdf"]);
}
let all: Vec<&str> = std::iter::once("pdf").chain(pdfcraft_engine::CONVERTIBLE).collect();
dialog.add_filter(tl!("PDF, images and text"), &all).add_filter("PDF", &["pdf"])
dialog.add_filter_any_case(tl!("PDF, images and text"), &all).add_filter_any_case("PDF", &["pdf"])
}
/// The Replace Pages dialog: the chosen file and the ranges (1-based, inclusive).
@@ -898,7 +899,7 @@ impl PdfCraftApp {
Some(_) => {}
None => {
let dialog = rfd::AsyncFileDialog::new()
.add_filter(tl!("Comment and form data").to_string(), &extensions)
.add_filter_any_case(tl!("Comment and form data").to_string(), &extensions)
.set_title(tl!("Import data").to_string());
let target = self.active_ids().map(|(_, id)| id);
self.ask_one(crate::pickers::Ask::File(dialog), target, import);
@@ -939,7 +940,7 @@ impl PdfCraftApp {
None => {
let dialog = rfd::AsyncFileDialog::new()
.set_title(tl!("Export").to_string())
.add_filter(ext.to_uppercase(), &[ext])
.add_filter_any_case(ext.to_uppercase(), &[ext])
.set_file_name(format!("{stem}.{ext}"));
let dialog = crate::editing::in_document_folder(dialog, folder.as_deref());
self.ask_one(crate::pickers::Ask::Save(dialog), None, write);
@@ -959,7 +960,7 @@ impl PdfCraftApp {
{
let dialog = rfd::AsyncFileDialog::new()
.set_title(tl!("Select data files to merge").to_string())
.add_filter(tl!("Form data and PDF forms").to_string(), &["fdf", "xfdf", "pdf"]);
.add_filter_any_case(tl!("Form data and PDF forms").to_string(), &["fdf", "xfdf", "pdf"]);
self.ask(crate::pickers::Ask::Files(dialog), None, |app, paths| {
let mut files = Vec::new();
for p in paths {
@@ -1004,7 +1005,7 @@ impl PdfCraftApp {
None => {
let dialog = rfd::AsyncFileDialog::new()
.set_title(tl!("Save the spreadsheet").to_string())
.add_filter("CSV", &["csv"])
.add_filter_any_case("CSV", &["csv"])
.set_file_name("report.csv");
self.ask_one(crate::pickers::Ask::Save(dialog), None, write);
}
@@ -1047,22 +1048,22 @@ impl PdfCraftApp {
let title = if comments { tl!("Export comments") } else { tl!("Export form data") };
let d = rfd::AsyncFileDialog::new().set_title(title).set_file_name(format!("{stem}.xfdf"));
let d = if comments {
d.add_filter("XFDF", &["xfdf"]).add_filter("FDF", &["fdf"])
d.add_filter_any_case("XFDF", &["xfdf"]).add_filter_any_case("FDF", &["fdf"])
} else if xfa {
// An XFA form's data, as Acrobat exports it, first.
d.set_file_name(format!("{stem}.xml"))
.add_filter("XML", &["xml"])
.add_filter("XDP", &["xdp"])
.add_filter("XFDF", &["xfdf"])
.add_filter("FDF", &["fdf"])
.add_filter("CSV", &["csv"])
.add_filter(tl!("Text").to_string(), &["txt"])
.add_filter_any_case("XML", &["xml"])
.add_filter_any_case("XDP", &["xdp"])
.add_filter_any_case("XFDF", &["xfdf"])
.add_filter_any_case("FDF", &["fdf"])
.add_filter_any_case("CSV", &["csv"])
.add_filter_any_case(tl!("Text").to_string(), &["txt"])
} else {
d.add_filter("XFDF", &["xfdf"])
.add_filter("FDF", &["fdf"])
.add_filter("XML", &["xml"])
.add_filter("CSV", &["csv"])
.add_filter(tl!("Text").to_string(), &["txt"])
d.add_filter_any_case("XFDF", &["xfdf"])
.add_filter_any_case("FDF", &["fdf"])
.add_filter_any_case("XML", &["xml"])
.add_filter_any_case("CSV", &["csv"])
.add_filter_any_case(tl!("Text").to_string(), &["txt"])
};
let d = crate::editing::in_document_folder(d, folder.as_deref());
self.ask_one(crate::pickers::Ask::Save(d), None, write);
+3 -1
View File
@@ -3,6 +3,8 @@
//! (Size, Poster, Multiple, Booklet); orientation; comments & forms; and a live preview of the
//! sheets. Printing sends the print-ready PDF to the system spooler; "Save as PDF" writes it.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2};
use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool};
@@ -551,7 +553,7 @@ impl PdfCraftApp {
Some(p) => write(self, p.into()),
None => {
let stem = name.trim_end_matches(".pdf").trim_end_matches(".PDF");
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem} (print).pdf"));
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(format!("{stem} (print).pdf"));
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, path| {
write(app, path);
})