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}