1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct ImageMetadata {
20 pub id: String,
22 pub asset: String,
24 pub media_type: String,
26 pub role: ImageRole,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub caption: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub alt: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub intrinsic_size: Option<IntrinsicSize>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub hash: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub accessibility: Option<AccessibilityMetadata>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub meaningful_content: Option<MeaningfulContent>,
46}
47
48impl ImageMetadata {
49 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "kebab-case")]
106pub enum ImageRole {
107 Decorative,
109 Informative,
111 Diagram,
113 Photo,
115 Logo,
117 RenderedTableProhibited,
119 RenderedTextProhibited,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct IntrinsicSize {
126 pub width: u32,
128 pub height: u32,
130 pub unit: String,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct AccessibilityMetadata {
138 #[serde(default)]
140 pub allow_decorative_alt: bool,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct MeaningfulContent {
147 #[serde(default)]
149 pub text: bool,
150 #[serde(default)]
152 pub numbers: bool,
153 #[serde(default)]
155 pub table_data: bool,
156 #[serde(default)]
158 pub markdown_refs: Vec<String>,
159 #[serde(default)]
161 pub table_refs: Vec<String>,
162}
163
164pub 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
177pub 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}