Skip to main content

common/parser_tools/
image_options.rs

1//! Image payloads supplied to an export.
2//!
3//! This crate never touches the filesystem โ€” an inline image stores only a
4//! `src` string, and resolving that string to bytes is the embedding
5//! application's job. Exports that need to *embed* an image therefore receive
6//! its bytes the same way the PDF exporter already receives fonts: handed over
7//! by the caller, keyed by exactly the `src` the document carries.
8//!
9//! That indirection is deliberate. The alternative โ€” having the exporter open
10//! files named by the document โ€” would make export depend on the caller's
11//! working directory, would let a document reference paths outside the
12//! project, and would make this crate's behaviour untestable without a
13//! filesystem fixture.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19/// One image's bytes, together with what they are.
20///
21/// `mime_type` is carried rather than sniffed because the caller already knows
22/// it (it stored the image) and because container formats need it verbatim:
23/// EPUB writes it into the OPF manifest, and getting it wrong produces a book
24/// that validates but will not render.
25#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
26pub struct ExportImage {
27    pub bytes: Vec<u8>,
28    pub mime_type: String,
29}
30
31impl ExportImage {
32    pub fn new(bytes: impl Into<Vec<u8>>, mime_type: impl Into<String>) -> Self {
33        Self {
34            bytes: bytes.into(),
35            mime_type: mime_type.into(),
36        }
37    }
38
39    /// The conventional file extension for this image's media type, used when a
40    /// container has to name the packaged file. Falls back to `bin` rather than
41    /// guessing, so an unknown type is visible instead of silently mislabelled.
42    pub fn extension(&self) -> &'static str {
43        match self.mime_type.as_str() {
44            "image/png" => "png",
45            "image/jpeg" => "jpg",
46            "image/webp" => "webp",
47            "image/gif" => "gif",
48            "image/svg+xml" => "svg",
49            _ => "bin",
50        }
51    }
52}
53
54/// Image bytes for one export, keyed by the `src` of the inline images that
55/// reference them.
56///
57/// A `BTreeMap` (not a `HashMap`) so packaged filenames come out in a stable
58/// order: an EPUB or DOCX built twice from the same document must be
59/// byte-comparable, which a randomised iteration order quietly prevents.
60#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
61pub struct ExportImages(BTreeMap<String, ExportImage>);
62
63impl ExportImages {
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Register bytes for the image referenced by `src`.
69    pub fn insert(&mut self, src: impl Into<String>, image: ExportImage) -> &mut Self {
70        self.0.insert(src.into(), image);
71        self
72    }
73
74    pub fn get(&self, src: &str) -> Option<&ExportImage> {
75        self.0.get(src)
76    }
77
78    pub fn iter(&self) -> impl Iterator<Item = (&String, &ExportImage)> {
79        self.0.iter()
80    }
81
82    pub fn is_empty(&self) -> bool {
83        self.0.is_empty()
84    }
85
86    pub fn len(&self) -> usize {
87        self.0.len()
88    }
89}
90
91impl<S: Into<String>> FromIterator<(S, ExportImage)> for ExportImages {
92    fn from_iter<I: IntoIterator<Item = (S, ExportImage)>>(iter: I) -> Self {
93        Self(iter.into_iter().map(|(s, i)| (s.into(), i)).collect())
94    }
95}
96
97/// How an HTML export represents an inline image.
98///
99/// HTML is the one text export where the choice is genuinely open: the output
100/// is a single string, but an `<img>` can either point at a file the caller
101/// will write beside it or carry the bytes inline.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
103pub enum HtmlImageMode {
104    /// Emit `src` exactly as the document stores it and embed nothing.
105    ///
106    /// The default, because it is the only mode that cannot silently inflate
107    /// the output: resolving the reference is then the caller's business, which
108    /// is also what makes a sidecar-assets layout possible.
109    #[default]
110    Reference,
111    /// Inline the bytes as a `data:` URI, producing a self-contained file.
112    ///
113    /// Base64 costs about a third more than the raw bytes, so a document with
114    /// large photographs produces a correspondingly large `.html`.
115    DataUri,
116    /// Drop images entirely, keeping their alt text as the accessible fallback.
117    Omit,
118}
119
120/// Options for an HTML export.
121#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
122pub struct HtmlExportOptions {
123    pub image_mode: HtmlImageMode,
124    /// Bytes for [`HtmlImageMode::DataUri`]. Unused in the other modes.
125    #[serde(default)]
126    pub images: ExportImages,
127}
128
129/// Base64 (standard alphabet, padded) for `data:` URIs.
130pub fn base64_encode(bytes: &[u8]) -> String {
131    use base64::Engine;
132    base64::engine::general_purpose::STANDARD.encode(bytes)
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn base64_matches_the_rfc_test_vectors() {
141        // RFC 4648 ยง10.
142        for (input, expected) in [
143            ("", ""),
144            ("f", "Zg=="),
145            ("fo", "Zm8="),
146            ("foo", "Zm9v"),
147            ("foob", "Zm9vYg=="),
148            ("fooba", "Zm9vYmE="),
149            ("foobar", "Zm9vYmFy"),
150        ] {
151            assert_eq!(base64_encode(input.as_bytes()), expected, "{input:?}");
152        }
153    }
154
155    #[test]
156    fn base64_handles_bytes_above_ascii() {
157        assert_eq!(base64_encode(&[0xff, 0xfe, 0xfd]), "//79");
158        assert_eq!(base64_encode(&[0x00, 0x00, 0x00]), "AAAA");
159    }
160
161    #[test]
162    fn extension_falls_back_visibly_for_unknown_types() {
163        assert_eq!(ExportImage::new(vec![], "image/png").extension(), "png");
164        assert_eq!(ExportImage::new(vec![], "image/jpeg").extension(), "jpg");
165        assert_eq!(
166            ExportImage::new(vec![], "application/x-thing").extension(),
167            "bin"
168        );
169    }
170
171    #[test]
172    fn images_iterate_in_a_stable_order() {
173        // Packaged filenames are derived from iteration order, so two exports of
174        // the same document must agree.
175        let build = || {
176            ExportImages::from_iter([
177                ("z.png", ExportImage::new(vec![1], "image/png")),
178                ("a.png", ExportImage::new(vec![2], "image/png")),
179                ("m.png", ExportImage::new(vec![3], "image/png")),
180            ])
181        };
182        let first: Vec<String> = build().iter().map(|(k, _)| k.clone()).collect();
183        let second: Vec<String> = build().iter().map(|(k, _)| k.clone()).collect();
184        assert_eq!(first, second);
185        assert_eq!(first, vec!["a.png", "m.png", "z.png"]);
186    }
187}