Skip to main content

ttf_rs/
validation.rs

1use crate::error::Result;
2use crate::font::Font;
3use crate::stream::calculate_checksum;
4
5/// Validation report for a font
6#[derive(Debug, Clone)]
7pub struct ValidationReport {
8    pub is_valid: bool,
9    pub errors: Vec<ValidationError>,
10    pub warnings: Vec<ValidationWarning>,
11}
12
13#[derive(Debug, Clone)]
14pub struct ValidationError {
15    pub error_type: ValidationErrorType,
16    pub message: String,
17    pub table: Option<String>,
18}
19
20#[derive(Debug, Clone)]
21pub struct ValidationWarning {
22    pub warning_type: ValidationWarningType,
23    pub message: String,
24    pub table: Option<String>,
25}
26
27#[derive(Debug, Clone, PartialEq)]
28pub enum ValidationErrorType {
29    InvalidSignature,
30    InvalidChecksum,
31    MissingRequiredTable,
32    InvalidTableStructure,
33    InvalidGlyphData,
34    InvalidCmapData,
35}
36
37#[derive(Debug, Clone, PartialEq)]
38pub enum ValidationWarningType {
39    NonStandardTable,
40    UnexpectedTableVersion,
41    DeprecatedTable,
42    PotentiallyProblematic,
43}
44
45impl Font {
46    /// Validate the font structure and return a validation report
47    pub fn validate(&self) -> Result<ValidationReport> {
48        let mut report = ValidationReport {
49            is_valid: true,
50            errors: Vec::new(),
51            warnings: Vec::new(),
52        };
53
54        // Validate SFNT signature
55        if self.sfnt_version != 0x00010000 && self.sfnt_version != 0x4F54544F {
56            report.errors.push(ValidationError {
57                error_type: ValidationErrorType::InvalidSignature,
58                message: format!("Invalid SFNT version: {:#x}", self.sfnt_version),
59                table: None,
60            });
61            report.is_valid = false;
62        }
63
64        // Check for required tables
65        let required_tables = [
66            (b"cmap", "character to glyph mapping"),
67            (b"head", "font header"),
68            (b"hhea", "horizontal header"),
69            (b"hmtx", "horizontal metrics"),
70            (b"maxp", "maximum profile"),
71            (b"name", "naming table"),
72            (b"OS/2", "OS/2 and Windows metrics"),
73            (b"post", "PostScript information"),
74        ];
75
76        for (tag, description) in &required_tables {
77            if self.get_table_record(tag).is_none() {
78                report.errors.push(ValidationError {
79                    error_type: ValidationErrorType::MissingRequiredTable,
80                    message: format!("Missing required table: {} ({})",
81                        String::from_utf8_lossy(tag.as_slice()), description),
82                    table: Some(String::from_utf8_lossy(tag.as_slice()).to_string()),
83                });
84                report.is_valid = false;
85            }
86        }
87
88        // TrueType fonts require glyf and loca tables
89        if self.sfnt_version == 0x00010000 {
90            let ttf_required = [(b"glyf", "glyph data"), (b"loca", "index to location")];
91            for (tag, description) in &ttf_required {
92                if self.get_table_record(tag).is_none() {
93                    report.errors.push(ValidationError {
94                        error_type: ValidationErrorType::MissingRequiredTable,
95                        message: format!("Missing required table for TrueType: {} ({})",
96                            String::from_utf8_lossy(tag.as_slice()), description),
97                        table: Some(String::from_utf8_lossy(tag.as_slice()).to_string()),
98                    });
99                    report.is_valid = false;
100                }
101            }
102        }
103
104        // Validate table checksums
105        for record in &self.table_records {
106            if let Some(table_data) = self.get_table_data(&record.table_tag) {
107                let calculated_checksum = calculate_checksum(&table_data);
108
109                // The head table checksum adjustment should be 0xB1B0AFBA
110                if record.table_tag == *b"head" {
111                    // For head table, we need to skip the checksum adjustment field when calculating
112                    // The checksum adjustment is at offset 8, and should be 0xB1B0AFBA - calculated_checksum
113                } else if calculated_checksum != record.checksum {
114                    report.warnings.push(ValidationWarning {
115                        warning_type: ValidationWarningType::PotentiallyProblematic,
116                        message: format!(
117                            "Checksum mismatch for table {}: expected {:#x}, got {:#x}",
118                            String::from_utf8_lossy(&record.table_tag),
119                            record.checksum,
120                            calculated_checksum
121                        ),
122                        table: Some(String::from_utf8_lossy(&record.table_tag).to_string()),
123                    });
124                }
125            }
126        }
127
128        // Validate head table magic number
129        if let Ok(head) = self.head_table() {
130            if head.magic_number != 0x5F0F3CF5 {
131                report.errors.push(ValidationError {
132                    error_type: ValidationErrorType::InvalidTableStructure,
133                    message: format!("Invalid magic number in head table: {:#x}", head.magic_number),
134                    table: Some("head".to_string()),
135                });
136                report.is_valid = false;
137            }
138
139            // Check units per em is valid
140            if head.units_per_em == 0 || head.units_per_em > 16384 {
141                report.warnings.push(ValidationWarning {
142                    warning_type: ValidationWarningType::PotentiallyProblematic,
143                    message: format!("Unusual units_per_em value: {}", head.units_per_em),
144                    table: Some("head".to_string()),
145                });
146            }
147        }
148
149        // Validate maxp table
150        if let Ok(maxp) = self.maxp_table() {
151            if maxp.num_glyphs == 0 {
152                report.errors.push(ValidationError {
153                    error_type: ValidationErrorType::InvalidGlyphData,
154                    message: "Font has no glyphs".to_string(),
155                    table: Some("maxp".to_string()),
156                });
157                report.is_valid = false;
158            }
159        }
160
161        // Validate cmap table
162        if let Ok(cmap) = self.cmap_table() {
163            if cmap.subtables.is_empty() {
164                report.errors.push(ValidationError {
165                    error_type: ValidationErrorType::InvalidCmapData,
166                    message: "Cmap table has no subtables".to_string(),
167                    table: Some("cmap".to_string()),
168                });
169                report.is_valid = false;
170            }
171        }
172
173        // Check for non-standard tables
174        let standard_tables = [
175            "cmap", "head", "hhea", "hmtx", "maxp", "name", "OS/2", "post",
176            "glyf", "loca", "kern", "GPOS", "GSUB", "BASE", "GDEF", "JSTF",
177            "vhea", "vmtx", "VORG", "CVT ", "fpgm", "prep", "gasp", "EBSC",
178            "trak", "ltsh", "PCLT", "VDMX", "hdmx", "CBDT", "CBLC", "COLR",
179            "CPAL", "sbix", "acnt", "avar", "bdat", "bloc", "bsln", "cvar",
180            "fdsc", "feat", "fmtx", "fvar", "gvar", "gcid", "glyf", "hvar",
181            "just", "lcar", "mort", "morx", "opbd", "prop", "trak", "Zapf",
182            "Silf", "Glat", "Gloc", "Feat", "Sill",
183        ];
184
185        for record in &self.table_records {
186            let tag_str = String::from_utf8_lossy(&record.table_tag).to_string();
187            if !standard_tables.contains(&tag_str.as_str()) {
188                report.warnings.push(ValidationWarning {
189                    warning_type: ValidationWarningType::NonStandardTable,
190                    message: format!("Non-standard table: {}", tag_str),
191                    table: Some(tag_str),
192                });
193            }
194        }
195
196        Ok(report)
197    }
198
199    /// Quick check if the font is valid (returns only boolean)
200    pub fn is_valid(&self) -> Result<bool> {
201        Ok(self.validate()?.is_valid)
202    }
203}
204
205impl ValidationReport {
206    /// Get a human-readable summary of the validation report
207    pub fn summary(&self) -> String {
208        let mut summary = String::new();
209
210        if self.is_valid {
211            summary.push_str("✓ Font is valid\n");
212        } else {
213            summary.push_str("✗ Font is invalid\n");
214        }
215
216        if !self.errors.is_empty() {
217            summary.push_str(&format!("\nErrors ({}):\n", self.errors.len()));
218            for error in &self.errors {
219                let table = error.table.as_ref().map(|t| format!("[{}] ", t)).unwrap_or_default();
220                summary.push_str(&format!("  ✗ {}: {}\n", table, error.message));
221            }
222        }
223
224        if !self.warnings.is_empty() {
225            summary.push_str(&format!("\nWarnings ({}):\n", self.warnings.len()));
226            for warning in &self.warnings {
227                let table = warning.table.as_ref().map(|t| format!("[{}] ", t)).unwrap_or_default();
228                summary.push_str(&format!("  ⚠ {}: {}\n", table, warning.message));
229            }
230        }
231
232        summary
233    }
234}