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 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/// Caller-selected preflight policy.
148#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
149#[serde(default, deny_unknown_fields)]
150pub struct PreflightOptions {
151    /// Treat warnings as rejection.
152    pub strict: bool,
153    /// Require accessibility metadata from the current schema.
154    pub require_accessibility: bool,
155    /// Minimum effective image resolution.
156    pub minimum_image_dpi: u32,
157    /// Maximum retained diagnostics.
158    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
172/// Validates reusable template IR and binding syntax.
173pub 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
179/// Validates bounded typed data, its optional schema, and every binding.
180pub 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
190/// Validates resolved page, glyph, table, overflow, and collision invariants.
191pub 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
214/// Runs exporter-aware validation over a fully resolved scene.
215pub 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}