ifc_lite_processing/pdf_vector/
mod.rs1pub(crate) mod fills;
8mod fill_paths;
9mod curve_hulls;
10mod flatten;
11mod strokes;
12mod stroke_topology;
13mod types;
14use sha2::{Digest, Sha256};
15pub use types::*;
16
17const ALGORITHM: &str = "ifclite-pdf-vector-state-v1";
18const MAX_OPERATIONS: usize = 100_000;
19const MAX_PATH_NUMBERS: usize = 2_000_000;
20const MAX_PATHS: usize = 20_000;
21const MAX_STACK: usize = 64;
22
23pub fn prepare_pdf_vector_page(page: &PdfVectorPage) -> Result<PreparedPdfVectorPage, String> {
27 validate_page(page)?;
28 let mut state = PdfVectorGraphicsState {
29 model_metres_from_path: page.model_metres_from_pdf,
30 fill_rgb: [0.; 3],
31 stroke_rgb: [0.; 3],
32 line_width: 1.,
33 line_cap: 0,
34 line_join: 0,
35 miter_limit: 10.,
36 dash_lengths: Vec::new(),
37 dash_phase: 0.,
38 };
39 let mut stack = Vec::new();
40 let mut paths = Vec::new();
41 let mut diagnostics = Vec::new();
42 let mut numbers = 0;
43 let mut previous = None;
44 for entry in &page.operations {
45 let ordinal = entry.ordinal;
46 if previous.is_some_and(|p| p >= ordinal) {
47 return Err("PDF operator ordinals must be strictly increasing".into());
48 }
49 previous = Some(ordinal);
50 let diagnostic = |code: &str| PdfVectorDiagnostic {
51 operator_ordinal: ordinal,
52 code: code.into(),
53 };
54 match &entry.operation {
55 PdfVectorOperator::Save => {
56 if stack.len() == MAX_STACK {
57 return Err("PDF graphics-state stack exceeds 64".into());
58 }
59 stack.push(state.clone());
60 }
61 PdfVectorOperator::Restore => {
62 state = stack.pop().ok_or("PDF restore has no matching save")?;
63 }
64 PdfVectorOperator::Transform { matrix } => {
65 validate_matrix(matrix)?;
66 state.model_metres_from_path = multiply(&state.model_metres_from_path, matrix);
67 validate_matrix(&state.model_metres_from_path)?;
68 }
69 PdfVectorOperator::FillColor { rgb } => {
70 validate_rgb(rgb)?;
71 state.fill_rgb = *rgb;
72 }
73 PdfVectorOperator::StrokeColor { rgb } => {
74 validate_rgb(rgb)?;
75 state.stroke_rgb = *rgb;
76 }
77 PdfVectorOperator::LineWidth { width } => {
78 scalar(*width, 0., 1e9)?;
79 state.line_width = *width;
80 }
81 PdfVectorOperator::LineCap { cap } => {
82 if *cap > 2 {
83 return Err("Invalid PDF line cap".into());
84 }
85 state.line_cap = *cap;
86 }
87 PdfVectorOperator::LineJoin { join } => {
88 if *join > 2 {
89 return Err("Invalid PDF line join".into());
90 }
91 state.line_join = *join;
92 }
93 PdfVectorOperator::MiterLimit { limit } => {
94 scalar(*limit, 1., 1e9)?;
95 state.miter_limit = *limit;
96 }
97 PdfVectorOperator::Dash { lengths, phase } => {
98 if lengths.len() > 128 {
99 return Err("PDF dash array exceeds 128 entries".into());
100 }
101 scalar(*phase, 0., 1e9)?;
102 for length in lengths {
103 scalar(*length, 0., 1e9)?;
104 }
105 if !lengths.is_empty() && lengths.iter().all(|x| *x == 0.) {
106 return Err("PDF dash array is all zero".into());
107 }
108 state.dash_lengths = lengths.clone();
109 state.dash_phase = *phase;
110 }
111 PdfVectorOperator::Path { paint, commands } => {
112 numbers += commands.len();
113 if numbers > MAX_PATH_NUMBERS {
114 return Err("PDF paths exceed two million numbers".into());
115 }
116 validate_path(commands)?;
117 if *paint == PdfVectorPaint::EndPath || commands.is_empty() {
120 continue;
121 }
122 if paths.len() == MAX_PATHS {
123 return Err("PDF page exceeds 20000 painted paths".into());
124 }
125 if paint.strokes() && state.line_width == 0. {
126 diagnostics.push(diagnostic("device-dependent-hairline"));
127 }
128 paths.push(PreparedPdfVectorPath {
129 operator_ordinal: ordinal,
130 paint: *paint,
131 commands: commands.clone(),
132 state: state.clone(),
133 });
134 }
135 PdfVectorOperator::Unsupported { operator } => {
136 if operator.is_empty()
137 || operator.len() > 128
138 || !operator.is_ascii()
139 || operator.chars().any(char::is_control)
140 {
141 return Err("Invalid unsupported PDF operator identity".into());
142 }
143 diagnostics.push(diagnostic(&format!("unsupported:{operator}")));
144 }
145 }
146 }
147 if !stack.is_empty() {
148 return Err("PDF graphics-state stack is unbalanced".into());
149 }
150 let mut hash = Sha256::new();
151 hash.update(ALGORITHM.as_bytes());
152 hash.update(serde_json::to_vec(page).map_err(|e| format!("Cannot bind PDF request: {e}"))?);
153 Ok(PreparedPdfVectorPage {
154 request_sha256: format!("{:x}", hash.finalize()),
155 algorithm: ALGORITHM.into(),
156 pdf_sha256: page.pdf_sha256.clone(),
157 page_number: page.page_number,
158 calibration_key: page.calibration_key.clone(),
159 tolerance_metres: page.tolerance_metres,
160 state_qualified: diagnostics.is_empty(),
161 geometry_ready: false,
162 page_clip_pdf: page.view_box,
163 pending_geometry: [
164 "pageClip",
165 "curveFlattening",
166 "strokeOutlining",
167 "fillClassification",
168 "paintOrder",
169 ],
170 paths,
171 diagnostics,
172 })
173}
174
175fn validate_page(page: &PdfVectorPage) -> Result<(), String> {
176 if page.pdf_sha256.len() != 64
177 || !page
178 .pdf_sha256
179 .bytes()
180 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
181 {
182 return Err("PDF source identity must be a lowercase SHA-256".into());
183 }
184 if page.decoder_version != "6.3.289" {
185 return Err("Unqualified PDF decoder version".into());
186 }
187 if page.page_number == 0 || page.page_number > 2000 {
188 return Err("Invalid PDF page number".into());
189 }
190 if page.operations.len() > MAX_OPERATIONS {
191 return Err("PDF page exceeds 100000 operations".into());
192 }
193 if page.calibration_key.is_empty() || page.calibration_key.len() > 256 {
194 return Err("PDF calibration identity is missing or too long".into());
195 }
196 for x in page.view_box {
197 scalar(x, -1e9, 1e9)?;
198 }
199 if page.view_box[0] >= page.view_box[2] || page.view_box[1] >= page.view_box[3] {
200 return Err("Invalid PDF CropBox".into());
201 }
202 scalar(page.user_unit, f64::MIN_POSITIVE, 75000.)?;
203 if ![0, 90, 180, 270].contains(&page.intrinsic_rotation) {
204 return Err("Invalid PDF rotation".into());
205 }
206 scalar(page.tolerance_metres, 1e-9, 1.)?;
207 validate_matrix(&page.model_metres_from_pdf)
208}
209
210fn scalar(x: f64, min: f64, max: f64) -> Result<(), String> {
211 if !x.is_finite() || x < min || x > max {
212 Err("PDF numeric value is invalid or exceeds bounds".into())
213 } else {
214 Ok(())
215 }
216}
217fn validate_rgb(rgb: &[f64; 3]) -> Result<(), String> {
218 for x in rgb {
219 scalar(*x, 0., 1.)?;
220 }
221 Ok(())
222}
223fn validate_matrix(m: &[f64; 6]) -> Result<(), String> {
224 for x in m {
225 scalar(*x, -1e12, 1e12)?;
226 }
227 let determinant = m[0] * m[3] - m[1] * m[2];
228 if !determinant.is_finite() || determinant == 0. {
229 return Err("PDF transform is singular".into());
230 }
231 Ok(())
232}
233fn multiply(a: &[f64; 6], b: &[f64; 6]) -> [f64; 6] {
234 [
235 a[0] * b[0] + a[2] * b[1],
236 a[1] * b[0] + a[3] * b[1],
237 a[0] * b[2] + a[2] * b[3],
238 a[1] * b[2] + a[3] * b[3],
239 a[0] * b[4] + a[2] * b[5] + a[4],
240 a[1] * b[4] + a[3] * b[5] + a[5],
241 ]
242}
243fn validate_path(commands: &[f64]) -> Result<(), String> {
244 let mut cursor = 0;
245 let mut has_current = false;
246 while cursor < commands.len() {
247 let command = commands[cursor];
248 let arity = match command {
249 0. | 1. => 2,
250 2. => 6,
251 3. => 4,
252 4. => 0,
253 _ => return Err("Unknown PDF DrawOPS command".into()),
254 };
255 if command != 0. && !has_current {
256 return Err("PDF path command has no current point".into());
257 }
258 if command == 0. {
259 has_current = true;
260 }
261 cursor += 1;
262 let values = commands
263 .get(cursor..cursor + arity)
264 .ok_or("Truncated PDF path command")?;
265 for x in values {
266 scalar(*x, -1e9, 1e9)?;
267 }
268 cursor += arity;
269 }
270 Ok(())
271}
272
273#[cfg(test)]
274#[path = "tests.rs"]
275mod tests;