M6: EPUB Export Options names a cover image file (#1934)

A Cover Image field with a Choose… button (PNG or JPEG) in the EPUB Export Options dialog; left empty, the first page stays the cover. The three labels are translated; a dialogs test exports with a chosen file and checks the EPUB's cover image and manifest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor BrownandClaude Fable 5.1 committed 2026-11-02 05:56:34 -05:00
1 parent cc3c70d4be
commit 02b20fd5e7
14 files changed
+115 -5

No files matched your search

+1
View File
@@ -382,6 +382,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
## Progress log
- **2026-11-02** · EPUB Export Options names a cover image file (PNG or JPEG, typed or chosen with Choose…) for the reflowable export; left empty, the first page stays the cover (#1934). No readiness percentage change.
- **2026-11-02** · Windows: the custom title bar window stays borderless, so the content is no longer drawn offset by the caption and border (black bars at the top and left, clicks landing off target). The workaround goes when winit carries the fix (storytold/winit#17). No readiness percentage change.
- **2026-10-30** · The Color Picker takes an eyedropper: dragged from the picker onto the page, it takes the colour the canvas shows (proofed under Proof Colors). No readiness percentage change.
- **2026-10-30** · The Color Picker is laid out as the reference app's: a field and slider for the component chosen, HSB, Lab, RGB and CMYK values, the old colour, and Add CMYK Swatch; Enter and Esc close it once. No readiness percentage change.
+1
View File
@@ -337,6 +337,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] {
"script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }],
"shortcuts" => &[OpenFilter { name: "Shortcut set (JSON, InDesign text)", extensions: &["json", "txt"] }],
"icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }],
"coverImage" => &[OpenFilter { name: "Cover image (PNG, JPEG)", extensions: &["png", "jpg", "jpeg"] }],
"book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }],
"xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }],
"dtd" => &[OpenFilter { name: "DTD", extensions: &["dtd"] }],
+30 -4
View File
@@ -37,7 +37,7 @@ impl Dialog {
let defaults = match id {
"newDocument" => crate::new_document::defaults(),
"goToPage" => json!({"page": "1"}),
"epubExport" => json!({"split": false, "mode": "style", "style": designcraft_doc::BASIC_PARAGRAPH}),
"epubExport" => json!({"split": false, "mode": "style", "style": designcraft_doc::BASIC_PARAGRAPH, "coverImage": ""}),
"qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}),
"insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}),
"convertTextToTable" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph", "columns": 1}),
@@ -703,8 +703,9 @@ fn epub_export_split(d: &Dialog) -> designcraft_doc::EpubSplit {
}
}
fn epub_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let lang = app.ui.language.as_str();
fn epub_export(app: &mut crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let lang = app.ui.language.clone();
let lang = lang.as_str();
let styles: Vec<String> = app
.session
.active()
@@ -733,6 +734,27 @@ fn epub_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
});
});
ui.add_space(8.0);
// The cover: the first page rendered, or an image file (PNG or JPEG) named here (#1934).
crate::rtl::label(ui, crate::i18n::tr(lang, "Cover Image:"));
ui.horizontal(|ui| {
text_field(ui, d, "coverImage", 240.0);
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "Choose…"))).clicked()
&& let Err(e) = app.ask_open("coverImage", Bound::Document, set_epub_cover)
{
app.status_error("EPUB Export Options", e);
}
});
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "A PNG or JPEG file; left empty, the first page is the cover.")).small());
}
/// EPUB Export Options › Choose…: the image file picked goes into the dialog's Cover Image field
/// while it is open.
fn set_epub_cover(app: &mut DesignApp, path: String) -> Result<Value, String> {
if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.id == "epubExport") {
d.fields.insert("coverImage".into(), json!(path));
}
Ok(Value::Null)
}
fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
@@ -4631,7 +4653,11 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result<Value, String> {
let v = serde_json::to_value(&split).map_err(|e| e.to_string())?;
app.run("document.epubSplit", json!({"split": v}))?;
}
crate::menus::export_epub(app, &json!({}))
// A cover image file named in the dialog; empty, the first page is the cover.
let cover = d.s("coverImage");
let cover = cover.trim();
let params = if cover.is_empty() { json!({}) } else { json!({"coverImage": cover}) };
crate::menus::export_epub(app, &params)
}
"pdfExport" => {
// Remember the last-used options; `pages` and `title` are per-job and not persisted.
+43
View File
@@ -2957,6 +2957,49 @@ fn epub_export_options_store_split_document() {
assert_eq!(app.session.active().unwrap().doc.settings.epub_split, EpubSplit::ExportTags, "Cancel stores nothing");
}
/// EPUB Export Options › Cover Image: a PNG or JPEG file named there (typed, or picked with
/// Choose…) is the cover; left empty, the first page is, as before (#1934).
#[test]
fn epub_export_options_take_a_cover_image_file() {
use std::io::Read;
use std::sync::{Arc, Mutex};
let mut app = test_app();
let dir = std::env::temp_dir().join(format!("designcraft-epub-cover-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let png = dir.join("cover.png");
let bytes = b"\x89PNG\r\n\x1a\ncover".to_vec();
std::fs::write(&png, &bytes).unwrap();
let written: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
let sink = written.clone();
app.services.write = Some(Box::new(move |_: &str, b: &[u8]| {
*sink.lock().unwrap() = b.to_vec();
Ok(())
}));
app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(dir.join("book.epub").to_string_lossy().into_owned())));
app.run("app.exportEpub", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().s("coverImage"), "", "no file by default: the first page is the cover");
// Choose… puts the picked file into the field; OK passes it to the export.
let path = png.to_string_lossy().into_owned();
set_epub_cover(&mut app, path.clone()).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().s("coverImage"), path);
confirm(&mut app).unwrap();
app.settle_dialogs();
let out = written.lock().unwrap().clone();
assert!(!out.is_empty(), "the EPUB was written");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out)).unwrap();
let mut cover = Vec::new();
z.by_name("OEBPS/images/cover.png").unwrap().read_to_end(&mut cover).unwrap();
assert_eq!(cover, bytes, "the file is the cover image");
let mut opf = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
assert!(opf.contains("properties=\"cover-image\""), "{opf}");
// The field is per export: the next dialog starts empty again.
app.run("app.exportEpub", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().s("coverImage"), "");
cancel(&mut app);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pdf_export_defaults() {
let d = Dialog::new("pdfExport", json!({}));
+3
View File
@@ -1021,6 +1021,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Box", "مربع"),
("Comb", "مشط"),
("Cover", "تغطية"),
("Cover Image:", "صورة الغلاف:"),
("Choose…", "اختيار…"),
("A PNG or JPEG file; left empty, the first page is the cover.", "ملف PNG أو JPEG؛ إن تُرك فارغًا تكون الصفحة الأولى هي الغلاف."),
("Dissolve", "تلاشي متناثر"),
("Fade", "تلاشي"),
("Push", "دفع"),
+6
View File
@@ -1447,6 +1447,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Box", "Rechteck"),
("Comb", "Kamm"),
("Cover", "Überdecken"),
("Cover Image:", "Titelbild:"),
("Choose…", "Auswählen …"),
(
"A PNG or JPEG file; left empty, the first page is the cover.",
"Eine PNG- oder JPEG-Datei; bleibt das Feld leer, ist die erste Seite das Titelbild.",
),
("Dissolve", "Auflösen"),
("Fade", "Überblenden"),
("Push", "Schieben"),
+3
View File
@@ -1627,6 +1627,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Box", "Riquadro"),
("Comb", "Pettine"),
("Cover", "Copri"),
("Cover Image:", "Immagine di copertina:"),
("Choose…", "Scegli…"),
("A PNG or JPEG file; left empty, the first page is the cover.", "Un file PNG o JPEG; se lasciato vuoto, la prima pagina è la copertina."),
("Dissolve", "Dissolvenza"),
("Fade", "Sfuma"),
("Push", "Spingi"),
+3
View File
@@ -1651,6 +1651,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Box", "ボックス"),
("Comb", "くし状"),
("Cover", "カバー"),
("Cover Image:", "表紙画像 :"),
("Choose…", "選択…"),
("A PNG or JPEG file; left empty, the first page is the cover.", "PNG または JPEG ファイル。空のままにすると最初のページが表紙になります。"),
("Dissolve", "ディゾルブ"),
("Fade", "フェード"),
("Push", "押し出し"),
+3
View File
@@ -1545,6 +1545,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Box", "ჩარჩო"),
("Comb", "სავარცხელი"),
("Cover", "დაფარვა"),
("Cover Image:", "ყდის სურათი:"),
("Choose…", "არჩევა…"),
("A PNG or JPEG file; left empty, the first page is the cover.", "PNG ან JPEG ფაილი; ცარიელად დატოვების შემთხვევაში ყდა პირველი გვერდი იქნება."),
("Dissolve", "გაფანტვა"),
("Fade", "მილევა"),
("Push", "გადაწევა"),
+3
View File
@@ -1025,6 +1025,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Box", "Caixa"),
("Comb", "Pente"),
("Cover", "Cobertura"),
("Cover Image:", "Imagem da capa:"),
("Choose…", "Escolher…"),
("A PNG or JPEG file; left empty, the first page is the cover.", "Um arquivo PNG ou JPEG; deixado vazio, a primeira página é a capa."),
("Dissolve", "Dissolver"),
("Fade", "Desvanecer"),
("Push", "Empurrar"),
+3
View File
@@ -816,6 +816,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Box", "Ruta"),
("Comb", "Kam"),
("Cover", "Täck"),
("Cover Image:", "Omslagsbild:"),
("Choose…", "Välj…"),
("A PNG or JPEG file; left empty, the first page is the cover.", "En PNG- eller JPEG-fil; lämnas den tom blir första sidan omslaget."),
("Dissolve", "Övertoning"),
("Fade", "Toning"),
("Push", "Skjut"),
+12
View File
@@ -4107,6 +4107,18 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[
("Box", ["Box", "Boîte", "Box", "Box", "方框"]),
("Comb", ["Comb", "Peigne", "Comb", "Comb", "梳状"]),
("Cover", ["Cover", "Recouvrir", "Cover", "Cover", "覆盖"]),
("Cover Image:", ["Titelbild:", "Image de couverture :", "Imagen de portada:", "表紙画像 :", "封面图像:"]),
("Choose…", ["Auswählen …", "Choisir…", "Elegir…", "選択…", "选择…"]),
(
"A PNG or JPEG file; left empty, the first page is the cover.",
[
"Eine PNG- oder JPEG-Datei; bleibt das Feld leer, ist die erste Seite das Titelbild.",
"Un fichier PNG ou JPEG ; laissé vide, la première page sert de couverture.",
"Un archivo PNG o JPEG; si se deja vacío, la primera página es la portada.",
"PNG または JPEG ファイル。空のままにすると最初のページが表紙になります。",
"PNG 或 JPEG 文件;留空时第一页即为封面。",
],
),
("Dissolve", ["Dissolve", "Dissolution", "Dissolve", "Dissolve", "溶解"]),
("Fade", ["Fade", "Fondu", "Fade", "Fade", "淡入淡出"]),
("Push", ["Push", "Pousser", "Push", "Push", "推入"]),
+3
View File
@@ -1624,6 +1624,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Box", "Рамка"),
("Comb", "Гребінка"),
("Cover", "Накриття"),
("Cover Image:", "Зображення обкладинки:"),
("Choose…", "Вибрати…"),
("A PNG or JPEG file; left empty, the first page is the cover.", "Файл PNG або JPEG; якщо залишити порожнім, обкладинкою буде перша сторінка."),
("Dissolve", "Розчинення"),
("Fade", "Згасання"),
("Push", "Зсув"),
+1 -1
View File
@@ -44,7 +44,7 @@ layer survive interchange; literal XML and round-trip tests are in
| PDF (print) | `.pdf` | ✓ all presets, PDF/X-1a/3/4, PDF/A, PDF/UA | ✓ krilla: real text with subset fonts, DeviceCMYK/RGB, spot Separations, bleed/slug, marks, spreads, compression options, remembered settings (#196); **PDF/X-4** with output intent and a validator (#223, #200); **PDF/A-2b**; tagged PDF; **PDF/X-1a:2003** flattened in its inks by craft-prepress | 53 tests in `crates/pdf`; no PDF/X-3; no presets / `.joboptions`; no external validator (veraPDF / callas) in CI | 18% |
| PDF (interactive) | `.pdf` | ✓ | ✓ links, bookmarks, buttons, AcroForm fields, page transitions, object states | | 2% |
| JPEG / PNG | `.jpg`, `.png` | ✓ | PNG from the UI; JPEG from the CLI only | no resolution / colour space options dialog for JPEG | 3% |
| EPUB reflowable | `.epub` | ✓ | ✓ stories in article order, tables, footnotes and endnotes, CSS from styles, images, nav | | 2% |
| EPUB reflowable | `.epub` | ✓ | ✓ stories in article order, tables, footnotes and endnotes, CSS from styles, images, nav | the cover is the first page, or a PNG or JPEG file named in EPUB Export Options (#1934) | 2% |
| EPUB fixed layout | `.epub` | ✓ (live HTML/CSS) | ~ page images with text kept for search | not live text | 1% |
| HTML | `.html` | ✓ | ~ one self-contained page (#314) | | 1% |
| HTML5 ZIP / Publish Online | | ✓ | ✗ (Publish Online out of scope) | | <1% |