Skip to main content

mcd_core/
images.rs

1//! Image metadata parsing and validation.
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    Manifest,
8    assets::validate_image_asset,
9    directives::ImagePlacement,
10    document::{DocumentBlock, McdDocument, SourceSpan},
11    errors::{Diagnostic, McdError, Result},
12    manifest::{ConformanceClaim, ImageManifestEntry},
13    package::McdPackage,
14};
15
16/// Parsed image metadata object.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct ImageMetadata {
20    /// Stable image id.
21    pub id: String,
22    /// Package asset path.
23    pub asset: String,
24    /// Declared asset media type.
25    pub media_type: String,
26    /// Image role.
27    pub role: ImageRole,
28    /// Optional caption.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub caption: Option<String>,
31    /// Optional alt text.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub alt: Option<String>,
34    /// Optional intrinsic size.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub intrinsic_size: Option<IntrinsicSize>,
37    /// Optional asset hash.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub hash: Option<String>,
40    /// Optional accessibility override.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub accessibility: Option<AccessibilityMetadata>,
43    /// Optional declaration for meaningful visual-only content.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub meaningful_content: Option<MeaningfulContent>,
46}
47
48impl ImageMetadata {
49    /// Parse image metadata from a package entry.
50    pub fn from_package(package: &McdPackage, path: &str) -> Result<Self> {
51        let bytes = package.read(path).map_err(|_| {
52            McdError::from_diagnostic(
53                Diagnostic::error(
54                    "image.metadata.missing",
55                    format!("Declared image metadata file '{path}' is missing."),
56                )
57                .with_source(path.to_owned()),
58            )
59        })?;
60        serde_json::from_slice::<Self>(bytes).map_err(McdError::from)
61    }
62
63    /// Validate image metadata and referenced asset.
64    pub fn validate(
65        &self,
66        expected_id: &str,
67        manifest: &Manifest,
68        package: &McdPackage,
69        source: &str,
70    ) -> Result<()> {
71        if self.id != expected_id {
72            return Err(image_error(
73                "image.id.mismatch",
74                format!(
75                    "Image metadata id '{}' does not match manifest image id '{}'.",
76                    self.id, expected_id
77                ),
78                source,
79            ));
80        }
81        if self.id.trim().is_empty() {
82            return Err(image_error(
83                "image.id.empty",
84                "Image metadata id cannot be empty.",
85                source,
86            ));
87        }
88        validate_intrinsic_size(self.intrinsic_size.as_ref(), source)?;
89        validate_role_text(self, manifest, source)?;
90        validate_meaningful_content(self.meaningful_content.as_ref(), source)?;
91        validate_image_asset(
92            package,
93            &self.asset,
94            &self.media_type,
95            self.hash.as_deref(),
96            &manifest.assets,
97            source,
98        )?;
99        Ok(())
100    }
101}
102
103/// Supported image roles.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "kebab-case")]
106pub enum ImageRole {
107    /// Decorative, non-semantic image.
108    Decorative,
109    /// Informative image.
110    Informative,
111    /// Diagram.
112    Diagram,
113    /// Photo.
114    Photo,
115    /// Logo.
116    Logo,
117    /// Prohibited rendered table role.
118    RenderedTableProhibited,
119    /// Prohibited rendered text role.
120    RenderedTextProhibited,
121}
122
123/// Intrinsic image size metadata.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct IntrinsicSize {
126    /// Width.
127    pub width: u32,
128    /// Height.
129    pub height: u32,
130    /// Unit, usually `px`.
131    pub unit: String,
132}
133
134/// Accessibility metadata.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct AccessibilityMetadata {
138    /// Permit alt text on decorative images when explicitly justified by metadata.
139    #[serde(default)]
140    pub allow_decorative_alt: bool,
141}
142
143/// Declaration that an image contains meaningful information and where it is canonicalized.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct MeaningfulContent {
147    /// Image contains meaningful text.
148    #[serde(default)]
149    pub text: bool,
150    /// Image contains meaningful numbers.
151    #[serde(default)]
152    pub numbers: bool,
153    /// Image contains table-like data.
154    #[serde(default)]
155    pub table_data: bool,
156    /// Markdown block or placement refs that canonicalize the content.
157    #[serde(default)]
158    pub markdown_refs: Vec<String>,
159    /// Table ids that canonicalize the content.
160    #[serde(default)]
161    pub table_refs: Vec<String>,
162}
163
164/// Load and validate all manifest-declared images.
165pub fn load_manifest_images(
166    package: &McdPackage,
167    manifest: &Manifest,
168) -> Result<IndexMap<String, ImageMetadata>> {
169    let mut images = IndexMap::new();
170    for entry in &manifest.images {
171        let metadata = load_manifest_image(package, manifest, entry)?;
172        images.insert(entry.id.clone(), metadata);
173    }
174    Ok(images)
175}
176
177/// Resolve Markdown image anchors to loaded image metadata.
178pub fn validate_image_anchors(
179    document: &McdDocument,
180    images: &IndexMap<String, ImageMetadata>,
181) -> Result<()> {
182    for block in &document.blocks {
183        let DocumentBlock::ImageRef {
184            placement, source, ..
185        } = block
186        else {
187            continue;
188        };
189        resolve_image_placement(placement, images).ok_or_else(|| {
190            image_anchor_error(
191                "image.anchor.unresolved",
192                "Image anchor does not resolve to a declared image metadata object.",
193                document,
194                *source,
195            )
196        })?;
197    }
198    Ok(())
199}
200
201fn load_manifest_image(
202    package: &McdPackage,
203    manifest: &Manifest,
204    entry: &ImageManifestEntry,
205) -> Result<ImageMetadata> {
206    let metadata = ImageMetadata::from_package(package, &entry.metadata)?;
207    metadata.validate(&entry.id, manifest, package, &entry.metadata)?;
208    Ok(metadata)
209}
210
211fn resolve_image_placement<'a>(
212    placement: &ImagePlacement,
213    images: &'a IndexMap<String, ImageMetadata>,
214) -> Option<&'a ImageMetadata> {
215    if let Some(image_id) = &placement.image {
216        return images.get(image_id);
217    }
218    let asset = placement.asset.as_deref()?;
219    images
220        .get(asset)
221        .or_else(|| images.values().find(|image| image.asset == asset))
222        .or_else(|| {
223            images
224                .values()
225                .find(|image| image.asset.strip_prefix("assets/") == Some(asset))
226        })
227}
228
229fn validate_role_text(metadata: &ImageMetadata, manifest: &Manifest, source: &str) -> Result<()> {
230    if manifest.conformance.contains(&ConformanceClaim::Strict)
231        && matches!(
232            metadata.role,
233            ImageRole::RenderedTableProhibited | ImageRole::RenderedTextProhibited
234        )
235    {
236        return Err(image_error(
237            "image.role.strict.invalid",
238            "Rendered table/text prohibited image roles are invalid in MCD-Strict packages.",
239            source,
240        ));
241    }
242
243    let alt = metadata.alt.as_deref().unwrap_or_default();
244    match metadata.role {
245        ImageRole::Decorative => {
246            if !alt.is_empty()
247                && !metadata
248                    .accessibility
249                    .as_ref()
250                    .is_some_and(|accessibility| accessibility.allow_decorative_alt)
251            {
252                return Err(image_error(
253                    "image.alt.decorative.nonempty",
254                    "Decorative images must have empty alt text unless accessibility metadata explicitly permits it.",
255                    source,
256                ));
257            }
258        }
259        ImageRole::Informative | ImageRole::Diagram | ImageRole::Photo | ImageRole::Logo => {
260            if alt.trim().is_empty() {
261                return Err(image_error(
262                    "image.alt.missing",
263                    format!("{:?} images require non-empty alt text.", metadata.role),
264                    source,
265                ));
266            }
267        }
268        ImageRole::RenderedTableProhibited | ImageRole::RenderedTextProhibited => {}
269    }
270
271    if matches!(metadata.role, ImageRole::Informative | ImageRole::Diagram)
272        && metadata
273            .caption
274            .as_deref()
275            .is_none_or(|caption| caption.trim().is_empty())
276    {
277        return Err(image_error(
278            "image.caption.missing",
279            "Informative and diagram images require a caption.",
280            source,
281        ));
282    }
283
284    Ok(())
285}
286
287fn validate_intrinsic_size(size: Option<&IntrinsicSize>, source: &str) -> Result<()> {
288    let Some(size) = size else {
289        return Ok(());
290    };
291    if size.width == 0 || size.height == 0 {
292        return Err(image_error(
293            "image.intrinsic_size.invalid",
294            "Intrinsic image dimensions must be greater than zero.",
295            source,
296        ));
297    }
298    if size.unit != "px" {
299        return Err(image_error(
300            "image.intrinsic_size.unit.unsupported",
301            "Only px intrinsic image size units are supported in this alpha.",
302            source,
303        ));
304    }
305    Ok(())
306}
307
308fn validate_meaningful_content(content: Option<&MeaningfulContent>, source: &str) -> Result<()> {
309    let Some(content) = content else {
310        return Ok(());
311    };
312    let declares_meaningful_content = content.text || content.numbers || content.table_data;
313    if declares_meaningful_content
314        && content.markdown_refs.is_empty()
315        && content.table_refs.is_empty()
316    {
317        return Err(image_error(
318            "image.meaningful_content.unlinked",
319            "Images declaring meaningful text, numbers, or table-like data must link to canonical Markdown or table references.",
320            source,
321        ));
322    }
323    Ok(())
324}
325
326fn image_anchor_error(
327    code: impl Into<String>,
328    message: impl Into<String>,
329    document: &McdDocument,
330    source: Option<SourceSpan>,
331) -> McdError {
332    let source = source
333        .map(|span| format!("{}:{span}", document.source_path))
334        .unwrap_or_else(|| document.source_path.clone());
335    image_error(code, message, &source)
336}
337
338fn image_error(code: impl Into<String>, message: impl Into<String>, source: &str) -> McdError {
339    McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source.to_owned()))
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn decorative_image_rejects_alt_text_without_override() {
348        let metadata = ImageMetadata {
349            id: "cover".to_owned(),
350            asset: "assets/cover.svg".to_owned(),
351            media_type: "image/svg+xml".to_owned(),
352            role: ImageRole::Decorative,
353            caption: None,
354            alt: Some("Cover".to_owned()),
355            intrinsic_size: None,
356            hash: None,
357            accessibility: None,
358            meaningful_content: None,
359        };
360
361        let manifest = Manifest {
362            format: "MCD".to_owned(),
363            version: "0.1".to_owned(),
364            profile: crate::manifest::McdProfile::Core,
365            conformance: Vec::new(),
366            entrypoint: "content/main.md".to_owned(),
367            title: None,
368            encoding: None,
369            tables: Vec::new(),
370            images: Vec::new(),
371            annotations: Vec::new(),
372            assets: Vec::new(),
373            external_data: Vec::new(),
374            provenance: None,
375            layout: None,
376        };
377
378        let err = validate_role_text(&metadata, &manifest, "images/cover.image.json")
379            .expect_err("invalid");
380        assert_eq!(
381            err.diagnostic().map(|diagnostic| diagnostic.code.as_str()),
382            Some("image.alt.decorative.nonempty")
383        );
384    }
385
386    #[test]
387    fn meaningful_content_requires_canonical_refs() {
388        let err = validate_meaningful_content(
389            Some(&MeaningfulContent {
390                text: false,
391                numbers: true,
392                table_data: true,
393                markdown_refs: Vec::new(),
394                table_refs: Vec::new(),
395            }),
396            "images/table.image.json",
397        )
398        .expect_err("invalid");
399
400        assert_eq!(
401            err.diagnostic().map(|diagnostic| diagnostic.code.as_str()),
402            Some("image.meaningful_content.unlinked")
403        );
404    }
405}