diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index ac0a8808a..8ca82da01 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -738,490 +738,4 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn full() -> Metadata { - Metadata { - // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the - // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. - regions: Vec::new(), - focus: None, - make: Some("Maker & Sons".into()), - model: Some("X <1>".into()), - serial_number: Some("SN1".into()), - software: Some("LightCraft".into()), - lens_make: Some("L".into()), - lens_model: Some("24-70mm".into()), - lens_serial_number: Some("LS".into()), - lens_spec: Some([24.0, 70.0, 2.8, 2.8]), - capture_time: DateTime::parse_iso("2022-05-06T07:08:09.123-04:00"), - exposure_time: Some(1.0 / 125.0), - f_number: Some(5.6), - iso: Some(800), - focal_length: Some(35.0), - focal_length_35mm: Some(52.0), - flash: Some(Flash { fired: true, raw: 0x59 }), - exposure_bias: Some(-1.0 / 3.0), - exposure_program: Some(2), - metering_mode: Some(3), - white_balance: Some(1), - orientation: Some(Orientation::Rotate270), - gps: Some(Gps { latitude: 48.858222, longitude: -2.2945, altitude: Some(-3.5) }), - width: Some(8000), - height: Some(6000), - artist: Some("Ann \"Photo\" Lee".into()), - copyright: Some("© 2022".into()), - copyright_marked: Some(true), - usage_terms: Some("Editorial use only; no ".into()), - copyright_url: Some("https://example.com/licence?a=1&b=2".into()), - title: Some("Tïtle".into()), - caption: Some("Line one\nline two".into()), - alt_text: Some("A tree by the sea".into()), - extended_description: Some("Long ".into()), - sublocation: Some("Champ de Mars".into()), - city: Some("Paris".into()), - state: Some("Île-de-France".into()), - country: Some("France".into()), - keywords: vec!["tree".into(), "sky & sea".into()], - hierarchical_keywords: vec!["Places|France|Paris".into()], - rating: Some(-1), - label: Some("Green".into()), - } - } - - #[test] - fn roundtrip_all_fields() { - let m = full(); - let json = r#"{"version":1,"exposure":0.5,"note":"<&>"}"#; - let x = write_xmp(&m, Some(json)); - let d = parse_xmp(&x).unwrap(); - assert_eq!(d.lc_settings.as_deref(), Some(json)); - let mut got = d.metadata.clone(); - // floats go through rationals - assert!((got.exposure_bias.unwrap() + 1.0 / 3.0).abs() < 1e-5); - got.exposure_bias = m.exposure_bias; - let g = got.gps.unwrap(); - assert!((g.latitude - 48.858222).abs() < 1e-6 && (g.longitude + 2.2945).abs() < 1e-6, "{g:?}"); - got.gps = m.gps; - assert_eq!(got, m); - } - - #[test] - fn empty_metadata_roundtrip() { - let x = write_xmp(&Metadata::default(), None); - let d = parse_xmp(&x).unwrap(); - assert_eq!(d.metadata, Metadata::default()); - assert_eq!(d.lc_settings, None); - } - - #[test] - fn foreign_namespace_cannot_impersonate_known_metadata() { - for body in [ - r#"xmp:Rating="2" lc:settings="foreign settings"/>"#, - r#">2foreign settings"#, - ] { - let packet = format!( - r#""# - ); - let d = parse_xmp(&packet).unwrap(); - assert_eq!(d.metadata.rating, None, "foreign rating: {packet}"); - assert_eq!(d.lc_settings, None, "foreign settings: {packet}"); - assert!(!d.properties.contains_key("xmp:Rating")); - assert!(!d.values.contains_key("lc:settings")); - assert!(d.properties.values().any(|v| v == &["foreign settings"])); - assert!(d.properties.keys().all(|key| !key.contains('/')), "opaque names must not create struct paths"); - } - } - - #[test] - fn foreign_namespace_names_are_distinct_and_scoped() { - let packet = r#" - - 1 - 2 - 4 - "#; - let d = parse_xmp(packet).unwrap(); - assert_eq!(d.metadata.rating, Some(4), "a local rebinding must not escape its scope"); - assert_eq!(d.properties.len(), 3, "different foreign URIs must not collide"); - assert_eq!(d.values.len(), 3); - assert_eq!(d.properties["xmp:Rating"], vec!["4"]); - assert!(d.properties.values().any(|v| v == &["1"])); - assert!(d.properties.values().any(|v| v == &["2"])); - } - - #[test] - fn attribute_form_foreign_prefixes_and_structs() { - let x = r#" - - TitelTitle & more é - - k1k2 - "#; - let d = parse_xmp(x).unwrap(); - let m = d.metadata; - assert_eq!(m.rating, Some(4)); - assert_eq!(m.label.as_deref(), Some("Blue")); - assert_eq!(m.f_number, Some(4.0)); - assert_eq!(m.title.as_deref(), Some("Title & more é")); - assert_eq!(m.keywords, vec!["k1".to_string(), "k2".to_string()]); - assert_eq!(m.flash, Some(Flash { fired: false, raw: 0x10 })); - let g = m.gps.unwrap(); - assert!((g.latitude - 10.5).abs() < 1e-9); - assert!((g.longitude + 20.26).abs() < 1e-9); - assert_eq!(d.properties["dc:title"], vec!["Title & more é".to_string(), "Titel".to_string()]); - } - - /// Copyright status, usage terms and info URL (XMP Rights Management schema): written as - /// `xmpRights:Marked` (Boolean), `xmpRights:UsageTerms` (Lang Alt) and `xmpRights:WebStatement` (URI). - #[test] - fn rights_management_fields() { - let m = Metadata { copyright_marked: Some(false), usage_terms: Some("CC0".into()), ..Default::default() }; - let x = write_xmp(&m, None); - assert!(x.contains("False"), "{x}"); - assert!(x.contains("\n \n CC0"), "{x}"); - assert!(!x.contains("WebStatement"), "unset fields are left out"); - assert_eq!(parse_xmp(&x).unwrap().metadata, m); - // attribute form, other prefixes; anything but True / False is "unknown" - let read = |marked: &str| { - let x = format!( - r#" - - "# - ); - parse_xmp(&x).unwrap().metadata - }; - assert_eq!(read("True").copyright_marked, Some(true)); - assert_eq!(read("false").copyright_marked, Some(false)); - assert_eq!(read("").copyright_marked, None); - assert_eq!(read("maybe").copyright_marked, None); - assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c")); - } - - /// A zero or negative `exif:ISOSpeedRatings` is "no ISO" (as in the EXIF reader), not `Some(0)`, and - /// must not hide a valid `exifEX:PhotographicSensitivity`. - #[test] - fn non_positive_iso_is_ignored() { - let read = |attrs: &str| { - let x = format!( - r#" - - "# - ); - parse_xmp(&x).unwrap().metadata.iso - }; - assert_eq!(read(r#"exif:ISOSpeedRatings="0" exifEX:PhotographicSensitivity="400""#), Some(400)); - assert_eq!(read(r#"exif:ISOSpeedRatings="-100""#), None); - assert_eq!(read(r#"exif:ISOSpeedRatings="0""#), None); - assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200)); - } - - #[test] - fn malformed_inputs() { - assert!(parse_xmp("").is_ok() || parse_xmp("").is_err()); - assert_eq!(parse_xmp("").unwrap().metadata, Metadata::default()); - assert_eq!(parse_xmp("not xml at all").unwrap().metadata, Metadata::default()); - let deep = "".repeat(1000); - assert_eq!(parse_xmp(&deep), Err(XmpError::TooComplex)); - let x = write_xmp(&full(), Some("{}")); - for n in (0..x.len()).step_by(7) { - if x.is_char_boundary(n) { - let _ = parse_xmp(&x[..n]); - } - } - } - - #[test] - fn lc_extras_and_crs_prefix() { - let x = write_xmp_lc(&Metadata::default(), &[("settings", "{\"a\":1}"), ("flag", "pick"), ("bad name", "x")]); - assert!(!x.contains("xmlns:crs"), "we never write crs:"); - let d = parse_xmp(&x).unwrap(); - assert_eq!(d.lc_settings.as_deref(), Some("{\"a\":1}")); - assert_eq!(d.properties["lc:flag"], vec!["pick".to_string()]); - assert!(!x.contains("bad name")); - let foreign = r#""#; - assert_eq!(parse_xmp(foreign).unwrap().properties["crs:Exposure2012"], vec!["+0.5".to_string()]); - } - - #[test] - fn gps_coords() { - assert_eq!(parse_gps_coord("45,30N"), Some(45.5)); - assert_eq!(parse_gps_coord("45,30,36S"), Some(-45.51)); - assert_eq!(parse_gps_coord("45X"), None); - assert_eq!(parse_gps_coord(""), None); - assert_eq!(parse_gps_coord("é"), None); - assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); - assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N"); - assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W"); - } - - /// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°. - #[test] - fn xmp_gps_out_of_range_rejected() { - let gps = |lat: &str, lon: &str| { - let x = format!( - r#""# - ); - parse_xmp(&x).unwrap().metadata.gps - }; - assert_eq!(gps("95,0N", "200,30E"), None); - assert_eq!(gps("95,0N", "10,0E"), None); - assert_eq!(gps("10,0S", "200,30W"), None); - assert_eq!(gps("90,0,1N", "0,0E"), None); - assert_eq!(gps("90,0N", "180,0E"), Some(Gps { latitude: 90.0, longitude: 180.0, altitude: None })); - assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); - } - - /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how - /// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one - /// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas - /// (the default, and the only unit real files use) don't need it. - #[test] - fn mwg_regions_element_form() { - let x = r#" - - - - - - - - Jane Doe - Face - - - - - - - Pet - Rex - Good boy - - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 3, "{regions:?}"); - - assert_eq!(regions[0].kind, RegionKind::Face); - assert_eq!(regions[0].name.as_deref(), Some("Jane Doe")); - assert_eq!(regions[0].description, None); - let r = regions[0].rect; - assert!((r.x0 - 0.4).abs() < 1e-9 && (r.x1 - 0.6).abs() < 1e-9, "{r:?}"); - assert!((r.y0 - 0.25).abs() < 1e-9 && (r.y1 - 0.55).abs() < 1e-9, "{r:?}"); - - // No mwg-rs:Type or mwg-rs:Name at all: still a usable region, kind falls back to `Other("")`. - assert_eq!(regions[1].kind, RegionKind::Other(String::new())); - assert_eq!(regions[1].name, None); - - assert_eq!(regions[2].kind, RegionKind::Pet); - assert_eq!(regions[2].name.as_deref(), Some("Rex")); - assert_eq!(regions[2].description.as_deref(), Some("Good boy")); - } - - /// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame - /// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the - /// upright frame. - #[test] - fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() { - let x = r#" - - - - - - - - - - - - - - - - - - - - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 5, "{regions:?}"); - let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9); - assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect); - assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect); - assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect); - assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect); - assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect); - } - - /// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an - /// `rdf:li rdf:parseType="Resource"`), `mwg-rs:Type` as an attribute instead of an element, and an - /// unrecognised `mwg-rs:Type` value kept verbatim. - #[test] - fn mwg_regions_attribute_form_and_unknown_type() { - let x = r#" - - - - - - - - - - - - - - - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 2, "{regions:?}"); - assert_eq!(regions[0].kind, RegionKind::BarCode); - assert_eq!(regions[1].kind, RegionKind::Other("Sticker".to_string())); - } - - /// Hostile/malformed regions are dropped individually, never panic, and never poison the rest of - /// the list: missing Area, non-finite or absurd numbers, zero/negative size, and a pixel-unit area - /// with no `AppliedToDimensions` to convert it (so it can't be stored as a nonsense fraction). - #[test] - fn mwg_regions_hostile_inputs_are_dropped_not_panicking() { - let x = r#" - - - - - No Area - - - - - - Face - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 1, "only the last, well-formed region should survive: {regions:?}"); - assert_eq!(regions[0].kind, RegionKind::Face); - } - - /// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a - /// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view - /// would then draw or crop far outside the image. - #[test] - fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() { - let x = r#" - - - - - Far right - Far left - Touching edge - Huge - Corner - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - let names: Vec<_> = regions.iter().map(|r| r.name.as_deref().unwrap_or("")).collect(); - assert_eq!(names, ["Huge", "Corner"], "{regions:?}"); - for r in ®ions { - let q = r.rect; - assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}"); - } - assert_eq!(regions[0].rect, Rect::UNIT); - let c = regions[1].rect; - assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}"); - } - - /// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords). - #[test] - fn regions_merge_like_keywords() { - let with_region = Region { rect: Rect::from_center(Point::new(0.5, 0.5), 0.2, 0.2), kind: RegionKind::Face, name: None, description: None }; - let mut a = Metadata::default(); - let b = Metadata { regions: vec![with_region.clone()], ..Default::default() }; - a.fill_missing(&b); - assert_eq!(a.regions, vec![with_region.clone()]); - - let mut a = Metadata::default(); - a.overlay_user_fields(&b); - assert_eq!(a.regions, vec![with_region]); - } - - #[test] - fn writer_drops_characters_xml_1_0_forbids() { - let bad = "a\u{1}b\u{0}c\u{FFFE}d\u{FFFF}e\u{1F}\tf\ng\rh"; - let m = Metadata { make: Some(bad.into()), caption: Some(bad.into()), keywords: vec![bad.into()], ..Default::default() }; - let x = write_xmp_lc(&m, &[("settings", bad)]); - let illegal: Vec = - x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect(); - assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}"); - assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}"); - } - - #[test] - fn numeric_references_to_forbidden_characters_are_not_decoded() { - assert_eq!(entity("#1"), None); - assert_eq!(entity("#0"), None); - assert_eq!(entity("#xFFFE"), None); - assert_eq!(entity("#x41"), Some('A')); - assert_eq!(entity("#9"), Some('\t')); - } - - /// Flattened keys repeat their whole ancestor path, so a long nested name with a wide child - /// fan-out would otherwise retain its prefix once per leaf: the cumulative key budget stops it - /// (a 65 KB document with 3000 leaves must not retain ~2.4 MB of key text). - #[test] - fn flattened_keys_stay_within_the_cumulative_budget() { - let level = "n".repeat(200); - let mut doc = String::from( - "", - ); - for _ in 0..4 { - doc.push_str(&format!("")); - } - // each leaf has a name of its own, so each one adds a key - for i in 0..3000 { - doc.push_str(&format!("")); - } - doc.push_str(""); - for _ in 0..4 { - doc.push_str(&format!("")); - } - doc.push_str(""); - let d = parse_xmp(&doc).unwrap(); - let total: usize = d.properties.keys().map(String::len).sum(); - assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget"); - assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent"); - assert!(!d.properties.is_empty(), "properties parsed before the budget are kept"); - } -} +mod tests; diff --git a/crates/meta/src/xmp/tests.rs b/crates/meta/src/xmp/tests.rs new file mode 100644 index 000000000..3939d2753 --- /dev/null +++ b/crates/meta/src/xmp/tests.rs @@ -0,0 +1,485 @@ +use super::*; + +fn full() -> Metadata { + Metadata { + // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the + // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. + regions: Vec::new(), + focus: None, + make: Some("Maker & Sons".into()), + model: Some("X <1>".into()), + serial_number: Some("SN1".into()), + software: Some("LightCraft".into()), + lens_make: Some("L".into()), + lens_model: Some("24-70mm".into()), + lens_serial_number: Some("LS".into()), + lens_spec: Some([24.0, 70.0, 2.8, 2.8]), + capture_time: DateTime::parse_iso("2022-05-06T07:08:09.123-04:00"), + exposure_time: Some(1.0 / 125.0), + f_number: Some(5.6), + iso: Some(800), + focal_length: Some(35.0), + focal_length_35mm: Some(52.0), + flash: Some(Flash { fired: true, raw: 0x59 }), + exposure_bias: Some(-1.0 / 3.0), + exposure_program: Some(2), + metering_mode: Some(3), + white_balance: Some(1), + orientation: Some(Orientation::Rotate270), + gps: Some(Gps { latitude: 48.858222, longitude: -2.2945, altitude: Some(-3.5) }), + width: Some(8000), + height: Some(6000), + artist: Some("Ann \"Photo\" Lee".into()), + copyright: Some("© 2022".into()), + copyright_marked: Some(true), + usage_terms: Some("Editorial use only; no ".into()), + copyright_url: Some("https://example.com/licence?a=1&b=2".into()), + title: Some("Tïtle".into()), + caption: Some("Line one\nline two".into()), + alt_text: Some("A tree by the sea".into()), + extended_description: Some("Long ".into()), + sublocation: Some("Champ de Mars".into()), + city: Some("Paris".into()), + state: Some("Île-de-France".into()), + country: Some("France".into()), + keywords: vec!["tree".into(), "sky & sea".into()], + hierarchical_keywords: vec!["Places|France|Paris".into()], + rating: Some(-1), + label: Some("Green".into()), + } +} + +#[test] +fn roundtrip_all_fields() { + let m = full(); + let json = r#"{"version":1,"exposure":0.5,"note":"<&>"}"#; + let x = write_xmp(&m, Some(json)); + let d = parse_xmp(&x).unwrap(); + assert_eq!(d.lc_settings.as_deref(), Some(json)); + let mut got = d.metadata.clone(); + // floats go through rationals + assert!((got.exposure_bias.unwrap() + 1.0 / 3.0).abs() < 1e-5); + got.exposure_bias = m.exposure_bias; + let g = got.gps.unwrap(); + assert!((g.latitude - 48.858222).abs() < 1e-6 && (g.longitude + 2.2945).abs() < 1e-6, "{g:?}"); + got.gps = m.gps; + assert_eq!(got, m); +} + +#[test] +fn empty_metadata_roundtrip() { + let x = write_xmp(&Metadata::default(), None); + let d = parse_xmp(&x).unwrap(); + assert_eq!(d.metadata, Metadata::default()); + assert_eq!(d.lc_settings, None); +} + +#[test] +fn foreign_namespace_cannot_impersonate_known_metadata() { + for body in [ + r#"xmp:Rating="2" lc:settings="foreign settings"/>"#, + r#">2foreign settings"#, + ] { + let packet = format!( + r#""# + ); + let d = parse_xmp(&packet).unwrap(); + assert_eq!(d.metadata.rating, None, "foreign rating: {packet}"); + assert_eq!(d.lc_settings, None, "foreign settings: {packet}"); + assert!(!d.properties.contains_key("xmp:Rating")); + assert!(!d.values.contains_key("lc:settings")); + assert!(d.properties.values().any(|v| v == &["foreign settings"])); + assert!(d.properties.keys().all(|key| !key.contains('/')), "opaque names must not create struct paths"); + } +} + +#[test] +fn foreign_namespace_names_are_distinct_and_scoped() { + let packet = r#" + + 1 + 2 + 4 + "#; + let d = parse_xmp(packet).unwrap(); + assert_eq!(d.metadata.rating, Some(4), "a local rebinding must not escape its scope"); + assert_eq!(d.properties.len(), 3, "different foreign URIs must not collide"); + assert_eq!(d.values.len(), 3); + assert_eq!(d.properties["xmp:Rating"], vec!["4"]); + assert!(d.properties.values().any(|v| v == &["1"])); + assert!(d.properties.values().any(|v| v == &["2"])); +} + +#[test] +fn attribute_form_foreign_prefixes_and_structs() { + let x = r#" + + TitelTitle & more é + + k1k2 + "#; + let d = parse_xmp(x).unwrap(); + let m = d.metadata; + assert_eq!(m.rating, Some(4)); + assert_eq!(m.label.as_deref(), Some("Blue")); + assert_eq!(m.f_number, Some(4.0)); + assert_eq!(m.title.as_deref(), Some("Title & more é")); + assert_eq!(m.keywords, vec!["k1".to_string(), "k2".to_string()]); + assert_eq!(m.flash, Some(Flash { fired: false, raw: 0x10 })); + let g = m.gps.unwrap(); + assert!((g.latitude - 10.5).abs() < 1e-9); + assert!((g.longitude + 20.26).abs() < 1e-9); + assert_eq!(d.properties["dc:title"], vec!["Title & more é".to_string(), "Titel".to_string()]); +} + +/// Copyright status, usage terms and info URL (XMP Rights Management schema): written as +/// `xmpRights:Marked` (Boolean), `xmpRights:UsageTerms` (Lang Alt) and `xmpRights:WebStatement` (URI). +#[test] +fn rights_management_fields() { + let m = Metadata { copyright_marked: Some(false), usage_terms: Some("CC0".into()), ..Default::default() }; + let x = write_xmp(&m, None); + assert!(x.contains("False"), "{x}"); + assert!(x.contains("\n \n CC0"), "{x}"); + assert!(!x.contains("WebStatement"), "unset fields are left out"); + assert_eq!(parse_xmp(&x).unwrap().metadata, m); + // attribute form, other prefixes; anything but True / False is "unknown" + let read = |marked: &str| { + let x = format!( + r#" + + "# + ); + parse_xmp(&x).unwrap().metadata + }; + assert_eq!(read("True").copyright_marked, Some(true)); + assert_eq!(read("false").copyright_marked, Some(false)); + assert_eq!(read("").copyright_marked, None); + assert_eq!(read("maybe").copyright_marked, None); + assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c")); +} + +/// A zero or negative `exif:ISOSpeedRatings` is "no ISO" (as in the EXIF reader), not `Some(0)`, and +/// must not hide a valid `exifEX:PhotographicSensitivity`. +#[test] +fn non_positive_iso_is_ignored() { + let read = |attrs: &str| { + let x = format!( + r#" + + "# + ); + parse_xmp(&x).unwrap().metadata.iso + }; + assert_eq!(read(r#"exif:ISOSpeedRatings="0" exifEX:PhotographicSensitivity="400""#), Some(400)); + assert_eq!(read(r#"exif:ISOSpeedRatings="-100""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="0""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200)); +} + +#[test] +fn malformed_inputs() { + assert!(parse_xmp("").is_ok() || parse_xmp("").is_err()); + assert_eq!(parse_xmp("").unwrap().metadata, Metadata::default()); + assert_eq!(parse_xmp("not xml at all").unwrap().metadata, Metadata::default()); + let deep = "".repeat(1000); + assert_eq!(parse_xmp(&deep), Err(XmpError::TooComplex)); + let x = write_xmp(&full(), Some("{}")); + for n in (0..x.len()).step_by(7) { + if x.is_char_boundary(n) { + let _ = parse_xmp(&x[..n]); + } + } +} + +#[test] +fn lc_extras_and_crs_prefix() { + let x = write_xmp_lc(&Metadata::default(), &[("settings", "{\"a\":1}"), ("flag", "pick"), ("bad name", "x")]); + assert!(!x.contains("xmlns:crs"), "we never write crs:"); + let d = parse_xmp(&x).unwrap(); + assert_eq!(d.lc_settings.as_deref(), Some("{\"a\":1}")); + assert_eq!(d.properties["lc:flag"], vec!["pick".to_string()]); + assert!(!x.contains("bad name")); + let foreign = r#""#; + assert_eq!(parse_xmp(foreign).unwrap().properties["crs:Exposure2012"], vec!["+0.5".to_string()]); +} + +#[test] +fn gps_coords() { + assert_eq!(parse_gps_coord("45,30N"), Some(45.5)); + assert_eq!(parse_gps_coord("45,30,36S"), Some(-45.51)); + assert_eq!(parse_gps_coord("45X"), None); + assert_eq!(parse_gps_coord(""), None); + assert_eq!(parse_gps_coord("é"), None); + assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); + assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N"); + assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W"); +} + +/// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°. +#[test] +fn xmp_gps_out_of_range_rejected() { + let gps = |lat: &str, lon: &str| { + let x = format!( + r#""# + ); + parse_xmp(&x).unwrap().metadata.gps + }; + assert_eq!(gps("95,0N", "200,30E"), None); + assert_eq!(gps("95,0N", "10,0E"), None); + assert_eq!(gps("10,0S", "200,30W"), None); + assert_eq!(gps("90,0,1N", "0,0E"), None); + assert_eq!(gps("90,0N", "180,0E"), Some(Gps { latitude: 90.0, longitude: 180.0, altitude: None })); + assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); +} + +/// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how +/// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one +/// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas +/// (the default, and the only unit real files use) don't need it. +#[test] +fn mwg_regions_element_form() { + let x = r#" + + + + + + + + Jane Doe + Face + + + + + + + Pet + Rex + Good boy + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 3, "{regions:?}"); + + assert_eq!(regions[0].kind, RegionKind::Face); + assert_eq!(regions[0].name.as_deref(), Some("Jane Doe")); + assert_eq!(regions[0].description, None); + let r = regions[0].rect; + assert!((r.x0 - 0.4).abs() < 1e-9 && (r.x1 - 0.6).abs() < 1e-9, "{r:?}"); + assert!((r.y0 - 0.25).abs() < 1e-9 && (r.y1 - 0.55).abs() < 1e-9, "{r:?}"); + + // No mwg-rs:Type or mwg-rs:Name at all: still a usable region, kind falls back to `Other("")`. + assert_eq!(regions[1].kind, RegionKind::Other(String::new())); + assert_eq!(regions[1].name, None); + + assert_eq!(regions[2].kind, RegionKind::Pet); + assert_eq!(regions[2].name.as_deref(), Some("Rex")); + assert_eq!(regions[2].description.as_deref(), Some("Good boy")); +} + +/// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame +/// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the +/// upright frame. +#[test] +fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() { + let x = r#" + + + + + + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 5, "{regions:?}"); + let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9); + assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect); + assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect); + assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect); + assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect); + assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect); +} + +/// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an +/// `rdf:li rdf:parseType="Resource"`), `mwg-rs:Type` as an attribute instead of an element, and an +/// unrecognised `mwg-rs:Type` value kept verbatim. +#[test] +fn mwg_regions_attribute_form_and_unknown_type() { + let x = r#" + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 2, "{regions:?}"); + assert_eq!(regions[0].kind, RegionKind::BarCode); + assert_eq!(regions[1].kind, RegionKind::Other("Sticker".to_string())); +} + +/// Hostile/malformed regions are dropped individually, never panic, and never poison the rest of +/// the list: missing Area, non-finite or absurd numbers, zero/negative size, and a pixel-unit area +/// with no `AppliedToDimensions` to convert it (so it can't be stored as a nonsense fraction). +#[test] +fn mwg_regions_hostile_inputs_are_dropped_not_panicking() { + let x = r#" + + + + + No Area + + + + + + Face + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 1, "only the last, well-formed region should survive: {regions:?}"); + assert_eq!(regions[0].kind, RegionKind::Face); +} + +/// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a +/// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view +/// would then draw or crop far outside the image. +#[test] +fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() { + let x = r#" + + + + + Far right + Far left + Touching edge + Huge + Corner + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + let names: Vec<_> = regions.iter().map(|r| r.name.as_deref().unwrap_or("")).collect(); + assert_eq!(names, ["Huge", "Corner"], "{regions:?}"); + for r in ®ions { + let q = r.rect; + assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}"); + } + assert_eq!(regions[0].rect, Rect::UNIT); + let c = regions[1].rect; + assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}"); +} + +/// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords). +#[test] +fn regions_merge_like_keywords() { + let with_region = Region { rect: Rect::from_center(Point::new(0.5, 0.5), 0.2, 0.2), kind: RegionKind::Face, name: None, description: None }; + let mut a = Metadata::default(); + let b = Metadata { regions: vec![with_region.clone()], ..Default::default() }; + a.fill_missing(&b); + assert_eq!(a.regions, vec![with_region.clone()]); + + let mut a = Metadata::default(); + a.overlay_user_fields(&b); + assert_eq!(a.regions, vec![with_region]); +} + +#[test] +fn writer_drops_characters_xml_1_0_forbids() { + let bad = "a\u{1}b\u{0}c\u{FFFE}d\u{FFFF}e\u{1F}\tf\ng\rh"; + let m = Metadata { make: Some(bad.into()), caption: Some(bad.into()), keywords: vec![bad.into()], ..Default::default() }; + let x = write_xmp_lc(&m, &[("settings", bad)]); + let illegal: Vec = + x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect(); + assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}"); + assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}"); +} + +#[test] +fn numeric_references_to_forbidden_characters_are_not_decoded() { + assert_eq!(entity("#1"), None); + assert_eq!(entity("#0"), None); + assert_eq!(entity("#xFFFE"), None); + assert_eq!(entity("#x41"), Some('A')); + assert_eq!(entity("#9"), Some('\t')); +} + +/// Flattened keys repeat their whole ancestor path, so a long nested name with a wide child +/// fan-out would otherwise retain its prefix once per leaf: the cumulative key budget stops it +/// (a 65 KB document with 3000 leaves must not retain ~2.4 MB of key text). +#[test] +fn flattened_keys_stay_within_the_cumulative_budget() { + let level = "n".repeat(200); + let mut doc = String::from( + "", + ); + for _ in 0..4 { + doc.push_str(&format!("")); + } + // each leaf has a name of its own, so each one adds a key + for i in 0..3000 { + doc.push_str(&format!("")); + } + doc.push_str(""); + for _ in 0..4 { + doc.push_str(&format!("")); + } + doc.push_str(""); + let d = parse_xmp(&doc).unwrap(); + let total: usize = d.properties.keys().map(String::len).sum(); + assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget"); + assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent"); + assert!(!d.properties.is_empty(), "properties parsed before the budget are kept"); +}