Skip to main content

fop_render/pdf/
compliance.rs

1//! PDF compliance modes (PDF/A-1b and PDF/UA-1)
2//!
3//! Implements ISO 19005-1 (PDF/A-1b) and ISO 14289-1 (PDF/UA-1) compliance.
4
5/// PDF compliance mode
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum PdfCompliance {
8    /// Standard PDF (no special compliance)
9    #[default]
10    Standard,
11    /// PDF/A-1b — archival format (ISO 19005-1)
12    PdfA1b,
13    /// PDF/UA-1 — accessible format (ISO 14289-1)
14    PdfUA1,
15    /// Both PDF/A-1b and PDF/UA-1
16    PdfA1bUA1,
17}
18
19impl PdfCompliance {
20    /// Whether this mode requires PDF/A-1b compliance
21    pub fn requires_pdfa(&self) -> bool {
22        matches!(self, PdfCompliance::PdfA1b | PdfCompliance::PdfA1bUA1)
23    }
24
25    /// Whether this mode requires PDF/UA-1 compliance
26    pub fn requires_pdfua(&self) -> bool {
27        matches!(self, PdfCompliance::PdfUA1 | PdfCompliance::PdfA1bUA1)
28    }
29}
30
31/// Minimal sRGB ICC profile (v2, 476 bytes).
32///
33/// This is a valid, conformant sRGB ICC profile suitable for use as
34/// an OutputIntent in PDF/A-1b documents. It encodes the IEC 61966-2-1
35/// sRGB colour space at a minimal size.
36///
37/// Layout (476 bytes total, declared in header bytes 0-3):
38/// - Header:        128 bytes (offsets   0–127)
39/// - Tag count:       4 bytes (offsets 128–131) = 9 tags
40/// - Tag table:     108 bytes (offsets 132–239) = 9 × 12 bytes
41/// - desc data:     100 bytes (offsets 240–339, 0x00F0–0x0153)
42/// - cprt data:      42 bytes (offsets 340–381, 0x0154–0x017D)
43/// - wtpt data:      20 bytes (offsets 382–401, 0x017E–0x0191)
44/// - rXYZ data:      20 bytes (offsets 402–421, 0x0192–0x01A5)
45/// - gXYZ data:      20 bytes (offsets 422–441, 0x01A6–0x01B9)
46/// - bXYZ data:      20 bytes (offsets 442–461, 0x01BA–0x01CD)
47/// - TRC data:       14 bytes (offsets 462–475, 0x01CE–0x01DB) shared by r/g/bTRC
48pub const SRGB_ICC_PROFILE: &[u8] = &[
49    // ── ICC profile header (128 bytes, offsets 0–127) ───────────────────────
50    0x00, 0x00, 0x01, 0xDC, // [0]  profile size = 476 (0x01DC)
51    0x00, 0x00, 0x00, 0x00, // [4]  CMM type (none)
52    0x02, 0x10, 0x00, 0x00, // [8]  profile version 2.1.0
53    0x6D, 0x6E, 0x74, 0x72, // [12] profile class: mntr (display device profile)
54    0x52, 0x47, 0x42, 0x20, // [16] colour space: RGB
55    0x58, 0x59, 0x5A, 0x20, // [20] PCS: XYZ
56    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // [24] date/time
57    0x61, 0x63, 0x73, 0x70, // [36] file signature: acsp
58    0x4D, 0x53, 0x46, 0x54, // [40] platform: MSFT
59    0x00, 0x00, 0x00, 0x00, // [44] profile flags
60    0x49, 0x45, 0x43, 0x20, // [48] device manufacturer: IEC
61    0x73, 0x52, 0x47, 0x42, // [52] device model: sRGB
62    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // [56] device attributes
63    0x00, 0x00, 0x00, 0x02, // [64] rendering intent: perceptual
64    // [68] PCS illuminant (D50 in s15Fixed16)
65    0x00, 0x00, 0xF6, 0xD6, // X = 0.9642
66    0x00, 0x01, 0x00, 0x00, // Y = 1.0000
67    0x00, 0x00, 0xD3, 0x2D, // Z = 0.8249
68    0x48, 0x50, 0x20, 0x20, // [80] profile creator: HP
69    // [84] MD5 profile ID (zeroed — optional for PDF/A)
70    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
71    // [100] reserved (28 bytes)
72    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
73    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
74    // ── Tag count (4 bytes, offset 128) ─────────────────────────────────────
75    0x00, 0x00, 0x00, 0x09, // 9 tags
76    // ── Tag table (9 × 12 = 108 bytes, offsets 132–239) ─────────────────────
77    // Each entry: 4-byte signature, 4-byte offset, 4-byte size
78    // desc  @ offset 0x00F0 (240), size 0x64 (100)
79    0x64, 0x65, 0x73, 0x63, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x64,
80    // cprt  @ offset 0x0154 (340), size 0x2A (42)
81    0x63, 0x70, 0x72, 0x74, 0x00, 0x00, 0x01, 0x54, 0x00, 0x00, 0x00, 0x2A,
82    // wtpt  @ offset 0x017E (382), size 0x14 (20)
83    0x77, 0x74, 0x70, 0x74, 0x00, 0x00, 0x01, 0x7E, 0x00, 0x00, 0x00, 0x14,
84    // rXYZ  @ offset 0x0192 (402), size 0x14 (20)
85    0x72, 0x58, 0x59, 0x5A, 0x00, 0x00, 0x01, 0x92, 0x00, 0x00, 0x00, 0x14,
86    // gXYZ  @ offset 0x01A6 (422), size 0x14 (20)
87    0x67, 0x58, 0x59, 0x5A, 0x00, 0x00, 0x01, 0xA6, 0x00, 0x00, 0x00, 0x14,
88    // bXYZ  @ offset 0x01BA (442), size 0x14 (20)
89    0x62, 0x58, 0x59, 0x5A, 0x00, 0x00, 0x01, 0xBA, 0x00, 0x00, 0x00, 0x14,
90    // rTRC  @ offset 0x01CE (462), size 0x0E (14)  ← all three TRC share this
91    0x72, 0x54, 0x52, 0x43, 0x00, 0x00, 0x01, 0xCE, 0x00, 0x00, 0x00, 0x0E,
92    // gTRC  @ offset 0x01CE (462), size 0x0E (14)
93    0x67, 0x54, 0x52, 0x43, 0x00, 0x00, 0x01, 0xCE, 0x00, 0x00, 0x00, 0x0E,
94    // bTRC  @ offset 0x01CE (462), size 0x0E (14)
95    0x62, 0x54, 0x52, 0x43, 0x00, 0x00, 0x01, 0xCE, 0x00, 0x00, 0x00, 0x0E,
96    // ── desc tag data (offset 240 = 0x00F0, size 100 = 0x64) ────────────────
97    0x64, 0x65, 0x73, 0x63, // type signature: desc
98    0x00, 0x00, 0x00, 0x00, // reserved
99    0x00, 0x00, 0x00, 0x13, // ASCII string length = 19
100    0x73, 0x52, 0x47, 0x42, 0x20, 0x49, 0x45, 0x43, // "sRGB IEC"
101    0x36, 0x31, 0x39, 0x36, 0x36, 0x2D, 0x32, 0x2D, // "61966-2-"
102    0x31, 0x00, 0x00, // "1" + 2-byte null terminator
103    // padding: type(4)+reserved(4)+len(4)+string17(17)+null2(2) = 31 used → 69 pad bytes needed
104    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
105    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
106    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
107    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
108    0x00, 0x00, 0x00, 0x00, 0x00,
109    // ── cprt tag data (offset 340 = 0x0154, size 42 = 0x2A) ─────────────────
110    0x74, 0x65, 0x78, 0x74, // type signature: text
111    0x00, 0x00, 0x00, 0x00, // reserved
112    // "Copyright IEC http://www.iec.ch" + null (32 bytes) + 2 bytes padding = 34 bytes
113    0x43, 0x6F, 0x70, 0x79, 0x72, 0x69, 0x67, 0x68, // "Copyrigh"
114    0x74, 0x20, 0x49, 0x45, 0x43, 0x20, 0x68, 0x74, // "t IEC ht"
115    0x74, 0x70, 0x3A, 0x2F, 0x2F, 0x77, 0x77, 0x77, // "tp://www"
116    0x2E, 0x69, 0x65, 0x63, 0x2E, 0x63, 0x68, 0x00, // ".iec.ch\0"
117    0x00, 0x00, // 2-byte pad to 42 total
118    // ── wtpt tag data (offset 382 = 0x017E, size 20 = 0x14) ─────────────────
119    // D50 white point in s15Fixed16: X=0.9642, Y=1.0000, Z=0.8249
120    0x58, 0x59, 0x5A, 0x20, // type: XYZ
121    0x00, 0x00, 0x00, 0x00, // reserved
122    0x00, 0x00, 0xF6, 0xD6, // X = 0.9642
123    0x00, 0x01, 0x00, 0x00, // Y = 1.0000
124    0x00, 0x00, 0xD3, 0x2D, // Z = 0.8249
125    // ── rXYZ tag data (offset 402 = 0x0192, size 20 = 0x14) ─────────────────
126    // sRGB red primary
127    0x58, 0x59, 0x5A, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6E, 0xA2, // X = 0.4361
128    0x00, 0x00, 0x38, 0xF2, // Y = 0.2225
129    0x00, 0x00, 0x03, 0x90, // Z = 0.0139
130    // ── gXYZ tag data (offset 422 = 0x01A6, size 20 = 0x14) ─────────────────
131    // sRGB green primary
132    0x58, 0x59, 0x5A, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x62, 0x99, // X = 0.3851
133    0x00, 0x00, 0xB7, 0x85, // Y = 0.7169
134    0x00, 0x00, 0x18, 0xDA, // Z = 0.0971
135    // ── bXYZ tag data (offset 442 = 0x01BA, size 20 = 0x14) ─────────────────
136    // sRGB blue primary
137    0x58, 0x59, 0x5A, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x0E, // X = 0.0938
138    0x00, 0x00, 0x0B, 0xA3, // Y = 0.0454
139    0x00, 0x00, 0xB6, 0xCF, // Z = 0.7142
140    // ── TRC tag data (offset 462 = 0x01CE, size 14 = 0x0E) ──────────────────
141    // Shared by rTRC, gTRC, bTRC.  Single gamma value ≈ 2.2 (563/256).
142    0x63, 0x75, 0x72, 0x76, // type: curv
143    0x00, 0x00, 0x00, 0x00, // reserved
144    0x00, 0x00, 0x00, 0x01, // count = 1 (single gamma entry)
145    0x02,
146    0x33, // gamma = 563/256 ≈ 2.20
147          // ── END (total 476 bytes = 0x01DC) ──────────────────────────────────────
148];
149
150/// Generate XMP metadata stream for PDF/A compliance.
151///
152/// Returns the complete XMP packet as a UTF-8 string, including the required
153/// Adobe XML namespace declarations and optional PDF/A and PDF/UA identifiers.
154pub fn generate_xmp_metadata(
155    title: Option<&str>,
156    creator_tool: &str,
157    compliance: PdfCompliance,
158) -> String {
159    let title_str = title.unwrap_or("Untitled");
160
161    let pdfa_part = if compliance.requires_pdfa() {
162        r#"  <rdf:Description rdf:about=""
163     xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
164   <pdfaid:part>1</pdfaid:part>
165   <pdfaid:conformance>B</pdfaid:conformance>
166  </rdf:Description>
167"#
168    } else {
169        ""
170    };
171
172    let pdfua_part = if compliance.requires_pdfua() {
173        r#"  <rdf:Description rdf:about=""
174     xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/">
175   <pdfuaid:part>1</pdfuaid:part>
176  </rdf:Description>
177"#
178    } else {
179        ""
180    };
181
182    format!(
183        "<?xpacket begin=\"\u{FEFF}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
184<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n \
185<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n  \
186<rdf:Description rdf:about=\"\"\n     \
187xmlns:dc=\"http://purl.org/dc/elements/1.1/\"\n     \
188xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\">\n   \
189<dc:title>\n    <rdf:Alt>\n     \
190<rdf:li xml:lang=\"x-default\">{title}</rdf:li>\n    \
191</rdf:Alt>\n   </dc:title>\n   \
192<dc:format>application/pdf</dc:format>\n   \
193<xmp:CreatorTool>{tool}</xmp:CreatorTool>\n  \
194</rdf:Description>\n\
195{pdfa}{pdfua}\
196</rdf:RDF>\n\
197</x:xmpmeta>\n\
198<?xpacket end=\"w\"?>",
199        title = title_str,
200        tool = creator_tool,
201        pdfa = pdfa_part,
202        pdfua = pdfua_part,
203    )
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_compliance_default() {
212        let c = PdfCompliance::default();
213        assert_eq!(c, PdfCompliance::Standard);
214    }
215
216    #[test]
217    fn test_compliance_pdfa_flags() {
218        assert!(PdfCompliance::PdfA1b.requires_pdfa());
219        assert!(!PdfCompliance::PdfA1b.requires_pdfua());
220        assert!(PdfCompliance::PdfA1bUA1.requires_pdfa());
221        assert!(PdfCompliance::PdfA1bUA1.requires_pdfua());
222        assert!(!PdfCompliance::Standard.requires_pdfa());
223    }
224
225    #[test]
226    fn test_xmp_metadata_pdfa() {
227        let xmp = generate_xmp_metadata(Some("Test Doc"), "fop-rs", PdfCompliance::PdfA1b);
228        assert!(xmp.contains("pdfaid:part"));
229        assert!(xmp.contains("<pdfaid:conformance>B</pdfaid:conformance>"));
230        assert!(!xmp.contains("pdfuaid"));
231    }
232
233    #[test]
234    fn test_xmp_metadata_pdfua() {
235        let xmp = generate_xmp_metadata(None, "fop-rs", PdfCompliance::PdfUA1);
236        assert!(!xmp.contains("pdfaid:part"));
237        assert!(xmp.contains("pdfuaid:part"));
238    }
239
240    #[test]
241    fn test_xmp_metadata_combined() {
242        let xmp = generate_xmp_metadata(Some("Test"), "fop-rs", PdfCompliance::PdfA1bUA1);
243        assert!(xmp.contains("pdfaid:part"));
244        assert!(xmp.contains("pdfuaid:part"));
245    }
246
247    #[test]
248    fn test_srgb_icc_profile_size() {
249        // The profile header declares its total byte count in the first 4 bytes.
250        // Verify that the declared size matches the actual compile-time array length.
251        assert!(
252            SRGB_ICC_PROFILE.len() >= 128,
253            "ICC profile must be at least 128 bytes (header only)"
254        );
255        let declared = u32::from_be_bytes([
256            SRGB_ICC_PROFILE[0],
257            SRGB_ICC_PROFILE[1],
258            SRGB_ICC_PROFILE[2],
259            SRGB_ICC_PROFILE[3],
260        ]) as usize;
261        assert_eq!(
262            declared,
263            SRGB_ICC_PROFILE.len(),
264            "ICC header declares {declared} bytes but array has {} bytes",
265            SRGB_ICC_PROFILE.len()
266        );
267    }
268}
269
270#[cfg(test)]
271mod tests_extended {
272    use super::*;
273
274    #[test]
275    fn test_compliance_standard_requires_nothing() {
276        let c = PdfCompliance::Standard;
277        assert!(!c.requires_pdfa());
278        assert!(!c.requires_pdfua());
279    }
280
281    #[test]
282    fn test_compliance_pdfua_only() {
283        let c = PdfCompliance::PdfUA1;
284        assert!(!c.requires_pdfa());
285        assert!(c.requires_pdfua());
286    }
287
288    #[test]
289    fn test_compliance_pdfa_variant_name() {
290        // Ensure all enum variants are distinct
291        assert_ne!(PdfCompliance::Standard, PdfCompliance::PdfA1b);
292        assert_ne!(PdfCompliance::PdfA1b, PdfCompliance::PdfUA1);
293        assert_ne!(PdfCompliance::PdfUA1, PdfCompliance::PdfA1bUA1);
294    }
295
296    #[test]
297    fn test_xmp_standard_contains_no_compliance_ids() {
298        let xmp = generate_xmp_metadata(Some("Doc"), "fop-rs", PdfCompliance::Standard);
299        assert!(!xmp.contains("pdfaid"));
300        assert!(!xmp.contains("pdfuaid"));
301    }
302
303    #[test]
304    fn test_xmp_metadata_contains_title() {
305        let xmp = generate_xmp_metadata(Some("My Title"), "fop-rs", PdfCompliance::Standard);
306        assert!(xmp.contains("My Title"));
307    }
308
309    #[test]
310    fn test_xmp_metadata_contains_creator_tool() {
311        let xmp = generate_xmp_metadata(None, "fop-render v1.0", PdfCompliance::Standard);
312        assert!(xmp.contains("fop-render v1.0"));
313    }
314
315    #[test]
316    fn test_xmp_metadata_no_title_uses_untitled() {
317        let xmp = generate_xmp_metadata(None, "fop", PdfCompliance::Standard);
318        assert!(xmp.contains("Untitled"));
319    }
320
321    #[test]
322    fn test_xmp_metadata_starts_with_xpacket() {
323        let xmp = generate_xmp_metadata(None, "fop", PdfCompliance::Standard);
324        assert!(xmp.starts_with("<?xpacket"));
325    }
326
327    #[test]
328    fn test_xmp_metadata_ends_with_xpacket() {
329        let xmp = generate_xmp_metadata(None, "fop", PdfCompliance::Standard);
330        assert!(xmp.ends_with("?>"));
331    }
332
333    #[test]
334    fn test_srgb_icc_profile_starts_with_signature() {
335        // ICC profile class for monitor (mntr) at offset 12: 0x6D 0x6E 0x74 0x72
336        assert_eq!(
337            &SRGB_ICC_PROFILE[12..16],
338            &[0x6D, 0x6E, 0x74, 0x72],
339            "ICC profile class should be 'mntr'"
340        );
341    }
342
343    #[test]
344    fn test_srgb_icc_profile_colour_space_rgb() {
345        // Colour space at offset 16–19 should be 'RGB ' (0x52 0x47 0x42 0x20)
346        assert_eq!(
347            &SRGB_ICC_PROFILE[16..20],
348            &[0x52, 0x47, 0x42, 0x20],
349            "ICC colour space should be 'RGB '"
350        );
351    }
352
353    #[test]
354    fn test_srgb_icc_profile_pcs_xyz() {
355        // PCS (Profile Connection Space) at offset 20–23 should be 'XYZ ' (0x58 0x59 0x5A 0x20)
356        assert_eq!(
357            &SRGB_ICC_PROFILE[20..24],
358            &[0x58, 0x59, 0x5A, 0x20],
359            "PCS should be 'XYZ '"
360        );
361    }
362
363    #[test]
364    fn test_compliance_copy_clone() {
365        let c = PdfCompliance::PdfA1b;
366        let c2 = c;
367        assert_eq!(c, c2);
368    }
369}