Skip to main content

ifc_lite_processing/pdf_vector/
report.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//! Page-level fidelity verdict. Exact means nothing visible is lost; anything
5//! else needs the host to show this report and the user to accept it.
6use super::extent::{self, Rect};
7use serde::Serialize;
8use sha2::{Digest, Sha256};
9use std::collections::BTreeMap;
10
11pub const FIDELITY_ALGORITHM: &str = "ifclite-pdf-fidelity-v1";
12/// Detailed entries are bounded; `summary` counts stay complete.
13pub const MAX_LISTED_OMISSIONS: usize = 4096;
14
15#[derive(Debug, Clone, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub struct Omission {
18    /// `text`, `image`, `pattern`, `clip`, `transparency`, `dash`,
19    /// `curvedStroke`, `hairline`, `hidden`, `annotation`
20    /// or `unsupported:<pinned operator>`.
21    pub kind: String,
22    pub operator_ordinal: u32,
23    /// Conservative extent in unrotated PDF user space (CropBox coordinates).
24    pub bbox_pdf: Option<Rect>,
25    /// Intersects the effective page clip and is not hidden/invisible.
26    pub visible: bool,
27}
28#[derive(Debug, Clone, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct OmissionSummary {
31    pub kind: String,
32    pub count: u32,
33    pub visible_count: u32,
34    /// Union of the visible entries.
35    pub bbox_pdf: Option<Rect>,
36}
37#[derive(Debug, Clone, Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct FidelityReport {
40    /// Binds the request digest and this verdict; a partial plan must quote it.
41    pub sha256: String,
42    pub algorithm: &'static str,
43    /// No visible content is omitted. Says nothing about geometric qualification.
44    pub exact: bool,
45    /// Only raster images are visible: keep the page as a raster reference.
46    pub raster_only: bool,
47    pub convertible_paths: u32,
48    /// Visible painted fill or stroke parts the planner leaves out. Paints
49    /// inside hidden optional content are listed under `hidden` but not
50    /// counted here, so an exact page always records zero.
51    pub omitted_paints: u32,
52    pub summary: Vec<OmissionSummary>,
53    pub omissions: Vec<Omission>,
54    pub omissions_truncated: bool,
55}
56#[derive(Serialize)]
57#[serde(rename_all = "camelCase")]
58struct Verdict<'a> {
59    exact: bool,
60    raster_only: bool,
61    convertible_paths: u32,
62    omitted_paints: u32,
63    summary: &'a [OmissionSummary],
64}
65
66#[derive(Default)]
67pub(super) struct ReportBuilder {
68    omissions: Vec<Omission>,
69    truncated: bool,
70    summary: BTreeMap<String, OmissionSummary>,
71    omitted_paints: u32,
72    visible_images: u32,
73    visible_vector: u32,
74}
75impl ReportBuilder {
76    /// `painted` marks omitted fill/stroke parts of real paths, distinct from
77    /// text, images and scope markers.
78    pub fn record(&mut self, kind: &str, ordinal: u32, bbox: Option<Rect>, visible: bool, painted: bool) {
79        if painted && visible {
80            self.omitted_paints += 1;
81        }
82        if visible {
83            if kind == "image" {
84                self.visible_images += 1;
85            } else if kind != "hidden" {
86                self.visible_vector += 1;
87            }
88        }
89        let entry = self.summary.entry(kind.to_owned()).or_insert_with(|| OmissionSummary {
90            kind: kind.to_owned(),
91            count: 0,
92            visible_count: 0,
93            bbox_pdf: None,
94        });
95        entry.count += 1;
96        if visible {
97            entry.visible_count += 1;
98            entry.bbox_pdf = extent::union(entry.bbox_pdf, bbox);
99        }
100        if self.omissions.len() < MAX_LISTED_OMISSIONS {
101            self.omissions.push(Omission {
102                kind: kind.to_owned(),
103                operator_ordinal: ordinal,
104                bbox_pdf: bbox,
105                visible,
106            });
107        } else {
108            self.truncated = true;
109        }
110    }
111    pub fn finish(self, request_sha256: &str, convertible_paths: usize) -> FidelityReport {
112        let mut summary: Vec<_> = self.summary.into_values().collect();
113        summary.sort_by(|a, b| {
114            b.visible_count
115                .cmp(&a.visible_count)
116                .then(b.count.cmp(&a.count))
117                .then(a.kind.cmp(&b.kind))
118        });
119        let convertible_paths = u32::try_from(convertible_paths).unwrap_or(u32::MAX);
120        let raster_only = convertible_paths == 0 && self.visible_vector == 0 && self.visible_images > 0;
121        let exact = !raster_only && summary.iter().all(|s| s.visible_count == 0);
122        let verdict = Verdict {
123            exact,
124            raster_only,
125            convertible_paths,
126            omitted_paints: self.omitted_paints,
127            summary: &summary,
128        };
129        let mut hash = Sha256::new();
130        hash.update(FIDELITY_ALGORITHM.as_bytes());
131        hash.update(b"\0");
132        hash.update(request_sha256.as_bytes());
133        hash.update(serde_json::to_vec(&verdict).unwrap_or_default());
134        FidelityReport {
135            sha256: format!("{:x}", hash.finalize()),
136            algorithm: FIDELITY_ALGORITHM,
137            exact,
138            raster_only,
139            convertible_paths,
140            omitted_paints: self.omitted_paints,
141            summary,
142            omissions: self.omissions,
143            omissions_truncated: self.truncated,
144        }
145    }
146}
147impl FidelityReport {
148    pub fn visible_omissions(&self) -> u32 {
149        self.summary.iter().map(|s| s.visible_count).sum()
150    }
151    /// Short human sentence for `IfcAnnotation.Description` and messages.
152    /// Kinds are spelled out for people; the JSON `Omissions` property keeps
153    /// the canonical tokens.
154    pub fn describe(&self) -> String {
155        if self.raster_only {
156            return "raster-only page".into();
157        }
158        if self.exact {
159            return "exact conversion".into();
160        }
161        let parts: Vec<String> = self
162            .summary
163            .iter()
164            .filter(|s| s.visible_count > 0)
165            .map(|s| format!("{} {}", s.visible_count, kind_label(&s.kind, s.visible_count)))
166            .collect();
167        format!("partial conversion; omitted {}", parts.join(", "))
168    }
169}
170
171/// Human wording for one omission kind, pluralised by `count`.
172pub fn kind_label(kind: &str, count: u32) -> String {
173    let plural = |one: &str, many: &str| if count == 1 { one } else { many }.to_owned();
174    match kind {
175        "text" => plural("text run", "text runs"),
176        "image" => plural("image", "images"),
177        "clip" => plural("clipped path", "clipped paths"),
178        "transparency" => plural("transparent path", "transparent paths"),
179        "pattern" => plural("pattern paint", "pattern paints"),
180        "dash" => plural("dashed stroke", "dashed strokes"),
181        "roundCapJoin" => plural("round-cap/join stroke", "round-cap/join strokes"),
182        "curvedStroke" => plural("curved stroke", "curved strokes"),
183        "hairline" => plural("hairline stroke", "hairline strokes"),
184        "hidden" => plural("hidden path", "hidden paths"),
185        "annotation" => plural("annotation appearance", "annotation appearances"),
186        other => match other.strip_prefix("unsupported:") {
187            Some(op) => format!("{} under unsupported operator {op}", plural("entry", "entries")),
188            None => other.to_owned(),
189        },
190    }
191}