Skip to main content

honzo_chunks/data/
img.rs

1//! `IMG_` chunk helpers.
2//!
3//! `IMG_` is for in-content images (figures/illustrations inside chapters)
4
5use honzo_core::HonzoError;
6use image::{codecs::jpeg::JpegEncoder, DynamicImage, GenericImageView};
7use lexepub::core::chapter::{AstNode, ParsedChapter};
8use lexepub::LexEpub;
9use std::collections::HashMap;
10
11pub const IMG_TAG: [u8; 4] = *b"IMG_";
12
13const MAX_IMAGE_DIM_PX: u32 = 20_000;
14
15fn check_dims(width: u32, height: u32) -> Result<(), HonzoError> {
16    if width == 0 || height == 0 {
17        return Err(HonzoError::Truncated);
18    }
19    if width > MAX_IMAGE_DIM_PX || height > MAX_IMAGE_DIM_PX {
20        return Err(HonzoError::Truncated);
21    }
22    Ok(())
23}
24
25/// Load the image and return the decoded `DynamicImage` after basic validation.
26pub fn load_image(bytes: &[u8]) -> Result<DynamicImage, HonzoError> {
27    let img = image::load_from_memory(bytes).map_err(|_| HonzoError::Truncated)?;
28    let (w, h) = img.dimensions();
29    check_dims(w, h)?;
30    Ok(img)
31}
32
33/// Validate raw image bytes for inclusion as an `IMG_` chunk.
34/// Ensures the bytes decode as a supported image and that dimensions are sane.
35pub fn validate_img(bytes: &[u8]) -> Result<&[u8], HonzoError> {
36    load_image(bytes)?;
37    Ok(bytes)
38}
39
40/// Attempt to guess the image MIME type from the bytes.
41pub fn guess_mime(bytes: &[u8]) -> Option<&'static str> {
42    match image::guess_format(bytes).ok()? {
43        image::ImageFormat::Png => Some("image/png"),
44        image::ImageFormat::Jpeg => Some("image/jpeg"),
45        image::ImageFormat::Gif => Some("image/gif"),
46        image::ImageFormat::Bmp => Some("image/bmp"),
47        image::ImageFormat::Tiff => Some("image/tiff"),
48        image::ImageFormat::WebP => Some("image/webp"),
49        image::ImageFormat::Ico => Some("image/x-icon"),
50        image::ImageFormat::Pnm => Some("image/x-portable-anymap"),
51        _ => None,
52    }
53}
54
55/// Helper to encode a `DynamicImage` to JPEG bytes with quality.
56pub fn encode_jpeg(img: &DynamicImage, quality: u8) -> Result<Vec<u8>, HonzoError> {
57    let rgb = img.to_rgb8();
58    let (w, h) = rgb.dimensions();
59    let mut out = Vec::new();
60    let mut encoder = JpegEncoder::new_with_quality(&mut out, quality);
61    encoder
62        .encode(&rgb, w, h, image::ExtendedColorType::Rgb8)
63        .map_err(|_| HonzoError::Truncated)?;
64    Ok(out)
65}
66
67/// Walk provided parsed chapters' ASTs and collect a mapping of raw href/src -> alt text.
68/// The returned map contains the raw attribute values as keys; callers should resolve
69/// them against manifest/OPF paths as needed.
70pub fn collect_img_alts_from_parsed(parsed: &[ParsedChapter]) -> HashMap<String, String> {
71    let mut map: HashMap<String, String> = HashMap::new();
72
73    fn walk(node: &AstNode, map: &mut HashMap<String, String>) {
74        if let AstNode::Element {
75            tag,
76            attrs,
77            children,
78            ..
79        } = node
80        {
81            if tag.eq_ignore_ascii_case("img") {
82                if let Some(src) = attrs.get("src").or_else(|| attrs.get("href")) {
83                    let alt = attrs.get("alt").cloned().unwrap_or_default();
84                    map.entry(src.clone()).or_insert(alt);
85                }
86            }
87            for c in children {
88                walk(c, map);
89            }
90        }
91    }
92
93    for p in parsed.iter() {
94        if let Some(ast) = &p.ast {
95            walk(ast, &mut map);
96        }
97    }
98
99    map
100}
101
102// Collect image alts and normalize keys by resolving per-chapter hrefs. The resolver
103// behavior is: try to resolve raw chapter-relative hrefs to manifest/OPF paths; if
104// resolution succeeds use the resolved path as a key, otherwise keep the raw href.
105// (The async variant below performs resolution via `LexEpub`.)
106pub async fn collect_and_resolve_img_alts_async(
107    parsed: &[ParsedChapter],
108    epub: &mut LexEpub,
109) -> HashMap<String, String> {
110    let raw_map = collect_img_alts_from_parsed(parsed);
111    let mut resolved: HashMap<String, String> = HashMap::new();
112
113    for (raw_href, alt) in raw_map.into_iter() {
114        let mut final_key = raw_href.clone();
115        for ci in 0..parsed.len() {
116            match epub.resolve_chapter_resource_path(ci, &raw_href).await {
117                Ok(p) => {
118                    final_key = p;
119                    break;
120                }
121                Err(_) => continue,
122            }
123        }
124        // Insert both resolved key and the original raw href so callers can lookup
125        // by either form.
126        resolved.entry(final_key.clone()).or_insert(alt.clone());
127        resolved.entry(raw_href).or_insert(alt);
128    }
129
130    resolved
131}