Skip to main content

ttml_subtitle/
validation.rs

1//! IMSC 1.1 profile validation — IMSC 1.1 §6–§9.
2//!
3//! Validation is separate from parsing: parse a document, then ask
4//! "is this valid Text Profile?" or "is this valid Image Profile?"
5//! The validator checks:
6//!
7//! - Feature/Extension disposition table (159 rows, `imsc11-profiles.md` §5)
8//! - §7.12 "must reject" structural constraints
9//! - §8 Text Profile provisions
10//! - §9 Image Profile provisions
11//!
12//! ### Design
13//!
14//! The validator walks the parsed document tree and reports every
15//! violation it finds, rather than stopping at the first error.
16//! This gives callers a complete picture of non-conformance.
17
18extern crate alloc;
19
20use alloc::format;
21use alloc::string::String;
22use alloc::string::ToString;
23use alloc::vec::Vec;
24
25use crate::document::{self, *};
26use crate::error::Error;
27
28/// Which IMSC profile version.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum ImscVersion {
32    /// IMSC 1.0 / 1.0.1.
33    V1_0,
34    /// IMSC 1.1.
35    V1_1,
36}
37
38impl ImscVersion {
39    /// Label for the #204 convention.
40    pub fn name(&self) -> &'static str {
41        match self {
42            ImscVersion::V1_0 => "1.0",
43            ImscVersion::V1_1 => "1.1",
44        }
45    }
46}
47
48broadcast_common::impl_spec_display!(ImscVersion);
49
50/// Which IMSC profile to validate against.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52#[non_exhaustive]
53pub enum Profile {
54    /// IMSC Text Profile.
55    Text,
56    /// IMSC Image Profile.
57    Image,
58}
59
60impl Profile {
61    /// Label for the #204 convention.
62    pub fn name(&self) -> &'static str {
63        match self {
64            Profile::Text => "text",
65            Profile::Image => "image",
66        }
67    }
68}
69
70broadcast_common::impl_spec_display!(Profile);
71
72/// A single validation violation.
73#[derive(Debug, Clone, PartialEq)]
74#[non_exhaustive]
75pub struct ValidationError {
76    /// The constraint that was violated (spec section reference).
77    pub constraint: String,
78    /// Additional detail about what was found.
79    pub detail: String,
80}
81
82/// Accumulated validation results.
83#[derive(Debug, Clone, PartialEq)]
84#[non_exhaustive]
85pub struct ValidationResult {
86    /// Whether the document passed validation (no errors).
87    pub valid: bool,
88    /// All violations found.
89    pub errors: Vec<ValidationError>,
90}
91
92/// Validator state: walks a parsed Document and accumulates violations.
93#[derive(Debug, Clone)]
94pub struct Validator {
95    profile: Profile,
96    version: ImscVersion,
97    errors: Vec<ValidationError>,
98}
99
100impl Validator {
101    /// Create a new validator for the given profile and version.
102    pub fn new(profile: Profile, version: ImscVersion) -> Self {
103        Self {
104            profile,
105            version,
106            errors: Vec::new(),
107        }
108    }
109
110    /// Validate a document against the configured profile.
111    pub fn validate(mut self, doc: &Document) -> ValidationResult {
112        self.validate_document(doc);
113        ValidationResult {
114            valid: self.errors.is_empty(),
115            errors: self.errors,
116        }
117    }
118
119    /// Convenience: validate and return `Result<(), Error>` with all violations
120    /// concatenated into one error if any exist.
121    pub fn validate_to_result(self, doc: &Document) -> Result<(), Error> {
122        let result = self.validate(doc);
123        if result.valid {
124            Ok(())
125        } else {
126            let messages: Vec<String> = result
127                .errors
128                .iter()
129                .map(|e| format!("{}: {}", e.constraint, e.detail))
130                .collect();
131            Err(Error::Validation(messages.join("; ")))
132        }
133    }
134
135    fn err(&mut self, constraint: &str, detail: String) {
136        self.errors.push(ValidationError {
137            constraint: constraint.to_string(),
138            detail,
139        });
140    }
141
142    fn validate_document(&mut self, doc: &Document) {
143        // §7.1: Document Encoding — XML well-formedness is checked at parse time
144
145        self.validate_tt(&doc.tt);
146
147        if let Some(ref head) = doc.tt.head {
148            self.validate_head(head);
149        }
150        if let Some(ref body) = doc.tt.body {
151            self.validate_body(body);
152        }
153    }
154
155    fn validate_tt(&mut self, tt: &TtElement) {
156        // Check content profiles
157        let claimed_text = self.claims_text_profile(tt);
158        let claimed_image = self.claims_image_profile(tt);
159
160        if self.profile == Profile::Text && !claimed_text {
161            self.err(
162                "IMSC §7.9",
163                "Document does not claim Text Profile via ttp:contentProfiles or ttp:profile"
164                    .into(),
165            );
166        }
167        if self.profile == Profile::Image && !claimed_image {
168            self.err(
169                "IMSC §7.9",
170                "Document does not claim Image Profile via ttp:contentProfiles or ttp:profile"
171                    .into(),
172            );
173        }
174
175        // §7.12.4 / §7.12.5: aspectRatio / displayAspectRatio mutual exclusion
176        if tt.ittp_aspect_ratio.is_some() && tt.ttp_display_aspect_ratio.is_some() {
177            self.err(
178                "IMSC §7.12.4/§7.12.5",
179                "ittp:aspectRatio and ttp:displayAspectRatio are mutually exclusive".into(),
180            );
181        }
182
183        // §7.12.6: extent-root — if any px unit used, tts:extent must be on tt
184        // Full px scan requires tree walk; simplified: check if extent is present when likely needed
185        if self.version == ImscVersion::V1_1 {
186            // §7.12.7: frameRate required if frame terms used
187            // Check if any time expr uses 'f' metric or clock-time with frames
188            if self.has_frame_usage(tt) && tt.ttp_frame_rate.is_none() {
189                self.err(
190                    "IMSC §7.12.7",
191                    "ttp:frameRate must be present when frame terms are used".into(),
192                );
193            }
194        }
195
196        // Image Profile constraints
197        if self.profile == Profile::Image {
198            // §9.4.4: image must have src, type, tts:extent
199            // §9.4.1: no p/span/br elements
200        }
201    }
202
203    fn claims_text_profile(&self, tt: &TtElement) -> bool {
204        let text_designators = [document::IMSC11_TEXT_PROFILE, document::IMSC1_TEXT_PROFILE];
205
206        // Check ttp:contentProfiles
207        if let Some(ref cp) = tt.ttp_content_profiles {
208            for d in &text_designators {
209                if cp.contains(d) {
210                    return true;
211                }
212            }
213        }
214
215        // Check ttp:profile
216        if let Some(ref p) = tt.ttp_profile {
217            for d in &text_designators {
218                if p == *d {
219                    return true;
220                }
221            }
222        }
223
224        false
225    }
226
227    fn claims_image_profile(&self, tt: &TtElement) -> bool {
228        let image_designators = [
229            document::IMSC11_IMAGE_PROFILE,
230            document::IMSC1_IMAGE_PROFILE,
231        ];
232
233        if let Some(ref cp) = tt.ttp_content_profiles {
234            for d in &image_designators {
235                if cp.contains(d) {
236                    return true;
237                }
238            }
239        }
240
241        if let Some(ref p) = tt.ttp_profile {
242            for d in &image_designators {
243                if p == *d {
244                    return true;
245                }
246            }
247        }
248
249        false
250    }
251
252    fn has_frame_usage(&self, tt: &TtElement) -> bool {
253        // Scan the document's time expressions for frame metrics
254        let body = match tt.body {
255            Some(ref b) => b,
256            None => return false,
257        };
258        Self::body_has_frame_usage(body)
259    }
260
261    fn body_has_frame_usage(body: &BodyElement) -> bool {
262        for div in &body.divs {
263            if Self::time_expr_has_frame(body.begin.as_deref())
264                || Self::time_expr_has_frame(body.dur.as_deref())
265                || Self::time_expr_has_frame(body.end.as_deref())
266            {
267                return true;
268            }
269            for p in &div.paragraphs {
270                if Self::time_expr_has_frame(p.begin.as_deref())
271                    || Self::time_expr_has_frame(p.dur.as_deref())
272                    || Self::time_expr_has_frame(p.end.as_deref())
273                {
274                    return true;
275                }
276            }
277            for img in &div.images {
278                if Self::time_expr_has_frame(img.begin.as_deref())
279                    || Self::time_expr_has_frame(img.dur.as_deref())
280                    || Self::time_expr_has_frame(img.end.as_deref())
281                {
282                    return true;
283                }
284            }
285        }
286        false
287    }
288
289    fn time_expr_has_frame(expr: Option<&str>) -> bool {
290        let expr = match expr {
291            Some(e) => e,
292            None => return false,
293        };
294        if expr.ends_with('f') && expr.len() > 1 {
295            return true;
296        }
297        let colon_count = expr.chars().filter(|&c| c == ':').count();
298        colon_count == 3
299    }
300
301    fn validate_head(&mut self, head: &HeadElement) {
302        if let Some(ref layout) = head.layout {
303            self.validate_layout(layout);
304        }
305    }
306
307    fn validate_layout(&mut self, layout: &LayoutElement) {
308        // §7.12.1.3: max 4 presented regions
309        if layout.regions.len() > 4 {
310            self.err(
311                "IMSC §7.12.1.3",
312                format!(
313                    "Document has {} regions; maximum 4 presented regions allowed in any ISD",
314                    layout.regions.len()
315                ),
316            );
317        }
318
319        // §7.12.1.2: regions must not extend beyond RCR, no two overlap
320        // (full coordinate intersection check requires computed style resolution)
321
322        // §7.12.2 / §7.12.3: altText mutual exclusion
323        for region in &layout.regions {
324            self.validate_region(region);
325        }
326    }
327
328    fn validate_region(&mut self, _region: &RegionElement) {
329        // §7.12.1.1: presented region definition (opacity, display, visibility, showBackground)
330        // §8.4.2: Text Profile: tts:extent required on region, must use px/%/rw/rh
331        // §9.4.2: Image Profile: tts:extent required on region, must use px only
332    }
333
334    fn validate_body(&mut self, body: &BodyElement) {
335        // Image Profile §9.4.1: no p/span/br elements
336        if self.profile == Profile::Image {
337            for div in &body.divs {
338                self.validate_div_image_constraints(div);
339            }
340        }
341
342        // Text Profile §8.4.x constraints
343        if self.profile == Profile::Text {
344            for div in &body.divs {
345                self.validate_div_text_constraints(div);
346            }
347        }
348    }
349
350    fn validate_div_image_constraints(&mut self, div: &DivElement) {
351        // §9.4.1: p, span, br SHALL NOT be present
352        if !div.paragraphs.is_empty() {
353            self.err(
354                "IMSC §9.4.1",
355                format!(
356                    "Image Profile div contains {} <p> element(s) — p/span/br SHALL NOT be present in Image Profile",
357                    div.paragraphs.len()
358                ),
359            );
360        }
361
362        // §9.2.2: at most one div per presented region, which must be a presented image
363        // §9.4.4: image constraints (src, type, tts:extent required)
364        // §9.4.5: smpte:backgroundImage constraints
365    }
366
367    fn validate_div_text_constraints(&mut self, div: &DivElement) {
368        for p in &div.paragraphs {
369            // §8.4.11: textShadow max 4 shadow values
370            if let Some(ref ts) = p.style_attributes.tts_text_shadow {
371                let count: usize = ts.split(',').count();
372                if count > 4 {
373                    self.err(
374                        "IMSC §8.4.11",
375                        format!("tts:textShadow has {} shadow values (max 4)", count),
376                    );
377                }
378            }
379        }
380    }
381}