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}