1use serde::{Deserialize, Serialize};
14
15use crate::{
16 DataValue, ElementKind, ErrorCode, ExportContext, ExportFormat, ExportRequest, FileMakerError,
17 HtmlMode, OperationControl, PdfMode, ResolvedScene, ResourceLimits, Result, TemplateIr,
18};
19
20#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ValidationSeverity {
24 Warning,
26 Error,
28}
29
30#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ValidationCode {
34 Binding,
36 Data,
38 Asset,
40 Glyph,
42 Collision,
44 Overflow,
46 Dpi,
48 Capability,
50 FontEmbedding,
52 Accessibility,
54 Contract,
56 Budget,
58}
59
60#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
62pub struct ValidationIssue {
63 pub severity: ValidationSeverity,
65 pub code: ValidationCode,
67 pub page: Option<usize>,
69 pub element: Option<String>,
71 pub message: String,
73}
74
75#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
77pub struct ValidationReport {
78 pub issues: Vec<ValidationIssue>,
80 #[serde(default)]
82 pub truncated: bool,
83}
84
85impl ValidationReport {
86 #[must_use]
88 pub fn has_errors(&self) -> bool {
89 self.issues
90 .iter()
91 .any(|issue| issue.severity == ValidationSeverity::Error)
92 }
93
94 #[must_use]
96 pub fn has_warnings(&self) -> bool {
97 self.issues
98 .iter()
99 .any(|issue| issue.severity == ValidationSeverity::Warning)
100 }
101
102 pub fn enforce(&self, strict: bool) -> Result<()> {
104 if self.truncated {
105 return Err(FileMakerError::new(
106 ErrorCode::Validation,
107 "validation report was truncated before completion",
108 ));
109 }
110 let rejected = self.issues.iter().find(|issue| {
111 issue.severity == ValidationSeverity::Error
112 || (strict && issue.severity == ValidationSeverity::Warning)
113 });
114 if let Some(issue) = rejected {
115 return Err(FileMakerError::new(
116 ErrorCode::Validation,
117 format!("validation rejected {:?}: {}", issue.code, issue.message),
118 ));
119 }
120 Ok(())
121 }
122
123 pub(crate) fn push(
124 &mut self,
125 severity: ValidationSeverity,
126 code: ValidationCode,
127 page: Option<usize>,
128 element: Option<&str>,
129 message: impl Into<String>,
130 max_issues: usize,
131 ) {
132 if self.issues.len() >= max_issues {
133 self.truncated = true;
134 return;
135 }
136 let message = crate::error::bounded_diagnostic(message.into(), 512);
137 self.issues.push(ValidationIssue {
138 severity,
139 code,
140 page,
141 element: element.map(ToOwned::to_owned),
142 message,
143 });
144 }
145}
146
147#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
149#[serde(default, deny_unknown_fields)]
150pub struct PreflightOptions {
151 pub strict: bool,
153 pub require_accessibility: bool,
155 pub minimum_image_dpi: u32,
157 pub max_issues: usize,
159}
160
161impl Default for PreflightOptions {
162 fn default() -> Self {
163 Self {
164 strict: false,
165 require_accessibility: false,
166 minimum_image_dpi: 150,
167 max_issues: 1_000,
168 }
169 }
170}
171
172pub fn validate_template(template: &TemplateIr, limits: &ResourceLimits) -> ValidationReport {
174 let mut report = ValidationReport::default();
175 crate::validation_data::inspect_template(template, limits, &mut report);
176 report
177}
178
179pub fn validate_data(
181 template: &TemplateIr,
182 data: &DataValue,
183 limits: &ResourceLimits,
184) -> ValidationReport {
185 let mut report = ValidationReport::default();
186 crate::validation_data::inspect_data(template, data, limits, &mut report);
187 report
188}
189
190pub fn validate_layout(
192 scene: &ResolvedScene,
193 limits: &ResourceLimits,
194 max_issues: usize,
195 control: &OperationControl,
196) -> Result<ValidationReport> {
197 if max_issues == 0 {
198 return Err(FileMakerError::new(
199 ErrorCode::Validation,
200 "layout validation requires a non-zero issue bound",
201 ));
202 }
203 crate::validation_layout::inspect(
204 scene,
205 limits,
206 &PreflightOptions {
207 max_issues,
208 ..PreflightOptions::default()
209 },
210 control,
211 )
212}
213
214pub fn preflight(
216 scene: &ResolvedScene,
217 request: &ExportRequest,
218 context: &ExportContext<'_>,
219 options: &PreflightOptions,
220 control: &OperationControl,
221) -> Result<ValidationReport> {
222 if options.max_issues == 0 || options.minimum_image_dpi == 0 {
223 return Err(FileMakerError::new(
224 ErrorCode::Validation,
225 "preflight options require non-zero issue and DPI bounds",
226 ));
227 }
228 crate::export::validate_request(scene, request, context.limits)?;
229 let mut report = crate::validation_layout::inspect(scene, context.limits, options, control)?;
230 inspect_accessibility(request, options, &mut report);
231 for page in &scene.pages {
232 for element in &page.elements {
233 inspect_element(page.index, element, request, context, options, &mut report)?;
234 }
235 }
236 report.enforce(options.strict)?;
237 Ok(report)
238}
239
240fn inspect_element(
241 page: usize,
242 element: &crate::ResolvedElement,
243 request: &ExportRequest,
244 context: &ExportContext<'_>,
245 options: &PreflightOptions,
246 report: &mut ValidationReport,
247) -> Result<()> {
248 crate::validation_capability::inspect_paint(page, element, request, options, report);
249 if matches!(
250 element.kind,
251 ElementKind::Chart | ElementKind::Qr | ElementKind::Barcode
252 ) {
253 report.push(
254 ValidationSeverity::Error,
255 ValidationCode::Capability,
256 Some(page),
257 Some(element.id.as_str()),
258 "requested exporter has no renderer for this prepared element kind",
259 options.max_issues,
260 );
261 }
262 if element.kind == ElementKind::Text {
263 inspect_text(page, element, request, context, options, report);
264 }
265 if element.kind == ElementKind::Image {
266 inspect_image(page, element, request, context, options, report)?;
267 }
268 if element.kind == ElementKind::Table {
269 crate::validation_table::inspect_export(page, element, request, context, options, report);
270 }
271 Ok(())
272}
273
274fn inspect_text(
275 page: usize,
276 element: &crate::ResolvedElement,
277 request: &ExportRequest,
278 context: &ExportContext<'_>,
279 options: &PreflightOptions,
280 report: &mut ValidationReport,
281) {
282 let Some(layout) = &element.text_layout else {
283 return;
284 };
285 if request.format == ExportFormat::Pdf
286 && matches!(request.pdf_mode, PdfMode::Editable | PdfMode::Hybrid)
287 {
288 for run in layout.lines.iter().flat_map(|line| &line.runs) {
289 if context.fonts.get(&run.font).is_err()
290 && context.fonts.get_pdf_standard(&run.font).is_err()
291 {
292 report.push(
293 ValidationSeverity::Error,
294 ValidationCode::FontEmbedding,
295 Some(page),
296 Some(element.id.as_str()),
297 format!("font `{}` is unavailable for embedding", run.font),
298 options.max_issues,
299 );
300 }
301 }
302 }
303}
304
305fn inspect_image(
306 page: usize,
307 element: &crate::ResolvedElement,
308 request: &ExportRequest,
309 context: &ExportContext<'_>,
310 options: &PreflightOptions,
311 report: &mut ValidationReport,
312) -> Result<()> {
313 if options.require_accessibility {
314 report.push(
315 ValidationSeverity::Warning,
316 ValidationCode::Accessibility,
317 Some(page),
318 Some(element.id.as_str()),
319 "schema 1.0 does not carry image alternative text",
320 options.max_issues,
321 );
322 }
323 let (Some(name), Some(resolver)) = (&element.asset, context.assets) else {
324 report.push(
325 ValidationSeverity::Error,
326 ValidationCode::Asset,
327 Some(page),
328 Some(element.id.as_str()),
329 "image asset or explicit resolver is missing",
330 options.max_issues,
331 );
332 return Ok(());
333 };
334 match resolver.resolve_asset(name, context.limits.max_asset_bytes) {
335 Ok(asset) => match element.image_placement {
336 Some(placement) if !placement.vector => {
337 let transformed_destination = element.transform.bounds(placement.destination)?;
338 let dpi_x_tenths = effective_dpi_tenths(
339 placement.source.width,
340 transformed_destination.size.width,
341 )?;
342 let dpi_y_tenths = effective_dpi_tenths(
343 placement.source.height,
344 transformed_destination.size.height,
345 )?;
346 let dpi_tenths = dpi_x_tenths.min(dpi_y_tenths);
347 if dpi_tenths < u64::from(options.minimum_image_dpi) * 10 {
348 report.push(
349 ValidationSeverity::Warning,
350 ValidationCode::Dpi,
351 Some(page),
352 Some(element.id.as_str()),
353 format!(
354 "effective image DPI {}.{} is below {}",
355 dpi_tenths / 10,
356 dpi_tenths % 10,
357 options.minimum_image_dpi
358 ),
359 options.max_issues,
360 );
361 }
362 if request.format == ExportFormat::Jpeg
363 && crate::validation_capability::raster_asset_has_alpha(&asset, placement)?
364 {
365 report.push(
366 ValidationSeverity::Warning,
367 ValidationCode::Capability,
368 Some(page),
369 Some(element.id.as_str()),
370 "JPEG flattens raster image alpha on white",
371 options.max_issues,
372 );
373 }
374 }
375 Some(_) => {
376 if matches!(
377 request.format,
378 ExportFormat::Pdf | ExportFormat::Png | ExportFormat::Jpeg
379 ) {
380 report.push(
381 ValidationSeverity::Warning,
382 ValidationCode::Capability,
383 Some(page),
384 Some(element.id.as_str()),
385 "requested exporter does not rasterize SVG assets",
386 options.max_issues,
387 );
388 }
389 }
390 None => report.push(
391 ValidationSeverity::Error,
392 ValidationCode::Asset,
393 Some(page),
394 Some(element.id.as_str()),
395 "image geometry was not resolved during layout",
396 options.max_issues,
397 ),
398 },
399 Err(error) => report.push(
400 ValidationSeverity::Error,
401 ValidationCode::Asset,
402 Some(page),
403 Some(element.id.as_str()),
404 error.to_string(),
405 options.max_issues,
406 ),
407 }
408 Ok(())
409}
410
411fn inspect_accessibility(
412 request: &ExportRequest,
413 options: &PreflightOptions,
414 report: &mut ValidationReport,
415) {
416 if options.require_accessibility
417 && !(request.format == ExportFormat::Html && request.html_mode == HtmlMode::Semantic)
418 {
419 report.push(
420 ValidationSeverity::Warning,
421 ValidationCode::Accessibility,
422 None,
423 None,
424 "requested exporter does not provide tagged semantic reading structure",
425 options.max_issues,
426 );
427 }
428}
429
430fn effective_dpi_tenths(pixels: u32, size: crate::Unit) -> Result<u64> {
431 if size <= crate::Unit::ZERO {
432 return Err(FileMakerError::new(
433 ErrorCode::GeometryInvalid,
434 "image destination size must be positive",
435 ));
436 }
437 let numerator = u128::from(pixels) * 720 * u128::from(crate::Unit::PER_POINT as u64);
438 let denominator = u128::try_from(size.raw()).map_err(|_| {
439 FileMakerError::new(
440 ErrorCode::GeometryInvalid,
441 "image destination size cannot be converted",
442 )
443 })?;
444 let value = numerator / denominator;
445 u64::try_from(value).map_err(|_| {
446 FileMakerError::new(ErrorCode::GeometryInvalid, "effective image DPI overflow")
447 })
448}