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, producing
5//! the convertible path subset and a page-level fidelity report. This does not
6//! authorize geometry. The explicit annotation planner separately composes
7//! qualified fills and bounded solid straight stroke outlines.
8pub(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
26/// Input identity is host-verified: native code receives decoded operations, not
27/// raw PDF bytes. Invalid structure/budget refuses atomically. Content the planner
28/// cannot convert is never dropped silently: it is reported with page extent and
29/// visibility, and `fidelity.exact` is false whenever any of it is visible.
30pub fn prepare_pdf_vector_page(page: &PdfVectorPage) -> Result<PreparedPdfVectorPage, String> {
31    prepare_pdf_vector_page_with_clip(page, None)
32}
33
34/// Prepare a PDF page within an optional registered rectangle in native PDF
35/// space. The separate argument keeps [`PdfVectorPage`] source-compatible for
36/// Rust callers while the WASM wire format accepts `conversionClipPdf`.
37pub 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;