| /* |
| * Copyright 2026 Google LLC |
| * |
| * Use of this source code is governed by a BSD-style license that can be |
| * found in the LICENSE file. |
| */ |
| |
| #include "rust/exif/FFI.h" |
| #include "rust/exif/FFI.rs.h" |
| #include "include/codec/SkEncodedOrigin.h" |
| #include "include/private/SkExif.h" |
| #include "tests/Test.h" |
| #include "tools/Resources.h" |
| |
| #include <cmath> |
| |
| // Helpers: build minimal EXIF blobs (big- and little-endian) for unit tests. |
| |
| // Writes a 2-byte big-endian u16 into `buf` at `off`. |
| static void write_u16_be(std::vector<uint8_t>& buf, size_t off, uint16_t v) { |
| buf[off] = static_cast<uint8_t>(v >> 8); |
| buf[off + 1] = static_cast<uint8_t>(v); |
| } |
| |
| // Writes a 4-byte big-endian u32 into `buf` at `off`. |
| static void write_u32_be(std::vector<uint8_t>& buf, size_t off, uint32_t v) { |
| buf[off] = static_cast<uint8_t>(v >> 24); |
| buf[off + 1] = static_cast<uint8_t>(v >> 16); |
| buf[off + 2] = static_cast<uint8_t>(v >> 8); |
| buf[off + 3] = static_cast<uint8_t>(v); |
| } |
| |
| // Writes a 2-byte little-endian u16 into `buf` at `off`. |
| static void write_u16_le(std::vector<uint8_t>& buf, size_t off, uint16_t v) { |
| buf[off] = static_cast<uint8_t>(v); |
| buf[off + 1] = static_cast<uint8_t>(v >> 8); |
| } |
| |
| // Writes a 4-byte little-endian u32 into `buf` at `off`. |
| static void write_u32_le(std::vector<uint8_t>& buf, size_t off, uint32_t v) { |
| buf[off] = static_cast<uint8_t>(v); |
| buf[off + 1] = static_cast<uint8_t>(v >> 8); |
| buf[off + 2] = static_cast<uint8_t>(v >> 16); |
| buf[off + 3] = static_cast<uint8_t>(v >> 24); |
| } |
| |
| // Returns a TIFF header (8 bytes, big-endian) pointing to IFD at `ifd_offset`. |
| static std::vector<uint8_t> tiff_header_be(uint32_t ifd_offset = 8) { |
| return {'M', 'M', 0x00, 0x2a, |
| static_cast<uint8_t>(ifd_offset >> 24), |
| static_cast<uint8_t>(ifd_offset >> 16), |
| static_cast<uint8_t>(ifd_offset >> 8), |
| static_cast<uint8_t>(ifd_offset)}; |
| } |
| |
| // Returns a TIFF header (8 bytes, little-endian) pointing to IFD at `ifd_offset`. |
| static std::vector<uint8_t> tiff_header_le(uint32_t ifd_offset = 8) { |
| return {'I', 'I', 0x2a, 0x00, |
| static_cast<uint8_t>(ifd_offset), |
| static_cast<uint8_t>(ifd_offset >> 8), |
| static_cast<uint8_t>(ifd_offset >> 16), |
| static_cast<uint8_t>(ifd_offset >> 24)}; |
| } |
| |
| // Builds a single-IFD little-endian EXIF blob containing only an Orientation tag. |
| static std::vector<uint8_t> make_orientation_exif_le(uint16_t orientation) { |
| std::vector<uint8_t> buf(26, 0); |
| auto hdr = tiff_header_le(8); |
| std::copy(hdr.begin(), hdr.end(), buf.begin()); |
| write_u16_le(buf, 8, 1); // IFD entry count = 1 |
| write_u16_le(buf, 10, 0x0112); // tag: Orientation |
| write_u16_le(buf, 12, 3); // type: SHORT |
| write_u32_le(buf, 14, 1); // count |
| write_u16_le(buf, 18, orientation); // value |
| write_u32_le(buf, 22, 0); // next IFD offset |
| return buf; |
| } |
| |
| // Builds a single-IFD EXIF blob containing only a SHORT Orientation tag. |
| static std::vector<uint8_t> make_orientation_exif_be(uint16_t orientation) { |
| // Header (8) + entry count (2) + 1 entry (12) + next IFD offset (4) = 26 bytes |
| std::vector<uint8_t> buf(26, 0); |
| // TIFF header pointing to IFD at offset 8. |
| auto hdr = tiff_header_be(8); |
| std::copy(hdr.begin(), hdr.end(), buf.begin()); |
| // IFD entry count = 1. |
| write_u16_be(buf, 8, 1); |
| // Entry: tag=0x0112 (Orientation), type=3 (SHORT), count=1, value=orientation. |
| write_u16_be(buf, 10, 0x0112); // tag |
| write_u16_be(buf, 12, 3); // type SHORT |
| write_u32_be(buf, 14, 1); // count |
| write_u16_be(buf, 18, orientation); // value (fits in 4 bytes inline, BE) |
| // next IFD = 0 (no more IFDs). |
| write_u32_be(buf, 22, 0); |
| return buf; |
| } |
| |
| // Builds a single-IFD EXIF blob with XResolution, YResolution, ResolutionUnit. |
| static std::vector<uint8_t> make_resolution_exif_be(float xres, float yres, uint16_t unit) { |
| // The RATIONAL values (8 bytes each) are stored after the IFD. |
| // Header=8, count=2, entries=3×12=36, next_ifd=4 → data at offset 52. |
| // XResolution → RATIONAL at offset 52, YResolution at 60. |
| const uint32_t xres_off = 52, yres_off = 60; |
| std::vector<uint8_t> buf(68, 0); |
| |
| auto hdr = tiff_header_be(8); |
| std::copy(hdr.begin(), hdr.end(), buf.begin()); |
| write_u16_be(buf, 8, 3); // 3 entries |
| |
| // Entry 0: ResolutionUnit (tag 0x0128, SHORT, 1, unit) |
| write_u16_be(buf, 10, 0x0128); |
| write_u16_be(buf, 12, 3); // SHORT |
| write_u32_be(buf, 14, 1); |
| write_u16_be(buf, 18, unit); |
| |
| // Entry 1: XResolution (tag 0x011a, RATIONAL, 1, offset) |
| write_u16_be(buf, 22, 0x011a); |
| write_u16_be(buf, 24, 5); // RATIONAL |
| write_u32_be(buf, 26, 1); |
| write_u32_be(buf, 30, xres_off); |
| |
| // Entry 2: YResolution (tag 0x011b, RATIONAL, 1, offset) |
| write_u16_be(buf, 34, 0x011b); |
| write_u16_be(buf, 36, 5); // RATIONAL |
| write_u32_be(buf, 38, 1); |
| write_u32_be(buf, 42, yres_off); |
| |
| // next IFD offset = 0 |
| write_u32_be(buf, 46, 0); |
| |
| // XResolution rational: (xres * 1000) / 1000 → numerator/denominator |
| auto xnum = static_cast<uint32_t>(xres * 1000.0f + 0.5f); |
| write_u32_be(buf, xres_off, xnum); |
| write_u32_be(buf, xres_off + 4, 1000); |
| |
| auto ynum = static_cast<uint32_t>(yres * 1000.0f + 0.5f); |
| write_u32_be(buf, yres_off, ynum); |
| write_u32_be(buf, yres_off + 4, 1000); |
| |
| return buf; |
| } |
| |
| // Unit tests: parse_exif() + ToSkExifMetadata() on synthetic EXIF data. |
| |
| DEF_TEST(RustExif_empty_data_returns_false, r) { |
| rust_exif::ExifMetadata meta; |
| bool ok = rust_exif::parse_exif(rust::Slice<const uint8_t>(nullptr, 0), meta); |
| REPORTER_ASSERT(r, !ok); |
| } |
| |
| DEF_TEST(RustExif_invalid_data_returns_false, r) { |
| rust_exif::ExifMetadata meta; |
| const uint8_t garbage[] = {'n', 'o', 't', ' ', 'e', 'x', 'i', 'f'}; |
| bool ok = rust_exif::parse_exif( |
| rust::Slice<const uint8_t>(garbage, sizeof(garbage)), meta); |
| REPORTER_ASSERT(r, !ok); |
| } |
| |
| DEF_TEST(RustExif_orientation_parsing, r) { |
| for (uint16_t orient = 1; orient <= 8; ++orient) { |
| auto blob = make_orientation_exif_be(orient); |
| rust_exif::ExifMetadata meta; |
| bool ok = rust_exif::parse_exif( |
| rust::Slice<const uint8_t>(blob.data(), blob.size()), meta); |
| REPORTER_ASSERT(r, ok); |
| REPORTER_ASSERT(r, meta.has_origin); |
| REPORTER_ASSERT(r, meta.origin == static_cast<uint32_t>(orient)); |
| |
| // Verify ToSkExifMetadata maps it to the right SkEncodedOrigin. |
| SkExif::Metadata sk_meta; |
| rust_exif::ToSkExifMetadata(meta, &sk_meta); |
| REPORTER_ASSERT(r, sk_meta.fOrigin.has_value()); |
| REPORTER_ASSERT(r, sk_meta.fOrigin.value() == static_cast<SkEncodedOrigin>(orient)); |
| } |
| } |
| |
| DEF_TEST(RustExif_orientation_out_of_range_ignored, r) { |
| // Orientation value 0 is invalid (EXIF spec says 1-8). |
| auto blob = make_orientation_exif_be(0); |
| rust_exif::ExifMetadata meta; |
| rust_exif::parse_exif(rust::Slice<const uint8_t>(blob.data(), blob.size()), meta); |
| REPORTER_ASSERT(r, !meta.has_origin); |
| |
| // Orientation value 9 is out of range. |
| blob = make_orientation_exif_be(9); |
| rust_exif::parse_exif(rust::Slice<const uint8_t>(blob.data(), blob.size()), meta); |
| REPORTER_ASSERT(r, !meta.has_origin); |
| } |
| |
| DEF_TEST(RustExif_little_endian_parsing, r) { |
| // EXIF supports both big-endian ('M','M') and little-endian ('I','I') byte order. |
| // Verify little-endian blobs are parsed correctly. |
| for (uint16_t orient = 1; orient <= 8; ++orient) { |
| auto blob = make_orientation_exif_le(orient); |
| rust_exif::ExifMetadata meta; |
| bool ok = rust_exif::parse_exif( |
| rust::Slice<const uint8_t>(blob.data(), blob.size()), meta); |
| REPORTER_ASSERT(r, ok); |
| REPORTER_ASSERT(r, meta.has_origin); |
| REPORTER_ASSERT(r, meta.origin == static_cast<uint32_t>(orient)); |
| } |
| } |
| |
| DEF_TEST(RustExif_resolution_parsing, r) { |
| constexpr float kEpsilon = 0.0001f; |
| auto blob = make_resolution_exif_be(72.0f, 72.0f, 2 /*inch*/); |
| rust_exif::ExifMetadata meta; |
| bool ok = rust_exif::parse_exif( |
| rust::Slice<const uint8_t>(blob.data(), blob.size()), meta); |
| REPORTER_ASSERT(r, ok); |
| REPORTER_ASSERT(r, meta.has_resolution_unit); |
| REPORTER_ASSERT(r, meta.resolution_unit == 2); |
| REPORTER_ASSERT(r, meta.has_x_resolution); |
| REPORTER_ASSERT(r, std::abs(meta.x_resolution - 72.0f) < kEpsilon); |
| REPORTER_ASSERT(r, meta.has_y_resolution); |
| REPORTER_ASSERT(r, std::abs(meta.y_resolution - 72.0f) < kEpsilon); |
| |
| // Verify ToSkExifMetadata conversion. |
| SkExif::Metadata sk_meta; |
| rust_exif::ToSkExifMetadata(meta, &sk_meta); |
| REPORTER_ASSERT(r, sk_meta.fResolutionUnit.has_value()); |
| REPORTER_ASSERT(r, sk_meta.fResolutionUnit.value() == 2); |
| REPORTER_ASSERT(r, sk_meta.fXResolution.has_value()); |
| REPORTER_ASSERT(r, std::abs(sk_meta.fXResolution.value() - 72.0f) < kEpsilon); |
| REPORTER_ASSERT(r, sk_meta.fYResolution.has_value()); |
| REPORTER_ASSERT(r, std::abs(sk_meta.fYResolution.value() - 72.0f) < kEpsilon); |
| } |
| |
| // Builds a single-IFD EXIF blob with PixelXDimension and PixelYDimension tags. |
| static std::vector<uint8_t> make_pixel_dimensions_exif_be(uint16_t width, uint16_t height) { |
| // Header=8, count=2, entries=2×12=24, next_ifd=4 → 38 bytes. |
| std::vector<uint8_t> buf(38, 0); |
| auto hdr = tiff_header_be(8); |
| std::copy(hdr.begin(), hdr.end(), buf.begin()); |
| write_u16_be(buf, 8, 2); // 2 entries |
| |
| // Entry 0: PixelXDimension (tag 0xa002, SHORT, 1, width) |
| write_u16_be(buf, 10, 0xa002); |
| write_u16_be(buf, 12, 3); // SHORT |
| write_u32_be(buf, 14, 1); |
| write_u16_be(buf, 18, width); |
| |
| // Entry 1: PixelYDimension (tag 0xa003, SHORT, 1, height) |
| write_u16_be(buf, 22, 0xa003); |
| write_u16_be(buf, 24, 3); // SHORT |
| write_u32_be(buf, 26, 1); |
| write_u16_be(buf, 30, height); |
| |
| // next IFD = 0 |
| write_u32_be(buf, 34, 0); |
| return buf; |
| } |
| |
| DEF_TEST(RustExif_pixel_dimensions_parsing, r) { |
| auto blob = make_pixel_dimensions_exif_be(1920, 1080); |
| rust_exif::ExifMetadata meta; |
| bool ok = rust_exif::parse_exif( |
| rust::Slice<const uint8_t>(blob.data(), blob.size()), meta); |
| REPORTER_ASSERT(r, ok); |
| REPORTER_ASSERT(r, meta.has_pixel_x_dimension); |
| REPORTER_ASSERT(r, meta.pixel_x_dimension == 1920u); |
| REPORTER_ASSERT(r, meta.has_pixel_y_dimension); |
| REPORTER_ASSERT(r, meta.pixel_y_dimension == 1080u); |
| |
| SkExif::Metadata sk_meta; |
| rust_exif::ToSkExifMetadata(meta, &sk_meta); |
| REPORTER_ASSERT(r, sk_meta.fPixelXDimension.has_value()); |
| REPORTER_ASSERT(r, sk_meta.fPixelXDimension.value() == 1920u); |
| REPORTER_ASSERT(r, sk_meta.fPixelYDimension.has_value()); |
| REPORTER_ASSERT(r, sk_meta.fPixelYDimension.value() == 1080u); |
| } |
| |
| // Equivalence tests: compare Rust parser against SkExif::Parse on real files. |
| |
| static bool approx_eq_f(float a, float b, float eps) { |
| return std::abs(a - b) < eps; |
| } |
| |
| // Compares a Rust-parsed ExifMetadata (converted to SkExif::Metadata) against |
| // the reference C++ SkExif::Parse result for the given resource EXIF file. |
| static void check_equivalence(skiatest::Reporter* r, const char* resource_path) { |
| sk_sp<SkData> data = GetResourceAsData(resource_path); |
| if (!data) { |
| INFOF(r, "Skipping equivalence test for %s: resource not available.", resource_path); |
| return; |
| } |
| |
| // C++ reference parse. |
| SkExif::Metadata cpp_meta; |
| SkExif::Parse(cpp_meta, data.get()); |
| |
| // Rust parse + conversion. |
| rust_exif::ExifMetadata rust_raw; |
| bool rust_ok = rust_exif::parse_exif( |
| rust::Slice<const uint8_t>( |
| reinterpret_cast<const uint8_t*>(data->data()), data->size()), |
| rust_raw); |
| SkExif::Metadata rust_meta; |
| if (rust_ok) { |
| rust_exif::ToSkExifMetadata(rust_raw, &rust_meta); |
| } |
| |
| constexpr float kEpsilon = 0.0001f; |
| |
| // fOrigin |
| REPORTER_ASSERT(r, |
| cpp_meta.fOrigin.has_value() == rust_meta.fOrigin.has_value(), |
| "[%s] fOrigin presence mismatch", resource_path); |
| if (cpp_meta.fOrigin.has_value() && rust_meta.fOrigin.has_value()) { |
| REPORTER_ASSERT(r, |
| cpp_meta.fOrigin.value() == rust_meta.fOrigin.value(), |
| "[%s] fOrigin value mismatch: cpp=%d rust=%d", |
| resource_path, |
| static_cast<int>(cpp_meta.fOrigin.value()), |
| static_cast<int>(rust_meta.fOrigin.value())); |
| } |
| |
| // fHdrHeadroom |
| REPORTER_ASSERT(r, |
| cpp_meta.fHdrHeadroom.has_value() == rust_meta.fHdrHeadroom.has_value(), |
| "[%s] fHdrHeadroom presence mismatch", resource_path); |
| if (cpp_meta.fHdrHeadroom.has_value() && rust_meta.fHdrHeadroom.has_value()) { |
| REPORTER_ASSERT(r, |
| approx_eq_f(cpp_meta.fHdrHeadroom.value(), |
| rust_meta.fHdrHeadroom.value(), |
| kEpsilon), |
| "[%s] fHdrHeadroom value mismatch: cpp=%f rust=%f", |
| resource_path, |
| cpp_meta.fHdrHeadroom.value(), |
| rust_meta.fHdrHeadroom.value()); |
| } |
| |
| // fResolutionUnit |
| REPORTER_ASSERT(r, |
| cpp_meta.fResolutionUnit.has_value() == rust_meta.fResolutionUnit.has_value(), |
| "[%s] fResolutionUnit presence mismatch", resource_path); |
| if (cpp_meta.fResolutionUnit.has_value() && rust_meta.fResolutionUnit.has_value()) { |
| REPORTER_ASSERT(r, |
| cpp_meta.fResolutionUnit.value() == rust_meta.fResolutionUnit.value(), |
| "[%s] fResolutionUnit value mismatch: cpp=%d rust=%d", |
| resource_path, |
| cpp_meta.fResolutionUnit.value(), |
| rust_meta.fResolutionUnit.value()); |
| } |
| |
| // fXResolution |
| REPORTER_ASSERT(r, |
| cpp_meta.fXResolution.has_value() == rust_meta.fXResolution.has_value(), |
| "[%s] fXResolution presence mismatch", resource_path); |
| if (cpp_meta.fXResolution.has_value() && rust_meta.fXResolution.has_value()) { |
| REPORTER_ASSERT(r, |
| approx_eq_f(cpp_meta.fXResolution.value(), |
| rust_meta.fXResolution.value(), |
| kEpsilon), |
| "[%s] fXResolution value mismatch: cpp=%f rust=%f", |
| resource_path, |
| cpp_meta.fXResolution.value(), |
| rust_meta.fXResolution.value()); |
| } |
| |
| // fYResolution |
| REPORTER_ASSERT(r, |
| cpp_meta.fYResolution.has_value() == rust_meta.fYResolution.has_value(), |
| "[%s] fYResolution presence mismatch", resource_path); |
| if (cpp_meta.fYResolution.has_value() && rust_meta.fYResolution.has_value()) { |
| REPORTER_ASSERT(r, |
| approx_eq_f(cpp_meta.fYResolution.value(), |
| rust_meta.fYResolution.value(), |
| kEpsilon), |
| "[%s] fYResolution value mismatch: cpp=%f rust=%f", |
| resource_path, |
| cpp_meta.fYResolution.value(), |
| rust_meta.fYResolution.value()); |
| } |
| |
| // fPixelXDimension |
| REPORTER_ASSERT(r, |
| cpp_meta.fPixelXDimension.has_value() == |
| rust_meta.fPixelXDimension.has_value(), |
| "[%s] fPixelXDimension presence mismatch", resource_path); |
| if (cpp_meta.fPixelXDimension.has_value() && rust_meta.fPixelXDimension.has_value()) { |
| REPORTER_ASSERT(r, |
| cpp_meta.fPixelXDimension.value() == |
| rust_meta.fPixelXDimension.value(), |
| "[%s] fPixelXDimension value mismatch: cpp=%u rust=%u", |
| resource_path, |
| cpp_meta.fPixelXDimension.value(), |
| rust_meta.fPixelXDimension.value()); |
| } |
| |
| // fPixelYDimension |
| REPORTER_ASSERT(r, |
| cpp_meta.fPixelYDimension.has_value() == |
| rust_meta.fPixelYDimension.has_value(), |
| "[%s] fPixelYDimension presence mismatch", resource_path); |
| if (cpp_meta.fPixelYDimension.has_value() && rust_meta.fPixelYDimension.has_value()) { |
| REPORTER_ASSERT(r, |
| cpp_meta.fPixelYDimension.value() == |
| rust_meta.fPixelYDimension.value(), |
| "[%s] fPixelYDimension value mismatch: cpp=%u rust=%u", |
| resource_path, |
| cpp_meta.fPixelYDimension.value(), |
| rust_meta.fPixelYDimension.value()); |
| } |
| } |
| |
| DEF_TEST(RustExif_equivalence_with_resource_files, r) { |
| // Test against the same EXIF files used in ExifTest.cpp. |
| check_equivalence(r, "images/test0-hdr.exif"); |
| check_equivalence(r, "images/test1-pixel32.exif"); |
| check_equivalence(r, "images/test2-nonuniform.exif"); |
| check_equivalence(r, "images/test3-little-endian.exif"); |
| } |