1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum PdfConformance {
17 PdfA2b,
19 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct PdfOptions {
35 pub profile: PdfConformance,
37}
38
39impl PdfOptions {
40 pub const fn new(profile: PdfConformance) -> Self {
42 Self { profile }
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum PdfError {
50 MissingEmbeddedFont { font_id: u32 },
52 InvalidStructure,
54 ForbiddenAction { action: String },
56 UnsupportedColor,
58 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 for PDF/A")
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(layout: &LayoutResult) -> Result<(), PdfError> {
83 if let Some(structure) = &layout.structure
84 && !valid_structure(structure, &layout.pages)
85 {
86 return Err(PdfError::InvalidStructure);
87 }
88
89 let mut usage = font::collect_glyph_usage(layout);
90 let fonts = layout
91 .fonts
92 .iter()
93 .map(|font| (font.id, font))
94 .collect::<std::collections::HashMap<_, _>>();
95 for (font_id, font_usage) in &mut usage {
96 let Some(font_data) = fonts.get(font_id) else {
97 return Err(PdfError::MissingEmbeddedFont { font_id: font_id.0 });
98 };
99 if font::prepare_font(font_data, font_usage).is_none() {
100 return Err(PdfError::MissingEmbeddedFont { font_id: font_id.0 });
101 }
102 }
103
104 for page in &layout.pages {
105 if let Some(background) = &page.background {
106 validate_paint(background)?;
107 }
108 validate_elements(&page.elements)?;
109 }
110 Ok(())
111}
112
113fn validate_elements(elements: &[PositionedElement]) -> Result<(), PdfError> {
114 for element in elements {
115 match element {
116 PositionedElement::Text(run) => validate_color(run.color)?,
117 PositionedElement::MultilingualText(run) => validate_color(run.color)?,
118 PositionedElement::Line { color, .. } | PositionedElement::FilledRect { color, .. } => {
119 validate_color(*color)?
120 }
121 PositionedElement::LinkAnnotation { url, .. } => {
122 let scheme = url
123 .split_once(':')
124 .map(|(scheme, _)| scheme.to_ascii_lowercase());
125 if scheme
126 .as_deref()
127 .is_some_and(|scheme| !matches!(scheme, "http" | "https" | "mailto" | "tel"))
128 {
129 return Err(PdfError::ForbiddenAction {
130 action: url.clone(),
131 });
132 }
133 }
134 PositionedElement::Path(path) => {
135 if let Some(fill) = &path.fill {
136 validate_paint(fill)?;
137 }
138 if let Some(stroke) = &path.stroke {
139 validate_paint(&stroke.paint)?;
140 }
141 }
142 PositionedElement::Group(group) => {
143 if !unit_component(group.opacity) {
144 return Err(PdfError::UnsupportedColor);
145 }
146 for effect in &group.effects {
147 match effect {
148 oxml_layout::Effect::OuterShadow { color, .. } => validate_color(*color)?,
149 _ => return Err(PdfError::UnsupportedColor),
150 }
151 }
152 validate_elements(&group.children)?;
153 }
154 PositionedElement::MarkedContent { children, .. } => validate_elements(children)?,
155 PositionedElement::Image { .. } => {}
156 _ => return Err(PdfError::UnsupportedColor),
157 }
158 }
159 Ok(())
160}
161
162fn validate_paint(paint: &Paint) -> Result<(), PdfError> {
163 match paint {
164 Paint::Solid(color) => validate_color(*color),
165 Paint::Linear { stops, .. } | Paint::Radial { stops, .. } => {
166 for stop in stops {
167 if !unit_component(stop.offset) {
168 return Err(PdfError::UnsupportedColor);
169 }
170 validate_color(stop.color)?;
171 }
172 Ok(())
173 }
174 Paint::Tile { .. } => Err(PdfError::UnsupportedPaint { paint: "tile" }),
175 }
176}
177
178fn validate_color(color: Color) -> Result<(), PdfError> {
179 if [color.r, color.g, color.b, color.a]
180 .into_iter()
181 .all(unit_component)
182 {
183 Ok(())
184 } else {
185 Err(PdfError::UnsupportedColor)
186 }
187}
188
189fn unit_component(component: f64) -> bool {
190 component.is_finite() && (0.0..=1.0).contains(&component)
191}
192
193pub(crate) fn archival_xmp(
194 layout: &LayoutResult,
195 profile: PdfConformance,
196 declares_pdfua: bool,
197) -> Vec<u8> {
198 let metadata = layout.metadata.as_ref();
199 let title = xml_escape(
200 metadata
201 .and_then(|value| value.title.as_deref())
202 .unwrap_or("Untitled document"),
203 );
204 let author = metadata
205 .and_then(|value| value.author.as_deref())
206 .map(xml_escape);
207 let subject = metadata
208 .and_then(|value| value.subject.as_deref())
209 .map(xml_escape);
210 let keywords = metadata
211 .and_then(|value| value.keywords.as_deref())
212 .map(xml_escape);
213 let creator = xml_escape(
214 metadata
215 .and_then(|value| value.creator.as_deref())
216 .unwrap_or("rdocx-pdf"),
217 );
218 let mut properties = format!(
219 "<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">{title}</rdf:li></rdf:Alt></dc:title>\n"
220 );
221 if let Some(author) = author {
222 properties.push_str(&format!(
223 "<dc:creator><rdf:Seq><rdf:li>{author}</rdf:li></rdf:Seq></dc:creator>\n"
224 ));
225 }
226 if let Some(subject) = subject {
227 properties.push_str(&format!(
228 "<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">{subject}</rdf:li></rdf:Alt></dc:description>\n"
229 ));
230 }
231 if let Some(keywords) = keywords {
232 properties.push_str(&format!("<pdf:Keywords>{keywords}</pdf:Keywords>\n"));
233 }
234 properties.push_str(&format!(
235 "<xmp:CreatorTool>{creator}</xmp:CreatorTool>\n<pdf:Producer>rdocx-pdf</pdf:Producer>\n"
236 ));
237 properties.push_str(&format!(
238 "<pdfaid:part>{}</pdfaid:part>\n<pdfaid:conformance>B</pdfaid:conformance>\n",
239 profile.part()
240 ));
241 if declares_pdfua {
242 properties.push_str("<pdfuaid:part>1</pdfuaid:part>\n");
243 }
244 properties.push_str(
245 "<xmpMM:DocumentID>uuid:72646f63-782d-7064-6661-2d3030303031</xmpMM:DocumentID>\n<xmpMM:InstanceID>uuid:72646f63-782d-7064-6661-2d3030303031</xmpMM:InstanceID>",
246 );
247 let extension = if declares_pdfua {
248 r#"
249<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#">
250<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">
251<pdfaSchema:schema>PDF/UA identification schema</pdfaSchema:schema>
252<pdfaSchema:namespaceURI>http://www.aiim.org/pdfua/ns/id/</pdfaSchema:namespaceURI>
253<pdfaSchema:prefix>pdfuaid</pdfaSchema:prefix>
254<pdfaSchema:property><rdf:Seq><rdf:li rdf:parseType="Resource">
255<pdfaProperty:name>part</pdfaProperty:name>
256<pdfaProperty:valueType>Integer</pdfaProperty:valueType>
257<pdfaProperty:category>internal</pdfaProperty:category>
258<pdfaProperty:description>PDF/UA version identifier</pdfaProperty:description>
259</rdf:li></rdf:Seq></pdfaSchema:property>
260</rdf:li></rdf:Bag></pdfaExtension:schemas>
261</rdf:Description>"#
262 } else {
263 ""
264 };
265 format!(
266 r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
267<x:xmpmeta xmlns:x="adobe:ns:meta/">
268<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
269<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/">
270{properties}
271</rdf:Description>
272{extension}
273</rdf:RDF>
274</x:xmpmeta>
275<?xpacket end="w"?>"#
276 )
277 .into_bytes()
278}
279
280fn xml_escape(value: &str) -> String {
281 value
282 .replace('&', "&")
283 .replace('<', "<")
284 .replace('>', ">")
285 .replace('"', """)
286 .replace('\'', "'")
287}