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::sync::Arc;
5
6use mos_core::{
7    AttrMap, AttrValue, Diagnostic, Document, NodeId, NodeKind, NodeSpec, SourceSpan, codes,
8};
9use mos_parse::{SetArg, SetValue};
10
11use crate::dependency::ExternalInputs;
12use crate::{
13    image, insert_label_attributes, set::coerce_positive_length, string_content_span, suggest,
14};
15
16// Decoder-derived image attributes, excluded from authored semantic hashes.
17pub(crate) const PIXELS_ATTR: &str = "pixels";
18pub(crate) const PIXEL_WIDTH_ATTR: &str = "pixel_width";
19pub(crate) const PIXEL_HEIGHT_ATTR: &str = "pixel_height";
20pub(crate) const COLOR_SPACE_ATTR: &str = "color_space";
21pub(crate) const BITS_PER_COMPONENT_ATTR: &str = "bits_per_component";
22
23/// Keys accepted by [`collect_one_figure_arg`]'s named-argument match; the
24/// MOS0015 nearest-match candidate set. Keep in sync with the match arms.
25const FIGURE_KEYS: &[&str] = &[
26    "image",
27    "caption",
28    "alt",
29    "width",
30    "height",
31    "label",
32    "numbered",
33    "supplement",
34];
35
36/// Keys accepted by [`collect_one_image_arg`]'s named-argument match; the
37/// MOS0015 nearest-match candidate set. Keep in sync with the match arms.
38const IMAGE_KEYS: &[&str] = &["src", "path", "alt", "width", "height", "label"];
39
40/// Lower `#image(...)` into one [`NodeKind::Image`] node.
41///
42/// Decoded pixels and dimensions are stored in node attributes so later stages
43/// do not re-open the source file.
44pub(crate) fn lower_image_directive(
45    document: &mut Document,
46    root: NodeId,
47    args: &[SetArg],
48    span: &SourceSpan,
49    inputs: &mut ExternalInputs<'_>,
50    em_pt: f64,
51    diagnostics: &mut Vec<Diagnostic>,
52) {
53    let Some((attributes, _label)) = build_image_attributes(args, span, inputs, em_pt, diagnostics)
54    else {
55        return;
56    };
57    document.alloc_child(
58        root,
59        NodeSpec::new(NodeKind::Image, span.clone()).with_attributes(attributes),
60    );
61}
62
63/// Lower `#figure(image: ..., caption: ...)` into a figure node.
64///
65/// The figure gets an image child and a caption paragraph child.
66pub(crate) fn lower_figure_directive(
67    document: &mut Document,
68    root: NodeId,
69    args: &[SetArg],
70    span: &SourceSpan,
71    inputs: &mut ExternalInputs<'_>,
72    em_pt: f64,
73    diagnostics: &mut Vec<Diagnostic>,
74) {
75    let figure_args = collect_figure_args(args, diagnostics);
76
77    // Build the image attributes before allocating the Figure node.
78    // Failed image load should not leave a phantom caption-only figure.
79    let Some((image_attrs, _label)) =
80        build_image_attributes(&figure_args.image_args, span, inputs, em_pt, diagnostics)
81    else {
82        return;
83    };
84
85    let mut figure_attrs: AttrMap = BTreeMap::new();
86    if let Some((label, label_span)) = figure_args.label {
87        insert_label_attributes(&mut figure_attrs, &label, Some(&label_span));
88    }
89    // Only the non-default opt-out is recorded; absence means "numbered".
90    if figure_args.numbered == Some(false) {
91        figure_attrs.insert("numbered".to_owned(), AttrValue::Bool(false));
92    }
93    if let Some(supp) = figure_args.supplement {
94        figure_attrs.insert("supplement".to_owned(), AttrValue::Str(supp));
95    }
96    let figure_id = document.alloc_child(
97        root,
98        NodeSpec::new(NodeKind::Figure, span.clone()).with_attributes(figure_attrs),
99    );
100    document.alloc_child(
101        figure_id,
102        NodeSpec::new(NodeKind::Image, span.clone()).with_attributes(image_attrs),
103    );
104    if let Some(caption) = figure_args.caption {
105        append_caption(document, figure_id, caption);
106    }
107}
108
109struct FigureDirectiveArgs {
110    image_args: Vec<SetArg>,
111    caption: Option<(String, SourceSpan)>,
112    label: Option<(String, SourceSpan)>,
113    numbered: Option<bool>,
114    supplement: Option<String>,
115}
116
117fn collect_figure_args(args: &[SetArg], diagnostics: &mut Vec<Diagnostic>) -> FigureDirectiveArgs {
118    let mut collected = FigureDirectiveArgs {
119        image_args: Vec::new(),
120        caption: None,
121        label: None,
122        numbered: None,
123        supplement: None,
124    };
125    for arg in args {
126        collect_one_figure_arg(arg, &mut collected, diagnostics);
127    }
128    collected
129}
130
131fn collect_one_figure_arg(
132    arg: &SetArg,
133    collected: &mut FigureDirectiveArgs,
134    diagnostics: &mut Vec<Diagnostic>,
135) {
136    match arg {
137        SetArg::Positional { .. } => collected.image_args.push(arg.clone()),
138        SetArg::Named {
139            key,
140            value,
141            key_span,
142            value_span,
143        } => match key.as_str() {
144            "image" => collected.image_args.push(SetArg::Positional {
145                value: value.clone(),
146                value_span: value_span.clone(),
147            }),
148            "width" | "height" | "alt" => collected.image_args.push(arg.clone()),
149            "caption" => collect_string_arg(
150                value,
151                value_span,
152                "`#figure(caption: ...)` expects a string",
153                &mut collected.caption,
154                diagnostics,
155            ),
156            "label" => match value {
157                SetValue::Str(s) => {
158                    collected.label = Some((s.clone(), string_content_span(value_span)));
159                }
160                _ => type_error(
161                    value_span,
162                    "`#figure(label: ...)` expects a string",
163                    diagnostics,
164                ),
165            },
166            "numbered" => collect_numbered(value, value_span, collected, diagnostics),
167            "supplement" => collect_supplement(value, value_span, collected, diagnostics),
168            _ => diagnostics.push(suggest::unknown_key_diagnostic(
169                format!(
170                    "unknown argument `{key}` for `#figure` (valid: {})",
171                    FIGURE_KEYS.join(", ")
172                ),
173                key,
174                key_span,
175                FIGURE_KEYS,
176            )),
177        },
178    }
179}
180
181fn collect_string_arg(
182    value: &SetValue,
183    value_span: &SourceSpan,
184    message: &'static str,
185    target: &mut Option<(String, SourceSpan)>,
186    diagnostics: &mut Vec<Diagnostic>,
187) {
188    match value {
189        SetValue::Str(s) => *target = Some((s.clone(), value_span.clone())),
190        _ => type_error(value_span, message, diagnostics),
191    }
192}
193
194fn collect_numbered(
195    value: &SetValue,
196    value_span: &SourceSpan,
197    collected: &mut FigureDirectiveArgs,
198    diagnostics: &mut Vec<Diagnostic>,
199) {
200    match value {
201        SetValue::Ident(word) if word == "true" => collected.numbered = Some(true),
202        SetValue::Ident(word) if word == "false" => collected.numbered = Some(false),
203        _ => type_error(
204            value_span,
205            "`#figure(numbered: ...)` expects `true` or `false`",
206            diagnostics,
207        ),
208    }
209}
210
211fn collect_supplement(
212    value: &SetValue,
213    value_span: &SourceSpan,
214    collected: &mut FigureDirectiveArgs,
215    diagnostics: &mut Vec<Diagnostic>,
216) {
217    match value {
218        SetValue::Str(s) => collected.supplement = Some(s.clone()),
219        SetValue::Ident(word) if word == "none" => collected.supplement = Some(String::new()),
220        _ => type_error(
221            value_span,
222            "`#figure(supplement: ...)` expects a string or `none`",
223            diagnostics,
224        ),
225    }
226}
227
228fn append_caption(document: &mut Document, figure_id: NodeId, caption: (String, SourceSpan)) {
229    let (text, caption_span) = caption;
230    let caption_id = document.alloc_child(
231        figure_id,
232        NodeSpec::new(NodeKind::Paragraph, caption_span.clone()).with_attributes({
233            let mut attrs = AttrMap::new();
234            attrs.insert("role".to_owned(), AttrValue::Str("caption".to_owned()));
235            attrs
236        }),
237    );
238    let mut child_attrs = AttrMap::new();
239    child_attrs.insert("text".to_owned(), AttrValue::Str(text));
240    document.alloc_child(
241        caption_id,
242        NodeSpec::new(NodeKind::Text, caption_span).with_attributes(child_attrs),
243    );
244}
245
246/// Walk a directive's argument list and produce the attribute map for
247/// an [`NodeKind::Image`] node, including the decoded pixel buffer.
248/// Returns `None` (and emits diagnostics) if the path argument is
249/// missing or the bytes can't be decoded -- the caller drops the node
250/// in that case rather than emitting a half-built image.
251fn build_image_attributes(
252    args: &[SetArg],
253    span: &SourceSpan,
254    inputs: &mut ExternalInputs<'_>,
255    em_pt: f64,
256    diagnostics: &mut Vec<Diagnostic>,
257) -> Option<(AttrMap, Option<String>)> {
258    let image_args = collect_image_args(args, em_pt, diagnostics);
259    let Some((path, path_span)) = image_args.src_path else {
260        diagnostics.push(
261            Diagnostic::simple(
262                &codes::MOS0037,
263                None,
264                "`#image(...)` requires a path (e.g. `#image(\"scan.png\")`)",
265            )
266            .with_span(span.clone()),
267        );
268        return None;
269    };
270    // A bare empty / whitespace-only path string is the same user
271    // mistake as omitting the path entirely -- they wrote `#image("")`
272    // and meant to fill in a filename.
273    if path.trim().is_empty() {
274        diagnostics.push(
275            Diagnostic::simple(
276                &codes::MOS0037,
277                None,
278                "`#image(...)` requires a non-empty path (e.g. `#image(\"scan.png\")`)",
279            )
280            .with_span(span.clone()),
281        );
282        return None;
283    }
284    let (resolved, decoded) = match image::load(&path, inputs, span, &path_span) {
285        Ok(v) => v,
286        Err(diag) => {
287            diagnostics.push(*diag);
288            return None;
289        }
290    };
291
292    let mut attrs: AttrMap = BTreeMap::new();
293    attrs.insert("src".to_owned(), AttrValue::Str(path));
294    attrs.insert(
295        "resolved_path".to_owned(),
296        AttrValue::Str(resolved.to_string_lossy().into_owned()),
297    );
298    if let Some(a) = image_args.alt {
299        attrs.insert("alt".to_owned(), AttrValue::Str(a));
300    }
301    if let Some(w) = image_args.declared_width {
302        attrs.insert("width".to_owned(), AttrValue::Length(w));
303    }
304    if let Some(h) = image_args.declared_height {
305        attrs.insert("height".to_owned(), AttrValue::Length(h));
306    }
307    if let Some((label_text, label_span)) = &image_args.label {
308        insert_label_attributes(&mut attrs, label_text, Some(label_span));
309    }
310    attrs.insert(
311        PIXEL_WIDTH_ATTR.to_owned(),
312        AttrValue::Int(i64::from(decoded.width)),
313    );
314    attrs.insert(
315        PIXEL_HEIGHT_ATTR.to_owned(),
316        AttrValue::Int(i64::from(decoded.height)),
317    );
318    attrs.insert(
319        COLOR_SPACE_ATTR.to_owned(),
320        AttrValue::Str("DeviceRGB".to_owned()),
321    );
322    attrs.insert(BITS_PER_COMPONENT_ATTR.to_owned(), AttrValue::Int(8));
323    attrs.insert(
324        PIXELS_ATTR.to_owned(),
325        AttrValue::Bytes(Arc::from(decoded.rgb8)),
326    );
327    Some((attrs, image_args.label.map(|(text, _)| text)))
328}
329
330struct ImageDirectiveArgs {
331    src_path: Option<(String, SourceSpan)>,
332    alt: Option<String>,
333    declared_width: Option<f64>,
334    declared_height: Option<f64>,
335    label: Option<(String, SourceSpan)>,
336}
337
338fn collect_image_args(
339    args: &[SetArg],
340    em_pt: f64,
341    diagnostics: &mut Vec<Diagnostic>,
342) -> ImageDirectiveArgs {
343    let mut collected = ImageDirectiveArgs {
344        src_path: None,
345        alt: None,
346        declared_width: None,
347        declared_height: None,
348        label: None,
349    };
350    for arg in args {
351        collect_one_image_arg(arg, em_pt, &mut collected, diagnostics);
352    }
353    collected
354}
355
356fn collect_one_image_arg(
357    arg: &SetArg,
358    em_pt: f64,
359    collected: &mut ImageDirectiveArgs,
360    diagnostics: &mut Vec<Diagnostic>,
361) {
362    match arg {
363        SetArg::Positional { value, value_span } => collect_image_path(
364            value,
365            value_span,
366            &mut collected.src_path,
367            diagnostics,
368        ),
369        SetArg::Named {
370            key,
371            value,
372            key_span,
373            value_span,
374        } => match key.as_str() {
375            "src" | "path" => collect_image_path(
376                value,
377                value_span,
378                &mut collected.src_path,
379                diagnostics,
380            ),
381            "alt" => match value {
382                SetValue::Str(s) => collected.alt = Some(s.clone()),
383                _ => type_error(value_span, "`#image(alt: ...)` expects a string", diagnostics),
384            },
385            "width" => {
386                if let Some(width) = coerce_positive_length(
387                    value,
388                    em_pt,
389                    "width",
390                    value_span,
391                    diagnostics,
392                ) {
393                    collected.declared_width = Some(width);
394                }
395            }
396            "height" => {
397                if let Some(height) = coerce_positive_length(
398                    value,
399                    em_pt,
400                    "height",
401                    value_span,
402                    diagnostics,
403                ) {
404                    collected.declared_height = Some(height);
405                }
406            }
407            "label" => match value {
408                SetValue::Str(s) => {
409                    collected.label = Some((s.clone(), string_content_span(value_span)));
410                }
411                _ => type_error(value_span, "`#image(label: ...)` expects a string", diagnostics),
412            },
413            // `src/path` groups the alias pair the way `#bibliography`'s
414            // message does, so the display list stays hand-written while
415            // IMAGE_KEYS feeds the near-miss candidates.
416            _ => diagnostics.push(suggest::unknown_key_diagnostic(
417                format!(
418                    "unknown argument `{key}` for `#image` (valid: src/path, alt, width, height, label)"
419                ),
420                key,
421                key_span,
422                IMAGE_KEYS,
423            )),
424        },
425    }
426}
427
428fn collect_image_path(
429    value: &SetValue,
430    value_span: &SourceSpan,
431    target: &mut Option<(String, SourceSpan)>,
432    diagnostics: &mut Vec<Diagnostic>,
433) {
434    match value {
435        SetValue::Str(s) => *target = Some((s.clone(), value_span.clone())),
436        _ => type_error(
437            value_span,
438            "`#image(...)` expects a string path",
439            diagnostics,
440        ),
441    }
442}
443
444fn type_error(value_span: &SourceSpan, message: &'static str, diagnostics: &mut Vec<Diagnostic>) {
445    diagnostics
446        .push(Diagnostic::simple(&codes::MOS0020, None, message).with_span(value_span.clone()));
447}