1#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum PdfCompliance {
8 #[default]
10 Standard,
11 PdfA1b,
13 PdfUA1,
15 PdfA1bUA1,
17}
18
19impl PdfCompliance {
20 pub fn requires_pdfa(&self) -> bool {
22 matches!(self, PdfCompliance::PdfA1b | PdfCompliance::PdfA1bUA1)
23 }
24
25 pub fn requires_pdfua(&self) -> bool {
27 matches!(self, PdfCompliance::PdfUA1 | PdfCompliance::PdfA1bUA1)
28 }
29}
30
31pub const SRGB_ICC_PROFILE: &[u8] = &[
49 0x00, 0x00, 0x01, 0xDC, 0x00, 0x00, 0x00, 0x00, 0x02, 0x10, 0x00, 0x00, 0x6D, 0x6E, 0x74, 0x72, 0x52, 0x47, 0x42, 0x20, 0x58, 0x59, 0x5A, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x61, 0x63, 0x73, 0x70, 0x4D, 0x53, 0x46, 0x54, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x43, 0x20, 0x73, 0x52, 0x47, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0xF6, 0xD6, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xD3, 0x2D, 0x48, 0x50, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
71 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 0x00, 0x00, 0x00, 0x09, 0x64, 0x65, 0x73, 0x63, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x64,
80 0x63, 0x70, 0x72, 0x74, 0x00, 0x00, 0x01, 0x54, 0x00, 0x00, 0x00, 0x2A,
82 0x77, 0x74, 0x70, 0x74, 0x00, 0x00, 0x01, 0x7E, 0x00, 0x00, 0x00, 0x14,
84 0x72, 0x58, 0x59, 0x5A, 0x00, 0x00, 0x01, 0x92, 0x00, 0x00, 0x00, 0x14,
86 0x67, 0x58, 0x59, 0x5A, 0x00, 0x00, 0x01, 0xA6, 0x00, 0x00, 0x00, 0x14,
88 0x62, 0x58, 0x59, 0x5A, 0x00, 0x00, 0x01, 0xBA, 0x00, 0x00, 0x00, 0x14,
90 0x72, 0x54, 0x52, 0x43, 0x00, 0x00, 0x01, 0xCE, 0x00, 0x00, 0x00, 0x0E,
92 0x67, 0x54, 0x52, 0x43, 0x00, 0x00, 0x01, 0xCE, 0x00, 0x00, 0x00, 0x0E,
94 0x62, 0x54, 0x52, 0x43, 0x00, 0x00, 0x01, 0xCE, 0x00, 0x00, 0x00, 0x0E,
96 0x64, 0x65, 0x73, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x13, 0x73, 0x52, 0x47, 0x42, 0x20, 0x49, 0x45, 0x43, 0x36, 0x31, 0x39, 0x36, 0x36, 0x2D, 0x32, 0x2D, 0x31, 0x00, 0x00, 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 0x74, 0x65, 0x78, 0x74, 0x00, 0x00, 0x00, 0x00, 0x43, 0x6F, 0x70, 0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x49, 0x45, 0x43, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3A, 0x2F, 0x2F, 0x77, 0x77, 0x77, 0x2E, 0x69, 0x65, 0x63, 0x2E, 0x63, 0x68, 0x00, 0x00, 0x00, 0x58, 0x59, 0x5A, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF6, 0xD6, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xD3, 0x2D, 0x58, 0x59, 0x5A, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6E, 0xA2, 0x00, 0x00, 0x38, 0xF2, 0x00, 0x00, 0x03, 0x90, 0x58, 0x59, 0x5A, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x62, 0x99, 0x00, 0x00, 0xB7, 0x85, 0x00, 0x00, 0x18, 0xDA, 0x58, 0x59, 0x5A, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x0E, 0x00, 0x00, 0x0B, 0xA3, 0x00, 0x00, 0xB6, 0xCF, 0x63, 0x75, 0x72, 0x76, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02,
146 0x33, ];
149
150pub 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#[derive(Debug, Default, Clone)]
211pub struct DcFields {
212 pub title: Option<String>,
214 pub creator: Option<String>,
216 pub description: Option<String>,
218 pub date: Option<String>,
220 pub rights: Option<String>,
222 pub language: Option<String>,
224}
225
226pub fn extract_dc_fields(xmp: &str) -> DcFields {
232 DcFields {
233 title: extract_dc_value(xmp, "title"),
234 creator: extract_dc_value(xmp, "creator"),
235 description: extract_dc_value(xmp, "description"),
236 date: extract_dc_value(xmp, "date"),
237 rights: extract_dc_value(xmp, "rights"),
238 language: extract_dc_value(xmp, "language"),
239 }
240}
241
242fn extract_dc_value(xmp: &str, tag: &str) -> Option<String> {
247 let open_tag = format!("<dc:{}>", tag);
249 let close_tag = format!("</dc:{}>", tag);
250
251 let start_pos = xmp.find(&open_tag)?;
252 let after_open = start_pos + open_tag.len();
253 let end_pos = xmp[after_open..].find(&close_tag)?;
254 let inner = &xmp[after_open..after_open + end_pos];
255
256 if let Some(li_start) = inner.find("<rdf:li") {
259 let after_li_open = li_start + 7; let tag_close = inner[after_li_open..].find('>')?;
262 let val_start = after_li_open + tag_close + 1;
263 let val_end = inner[val_start..].find("</rdf:li")?;
264 let value = inner[val_start..val_start + val_end].trim().to_string();
265 if !value.is_empty() {
266 return Some(value);
267 }
268 }
269
270 let value = inner.trim().to_string();
272 if value.is_empty() {
273 None
274 } else {
275 Some(value)
276 }
277}
278
279pub fn reconcile_xmp(source: &str, compliance: PdfCompliance) -> String {
292 let with_wrappers = if source.contains("<?xpacket") {
294 source.to_string()
295 } else {
296 format!(
298 "<?xpacket begin=\"\u{FEFF}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n{}\n<?xpacket end=\"w\"?>",
299 source
300 )
301 };
302
303 let pdfa_block = r#" <rdf:Description rdf:about=""
305 xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
306 <pdfaid:part>1</pdfaid:part>
307 <pdfaid:conformance>B</pdfaid:conformance>
308 </rdf:Description>"#;
309
310 let pdfua_block = r#" <rdf:Description rdf:about=""
311 xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/">
312 <pdfuaid:part>1</pdfuaid:part>
313 </rdf:Description>"#;
314
315 let needs_pdfa = compliance.requires_pdfa() && !with_wrappers.contains("pdfaid:part");
317 let needs_pdfua = compliance.requires_pdfua() && !with_wrappers.contains("pdfuaid:part");
318
319 if !needs_pdfa && !needs_pdfua {
320 return with_wrappers;
321 }
322
323 let injection_point = "</rdf:RDF>";
325 if let Some(rdf_close_pos) = with_wrappers.find(injection_point) {
326 let mut injected = String::with_capacity(with_wrappers.len() + 256);
327 injected.push_str(&with_wrappers[..rdf_close_pos]);
328 if needs_pdfa {
329 injected.push('\n');
330 injected.push_str(pdfa_block);
331 injected.push('\n');
332 }
333 if needs_pdfua {
334 injected.push('\n');
335 injected.push_str(pdfua_block);
336 injected.push('\n');
337 }
338 injected.push_str(&with_wrappers[rdf_close_pos..]);
339 injected
340 } else {
341 with_wrappers
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn test_compliance_default() {
352 let c = PdfCompliance::default();
353 assert_eq!(c, PdfCompliance::Standard);
354 }
355
356 #[test]
357 fn test_compliance_pdfa_flags() {
358 assert!(PdfCompliance::PdfA1b.requires_pdfa());
359 assert!(!PdfCompliance::PdfA1b.requires_pdfua());
360 assert!(PdfCompliance::PdfA1bUA1.requires_pdfa());
361 assert!(PdfCompliance::PdfA1bUA1.requires_pdfua());
362 assert!(!PdfCompliance::Standard.requires_pdfa());
363 }
364
365 #[test]
366 fn test_xmp_metadata_pdfa() {
367 let xmp = generate_xmp_metadata(Some("Test Doc"), "fop-rs", PdfCompliance::PdfA1b);
368 assert!(xmp.contains("pdfaid:part"));
369 assert!(xmp.contains("<pdfaid:conformance>B</pdfaid:conformance>"));
370 assert!(!xmp.contains("pdfuaid"));
371 }
372
373 #[test]
374 fn test_xmp_metadata_pdfua() {
375 let xmp = generate_xmp_metadata(None, "fop-rs", PdfCompliance::PdfUA1);
376 assert!(!xmp.contains("pdfaid:part"));
377 assert!(xmp.contains("pdfuaid:part"));
378 }
379
380 #[test]
381 fn test_xmp_metadata_combined() {
382 let xmp = generate_xmp_metadata(Some("Test"), "fop-rs", PdfCompliance::PdfA1bUA1);
383 assert!(xmp.contains("pdfaid:part"));
384 assert!(xmp.contains("pdfuaid:part"));
385 }
386
387 #[test]
388 fn test_srgb_icc_profile_size() {
389 assert!(
392 SRGB_ICC_PROFILE.len() >= 128,
393 "ICC profile must be at least 128 bytes (header only)"
394 );
395 let declared = u32::from_be_bytes([
396 SRGB_ICC_PROFILE[0],
397 SRGB_ICC_PROFILE[1],
398 SRGB_ICC_PROFILE[2],
399 SRGB_ICC_PROFILE[3],
400 ]) as usize;
401 assert_eq!(
402 declared,
403 SRGB_ICC_PROFILE.len(),
404 "ICC header declares {declared} bytes but array has {} bytes",
405 SRGB_ICC_PROFILE.len()
406 );
407 }
408}
409
410#[cfg(test)]
411mod tests_extended {
412 use super::*;
413
414 #[test]
415 fn test_compliance_standard_requires_nothing() {
416 let c = PdfCompliance::Standard;
417 assert!(!c.requires_pdfa());
418 assert!(!c.requires_pdfua());
419 }
420
421 #[test]
422 fn test_compliance_pdfua_only() {
423 let c = PdfCompliance::PdfUA1;
424 assert!(!c.requires_pdfa());
425 assert!(c.requires_pdfua());
426 }
427
428 #[test]
429 fn test_compliance_pdfa_variant_name() {
430 assert_ne!(PdfCompliance::Standard, PdfCompliance::PdfA1b);
432 assert_ne!(PdfCompliance::PdfA1b, PdfCompliance::PdfUA1);
433 assert_ne!(PdfCompliance::PdfUA1, PdfCompliance::PdfA1bUA1);
434 }
435
436 #[test]
437 fn test_xmp_standard_contains_no_compliance_ids() {
438 let xmp = generate_xmp_metadata(Some("Doc"), "fop-rs", PdfCompliance::Standard);
439 assert!(!xmp.contains("pdfaid"));
440 assert!(!xmp.contains("pdfuaid"));
441 }
442
443 #[test]
444 fn test_xmp_metadata_contains_title() {
445 let xmp = generate_xmp_metadata(Some("My Title"), "fop-rs", PdfCompliance::Standard);
446 assert!(xmp.contains("My Title"));
447 }
448
449 #[test]
450 fn test_xmp_metadata_contains_creator_tool() {
451 let xmp = generate_xmp_metadata(None, "fop-render v1.0", PdfCompliance::Standard);
452 assert!(xmp.contains("fop-render v1.0"));
453 }
454
455 #[test]
456 fn test_xmp_metadata_no_title_uses_untitled() {
457 let xmp = generate_xmp_metadata(None, "fop", PdfCompliance::Standard);
458 assert!(xmp.contains("Untitled"));
459 }
460
461 #[test]
462 fn test_xmp_metadata_starts_with_xpacket() {
463 let xmp = generate_xmp_metadata(None, "fop", PdfCompliance::Standard);
464 assert!(xmp.starts_with("<?xpacket"));
465 }
466
467 #[test]
468 fn test_xmp_metadata_ends_with_xpacket() {
469 let xmp = generate_xmp_metadata(None, "fop", PdfCompliance::Standard);
470 assert!(xmp.ends_with("?>"));
471 }
472
473 #[test]
474 fn test_srgb_icc_profile_starts_with_signature() {
475 assert_eq!(
477 &SRGB_ICC_PROFILE[12..16],
478 &[0x6D, 0x6E, 0x74, 0x72],
479 "ICC profile class should be 'mntr'"
480 );
481 }
482
483 #[test]
484 fn test_srgb_icc_profile_colour_space_rgb() {
485 assert_eq!(
487 &SRGB_ICC_PROFILE[16..20],
488 &[0x52, 0x47, 0x42, 0x20],
489 "ICC colour space should be 'RGB '"
490 );
491 }
492
493 #[test]
494 fn test_srgb_icc_profile_pcs_xyz() {
495 assert_eq!(
497 &SRGB_ICC_PROFILE[20..24],
498 &[0x58, 0x59, 0x5A, 0x20],
499 "PCS should be 'XYZ '"
500 );
501 }
502
503 #[test]
504 fn test_compliance_copy_clone() {
505 let c = PdfCompliance::PdfA1b;
506 let c2 = c;
507 assert_eq!(c, c2);
508 }
509
510 #[test]
513 fn test_reconcile_xmp_adds_xpacket_wrappers() {
514 let source = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"></rdf:RDF></x:xmpmeta>"#;
515 let result = reconcile_xmp(source, PdfCompliance::Standard);
516 assert!(
517 result.starts_with("<?xpacket"),
518 "Should start with <?xpacket"
519 );
520 assert!(result.ends_with("?>"), "Should end with ?>");
521 }
522
523 #[test]
524 fn test_reconcile_xmp_keeps_existing_wrappers() {
525 let source = "<?xpacket begin=\"\u{FEFF}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"></rdf:RDF></x:xmpmeta>\n<?xpacket end=\"w\"?>";
526 let result = reconcile_xmp(source, PdfCompliance::Standard);
527 let count = result.matches("<?xpacket").count();
529 assert_eq!(count, 2, "Should have exactly two xpacket PIs");
530 }
531
532 #[test]
533 fn test_reconcile_xmp_splices_pdfa() {
534 let source = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"></rdf:RDF></x:xmpmeta>"#;
535 let result = reconcile_xmp(source, PdfCompliance::PdfA1b);
536 assert!(result.contains("pdfaid:part"), "Should contain pdfaid:part");
537 assert!(
538 result.contains("pdfaid:conformance"),
539 "Should contain pdfaid:conformance"
540 );
541 }
542
543 #[test]
544 fn test_reconcile_xmp_splices_pdfua() {
545 let source = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"></rdf:RDF></x:xmpmeta>"#;
546 let result = reconcile_xmp(source, PdfCompliance::PdfUA1);
547 assert!(
548 result.contains("pdfuaid:part"),
549 "Should contain pdfuaid:part"
550 );
551 assert!(
552 !result.contains("pdfaid:part"),
553 "Should NOT contain pdfaid:part"
554 );
555 }
556
557 #[test]
558 fn test_reconcile_xmp_splices_both_for_combined() {
559 let source = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"></rdf:RDF></x:xmpmeta>"#;
560 let result = reconcile_xmp(source, PdfCompliance::PdfA1bUA1);
561 assert!(result.contains("pdfaid:part"), "Should contain pdfaid:part");
562 assert!(
563 result.contains("pdfuaid:part"),
564 "Should contain pdfuaid:part"
565 );
566 }
567
568 #[test]
569 fn test_reconcile_xmp_does_not_duplicate_existing_pdfa() {
570 let source = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
571<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
572 <rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
573 <pdfaid:part>1</pdfaid:part>
574 <pdfaid:conformance>B</pdfaid:conformance>
575 </rdf:Description>
576</rdf:RDF></x:xmpmeta>"#;
577 let result = reconcile_xmp(source, PdfCompliance::PdfA1b);
578 let count = result.matches("<pdfaid:part>").count();
581 assert_eq!(
582 count, 1,
583 "<pdfaid:part> open-tag should appear exactly once"
584 );
585 }
586
587 #[test]
590 fn test_extract_dc_fields_title_from_alt() {
591 let xmp = r#"<?xpacket begin="" id="W"?>
592<x:xmpmeta xmlns:x="adobe:ns:meta/">
593<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
594 <rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
595 <dc:title><rdf:Alt><rdf:li xml:lang="x-default">My Test Document</rdf:li></rdf:Alt></dc:title>
596 </rdf:Description>
597</rdf:RDF></x:xmpmeta>
598<?xpacket end="w"?>"#;
599 let fields = extract_dc_fields(xmp);
600 assert_eq!(fields.title.as_deref(), Some("My Test Document"));
601 }
602
603 #[test]
604 fn test_extract_dc_fields_title_simple() {
605 let xmp = r#"<dc:title>Simple Title</dc:title>"#;
606 let fields = extract_dc_fields(xmp);
607 assert_eq!(fields.title.as_deref(), Some("Simple Title"));
608 }
609
610 #[test]
611 fn test_extract_dc_fields_creator() {
612 let xmp = r#"<dc:creator>Jane Doe</dc:creator>"#;
613 let fields = extract_dc_fields(xmp);
614 assert_eq!(fields.creator.as_deref(), Some("Jane Doe"));
615 }
616
617 #[test]
618 fn test_extract_dc_fields_description() {
619 let xmp = r#"<dc:description>A document about things.</dc:description>"#;
620 let fields = extract_dc_fields(xmp);
621 assert_eq!(
622 fields.description.as_deref(),
623 Some("A document about things.")
624 );
625 }
626
627 #[test]
628 fn test_extract_dc_fields_absent_returns_none() {
629 let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"></x:xmpmeta>"#;
630 let fields = extract_dc_fields(xmp);
631 assert!(fields.title.is_none());
632 assert!(fields.creator.is_none());
633 assert!(fields.description.is_none());
634 assert!(fields.date.is_none());
635 assert!(fields.rights.is_none());
636 assert!(fields.language.is_none());
637 }
638
639 #[test]
640 fn test_extract_dc_date_from_xmp() {
641 let xmp = r#"<dc:date>2026-05-15</dc:date>"#;
642 let dc = extract_dc_fields(xmp);
643 assert_eq!(dc.date.as_deref(), Some("2026-05-15"));
644 }
645
646 #[test]
647 fn test_extract_dc_rights_from_xmp() {
648 let xmp = r#"<dc:rights>CC-BY 4.0</dc:rights>"#;
649 let dc = extract_dc_fields(xmp);
650 assert_eq!(dc.rights.as_deref(), Some("CC-BY 4.0"));
651 }
652
653 #[test]
654 fn test_extract_dc_language_from_xmp() {
655 let xmp = r#"<dc:language>en</dc:language>"#;
656 let dc = extract_dc_fields(xmp);
657 assert_eq!(dc.language.as_deref(), Some("en"));
658 }
659}