Skip to main content

ifc_lite_processing/pdf_vector/
mod.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//! Bounded graphics-state preparation for the pinned PDF.js adapter. This does
5//! not authorize geometry. The explicit annotation planner separately composes
6//! qualified fills and bounded solid straight stroke outlines.
7pub(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
23/// Input identity is host-verified: native code receives decoded operations, not
24/// raw PDF bytes. Invalid structure/budget refuses atomically; unsupported paint
25/// is reported with its original operator index and never becomes a ready plan.
26pub 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                // Empty paths/endPath consume no visible paint. In particular an
118                // unused zero-width setting does not itself paint a hairline.
119                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;