Skip to main content

hammer/
export.rs

1//! The `export` export: renders one of the manifest's `[[exporters]]` formats from the story, and
2//! returns the file's bytes.
3//!
4//! An exporter whose manifest entry sets `images = true` gets the files of the images the story shows
5//! in [Request::images], and with `cover = true` as well, the project's cover. In markdown, an image
6//! is `![image](hammer://image/<id>)`, which [crate::images] reads; in prose, a line holding only an
7//! image is a block `{"type": "image", "id": 5, "name": "Map"}`, which [prose_image_id] reads. An image
8//! inside a line, or one whose file Hammer could not read, is its name as text, never a block.
9//!
10//! ```ignore
11//! let request: hammer::export::Request = hammer::input_json()?;
12//! for scene in request.chapters.iter().flat_map(|chapter| &chapter.scenes) {
13//!     for id in hammer::images::find_ids(scene) {
14//!         if let Some(image) = request.image(id) { /* image.content holds the file */ }
15//!     }
16//! }
17//! ```
18//!
19//! An exporter whose manifest entry sets `output = "archive"` returns an [Archive] instead: files that
20//! Hammer zips, in order, into the one export file, whose `extension` and `mime` the manifest still
21//! gives (`zip` and `application/zip`). An image Hammer sent is named by its id, and Hammer writes its
22//! bytes, so the reply carries none.
23//!
24//! ```ignore
25//! let mut archive = hammer::export::Archive::new();
26//! archive.text("story.md", story)?;
27//! for image in &request.images {
28//!     archive.image(&format!("images/{}.{}", image.id, image.extension), image)?;
29//! }
30//! archive.to_json()
31//! ```
32
33use serde::{Deserialize, Serialize};
34use serde_json::{Map, Value};
35
36#[derive(Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct Request {
39    /// Which of the manifest's `[[exporters]]` formats to render.
40    pub format: String,
41    pub project_name: String,
42    /// The project's BCP 47 tag.
43    #[serde(default = "english")]
44    pub language: String,
45    /// Whether the scene tree's top-level nodes are chapters; when not, the story is one chapter.
46    #[serde(default = "yes")]
47    pub top_level_as_chapters: bool,
48    #[serde(default)]
49    pub chapters: Vec<Chapter>,
50    /// The plugin's declared settings, every key present.
51    #[serde(default)]
52    pub settings: Map<String, Value>,
53    /// The UI's BCP 47 tag, for replying in the user's language; not [Request::language], the story's.
54    #[serde(default)]
55    pub locale: Option<String>,
56    /// For a format whose manifest entry sets `images = true`, each project image the story refers to
57    /// whose file Hammer could read, sorted by id, and the cover with `cover = true`; empty otherwise.
58    #[serde(default)]
59    pub images: Vec<Image>,
60    /// The cover's id among [Request::images], with `cover = true`; none when the project has no cover
61    /// or its file could not be read.
62    #[serde(default)]
63    pub cover_image_id: Option<i32>,
64}
65
66impl Request {
67    /// The image `id` among [Request::images], or none when it is not there.
68    pub fn image(&self, id: i32) -> Option<&Image> {
69        self.images.iter().find(|image| image.id == id)
70    }
71
72    /// The cover among [Request::images], or none when there is none.
73    pub fn cover(&self) -> Option<&Image> {
74        self.cover_image_id.and_then(|id| self.image(id))
75    }
76}
77
78/// A chapter's scenes: as markdown in `scenes`, or, for a format whose manifest entry sets
79/// `input = "prose"`, in `prose` as Hammer's parsed blocks, one list of blocks per scene.
80///
81/// With `images = true`, a line holding only an image is an `image` block, which [prose_image_id]
82/// reads; an image inside a line, or one whose file Hammer could not read, is its name as text, never a
83/// block.
84#[derive(Debug, Clone, PartialEq, Deserialize)]
85pub struct Chapter {
86    pub name: String,
87    #[serde(default)]
88    pub scenes: Vec<String>,
89    #[serde(default)]
90    pub prose: Vec<Vec<Value>>,
91}
92
93/// A project image and its file: `image.get`'s [crate::ops::ImageContent] with `mime_type` added.
94#[derive(Debug, Clone, PartialEq, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct Image {
97    pub id: i32,
98    pub name: String,
99    pub extension: String,
100    /// The file's media type; `application/octet-stream` when Hammer does not know the extension.
101    pub mime_type: String,
102    #[serde(with = "crate::base64::bytes")]
103    pub content: Vec<u8>,
104}
105
106/// The id of a prose `image` block, `{"type": "image", "id": 5, "name": "Map"}`; none for any other
107/// block.
108pub fn prose_image_id(block: &Value) -> Option<i32> {
109    if block.get("type")?.as_str()? != "image" {
110        return None;
111    }
112    block.get("id")?.as_i64()?.try_into().ok()
113}
114
115/// What the `export` export returns, as [Archive::to_json], for a format whose manifest entry sets
116/// `output = "archive"`: files Hammer zips in the order they were added.
117///
118/// A path is relative, with `/` between its segments: not empty, not starting with `/`, no `\`, no
119/// empty segment (so no `//` or trailing `/`), no `.` or `..` segment, and not a path already added.
120/// Adding a file with any other path fails, naming it, as Hammer would refuse the reply.
121#[derive(Default)]
122pub struct Archive {
123    files: Vec<File>,
124}
125
126#[derive(Serialize)]
127struct File {
128    path: String,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    text: Option<String>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    content: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    image: Option<i32>,
135}
136
137/// What [Archive::image] names an image of [Request::images] by: its id, or the [Image] itself.
138pub trait ArchiveImage {
139    /// The image's id.
140    fn id(&self) -> i32;
141}
142
143impl ArchiveImage for i32 {
144    fn id(&self) -> i32 {
145        *self
146    }
147}
148
149impl ArchiveImage for Image {
150    fn id(&self) -> i32 {
151        self.id
152    }
153}
154
155impl<T: ArchiveImage + ?Sized> ArchiveImage for &T {
156    fn id(&self) -> i32 {
157        (**self).id()
158    }
159}
160
161impl Archive {
162    /// An archive without files.
163    pub fn new() -> Archive {
164        Archive::default()
165    }
166
167    /// Adds `text` as the file at `path`, written as UTF-8.
168    pub fn text(&mut self, path: impl Into<String>, text: impl Into<String>) -> crate::Result<&mut Archive> {
169        self.add(File { path: path.into(), text: Some(text.into()), content: None, image: None })
170    }
171
172    /// Adds `bytes` as the file at `path`.
173    pub fn bytes(&mut self, path: impl Into<String>, bytes: &[u8]) -> crate::Result<&mut Archive> {
174        self.add(File { path: path.into(), text: None, content: Some(crate::base64::encode(bytes)), image: None })
175    }
176
177    /// Adds the file at `path` whose bytes `encoded` holds as standard padded base64, passed on without
178    /// decoding it, for base64 a plugin kept itself; Hammer fails the export when it is not base64.
179    pub fn base64(&mut self, path: impl Into<String>, encoded: impl Into<String>) -> crate::Result<&mut Archive> {
180        self.add(File { path: path.into(), text: None, content: Some(encoded.into()), image: None })
181    }
182
183    /// Adds an image of [Request::images] as the file at `path`, named by its id: `5` or `&image`. Hammer
184    /// writes its bytes, so the reply carries none; an id it did not send fails the export.
185    pub fn image(&mut self, path: impl Into<String>, image: impl ArchiveImage) -> crate::Result<&mut Archive> {
186        self.add(File { path: path.into(), text: None, content: None, image: Some(image.id()) })
187    }
188
189    /// The `export` export's reply; fails when no file was added.
190    pub fn to_json(&self) -> crate::Result<String> {
191        if self.files.is_empty() {
192            return Err("An archive needs at least one file".into());
193        }
194        #[derive(Serialize)]
195        struct Reply<'a> {
196            files: &'a [File],
197        }
198        Ok(serde_json::to_string(&Reply { files: &self.files })?)
199    }
200
201    fn add(&mut self, file: File) -> crate::Result<&mut Archive> {
202        if let Some(rule) = self.refuses(&file.path) {
203            return Err(format!("Archive path \"{}\" {rule}", file.path).into());
204        }
205        self.files.push(file);
206        Ok(self)
207    }
208
209    fn refuses(&self, path: &str) -> Option<&'static str> {
210        if path.is_empty() {
211            return Some("is empty");
212        }
213        if path.starts_with('/') {
214            return Some("starts with /");
215        }
216        if path.contains('\\') {
217            return Some("has a backslash");
218        }
219        for segment in path.split('/') {
220            match segment {
221                "" => return Some("has an empty segment"),
222                "." => return Some("has a . segment"),
223                ".." => return Some("has a .. segment"),
224                _ => {}
225            }
226        }
227        self.files.iter().any(|file| file.path == path).then_some("is already in the archive")
228    }
229}
230
231fn english() -> String {
232    "en".into()
233}
234
235fn yes() -> bool {
236    true
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use serde_json::json;
243
244    #[test]
245    fn a_request_carries_its_images_and_cover() {
246        let request: Request = serde_json::from_value(json!({
247            "format": "book.epub", "projectName": "Storm", "language": "fr", "topLevelAsChapters": false,
248            "chapters": [{"name": "One", "scenes": [], "prose": [
249                [{"type": "paragraph", "spans": [{"text": "Rain."}]}, {"type": "image", "id": 5, "name": "Map"}],
250                [{"type": "paragraph", "spans": [{"text": "Map"}]}],
251            ]}],
252            "images": [
253                {"id": 2, "name": "Cover", "extension": "jpg", "mimeType": "image/jpeg", "content": "AP8="},
254                {"id": 5, "name": "Map", "extension": "png", "mimeType": "image/png", "content": "iVBORw=="},
255            ],
256            "coverImageId": 2, "settings": {}, "locale": null,
257        }))
258        .unwrap();
259        assert_eq!(request.project_name, "Storm");
260        assert_eq!(request.language, "fr");
261        assert!(!request.top_level_as_chapters);
262        assert_eq!(request.images.iter().map(|image| image.id).collect::<Vec<_>>(), vec![2, 5]);
263        let map = request.image(5).unwrap();
264        assert_eq!((map.name.as_str(), map.extension.as_str(), map.mime_type.as_str()), ("Map", "png", "image/png"));
265        assert_eq!(map.content, b"\x89PNG");
266        assert!(request.image(3).is_none());
267        assert_eq!(request.cover_image_id, Some(2));
268        assert_eq!(request.cover().map(|image| image.content.as_slice()), Some(&[0u8, 255][..]));
269        let prose = &request.chapters[0].prose;
270        assert_eq!(prose.len(), 2);
271        assert_eq!(prose.iter().flatten().map(prose_image_id).collect::<Vec<_>>(), vec![None, Some(5), None]);
272        let ids: Vec<_> =
273            request.chapters.iter().flat_map(|chapter| &chapter.prose).flatten().filter_map(prose_image_id).collect();
274        assert_eq!(ids, vec![5]);
275    }
276
277    #[test]
278    fn a_request_from_a_host_without_images_has_none() {
279        let request: Request = serde_json::from_value(json!({
280            "format": "hello.txt", "projectName": "Storm",
281            "chapters": [{"name": "One", "scenes": ["![image](hammer://image/5)"]}], "settings": {},
282        }))
283        .unwrap();
284        assert_eq!(request.language, "en");
285        assert!(request.top_level_as_chapters);
286        assert!(request.images.is_empty());
287        assert_eq!(request.cover_image_id, None);
288        assert!(request.cover().is_none());
289        assert!(request.image(5).is_none());
290        assert!(request.chapters[0].prose.is_empty());
291    }
292
293    #[test]
294    fn a_cover_not_among_the_images_is_none() {
295        let request: Request =
296            serde_json::from_value(json!({"format": "f", "projectName": "p", "images": [], "coverImageId": 9}))
297                .unwrap();
298        assert!(request.cover().is_none());
299    }
300
301    #[test]
302    fn content_that_is_not_base64_fails() {
303        let request = serde_json::from_value::<Request>(json!({"format": "f", "projectName": "p", "images": [
304            {"id": 1, "name": "a", "extension": "png", "mimeType": "image/png", "content": "not base64!"},
305        ]}));
306        assert!(request.is_err());
307    }
308
309    #[test]
310    fn only_an_image_block_has_an_image_id() {
311        for block in [
312            json!({"type": "paragraph", "spans": [{"text": "Map"}]}),
313            json!({"type": "image", "name": "Map"}),
314            json!({"type": "image", "id": "5"}),
315            json!({"type": "image", "id": 99999999999i64}),
316            json!({"id": 5}),
317            json!([]),
318            json!("image"),
319            Value::Null,
320        ] {
321            assert_eq!(prose_image_id(&block), None, "{block}");
322        }
323        assert_eq!(prose_image_id(&json!({"name": "Map", "id": 7, "type": "image"})), Some(7));
324    }
325
326    #[test]
327    fn an_archive_lists_its_files_in_order_each_with_text_or_content() {
328        let mut archive = Archive::new();
329        archive.text("story.md", "# One\n\"Rain.\" é").unwrap();
330        archive.bytes("images/1.png", b"\x89PNG").unwrap().base64("cover.jpg", "AP8=").unwrap();
331        assert_eq!(
332            archive.to_json().unwrap(),
333            r##"{"files":[{"path":"story.md","text":"# One\n\"Rain.\" é"},"##.to_owned()
334                + r#"{"path":"images/1.png","content":"iVBORw=="},{"path":"cover.jpg","content":"AP8="}]}"#
335        );
336    }
337
338    #[test]
339    fn archive_bytes_are_standard_base64_with_padding() {
340        let mut archive = Archive::new();
341        for (path, bytes) in [("1", &b"f"[..]), ("2", b"fo"), ("3", b"foo"), ("4", b"\xFB\xFF\xBF"), ("5", b"")] {
342            archive.bytes(path, bytes).unwrap();
343        }
344        let reply: Value = serde_json::from_str(&archive.to_json().unwrap()).unwrap();
345        assert_eq!(
346            reply,
347            json!({"files": [
348                {"path": "1", "content": "Zg=="}, {"path": "2", "content": "Zm8="}, {"path": "3", "content": "Zm9v"},
349                {"path": "4", "content": "+/+/"}, {"path": "5", "content": ""},
350            ]})
351        );
352    }
353
354    #[test]
355    fn archive_base64_is_passed_on_as_it_is() {
356        // Decoding and encoding again would give Zg== and Zm8=.
357        let mut archive = Archive::new();
358        archive.base64("a", "Zh==").unwrap().base64("b", String::from("Zm8")).unwrap();
359        assert_eq!(
360            archive.to_json().unwrap(),
361            r#"{"files":[{"path":"a","content":"Zh=="},{"path":"b","content":"Zm8"}]}"#
362        );
363    }
364
365    #[test]
366    fn an_archive_image_is_its_id_a_json_integer() {
367        let mut archive = Archive::new();
368        archive.image("images/5.png", 5).unwrap().image("images/0.jpg", 0).unwrap();
369        archive.image("big.gif", i32::MAX).unwrap();
370        assert_eq!(
371            archive.to_json().unwrap(),
372            r#"{"files":[{"path":"images/5.png","image":5},{"path":"images/0.jpg","image":0},"#.to_owned()
373                + r#"{"path":"big.gif","image":2147483647}]}"#
374        );
375    }
376
377    #[test]
378    fn an_archive_image_hammer_sent_is_named_by_its_id() {
379        let request: Request = serde_json::from_value(json!({"format": "book.zip", "projectName": "Storm", "images": [
380            {"id": 2, "name": "Cover", "extension": "jpg", "mimeType": "image/jpeg", "content": "AP8="},
381            {"id": 5, "name": "Map", "extension": "png", "mimeType": "image/png", "content": "iVBORw0KGgo="},
382        ], "coverImageId": 2}))
383        .unwrap();
384        let mut archive = Archive::new();
385        archive.image("images/5.png", request.image(5).unwrap()).unwrap();
386        archive.image("cover.jpg", request.cover().unwrap().clone()).unwrap();
387        for image in &request.images {
388            archive.image(format!("all/{}.{}", image.id, image.extension), image).unwrap();
389        }
390        assert_eq!(
391            archive.to_json().unwrap(),
392            r#"{"files":[{"path":"images/5.png","image":5},{"path":"cover.jpg","image":2},"#.to_owned()
393                + r#"{"path":"all/2.jpg","image":2},{"path":"all/5.png","image":5}]}"#
394        );
395    }
396
397    #[test]
398    fn archive_text_bytes_and_images_keep_their_order() {
399        let mut archive = Archive::new();
400        archive.image("images/5.png", 5).unwrap().text("story.md", "![Map](images/5.png)").unwrap();
401        archive.bytes("data.bin", b"\x01").unwrap().image("cover.jpg", 2).unwrap();
402        archive.base64("notes.txt", "Zg==").unwrap();
403        assert_eq!(
404            archive.to_json().unwrap(),
405            r#"{"files":[{"path":"images/5.png","image":5},{"path":"story.md","text":"![Map](images/5.png)"},"#
406                .to_owned()
407                + r#"{"path":"data.bin","content":"AQ=="},{"path":"cover.jpg","image":2},"#
408                + r#"{"path":"notes.txt","content":"Zg=="}]}"#
409        );
410    }
411
412    #[test]
413    fn an_archive_image_path_is_checked_like_any_other() {
414        let mut archive = Archive::new();
415        archive.text("images/5.png", "a").unwrap();
416        let error = archive.image("images/5.png", 5).err().unwrap();
417        assert_eq!(error.to_string(), "Archive path \"images/5.png\" is already in the archive");
418        assert_eq!(archive.image("/5.png", 5).err().unwrap().to_string(), "Archive path \"/5.png\" starts with /");
419    }
420
421    #[test]
422    fn each_archive_path_rule_refuses_a_file_naming_its_path() {
423        for (path, rule) in [
424            ("", "is empty"),
425            ("/story.md", "starts with /"),
426            ("images\\1.png", "has a backslash"),
427            ("images//1.png", "has an empty segment"),
428            ("images/", "has an empty segment"),
429            ("./story.md", "has a . segment"),
430            ("images/../story.md", "has a .. segment"),
431            ("images/.", "has a . segment"),
432            (".", "has a . segment"),
433            ("..", "has a .. segment"),
434        ] {
435            let mut archive = Archive::new();
436            let error = archive.text(path, "x").err().unwrap();
437            assert_eq!(error.to_string(), format!("Archive path \"{path}\" {rule}"));
438            assert!(archive.to_json().is_err(), "{path:?}");
439        }
440    }
441
442    #[test]
443    fn an_archive_path_already_added_is_refused() {
444        let mut archive = Archive::new();
445        archive.text("story.md", "a").unwrap();
446        let error = archive.bytes("story.md", b"\x01").err().unwrap();
447        assert_eq!(error.to_string(), "Archive path \"story.md\" is already in the archive");
448        archive.text("Story.md", "b").unwrap().text("a", "c").unwrap().text("ab", "d").unwrap();
449        assert_eq!(archive.text("a", "e").err().unwrap().to_string(), "Archive path \"a\" is already in the archive");
450        assert_eq!(
451            archive.to_json().unwrap(),
452            r#"{"files":[{"path":"story.md","text":"a"},{"path":"Story.md","text":"b"},"#.to_owned()
453                + r#"{"path":"a","text":"c"},{"path":"ab","text":"d"}]}"#
454        );
455    }
456
457    #[test]
458    fn nested_archive_paths_and_dots_inside_a_segment_are_fine() {
459        let mut archive = Archive::new();
460        archive.bytes("images/2026/a b.png", b"\x01").unwrap().text(".hidden/...", "").unwrap();
461        archive.text("a..b/c.d", "").unwrap().text("images/carte été.png", "").unwrap();
462        let paths: Vec<_> = archive.files.iter().map(|file| file.path.as_str()).collect();
463        assert_eq!(paths, ["images/2026/a b.png", ".hidden/...", "a..b/c.d", "images/carte été.png"]);
464    }
465
466    #[test]
467    fn an_archive_without_files_has_no_reply() {
468        assert_eq!(Archive::new().to_json().err().unwrap().to_string(), "An archive needs at least one file");
469    }
470}