Skip to main content

mos_eval/
image_lower.rs

1//! Lower `#image` and `#figure` parser directives into semantic nodes.
2
3use std::collections::BTreeMap;
4use std::path::Path;
5use std::sync::Arc;
6
7use mos_core::{
8    AttrMap, AttrValue, Diagnostic, Document, NodeId, NodeKind, NodeSpec, SourceSpan, codes,
9};
10use mos_parse::{SetArg, SetValue};
11
12use crate::{image, insert_label_attributes, set::coerce_positive_length};
13
14/// Lower a top-level `#image(...)` directive into a single
15/// [`NodeKind::Image`] node hanging off the document root. The decoded
16/// pixel buffer and pixel dimensions are stashed in attributes so the
17/// layout engine and PDF backend don't have to re-open the source file.
18pub(super) fn lower_image_directive(
19    document: &mut Document,
20    root: NodeId,
21    args: &[SetArg],
22    span: &SourceSpan,
23    source_file: &Path,
24    em_pt: f64,
25    diagnostics: &mut Vec<Diagnostic>,
26) {
27    let Some((attributes, _label)) =
28        build_image_attributes(args, span, source_file, em_pt, diagnostics)
29    else {
30        return;
31    };
32    document.alloc_child(
33        root,
34        NodeSpec::new(NodeKind::Image, span.clone()).with_attributes(attributes),
35    );
36}
37
38/// Lower a `#figure(image: ..., caption: ...)` directive into a
39/// [`NodeKind::Figure`] node with two children: an Image node (built
40/// the same way `#image(...)` would build it) and a caption paragraph.
41/// The caption is rendered beneath the image by the layout engine.
42pub(super) fn lower_figure_directive(
43    document: &mut Document,
44    root: NodeId,
45    args: &[SetArg],
46    span: &SourceSpan,
47    source_file: &Path,
48    em_pt: f64,
49    diagnostics: &mut Vec<Diagnostic>,
50) {
51    // Pluck the image-specifying args (`image:` path, optional
52    // `width`/`height`/`alt`) into a synthetic SetArg list so the
53    // existing builder can reuse them. A leading positional string
54    // (the `SetArg::Positional` arm below) is also accepted as the
55    // image path -- `#figure("x.png")` is the captionless short form,
56    // equivalent to `#figure(image: "x.png")`.
57    let mut image_args: Vec<SetArg> = Vec::new();
58    let mut caption: Option<(String, SourceSpan)> = None;
59    let mut figure_label: Option<(String, SourceSpan)> = None;
60    // Numbering controls (issue #76). `numbered: false` opts a figure out
61    // of the auto `Figure N` counter; `supplement: "Plate"` swaps the
62    // supplement word. Both default to the standard numbered `#figure`.
63    let mut numbered: Option<bool> = None;
64    let mut supplement: Option<String> = None;
65    for arg in args {
66        match arg {
67            // A leading positional string is the same shorthand
68            // `#image(...)` accepts -- `#figure("scan.png")` is the
69            // captioned-image short form, equivalent to
70            // `#figure(image: "scan.png")`.
71            SetArg::Positional { .. } => image_args.push(arg.clone()),
72            SetArg::Named {
73                key,
74                value,
75                key_span,
76                value_span,
77            } => match key.as_str() {
78                "image" => {
79                    // Rewrite the named `image:` arg as the positional
80                    // slot `build_image_attributes` expects.
81                    image_args.push(SetArg::Positional {
82                        value: value.clone(),
83                        value_span: value_span.clone(),
84                    });
85                }
86                "width" | "height" | "alt" => {
87                    image_args.push(arg.clone());
88                }
89                "caption" => match value {
90                    SetValue::Str(s) => {
91                        caption = Some((s.clone(), value_span.clone()));
92                    }
93                    _ => diagnostics.push(
94                        Diagnostic::simple(&codes::MOS0020, None,
95                            "`#figure(caption: ...)` expects a string",
96                        )
97                        .with_span(value_span.clone()),
98                    ),
99                },
100                "label" => match value {
101                    SetValue::Str(s) => {
102                        figure_label = Some((s.clone(), string_content_span(value_span)));
103                    }
104                    _ => diagnostics.push(
105                        Diagnostic::simple(&codes::MOS0020, None,
106                            "`#figure(label: ...)` expects a string",
107                        )
108                        .with_span(value_span.clone()),
109                    ),
110                },
111                // `numbered: false` skips the auto `Figure N` counter for
112                // this figure; `true` is the default. The value is a bare
113                // ident (`SetValue::Ident`), not a string.
114                "numbered" => match value {
115                    SetValue::Ident(word) if word == "true" => numbered = Some(true),
116                    SetValue::Ident(word) if word == "false" => numbered = Some(false),
117                    _ => diagnostics.push(
118                        Diagnostic::simple(&codes::MOS0020, None,
119                            "`#figure(numbered: ...)` expects `true` or `false`",
120                        )
121                        .with_span(value_span.clone()),
122                    ),
123                },
124                // `supplement: "Plate"` replaces the "Figure" supplement
125                // word in both the caption and references to this figure.
126                // `supplement: ""` or `supplement: none` drops the word
127                // entirely, rendering the number alone ("no visible prefix").
128                "supplement" => match value {
129                    SetValue::Str(s) => supplement = Some(s.clone()),
130                    SetValue::Ident(word) if word == "none" => supplement = Some(String::new()),
131                    _ => diagnostics.push(
132                        Diagnostic::simple(&codes::MOS0020, None,
133                            "`#figure(supplement: ...)` expects a string or `none`",
134                        )
135                        .with_span(value_span.clone()),
136                    ),
137                },
138                _ => diagnostics.push(
139                    Diagnostic::simple(&codes::MOS0015, None,
140                        format!(
141                            "unknown argument `{key}` for `#figure` (valid: image, caption, alt, width, height, label, numbered, supplement)"
142                        ),
143                    )
144                    .with_span(key_span.clone()),
145                ),
146            },
147        }
148    }
149
150    // Build the image attributes before allocating the Figure node.
151    // Failed image load should not leave a phantom caption-only figure.
152    let Some((image_attrs, _label)) =
153        build_image_attributes(&image_args, span, source_file, em_pt, diagnostics)
154    else {
155        return;
156    };
157
158    let mut figure_attrs: AttrMap = BTreeMap::new();
159    if let Some((label, label_span)) = figure_label {
160        insert_label_attributes(&mut figure_attrs, &label, Some(&label_span));
161    }
162    // Only the non-default opt-out is recorded; absence means "numbered".
163    if numbered == Some(false) {
164        figure_attrs.insert("numbered".to_owned(), AttrValue::Bool(false));
165    }
166    if let Some(supp) = supplement {
167        figure_attrs.insert("supplement".to_owned(), AttrValue::Str(supp));
168    }
169    let figure_id = document.alloc_child(
170        root,
171        NodeSpec::new(NodeKind::Figure, span.clone()).with_attributes(figure_attrs),
172    );
173    document.alloc_child(
174        figure_id,
175        NodeSpec::new(NodeKind::Image, span.clone()).with_attributes(image_attrs),
176    );
177    if let Some((text, caption_span)) = caption {
178        let caption_id = document.alloc_child(
179            figure_id,
180            NodeSpec::new(NodeKind::Paragraph, caption_span.clone()).with_attributes({
181                let mut a = AttrMap::new();
182                // Tag the caption so the layout engine can give it
183                // distinct styling later. For now it renders as a
184                // plain paragraph beneath the image.
185                a.insert("role".to_owned(), AttrValue::Str("caption".to_owned()));
186                a
187            }),
188        );
189        let mut child_attrs = AttrMap::new();
190        child_attrs.insert("text".to_owned(), AttrValue::Str(text));
191        document.alloc_child(
192            caption_id,
193            NodeSpec::new(NodeKind::Text, caption_span).with_attributes(child_attrs),
194        );
195    }
196}
197
198/// Walk a directive's argument list and produce the attribute map for
199/// an [`NodeKind::Image`] node, including the decoded pixel buffer.
200/// Returns `None` (and emits diagnostics) if the path argument is
201/// missing or the bytes can't be decoded -- the caller drops the node
202/// in that case rather than emitting a half-built image.
203fn build_image_attributes(
204    args: &[SetArg],
205    span: &SourceSpan,
206    source_file: &Path,
207    em_pt: f64,
208    diagnostics: &mut Vec<Diagnostic>,
209) -> Option<(AttrMap, Option<String>)> {
210    let mut src_path: Option<(String, SourceSpan)> = None;
211    let mut alt: Option<String> = None;
212    let mut declared_width: Option<f64> = None;
213    let mut declared_height: Option<f64> = None;
214    let mut label: Option<(String, SourceSpan)> = None;
215    for arg in args {
216        match arg {
217            // Positional first arg -- the path literal.
218            SetArg::Positional { value, value_span } => match value {
219                SetValue::Str(s) => src_path = Some((s.clone(), value_span.clone())),
220                _ => diagnostics.push(
221                    Diagnostic::simple(&codes::MOS0020, None,
222                        "`#image(...)` expects a string path",
223                    )
224                    .with_span(value_span.clone()),
225                ),
226            },
227            SetArg::Named {
228                key,
229                value,
230                key_span,
231                value_span,
232            } => match key.as_str() {
233                "src" | "path" => match value {
234                    SetValue::Str(s) => src_path = Some((s.clone(), value_span.clone())),
235                    _ => diagnostics.push(
236                        Diagnostic::simple(&codes::MOS0020, None,
237                            "`#image(...)` expects a string path",
238                        )
239                        .with_span(value_span.clone()),
240                    ),
241                },
242                "alt" => match value {
243                    SetValue::Str(s) => alt = Some(s.clone()),
244                    _ => diagnostics.push(
245                        Diagnostic::simple(&codes::MOS0020, None,
246                            "`#image(alt: ...)` expects a string",
247                        )
248                        .with_span(value_span.clone()),
249                    ),
250                },
251                "width" => {
252                    if let Some(v) =
253                        coerce_positive_length(value, em_pt, "width", value_span, diagnostics)
254                    {
255                        declared_width = Some(v);
256                    }
257                }
258                "height" => {
259                    if let Some(v) =
260                        coerce_positive_length(value, em_pt, "height", value_span, diagnostics)
261                    {
262                        declared_height = Some(v);
263                    }
264                }
265                "label" => match value {
266                    SetValue::Str(s) => label = Some((s.clone(), string_content_span(value_span))),
267                    _ => diagnostics.push(
268                        Diagnostic::simple(&codes::MOS0020, None,
269                            "`#image(label: ...)` expects a string",
270                        )
271                        .with_span(value_span.clone()),
272                    ),
273                },
274                _ => diagnostics.push(
275                    Diagnostic::simple(&codes::MOS0015, None,
276                        format!(
277                            "unknown argument `{key}` for `#image` (valid: src/path, alt, width, height, label)"
278                        ),
279                    )
280                    .with_span(key_span.clone()),
281                ),
282            },
283        }
284    }
285    let Some((path, _path_span)) = src_path else {
286        diagnostics.push(
287            Diagnostic::simple(
288                &codes::MOS0037,
289                None,
290                "`#image(...)` requires a path (e.g. `#image(\"scan.png\")`)",
291            )
292            .with_span(span.clone()),
293        );
294        return None;
295    };
296    // A bare empty / whitespace-only path string is the same user
297    // mistake as omitting the path entirely -- they wrote `#image("")`
298    // and meant to fill in a filename.
299    if path.trim().is_empty() {
300        diagnostics.push(
301            Diagnostic::simple(
302                &codes::MOS0037,
303                None,
304                "`#image(...)` requires a non-empty path (e.g. `#image(\"scan.png\")`)",
305            )
306            .with_span(span.clone()),
307        );
308        return None;
309    }
310    let (resolved, decoded) = match image::load(&path, source_file, span) {
311        Ok(v) => v,
312        Err(diag) => {
313            diagnostics.push(*diag);
314            return None;
315        }
316    };
317
318    let mut attrs: AttrMap = BTreeMap::new();
319    attrs.insert("src".to_owned(), AttrValue::Str(path));
320    attrs.insert(
321        "resolved_path".to_owned(),
322        AttrValue::Str(resolved.to_string_lossy().into_owned()),
323    );
324    if let Some(a) = alt {
325        attrs.insert("alt".to_owned(), AttrValue::Str(a));
326    }
327    if let Some(w) = declared_width {
328        attrs.insert("width".to_owned(), AttrValue::Length(w));
329    }
330    if let Some(h) = declared_height {
331        attrs.insert("height".to_owned(), AttrValue::Length(h));
332    }
333    if let Some((label_text, label_span)) = &label {
334        insert_label_attributes(&mut attrs, label_text, Some(label_span));
335    }
336    attrs.insert(
337        "pixel_width".to_owned(),
338        AttrValue::Int(i64::from(decoded.width)),
339    );
340    attrs.insert(
341        "pixel_height".to_owned(),
342        AttrValue::Int(i64::from(decoded.height)),
343    );
344    attrs.insert(
345        "color_space".to_owned(),
346        AttrValue::Str("DeviceRGB".to_owned()),
347    );
348    attrs.insert("bits_per_component".to_owned(), AttrValue::Int(8));
349    attrs.insert(
350        "pixels".to_owned(),
351        AttrValue::Bytes(Arc::from(decoded.rgb8)),
352    );
353    Some((attrs, label.map(|(text, _)| text)))
354}
355
356fn string_content_span(value_span: &SourceSpan) -> SourceSpan {
357    if value_span.end() > value_span.start().saturating_add(1) {
358        SourceSpan::new(
359            value_span.file.clone(),
360            value_span.start() + 1,
361            value_span.end() - 1,
362        )
363    } else {
364        value_span.clone()
365    }
366}