From f420639276fda55245ebb680cfc9fb51098d4f2b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:35:36 +0200 Subject: [PATCH] EPUB/HTML: no file name as alt text, readable image names in the package (#774) - An image without alt text (Object Export Options) got its file name as alt, which a screen reader then reads aloud. It is now decorative (alt=""), and file.exportEpub lists those images in a new warnings array so they can be given alt text. - Images and media were packaged as images/.. They are now -., the name from the original file name (lowercase ASCII); the id keeps two files of the same name apart. Fixes #774 --- crates/engine/src/cmd/export.rs | 11 +++-- crates/epub/src/lib.rs | 76 ++++++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d6..7fa6ea3a 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec { "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})", has_doc, export_pdf), cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, - "{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})", + "{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative", has_doc, export_epub), cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", @@ -157,13 +157,17 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { }; } let bytes = designcraft_epub::export_epub(epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; + let warnings: Vec = designcraft_epub::images_without_alt(epub_doc) + .into_iter() + .map(|name| format!("{name}: no alt text (Object Export Options); exported as decorative (alt=\"\")")) + .collect(); match p.get("path").and_then(Value::as_str) { Some(path) => { #[cfg(not(target_arch = "wasm32"))] crate::atomic::write_file(path, &bytes)?; - Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) + Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings})) } - None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), + None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})), } } @@ -659,6 +663,7 @@ mod text_tests { s.execute("file.new", &json!({"pages": 2})).unwrap(); s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Page one words", "caret": false})).unwrap(); let r = s.execute("file.exportEpub", &json!({"cover": true})).unwrap(); + assert_eq!(r["warnings"], json!([]), "no images, nothing to report"); let mut z = unzip(r["base64"].as_str().unwrap()); let opf = read(&mut z, "OEBPS/content.opf"); assert!(opf.contains("cover-image") && opf.contains("")); diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 77b509d0..41959af5 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -544,6 +544,17 @@ pub fn reading_order(doc: &Document) -> Vec { keyed.into_iter().map(|(_, b)| b).collect() } +/// Package path of an asset: `{dir}/{name}-{id}.{ext}`, the name from its original file name +/// (lowercase ASCII, so the package stays readable) and the id keeping two assets of the same +/// name apart. +fn asset_path(dir: &str, a: &designcraft_doc::Asset, mime: &str) -> String { + let stem = a.name.rsplit(['/', '\\']).next().unwrap_or(""); + let stem = stem.rsplit_once('.').map_or(stem, |(s, _)| s); + let name: String = stem.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); + let name: String = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-").chars().take(40).collect(); + if name.is_empty() { format!("{dir}/{}.{}", a.id.0, ext(mime)) } else { format!("{dir}/{name}-{}.{}", a.id.0, ext(mime)) } +} + fn ext(mime: &str) -> &'static str { match mime { "image/jpeg" => "jpg", @@ -598,7 +609,7 @@ fn body_html<'a>( let mime = safe_mime(&a.mime); // Video and sound: an HTML5 player (with the poster, when there is one). if let Some(kind) = designcraft_doc::media_kind(mime) { - let name = format!("media/{}.{}", g.asset.0, ext(mime)); + let name = asset_path("media", a, mime); images.insert(name.clone(), (mime, a.data.as_slice())); let m = doc.item(*iid).and_then(|i| i.media.clone()).unwrap_or_default(); let mut attrs = String::new(); @@ -611,7 +622,7 @@ fn body_html<'a>( } if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) { let poster_mime = safe_mime(&pa.mime); - let pn = format!("images/{}.{}", pa.id.0, ext(poster_mime)); + let pn = asset_path("images", pa, poster_mime); images.insert(pn.clone(), (poster_mime, pa.data.as_slice())); if kind == "video" { attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data)))); @@ -625,10 +636,11 @@ fn body_html<'a>( ); continue; } - let name = format!("images/{}.{}", g.asset.0, ext(mime)); + let name = asset_path("images", a, mime); images.insert(name.clone(), (mime, a.data.as_slice())); - // Alt text from Object Export Options, else the file name. - let alt = doc.item(*iid).map(|i| i.alt_text.as_str()).filter(|t| !t.is_empty()).unwrap_or(&a.name); + // Alt text from Object Export Options; without one the image counts as decorative + // (`alt=""`): a file name read aloud helps nobody. + let alt = doc.item(*iid).map_or("", |i| i.alt_text.as_str()); // Object Export Options: alignment and a page break before. let eo = doc.item(*iid).map(|i| i.export_options.clone()).unwrap_or_default(); let mut css = String::new(); @@ -647,6 +659,22 @@ fn body_html<'a>( (body, images, toc) } +/// Placed images (not video or sound) without alt text, by asset name: they are exported as +/// decorative (`alt=""`), which an export can report. +pub fn images_without_alt(doc: &Document) -> Vec { + let mut out = Vec::new(); + for b in reading_order(doc) { + let Block::Image(iid) = b else { continue }; + let Some(it) = doc.item(iid) else { continue }; + let Content::Graphic(g) = &it.content else { continue }; + let Some(a) = doc.assets.get(&g.asset) else { continue }; + if !a.data.is_empty() && designcraft_doc::media_kind(safe_mime(&a.mime)).is_none() && it.alt_text.trim().is_empty() { + out.push(a.name.clone()); + } + } + out +} + /// Options for [`export_html`]. #[derive(Clone, Debug, Default)] pub struct HtmlOptions { @@ -845,6 +873,44 @@ mod tests { assert_eq!(base64(b"M"), "TQ=="); } + /// #774: an image without alt text is decorative (`alt=""`) and reported, and package names + /// come from the original file names. + #[test] + fn images_get_readable_names_and_no_file_name_alt() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let place = |d: &mut Document, name: &str, mime: &str, alt: &str, y: f64| { + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { page: 0, id: aid, name: name.into(), mime: mime.into(), link: None, data: Arc::new(vec![1]), pixels: Some((1, 1)) }), + ); + let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0))); + item.alt_text = alt.into(); + item.content = Content::Graphic(Graphic { + asset: aid, + size: (1.0, 1.0), + xf: Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + aid.0 + }; + let a = place(&mut d, "/tmp/Anim Frame (1).GIF", "image/gif", "", 36.0); + let b = place(&mut d, "photo.jpg", "image/jpeg", "A heron", 120.0); + let c = place(&mut d, "???.png", "image/png", "Mark", 200.0); + assert_eq!(images_without_alt(&d), vec!["/tmp/Anim Frame (1).GIF".to_string()]); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut x = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut x).unwrap(); + assert!(x.contains(&format!("\"\"/")), "{x}"); + assert!(x.contains(&format!("\"A")), "{x}"); + assert!(x.contains(&format!("\"Mark\"/")), "{x}"); + assert!(z.by_name(&format!("OEBPS/images/anim-frame-1-{a}.gif")).is_ok()); + } + #[test] fn metadata_is_serialized_for_its_output_context() { let mut d = Document::new(&NewDocument::default());