Skip to main content

oxml_pdf/
conformance.rs

1//! PDF/A profile selection, preflight, and deterministic metadata.
2
3use std::error::Error;
4use std::fmt;
5
6use oxml_layout::{Color, LayoutResult, Paint, PositionedElement};
7
8use crate::font;
9use crate::structure::valid_structure;
10
11pub(crate) const SRGB2014: &[u8] = include_bytes!("../assets/sRGB2014.icc");
12pub(crate) const FILE_IDENTIFIER: &[u8; 16] = b"rdocx-pdfa-00001";
13
14/// An archival conformance level supported by the PDF backend.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum PdfConformance {
17    /// ISO 19005-2, level B.
18    PdfA2b,
19    /// ISO 19005-3, level B.
20    PdfA3b,
21}
22
23impl PdfConformance {
24    pub(crate) const fn part(self) -> u8 {
25        match self {
26            Self::PdfA2b => 2,
27            Self::PdfA3b => 3,
28        }
29    }
30}
31
32/// Options for the explicit archival PDF renderer.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct PdfOptions {
35    /// The required archival profile.
36    pub profile: PdfConformance,
37}
38
39impl PdfOptions {
40    /// Select one supported archival profile.
41    pub const fn new(profile: PdfConformance) -> Self {
42        Self { profile }
43    }
44}
45
46/// A reason an input cannot be represented by the requested PDF/A profile.
47#[derive(Debug, Clone, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum PdfError {
50    /// A shaped run refers to a font that cannot be embedded and subset.
51    MissingEmbeddedFont { font_id: u32 },
52    /// The logical structure tree or one of its marked-content references is invalid.
53    InvalidStructure,
54    /// A link requests an action that the archival path refuses.
55    ForbiddenAction { action: String },
56    /// A color component is non-finite or outside its declared range.
57    UnsupportedColor,
58    /// A paint kind has no archival PDF representation.
59    UnsupportedPaint { paint: &'static str },
60}
61
62impl fmt::Display for PdfError {
63    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            Self::MissingEmbeddedFont { font_id } => {
66                write!(formatter, "font {font_id} cannot be embedded in PDF")
67            }
68            Self::InvalidStructure => formatter.write_str("invalid tagged PDF structure"),
69            Self::ForbiddenAction { action } => {
70                write!(formatter, "action is forbidden in PDF/A output: {action}")
71            }
72            Self::UnsupportedColor => formatter.write_str("unsupported PDF/A color state"),
73            Self::UnsupportedPaint { paint } => {
74                write!(formatter, "unsupported PDF/A paint: {paint}")
75            }
76        }
77    }
78}
79
80impl Error for PdfError {}
81
82pub(crate) fn preflight(
83    layout: &LayoutResult,
84) -> Result<std::collections::BTreeMap<oxml_layout::FontId, font::PreparedFont>, PdfError> {
85    if let Some(structure) = &layout.structure
86        && !valid_structure(structure, &layout.pages)
87    {
88        return Err(PdfError::InvalidStructure);
89    }
90
91    let prepared_fonts = font::prepare_fonts(layout)?;
92
93    for page in &layout.pages {
94        if let Some(background) = &page.background {
95            validate_paint(background)?;
96        }
97        validate_elements(&page.elements)?;
98    }
99    Ok(prepared_fonts)
100}
101
102fn validate_elements(elements: &[PositionedElement]) -> Result<(), PdfError> {
103    for element in elements {
104        match element {
105            PositionedElement::Text(run) => validate_color(run.color)?,
106            PositionedElement::MultilingualText(run) => validate_color(run.color)?,
107            PositionedElement::Line { color, .. } | PositionedElement::FilledRect { color, .. } => {
108                validate_color(*color)?
109            }
110            PositionedElement::LinkAnnotation { url, .. } => {
111                let scheme = url
112                    .split_once(':')
113                    .map(|(scheme, _)| scheme.to_ascii_lowercase());
114                if scheme
115                    .as_deref()
116                    .is_some_and(|scheme| !matches!(scheme, "http" | "https" | "mailto" | "tel"))
117                {
118                    return Err(PdfError::ForbiddenAction {
119                        action: url.clone(),
120                    });
121                }
122            }
123            PositionedElement::Path(path) => {
124                if let Some(fill) = &path.fill {
125                    validate_paint(fill)?;
126                }
127                if let Some(stroke) = &path.stroke {
128                    validate_paint(&stroke.paint)?;
129                }
130            }
131            PositionedElement::Group(group) => {
132                if !unit_component(group.opacity) {
133                    return Err(PdfError::UnsupportedColor);
134                }
135                for effect in &group.effects {
136                    match effect {
137                        oxml_layout::Effect::OuterShadow { color, .. } => validate_color(*color)?,
138                        _ => return Err(PdfError::UnsupportedColor),
139                    }
140                }
141                validate_elements(&group.children)?;
142            }
143            PositionedElement::MarkedContent { children, .. } => validate_elements(children)?,
144            PositionedElement::Image { .. } => {}
145            _ => return Err(PdfError::UnsupportedColor),
146        }
147    }
148    Ok(())
149}
150
151fn validate_paint(paint: &Paint) -> Result<(), PdfError> {
152    match paint {
153        Paint::Solid(color) => validate_color(*color),
154        Paint::Linear { stops, .. } | Paint::Radial { stops, .. } => {
155            for stop in stops {
156                if !unit_component(stop.offset) {
157                    return Err(PdfError::UnsupportedColor);
158                }
159                validate_color(stop.color)?;
160            }
161            Ok(())
162        }
163        Paint::Grid { colors, .. } => {
164            for color in colors {
165                validate_color(*color)?;
166            }
167            Ok(())
168        }
169        Paint::Tile { .. } => Err(PdfError::UnsupportedPaint { paint: "tile" }),
170    }
171}
172
173fn validate_color(color: Color) -> Result<(), PdfError> {
174    if [color.r, color.g, color.b, color.a]
175        .into_iter()
176        .all(unit_component)
177    {
178        Ok(())
179    } else {
180        Err(PdfError::UnsupportedColor)
181    }
182}
183
184fn unit_component(component: f64) -> bool {
185    component.is_finite() && (0.0..=1.0).contains(&component)
186}
187
188pub(crate) fn archival_xmp(
189    layout: &LayoutResult,
190    profile: PdfConformance,
191    declares_pdfua: bool,
192) -> Vec<u8> {
193    let metadata = layout.metadata.as_ref();
194    let title = xml_escape(
195        metadata
196            .and_then(|value| value.title.as_deref())
197            .unwrap_or("Untitled document"),
198    );
199    let author = metadata
200        .and_then(|value| value.author.as_deref())
201        .map(xml_escape);
202    let subject = metadata
203        .and_then(|value| value.subject.as_deref())
204        .map(xml_escape);
205    let keywords = metadata
206        .and_then(|value| value.keywords.as_deref())
207        .map(xml_escape);
208    let creator = xml_escape(
209        metadata
210            .and_then(|value| value.creator.as_deref())
211            .unwrap_or("rdocx-pdf"),
212    );
213    let mut properties = format!(
214        "<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">{title}</rdf:li></rdf:Alt></dc:title>\n"
215    );
216    if let Some(author) = author {
217        properties.push_str(&format!(
218            "<dc:creator><rdf:Seq><rdf:li>{author}</rdf:li></rdf:Seq></dc:creator>\n"
219        ));
220    }
221    if let Some(subject) = subject {
222        properties.push_str(&format!(
223            "<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">{subject}</rdf:li></rdf:Alt></dc:description>\n"
224        ));
225    }
226    if let Some(keywords) = keywords {
227        properties.push_str(&format!("<pdf:Keywords>{keywords}</pdf:Keywords>\n"));
228    }
229    properties.push_str(&format!(
230        "<xmp:CreatorTool>{creator}</xmp:CreatorTool>\n<pdf:Producer>rdocx-pdf</pdf:Producer>\n"
231    ));
232    properties.push_str(&format!(
233        "<pdfaid:part>{}</pdfaid:part>\n<pdfaid:conformance>B</pdfaid:conformance>\n",
234        profile.part()
235    ));
236    if declares_pdfua {
237        properties.push_str("<pdfuaid:part>1</pdfuaid:part>\n");
238    }
239    properties.push_str(
240        "<xmpMM:DocumentID>uuid:72646f63-782d-7064-6661-2d3030303031</xmpMM:DocumentID>\n<xmpMM:InstanceID>uuid:72646f63-782d-7064-6661-2d3030303031</xmpMM:InstanceID>",
241    );
242    let extension = if declares_pdfua {
243        r#"
244<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
245<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">
246<pdfaSchema:schema>PDF/UA identification schema</pdfaSchema:schema>
247<pdfaSchema:namespaceURI>http://www.aiim.org/pdfua/ns/id/</pdfaSchema:namespaceURI>
248<pdfaSchema:prefix>pdfuaid</pdfaSchema:prefix>
249<pdfaSchema:property><rdf:Seq><rdf:li rdf:parseType="Resource">
250<pdfaProperty:name>part</pdfaProperty:name>
251<pdfaProperty:valueType>Integer</pdfaProperty:valueType>
252<pdfaProperty:category>internal</pdfaProperty:category>
253<pdfaProperty:description>PDF/UA version identifier</pdfaProperty:description>
254</rdf:li></rdf:Seq></pdfaSchema:property>
255</rdf:li></rdf:Bag></pdfaExtension:schemas>
256</rdf:Description>"#
257    } else {
258        ""
259    };
260    format!(
261        r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
262<x:xmpmeta xmlns:x="adobe:ns:meta/">
263<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
264<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/">
265{properties}
266</rdf:Description>
267{extension}
268</rdf:RDF>
269</x:xmpmeta>
270<?xpacket end="w"?>"#
271    )
272    .into_bytes()
273}
274
275fn xml_escape(value: &str) -> String {
276    value
277        .replace('&', "&amp;")
278        .replace('<', "&lt;")
279        .replace('>', "&gt;")
280        .replace('"', "&quot;")
281        .replace('\'', "&apos;")
282}