ifc_lite_processing/pdf_vector/
mod.rs1pub(crate) mod fills;
9mod fill_paths;
10mod curve_hulls;
11mod dashes;
12mod extent;
13mod flatten;
14mod interpret;
15mod report;
16mod strokes;
17mod stroke_topology;
18mod types;
19use sha2::{Digest, Sha256};
20pub use report::{FidelityReport, Omission, OmissionSummary, FIDELITY_ALGORITHM, MAX_LISTED_OMISSIONS};
21pub use types::*;
22
23const ALGORITHM: &str = "ifclite-pdf-vector-state-v1";
24const MAX_OPERATIONS: usize = 100_000;
25
26pub fn prepare_pdf_vector_page(page: &PdfVectorPage) -> Result<PreparedPdfVectorPage, String> {
31 prepare_pdf_vector_page_with_clip(page, None)
32}
33
34pub fn prepare_pdf_vector_page_with_clip(
38 page: &PdfVectorPage,
39 conversion_clip_pdf: Option<[f64; 4]>,
40) -> Result<PreparedPdfVectorPage, String> {
41 validate_page(page)?;
42 validate_conversion_clip(page, conversion_clip_pdf)?;
43 let interpreted = interpret::run(page, conversion_clip_pdf)?;
44 let mut hash = Sha256::new();
45 hash.update(ALGORITHM.as_bytes());
46 hash.update(serde_json::to_vec(page).map_err(|e| format!("Cannot bind PDF request: {e}"))?);
47 if let Some(clip) = conversion_clip_pdf {
48 hash.update(b"conversion-clip-pdf\0");
49 hash.update(serde_json::to_vec(&clip).map_err(|e| format!("Cannot bind PDF clip: {e}"))?);
50 }
51 let request_sha256 = format!("{:x}", hash.finalize());
52 let fidelity = interpreted.report.finish(&request_sha256, interpreted.paths.len());
53 Ok(PreparedPdfVectorPage {
54 request_sha256,
55 algorithm: ALGORITHM.into(),
56 pdf_sha256: page.pdf_sha256.clone(),
57 page_number: page.page_number,
58 calibration_key: page.calibration_key.clone(),
59 tolerance_metres: page.tolerance_metres,
60 page_clip_pdf: conversion_clip_pdf.unwrap_or(page.view_box),
61 paths: interpreted.paths,
62 fidelity,
63 })
64}
65
66fn validate_page(page: &PdfVectorPage) -> Result<(), String> {
67 if page.pdf_sha256.len() != 64
68 || !page
69 .pdf_sha256
70 .bytes()
71 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
72 {
73 return Err("PDF source identity must be a lowercase SHA-256".into());
74 }
75 if page.decoder_version != "6.3.289" {
76 return Err("Unqualified PDF decoder version".into());
77 }
78 if page.pdf_format_version.as_ref().is_some_and(|version| {
79 version.is_empty()
80 || version.len() > 16
81 || !version.is_ascii()
82 || version.chars().any(char::is_control)
83 }) {
84 return Err("Invalid PDF format version".into());
85 }
86 if page.page_number == 0 || page.page_number > 2000 {
87 return Err("Invalid PDF page number".into());
88 }
89 if page.operations.len() > MAX_OPERATIONS {
90 return Err("PDF page exceeds 100000 operations".into());
91 }
92 if page.calibration_key.is_empty() || page.calibration_key.len() > 256 {
93 return Err("PDF calibration identity is missing or too long".into());
94 }
95 for x in page.view_box {
96 scalar(x, -1e9, 1e9)?;
97 }
98 if page.view_box[0] >= page.view_box[2] || page.view_box[1] >= page.view_box[3] {
99 return Err("Invalid PDF CropBox".into());
100 }
101 scalar(page.user_unit, f64::MIN_POSITIVE, 75000.)?;
102 if ![0, 90, 180, 270].contains(&page.intrinsic_rotation) {
103 return Err("Invalid PDF rotation".into());
104 }
105 scalar(page.tolerance_metres, 1e-9, 1.)?;
106 validate_matrix(&page.model_metres_from_pdf)
107}
108
109fn validate_conversion_clip(
110 page: &PdfVectorPage,
111 conversion_clip_pdf: Option<[f64; 4]>,
112) -> Result<(), String> {
113 if let Some(clip) = conversion_clip_pdf {
114 for x in clip { scalar(x, -1e9, 1e9)?; }
115 if clip[0] >= clip[2] || clip[1] >= clip[3]
116 || clip[0] < page.view_box[0] || clip[1] < page.view_box[1]
117 || clip[2] > page.view_box[2] || clip[3] > page.view_box[3]
118 {
119 return Err("Invalid PDF conversion clip".into());
120 }
121 }
122 Ok(())
123}
124
125pub(super) fn scalar(x: f64, min: f64, max: f64) -> Result<(), String> {
126 if !x.is_finite() || x < min || x > max {
127 Err("PDF numeric value is invalid or exceeds bounds".into())
128 } else {
129 Ok(())
130 }
131}
132fn validate_rgb(rgb: &[f64; 3]) -> Result<(), String> {
133 for x in rgb {
134 scalar(*x, 0., 1.)?;
135 }
136 Ok(())
137}
138fn validate_matrix(m: &[f64; 6]) -> Result<(), String> {
139 for x in m {
140 scalar(*x, -1e12, 1e12)?;
141 }
142 let determinant = m[0] * m[3] - m[1] * m[2];
143 if !determinant.is_finite() || determinant == 0. {
144 return Err("PDF transform is singular".into());
145 }
146 Ok(())
147}
148fn multiply(a: &[f64; 6], b: &[f64; 6]) -> [f64; 6] {
149 [
150 a[0] * b[0] + a[2] * b[1],
151 a[1] * b[0] + a[3] * b[1],
152 a[0] * b[2] + a[2] * b[3],
153 a[1] * b[2] + a[3] * b[3],
154 a[0] * b[4] + a[2] * b[5] + a[4],
155 a[1] * b[4] + a[3] * b[5] + a[5],
156 ]
157}
158fn validate_path(commands: &[f64]) -> Result<(), String> {
159 let mut cursor = 0;
160 let mut has_current = false;
161 while cursor < commands.len() {
162 let command = commands[cursor];
163 let arity = match command {
164 0. | 1. => 2,
165 2. => 6,
166 3. => 4,
167 4. => 0,
168 _ => return Err("Unknown PDF DrawOPS command".into()),
169 };
170 if command != 0. && !has_current {
171 return Err("PDF path command has no current point".into());
172 }
173 if command == 0. {
174 has_current = true;
175 }
176 cursor += 1;
177 let values = commands
178 .get(cursor..cursor + arity)
179 .ok_or("Truncated PDF path command")?;
180 for x in values {
181 scalar(*x, -1e9, 1e9)?;
182 }
183 cursor += arity;
184 }
185 Ok(())
186}
187
188#[cfg(test)]
189#[path = "tests.rs"]
190mod tests;
191#[cfg(test)]
192#[path = "report_tests.rs"]
193mod report_tests;