pub(crate) mod fills;
mod fill_paths;
mod curve_hulls;
mod dashes;
mod extent;
mod flatten;
mod interpret;
mod report;
mod strokes;
mod stroke_topology;
mod types;
use sha2::{Digest, Sha256};
pub use report::{FidelityReport, Omission, OmissionSummary, FIDELITY_ALGORITHM, MAX_LISTED_OMISSIONS};
pub use types::*;
const ALGORITHM: &str = "ifclite-pdf-vector-state-v1";
const MAX_OPERATIONS: usize = 100_000;
pub fn prepare_pdf_vector_page(page: &PdfVectorPage) -> Result<PreparedPdfVectorPage, String> {
prepare_pdf_vector_page_with_clip(page, None)
}
pub fn prepare_pdf_vector_page_with_clip(
page: &PdfVectorPage,
conversion_clip_pdf: Option<[f64; 4]>,
) -> Result<PreparedPdfVectorPage, String> {
validate_page(page)?;
validate_conversion_clip(page, conversion_clip_pdf)?;
let interpreted = interpret::run(page, conversion_clip_pdf)?;
let mut hash = Sha256::new();
hash.update(ALGORITHM.as_bytes());
hash.update(serde_json::to_vec(page).map_err(|e| format!("Cannot bind PDF request: {e}"))?);
if let Some(clip) = conversion_clip_pdf {
hash.update(b"conversion-clip-pdf\0");
hash.update(serde_json::to_vec(&clip).map_err(|e| format!("Cannot bind PDF clip: {e}"))?);
}
let request_sha256 = format!("{:x}", hash.finalize());
let fidelity = interpreted.report.finish(&request_sha256, interpreted.paths.len());
Ok(PreparedPdfVectorPage {
request_sha256,
algorithm: ALGORITHM.into(),
pdf_sha256: page.pdf_sha256.clone(),
page_number: page.page_number,
calibration_key: page.calibration_key.clone(),
tolerance_metres: page.tolerance_metres,
page_clip_pdf: conversion_clip_pdf.unwrap_or(page.view_box),
paths: interpreted.paths,
fidelity,
})
}
fn validate_page(page: &PdfVectorPage) -> Result<(), String> {
if page.pdf_sha256.len() != 64
|| !page
.pdf_sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err("PDF source identity must be a lowercase SHA-256".into());
}
if page.decoder_version != "6.3.289" {
return Err("Unqualified PDF decoder version".into());
}
if page.pdf_format_version.as_ref().is_some_and(|version| {
version.is_empty()
|| version.len() > 16
|| !version.is_ascii()
|| version.chars().any(char::is_control)
}) {
return Err("Invalid PDF format version".into());
}
if page.page_number == 0 || page.page_number > 2000 {
return Err("Invalid PDF page number".into());
}
if page.operations.len() > MAX_OPERATIONS {
return Err("PDF page exceeds 100000 operations".into());
}
if page.calibration_key.is_empty() || page.calibration_key.len() > 256 {
return Err("PDF calibration identity is missing or too long".into());
}
for x in page.view_box {
scalar(x, -1e9, 1e9)?;
}
if page.view_box[0] >= page.view_box[2] || page.view_box[1] >= page.view_box[3] {
return Err("Invalid PDF CropBox".into());
}
scalar(page.user_unit, f64::MIN_POSITIVE, 75000.)?;
if ![0, 90, 180, 270].contains(&page.intrinsic_rotation) {
return Err("Invalid PDF rotation".into());
}
scalar(page.tolerance_metres, 1e-9, 1.)?;
validate_matrix(&page.model_metres_from_pdf)
}
fn validate_conversion_clip(
page: &PdfVectorPage,
conversion_clip_pdf: Option<[f64; 4]>,
) -> Result<(), String> {
if let Some(clip) = conversion_clip_pdf {
for x in clip { scalar(x, -1e9, 1e9)?; }
if clip[0] >= clip[2] || clip[1] >= clip[3]
|| clip[0] < page.view_box[0] || clip[1] < page.view_box[1]
|| clip[2] > page.view_box[2] || clip[3] > page.view_box[3]
{
return Err("Invalid PDF conversion clip".into());
}
}
Ok(())
}
pub(super) fn scalar(x: f64, min: f64, max: f64) -> Result<(), String> {
if !x.is_finite() || x < min || x > max {
Err("PDF numeric value is invalid or exceeds bounds".into())
} else {
Ok(())
}
}
fn validate_rgb(rgb: &[f64; 3]) -> Result<(), String> {
for x in rgb {
scalar(*x, 0., 1.)?;
}
Ok(())
}
fn validate_matrix(m: &[f64; 6]) -> Result<(), String> {
for x in m {
scalar(*x, -1e12, 1e12)?;
}
let determinant = m[0] * m[3] - m[1] * m[2];
if !determinant.is_finite() || determinant == 0. {
return Err("PDF transform is singular".into());
}
Ok(())
}
fn multiply(a: &[f64; 6], b: &[f64; 6]) -> [f64; 6] {
[
a[0] * b[0] + a[2] * b[1],
a[1] * b[0] + a[3] * b[1],
a[0] * b[2] + a[2] * b[3],
a[1] * b[2] + a[3] * b[3],
a[0] * b[4] + a[2] * b[5] + a[4],
a[1] * b[4] + a[3] * b[5] + a[5],
]
}
fn validate_path(commands: &[f64]) -> Result<(), String> {
let mut cursor = 0;
let mut has_current = false;
while cursor < commands.len() {
let command = commands[cursor];
let arity = match command {
0. | 1. => 2,
2. => 6,
3. => 4,
4. => 0,
_ => return Err("Unknown PDF DrawOPS command".into()),
};
if command != 0. && !has_current {
return Err("PDF path command has no current point".into());
}
if command == 0. {
has_current = true;
}
cursor += 1;
let values = commands
.get(cursor..cursor + arity)
.ok_or("Truncated PDF path command")?;
for x in values {
scalar(*x, -1e9, 1e9)?;
}
cursor += arity;
}
Ok(())
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
#[cfg(test)]
#[path = "report_tests.rs"]
mod report_tests;