From cdea50daaceafde4678510d3c0dcf26f1cd4bf8c Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 12:11:02 +0800 Subject: [PATCH] M11: PDF/A verify checks the value types of the TIFF, EXIF and aux XMP schemas (6.6.2.3.1) The TIFF, EXIF and Additional EXIF namespaces come from the September 2005 XMP Specification that PDF/A-2 cites ("EXIF Schemas", pages 58-70), with its EXIF value types (pages 83-87): Rational as numerator/ denominator, GPSCoordinate as DDD,MM,SSk or DDD,MM.mmk, and the Flash, OECF/SFR, CFAPattern and DeviceSettings structures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/tests.rs | 58 ++++++++++- crates/preflight/src/xmp_types.rs | 158 +++++++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 4 deletions(-) diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index e2a160904f..026479c069 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -494,7 +494,8 @@ const XMP_NS: &str = concat!( r#"xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" "#, r#"xmlns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#" xmlns:xmpDM="http://ns.adobe.com/xmp/1.0/DynamicMedia/" "#, r#"xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" xmlns:xmpTPg="http://ns.adobe.com/xap/1.0/t/pg/" "#, - r#"xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#" xmlns:pdf="http://ns.adobe.com/pdf/1.3/""# + r#"xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" "#, + r#"xmlns:tiff="http://ns.adobe.com/tiff/1.0/" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:aux="http://ns.adobe.com/exif/1.0/aux/""# ); /// The mistyped predefined properties of a packet whose one description holds `body` (its @@ -684,3 +685,58 @@ fn xmp_value_types_are_reported_by_verify() { ); assert_eq!(issues(&doc).len(), 1); } + +/// PDF/A-2 6.6.2.3.1 for the TIFF, EXIF and Additional EXIF schemas of the September 2005 XMP +/// Specification (pages 58–70), with the EXIF value types of pages 83–87. +#[test] +fn xmp_exif_schemas_have_their_value_types() { + // TIFF: integers, closed choices of integer, rationals, sequences, language alternatives. + assert!(element("tiff:ImageWidth", "256").is_empty()); + assert_eq!(element("tiff:ImageWidth", "256 mm"), ["tiff:ImageWidth"]); + assert_eq!(element("tiff:Orientation", "4/1"), ["tiff:Orientation"]); + assert!(element("tiff:XResolution", "72/1").is_empty()); + assert_eq!(element("tiff:XResolution", "1/2"), ["tiff:XResolution"]); + assert!(element("tiff:BitsPerSample", "88").is_empty()); + assert_eq!(element("tiff:BitsPerSample", "88.0"), ["tiff:BitsPerSample"]); + assert_eq!(element("tiff:TransferFunction", "0"), ["tiff:TransferFunction"]); + assert_eq!(element("tiff:ImageDescription", "A picture"), ["tiff:ImageDescription"]); + // EXIF: GPS coordinates (page 85). + for good in ["51,30,26N", "0,5.25W", "120,5,0E", "8,0.000001S"] { + assert!(element("exif:GPSLatitude", good).is_empty(), "{good}"); + } + for bad in ["51.5N", "51,30,26", "51,30.N", "51,30,26X", "N", "51,30,2.6N", "a,30,26N"] { + assert_eq!(element("exif:GPSLatitude", bad), ["exif:GPSLatitude"], "{bad}"); + } + assert!(element("exif:GPSTimeStamp", "2016-02-01T13:19:21").is_empty()); + assert_eq!(element("exif:ISOSpeedRatings", "100"), ["exif:ISOSpeedRatings"]); + // EXIF structures: Flash, OECF/SFR, CFAPattern, DeviceSettings (pages 85–87). + assert!(mistyped(r#">True3"#).is_empty()); + assert_eq!(mistyped(r#">yes"#), ["exif:Flash"]); + assert_eq!(mistyped(r#">"#), ["exif:Flash"]); + assert_eq!(element("exif:Flash", "fired"), ["exif:Flash"]); + assert!( + mistyped(r#">1/2"#).is_empty() + ); + assert_eq!( + mistyped(r#">0.5"#), + ["exif:OECF"] + ); + assert_eq!( + mistyped( + r#">1/2"# + ), + ["exif:CFAPattern"] + ); + assert_eq!( + mistyped(r#">2.0"#), + ["exif:DeviceSettingDescription"] + ); + assert_eq!( + mistyped(r#">a"#), + ["exif:DeviceSettingDescription"] + ); + // Additional EXIF properties (page 69). + assert!(element("aux:Lens", "50mm").is_empty()); + assert_eq!(element("aux:Lens", "50mm"), ["aux:Lens"]); + assert_eq!(element("aux:SerialNumber", "1"), ["aux:SerialNumber"]); +} diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index 55f542929c..094bde8740 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -4,7 +4,10 @@ //! The types come from ISO 16684-1:2011 (XMP Part 1) clause 8 for the Dublin Core, XMP Basic and //! XMP Rights Management namespaces, and from XMP Specification Part 2 (Additional Properties) for //! the Media Management, Basic Job Ticket, Paged-Text, Dynamic Media, Adobe PDF, Photoshop and -//! Camera Raw namespaces, with the structures they use. A value is read as RDF/XML serializes it +//! Camera Raw namespaces, with the structures they use. The TIFF, EXIF and Additional EXIF (`aux`) +//! namespaces, which the current Part 2 leaves to CIPA DC-010, come from the September 2005 XMP +//! Specification that PDF/A-2 cites: "EXIF Schemas" (pages 58–70) and "EXIF Schema Value Types" +//! (pages 83–87). A value is read as RDF/XML serializes it //! (ISO 16684-1 7.9): a simple value as text or an attribute, an array as `rdf:Seq`, `rdf:Bag` or //! `rdf:Alt`, a structure as `rdf:parseType="Resource"`, an `rdf:Description` or attributes, and a //! qualified value through its `rdf:value` (element or attribute). Every metadata stream of the @@ -12,7 +15,8 @@ //! //! The simple types are checked by their lexical form (ISO 16684-1 8.2.1): Boolean is `True` or //! `False`; Integer an optionally signed run of digits; Real a decimal; Date one of the six forms -//! of 8.2.1.2; Rational two integers around a `/`, as Part 2's `xmpDM:scale` example writes it. +//! of 8.2.1.2; Rational two integers around a `/`, as Part 2's `xmpDM:scale` example writes it and +//! the 2005 edition defines it (page 84); GPSCoordinate `DDD,MM,SSk` or `DDD,MM.mmk` (page 85). //! The other simple types are text. A field a structure doesn't define, and a property a //! namespace doesn't define, are left to the clauses about them. @@ -43,6 +47,8 @@ enum Ty { Boolean, Date, Rational, + /// `DDD,MM,SSk` or `DDD,MM.mmk`, `k` one of N, S, E, W (2005 edition, page 85). + GpsCoordinate, /// An alternative array of text items, each with an `xml:lang` (ISO 16684-1 8.2.2.4). LangAlt, Seq(&'static Ty), @@ -198,6 +204,19 @@ const ANCESTOR: &[Field] = &[(PHOTOSHOP, "AncestorID", Text)]; /// Part 2 Table 32. const LAYER: &[Field] = &[(PHOTOSHOP, "LayerName", Text), (PHOTOSHOP, "LayerText", Text)]; +const EXIF: &str = "http://ns.adobe.com/exif/1.0/"; + +/// Flash (2005 edition, page 85). +const FLASH: &[Field] = + &[(EXIF, "Fired", Boolean), (EXIF, "Return", Integer), (EXIF, "Mode", Integer), (EXIF, "Function", Boolean), (EXIF, "RedEyeMode", Boolean)]; +/// OECF/SFR (2005 edition, page 86). +const OECF_SFR: &[Field] = + &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Names", Ty::Seq(&Text)), (EXIF, "Values", Ty::Seq(&Rational))]; +/// CFAPattern (2005 edition, page 87). +const CFA_PATTERN: &[Field] = &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Values", Ty::Seq(&Integer))]; +/// DeviceSettings (2005 edition, page 87). +const DEVICE_SETTINGS: &[Field] = &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Settings", Ty::Seq(&Text))]; + const SCHEMAS: &[Schema] = &[ // ISO 16684-1 Table 4. Schema { @@ -447,6 +466,126 @@ const SCHEMAS: &[Schema] = &[ ("WhiteBalance", Text), ], }, + // 2005 edition, "EXIF Schema for TIFF Properties", pages 58–60. A Closed Choice of Integer is an + // Integer. + Schema { + uri: "http://ns.adobe.com/tiff/1.0/", + prefix: "tiff", + properties: &[ + ("ImageWidth", Integer), + ("ImageLength", Integer), + ("BitsPerSample", Ty::Seq(&Integer)), + ("Compression", Integer), + ("PhotometricInterpretation", Integer), + ("Orientation", Integer), + ("SamplesPerPixel", Integer), + ("PlanarConfiguration", Integer), + ("YCbCrSubSampling", Ty::Seq(&Integer)), + ("YCbCrPositioning", Integer), + ("XResolution", Rational), + ("YResolution", Rational), + ("ResolutionUnit", Integer), + ("TransferFunction", Ty::Seq(&Integer)), + ("WhitePoint", Ty::Seq(&Rational)), + ("PrimaryChromaticities", Ty::Seq(&Rational)), + ("YCbCrCoefficients", Ty::Seq(&Rational)), + ("ReferenceBlackWhite", Ty::Seq(&Rational)), + ("DateTime", Date), + ("ImageDescription", LangAlt), + ("Make", Text), + ("Model", Text), + ("Software", Text), + ("Artist", Text), + ("Copyright", LangAlt), + ], + }, + // 2005 edition, "EXIF Schema for EXIF-specific Properties", pages 60–69. + Schema { + uri: EXIF, + prefix: "exif", + properties: &[ + ("ExifVersion", Text), + ("FlashpixVersion", Text), + ("ColorSpace", Integer), + ("ComponentsConfiguration", Ty::Seq(&Integer)), + ("CompressedBitsPerPixel", Rational), + ("PixelXDimension", Integer), + ("PixelYDimension", Integer), + ("UserComment", LangAlt), + ("RelatedSoundFile", Text), + ("DateTimeOriginal", Date), + ("DateTimeDigitized", Date), + ("ExposureTime", Rational), + ("FNumber", Rational), + ("ExposureProgram", Integer), + ("SpectralSensitivity", Text), + ("ISOSpeedRatings", Ty::Seq(&Integer)), + ("OECF", Ty::Struct(OECF_SFR)), + ("ShutterSpeedValue", Rational), + ("ApertureValue", Rational), + ("BrightnessValue", Rational), + ("ExposureBiasValue", Rational), + ("MaxApertureValue", Rational), + ("SubjectDistance", Rational), + ("MeteringMode", Integer), + ("LightSource", Integer), + ("Flash", Ty::Struct(FLASH)), + ("FocalLength", Rational), + ("SubjectArea", Ty::Seq(&Integer)), + ("FlashEnergy", Rational), + ("SpatialFrequencyResponse", Ty::Struct(OECF_SFR)), + ("FocalPlaneXResolution", Rational), + ("FocalPlaneYResolution", Rational), + ("FocalPlaneResolutionUnit", Integer), + ("SubjectLocation", Ty::Seq(&Integer)), + ("ExposureIndex", Rational), + ("SensingMethod", Integer), + ("FileSource", Integer), + ("SceneType", Integer), + ("CFAPattern", Ty::Struct(CFA_PATTERN)), + ("CustomRendered", Integer), + ("ExposureMode", Integer), + ("WhiteBalance", Integer), + ("DigitalZoomRatio", Rational), + ("FocalLengthIn35mmFilm", Integer), + ("SceneCaptureType", Integer), + ("GainControl", Integer), + ("Contrast", Integer), + ("Saturation", Integer), + ("Sharpness", Integer), + ("DeviceSettingDescription", Ty::Struct(DEVICE_SETTINGS)), + ("SubjectDistanceRange", Integer), + ("ImageUniqueID", Text), + ("GPSVersionID", Text), + ("GPSLatitude", Ty::GpsCoordinate), + ("GPSLongitude", Ty::GpsCoordinate), + ("GPSAltitudeRef", Integer), + ("GPSAltitude", Rational), + ("GPSTimeStamp", Date), + ("GPSSatellites", Text), + ("GPSStatus", Text), + ("GPSMeasureMode", Text), + ("GPSDOP", Rational), + ("GPSSpeedRef", Text), + ("GPSSpeed", Rational), + ("GPSTrackRef", Text), + ("GPSTrack", Rational), + ("GPSImgDirectionRef", Text), + ("GPSImgDirection", Rational), + ("GPSMapDatum", Text), + ("GPSDestLatitude", Ty::GpsCoordinate), + ("GPSDestLongitude", Ty::GpsCoordinate), + ("GPSDestBearingRef", Text), + ("GPSDestBearing", Rational), + ("GPSDestDistanceRef", Text), + ("GPSDestDistance", Rational), + ("GPSProcessingMethod", Text), + ("GPSAreaInformation", Text), + ("GPSDifferential", Integer), + ], + }, + // 2005 edition, "EXIF Schema for Additional EXIF Properties", page 69. + Schema { uri: "http://ns.adobe.com/exif/1.0/aux/", prefix: "aux", properties: &[("Lens", Text), ("SerialNumber", Text)] }, ]; /// The properties of predefined schemas whose values don't have their type, in every metadata @@ -549,7 +688,7 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { return simple(ty, v); } match ty { - Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => { + Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational | Ty::GpsCoordinate => { // `rdf:resource` makes the value a URI, which only a text type takes. if e.attribute((RDF, "resource")).is_some() { return matches!(ty, Ty::Text); @@ -642,12 +781,25 @@ fn simple(ty: Ty, v: &str) -> bool { Ty::Boolean => matches!(v, "True" | "False"), Ty::Date => date(v), Ty::Rational => v.split_once('/').is_some_and(|(n, d)| integer(n) && integer(d)), + Ty::GpsCoordinate => gps_coordinate(v), Ty::Text | Ty::Guid => true, Ty::OneOf(types) => types.iter().any(|t| simple(*t, v)), _ => false, } } +/// `DDD,MM,SSk` or `DDD,MM.mmk`: degrees, minutes and seconds or minutes with any number of +/// fractional digits, then N, S, E or W (2005 edition, page 85). +fn gps_coordinate(v: &str) -> bool { + let Some(v) = v.strip_suffix(['N', 'S', 'E', 'W']) else { return false }; + let Some((degrees, rest)) = v.split_once(',') else { return false }; + let minutes_ok = match rest.split_once(',') { + Some((minutes, seconds)) => digits(minutes) && digits(seconds), + None => rest.split_once('.').is_some_and(|(m, f)| digits(m) && digits(f)), + }; + digits(degrees) && minutes_ok +} + fn digits(s: &str) -> bool { !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) }