Skip to main content

appcore_filemaker/
validation.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: validation.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded validation contracts and behavior for this crate.
12
13use 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/// Severity retained in machine-readable validation output.
21#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ValidationSeverity {
24    /// Rendering can proceed unless strict warnings are requested.
25    Warning,
26    /// Rendering must not proceed.
27    Error,
28}
29
30/// Stable validation/preflight condition.
31#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ValidationCode {
34    /// Binding/condition/repeat expression is invalid.
35    Binding,
36    /// Typed input does not satisfy its structural or declared data contract.
37    Data,
38    /// Referenced asset is absent or invalid.
39    Asset,
40    /// Font/glyph layout cannot be preserved.
41    Glyph,
42    /// Resolved elements overlap.
43    Collision,
44    /// Visual bounds leave the page.
45    Overflow,
46    /// Effective raster resolution is below policy.
47    Dpi,
48    /// Requested exporter cannot preserve an element.
49    Capability,
50    /// Editable PDF font embedding is not configured.
51    FontEmbedding,
52    /// Required accessibility metadata is unavailable.
53    Accessibility,
54    /// A generic schema/data/layout invariant failed.
55    Contract,
56    /// Diagnostic/comparison budget was exhausted.
57    Budget,
58}
59
60/// One bounded first-class validation issue.
61#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
62pub struct ValidationIssue {
63    /// Severity.
64    pub severity: ValidationSeverity,
65    /// Stable condition.
66    pub code: ValidationCode,
67    /// Optional page.
68    pub page: Option<usize>,
69    /// Optional element.
70    pub element: Option<String>,
71    /// Bounded explanation.
72    pub message: String,
73}
74
75/// Deterministic validation output.
76#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
77pub struct ValidationReport {
78    /// Issues in discovery order.
79    pub issues: Vec<ValidationIssue>,
80    /// Whether additional issues were omitted by the caller's bound.
81    #[serde(default)]
82    pub truncated: bool,
83}
84
85impl ValidationReport {
86    /// Returns true when at least one hard error exists.
87    #[must_use]
88    pub fn has_errors(&self) -> bool {
89        self.issues
90            .iter()
91            .any(|issue| issue.severity == ValidationSeverity::Error)
92    }
93
94    /// Returns true when at least one warning exists.
95    #[must_use]
96    pub fn has_warnings(&self) -> bool {
97        self.issues
98            .iter()
99            .any(|issue| issue.severity == ValidationSeverity::Warning)
100    }
101
102    /// Enforces errors, plus warnings when strict mode is active.
103    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/// Caller-selected preflight policy.
149#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
150#[serde(default, deny_unknown_fields)]
151pub struct PreflightOptions {
152    /// Treat warnings as rejection.
153    pub strict: bool,
154    /// Require accessibility metadata from the current schema.
155    pub require_accessibility: bool,
156    /// Minimum effective image resolution.
157    pub minimum_image_dpi: u32,
158    /// Maximum retained diagnostics.
159    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
173/// Validates reusable template IR and binding syntax.
174pub 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
180/// Validates bounded typed data, its optional schema, and every binding.
181pub 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
191/// Validates resolved page, glyph, table, overflow, and collision invariants.
192pub 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
215/// Runs exporter-aware validation over a fully resolved scene.
216pub 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}