1use serde::{Deserialize, Serialize};
34use serde_json::{Map, Value};
35
36#[derive(Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct Request {
39 pub format: String,
41 pub project_name: String,
42 #[serde(default = "english")]
44 pub language: String,
45 #[serde(default = "yes")]
47 pub top_level_as_chapters: bool,
48 #[serde(default)]
49 pub chapters: Vec<Chapter>,
50 #[serde(default)]
52 pub settings: Map<String, Value>,
53 #[serde(default)]
55 pub locale: Option<String>,
56 #[serde(default)]
59 pub images: Vec<Image>,
60 #[serde(default)]
63 pub cover_image_id: Option<i32>,
64}
65
66impl Request {
67 pub fn image(&self, id: i32) -> Option<&Image> {
69 self.images.iter().find(|image| image.id == id)
70 }
71
72 pub fn cover(&self) -> Option<&Image> {
74 self.cover_image_id.and_then(|id| self.image(id))
75 }
76}
77
78#[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#[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 pub mime_type: String,
102 #[serde(with = "crate::base64::bytes")]
103 pub content: Vec<u8>,
104}
105
106pub 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#[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
137pub trait ArchiveImage {
139 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 pub fn new() -> Archive {
164 Archive::default()
165 }
166
167 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 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 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 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 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": [""]}], "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 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", "").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":""},"#
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}