diff --git a/ROADMAP.md b/ROADMAP.md index 3db96c3e5..5bc9ba7db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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** · The Pen shows the segment a dragged smooth point shapes while it is dragged, and its rubber band curves out of a smooth point's direction line (#1438, first item). No readiness percentage change. - **2026-11-02** · A Custom text variable defined with a number (a typed `2026` reaches the command as one) shows its digits instead of nothing (#2093, first item). 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. diff --git a/crates/ui-egui/src/app/file_dialogs.rs b/crates/ui-egui/src/app/file_dialogs.rs index 0d3a2b4da..9f1271389 100644 --- a/crates/ui-egui/src/app/file_dialogs.rs +++ b/crates/ui-egui/src/app/file_dialogs.rs @@ -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"] }], diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 745d5a122..9516801f6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 = 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 { + 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) { @@ -4638,7 +4660,11 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result { 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, ¶ms) } "pdfExport" => { // Remember the last-used options; `pages` and `title` are per-job and not persisted. diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index d6dc0147a..272165b08 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2957,6 +2957,47 @@ 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 = tempfile::tempdir().unwrap(); + let png = dir.path().join("cover.png"); + let bytes = b"\x89PNG\r\n\x1a\ncover".to_vec(); + std::fs::write(&png, &bytes).unwrap(); + let written: Arc>> = 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.path().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); +} + #[test] fn pdf_export_defaults() { let d = Dialog::new("pdfExport", json!({})); diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 0c66b894c..651b748f9 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -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", "دفع"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index b4b7cd90d..f27e40c52 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -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"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 87ffdbadb..70d08085e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -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"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 641fe64b6..9732958f7 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -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", "押し出し"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index a2581ab2c..b89840044 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -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", "გადაწევა"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 1562ecc6f..0ef8fa755 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -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"), diff --git a/crates/ui-egui/src/i18n/sv.rs b/crates/ui-egui/src/i18n/sv.rs index 1a7550a35..8d7c46e15 100644 --- a/crates/ui-egui/src/i18n/sv.rs +++ b/crates/ui-egui/src/i18n/sv.rs @@ -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"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index 2d4688558..27f5cf389 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -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", "推入"]), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 5bae3381f..03918520f 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -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", "Зсув"), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7b70a9a43..fcab4b9f9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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% |