Skip to main content

hammer/
images.rs

1//! Image references in Markdown. Hammer shows a project image as `![image](hammer://image/<id>)`, or
2//! with the destination in angle brackets, `<hammer://image/<id>>`. The alt text means nothing: Hammer
3//! shows the image's own name.
4
5/// What every image reference starts with.
6pub const PREFIX: &str = "hammer://image/";
7
8/// The reference to the image `image_id`, `hammer://image/<id>`, for Markdown such as `![image](…)`.
9pub fn reference(image_id: i32) -> String {
10    format!("{PREFIX}{image_id}")
11}
12
13/// The image id a reference names: `hammer://image/3`, or `<hammer://image/3>`, is 3. None for
14/// anything else, such as another scheme, an id that is empty, not all ASCII digits, or too large for
15/// an `i32`.
16pub fn parse_reference(reference: &str) -> Option<i32> {
17    let source = reference.strip_prefix('<').and_then(|it| it.strip_suffix('>')).unwrap_or(reference);
18    let digits = source.strip_prefix(PREFIX)?;
19    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
20        return None;
21    }
22    digits.parse().ok()
23}
24
25/// Every image id `markdown` refers to, each once, in the order they first appear: in images, angle-bracket destinations, reference definitions
26/// (`[map]: hammer://image/3`), and code alike, as Hammer counts them. A reference is
27/// `hammer://image/` and its digits where no letter, digit, `_`, `+`, `.`, or `-` comes just before it
28/// and no letter, digit, or `_` just after, so `hammer://image/12x` names none. An id too large for an
29/// `i32` names none either.
30pub fn find_ids(markdown: &str) -> Vec<i32> {
31    let bytes = markdown.as_bytes();
32    let mut ids = Vec::new();
33    let mut from = 0;
34    while let Some(found) = markdown[from..].find(PREFIX) {
35        let at = from + found;
36        let start = at + PREFIX.len();
37        let end = start + bytes[start..].iter().take_while(|b| b.is_ascii_digit()).count();
38        let bounded = (at == 0 || !is_lead(bytes[at - 1])) && (end == bytes.len() || !is_word(bytes[end]));
39        if end > start && bounded {
40            if let Ok(id) = markdown[start..end].parse() {
41                if !ids.contains(&id) {
42                    ids.push(id);
43                }
44            }
45        }
46        from = end;
47    }
48    ids
49}
50
51/// The image a reference names, as [`parse_reference`] reads it, with its bytes: `image.get`, which
52/// the manifest must ask for. An InvalidInput error for a reference that names no image, and
53/// `image.get`'s for one not in `project`.
54pub fn get(project: &str, reference: &str) -> crate::Result<crate::ops::ImageContent> {
55    match parse_reference(reference) {
56        Some(id) => crate::ops::image::get(project, id),
57        None => Err(format!("InvalidInput: Not an image reference: {reference}").into()),
58    }
59}
60
61fn is_word(b: u8) -> bool {
62    b.is_ascii_alphanumeric() || b == b'_'
63}
64
65fn is_lead(b: u8) -> bool {
66    is_word(b) || matches!(b, b'+' | b'.' | b'-')
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn a_reference_names_its_image_id() {
75        assert_eq!(parse_reference("hammer://image/3"), Some(3));
76        assert_eq!(parse_reference("<hammer://image/3>"), Some(3));
77        assert_eq!(parse_reference("hammer://image/007"), Some(7));
78        assert_eq!(parse_reference(&reference(42)), Some(42));
79        assert_eq!(reference(42), "hammer://image/42");
80        assert_eq!(parse_reference("hammer://image/2147483647"), Some(i32::MAX));
81    }
82
83    #[test]
84    fn anything_else_names_no_image() {
85        for text in [
86            "https://example.com/3.png", "hammer://scene/3", "hammer://image/", "hammer://image/3x",
87            "hammer://image/-3", "hammer://image/+3", "hammer://image/3 ", " hammer://image/3",
88            "hammer://image/99999999999", "hammer://image/2147483648", "<hammer://image/3", "hammer://image/3>",
89            "<<hammer://image/3>>", "<>", "", "hammer://image/3",
90        ] {
91            assert_eq!(parse_reference(text), None, "{text}");
92        }
93    }
94
95    #[test]
96    fn finds_every_referenced_id() {
97        let text = "![a](hammer://image/1)\n\nSee ![b](<hammer://image/12> \"t\") and [c]: hammer://image/7";
98        assert_eq!(find_ids(text), vec![1, 12, 7]);
99        assert_eq!(find_ids("hammer://image/9 hammer://image/2 hammer://image/9"), vec![9, 2]);
100        assert_eq!(find_ids("```\n![x](hammer://image/5)\n```\n![y](hammer://image/5)"), vec![5]);
101        assert_eq!(find_ids("A [link](hammer://image/3)."), vec![3]);
102        assert_eq!(find_ids("éhammer://image/4é"), vec![4]);
103        assert_eq!(find_ids("No images here."), Vec::<i32>::new());
104    }
105
106    #[test]
107    fn an_id_runs_to_its_last_digit_and_the_scheme_stands_alone() {
108        assert_eq!(find_ids("![a](hammer://image/12)"), vec![12]);
109        assert_eq!(find_ids("hammer://image/12"), vec![12]);
110        assert_eq!(find_ids("hammer://image/12.png"), vec![12]);
111        for text in [
112            "hammer://image/12x", "hammer://image/12_", "xhammer://image/3", "_hammer://image/3", "+hammer://image/3",
113            ".hammer://image/3", "-hammer://image/3", "9hammer://image/3", "hammer://image/", "hammer://image/x",
114        ] {
115            assert_eq!(find_ids(text), Vec::<i32>::new(), "{text}");
116        }
117    }
118
119    #[test]
120    fn an_id_too_large_for_an_i32_names_no_image() {
121        assert_eq!(find_ids("![a](hammer://image/3) ![b](hammer://image/99999999999)"), vec![3]);
122    }
123}