Skip to main content

pdfrum_doc/pdfa/
policy.rs

1//! What the caller decides before the conversion runs, and what the
2//! conversion tells them afterwards.
3//!
4//! # Why these are types and not a bool and a string
5//!
6//! The failure mode is *silent lossy conversion*. A conversion that quietly
7//! drops a font, rasterizes a page or
8//! deletes an annotation has produced a file that passes a validator and is
9//! not the document the caller handed in. The only defence is that every
10//! compromise is (a) authorized in advance and (b) reported afterwards in a
11//! form a program can act on.
12//!
13//! So [`Policy`] is a struct of enums, one per decision the pipeline can face,
14//! and [`Conversion`] carries a `Vec<Compromise>` rather than a formatted
15//! summary. A caller can count them, filter them, refuse to ship a file that
16//! made one of a particular kind, or map them into their own vocabulary — none
17//! of which is possible against a string.
18
19use core::fmt;
20
21use pdfrum_object::ObjRef;
22
23use super::Level;
24
25/// What to do when the document cannot be converted faithfully.
26///
27/// One variant per *kind* of compromise the pipeline knows how to make, plus
28/// [`Concession::Refuse`], which is always available and is the default. The
29/// enum is deliberately not `bool`: "rasterize it" and "drop it" are different
30/// answers with different consequences, and a caller who wants one and not the
31/// other must be able to say so.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
33pub enum Concession {
34    /// Do not convert. The conversion stops and reports why.
35    ///
36    /// The default at every decision point, because a caller who has not
37    /// thought about a compromise has not agreed to it.
38    #[default]
39    Refuse,
40    /// Make the compromise, and record it in [`Conversion::compromises`].
41    Accept,
42}
43
44impl Concession {
45    /// Whether this concession permits the compromise.
46    #[must_use]
47    pub const fn accepts(self) -> bool {
48        matches!(self, Concession::Accept)
49    }
50}
51
52/// What the conversion is permitted to do to a document it cannot convert
53/// faithfully.
54///
55/// Every field defaults to [`Concession::Refuse`], so [`Policy::default()`] is
56/// the strict policy: convert what can be converted losslessly and refuse
57/// anything else. Widening it is an explicit act per axis.
58///
59/// The axes are separate because a caller's answers genuinely differ between
60/// them — an archive may accept a substituted font (the text stays text, and
61/// stays searchable) while refusing a rasterized page (the text stops being
62/// text at all).
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64#[non_exhaustive]
65pub struct Policy {
66    /// A font that is used for rendering, is not embedded, and whose program
67    /// we do not have.
68    ///
69    /// Refusing stops the conversion and names the font, because an unembedded
70    /// font is a hard PDF/A failure that no amount of other repair will fix.
71    ///
72    /// Accepting is meant to substitute a standard font of the same broad
73    /// shape. That repair is not implemented yet, so
74    /// today accepting means "convert as far as you can": every other repair
75    /// applies, the font stays unembedded, and no [`Compromise`] is reported
76    /// because nothing was compromised. It is the honest reading of `Accept`
77    /// until the substitution lands.
78    pub unembeddable_font: Concession,
79    /// Content the level forbids outright and that cannot be rewritten —
80    /// transparency under A-1b being the case.
81    ///
82    /// Refusing stops the conversion and names the page. Only A-1b can reach
83    /// this: A-2b permits transparency, so nothing there is unrepresentable.
84    ///
85    /// Accepting is meant to rasterize the offending page — faithful to the
86    /// *appearance*, and destroying the text, the vectors and the
87    /// selectability. That repair is not implemented yet, so accepting reads as "convert as far as you can" exactly as
88    /// [`Policy::unembeddable_font`] does, and
89    /// [`Conversion::rasterized_pages`] is always empty for now.
90    pub unrepresentable_content: Concession,
91    /// An annotation or action of a kind PDF/A forbids.
92    ///
93    /// Accepting removes it — the JavaScript, the `/Launch` action, the
94    /// `/Movie` annotation. Refusing stops the conversion. This is separated
95    /// from the two above because it is the one most callers *do* want: a
96    /// launch action in an archived file is a liability, not content, and
97    /// stripping it is the point of archiving rather than a loss.
98    ///
99    /// It also covers one repair that *adds* rather than removes: PDF/A
100    /// requires every annotation be visible and printable, so an annotation
101    /// hidden by its `/F` flags has them cleared and becomes visible. That
102    /// changes what the page draws, which is why it needs permission and
103    /// reports [`Compromise::AnnotationFlagsChanged`].
104    pub forbidden_feature: Concession,
105}
106
107impl Policy {
108    /// Refuse every compromise: convert only what converts losslessly.
109    ///
110    /// The same as [`Policy::default()`], named so a caller can say what they
111    /// mean at the call site.
112    #[must_use]
113    pub const fn strict() -> Self {
114        Self {
115            unembeddable_font: Concession::Refuse,
116            unrepresentable_content: Concession::Refuse,
117            forbidden_feature: Concession::Refuse,
118        }
119    }
120
121    /// Accept every compromise the pipeline knows how to make.
122    ///
123    /// The archivist's policy: produce a conforming file and tell me what it
124    /// cost. Every compromise still lands in [`Conversion::compromises`] — the
125    /// difference from [`Policy::strict`] is what stops the conversion, never
126    /// what goes unreported.
127    #[must_use]
128    pub const fn lossy() -> Self {
129        Self {
130            unembeddable_font: Concession::Accept,
131            unrepresentable_content: Concession::Accept,
132            forbidden_feature: Concession::Accept,
133        }
134    }
135
136    /// This policy with [`Policy::unembeddable_font`] set.
137    ///
138    /// The struct is `#[non_exhaustive]` so that a concession added later is
139    /// not a breaking change for callers — which also means a caller cannot
140    /// write a struct literal. These three are how a policy is built from one
141    /// of the two named starting points:
142    ///
143    /// ```
144    /// use pdfrum_doc::pdfa::{Concession, Policy};
145    ///
146    /// // Strip what PDF/A forbids, but never substitute a font behind my back.
147    /// let policy = Policy::strict().forbidden_feature(Concession::Accept);
148    /// assert!(policy.forbidden_feature.accepts());
149    /// assert!(!policy.unembeddable_font.accepts());
150    /// ```
151    #[must_use]
152    pub const fn unembeddable_font(mut self, concession: Concession) -> Self {
153        self.unembeddable_font = concession;
154        self
155    }
156
157    /// This policy with [`Policy::unrepresentable_content`] set.
158    #[must_use]
159    pub const fn unrepresentable_content(mut self, concession: Concession) -> Self {
160        self.unrepresentable_content = concession;
161        self
162    }
163
164    /// This policy with [`Policy::forbidden_feature`] set.
165    #[must_use]
166    pub const fn forbidden_feature(mut self, concession: Concession) -> Self {
167        self.forbidden_feature = concession;
168        self
169    }
170}
171
172/// One thing the conversion did that changed the document.
173///
174/// Every variant carries the object or page it happened to, for the same
175/// reason [`Subject`](crate::pdfa::Subject) does: a caller that wants to show the user
176/// what changed needs to reach the thing, and a sentence cannot be turned back
177/// into an `ObjRef`.
178///
179/// This is the *complete* list of ways the conversion can alter meaning. A
180/// repair that changes nothing a reader would notice — minting a `/ID`,
181/// writing an output intent, rewriting the XMP packet — is not a compromise
182/// and does not appear here.
183#[derive(Debug, Clone, PartialEq, Eq)]
184#[non_exhaustive]
185pub enum Compromise {
186    /// A font's program was not in the file and could not be obtained, so a
187    /// standard font was substituted. The glyphs the page draws change.
188    ///
189    /// **Never produced yet**, like [`Compromise::PageRasterized`]: the
190    /// substitution behind [`Policy::unembeddable_font`] is not implemented.
191    /// Both are here rather than added with their repairs because each
192    /// completes a triple that *is* live — the policy field, the [`Refusal`]
193    /// a caller gets today, and the compromise they will get instead. A
194    /// caller writes the match arm once.
195    FontSubstituted {
196        /// The font dictionary that was rewritten.
197        font: ObjRef,
198        /// The `/BaseFont` name it had.
199        base_name: String,
200        /// The standard font put in its place.
201        substitute: &'static str,
202    },
203    /// An action was removed: JavaScript, or one of the kinds the level
204    /// forbids by name.
205    ActionRemoved {
206        /// The object the action hung off — a catalog, an annotation, a field.
207        holder: ObjRef,
208        /// The `/S` subtype removed, as PDF spells it.
209        kind: String,
210    },
211    /// An annotation was removed because its subtype is not permitted.
212    AnnotationRemoved {
213        /// The annotation dictionary.
214        annotation: ObjRef,
215        /// The page it was on.
216        page: u32,
217        /// Its `/Subtype`.
218        subtype: String,
219    },
220    /// An annotation was kept but its flag word was corrected, so it now
221    /// prints and is visible where it was not.
222    AnnotationFlagsChanged {
223        /// The annotation dictionary.
224        annotation: ObjRef,
225        /// The page it is on.
226        page: u32,
227    },
228    /// A page was rendered to an image and rebuilt around it. Its text is no
229    /// longer text and its vectors are no longer vectors.
230    ///
231    /// **Never produced yet**: the rasterizing repair is not implemented.
232    /// The variant is part of the vocabulary because
233    /// [`Conversion::rasterized_pages`] reports which pages changed, and a
234    /// caller writes that match arm once, not when the repair lands.
235    PageRasterized {
236        /// The page, zero-based.
237        page: u32,
238        /// The resolution it was rendered at, in whole dots per inch.
239        ///
240        /// An integer newtype rather than an `f32`: a fractional render
241        /// resolution is not a thing a caller asks for, and `f32` would cost
242        /// this whole report its `Eq`.
243        dpi: Dpi,
244        /// Why the page could not be kept as content.
245        cause: RasterCause,
246    },
247    /// An embedded file was removed. A-1b forbids them outright.
248    EmbeddedFileRemoved {
249        /// The name tree entry, when the file specification is indirect.
250        file: Option<ObjRef>,
251        /// The file's name, as the name tree spelled it.
252        name: String,
253    },
254    /// Optional content was removed, so what was conditionally visible is now
255    /// unconditionally so. A-1b forbids `/OCProperties`.
256    OptionalContentRemoved {
257        /// The catalog the `/OCProperties` hung off.
258        catalog: ObjRef,
259    },
260    /// A `/Metadata` packet on an object other than the catalog was removed.
261    ///
262    /// PDF/A's schema rules apply to every packet in the file, and a packet on
263    /// an image describing its camera or its rights holder usually uses
264    /// schemas PDF/A does not predefine. Nothing renders differently without
265    /// it — it is descriptive metadata, not content — but it is information
266    /// the file no longer carries, so the caller is told.
267    ObjectMetadataRemoved {
268        /// The object it hung off.
269        object: ObjRef,
270    },
271}
272
273/// A render resolution, in whole dots per inch.
274///
275/// A newtype because asks for one on a unit-bearing scalar, and
276/// because it is what lets [`Conversion`] keep `Eq` — a report a caller cannot
277/// compare for equality is a report they cannot write a test against.
278#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
279pub struct Dpi(pub u16);
280
281impl fmt::Display for Dpi {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        write!(f, "{} dpi", self.0)
284    }
285}
286
287/// Why a page had to be rasterized rather than repaired.
288///
289/// A separate enum rather than a string on [`Compromise::PageRasterized`],
290/// because the two causes are acted on differently: a caller converting to
291/// A-1b who sees [`RasterCause::Transparency`] can reasonably retry at A-2b
292/// and keep the vectors, and a program should be able to notice that without
293/// matching on prose.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
295#[non_exhaustive]
296pub enum RasterCause {
297    /// The page uses transparency and the level forbids it. A-1b only: A-2b
298    /// permits transparency, so this cause cannot arise there.
299    Transparency,
300    /// The page draws with a font whose program is not available and which
301    /// could not be substituted.
302    UnembeddableFont,
303}
304
305impl fmt::Display for RasterCause {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        f.write_str(match self {
308            RasterCause::Transparency => "the page uses transparency, which this level forbids",
309            RasterCause::UnembeddableFont => "the page draws with a font that cannot be embedded",
310        })
311    }
312}
313
314impl fmt::Display for Compromise {
315    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
316        match self {
317            Compromise::FontSubstituted {
318                base_name,
319                substitute,
320                ..
321            } => write!(
322                f,
323                "substituted {substitute} for the unembedded /{base_name}"
324            ),
325            Compromise::ActionRemoved { kind, .. } => write!(f, "removed a /{kind} action"),
326            Compromise::AnnotationRemoved { page, subtype, .. } => {
327                write!(f, "removed a /{subtype} annotation from page {}", page + 1)
328            }
329            Compromise::AnnotationFlagsChanged { page, .. } => {
330                write!(f, "corrected an annotation's flags on page {}", page + 1)
331            }
332            Compromise::PageRasterized { page, dpi, cause } => {
333                write!(f, "rasterized page {} at {dpi}: {cause}", page + 1)
334            }
335            Compromise::EmbeddedFileRemoved { name, .. } => {
336                write!(f, "removed the embedded file {name}")
337            }
338            Compromise::OptionalContentRemoved { .. } => {
339                f.write_str("removed the optional-content configuration")
340            }
341            Compromise::ObjectMetadataRemoved { object } => write!(
342                f,
343                "removed the XMP packet on object {} {}",
344                object.num, object.generation
345            ),
346        }
347    }
348}
349
350/// Why a conversion refused.
351///
352/// The mirror of [`Compromise`]: each variant is a compromise the pipeline
353/// would have had to make and the policy did not authorize. A caller that gets
354/// one of these knows exactly which [`Policy`] field to widen, which is why
355/// the refusal names the concession rather than describing the problem.
356#[derive(Debug, Clone, PartialEq, Eq)]
357#[non_exhaustive]
358pub enum Refusal {
359    /// A font is used for rendering, is not embedded, and its program was not
360    /// found. Widen [`Policy::unembeddable_font`].
361    UnembeddableFont {
362        /// The font dictionary.
363        font: ObjRef,
364        /// Its `/BaseFont` name.
365        base_name: String,
366    },
367    /// A page carries content the level forbids and that cannot be rewritten.
368    /// Widen [`Policy::unrepresentable_content`].
369    UnrepresentableContent {
370        /// The page, zero-based.
371        page: u32,
372        /// What would have forced the rasterization.
373        cause: RasterCause,
374    },
375    /// The document carries a feature PDF/A forbids — JavaScript, a `/Launch`
376    /// action, a `/Movie` annotation. Widen [`Policy::forbidden_feature`].
377    ForbiddenFeature {
378        /// The object carrying it.
379        holder: ObjRef,
380        /// What it is, as PDF spells it.
381        kind: String,
382    },
383}
384
385impl fmt::Display for Refusal {
386    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387        match self {
388            Refusal::UnembeddableFont { base_name, .. } => write!(
389                f,
390                "/{base_name} is not embedded and its program was not found; \
391                 set Policy::unembeddable_font to accept a substitute"
392            ),
393            Refusal::UnrepresentableContent { page, cause } => write!(
394                f,
395                "page {}: {cause}; set Policy::unrepresentable_content to \
396                 accept a rasterized page",
397                page + 1
398            ),
399            Refusal::ForbiddenFeature { kind, .. } => write!(
400                f,
401                "the document carries {kind}, which PDF/A forbids; set \
402                 Policy::forbidden_feature to accept its removal"
403            ),
404        }
405    }
406}
407
408/// What a conversion produced, and what it cost.
409///
410/// Returned by the facade's `Document::to_pdfa` whether or not the conversion
411/// succeeded: [`Conversion::refusals`] non-empty means no bytes were written
412/// and the file is unchanged, and an empty `refusals` with a non-empty
413/// [`Conversion::compromises`] means a file was written that is not the
414/// document that went in.
415///
416/// The three states are distinguishable without inspecting the byte count,
417/// which is the point — see [`Conversion::converted`].
418#[derive(Debug, Clone, PartialEq, Eq)]
419pub struct Conversion {
420    /// The level converted to.
421    pub level: Level,
422    /// Everything the conversion did that changed the document's meaning, in
423    /// the order the pipeline did it.
424    ///
425    /// Empty means the conversion was faithful: the file that came out draws
426    /// what the file that went in drew.
427    pub compromises: Vec<Compromise>,
428    /// Every compromise the policy did not authorize.
429    ///
430    /// Non-empty means **nothing was written**. The conversion collects all of
431    /// them rather than stopping at the first, so a caller can widen the
432    /// policy once instead of discovering the obstacles one run at a time.
433    pub refusals: Vec<Refusal>,
434}
435
436impl Conversion {
437    /// Whether a file was written.
438    ///
439    /// False exactly when [`Conversion::refusals`] is non-empty.
440    #[must_use]
441    pub fn converted(&self) -> bool {
442        self.refusals.is_empty()
443    }
444
445    /// Whether a file was written and it draws what the input drew.
446    #[must_use]
447    pub fn faithful(&self) -> bool {
448        self.converted() && self.compromises.is_empty()
449    }
450
451    /// The pages that were rendered to images, in page order.
452    ///
453    /// The one compromise whose *extent* a caller almost always wants
454    /// separately from the list, because it is the one that changes what a
455    /// page fundamentally is.
456    #[must_use]
457    pub fn rasterized_pages(&self) -> Vec<u32> {
458        let mut pages: Vec<u32> = self
459            .compromises
460            .iter()
461            .filter_map(|c| match c {
462                Compromise::PageRasterized { page, .. } => Some(*page),
463                _ => None,
464            })
465            .collect();
466        pages.sort_unstable();
467        pages.dedup();
468        pages
469    }
470}
471
472impl fmt::Display for Conversion {
473    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
474        if !self.converted() {
475            write!(f, "{} refused: ", self.level)?;
476            for (i, refusal) in self.refusals.iter().enumerate() {
477                if i > 0 {
478                    f.write_str("; ")?;
479                }
480                write!(f, "{refusal}")?;
481            }
482            return Ok(());
483        }
484        if self.compromises.is_empty() {
485            return write!(f, "{} conversion, faithful", self.level);
486        }
487        write!(
488            f,
489            "{} conversion with {} compromise(s)",
490            self.level,
491            self.compromises.len()
492        )
493    }
494}