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 mut message = message.into();
137 message.truncate(512);
138 self.issues.push(ValidationIssue {
139 severity,
140 code,
141 page,
142 element: element.map(ToOwned::to_owned),
143 message,
144 });
145 }
146}
147
148#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
150#[serde(default, deny_unknown_fields)]
151pub struct PreflightOptions {
152 pub strict: bool,
154 pub require_accessibility: bool,
156 pub minimum_image_dpi: u32,
158 pub max_issues: usize,
160}
161
162impl Default for PreflightOptions {
163 fn default() -> Self {
164 Self {
165 strict: false,
166 require_accessibility: false,
167 minimum_image_dpi: 150,
168 max_issues: 1_000,
169 }
170 }
171}
172
173pub fn validate_template(template: &TemplateIr, limits: &ResourceLimits) -> ValidationReport {
175 let mut report = ValidationReport::default();
176 crate::validation_data::inspect_template(template, limits, &mut report);
177 report
178}
179
180pub fn validate_data(
182 template: &TemplateIr,
183 data: &DataValue,
184 limits: &ResourceLimits,
185) -> ValidationReport {
186 let mut report = ValidationReport::default();
187 crate::validation_data::inspect_data(template, data, limits, &mut report);
188 report
189}
190
191pub fn validate_layout(
193 scene: &ResolvedScene,
194 limits: &ResourceLimits,
195 max_issues: usize,
196 control: &OperationControl,
197) -> Result<ValidationReport> {
198 if max_issues == 0 {
199 return Err(FileMakerError::new(
200 ErrorCode::Validation,
201 "layout validation requires a non-zero issue bound",
202 ));
203 }
204 crate::validation_layout::inspect(
205 scene,
206 limits,
207 &PreflightOptions {
208 max_issues,
209 ..PreflightOptions::default()
210 },
211 control,
212 )
213}
214
215pub fn preflight(
217 scene: &ResolvedScene,
218 request: &ExportRequest,
219 context: &ExportContext<'_>,
220 options: &PreflightOptions,
221 control: &OperationControl,
222) -> Result<ValidationReport> {
223 if options.max_issues == 0 || options.minimum_image_dpi == 0 {
224 return Err(FileMakerError::new(
225 ErrorCode::Validation,
226 "preflight options require non-zero issue and DPI bounds",
227 ));
228 }
229 crate::export::validate_request(scene, request, context.limits)?;
230 let mut report = crate::validation_layout::inspect(scene, context.limits, options, control)?;
231 inspect_accessibility(request, options, &mut report);
232 for page in &scene.pages {
233 for element in &page.elements {
234 inspect_element(page.index, element, request, context, options, &mut report)?;
235 }
236 }
237 report.enforce(options.strict)?;
238 Ok(report)
239}
240
241fn inspect_element(
242 page: usize,
243 element: &crate::ResolvedElement,
244 request: &ExportRequest,
245 context: &ExportContext<'_>,
246 options: &PreflightOptions,
247 report: &mut ValidationReport,
248) -> Result<()> {
249 crate::validation_capability::inspect_paint(page, element, request, options, report);
250 if matches!(
251 element.kind,
252 ElementKind::Chart | ElementKind::Qr | ElementKind::Barcode
253 ) {
254 report.push(
255 ValidationSeverity::Error,
256 ValidationCode::Capability,
257 Some(page),
258 Some(element.id.as_str()),
259 "requested exporter has no renderer for this prepared element kind",
260 options.max_issues,
261 );
262 }
263 if element.kind == ElementKind::Text {
264 inspect_text(page, element, request, context, options, report);
265 }
266 if element.kind == ElementKind::Image {
267 inspect_image(page, element, request, context, options, report)?;
268 }
269 if element.kind == ElementKind::Table {
270 crate::validation_table::inspect_export(page, element, request, context, options, report);
271 }
272 Ok(())
273}
274
275fn inspect_text(
276 page: usize,
277 element: &crate::ResolvedElement,
278 request: &ExportRequest,
279 context: &ExportContext<'_>,
280 options: &PreflightOptions,
281 report: &mut ValidationReport,
282) {
283 let Some(layout) = &element.text_layout else {
284 return;
285 };
286 if request.format == ExportFormat::Pdf
287 && matches!(request.pdf_mode, PdfMode::Editable | PdfMode::Hybrid)
288 {
289 for run in layout.lines.iter().flat_map(|line| &line.runs) {
290 if context.fonts.get(&run.font).is_err() {
291 report.push(
292 ValidationSeverity::Error,
293 ValidationCode::FontEmbedding,
294 Some(page),
295 Some(element.id.as_str()),
296 format!("font `{}` is unavailable for embedding", run.font),
297 options.max_issues,
298 );
299 }
300 }
301 }
302}
303
304fn inspect_image(
305 page: usize,
306 element: &crate::ResolvedElement,
307 request: &ExportRequest,
308 context: &ExportContext<'_>,
309 options: &PreflightOptions,
310 report: &mut ValidationReport,
311) -> Result<()> {
312 if options.require_accessibility {
313 report.push(
314 ValidationSeverity::Warning,
315 ValidationCode::Accessibility,
316 Some(page),
317 Some(element.id.as_str()),
318 "schema 1.0 does not carry image alternative text",
319 options.max_issues,
320 );
321 }
322 let (Some(name), Some(resolver)) = (&element.asset, context.assets) else {
323 report.push(
324 ValidationSeverity::Error,
325 ValidationCode::Asset,
326 Some(page),
327 Some(element.id.as_str()),
328 "image asset or explicit resolver is missing",
329 options.max_issues,
330 );
331 return Ok(());
332 };
333 match resolver.resolve_asset(name, context.limits.max_asset_bytes) {
334 Ok(asset) => match element.image_placement {
335 Some(placement) if !placement.vector => {
336 let transformed_destination = element.transform.bounds(placement.destination)?;
337 let dpi_x_tenths = effective_dpi_tenths(
338 placement.source.width,
339 transformed_destination.size.width,
340 )?;
341 let dpi_y_tenths = effective_dpi_tenths(
342 placement.source.height,
343 transformed_destination.size.height,
344 )?;
345 let dpi_tenths = dpi_x_tenths.min(dpi_y_tenths);
346 if dpi_tenths < u64::from(options.minimum_image_dpi) * 10 {
347 report.push(
348 ValidationSeverity::Warning,
349 ValidationCode::Dpi,
350 Some(page),
351 Some(element.id.as_str()),
352 format!(
353 "effective image DPI {}.{} is below {}",
354 dpi_tenths / 10,
355 dpi_tenths % 10,
356 options.minimum_image_dpi
357 ),
358 options.max_issues,
359 );
360 }
361 if request.format == ExportFormat::Jpeg
362 && crate::validation_capability::raster_asset_has_alpha(&asset, placement)?
363 {
364 report.push(
365 ValidationSeverity::Warning,
366 ValidationCode::Capability,
367 Some(page),
368 Some(element.id.as_str()),
369 "JPEG flattens raster image alpha on white",
370 options.max_issues,
371 );
372 }
373 }
374 Some(_) => {
375 if matches!(
376 request.format,
377 ExportFormat::Pdf | ExportFormat::Png | ExportFormat::Jpeg
378 ) {
379 report.push(
380 ValidationSeverity::Warning,
381 ValidationCode::Capability,
382 Some(page),
383 Some(element.id.as_str()),
384 "requested exporter does not rasterize SVG assets",
385 options.max_issues,
386 );
387 }
388 }
389 None => report.push(
390 ValidationSeverity::Error,
391 ValidationCode::Asset,
392 Some(page),
393 Some(element.id.as_str()),
394 "image geometry was not resolved during layout",
395 options.max_issues,
396 ),
397 },
398 Err(error) => report.push(
399 ValidationSeverity::Error,
400 ValidationCode::Asset,
401 Some(page),
402 Some(element.id.as_str()),
403 error.to_string(),
404 options.max_issues,
405 ),
406 }
407 Ok(())
408}
409
410fn inspect_accessibility(
411 request: &ExportRequest,
412 options: &PreflightOptions,
413 report: &mut ValidationReport,
414) {
415 if options.require_accessibility
416 && !(request.format == ExportFormat::Html && request.html_mode == HtmlMode::Semantic)
417 {
418 report.push(
419 ValidationSeverity::Warning,
420 ValidationCode::Accessibility,
421 None,
422 None,
423 "requested exporter does not provide tagged semantic reading structure",
424 options.max_issues,
425 );
426 }
427}
428
429fn effective_dpi_tenths(pixels: u32, size: crate::Unit) -> Result<u64> {
430 if size <= crate::Unit::ZERO {
431 return Err(FileMakerError::new(
432 ErrorCode::GeometryInvalid,
433 "image destination size must be positive",
434 ));
435 }
436 let numerator = u128::from(pixels) * 720 * u128::from(crate::Unit::PER_POINT as u64);
437 let denominator = u128::try_from(size.raw()).map_err(|_| {
438 FileMakerError::new(
439 ErrorCode::GeometryInvalid,
440 "image destination size cannot be converted",
441 )
442 })?;
443 let value = numerator / denominator;
444 u64::try_from(value).map_err(|_| {
445 FileMakerError::new(ErrorCode::GeometryInvalid, "effective image DPI overflow")
446 })
447}