ttml_subtitle/
validation.rs1extern 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum ImscVersion {
32 V1_0,
34 V1_1,
36}
37
38impl ImscVersion {
39 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52#[non_exhaustive]
53pub enum Profile {
54 Text,
56 Image,
58}
59
60impl Profile {
61 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#[derive(Debug, Clone, PartialEq)]
74#[non_exhaustive]
75pub struct ValidationError {
76 pub constraint: String,
78 pub detail: String,
80}
81
82#[derive(Debug, Clone, PartialEq)]
84#[non_exhaustive]
85pub struct ValidationResult {
86 pub valid: bool,
88 pub errors: Vec<ValidationError>,
90}
91
92#[derive(Debug, Clone)]
94pub struct Validator {
95 profile: Profile,
96 version: ImscVersion,
97 errors: Vec<ValidationError>,
98}
99
100impl Validator {
101 pub fn new(profile: Profile, version: ImscVersion) -> Self {
103 Self {
104 profile,
105 version,
106 errors: Vec::new(),
107 }
108 }
109
110 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 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 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 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 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 if self.version == ImscVersion::V1_1 {
186 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 if self.profile == Profile::Image {
198 }
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 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 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 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 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 for region in &layout.regions {
324 self.validate_region(region);
325 }
326 }
327
328 fn validate_region(&mut self, _region: &RegionElement) {
329 }
333
334 fn validate_body(&mut self, body: &BodyElement) {
335 if self.profile == Profile::Image {
337 for div in &body.divs {
338 self.validate_div_image_constraints(div);
339 }
340 }
341
342 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 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 }
366
367 fn validate_div_text_constraints(&mut self, div: &DivElement) {
368 for p in &div.paragraphs {
369 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}