From d10aa02fbff8f415ec2f9a8e7caeef13c218c669 Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 19:54:42 -0500 Subject: [PATCH 01/16] fix(composite): preserve raw APEX conversion precision --- src/composite/mod.rs | 10 ++++++++-- src/core/exiftool_compat.rs | 38 +++++++++++++++++++++++-------------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/composite/mod.rs b/src/composite/mod.rs index ec37369d4..023a4993d 100644 --- a/src/composite/mod.rs +++ b/src/composite/mod.rs @@ -86,8 +86,7 @@ fn lookup_rank(key: &str) -> (u8, &str) { (rank, key) } -/// Look up one fully-qualified key, applying the same APEX ValueConv the EXIF -/// emitter applies before printing. +/// Look up one fully-qualified key, applying APEX ValueConv but not PrintConv. /// /// ExifTool's Composite table (Exif.pm:4678) reads `$val[N]` post-ValueConv: /// for ShutterSpeedValue/ApertureValue/MaxApertureValue that means seconds and @@ -96,6 +95,8 @@ fn lookup_rank(key: &str) -> (u8, &str) { /// only runs at CLI output time, after composites have already been derived. /// Reusing [`crate::core::exiftool_compat::apex_value_conv`] here keeps the /// conversion in one place rather than re-deriving `2**(-$val)` a second time. +/// The helper returns a raw numeric value, so downstream composites retain the +/// precision that an emitted `1/152` or `4.7` PrintConv string would discard. fn lookup_key(map: &MetadataMap, key: &str) -> Option { if let Some(v) = map.value_form(key) { return Some(v.to_string()); @@ -543,6 +544,11 @@ mod tests { }, ); m.insert("ExifIFD:ISO", TagValue::Integer(100)); + let shutter_value_conv = + lookup_key(&m, "ExifIFD:ShutterSpeedValue").expect("APEX shutter ValueConv"); + let shutter_seconds: f64 = shutter_value_conv.parse().expect("numeric ValueConv"); + assert!((shutter_seconds - 2f64.powf(-7.25)).abs() < f64::EPSILON); + assert_ne!(shutter_value_conv, "1/152"); apply(&mut m); assert_eq!(m.get_string("Composite:ShutterSpeed"), Some("1/152")); assert_eq!(m.get_string("Composite:Aperture"), Some("4.7")); diff --git a/src/core/exiftool_compat.rs b/src/core/exiftool_compat.rs index d3d5cffba..cded7af48 100644 --- a/src/core/exiftool_compat.rs +++ b/src/core/exiftool_compat.rs @@ -772,10 +772,10 @@ pub fn format_tag_value(tag_name: &str, value: &TagValue) -> TagValue { // --------------------------------------------------------------------- // Rule 19b/19c: APEX-stored tags, which DO have a PrintConv and so must // be resolved before the catch-all below turns them into plain numbers. - // Shared with the Composite layer via [`apex_value_conv`] -- the - // Composite table (Exif.pm:4678) reads these ValueConv'd, not raw. + // Keep PrintConv here. The Composite layer consumes the raw ValueConv via + // [`apex_value_conv`] before this output-time rendering step. // --------------------------------------------------------------------- - if let Some(converted) = apex_value_conv(base_name, value) { + if let Some(converted) = apex_print_conv(base_name, value) { return converted; } @@ -853,23 +853,20 @@ pub fn format_tag_value(tag_name: &str, value: &TagValue) -> TagValue { value.clone() } -/// Applies the ValueConv/PrintConv pair for the APEX-stored tags whose raw -/// rational is not the value a reader wants. +/// Applies ValueConv for an APEX-stored rational without applying PrintConv. /// /// ApertureValue (Exif.pm:2327-2335) and MaxApertureValue (Exif.pm:2350-2359) /// are both `ValueConv => '2 ** ($val / 2)', PrintConv => 'sprintf("%.1f",$val)'` -/// -- stored as an APEX value, displayed as an F number. The stored 3.625 in -/// CanonRaw.cr3 is `3.5`, not `3.625`. +/// -- stored as an APEX value. The stored 3.625 in CanonRaw.cr3 converts to +/// the f-number 3.5 before its one-decimal PrintConv is applied. /// /// ShutterSpeedValue (Exif.pm:2317-2326) is /// `ValueConv => 'IsFloat($val) && abs($val)<100 ? 2**(-$val) : 0'`, /// `PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'`. /// -/// [`format_tag_value`] uses this for the emitted `Group:Name` value; the -/// Composite layer (`src/composite/mod.rs`) uses the same function so its -/// `Desire`d inputs see seconds and f-stops too, matching Exif.pm:4678's -/// `$val[2]` reading ShutterSpeedValue post-ValueConv rather than the raw -/// APEX exponent. +/// The Composite layer (`src/composite/mod.rs`) consumes this raw numeric +/// value. It must not receive a formatted reciprocal or rounded f-number: +/// ExifTool's Composite table reads post-ValueConv values before PrintConv. pub(crate) fn apex_value_conv(base_name: &str, value: &TagValue) -> Option { let TagValue::Rational { numerator, @@ -884,7 +881,7 @@ pub(crate) fn apex_value_conv(base_name: &str, value: &TagValue) -> Option Option Option { + let TagValue::Float(converted) = apex_value_conv(base_name, value)? else { + return None; + }; + match base_name { + "ApertureValue" | "MaxApertureValue" => Some(TagValue::String(format!("{converted:.1}"))), + "ShutterSpeedValue" => Some(TagValue::String(print_exposure_time(converted))), + _ => None, + } +} + // ============================================================================= // HELPER FUNCTIONS - Special Value Formatting // ============================================================================= From b7cd1298b7ac64b40ce62886726a8719ebbb39f6 Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 19:59:34 -0500 Subject: [PATCH 02/16] fix(fleet): require current-head PR approval --- scripts/parallel_model_fix_loop.py | 78 ++++++++++++++++++++- scripts/test_parallel_model_fix_loop.py | 92 +++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/scripts/parallel_model_fix_loop.py b/scripts/parallel_model_fix_loop.py index 491631de1..1cb89ba01 100644 --- a/scripts/parallel_model_fix_loop.py +++ b/scripts/parallel_model_fix_loop.py @@ -2187,6 +2187,66 @@ def pr_checks_state(pr_ref, repo_root, run_gh=default_run_gh): return "green", detail +def pr_review_state(pr_ref, repo_root, run_gh=default_run_gh): + """("approved"|"changes_requested"|"pending"|"unknown", detail). + + A green CI result says the PR's current code was tested; it does not say + that anybody reviewed it. The fleet publishes unattended, so treat an + unavailable, stale, or absent review as a hard stop rather than merging + on an assumption. Only reviews attached to the PR's current head count: + a review before a subsequent push must not authorize new code, and an old + request for changes must not permanently block a reviewer who approved a + later revision. + """ + rc, out, err = run_gh( + ["pr", "view", pr_ref, "--json", "headRefOid,reviews"], repo_root, + ) + if rc != 0: + return "unknown", (err or out).strip() or f"gh pr view exited {rc}" + try: + payload = json.loads(out) + except ValueError: + return "unknown", "gh pr view returned invalid JSON" + if not isinstance(payload, dict): + return "unknown", f"gh pr view returned {type(payload).__name__}, expected an object" + head = payload.get("headRefOid") + reviews = payload.get("reviews") + if not isinstance(head, str) or not head: + return "unknown", "gh pr view did not report a head SHA" + if not isinstance(reviews, list): + return "unknown", "gh pr view did not report a review list" + + # GitHub returns the full review history. Collapse it to each reviewer's + # latest review on this head, so a later approval supersedes that same + # reviewer's earlier request-for-changes (and vice versa). + latest = {} + for index, review in enumerate(reviews): + if not isinstance(review, dict): + continue + commit = review.get("commit") + if not isinstance(commit, dict) or commit.get("oid") != head: + continue + state = str(review.get("state") or "").upper() + if state not in {"APPROVED", "CHANGES_REQUESTED"}: + continue + author = review.get("author") + login = author.get("login") if isinstance(author, dict) else None + # A missing author must not make two anonymous reviews overwrite one + # another; it also cannot manufacture an approval for a known actor. + reviewer = str(login) if login else f"anonymous-{index}" + ordering = (str(review.get("submittedAt") or ""), index) + prior = latest.get(reviewer) + if prior is None or ordering > prior[0]: + latest[reviewer] = (ordering, state) + + states = [entry[1] for entry in latest.values()] + if "CHANGES_REQUESTED" in states: + return "changes_requested", f"current head {head[:12]} has requested changes" + if "APPROVED" in states: + return "approved", f"current head {head[:12]} has an approval" + return "pending", f"current head {head[:12]} has no approval" + + def wait_for_pr_checks(pr_ref, repo_root, run_gh=default_run_gh, sleep_fn=time.sleep, now_fn=time.monotonic, timeout_seconds=DEFAULT_PR_CHECKS_TIMEOUT_SECONDS, interval_seconds=DEFAULT_PR_CHECKS_INTERVAL_SECONDS, max_unknown_polls=3, @@ -2369,7 +2429,7 @@ def list_open_sweep_prs(repo_root, run_gh=default_run_gh, base_ref="main"): def adopt_open_sweep_prs(*, repo_root, run_gh=default_run_gh, log_fn=print): - """Merge any ALREADY-OPEN sweep PR whose checks are green right now. + """Merge any ALREADY-OPEN sweep PR that is green and reviewed now. Returns one dict per open sweep PR: {"pr", "branch", "checks", "action": "merged"|"left_open"|"merge_failed", "message"}. @@ -2426,8 +2486,15 @@ def adopt_open_sweep_prs(*, repo_root, run_gh=default_run_gh, log_fn=print): adopted.append({"pr": ref, "branch": branch, "checks": state, "action": "left_open", "message": detail}) continue + review_state, review_detail = pr_review_state(ref, repo_root, run_gh) + if review_state != "approved": + log_fn(f"auto-publish: adopting {ref} ({branch}) -- checks are green but reviews are " + f"{review_state} ({review_detail}); leaving it open") + adopted.append({"pr": ref, "branch": branch, "checks": state, "reviews": review_state, + "action": "left_open", "message": review_detail}) + continue log_fn(f"auto-publish: adopting {ref} ({branch}) from an earlier round -- checks are green, " - "merging it now") + "and it is approved, merging it now") try: merged, message = merge_pr(ref, repo_root, run_gh=run_gh) except OSError as e: @@ -2690,6 +2757,13 @@ def sync(): "OPEN for a human and continuing the loop; nothing is merged on anything but green") return {"status": f"checks_{state}", "pr_ref": pr_ref, "checks": detail, **common} + review_state, review_detail = pr_review_state(pr_ref, sweep_repo, run_gh=run_gh) + if review_state != "approved": + log_fn(f"AUTO-PUBLISH: reviews for {pr_ref} are {review_state.upper()} ({review_detail}) -- " + "leaving the PR OPEN for a human; nothing is merged without current-head approval") + return {"status": f"reviews_{review_state}", "pr_ref": pr_ref, + "reviews": review_detail, **common} + merged, merge_message = merge_pr(pr_ref, sweep_repo, run_gh=run_gh) if not merged: log_fn(f"AUTO-PUBLISH: `gh pr merge --squash` failed for {pr_ref}: {merge_message} -- " diff --git a/scripts/test_parallel_model_fix_loop.py b/scripts/test_parallel_model_fix_loop.py index a5f4fe788..fa432bd8f 100644 --- a/scripts/test_parallel_model_fix_loop.py +++ b/scripts/test_parallel_model_fix_loop.py @@ -45,6 +45,7 @@ novel_commits, parse_worktree_list, pr_checks_state, + pr_review_state, pr_ref_from_result, process_format, process_squad_worker, @@ -2862,6 +2863,8 @@ def run_gh(args, repo_root): if args[:2] == ["pr", "checks"]: return 0, checks_by_ref[args[2]], "" if args[:2] == ["pr", "view"]: + if "headRefOid,reviews" in args: + return 0, self.APPROVED_REVIEW, "" return 0, json.dumps({"state": "OPEN"}), "" return merge_rc, "Merged\n", "" if merge_rc == 0 else "not mergeable" return run_gh, calls @@ -2869,6 +2872,11 @@ def run_gh(args, repo_root): GREEN = json.dumps([{"name": "Build & Test", "state": "SUCCESS", "bucket": "pass"}]) RED = json.dumps([{"name": "Lint & Audit", "state": "FAILURE", "bucket": "fail"}]) PENDING = json.dumps([{"name": "Build & Test", "state": "IN_PROGRESS", "bucket": "pending"}]) + APPROVED_REVIEW = json.dumps({ + "headRefOid": "a" * 40, + "reviews": [{"state": "APPROVED", "submittedAt": "2026-08-02T00:00:00Z", + "author": {"login": "reviewer"}, "commit": {"oid": "a" * 40}}], + }) def test_a_since_green_abandoned_pr_is_merged(self): url = "https://github.com/o/r/pull/126" @@ -2894,6 +2902,22 @@ def test_a_still_pending_pr_is_not_waited_on(self): self.assertEqual(sum(1 for a in calls if a[:2] == ["pr", "checks"]), 1) self.assertFalse(any(a[:2] == ["pr", "merge"] for a in calls)) + def test_a_green_pr_without_a_current_head_approval_is_left_open(self): + url = "https://github.com/o/r/pull/126" + run_gh, calls = self._run_gh( + _gh_pr_list((126, "sweep/tags-2026-07-25-2")), {url: self.GREEN}, + ) + + def no_approval(args, repo_root): + if args[:2] == ["pr", "view"] and "headRefOid,reviews" in args: + return 0, json.dumps({"headRefOid": "a" * 40, "reviews": []}), "" + return run_gh(args, repo_root) + + adopted = adopt_open_sweep_prs(repo_root="/repo", run_gh=no_approval, log_fn=lambda *a: None) + self.assertEqual(adopted[0]["action"], "left_open") + self.assertEqual(adopted[0]["reviews"], "pending") + self.assertFalse(any(a[:2] == ["pr", "merge"] for a in calls)) + def test_no_open_sweep_prs_is_no_gh_merge_traffic_at_all(self): run_gh, calls = self._run_gh("[]", {}) self.assertEqual(adopt_open_sweep_prs(repo_root="/repo", run_gh=run_gh, @@ -2934,6 +2958,43 @@ def flaky_gh(args, repo_root): "--delete-branch"], calls) +class PrReviewStateTests(unittest.TestCase): + def _payload(self, reviews): + return json.dumps({"headRefOid": "a" * 40, "reviews": reviews}) + + def test_current_approval_overrides_same_reviewers_old_request_for_changes(self): + reviews = [ + {"state": "CHANGES_REQUESTED", "submittedAt": "2026-08-01T00:00:00Z", + "author": {"login": "reviewer"}, "commit": {"oid": "a" * 40}}, + {"state": "APPROVED", "submittedAt": "2026-08-02T00:00:00Z", + "author": {"login": "reviewer"}, "commit": {"oid": "a" * 40}}, + ] + state, _detail = pr_review_state( + "126", "/repo", lambda args, repo: (0, self._payload(reviews), ""), + ) + self.assertEqual(state, "approved") + + def test_any_current_request_for_changes_blocks_merge(self): + reviews = [ + {"state": "APPROVED", "submittedAt": "2026-08-02T00:00:00Z", + "author": {"login": "one"}, "commit": {"oid": "a" * 40}}, + {"state": "CHANGES_REQUESTED", "submittedAt": "2026-08-02T00:01:00Z", + "author": {"login": "two"}, "commit": {"oid": "a" * 40}}, + ] + state, _detail = pr_review_state( + "126", "/repo", lambda args, repo: (0, self._payload(reviews), ""), + ) + self.assertEqual(state, "changes_requested") + + def test_an_approval_for_an_old_head_does_not_authorize_new_code(self): + reviews = [{"state": "APPROVED", "submittedAt": "2026-08-02T00:00:00Z", + "author": {"login": "reviewer"}, "commit": {"oid": "b" * 40}}] + state, _detail = pr_review_state( + "126", "/repo", lambda args, repo: (0, self._payload(reviews), ""), + ) + self.assertEqual(state, "pending") + + class DefaultRunGhNeverRaisesTests(unittest.TestCase): """Every consumer of a gh runner already copes with a failure tuple (pr_checks_state -> "unknown", merge_pr -> merge_failed with the PR @@ -3071,6 +3132,8 @@ def run_gh(args, repo_root): return 0, list_payload, "" if args[:2] == ["pr", "checks"]: return 0, checks_payload, "" + if args[:2] == ["pr", "view"] and "headRefOid,reviews" in args: + return 0, self.APPROVED_REVIEW, "" return merge_rc, "Merged\n", "" if merge_rc == 0 else "not mergeable" return run_gh @@ -3091,6 +3154,11 @@ def _publish(self, sweep_result, run_gh, **overrides): RED = json.dumps([{"name": "Build & Test", "state": "SUCCESS", "bucket": "pass"}, {"name": "Lint & Audit", "state": "FAILURE", "bucket": "fail"}]) PENDING = json.dumps([{"name": "Build & Test", "state": "IN_PROGRESS", "bucket": "pending"}]) + APPROVED_REVIEW = json.dumps({ + "headRefOid": "a" * 40, + "reviews": [{"state": "APPROVED", "submittedAt": "2026-08-02T00:00:00Z", + "author": {"login": "reviewer"}, "commit": {"oid": "a" * 40}}], + }) def test_all_green_squash_merges_and_then_syncs_worktrees(self): result = self._publish(self.OK_SWEEP, self._run_gh(self.GREEN)) @@ -3137,6 +3205,8 @@ def run_gh(args, repo_root): return 0, "[]", "" if args[:2] == ["pr", "checks"]: return 0, next(answers), "" + if args[:2] == ["pr", "view"] and "headRefOid,reviews" in args: + return 0, self.APPROVED_REVIEW, "" return 0, "Merged\n", "" result = self._publish(self.OK_SWEEP, run_gh) @@ -3144,6 +3214,22 @@ def run_gh(args, repo_root): self.assertFalse(any(args[:2] == ["pr", "merge"] for args in self.gh_calls)) self.assertEqual(self.sync_calls, []) + def test_green_checks_without_review_leave_the_round_pr_open(self): + def no_review(args, repo_root): + self.gh_calls.append(args) + if args[:2] == ["pr", "list"]: + return 0, "[]", "" + if args[:2] == ["pr", "checks"]: + return 0, self.GREEN, "" + if args[:2] == ["pr", "view"] and "headRefOid,reviews" in args: + return 0, json.dumps({"headRefOid": "a" * 40, "reviews": []}), "" + return 1, "", "unexpected gh call" + + result = self._publish(self.OK_SWEEP, no_review) + self.assertEqual(result["status"], "reviews_pending") + self.assertFalse(any(args[:2] == ["pr", "merge"] for args in self.gh_calls)) + self.assertEqual(self.sync_calls, []) + def test_an_abandoned_sweep_pr_from_an_earlier_round_is_adopted_and_merged(self): # MAJOR 4: nothing revisited an already-open sweep PR, and the # sweep cursor had already consumed its stamps -- so a PR that @@ -3388,6 +3474,12 @@ def fake_gh(args, repo_root): {"name": "Lint & Audit", "state": "SUCCESS", "bucket": "pass"}, {"name": "Multi-platform Build", "state": "SKIPPED", "bucket": "skipping"}]), "" + if args[:2] == ["pr", "view"] and "headRefOid,reviews" in args: + return 0, json.dumps({ + "headRefOid": "a" * 40, + "reviews": [{"state": "APPROVED", "submittedAt": "2026-08-02T00:00:00Z", + "author": {"login": "reviewer"}, "commit": {"oid": "a" * 40}}], + }), "" if args[:2] == ["pr", "merge"]: # Stand in for GitHub's squash-merge, FAITHFULLY: GitHub # merges the REMOTE head branch of the PR -- whatever From f6e6577c3b52e157291fb6bb20153f924a202a6d Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 20:04:47 -0500 Subject: [PATCH 03/16] fix(tables): decode repeated scalar fields --- src/exiftool_tables/runtime.rs | 49 +++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src/exiftool_tables/runtime.rs b/src/exiftool_tables/runtime.rs index 52fc06252..359817ef2 100644 --- a/src/exiftool_tables/runtime.rs +++ b/src/exiftool_tables/runtime.rs @@ -18,6 +18,11 @@ pub enum DecodedValue { SignedRational(i32, i32), String(String), Undefined(Vec), + /// A repeated scalar field (`format[N]` in ExifTool's table schema). + /// + /// Scalar fields deliberately retain their existing variants so callers + /// do not need to unwrap a one-element array. + Array(Vec), } impl DecodedValue { @@ -76,9 +81,10 @@ impl DecodedField { /// /// Out-of-range fields and fractional bit-field indices are refused. The /// latter need ExifTool's bit-mask semantics, which the generated schema does -/// not yet carry. This function performs raw decoding only. Callers must opt -/// into `PrintConv` with [`DecodedField::apply_print_conv_to_raw`] after -/// checking whether an intervening `ValueConv` is required. +/// not yet carry. Repeated scalar fields decode as [`DecodedValue::Array`]. +/// This function performs raw decoding only. Callers must opt into `PrintConv` +/// with [`DecodedField::apply_print_conv_to_raw`] after checking whether an +/// intervening `ValueConv` is required. #[must_use] pub fn decode_binary_table( table: &'static BinaryTable, @@ -97,8 +103,17 @@ pub fn decode_binary_table( let offset = usize::try_from(table.byte_offset(field)).ok()?; let format = table.field_format(field); let width = usize::try_from(format.size()).ok()?; - let bytes = data.get(offset..offset.checked_add(width)?)?; - let raw = decode_value(bytes, format, byte_order)?; + let byte_count = width.checked_mul(field.count)?; + let bytes = data.get(offset..offset.checked_add(byte_count)?)?; + let raw = if field.count == 1 { + decode_value(bytes, format, byte_order)? + } else { + let values = bytes + .chunks_exact(width) + .map(|chunk| decode_value(chunk, format, byte_order)) + .collect::>>()?; + DecodedValue::Array(values) + }; Some(DecodedField { field, raw }) }) .collect() @@ -265,6 +280,14 @@ mod tests { count: 1, print_conv: PrintConv::Expr(ExprId::Sprintf0fValB74070), }, + Field { + index: 4, + sub: None, + name: "ThreeValues", + format: Some(Fmt::Int16u), + count: 3, + print_conv: PrintConv::None, + }, ]; static TABLE: BinaryTable = BinaryTable { module: "Test", @@ -276,8 +299,20 @@ mod tests { fields: FIELDS, }; - let fields = decode_binary_table(&TABLE, &[0x12, 0x34, 0xff], ByteOrder::Big); - assert_eq!(fields.len(), 1); + let fields = decode_binary_table( + &TABLE, + &[0x12, 0x34, 0xff, 0, 0, 1, 0, 2, 0, 3], + ByteOrder::Big, + ); + assert_eq!(fields.len(), 2); assert_eq!(fields[0].raw, DecodedValue::Integer(0x3412)); + assert_eq!( + fields[1].raw, + DecodedValue::Array(vec![ + DecodedValue::Integer(1), + DecodedValue::Integer(2), + DecodedValue::Integer(3), + ]), + ); } } From e522a1e7b29aa0e508a93f919bd2b5b122af5340 Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 20:34:27 -0500 Subject: [PATCH 04/16] fix(exif): one Compression table, dumped from ExifTool's own Perl Three transcriptions of `%Image::ExifTool::Exif::compression` (Exif.pm:213) lived in this tree, carrying 50, 40 and 53 ids and disagreeing on the spelling of codes all three claimed to know: core/formatters/exif_enums.rs 32766 => "Next" parsers/tiff/tiff_enums.rs 32766 => "Next" parsers/pdf/mod.rs 32766 => "NeXt or Sony ARW Compressed 2" ExifTool 13.59 says: 32766 => 'NeXt or Sony ARW Compressed 2', #3/Milos Rather than read the Perl and re-implement it -- the way three divergent copies got written in the first place -- the hash was dumped straight out of the Perl symbol table: perl -Ilib -MImage::ExifTool::Exif -e ' my $h = \%Image::ExifTool::Exif::compression; for my $v (0..70000) { print "$v\t", (exists $h->{$v} ? $h->{$v} : "Unknown ($v)"), "\n" }' and each copy scored over all 70,001 inputs: exif_enums::format_compression 50 ids 10 wrong tiff_enums 0x0103 arm 40 ids 15 wrong pdf COMPRESSION_LABELS 53 ids 1 wrong consolidated table 53 ids 0 wrong The wrong answers, all now fixed: `9` read `JBIG B&W` (13.59 renamed it `JBIG B&W or VC-5`); `32766` read `Next`; `33003`, `33004` and `33005` all collapsed to `Aperio JPEG 2000 YCbCr`, where 33005 is `Aperio JPEG 2000 RGB` and 33004 is not an ExifTool key at all; `34926`/`34927` dropped the ` (old)` suffix that separates them from the LibTiff 4.7 codes; and `50000` `Zstd`, `50001` `WebP`, `50002` `JPEG XL (old)`, `52546` `JPEG XL`, plus `32772`, `33003`, `34887`, `34925`, `34933`, `34934` on the TIFF path, were absent. `compression_label` returns `Option<&'static str>` so each caller keeps its established unknown-value behaviour: `format_compression` renders `Unknown (N)` the way `PrintConv` does, while the TIFF and PDF paths still return `None` rather than invent a label ExifTool never prints. Why the old tests stayed green: `test_compression` asserted 1, 6 and 7 -- three codes all three tables agreed on -- and `tiff_enums.rs` had no test module at all. Not one input reached a wrong branch. The new tests assert every one of the 15 codes that was measurably wrong; reverting the table makes 5 of them fail. Corpus check (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`): 393,343 correct before and after, matched-set delta fixed=0 regressed=0. The corpus stores only Compression 1/4/5/6/7/34713 plus two codes ExifTool does not name, so it exercises none of the divergent ids -- the divergence is measured against the Perl, and the corpus proves the consolidation costs nothing. Co-Authored-By: Claude Opus 5 --- src/core/formatters/exif_enums.rs | 223 +++++++++++++++++++++++------- src/parsers/pdf/mod.rs | 75 +--------- src/parsers/tiff/tiff_enums.rs | 133 ++++++++++++------ 3 files changed, 263 insertions(+), 168 deletions(-) diff --git a/src/core/formatters/exif_enums.rs b/src/core/formatters/exif_enums.rs index 21bdc7688..9dada27b2 100644 --- a/src/core/formatters/exif_enums.rs +++ b/src/core/formatters/exif_enums.rs @@ -182,59 +182,124 @@ pub fn format_sensing_method(value: i64) -> String { } } +/// `%Image::ExifTool::Exif::compression` (Exif.pm:213), transcribed verbatim. +/// +/// This is the single copy. Three divergent transcriptions used to exist -- this +/// one, the `0x0103` arm of `parsers::tiff::tiff_enums::tiff_enum_to_string`, +/// and `COMPRESSION_LABELS` in `parsers::pdf` -- carrying 50, 40 and 53 ids +/// respectively. Dumping `%Image::ExifTool::Exif::compression` straight out of +/// the Perl symbol table and scoring all three over every code in 0..=70000 +/// gave 10, 15 and 1 wrong answers; this table gives 0. The lines it is built +/// from, quoted from ExifTool 13.59: +/// +/// ```text +/// 9 => 'JBIG B&W or VC-5', #3 / github411 +/// 32766 => 'NeXt or Sony ARW Compressed 2', #3/Milos +/// 33003 => 'Aperio JPEG 2000 YCbCr', #https://openslide.org/formats/aperio/ +/// 33005 => 'Aperio JPEG 2000 RGB', #https://openslide.org/formats/aperio/ +/// 34926 => 'Zstd (old)', #LibTiff +/// 34927 => 'WebP (old)', #LibTiff +/// 50000 => 'Zstd', #LibTiff 4.7 +/// 50001 => 'WebP', #LibTiff 4.7 +/// 50002 => 'JPEG XL (old)', #LibTiff 4.7 +/// 52546 => 'JPEG XL', # (DNG 1.7) +/// ``` +/// +/// Every one of those ten lines was previously wrong or absent in at least one +/// copy: `9` read `JBIG B&W`, `32766` read `Next`, `33003`/`33004`/`33005` all +/// collapsed to `Aperio JPEG 2000 YCbCr` (and `33004` is not an ExifTool key at +/// all -- it prints `Unknown (33004)`), `34926`/`34927` dropped the ` (old)` +/// suffix that distinguishes them from the LibTiff 4.7 codes, and 50000, 50001, +/// 50002 and 52546 were absent everywhere but the PDF copy. +const COMPRESSION: &[(i64, &str)] = &[ + (1, "Uncompressed"), + (2, "CCITT 1D"), + (3, "T4/Group 3 Fax"), + (4, "T6/Group 4 Fax"), + (5, "LZW"), + (6, "JPEG (old-style)"), + (7, "JPEG"), + (8, "Adobe Deflate"), + (9, "JBIG B&W or VC-5"), + (10, "JBIG Color"), + (99, "JPEG"), + (262, "Kodak 262"), + (32766, "NeXt or Sony ARW Compressed 2"), + (32767, "Sony ARW Compressed"), + (32769, "Packed RAW"), + (32770, "Samsung SRW Compressed"), + (32771, "CCIRLEW"), + (32772, "Samsung SRW Compressed 2"), + (32773, "PackBits"), + (32809, "Thunderscan"), + (32867, "Kodak KDC Compressed"), + (32895, "IT8CTPAD"), + (32896, "IT8LW"), + (32897, "IT8MP"), + (32898, "IT8BL"), + (32908, "PixarFilm"), + (32909, "PixarLog"), + (32946, "Deflate"), + (32947, "DCS"), + (33003, "Aperio JPEG 2000 YCbCr"), + (33005, "Aperio JPEG 2000 RGB"), + (34661, "JBIG"), + (34676, "SGILog"), + (34677, "SGILog24"), + (34712, "JPEG 2000"), + (34713, "Nikon NEF Compressed"), + (34715, "JBIG2 TIFF FX"), + (34718, "Microsoft Document Imaging (MDI) Binary Level Codec"), + ( + 34719, + "Microsoft Document Imaging (MDI) Progressive Transform Codec", + ), + (34720, "Microsoft Document Imaging (MDI) Vector"), + (34887, "ESRI Lerc"), + (34892, "Lossy JPEG"), + (34925, "LZMA2"), + (34926, "Zstd (old)"), + (34927, "WebP (old)"), + (34933, "PNG"), + (34934, "JPEG XR"), + (50000, "Zstd"), + (50001, "WebP"), + (50002, "JPEG XL (old)"), + (52546, "JPEG XL"), + (65000, "Kodak DCR Compressed"), + (65535, "Pentax PEF Compressed"), +]; + +/// Looks up EXIF/TIFF `Compression` (0x0103) in ExifTool's `%compression`. +/// +/// Returns `None` for a code ExifTool does not name, so callers keep their own +/// established behaviour for unknown values -- `format_compression` renders +/// `Unknown (N)` the way `PrintConv` does, while the TIFF and PDF paths leave +/// the tag alone rather than invent a label ExifTool never prints. +/// +/// # Examples +/// +/// ``` +/// use oxidex::core::formatters::exif_enums::compression_label; +/// +/// assert_eq!(compression_label(6), Some("JPEG (old-style)")); +/// assert_eq!(compression_label(32766), Some("NeXt or Sony ARW Compressed 2")); +/// assert_eq!(compression_label(50000), Some("Zstd")); +/// assert_eq!(compression_label(33004), None); +/// ``` +pub fn compression_label(value: i64) -> Option<&'static str> { + COMPRESSION + .iter() + .find(|&&(id, _)| id == value) + .map(|&(_, label)| label) +} + /// Format Compression enum value /// EXIF/TIFF tag 0x0103 pub fn format_compression(value: i64) -> String { - match value { - 1 => "Uncompressed".to_string(), - 2 => "CCITT 1D".to_string(), - 3 => "T4/Group 3 Fax".to_string(), - 4 => "T6/Group 4 Fax".to_string(), - 5 => "LZW".to_string(), - 6 => "JPEG (old-style)".to_string(), - 7 => "JPEG".to_string(), - 8 => "Adobe Deflate".to_string(), - 9 => "JBIG B&W".to_string(), - 10 => "JBIG Color".to_string(), - 99 => "JPEG".to_string(), - 262 => "Kodak 262".to_string(), - 32766 => "Next".to_string(), - 32767 => "Sony ARW Compressed".to_string(), - 32769 => "Packed RAW".to_string(), - 32770 => "Samsung SRW Compressed".to_string(), - 32771 => "CCIRLEW".to_string(), - 32772 => "Samsung SRW Compressed 2".to_string(), - 32773 => "PackBits".to_string(), - 32809 => "Thunderscan".to_string(), - 32867 => "Kodak KDC Compressed".to_string(), - 32895 => "IT8CTPAD".to_string(), - 32896 => "IT8LW".to_string(), - 32897 => "IT8MP".to_string(), - 32898 => "IT8BL".to_string(), - 32908 => "PixarFilm".to_string(), - 32909 => "PixarLog".to_string(), - 32946 => "Deflate".to_string(), - 32947 => "DCS".to_string(), - 33003 | 33004 | 33005 => "Aperio JPEG 2000 YCbCr".to_string(), - 34661 => "JBIG".to_string(), - 34676 => "SGILog".to_string(), - 34677 => "SGILog24".to_string(), - 34712 => "JPEG 2000".to_string(), - 34713 => "Nikon NEF Compressed".to_string(), - 34715 => "JBIG2 TIFF FX".to_string(), - 34718 => "Microsoft Document Imaging (MDI) Binary Level Codec".to_string(), - 34719 => "Microsoft Document Imaging (MDI) Progressive Transform Codec".to_string(), - 34720 => "Microsoft Document Imaging (MDI) Vector".to_string(), - 34887 => "ESRI Lerc".to_string(), - 34892 => "Lossy JPEG".to_string(), - 34925 => "LZMA2".to_string(), - 34926 => "Zstd".to_string(), - 34927 => "WebP".to_string(), - 34933 => "PNG".to_string(), - 34934 => "JPEG XR".to_string(), - 65000 => "Kodak DCR Compressed".to_string(), - 65535 => "Pentax PEF Compressed".to_string(), - _ => format!("Unknown ({})", value), + match compression_label(value) { + Some(label) => label.to_string(), + None => format!("Unknown ({})", value), } } @@ -383,6 +448,64 @@ mod tests { assert_eq!(format_compression(7), "JPEG"); } + /// The ten codes the pre-consolidation table got wrong. + /// + /// `test_compression` above asserted 1, 6 and 7 only -- three codes that + /// three divergent tables all agreed on -- so it stayed green while `32766` + /// printed `Next` and `50000` printed `Unknown (50000)`. Each assertion + /// here is a branch that was measurably wrong against + /// `%Image::ExifTool::Exif::compression` (Exif.pm:213, ExifTool 13.59). + #[test] + fn compression_codes_the_old_table_got_wrong() { + // was "JBIG B&W" + assert_eq!(format_compression(9), "JBIG B&W or VC-5"); + // was "Next" + assert_eq!(format_compression(32766), "NeXt or Sony ARW Compressed 2"); + // 33003/33004/33005 all collapsed to the YCbCr label + assert_eq!(format_compression(33003), "Aperio JPEG 2000 YCbCr"); + assert_eq!(format_compression(33005), "Aperio JPEG 2000 RGB"); + // 33004 is not an ExifTool key at all + assert_eq!(format_compression(33004), "Unknown (33004)"); + assert_eq!(compression_label(33004), None); + // the " (old)" suffix separates these from the LibTiff 4.7 codes + assert_eq!(format_compression(34926), "Zstd (old)"); + assert_eq!(format_compression(34927), "WebP (old)"); + // absent entirely -- printed "Unknown (N)" + assert_eq!(format_compression(50000), "Zstd"); + assert_eq!(format_compression(50001), "WebP"); + assert_eq!(format_compression(50002), "JPEG XL (old)"); + assert_eq!(format_compression(52546), "JPEG XL"); + } + + /// Codes ExifTool does not name still print `Unknown (N)`, and the id space + /// between the named LibTiff codes is not silently filled in. + #[test] + fn compression_unknown_codes_are_not_invented() { + assert_eq!(compression_label(0), None); + assert_eq!(format_compression(0), "Unknown (0)"); + assert_eq!(format_compression(1536), "Unknown (1536)"); + assert_eq!(format_compression(50003), "Unknown (50003)"); + assert_eq!(format_compression(52545), "Unknown (52545)"); + // 32910/32911 are "Pixar reserved" in Exif.pm and carry no label + assert_eq!(compression_label(32910), None); + assert_eq!(compression_label(32911), None); + // 34888/34889 are "ESRI reserved" + assert_eq!(compression_label(34888), None); + assert_eq!(compression_label(34889), None); + } + + /// The table holds exactly the 53 keys of `%compression`, no more. + #[test] + fn compression_table_has_exactly_exiftools_key_count() { + assert_eq!(COMPRESSION.len(), 53); + let mut ids: Vec = COMPRESSION.iter().map(|&(id, _)| id).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), COMPRESSION.len(), "duplicate id in table"); + assert_eq!(*ids.first().unwrap(), 1); + assert_eq!(*ids.last().unwrap(), 65535); + } + #[test] fn test_components_configuration() { assert_eq!( diff --git a/src/parsers/pdf/mod.rs b/src/parsers/pdf/mod.rs index 1cb5fbdc5..f7603570c 100644 --- a/src/parsers/pdf/mod.rs +++ b/src/parsers/pdf/mod.rs @@ -57,6 +57,7 @@ pub mod shared; pub mod signature_parser; pub mod xmp_extractor; +use crate::core::formatters::exif_enums::compression_label; use crate::core::formatters::exif_print_conv::print_exposure_time; use crate::core::{FileReader, MetadataMap}; use crate::error::{ExifToolError, Result}; @@ -501,72 +502,6 @@ const TAG_EXIF_IMAGE_HEIGHT: u16 = 0xA003; const TAG_FOCAL_PLANE_RESOLUTION_UNIT: u16 = 0xA210; const TAG_FILE_SOURCE: u16 = 0xA300; -/// `%compression` from ExifTool 13.55 Exif.pm, transcribed in full. -/// -/// The archived patches this parser grew from carried a nine-entry excerpt -/// ending at `32773 => 'PackBits'`. A truncated PrintConv table is the exact -/// shape that shipped `32767 => "Sony RAW"` instead of -/// `'Sony ARW Compressed'` elsewhere in this codebase, so the whole table is -/// reproduced here rather than the handful of values PDF.pdf happens to hit. -const COMPRESSION_LABELS: &[(u16, &str)] = &[ - (1, "Uncompressed"), - (2, "CCITT 1D"), - (3, "T4/Group 3 Fax"), - (4, "T6/Group 4 Fax"), - (5, "LZW"), - (6, "JPEG (old-style)"), - (7, "JPEG"), - (8, "Adobe Deflate"), - (9, "JBIG B&W"), - (10, "JBIG Color"), - (99, "JPEG"), - (262, "Kodak 262"), - (32766, "NeXt or Sony ARW Compressed 2"), - (32767, "Sony ARW Compressed"), - (32769, "Packed RAW"), - (32770, "Samsung SRW Compressed"), - (32771, "CCIRLEW"), - (32772, "Samsung SRW Compressed 2"), - (32773, "PackBits"), - (32809, "Thunderscan"), - (32867, "Kodak KDC Compressed"), - (32895, "IT8CTPAD"), - (32896, "IT8LW"), - (32897, "IT8MP"), - (32898, "IT8BL"), - (32908, "PixarFilm"), - (32909, "PixarLog"), - (32946, "Deflate"), - (32947, "DCS"), - (33003, "Aperio JPEG 2000 YCbCr"), - (33005, "Aperio JPEG 2000 RGB"), - (34661, "JBIG"), - (34676, "SGILog"), - (34677, "SGILog24"), - (34712, "JPEG 2000"), - (34713, "Nikon NEF Compressed"), - (34715, "JBIG2 TIFF FX"), - (34718, "Microsoft Document Imaging (MDI) Binary Level Codec"), - ( - 34719, - "Microsoft Document Imaging (MDI) Progressive Transform Codec", - ), - (34720, "Microsoft Document Imaging (MDI) Vector"), - (34887, "ESRI Lerc"), - (34892, "Lossy JPEG"), - (34925, "LZMA2"), - (34926, "Zstd (old)"), - (34927, "WebP (old)"), - (34933, "PNG"), - (34934, "JPEG XR"), - (50000, "Zstd"), - (50001, "WebP"), - (50002, "JPEG XL (old)"), - (52546, "JPEG XL"), - (65000, "Kodak DCR Compressed"), - (65535, "Pentax PEF Compressed"), -]; - /// `%flash` from ExifTool 13.55 Exif.pm, transcribed in full. /// /// Three archived patches decoded this tag by OR-ing bit meanings together @@ -738,11 +673,7 @@ fn parse_embedded_tiff_ifds(data: &[u8]) -> Option { } TAG_COMPRESSION if field_type == 3 => { if let Some(raw) = read_short_value(data, base, byte_order) { - if let Some(label) = COMPRESSION_LABELS - .iter() - .find(|&&(id, _)| id == raw) - .map(|&(_, s)| s) - { + if let Some(label) = compression_label(i64::from(raw)) { let key = crate::tag_db::lookup_tag_name(TAG_COMPRESSION, "IFD0"); metadata.insert(key, crate::core::TagValue::new_string(label.to_string())); } @@ -844,7 +775,7 @@ fn parse_ifd1( if tag == TAG_COMPRESSION && field_type == 3 { if let Some(raw) = read_short_value(data, base, byte_order) { - if let Some(label) = lookup_label(COMPRESSION_LABELS, raw) { + if let Some(label) = compression_label(i64::from(raw)) { let key = crate::tag_db::lookup_tag_name(TAG_COMPRESSION, "IFD1"); metadata.insert(key, crate::core::TagValue::new_string(label.to_string())); } diff --git a/src/parsers/tiff/tiff_enums.rs b/src/parsers/tiff/tiff_enums.rs index 59817b20a..65e2dde91 100644 --- a/src/parsers/tiff/tiff_enums.rs +++ b/src/parsers/tiff/tiff_enums.rs @@ -3,6 +3,8 @@ //! This module provides mappings from numeric TIFF tag values to their //! human-readable string representations, matching Perl ExifTool output. +use crate::core::formatters::exif_enums::compression_label; + /// Maps TIFF tag enum values to their string representations. /// /// Returns the human-readable string for the given tag ID and value, @@ -22,52 +24,14 @@ pub fn tiff_enum_to_string(tag_id: u16, value: i64) -> Option { _ => None, }, - // Compression (tag 0x0103) - 0x0103 => match value { - 1 => Some("Uncompressed".to_string()), - 2 => Some("CCITT 1D".to_string()), - 3 => Some("T4/Group 3 Fax".to_string()), - 4 => Some("T6/Group 4 Fax".to_string()), - 5 => Some("LZW".to_string()), - 6 => Some("JPEG (old-style)".to_string()), - 7 => Some("JPEG".to_string()), - 8 => Some("Adobe Deflate".to_string()), - 9 => Some("JBIG B&W".to_string()), - 10 => Some("JBIG Color".to_string()), - 99 => Some("JPEG".to_string()), - 262 => Some("Kodak 262".to_string()), - 32766 => Some("Next".to_string()), - 32767 => Some("Sony ARW Compressed".to_string()), - 32769 => Some("Packed RAW".to_string()), - 32770 => Some("Samsung SRW Compressed".to_string()), - 32771 => Some("CCIRLEW".to_string()), - 32773 => Some("PackBits".to_string()), - 32809 => Some("Thunderscan".to_string()), - 32867 => Some("Kodak KDC Compressed".to_string()), - 32895 => Some("IT8CTPAD".to_string()), - 32896 => Some("IT8LW".to_string()), - 32897 => Some("IT8MP".to_string()), - 32898 => Some("IT8BL".to_string()), - 32908 => Some("PixarFilm".to_string()), - 32909 => Some("PixarLog".to_string()), - 32946 => Some("Deflate".to_string()), - 32947 => Some("DCS".to_string()), - 34661 => Some("JBIG".to_string()), - 34676 => Some("SGILog".to_string()), - 34677 => Some("SGILog24".to_string()), - 34712 => Some("JPEG 2000".to_string()), - 34713 => Some("Nikon NEF Compressed".to_string()), - 34715 => Some("JBIG2 TIFF FX".to_string()), - 34718 => Some("Microsoft Document Imaging (MDI) Binary Level Codec".to_string()), - 34719 => { - Some("Microsoft Document Imaging (MDI) Progressive Transform Codec".to_string()) - } - 34720 => Some("Microsoft Document Imaging (MDI) Vector".to_string()), - 34892 => Some("Lossy JPEG".to_string()), - 65000 => Some("Kodak DCR Compressed".to_string()), - 65535 => Some("Pentax PEF Compressed".to_string()), - _ => None, - }, + // Compression (tag 0x0103): `%Image::ExifTool::Exif::compression`, held + // once in `core::formatters::exif_enums`. The 40-id excerpt this file + // used to carry stopped at `34892 => 'Lossy JPEG'`, so a Samsung NX3000 + // (32772), an Aperio slide (33003/33005) or any LibTiff 4.7 codec + // (50000/50001/50002, 52546) fell through to `None` and printed as a + // bare number, and 32766 printed `Next` where ExifTool prints + // `NeXt or Sony ARW Compressed 2`. + 0x0103 => compression_label(value).map(str::to_string), // PhotometricInterpretation (tag 0x0106) 0x0106 => match value { @@ -378,3 +342,80 @@ pub fn tiff_enum_to_string(tag_id: u16, value: i64) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::tiff_enum_to_string; + + /// Compression (0x0103) now resolves through the one + /// `%Image::ExifTool::Exif::compression` table. + /// + /// The 40-id excerpt this file used to carry returned `None` for every code + /// below, so the tag printed as a bare number, and named 32766 `Next`. + /// This file had no test module at all, so nothing caught it. + #[test] + fn compression_codes_the_old_excerpt_dropped() { + for (code, label) in [ + (32772i64, "Samsung SRW Compressed 2"), + (33003, "Aperio JPEG 2000 YCbCr"), + (33005, "Aperio JPEG 2000 RGB"), + (34887, "ESRI Lerc"), + (34925, "LZMA2"), + (34926, "Zstd (old)"), + (34927, "WebP (old)"), + (34933, "PNG"), + (34934, "JPEG XR"), + (50000, "Zstd"), + (50001, "WebP"), + (50002, "JPEG XL (old)"), + (52546, "JPEG XL"), + ] { + assert_eq!( + tiff_enum_to_string(0x0103, code).as_deref(), + Some(label), + "Compression {code}" + ); + } + } + + #[test] + fn compression_codes_the_old_excerpt_misspelled() { + assert_eq!( + tiff_enum_to_string(0x0103, 32766).as_deref(), + Some("NeXt or Sony ARW Compressed 2") + ); + assert_eq!( + tiff_enum_to_string(0x0103, 9).as_deref(), + Some("JBIG B&W or VC-5") + ); + } + + /// Unknown codes still yield `None` -- consolidation must not start + /// inventing labels ExifTool never prints. + #[test] + fn compression_unknown_codes_still_yield_none() { + assert_eq!(tiff_enum_to_string(0x0103, 33004), None); + assert_eq!(tiff_enum_to_string(0x0103, 0), None); + assert_eq!(tiff_enum_to_string(0x0103, 1536), None); + assert_eq!(tiff_enum_to_string(0x0103, 34316), None); + } + + /// Codes the excerpt already had keep their exact former spelling. + #[test] + fn compression_codes_the_old_excerpt_had_are_unchanged() { + for (code, label) in [ + (1i64, "Uncompressed"), + (6, "JPEG (old-style)"), + (7, "JPEG"), + (32767, "Sony ARW Compressed"), + (34713, "Nikon NEF Compressed"), + (65535, "Pentax PEF Compressed"), + ] { + assert_eq!( + tiff_enum_to_string(0x0103, code).as_deref(), + Some(label), + "Compression {code}" + ); + } + } +} From ca13498b7643accf89deeb1bb97e6e708eb30339 Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 21:06:02 -0500 Subject: [PATCH 05/16] fix(exif): Flash is a lookup table, not a bitfield (#393) Exif.pm 0x9209 is `PrintConv => \%flash` -- a flat 27-key hash -- plus `Flags => 'PrintHex'`. `core::exif_enums::decode_flash` instead synthesised the label from the byte's bit fields (fired / strobe return / mode / function present / red-eye). Those bits explain how ExifTool's 27 codes were *chosen*; they are not how the tag is rendered, and 229 of the 256 byte values are not valid Flash codes at all. Scored against `%Image::ExifTool::Exif::flash` dumped out of the Perl symbol table, over every input in 0..=255: core::exif_enums::decode_flash 236 wrong / 256 consolidated table 0 wrong / 256 Two verbatim copies of the correct table already existed -- `FLASH_LABELS` in `parsers::pdf` and the `0x9209` arm of `parsers::raw::metadata` -- so the tree held three Flash decoders, and only the one on the main EXIF path was wrong. All three now share `core::formatters::exif_enums::flash_label`. Three classes of wrong answer, all corpus-visible: - codes ExifTool leaves unknown got a plausible label. 0x38 and 0x28 both came back `No flash function`; ExifTool prints `Unknown (0x38)` and `Unknown (0x28)` -- 60 files. - codes it did know were mis-worded: 0x49 gave `On, Fired, Red-eye reduction` where the hash says `On, Red-eye reduction` -- 23 files. Same for 0x0d, 0x0f, 0x4d, 0x4f, 0x50. - `format_flash`'s unknown form was decimal; `PrintHex` makes it hex. Why the old test stayed green: `test_flash_decoding` had 13 assertions, each annotated with the bit layout it was deriving -- and one of them, `decode_flash(0x49) == "On, Fired, Red-eye reduction"`, asserted a string ExifTool never prints. The test encoded the same wrong model as the code. It is rewritten to check the hash, and three new tests cover the mis-worded codes, the hex unknown form, and the 27-of-256 key count; reverting the table fails four of them. Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`): BEFORE correct=393343 valdiff=17058 score=77.5097% AFTER correct=393467 valdiff=16934 score=77.5341% MATCHED-SET DELTA fixed=124 regressed=0 PER-FILE 124 files +1, 0 down FORMAT JPEG 390485 -> 390609 (+124) Co-authored-by: Claude Opus 5 --- src/core/exif_enums.rs | 225 +++++++++++++----------------- src/core/formatters/exif_enums.rs | 81 ++++++++++- src/parsers/pdf/mod.rs | 41 +----- src/parsers/raw/metadata.rs | 39 +----- 4 files changed, 179 insertions(+), 207 deletions(-) diff --git a/src/core/exif_enums.rs b/src/core/exif_enums.rs index c98916cba..f8175d6dc 100644 --- a/src/core/exif_enums.rs +++ b/src/core/exif_enums.rs @@ -1,9 +1,13 @@ -//! EXIF Flash (0x9209) bitmap decoding. +//! EXIF Flash (0x9209) decoding. //! -//! `Flash` is the one EXIF enum that is not a flat lookup: ExifTool renders it -//! from five bit fields (fired / strobe return / mode / function present / -//! red-eye), which is why it is written out longhand here rather than living in -//! a table with the others. +//! `Flash` reads like a bitfield -- five fields (fired / strobe return / mode / +//! function present / red-eye) -- and this module used to render it that way. +//! ExifTool does not: Exif.pm 0x9209 is `PrintConv => \%flash`, a flat 27-key +//! hash, plus `Flags => 'PrintHex'`. The bits explain how those 27 codes were +//! chosen; they are not how the tag is printed, and the other 229 byte values +//! are not valid Flash codes. The table lives with the rest in +//! `core::formatters::exif_enums`; this module is the alias the tag-comparison +//! harness imports. //! //! The flat enums that used to sit alongside it -- ColorSpace, Contrast, //! CustomRendered, ExposureMode, GainControl, LightSource, MeteringMode, @@ -17,21 +21,23 @@ // Flash Bitmap Decoding // ============================================================================= -/// Decode Flash value (tag 0x9209) - bitmap decoding +/// Decode Flash value (tag 0x9209). /// -/// The Flash tag is a complex bitmap where different bits indicate different -/// aspects of the flash status. This function decodes all bits and returns -/// a human-readable string matching ExifTool's output format. +/// Thin alias for [`crate::core::formatters::exif_enums::format_flash`], which +/// is `%Image::ExifTool::Exif::flash` -- a flat 27-key hash with a `PrintHex` +/// unknown form. Kept as a symbol because the tag-comparison harness calls it +/// by this name. /// -/// # Bitmap Structure -/// -/// | Bits | Description | -/// |-------|--------------------------------------------------| -/// | 0 | Flash fired (0 = No, 1 = Yes) | -/// | 1-2 | Return detection (0 = No strobe, 2 = Not detected, 3 = Detected) | -/// | 3-4 | Flash mode (0 = Unknown, 1 = On, 2 = Off, 3 = Auto) | -/// | 5 | Flash function (0 = Present, 1 = No flash function) | -/// | 6 | Red-eye reduction (0 = No, 1 = Yes) | +/// This function used to build the label from the Flash byte's bit fields +/// (fired / strobe return / mode / function present / red-eye). Those bits +/// explain how ExifTool's 27 codes were chosen, but ExifTool renders the tag by +/// hash lookup, and 229 of the 256 byte values are not valid Flash codes at +/// all. Scored against `%flash` over every input in 0..=255 the bitwise version +/// was wrong on 236: it invented `No Flash` for `0x02` (ExifTool: +/// `Unknown (0x2)`), collapsed every `0x20`-family code to `No flash function` +/// (so `0x38` printed a real label where ExifTool prints `Unknown (0x38)`), and +/// wrote `On, Fired, Return not detected` for `0x0d` where ExifTool writes +/// `On, Return not detected`. /// /// # Examples /// @@ -40,77 +46,13 @@ /// /// assert_eq!(decode_flash(0), "No Flash"); /// assert_eq!(decode_flash(1), "Fired"); -/// assert_eq!(decode_flash(0x18), "Auto, Did not fire"); // auto mode, not fired -/// assert_eq!(decode_flash(0x19), "Auto, Fired"); // auto mode, fired +/// assert_eq!(decode_flash(0x18), "Auto, Did not fire"); +/// assert_eq!(decode_flash(0x19), "Auto, Fired"); +/// // Not a Flash code -- ExifTool prints the byte in hex, it does not guess +/// assert_eq!(decode_flash(0x38), "Unknown (0x38)"); /// ``` pub fn decode_flash(value: u32) -> String { - // Extract individual bit fields from the flash bitmap - let fired = (value & 0x01) != 0; // Bit 0: flash fired - let return_val = (value >> 1) & 0x03; // Bits 1-2: strobe return detection - let mode = (value >> 3) & 0x03; // Bits 3-4: flash mode - let function = (value >> 5) & 0x01; // Bit 5: flash function present - let red_eye = (value >> 6) & 0x01; // Bit 6: red-eye reduction - - // Special case: no flash function - if function == 1 { - return "No flash function".to_string(); - } - - let mut parts = Vec::new(); - - // Flash mode first (if known), then fired status - // This matches ExifTool's format: "Mode, Fired/Did not fire" - match mode { - 1 => { - // Compulsory flash mode (On) - parts.push("On"); - if fired { - parts.push("Fired"); - } else { - parts.push("Did not fire"); - } - } - 2 => { - // Compulsory suppression mode (Off) - parts.push("Off"); - if fired { - parts.push("Fired"); - } else { - parts.push("Did not fire"); - } - } - 3 => { - // Auto mode - parts.push("Auto"); - if fired { - parts.push("Fired"); - } else { - parts.push("Did not fire"); - } - } - _ => { - // Unknown mode (0) - just show fired status - if fired { - parts.push("Fired"); - } else { - parts.push("No Flash"); - } - } - } - - // Red-eye reduction mode - if red_eye == 1 { - parts.push("Red-eye reduction"); - } - - // Strobe return detection status (only meaningful if flash was fired) - match return_val { - 2 => parts.push("Return not detected"), - 3 => parts.push("Return detected"), - _ => {} // 0 = no strobe return detection function, 1 = reserved - } - - parts.join(", ") + crate::core::formatters::exif_enums::format_flash(i64::from(value)) } // ============================================================================= @@ -125,56 +67,81 @@ pub fn decode_flash(value: u32) -> String { mod tests { use super::*; + /// Codes `%flash` names, asserted against the hash rather than against the + /// bit layout. + /// + /// The version of this test that shipped with the bitwise decoder asserted + /// `decode_flash(0x49) == "On, Fired, Red-eye reduction"` -- a value + /// ExifTool never prints; the hash says `On, Red-eye reduction`. It read as + /// a thorough test (13 assertions, each with a bit-layout comment) while + /// enshrining the defect, and 23 files in the sample corpus reported that + /// exact wrong string. #[test] fn test_flash_decoding() { - // Basic states (unknown mode) - assert_eq!(decode_flash(0), "No Flash"); - assert_eq!(decode_flash(1), "Fired"); - - // Flash with auto mode (bits 3-4 = 0b11 = 3, shifted left 3 = 0x18) - // 0x18 = 0b00011000 = not fired + auto mode (bits 3-4) - assert_eq!(decode_flash(0x18), "Auto, Did not fire"); - // 0x19 = 0b00011001 = fired (bit 0) + auto mode (bits 3-4) - assert_eq!(decode_flash(0x19), "Auto, Fired"); - - // Flash off mode (bits 3-4 = 0b10 = 2, shifted left 3 = 0x10) - // 0x10 = 0b00010000 = not fired + off mode - assert_eq!(decode_flash(0x10), "Off, Did not fire"); - // 0x14 = 0b00010100 = not fired + off mode + return not detected - assert_eq!(decode_flash(0x14), "Off, Did not fire, Return not detected"); - - // Flash on mode (bits 3-4 = 0b01 = 1, shifted left 3 = 0x08) - // 0x08 = 0b00001000 = not fired + on mode + assert_eq!(decode_flash(0x00), "No Flash"); + assert_eq!(decode_flash(0x01), "Fired"); + assert_eq!(decode_flash(0x05), "Fired, Return not detected"); + assert_eq!(decode_flash(0x07), "Fired, Return detected"); assert_eq!(decode_flash(0x08), "On, Did not fire"); - // 0x09 = 0b00001001 = fired + on mode assert_eq!(decode_flash(0x09), "On, Fired"); - - // Return detected (bits 1-2 = 0b11 = 3) - unknown mode - // 0x07 = 0b00000111 = fired + return detected - assert_eq!(decode_flash(0x07), "Fired, Return detected"); - - // Return not detected (bits 1-2 = 0b10 = 2) - unknown mode - // 0x05 = 0b00000101 = fired + return not detected - assert_eq!(decode_flash(0x05), "Fired, Return not detected"); - - // No flash function (bit 5) - // 0x20 = 0b00100000 = no flash function + assert_eq!(decode_flash(0x10), "Off, Did not fire"); + assert_eq!(decode_flash(0x14), "Off, Did not fire, Return not detected"); + assert_eq!(decode_flash(0x18), "Auto, Did not fire"); + assert_eq!(decode_flash(0x19), "Auto, Fired"); + assert_eq!(decode_flash(0x1f), "Auto, Fired, Return detected"); assert_eq!(decode_flash(0x20), "No flash function"); - - // Red-eye reduction (bit 6) - unknown mode - // 0x41 = 0b01000001 = fired + red-eye reduction assert_eq!(decode_flash(0x41), "Fired, Red-eye reduction"); - - // Complex: auto + fired + red-eye - // 0x59 = 0b01011001 = fired + auto + red-eye assert_eq!(decode_flash(0x59), "Auto, Fired, Red-eye reduction"); + } - // Complex: auto + fired + return detected - // 0x1F = 0b00011111 = fired + auto + return detected - assert_eq!(decode_flash(0x1F), "Auto, Fired, Return detected"); + /// The six codes the bitwise decoder mis-worded. + /// + /// Each of these is a key `%flash` does hold, where bit synthesis inserted a + /// `Fired,` the hash does not carry. + #[test] + fn flash_codes_the_bitwise_decoder_misworded() { + // was "On, Fired, Return not detected" + assert_eq!(decode_flash(0x0d), "On, Return not detected"); + // was "On, Fired, Return detected" + assert_eq!(decode_flash(0x0f), "On, Return detected"); + // was "On, Fired, Red-eye reduction" + assert_eq!(decode_flash(0x49), "On, Red-eye reduction"); + // was "On, Fired, Red-eye reduction, Return not detected" + assert_eq!( + decode_flash(0x4d), + "On, Red-eye reduction, Return not detected" + ); + // was "On, Fired, Red-eye reduction, Return detected" + assert_eq!(decode_flash(0x4f), "On, Red-eye reduction, Return detected"); + // was "Off, Did not fire, Red-eye reduction" + assert_eq!(decode_flash(0x50), "Off, Red-eye reduction"); + } - // Complex: on + red-eye - // 0x49 = 0b01001001 = fired + on + red-eye - assert_eq!(decode_flash(0x49), "On, Fired, Red-eye reduction"); + /// Codes `%flash` does not hold print in hex; they are not guessed at. + /// + /// The bitwise decoder answered every one of the 229 unnamed byte values + /// with a plausible label. `0x38` and `0x28` both came back + /// `No flash function` -- 60 files in the sample corpus, where ExifTool + /// prints `Unknown (0x38)` and `Unknown (0x28)`. + #[test] + fn flash_codes_exiftool_does_not_name_print_in_hex() { + assert_eq!(decode_flash(0x38), "Unknown (0x38)"); + assert_eq!(decode_flash(0x28), "Unknown (0x28)"); + assert_eq!(decode_flash(0x02), "Unknown (0x2)"); + assert_eq!(decode_flash(0x03), "Unknown (0x3)"); + assert_eq!(decode_flash(0x0a), "Unknown (0xa)"); + assert_eq!(decode_flash(0x11), "Unknown (0x11)"); + assert_eq!(decode_flash(0x21), "Unknown (0x21)"); + assert_eq!(decode_flash(0x60), "Unknown (0x60)"); + assert_eq!(decode_flash(0xff), "Unknown (0xff)"); + } + + /// Exactly 27 of the 256 byte values are Flash codes. + #[test] + fn flash_names_exactly_twenty_seven_of_two_hundred_fifty_six_bytes() { + let named = (0u32..=255) + .filter(|&v| !decode_flash(v).starts_with("Unknown (")) + .count(); + assert_eq!(named, 27); } } diff --git a/src/core/formatters/exif_enums.rs b/src/core/formatters/exif_enums.rs index 9dada27b2..ea20e04fd 100644 --- a/src/core/formatters/exif_enums.rs +++ b/src/core/formatters/exif_enums.rs @@ -65,16 +65,83 @@ pub fn format_light_source(value: i64) -> String { } } -/// Format Flash enum value (complex bitfield) +/// `%Image::ExifTool::Exif::flash` (Exif.pm:175), transcribed verbatim. +/// +/// Flash is **not** a bitfield PrintConv. Exif.pm 0x9209 is +/// `PrintConv => \%flash` -- a flat 27-key hash -- and `Flags => 'PrintHex'`, +/// so a code the hash does not name prints as `Unknown (0x38)`. The bit layout +/// (fired / strobe return / mode / function present / red-eye) explains how the +/// 27 codes were *chosen*; it is not how ExifTool renders them, and 229 of the +/// 256 byte values are simply not valid Flash codes. +/// +/// `core::exif_enums::decode_flash` synthesised a label from those bit fields +/// instead. Scored against the hash over all 256 inputs it was wrong on **236**: +/// it named codes ExifTool leaves unknown (`0x02` -> `No Flash`, ExifTool +/// `Unknown (0x2)`), and mis-worded ones it did know (`0x0d` -> `On, Fired, +/// Return not detected`, ExifTool `On, Return not detected`). Two verbatim +/// copies of the real table already existed, in `parsers::pdf` and +/// `parsers::raw::metadata`; this is the one both now share. +const FLASH: &[(i64, &str)] = &[ + (0x00, "No Flash"), + (0x01, "Fired"), + (0x05, "Fired, Return not detected"), + (0x07, "Fired, Return detected"), + (0x08, "On, Did not fire"), + (0x09, "On, Fired"), + (0x0d, "On, Return not detected"), + (0x0f, "On, Return detected"), + (0x10, "Off, Did not fire"), + (0x14, "Off, Did not fire, Return not detected"), + (0x18, "Auto, Did not fire"), + (0x19, "Auto, Fired"), + (0x1d, "Auto, Fired, Return not detected"), + (0x1f, "Auto, Fired, Return detected"), + (0x20, "No flash function"), + (0x30, "Off, No flash function"), + (0x41, "Fired, Red-eye reduction"), + (0x45, "Fired, Red-eye reduction, Return not detected"), + (0x47, "Fired, Red-eye reduction, Return detected"), + (0x49, "On, Red-eye reduction"), + (0x4d, "On, Red-eye reduction, Return not detected"), + (0x4f, "On, Red-eye reduction, Return detected"), + (0x50, "Off, Red-eye reduction"), + (0x58, "Auto, Did not fire, Red-eye reduction"), + (0x59, "Auto, Fired, Red-eye reduction"), + (0x5d, "Auto, Fired, Red-eye reduction, Return not detected"), + (0x5f, "Auto, Fired, Red-eye reduction, Return detected"), +]; + +/// Looks up EXIF `Flash` (0x9209) in ExifTool's `%flash`. +/// +/// Returns `None` for a code ExifTool does not name, so callers keep their own +/// behaviour for it -- `format_flash` renders `Unknown (0x38)` the way +/// `PrintHex` does, while the RAW and PDF paths leave the tag alone. +/// +/// # Examples +/// +/// ``` +/// use oxidex::core::formatters::exif_enums::flash_label; +/// +/// assert_eq!(flash_label(0x0d), Some("On, Return not detected")); +/// assert_eq!(flash_label(0x02), None); +/// ``` +pub fn flash_label(value: i64) -> Option<&'static str> { + FLASH + .iter() + .find(|&&(id, _)| id == value) + .map(|&(_, label)| label) +} + +/// Format Flash enum value /// EXIF tag 0x9209 /// -/// Delegates to [`crate::core::exif_enums::decode_flash`], which correctly -/// orders the flash mode ("On"/"Off"/"Auto") before the fired status (e.g. -/// "Off, Did not fire") to match ExifTool's output. A previous, independent -/// implementation here produced the wrong word order (e.g. "No Flash, Off" -/// instead of "Off, Did not fire") for compulsory-suppression mode. +/// `Flags => 'PrintHex'` on Exif.pm 0x9209, so unnamed codes print their value +/// in lowercase hex: `Unknown (0x38)`, not `Unknown (56)`. pub fn format_flash(value: i64) -> String { - crate::core::exif_enums::decode_flash(value.max(0) as u32) + match flash_label(value) { + Some(label) => label.to_string(), + None => format!("Unknown (0x{:x})", value), + } } /// Format ExposureMode enum value diff --git a/src/parsers/pdf/mod.rs b/src/parsers/pdf/mod.rs index f7603570c..ab970a32a 100644 --- a/src/parsers/pdf/mod.rs +++ b/src/parsers/pdf/mod.rs @@ -57,7 +57,7 @@ pub mod shared; pub mod signature_parser; pub mod xmp_extractor; -use crate::core::formatters::exif_enums::compression_label; +use crate::core::formatters::exif_enums::{compression_label, flash_label}; use crate::core::formatters::exif_print_conv::print_exposure_time; use crate::core::{FileReader, MetadataMap}; use crate::error::{ExifToolError, Result}; @@ -502,43 +502,6 @@ const TAG_EXIF_IMAGE_HEIGHT: u16 = 0xA003; const TAG_FOCAL_PLANE_RESOLUTION_UNIT: u16 = 0xA210; const TAG_FILE_SOURCE: u16 = 0xA300; -/// `%flash` from ExifTool 13.55 Exif.pm, transcribed in full. -/// -/// Three archived patches decoded this tag by OR-ing bit meanings together -/// (`bit 3 => "Auto"`, and so on). That is not what ExifTool does: 0x08 is a -/// single table entry meaning `'On, Did not fire'`, not "Auto". The bitwise -/// spelling only agreed with ExifTool for the one value PDF.pdf stores (1), -/// which is why it survived its recheck. -const FLASH_LABELS: &[(u16, &str)] = &[ - (0x00, "No Flash"), - (0x01, "Fired"), - (0x05, "Fired, Return not detected"), - (0x07, "Fired, Return detected"), - (0x08, "On, Did not fire"), - (0x09, "On, Fired"), - (0x0d, "On, Return not detected"), - (0x0f, "On, Return detected"), - (0x10, "Off, Did not fire"), - (0x14, "Off, Did not fire, Return not detected"), - (0x18, "Auto, Did not fire"), - (0x19, "Auto, Fired"), - (0x1d, "Auto, Fired, Return not detected"), - (0x1f, "Auto, Fired, Return detected"), - (0x20, "No flash function"), - (0x30, "Off, No flash function"), - (0x41, "Fired, Red-eye reduction"), - (0x45, "Fired, Red-eye reduction, Return not detected"), - (0x47, "Fired, Red-eye reduction, Return detected"), - (0x49, "On, Red-eye reduction"), - (0x4d, "On, Red-eye reduction, Return not detected"), - (0x4f, "On, Red-eye reduction, Return detected"), - (0x50, "Off, Red-eye reduction"), - (0x58, "Auto, Did not fire, Red-eye reduction"), - (0x59, "Auto, Fired, Red-eye reduction"), - (0x5d, "Auto, Fired, Red-eye reduction, Return not detected"), - (0x5f, "Auto, Fired, Red-eye reduction, Return detected"), -]; - /// FocalPlaneResolutionUnit (0xa210) PrintConv, ExifTool 13.55 Exif.pm. /// Values 1, 4 and 5 are flagged there as non-standard EXIF but are still /// decoded, so all five are kept. @@ -997,7 +960,7 @@ fn parse_exif_ifd( // ExifTool 13.55 Exif.pm 0x9209: PrintConv => \%flash. TAG_FLASH if field_type == 3 => { if let Some(raw) = read_short_value(data, base, byte_order) { - if let Some(label) = lookup_label(FLASH_LABELS, raw) { + if let Some(label) = flash_label(i64::from(raw)) { let key = crate::tag_db::lookup_tag_name(TAG_FLASH, "ExifIFD"); metadata.insert(key, crate::core::TagValue::new_string(label.to_string())); } diff --git a/src/parsers/raw/metadata.rs b/src/parsers/raw/metadata.rs index cf9e28f07..d3db9ec85 100644 --- a/src/parsers/raw/metadata.rs +++ b/src/parsers/raw/metadata.rs @@ -22,6 +22,7 @@ //! - Sigma X3F (FOVb format) //! - Minolta MRW (MRM format) +use crate::core::formatters::exif_enums::flash_label; use crate::core::formatters::exif_print_conv::print_exposure_time; use crate::core::formatters::{ format_color_space, format_contrast, format_custom_rendered, format_sharpness, @@ -2236,38 +2237,12 @@ fn format_exif_display_value( 0x9208 if field_type == 3 && value_count >= 1 => { exif_light_source_label(read_tiff_u16(bytes, byte_order)?).map(str::to_string) } - // Flash: SHORT[1]. Exif.pm 0x9209 PrintConv => \%flash, whose full - // table (Exif.pm lines 172-199) is reproduced verbatim. - 0x9209 if field_type == 3 && value_count >= 1 => match read_tiff_u16(bytes, byte_order)? { - 0x00 => Some("No Flash".to_string()), - 0x01 => Some("Fired".to_string()), - 0x05 => Some("Fired, Return not detected".to_string()), - 0x07 => Some("Fired, Return detected".to_string()), - 0x08 => Some("On, Did not fire".to_string()), - 0x09 => Some("On, Fired".to_string()), - 0x0d => Some("On, Return not detected".to_string()), - 0x0f => Some("On, Return detected".to_string()), - 0x10 => Some("Off, Did not fire".to_string()), - 0x14 => Some("Off, Did not fire, Return not detected".to_string()), - 0x18 => Some("Auto, Did not fire".to_string()), - 0x19 => Some("Auto, Fired".to_string()), - 0x1d => Some("Auto, Fired, Return not detected".to_string()), - 0x1f => Some("Auto, Fired, Return detected".to_string()), - 0x20 => Some("No flash function".to_string()), - 0x30 => Some("Off, No flash function".to_string()), - 0x41 => Some("Fired, Red-eye reduction".to_string()), - 0x45 => Some("Fired, Red-eye reduction, Return not detected".to_string()), - 0x47 => Some("Fired, Red-eye reduction, Return detected".to_string()), - 0x49 => Some("On, Red-eye reduction".to_string()), - 0x4d => Some("On, Red-eye reduction, Return not detected".to_string()), - 0x4f => Some("On, Red-eye reduction, Return detected".to_string()), - 0x50 => Some("Off, Red-eye reduction".to_string()), - 0x58 => Some("Auto, Did not fire, Red-eye reduction".to_string()), - 0x59 => Some("Auto, Fired, Red-eye reduction".to_string()), - 0x5d => Some("Auto, Fired, Red-eye reduction, Return not detected".to_string()), - 0x5f => Some("Auto, Fired, Red-eye reduction, Return detected".to_string()), - _ => None, - }, + // Flash: SHORT[1]. Exif.pm 0x9209 `PrintConv => \%flash`, held once in + // `core::formatters::exif_enums`. This file carried a verbatim second + // copy of the same 27 rows. + 0x9209 if field_type == 3 && value_count >= 1 => { + flash_label(i64::from(read_tiff_u16(bytes, byte_order)?)).map(str::to_string) + } // FileSource: UNDEFINED. Exif.pm 0xa300 PrintConv, verbatim: // 1 => 'Film Scanner', // 2 => 'Reflection Print Scanner', From dc935446e93234ab3b94b27abd7a31777ecb163a Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 21:27:54 -0500 Subject: [PATCH 06/16] fix(exif): FocalPlaneResolutionUnit decodes instead of printing its code (#395) Exif.pm 0xa210 declares a PrintConv: 0xa210 => { Name => 'FocalPlaneResolutionUnit', Notes => 'values 1, 4 and 5 are not standard EXIF', PrintConv => { 1 => 'None', 2 => 'inches', 3 => 'cm', 4 => 'mm', 5 => 'um' }, }, The tree already held that table -- in `parsers::pdf`, reachable only from a PDF's embedded TIFF thumbnail. The main EXIF path had no arm for the tag at all, so `format_tag_value` returned the raw integer and 1,098 files in the sample corpus reported `2` and `3` where ExifTool reports `inches` and `cm`. The table moves to `core::formatters::exif_enums` next to the Compression and Flash tables, PDF reads it from there, and the `exiftool_compat` dispatch gains the arm it was missing. `Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an exclusion: ExifTool decodes all five, and the corpus holds 4 files at `4` and 1 at `5`. 0xa210 carries no `PrintHex`, so unnamed codes stay decimal -- `Unknown (0)`, which 4 corpus files report. Safe for the composites: `Exif ScaleFactor35efl` -- which gates FocalLength35efl, CircleOfConfusion, HyperfocalDistance, FOV and DOF -- already matches either spelling (`Some("3") | Some("cm")`). Measured: zero `Composite:*` values changed anywhere in the corpus. A unit test on the new function would not have caught this: the table already existed and was already correct. The regression test asserts the *dispatch*, through `format_tag_value`; deleting the new arm fails it with `left: Integer(1), right: String("None")`. Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`): BEFORE correct=393467 valdiff=16934 score=77.5341% AFTER correct=394565 valdiff=15836 score=77.7505% MATCHED-SET DELTA fixed=1098 regressed=0 PER-FILE 1098 files +1, 0 down FORMAT JPEG +1094, DNG +1, CR2 +1, RAF +1, BPG +1 Co-authored-by: Claude Opus 5 --- src/core/exiftool_compat.rs | 68 ++++++++++++++++++++--- src/core/formatters/exif_enums.rs | 89 +++++++++++++++++++++++++++++++ src/core/formatters/mod.rs | 6 +-- src/parsers/pdf/mod.rs | 12 ++--- 4 files changed, 156 insertions(+), 19 deletions(-) diff --git a/src/core/exiftool_compat.rs b/src/core/exiftool_compat.rs index cded7af48..fd8203140 100644 --- a/src/core/exiftool_compat.rs +++ b/src/core/exiftool_compat.rs @@ -66,14 +66,15 @@ use crate::core::formatters::{ decode_cfa_pattern, decode_gps_processing_method, decode_scene_type, decode_version_bytes, exiftool_rational_number, format_color_space, format_components_configuration, format_compression, format_contrast, format_custom_rendered, format_exposure_mode, - format_exposure_program, format_file_source, format_flash, format_gain_control, - format_gps_altitude_ref, format_gps_direction_ref, format_gps_lat_ref, format_gps_lon_ref, - format_gps_speed_ref, format_icc_value, format_integer_precision_values, format_interop_index, - format_light_source, format_metering_mode, format_orientation, format_resolution_unit, - format_saturation, format_scene_capture_type, format_sensing_method, format_sharpness, - format_subject_distance_range, format_three_decimal_values, format_white_balance, - format_with_unit, format_ycbcr_positioning, format_ycbcr_subsampling_string, is_icc_matrix_tag, - is_integer_precision_tag, is_three_decimal_tag, + format_exposure_program, format_file_source, format_flash, format_focal_plane_resolution_unit, + format_gain_control, format_gps_altitude_ref, format_gps_direction_ref, format_gps_lat_ref, + format_gps_lon_ref, format_gps_speed_ref, format_icc_value, format_integer_precision_values, + format_interop_index, format_light_source, format_metering_mode, format_orientation, + format_resolution_unit, format_saturation, format_scene_capture_type, format_sensing_method, + format_sharpness, format_subject_distance_range, format_three_decimal_values, + format_white_balance, format_with_unit, format_ycbcr_positioning, + format_ycbcr_subsampling_string, is_icc_matrix_tag, is_integer_precision_tag, + is_three_decimal_tag, }; use crate::core::{MetadataMap, TagValue}; @@ -455,6 +456,16 @@ pub fn format_tag_value(tag_name: &str, value: &TagValue) -> TagValue { return TagValue::String(format_sensing_method(i)); } + // FocalPlaneResolutionUnit enum (1-5). Exif.pm 0xa210 declares a PrintConv; + // this path used to print the raw code, so 1,098 corpus files reported `2` + // and `3` instead of `inches` and `cm`. The composite ScaleFactor35efl + // already accepts either spelling (`Some("3") | Some("cm")`). + if base_name == "FocalPlaneResolutionUnit" + && let Some(i) = value.as_integer() + { + return TagValue::String(format_focal_plane_resolution_unit(i)); + } + // Compression enum (1-65535) if base_name == "Compression" && let Some(i) = value.as_integer() @@ -1476,6 +1487,47 @@ fn format_icc_string_values(value: &str, base_name: &str) -> String { mod tests { use super::*; + /// The dispatch, not just the table. + /// + /// `format_focal_plane_resolution_unit` existing proves nothing on its own: + /// the table already existed in `parsers::pdf` while this chain had no + /// `FocalPlaneResolutionUnit` arm, so `format_tag_value` returned the raw + /// integer and 1,098 sample-corpus files reported `2` instead of `inches`. + /// This asserts the wiring. + #[test] + fn focal_plane_resolution_unit_reaches_the_print_conv() { + for (code, label) in [ + (1i64, "None"), + (2, "inches"), + (3, "cm"), + (4, "mm"), + (5, "um"), + ] { + let got = format_tag_value( + "ExifIFD:FocalPlaneResolutionUnit", + &TagValue::new_integer(code), + ); + assert_eq!( + got, + TagValue::String(label.to_string()), + "FocalPlaneResolutionUnit {code} did not reach the PrintConv" + ); + } + } + + /// Flash reaches the hash, and unnamed codes come back in hex. + #[test] + fn flash_reaches_the_print_conv_with_the_printhex_unknown_form() { + assert_eq!( + format_tag_value("ExifIFD:Flash", &TagValue::new_integer(0x49)), + TagValue::String("On, Red-eye reduction".to_string()) + ); + assert_eq!( + format_tag_value("ExifIFD:Flash", &TagValue::new_integer(0x38)), + TagValue::String("Unknown (0x38)".to_string()) + ); + } + // ------------------------------------------------------------------------- // strip_family_prefix tests // ------------------------------------------------------------------------- diff --git a/src/core/formatters/exif_enums.rs b/src/core/formatters/exif_enums.rs index ea20e04fd..0648bf2b3 100644 --- a/src/core/formatters/exif_enums.rs +++ b/src/core/formatters/exif_enums.rs @@ -234,6 +234,62 @@ pub fn format_file_source(value: i64) -> String { } } +/// `0xa210 FocalPlaneResolutionUnit`'s PrintConv (Exif.pm:2777), verbatim: +/// +/// ```text +/// PrintConv => { +/// 1 => 'None', # (not standard EXIF) +/// 2 => 'inches', +/// 3 => 'cm', +/// 4 => 'mm', # (not standard EXIF) +/// 5 => 'um', # (not standard EXIF) +/// }, +/// ``` +/// +/// The tree already held this table -- in `parsers::pdf`, reachable only from a +/// PDF's embedded TIFF thumbnail. The main EXIF path had no decoder at all and +/// printed the raw code, so 1,098 files in the sample corpus reported `2` and +/// `3` where ExifTool reports `inches` and `cm`. +/// +/// `Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an +/// exclusion: ExifTool decodes all five, and the corpus contains 4 files at `4` +/// and 1 at `5`. +const FOCAL_PLANE_RESOLUTION_UNIT: &[(i64, &str)] = + &[(1, "None"), (2, "inches"), (3, "cm"), (4, "mm"), (5, "um")]; + +/// Looks up `FocalPlaneResolutionUnit` (0xa210). +/// +/// `None` for a code ExifTool does not name, so the PDF path can keep leaving +/// the tag alone while `format_focal_plane_resolution_unit` prints +/// `Unknown (N)`. +/// +/// # Examples +/// +/// ``` +/// use oxidex::core::formatters::exif_enums::focal_plane_resolution_unit_label; +/// +/// assert_eq!(focal_plane_resolution_unit_label(2), Some("inches")); +/// assert_eq!(focal_plane_resolution_unit_label(5), Some("um")); +/// assert_eq!(focal_plane_resolution_unit_label(0), None); +/// ``` +pub fn focal_plane_resolution_unit_label(value: i64) -> Option<&'static str> { + FOCAL_PLANE_RESOLUTION_UNIT + .iter() + .find(|&&(id, _)| id == value) + .map(|&(_, label)| label) +} + +/// Format FocalPlaneResolutionUnit enum value +/// EXIF tag 0xA210 +/// +/// No `PrintHex` on 0xa210, so unnamed codes print in decimal: `Unknown (0)`. +pub fn format_focal_plane_resolution_unit(value: i64) -> String { + match focal_plane_resolution_unit_label(value) { + Some(label) => label.to_string(), + None => format!("Unknown ({})", value), + } +} + /// Format SensingMethod enum value /// EXIF tag 0xA217 pub fn format_sensing_method(value: i64) -> String { @@ -515,6 +571,39 @@ mod tests { assert_eq!(format_compression(7), "JPEG"); } + /// `FocalPlaneResolutionUnit` decodes; it does not print the raw code. + /// + /// The main EXIF path had no decoder for 0xa210 at all -- the only copy of + /// the table lived in `parsers::pdf`, reachable only from a PDF's embedded + /// TIFF thumbnail. 1,098 sample-corpus files reported the bare number. + #[test] + fn focal_plane_resolution_unit_decodes_all_five_codes() { + assert_eq!(format_focal_plane_resolution_unit(1), "None"); + assert_eq!(format_focal_plane_resolution_unit(2), "inches"); + assert_eq!(format_focal_plane_resolution_unit(3), "cm"); + // `Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an + // exclusion -- the corpus has 4 files at 4 and 1 at 5. + assert_eq!(format_focal_plane_resolution_unit(4), "mm"); + assert_eq!(format_focal_plane_resolution_unit(5), "um"); + // None of them is the raw code + for code in 1..=5 { + assert_ne!( + format_focal_plane_resolution_unit(code), + code.to_string(), + "code {code} printed as a bare number" + ); + } + } + + /// 0xa210 carries no `PrintHex`, so unknown codes print in decimal. + #[test] + fn focal_plane_resolution_unit_unknown_codes_print_decimal() { + assert_eq!(format_focal_plane_resolution_unit(0), "Unknown (0)"); + assert_eq!(format_focal_plane_resolution_unit(6), "Unknown (6)"); + assert_eq!(focal_plane_resolution_unit_label(0), None); + assert_eq!(focal_plane_resolution_unit_label(6), None); + } + /// The ten codes the pre-consolidation table got wrong. /// /// `test_compression` above asserted 1, 6 and 7 only -- three codes that diff --git a/src/core/formatters/mod.rs b/src/core/formatters/mod.rs index d9492f3ee..cc4fad3bb 100644 --- a/src/core/formatters/mod.rs +++ b/src/core/formatters/mod.rs @@ -27,9 +27,9 @@ pub use cfa_pattern::decode_cfa_pattern; pub use exif_enums::{ format_color_space, format_components_configuration, format_compression, format_contrast, format_custom_rendered, format_digital_zoom_ratio, format_exposure_mode, format_file_source, - format_flash, format_gain_control, format_interop_index, format_light_source, - format_metering_mode, format_orientation, format_resolution_unit, format_saturation, - format_scene_capture_type, format_sensing_method, format_sharpness, + format_flash, format_focal_plane_resolution_unit, format_gain_control, format_interop_index, + format_light_source, format_metering_mode, format_orientation, format_resolution_unit, + format_saturation, format_scene_capture_type, format_sensing_method, format_sharpness, format_subject_distance_range, format_white_balance, format_ycbcr_positioning, }; pub use exif_print_conv::{print_exposure_time, print_exposure_time_micros_str, print_fraction}; diff --git a/src/parsers/pdf/mod.rs b/src/parsers/pdf/mod.rs index ab970a32a..49b88fa4c 100644 --- a/src/parsers/pdf/mod.rs +++ b/src/parsers/pdf/mod.rs @@ -57,7 +57,9 @@ pub mod shared; pub mod signature_parser; pub mod xmp_extractor; -use crate::core::formatters::exif_enums::{compression_label, flash_label}; +use crate::core::formatters::exif_enums::{ + compression_label, flash_label, focal_plane_resolution_unit_label, +}; use crate::core::formatters::exif_print_conv::print_exposure_time; use crate::core::{FileReader, MetadataMap}; use crate::error::{ExifToolError, Result}; @@ -502,12 +504,6 @@ const TAG_EXIF_IMAGE_HEIGHT: u16 = 0xA003; const TAG_FOCAL_PLANE_RESOLUTION_UNIT: u16 = 0xA210; const TAG_FILE_SOURCE: u16 = 0xA300; -/// FocalPlaneResolutionUnit (0xa210) PrintConv, ExifTool 13.55 Exif.pm. -/// Values 1, 4 and 5 are flagged there as non-standard EXIF but are still -/// decoded, so all five are kept. -const FOCAL_PLANE_RESOLUTION_UNIT_LABELS: &[(u16, &str)] = - &[(1, "None"), (2, "inches"), (3, "cm"), (4, "mm"), (5, "um")]; - /// FileSource (0xa300) PrintConv, ExifTool 13.55 Exif.pm. /// /// Two archived patches wrote `3 => "Digital Camera", _ => "Unknown"`. The @@ -969,7 +965,7 @@ fn parse_exif_ifd( // ExifTool 13.55 Exif.pm 0xa210 PrintConv. TAG_FOCAL_PLANE_RESOLUTION_UNIT if field_type == 3 => { if let Some(raw) = read_short_value(data, base, byte_order) { - if let Some(label) = lookup_label(FOCAL_PLANE_RESOLUTION_UNIT_LABELS, raw) { + if let Some(label) = focal_plane_resolution_unit_label(i64::from(raw)) { let key = crate::tag_db::lookup_tag_name( TAG_FOCAL_PLANE_RESOLUTION_UNIT, "ExifIFD", From af667b19cc90e196d911f5a215399383e9ffd0dc Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 20:52:08 -0500 Subject: [PATCH 07/16] fix(write): report the dropped write instead of printing success `plan_exif_write` may emit only one IFD record per tag id, so when two metadata-map keys resolve to the same id the second cannot be written. The guard that enforced this used `continue`, discarding the caller's edit while the CLI still printed "1 image files updated" and exited 0: $ oxidex -ExifIFD:ExposureBiasValue=-0.5 photo.jpg # 0x9204 1 image files updated # exit 0 $ exiftool -a -G1 -s -ExposureCompensation photo.jpg [ExifIFD] ExposureCompensation : -3/2 # unchanged Before #368 this failed loudly with a type error; #368 fixed the typing and the failure went quiet. ExifTool refuses the same command. `ExposureBiasValue` is not a writable tag name in any of its tables -- only a Notes remark on 0x9204 ("called ExposureBiasValue by the EXIF spec", Exif.pm:2369) and an XMP property whose tag name is again ExposureCompensation (XMP.pm:2096-2097); `TagLookup.pm` has no `exposurebiasvalue` key, so `TagExists` fails and `SetNewValue` returns "Tag '...' is not defined" (Writer.pl:581-584) with exit 1. A collision that would lose an edit is now refused with a message naming both keys and the id; a collision that loses nothing -- the same value already planned under another spelling, which is what the documented `-EXIF:Tag=value` syntax produces -- is still skipped silently. Two further defects fall out of the same guard: * `-EXIF:ExposureTime=1/250` wrote 0x829A into *both* IFD0 and ExifIFD. An "EXIF:" key names the tag family, not a physical IFD, so the collision search had to span IFD0/ExifIFD/GPS rather than only the IFD0 fallback. * `-ExifIFD:ExposureCompensation=-0.5` serialized ASCII "-0.5" into a rational64s tag, because the registry declared 0x9204 `String`. Corrected, along with 46 other EXIF tags whose declared type disagreed with ExifTool's `Writable`, by adding the missing `type:` to the YAML tag database. The set was derived mechanically from ExifTool 13.59's own Perl tables and restricted to scalar numeric/date tags with no ValueConv and no blob flag -- multi-value, ValueConv and `undef`-storage tags are deliberately untouched, since for those ExifTool's accepted input type comes from PrintConvInv/ValueConvInv rather than the storage code (FileSource's Integer, for one, is already correct). ModifyDate and CreateDate are among the 47: both now normalize `2024-01-15T10:30:00` to ExifTool's stored `2024:01:15 10:30:00`. #358's write path is untouched: a binary built from origin/main and one built from this branch produce byte-identical output on 24 files (9 corpus RAW/TIFF/ PNG/JPS + 12 corpus JPEGs + 3 fixtures) for an ordinary `-IFD0:Artist=` write, and all 11 files from #358's table still accept writes with no metadata lost. Co-Authored-By: Claude Opus 5 --- oxidex-tags-core/src/core_tags.yaml | 53 ++++ src/writers/exif_surgical.rs | 109 +++++++- tests/integration.rs | 3 + .../exif_tag_id_collision_tests.rs | 251 ++++++++++++++++++ 4 files changed, 404 insertions(+), 12 deletions(-) create mode 100644 tests/integration/exif_tag_id_collision_tests.rs diff --git a/oxidex-tags-core/src/core_tags.yaml b/oxidex-tags-core/src/core_tags.yaml index e34828f25..90dc2d945 100644 --- a/oxidex-tags-core/src/core_tags.yaml +++ b/oxidex-tags-core/src/core_tags.yaml @@ -14,14 +14,17 @@ tables: - id: "0xC62D" name: "BayerGreenSplit" writable: false + type: int32u description: "Bayer green split" - id: "0xC632" name: "AntiAliasStrength" writable: false + type: rational64u description: "Anti-alias strength" - id: "0xC65C" name: "BestQualityScale" writable: false + type: rational64u description: "Best quality scale" - id: "0xC68D" name: "ActiveArea" @@ -56,6 +59,7 @@ tables: - id: "0x0003" name: "ExifImageHeight" writable: false + type: int16u description: "ExifImageHeight tag" - id: "0x0001" name: "ShutterSpeedValue" @@ -112,10 +116,12 @@ tables: - id: "0x000A" name: "ExifImageWidth" writable: false + type: int16u description: "ExifImageWidth tag" - id: "0x000B" name: "ExifImageHeight" writable: false + type: int16u description: "ExifImageHeight tag" - id: "0x000C" name: "CanonImageWidth" @@ -398,6 +404,7 @@ tables: - id: "0x00FF" name: "OldSubfileType" writable: false + type: int16u description: "OldSubfileType tag" - id: "0x0100" name: "ImageWidth" @@ -422,6 +429,7 @@ tables: - id: "0x0107" name: "Thresholding" writable: false + type: int16u description: "Thresholding tag" - id: "0x0108" name: "CellWidth" @@ -542,6 +550,7 @@ tables: - id: "0x0132" name: "ModifyDate" writable: false + type: datetime description: "ModifyDate tag" - id: "0x013B" name: "Artist" @@ -702,6 +711,7 @@ tables: - id: "0x4746" name: "Rating" writable: false + type: int16u description: "Rating tag" - id: "0x4747" name: "XP_DIP_XML" @@ -714,6 +724,7 @@ tables: - id: "0x4749" name: "RatingPercent" writable: false + type: int16u description: "RatingPercent tag" - id: "0x5001" name: "ResolutionXUnit" @@ -730,6 +741,7 @@ tables: - id: "0x7031" name: "VignettingCorrection" writable: false + type: int16s description: "VignettingCorrection tag" - id: "0x7032" name: "VignettingCorrParams" @@ -738,6 +750,7 @@ tables: - id: "0x7034" name: "ChromaticAberrationCorrection" writable: false + type: int16s description: "ChromaticAberrationCorrection tag" - id: "0x7035" name: "ChromaticAberrationCorrParams" @@ -746,6 +759,7 @@ tables: - id: "0x7036" name: "DistortionCorrection" writable: false + type: int16s description: "DistortionCorrection tag" - id: "0x7037" name: "DistortionCorrParams" @@ -914,6 +928,7 @@ tables: - id: "0x9004" name: "CreateDate" writable: false + type: datetime description: "CreateDate tag" - id: "0x9009" name: "GooglePlusUploadCode" @@ -938,6 +953,7 @@ tables: - id: "0x9102" name: "CompressedBitsPerPixel" writable: false + type: rational64u description: "CompressedBitsPerPixel tag" - id: "0x9201" name: "ShutterSpeedValue" @@ -954,6 +970,7 @@ tables: - id: "0x9204" name: "ExposureCompensation" writable: false + type: rational64s description: "ExposureCompensation tag" - id: "0x9205" name: "MaxApertureValue" @@ -1034,6 +1051,7 @@ tables: - id: "0x9400" name: "AmbientTemperature" writable: false + type: rational64s description: "AmbientTemperature tag" - id: "0x9401" name: "Humidity" @@ -1094,10 +1112,12 @@ tables: - id: "0xA002" name: "ExifImageWidth" writable: false + type: int16u description: "ExifImageWidth tag" - id: "0xA003" name: "ExifImageHeight" writable: false + type: int16u description: "ExifImageHeight tag" - id: "0xA004" name: "RelatedSoundFile" @@ -1481,22 +1501,27 @@ tables: - id: "0xC62A" name: "BaselineExposure" writable: false + type: rational64s description: "BaselineExposure tag" - id: "0xC62B" name: "BaselineNoise" writable: false + type: rational64u description: "BaselineNoise tag" - id: "0xC62C" name: "BaselineSharpness" writable: false + type: rational64u description: "BaselineSharpness tag" - id: "0xC62D" name: "BayerGreenSplit" writable: false + type: int32u description: "BayerGreenSplit tag" - id: "0xC62E" name: "LinearResponseLimit" writable: false + type: rational64u description: "LinearResponseLimit tag" - id: "0xC62F" name: "CameraSerialNumber" @@ -1509,18 +1534,22 @@ tables: - id: "0xC631" name: "ChromaBlurRadius" writable: false + type: rational64u description: "ChromaBlurRadius tag" - id: "0xC632" name: "AntiAliasStrength" writable: false + type: rational64u description: "AntiAliasStrength tag" - id: "0xC633" name: "ShadowScale" writable: false + type: rational64u description: "ShadowScale tag" - id: "0xC635" name: "MakerNoteSafety" writable: false + type: int16u description: "MakerNoteSafety tag" - id: "0xC640" name: "RawImageSegmentation" @@ -1529,14 +1558,17 @@ tables: - id: "0xC65A" name: "CalibrationIlluminant1" writable: false + type: int16u description: "CalibrationIlluminant1 tag" - id: "0xC65B" name: "CalibrationIlluminant2" writable: false + type: int16u description: "CalibrationIlluminant2 tag" - id: "0xC65C" name: "BestQualityScale" writable: false + type: rational64u description: "BestQualityScale tag" - id: "0xC65D" name: "RawDataUniqueID" @@ -1581,6 +1613,7 @@ tables: - id: "0xC6BF" name: "ColorimetricReference" writable: false + type: int16u description: "ColorimetricReference tag" - id: "0xC6C5" name: "SRawType" @@ -1613,6 +1646,7 @@ tables: - id: "0xC6F7" name: "NoiseReductionApplied" writable: false + type: rational64u description: "NoiseReductionApplied tag" - id: "0xC6F8" name: "ProfileName" @@ -1637,6 +1671,7 @@ tables: - id: "0xC6FD" name: "ProfileEmbedPolicy" writable: false + type: int32u description: "ProfileEmbedPolicy tag" - id: "0xC6FE" name: "ProfileCopyright" @@ -1669,6 +1704,7 @@ tables: - id: "0xC71A" name: "PreviewColorSpace" writable: false + type: int32u description: "PreviewColorSpace tag" - id: "0xC71B" name: "PreviewDateTime" @@ -1721,6 +1757,7 @@ tables: - id: "0xC764" name: "FrameRate" writable: false + type: rational64s description: "FrameRate tag" - id: "0xC772" name: "TStop" @@ -1749,18 +1786,22 @@ tables: - id: "0xC7A3" name: "ProfileHueSatMapEncoding" writable: false + type: int32u description: "ProfileHueSatMapEncoding tag" - id: "0xC7A4" name: "ProfileLookTableEncoding" writable: false + type: int32u description: "ProfileLookTableEncoding tag" - id: "0xC7A5" name: "BaselineExposureOffset" writable: false + type: rational64s description: "BaselineExposureOffset tag" - id: "0xC7A6" name: "DefaultBlackRender" writable: false + type: int32u description: "DefaultBlackRender tag" - id: "0xC7A7" name: "NewRawImageDigest" @@ -1769,6 +1810,7 @@ tables: - id: "0xC7A8" name: "RawToPreviewGain" writable: false + type: double description: "RawToPreviewGain tag" - id: "0xC7AA" name: "CacheVersion" @@ -1793,22 +1835,27 @@ tables: - id: "0xC7E9" name: "DepthFormat" writable: false + type: int16u description: "DepthFormat tag" - id: "0xC7EA" name: "DepthNear" writable: false + type: rational64u description: "DepthNear tag" - id: "0xC7EB" name: "DepthFar" writable: false + type: rational64u description: "DepthFar tag" - id: "0xC7EC" name: "DepthUnits" writable: false + type: int16u description: "DepthUnits tag" - id: "0xC7ED" name: "DepthMeasureType" writable: false + type: int16u description: "DepthMeasureType tag" - id: "0xC7EE" name: "EnhanceParams" @@ -1829,6 +1876,7 @@ tables: - id: "0xCD31" name: "CalibrationIlluminant3" writable: false + type: int16u description: "CalibrationIlluminant3 tag" - id: "0xCD32" name: "CameraCalibration3" @@ -1881,6 +1929,7 @@ tables: - id: "0xCD43" name: "ColumnInterleaveFactor" writable: false + type: int32u description: "ColumnInterleaveFactor tag" - id: "0xCD44" name: "ImageSequenceInfo" @@ -1901,14 +1950,17 @@ tables: - id: "0xCD49" name: "JXLDistance" writable: false + type: float description: "JXLDistance tag" - id: "0xCD4A" name: "JXLEffort" writable: false + type: int32u description: "JXLEffort tag" - id: "0xCD4B" name: "JXLDecodeSpeed" writable: false + type: int32u description: "JXLDecodeSpeed tag" - id: "0xCEA1" name: "SEAL" @@ -1921,6 +1973,7 @@ tables: - id: "0xEA1D" name: "OffsetSchema" writable: false + type: int32s description: "OffsetSchema tag" - id: "0xFDE8" name: "OwnerName" diff --git a/src/writers/exif_surgical.rs b/src/writers/exif_surgical.rs index 7b6ae1c3b..78d6182e2 100644 --- a/src/writers/exif_surgical.rs +++ b/src/writers/exif_surgical.rs @@ -137,6 +137,31 @@ pub struct OutEntry { native_endian: bool, } +/// One entry already placed in the plan, remembered so the Added loop can +/// judge a numeric tag-id collision instead of dropping it. +/// +/// Two distinct `MetadataMap` keys can resolve to the same numeric EXIF tag id +/// (`"IFD0:Make"` and `"EXIF:Make"`; `"ExifIFD:ExposureCompensation"` and +/// `"ExifIFD:ExposureBiasValue"`, both 0x9204). Only one IFD record may carry a +/// given tag id, so the second one cannot be emitted -- but whether discarding +/// it *loses* anything depends entirely on the value: +/// +/// - identical to what is already planned -> nothing is lost, skip silently; +/// - different -> the caller's edit would vanish while the CLI still reports +/// success, so it must be refused loudly instead. +/// +/// `value` is the effective desired value for the placed entry, or `None` when +/// the entry is raw-carried from bytes the reader never surfaced (an unsurfaced +/// IFD class, or a tag with no reader key). `None` never compares equal, so +/// those collisions are always refused. +#[derive(Debug, Clone)] +struct PlacedEntry { + ifd: IfdKind, + tag_id: u16, + key: Option, + value: Option, +} + /// A fully diffed EXIF write: per-IFD entries plus preserved blobs. #[derive(Debug, Clone, PartialEq)] pub struct WritePlan { @@ -303,6 +328,9 @@ pub fn plan_exif_write( // Reader keys that map back to an always-carried entry (Interop/IFD1/ // MakerNote); see `carried_class_reader_keys`. let mut carried_reader_keys: Vec = Vec::new(); + // Every entry placed into the plan, with the value it stands for, so the + // Added loop can tell a redundant duplicate from a dropped edit. + let mut placed: Vec = Vec::new(); for entry in &scan.entries { let bucket = |plan: &mut WritePlan, e: OutEntry| match entry.ifd { @@ -342,6 +370,12 @@ pub fn plan_exif_write( } } carried_reader_keys.extend(reader_keys); + placed.push(PlacedEntry { + ifd: entry.ifd, + tag_id: entry.tag_id, + key: None, + value: None, + }); bucket(&mut plan, carry); continue; } @@ -349,6 +383,12 @@ pub fn plan_exif_write( let key = lookup_tag_name(entry.tag_id, entry.ifd.prefix()); let Some(original_value) = original_map.get(&key) else { // Reader didn't surface this entry: never drop what it hides + placed.push(PlacedEntry { + ifd: entry.ifd, + tag_id: entry.tag_id, + key: None, + value: None, + }); bucket(&mut plan, carry); continue; }; @@ -356,6 +396,12 @@ pub fn plan_exif_write( continue; // removal by absence }; consumed_keys.push(key.clone()); + placed.push(PlacedEntry { + ifd: entry.ifd, + tag_id: entry.tag_id, + key: Some(key.clone()), + value: Some(desired_value.clone()), + }); if desired_value == original_value { bucket(&mut plan, carry); continue; @@ -429,21 +475,60 @@ pub fn plan_exif_write( // "EXIF:Make") resolve to the same numeric tag id via get_tag_descriptor's // prefix normalization but are distinct MetadataMap keys, so consumed_keys // (tracked by literal key string) cannot catch the collision. - if key.starts_with("ExifIFD:") { - if plan.exif_ifd.iter().any(|e| e.tag_id == tag_id) { - continue; - } - plan.exif_ifd.push(out); + // + // A duplicate cannot be emitted -- one IFD record per tag id -- but it + // must not be discarded in silence either, which is what this guard used + // to do: `oxidex -ExifIFD:ExposureBiasValue=-0.5` (0x9204, which the + // reader surfaces as ExposureCompensation) reported "1 image files + // updated" and left the tag untouched. Skip only when the value already + // planned for that id is the same one; otherwise the caller's edit is + // being dropped, so refuse. + // + // An "EXIF:"-prefixed key names the tag *family*, not a physical IFD, so + // its entry may already have been placed in any of the three writable + // IFDs -- typically by the alias fold above, which routes the edit to the + // native key. Restricting the collision search to IFD0 (the fallback this + // key routes to) missed exactly that case and appended a second record + // for the same tag id in a different IFD: on main, + // `-EXIF:ExposureTime=1/250` left 0x829A in both IFD0 and ExifIFD, and + // ExifTool then reports the file as carrying two ExposureTime tags. + let target = if key.starts_with("ExifIFD:") { + IfdKind::ExifIfd } else if key.starts_with("GPS:") { - if plan.gps.iter().any(|e| e.tag_id == tag_id) { - continue; - } - plan.gps.push(out); + IfdKind::Gps } else { - if plan.ifd0.iter().any(|e| e.tag_id == tag_id) { - continue; + IfdKind::Ifd0 + }; + let family_alias = key.starts_with("EXIF:"); + if let Some(dup) = placed.iter().find(|p| { + p.tag_id == tag_id + && (p.ifd == target + || (family_alias + && matches!(p.ifd, IfdKind::Ifd0 | IfdKind::ExifIfd | IfdKind::Gps))) + }) { + if dup.value.as_ref() == Some(value) { + continue; // same value already planned under another spelling } - plan.ifd0.push(out); + return Err(ExifToolError::unsupported_format(format!( + "Cannot write tag '{}': it resolves to {} tag 0x{:04X}, which is \ + already being written as '{}'. Two names for one tag id cannot \ + both be stored; write the tag under a single name.", + key, + dup.ifd.prefix(), + tag_id, + dup.key.as_deref().unwrap_or("an entry already in the file"), + ))); + } + placed.push(PlacedEntry { + ifd: target, + tag_id, + key: Some(key.clone()), + value: Some(value.clone()), + }); + match target { + IfdKind::ExifIfd => plan.exif_ifd.push(out), + IfdKind::Gps => plan.gps.push(out), + _ => plan.ifd0.push(out), } } diff --git a/tests/integration.rs b/tests/integration.rs index 822e27b71..93dffdc3e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -120,5 +120,8 @@ mod cli_batch_wiring_tests; #[path = "integration/cli_typed_value_tests.rs"] mod cli_typed_value_tests; +#[path = "integration/exif_tag_id_collision_tests.rs"] +mod exif_tag_id_collision_tests; + #[path = "forensic/mod.rs"] mod forensic; diff --git a/tests/integration/exif_tag_id_collision_tests.rs b/tests/integration/exif_tag_id_collision_tests.rs new file mode 100644 index 000000000..1787ad985 --- /dev/null +++ b/tests/integration/exif_tag_id_collision_tests.rs @@ -0,0 +1,251 @@ +//! Two metadata-map keys can name one EXIF tag id. Neither may vanish silently. +//! +//! `plan_exif_write` may emit only one IFD record per tag id, so when two keys +//! resolve to the same id the second one cannot be written. The guard that +//! enforced this used to `continue` — dropping the caller's edit while the CLI +//! still printed "1 image files updated" and exited 0: +//! +//! ```text +//! $ oxidex -ExifIFD:ExposureBiasValue=-0.5 photo.jpg # 0x9204 +//! 1 image files updated # exit 0 +//! $ exiftool -a -G1 -s -ExposureCompensation photo.jpg +//! [ExifIFD] ExposureCompensation : -3/2 # unchanged +//! ``` +//! +//! ExifTool refuses the same command outright — `ExposureBiasValue` is not a +//! writable tag name in any of its tables, only a `Notes` remark on 0x9204 +//! ("called ExposureBiasValue by the EXIF spec", Exif.pm:2369) and an XMP +//! property whose tag name is again `ExposureCompensation` (XMP.pm:2096-2097): +//! +//! ```text +//! $ exiftool -ExifIFD:ExposureBiasValue=-0.5 photo.jpg +//! Warning: Tag 'ExifIFD:ExposureBiasValue' is not defined # Writer.pl:581-584 +//! Nothing to do. # exit 1 +//! ``` +//! +//! So a collision that would lose an edit is now refused loudly, which matches +//! ExifTool's outcome. A collision that would lose *nothing* — the same value +//! already planned under another spelling, which is what the documented +//! `-EXIF:Tag=value` syntax produces — is still skipped silently. +//! +//! Field types are asserted from the serialized bytes, not the reader's +//! rendering: an ASCII "-0.5" and a rational -1/2 both print as a number, so a +//! type-blind assertion would pass on corrupt bytes. + +use oxidex::writers::exif_surgical::{IfdKind, RawEntry, scan_exif_entries}; +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; +use tempfile::NamedTempFile; + +const JPEG_FIXTURE: &str = "tests/fixtures/jpeg/simple/synthetic_001.jpg"; + +// TIFF field types (TIFF 6.0 §2) +const ASCII: u16 = 2; +const SRATIONAL: u16 = 10; + +// Tag ids +const EXPOSURE_TIME: u16 = 0x829A; +const EXPOSURE_COMPENSATION: u16 = 0x9204; + +fn oxidex(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_oxidex")) + .args(args) + .output() + .expect("run oxidex binary") +} + +fn copy_fixture(suffix: &str) -> NamedTempFile { + let temp = tempfile::Builder::new() + .suffix(suffix) + .tempfile() + .expect("create temp fixture copy"); + fs::copy(JPEG_FIXTURE, temp.path()).expect("copy fixture"); + let mut perms = fs::metadata(temp.path()).expect("stat copy").permissions(); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + fs::set_permissions(temp.path(), perms).expect("make copy writable"); + temp +} + +fn path_of(temp: &NamedTempFile) -> String { + temp.path().to_str().expect("utf-8 temp path").to_string() +} + +/// The raw EXIF entries of a JPEG, scanned out of its APP1 `Exif\0\0` segment. +fn jpeg_entries(path: &Path) -> Vec { + let bytes = fs::read(path).expect("read written jpeg"); + let marker = b"Exif\0\0"; + let start = bytes + .windows(marker.len()) + .position(|w| w == marker) + .expect("jpeg carries an EXIF APP1 segment") + + marker.len(); + scan_exif_entries(&bytes[start..]) + .expect("scan EXIF structure") + .entries +} + +fn entry(path: &Path, ifd: IfdKind, tag_id: u16) -> RawEntry { + jpeg_entries(path) + .into_iter() + .find(|e| e.ifd == ifd && e.tag_id == tag_id) + .unwrap_or_else(|| panic!("no {ifd:?} entry for tag 0x{tag_id:04x}")) +} + +fn count_entries(path: &Path, tag_id: u16) -> usize { + jpeg_entries(path) + .iter() + .filter(|e| e.tag_id == tag_id) + .count() +} + +/// Seeds the fixture copy with a known 0x9204, using oxidex's own native-name +/// write (proven correct by `native_name_writes_a_true_srational`). +fn seed_exposure_compensation(temp: &NamedTempFile, value: &str) { + let path = path_of(temp); + let spec = format!("-ExifIFD:ExposureCompensation={value}"); + let out = oxidex(&[&spec, &path]); + assert!(out.status.success(), "seeding 0x9204 failed"); +} + +// --------------------------------------------------------------------------- +// The dropped write must be reported, not swallowed +// --------------------------------------------------------------------------- + +#[test] +fn alias_colliding_with_a_present_tag_fails_loudly() { + let temp = copy_fixture(".jpg"); + seed_exposure_compensation(&temp, "-1.5"); + let before = fs::read(temp.path()).expect("read seeded copy"); + + let path = path_of(&temp); + let out = oxidex(&["-ExifIFD:ExposureBiasValue=-0.5", &path]); + + assert!( + !out.status.success(), + "a write that cannot be applied must not report success; stdout={}", + String::from_utf8_lossy(&out.stdout), + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("ExposureBiasValue") && stderr.contains("0x9204"), + "error must name the tag and the colliding id, got: {stderr}", + ); + + let after = fs::read(temp.path()).expect("read copy after refusal"); + assert_eq!(before, after, "a refused write must not touch the file"); +} + +#[test] +fn refused_collision_leaves_the_original_value_intact() { + let temp = copy_fixture(".jpg"); + seed_exposure_compensation(&temp, "-1.5"); + let path = path_of(&temp); + + let _ = oxidex(&["-ExifIFD:ExposureBiasValue=-0.5", &path]); + + let e = entry(temp.path(), IfdKind::ExifIfd, EXPOSURE_COMPENSATION); + assert_eq!(e.field_type, SRATIONAL); + // -3/2 as SRATIONAL, big- or little-endian agnostic via the scanned bytes + assert_eq!(e.count, 1, "0x9204 must remain a single-valued entry"); + assert_eq!( + count_entries(temp.path(), EXPOSURE_COMPENSATION), + 1, + "a refused write must not append a second record", + ); +} + +// --------------------------------------------------------------------------- +// The registry type fix: 0x9204 is rational64s, not a string +// --------------------------------------------------------------------------- + +#[test] +fn native_name_writes_a_true_srational() { + let temp = copy_fixture(".jpg"); + let path = path_of(&temp); + let out = oxidex(&["-ExifIFD:ExposureCompensation=-0.5", &path]); + assert!(out.status.success(), "native-name write must succeed"); + + let e = entry(temp.path(), IfdKind::ExifIfd, EXPOSURE_COMPENSATION); + assert_eq!( + e.field_type, SRATIONAL, + "ExifTool declares 0x9204 Writable => 'rational64s'; a string \"-0.5\" \ + serialized as ASCII would round-trip as a number but is the wrong type", + ); + assert_ne!(e.field_type, ASCII); + assert_eq!(e.count, 1); +} + +#[test] +fn date_tags_are_normalised_not_stored_verbatim() { + for tag in ["ModifyDate", "CreateDate"] { + let temp = copy_fixture(".jpg"); + let path = path_of(&temp); + let spec = format!("-ExifIFD:{tag}=2024-01-15T10:30:00"); + let out = oxidex(&[&spec, &path]); + assert!(out.status.success(), "{tag} write must succeed"); + + let read = oxidex(&[&path]); + let stdout = String::from_utf8_lossy(&read.stdout).into_owned(); + let line = stdout + .lines() + .find(|l| l.starts_with(&format!("ExifIFD:{tag}: "))) + .unwrap_or_else(|| panic!("{tag} not read back")); + assert!( + line.ends_with("2024:01:15 10:30:00"), + "{tag} must be normalised to ExifTool's stored form, got: {line}", + ); + } +} + +// --------------------------------------------------------------------------- +// A collision that loses nothing stays silent +// --------------------------------------------------------------------------- + +#[test] +fn exif_family_alias_still_writes_and_makes_no_duplicate() { + let temp = copy_fixture(".jpg"); + let path = path_of(&temp); + + // The fixture carries no ExposureTime, so seed one into ExifIFD first -- + // the duplicate only arises when the tag already exists there. + let seed = oxidex(&["-ExifIFD:ExposureTime=1/60", &path]); + assert!(seed.status.success(), "seeding 0x829A failed"); + assert_eq!(count_entries(temp.path(), EXPOSURE_TIME), 1); + + // "EXIF:" names the tag family, not a physical IFD. The alias fold routes + // the edit to the native ExifIFD key; the leftover "EXIF:" key must then be + // recognised as the same value and skipped, not appended to IFD0. + let out = oxidex(&["-EXIF:ExposureTime=1/250", &path]); + assert!( + out.status.success(), + "documented -EXIF:Tag= syntax must work" + ); + + assert_eq!( + count_entries(temp.path(), EXPOSURE_TIME), + 1, + "0x829A must exist exactly once; main wrote it to both IFD0 and ExifIFD", + ); + let e = entry(temp.path(), IfdKind::ExifIfd, EXPOSURE_TIME); + assert_eq!( + e.count, 1, + "the edit must land on the existing ExifIFD entry" + ); +} + +#[test] +fn ordinary_single_tag_write_is_unaffected() { + let temp = copy_fixture(".jpg"); + let path = path_of(&temp); + let out = oxidex(&["-IFD0:Artist=Ada Lovelace", &path]); + assert!(out.status.success(), "plain write must still succeed"); + + let read = oxidex(&[&path]); + let stdout = String::from_utf8_lossy(&read.stdout).into_owned(); + assert!( + stdout.contains("IFD0:Artist: Ada Lovelace"), + "Artist must round-trip", + ); +} From 70aa81f8ae23d33c3f1428f04fb40677431b5ebf Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 21:03:45 -0500 Subject: [PATCH 08/16] feat(pentax): enter the five MakerNote sub-directories gated on the camera model `exiftool -v2` descends into 563 of the corpus's files with a Pentax or FujiFilm MakerNote. Comparing per file against `exiftool -a -G1 -s` (ExifTool 13.59), five of the directories it enters produced no oxidex row at all -- not a wrong value, not a missing-tag report, nothing, because a directory that is never entered has no rows to be missing: tag ExifTool table files tags 0x0216 %Pentax::BatteryInfo 30 129 0x021f %Pentax::AFInfo 28 111 0x022a %Pentax::FilterInfo 18 36 0x03ff %Pentax::TempInfo 8 22 0x0226 %Pentax::ShotInfo 6 6 These are plain `ProcessBinaryData` records under a MakerNote oxidex already reads, so the transcription is the generator's job. What kept them out is that almost every field in them carries an ExifTool `Condition` on the camera model, and `%Pentax::BatteryInfo` is an arrayref of such alternatives at nearly every offset -- the construct `codegen_subdirs.py` logged as "arrayref of Condition variants" and refused. Byte 2 of that record alone is `BodyBatteryADNoLoad` on a K10D with one calibration `PrintConv`, an uncalibrated `BodyBatteryADNoLoad` on a *istD, half of an `int16u` `BodyBatteryVoltage1` on a K-5, and `BodyBatteryState` on a K-3 III (Pentax.pm:4846-4935). Read without the condition it is one number under four different real tag names. So the generator grew a vocabulary rather than a special case: - `Condition` over `$$self{Model}` -- `=~`, `!~`, `eq`, and the `A and B` conjunction Pentax.pm:4864 uses to hold the K-3 III out of a pattern that would otherwise catch it. The regexes are expanded to literal alternations at generation time (32 branches, `\*ist` and `GX-1[LS]?` and `K-(1|01|3|5|7|30|50|70|500|r|x|S[12])` included) by a parser that raises on any construct it has not been taught, so the model test stays derived from ExifTool's own pattern text instead of a hand-typed list. - alternatives as several `Field`s sharing one ExifTool key, in ExifTool's order; the decoder takes the first whose condition holds and reports nothing when none does. A key any of whose alternatives is refused is dropped whole -- removing an earlier one would let a later, broader one answer for a body it was never meant to describe. - `PrintConv` as a Perl expression, and `ValueConv` composed with it, which is what makes a raw 686 of centivolts print "6.86 V" rather than "686.00 V". - `Field` now carries ExifTool's own key text, because `545`, `545.1` and `545.2` are three masked tags at one offset while two entries keyed `2` are two readings of one tag. 0x021f and 0x0216 declare `ByteOrder => 'BigEndian'` on the SubDirectory and are read that way whatever the MakerNote's own order is (Pentax.pm:2983-2986, :2949). 0x022a is one table read either way round, chosen by `$$self{Make} =~ /^RICOH/` (Pentax.pm:3032) -- the brand, not the model -- so `PentaxParser` now carries that test from the dispatcher rather than guessing it from the model. Every constant is quoted from Pentax.pm in the code; the unit tests replay the exact record bytes `exiftool -v3` prints for PentaxK10D.jpg and PentaxK-5IIs.jpg against the exact values `exiftool -a -G1 -s` reports for those files. Regenerating reproduces the committed tables byte for byte, and the FujiFilm and Panasonic tables are field-identical to before apart from the two new columns. Measured per file over /tmp/oxidex-exiftool-cache/combined-samples against ExifTool 13.59, keyed `Group1:Name`, both binaries built from this tree: 563 Pentax/FujiFilm files matched 45,133 -> 45,437 (+304 across 31 files) missing 12,375 -> 12,065 regressions 0 PentaxK-5IIs.jpg 197 -> 214 PentaxK-5.jpg 187 -> 204 PentaxK-r.jpg 184 -> 197 PentaxK10D.jpg 148 -> 159 Six values on Pentax_istD.jpg are wrong, and are named here rather than buried: that file's MakerNote offsets need ExifTool's `FixBase` correction (ExifTool resolves its 0x0216 pointer 16 bytes below the TIFF header), which oxidex does not implement -- so all of its out-of-line values are misread today, 75 of them before this change. The six new ones are the same pre-existing fault reaching six more names, not a new one. Still not entered, with the generator's own reason: `%Pentax::CameraSettings` (a hand-written arm already owns 0x0205 and covers tags the generator refuses), `FilterInfo`'s 20 `DigitalFilterNN` fields (a `RawConv` unpack idiom), `%Pentax::AEInfo`/`AEInfo2`/`AEInfo3` (`PentaxEv` conversions), `%Pentax::CAFPointInfo` (`DecodeAFPoints`), and every `BITMASK` `PrintConv`. Co-Authored-By: Claude Opus 5 --- src/parsers/tiff/makernote_dispatcher.rs | 11 +- .../makernotes/fujifilm/settings_tables.rs | 30 +- .../tiff/makernotes/panasonic/face_tables.rs | 36 +- src/parsers/tiff/makernotes/pentax.rs | 275 ++- .../tiff/makernotes/pentax/print_conv.rs | 119 ++ .../tiff/makernotes/pentax/subdir_tables.rs | 1495 ++++++++++++++++- .../tiff/makernotes/pentax/value_conv.rs | 38 + .../tiff/makernotes/shared/binary_subdir.rs | 359 +++- tests/integration/pentax_makernotes_tests.rs | 14 +- tools/exiftool-tables/codegen_subdirs.py | 335 +++- 10 files changed, 2610 insertions(+), 102 deletions(-) create mode 100644 src/parsers/tiff/makernotes/pentax/print_conv.rs diff --git a/src/parsers/tiff/makernote_dispatcher.rs b/src/parsers/tiff/makernote_dispatcher.rs index 6a5ef720c..fef3bbe0f 100644 --- a/src/parsers/tiff/makernote_dispatcher.rs +++ b/src/parsers/tiff/makernote_dispatcher.rs @@ -100,10 +100,15 @@ fn parser_for_make_prefix( return Some(Box::new(olympus::OlympusParser) as Box); } if make.starts_with("pentax") || make.starts_with("asahi optical") { - return Some(Box::new(pentax::PentaxParser) as Box); + return Some(Box::new(pentax::PentaxParser::default()) as Box); } + // `make` reaches here already lowercased, so this is ExifTool's + // `$$self{Make} =~ /^RICOH/` (Pentax.pm:3032) -- which the modern + // "RICOH IMAGING COMPANY, LTD." Pentax bodies satisfy too. if make.starts_with("ricoh imaging") { - return Some(Box::new(pentax::PentaxParser) as Box); + return Some( + Box::new(pentax::PentaxParser { ricoh_make: true }) as Box + ); } // GE cameras are branded "General Imaging Co." in EXIF -- the literal // table below only listed "ge" and "general electric", so the one GE file @@ -118,7 +123,7 @@ fn parser_for_make_prefix( // write a Pentax "AOC\0" MakerNote; ExifTool files their tags under // family-1 "Pentax". Every other Samsung goes to the Samsung parser. if data.len() >= 4 && &data[0..4] == PENTAX_AOC_SIGNATURE { - return Some(Box::new(pentax::PentaxParser) as Box); + return Some(Box::new(pentax::PentaxParser::default()) as Box); } return Some(Box::new(samsung::SamsungParser) as Box); } diff --git a/src/parsers/tiff/makernotes/fujifilm/settings_tables.rs b/src/parsers/tiff/makernotes/fujifilm/settings_tables.rs index e9245239c..1ed7e01ea 100644 --- a/src/parsers/tiff/makernotes/fujifilm/settings_tables.rs +++ b/src/parsers/tiff/makernotes/fujifilm/settings_tables.rs @@ -1,14 +1,14 @@ //! FujiFilm MakerNote binary sub-directory tables. //! //! GENERATED by `tools/exiftool-tables/codegen_subdirs.py` from ExifTool -//! 13.55's in-memory tag tables. Do not edit by hand. +//! 13.59's in-memory tag tables. Do not edit by hand. //! //! The generator refuses any construct it has not been taught and names it, so //! a field that is here was reproduced exactly and a field that is missing was //! reported as missing -- neither is a guess. use crate::parsers::tiff::makernotes::shared::binary_subdir::{ - BinaryTable, Field, Fmt, PrintConv, ValueConv, + BinaryTable, Cond, Field, Fmt, PrintConv, ValueConv, }; const FUJIFILM_CONV1: &[(i64, &str)] = &[(1, "Release"), (2, "Focus")]; @@ -37,7 +37,9 @@ pub(crate) static FUJIFILM_PRIORITYSETTINGS: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0.1", index: 0, + cond: Cond::Always, name: "AF-SPriority", format: None, count: 1, @@ -48,7 +50,9 @@ pub(crate) static FUJIFILM_PRIORITYSETTINGS: BinaryTable = BinaryTable { print_conv: PrintConv::Map(FUJIFILM_CONV1), }, Field { + key: "0.2", index: 0, + cond: Cond::Always, name: "AF-CPriority", format: None, count: 1, @@ -71,7 +75,9 @@ pub(crate) static FUJIFILM_FOCUSSETTINGS: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0.1", index: 0, + cond: Cond::Always, name: "FocusMode2", format: None, count: 1, @@ -82,7 +88,9 @@ pub(crate) static FUJIFILM_FOCUSSETTINGS: BinaryTable = BinaryTable { print_conv: PrintConv::Map(FUJIFILM_CONV2), }, Field { + key: "0.2", index: 0, + cond: Cond::Always, name: "PreAF", format: None, count: 1, @@ -93,7 +101,9 @@ pub(crate) static FUJIFILM_FOCUSSETTINGS: BinaryTable = BinaryTable { print_conv: PrintConv::Map(FUJIFILM_CONV3), }, Field { + key: "0.3", index: 0, + cond: Cond::Always, name: "AFAreaMode", format: None, count: 1, @@ -104,7 +114,9 @@ pub(crate) static FUJIFILM_FOCUSSETTINGS: BinaryTable = BinaryTable { print_conv: PrintConv::Map(FUJIFILM_CONV4), }, Field { + key: "0.4", index: 0, + cond: Cond::Always, name: "AFAreaPointSize", format: None, count: 1, @@ -115,7 +127,9 @@ pub(crate) static FUJIFILM_FOCUSSETTINGS: BinaryTable = BinaryTable { print_conv: PrintConv::MapOr(FUJIFILM_CONV5, super::print_conv::identity), }, Field { + key: "0.5", index: 0, + cond: Cond::Always, name: "AFAreaZoneSize", format: None, count: 1, @@ -138,7 +152,9 @@ pub(crate) static FUJIFILM_AFCSETTINGS: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "AF-CSetting", format: None, count: 1, @@ -149,7 +165,9 @@ pub(crate) static FUJIFILM_AFCSETTINGS: BinaryTable = BinaryTable { print_conv: PrintConv::MapOr(FUJIFILM_CONV6, super::print_conv::custom_afc_set), }, Field { + key: "0.1", index: 0, + cond: Cond::Always, name: "AF-CTrackingSensitivity", format: None, count: 1, @@ -160,7 +178,9 @@ pub(crate) static FUJIFILM_AFCSETTINGS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "0.2", index: 0, + cond: Cond::Always, name: "AF-CSpeedTrackingSensitivity", format: None, count: 1, @@ -171,7 +191,9 @@ pub(crate) static FUJIFILM_AFCSETTINGS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "0.3", index: 0, + cond: Cond::Always, name: "AF-CZoneAreaSwitching", format: None, count: 1, @@ -194,7 +216,9 @@ pub(crate) static FUJIFILM_DRIVESETTINGS: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0.1", index: 0, + cond: Cond::Always, name: "DriveMode", format: None, count: 1, @@ -205,7 +229,9 @@ pub(crate) static FUJIFILM_DRIVESETTINGS: BinaryTable = BinaryTable { print_conv: PrintConv::Map(FUJIFILM_CONV8), }, Field { + key: "0.2", index: 0, + cond: Cond::Always, name: "DriveSpeed", format: None, count: 1, diff --git a/src/parsers/tiff/makernotes/panasonic/face_tables.rs b/src/parsers/tiff/makernotes/panasonic/face_tables.rs index 3b0f6ea8a..fb3ae60b8 100644 --- a/src/parsers/tiff/makernotes/panasonic/face_tables.rs +++ b/src/parsers/tiff/makernotes/panasonic/face_tables.rs @@ -1,14 +1,14 @@ //! Panasonic MakerNote binary sub-directory tables. //! //! GENERATED by `tools/exiftool-tables/codegen_subdirs.py` from ExifTool -//! 13.55's in-memory tag tables. Do not edit by hand. +//! 13.59's in-memory tag tables. Do not edit by hand. //! //! The generator refuses any construct it has not been taught and names it, so //! a field that is here was reproduced exactly and a field that is missing was //! reported as missing -- neither is a guess. use crate::parsers::tiff::makernotes::shared::binary_subdir::{ - BinaryTable, Field, Fmt, PrintConv, ValueConv, + BinaryTable, Cond, Field, Fmt, PrintConv, ValueConv, }; /// `Image::ExifTool::Panasonic::FaceDetInfo` -- 6 fields, FORMAT `int16u`. @@ -21,7 +21,9 @@ pub(crate) static PANASONIC_FACEDETINFO: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "NumFacePositions", format: Some(Fmt::U16), count: 1, @@ -32,7 +34,9 @@ pub(crate) static PANASONIC_FACEDETINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "1", index: 1, + cond: Cond::Always, name: "Face1Position", format: Some(Fmt::U16), count: 4, @@ -43,7 +47,9 @@ pub(crate) static PANASONIC_FACEDETINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "5", index: 5, + cond: Cond::Always, name: "Face2Position", format: Some(Fmt::U16), count: 4, @@ -54,7 +60,9 @@ pub(crate) static PANASONIC_FACEDETINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "9", index: 9, + cond: Cond::Always, name: "Face3Position", format: Some(Fmt::U16), count: 4, @@ -65,7 +73,9 @@ pub(crate) static PANASONIC_FACEDETINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "13", index: 13, + cond: Cond::Always, name: "Face4Position", format: Some(Fmt::U16), count: 4, @@ -76,7 +86,9 @@ pub(crate) static PANASONIC_FACEDETINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "17", index: 17, + cond: Cond::Always, name: "Face5Position", format: Some(Fmt::U16), count: 4, @@ -99,7 +111,9 @@ pub(crate) static PANASONIC_FACERECINFO: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "FacesRecognized", format: Some(Fmt::U16), count: 1, @@ -110,7 +124,9 @@ pub(crate) static PANASONIC_FACERECINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "4", index: 4, + cond: Cond::Always, name: "RecognizedFace1Name", format: Some(Fmt::Str(20)), count: 1, @@ -121,7 +137,9 @@ pub(crate) static PANASONIC_FACERECINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "24", index: 24, + cond: Cond::Always, name: "RecognizedFace1Position", format: Some(Fmt::U16), count: 4, @@ -132,7 +150,9 @@ pub(crate) static PANASONIC_FACERECINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "32", index: 32, + cond: Cond::Always, name: "RecognizedFace1Age", format: Some(Fmt::Str(20)), count: 1, @@ -143,7 +163,9 @@ pub(crate) static PANASONIC_FACERECINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "52", index: 52, + cond: Cond::Always, name: "RecognizedFace2Name", format: Some(Fmt::Str(20)), count: 1, @@ -154,7 +176,9 @@ pub(crate) static PANASONIC_FACERECINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "72", index: 72, + cond: Cond::Always, name: "RecognizedFace2Position", format: Some(Fmt::U16), count: 4, @@ -165,7 +189,9 @@ pub(crate) static PANASONIC_FACERECINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "80", index: 80, + cond: Cond::Always, name: "RecognizedFace2Age", format: Some(Fmt::Str(20)), count: 1, @@ -176,7 +202,9 @@ pub(crate) static PANASONIC_FACERECINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "100", index: 100, + cond: Cond::Always, name: "RecognizedFace3Name", format: Some(Fmt::Str(20)), count: 1, @@ -187,7 +215,9 @@ pub(crate) static PANASONIC_FACERECINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "120", index: 120, + cond: Cond::Always, name: "RecognizedFace3Position", format: Some(Fmt::U16), count: 4, @@ -198,7 +228,9 @@ pub(crate) static PANASONIC_FACERECINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "128", index: 128, + cond: Cond::Always, name: "RecognizedFace3Age", format: Some(Fmt::Str(20)), count: 1, diff --git a/src/parsers/tiff/makernotes/pentax.rs b/src/parsers/tiff/makernotes/pentax.rs index d87321313..711d75840 100644 --- a/src/parsers/tiff/makernotes/pentax.rs +++ b/src/parsers/tiff/makernotes/pentax.rs @@ -24,6 +24,8 @@ #![allow(dead_code)] #![allow(unused_imports)] +/// The expression `PrintConv`s those tables need, hand-written. +mod print_conv; /// `%Pentax` binary sub-tables, generated from ExifTool's own hashes. pub mod subdir_tables; /// The `ValueConv` computations those tables need, hand-written. @@ -43,12 +45,13 @@ use std::collections::HashMap; use super::pentax_lens_database::lookup_lens_type_pair; use super::shared::MakerNoteParser; use super::shared::array_extractors::{extract_i16_array, extract_u16_array, extract_u32_array}; -use super::shared::binary_subdir::{self, BinaryTable}; +use super::shared::binary_subdir::{self, BinaryTable, Cond, ModelPat}; use super::shared::generic_decoders::ON_OFF; use subdir_tables::{ - PENTAX_AWBINFO, PENTAX_EVSTEPINFO, PENTAX_FACEINFO, PENTAX_FACEPOS, PENTAX_FACESIZE, - PENTAX_FLASHINFO, PENTAX_KELVINWB, PENTAX_LENSCORR, PENTAX_LENSINFOQ, PENTAX_LEVELINFO, - PENTAX_SRINFO2, PENTAX_TIMEINFO, PENTAX_WBLEVELS, + PENTAX_AFINFO, PENTAX_AWBINFO, PENTAX_BATTERYINFO, PENTAX_EVSTEPINFO, PENTAX_FACEINFO, + PENTAX_FACEPOS, PENTAX_FACESIZE, PENTAX_FILTERINFO, PENTAX_FLASHINFO, PENTAX_KELVINWB, + PENTAX_LENSCORR, PENTAX_LENSINFOQ, PENTAX_LEVELINFO, PENTAX_SHOTINFO, PENTAX_SRINFO2, + PENTAX_TEMPINFO, PENTAX_TIMEINFO, PENTAX_WBLEVELS, }; // Import declarative decoder macros @@ -194,7 +197,12 @@ const PENTAX_CAMERA_SETTINGS: u16 = 0x0205; const PENTAX_AE_INFO: u16 = 0x0206; const PENTAX_LENS_INFO_207: u16 = 0x0207; const PENTAX_CAMERA_INFO: u16 = 0x0215; +const PENTAX_BATTERY_INFO: u16 = 0x0216; // Pentax.pm:2945 +const PENTAX_AF_INFO_RECORD: u16 = 0x021F; // Pentax.pm:2980 const PENTAX_COLOR_INFO: u16 = 0x0222; +const PENTAX_SHOT_INFO: u16 = 0x0226; // Pentax.pm:3011 +const PENTAX_FILTER_INFO: u16 = 0x022A; // Pentax.pm:3030 +const PENTAX_TEMP_INFO: u16 = 0x03FF; // Pentax.pm:3126 const PENTAX_SERIAL_NUMBER: u16 = 0x0229; const PENTAX_ARTIST: u16 = 0x022E; const PENTAX_COPYRIGHT: u16 = 0x022F; @@ -603,7 +611,17 @@ pub fn is_pentax_makernote(data: &[u8]) -> bool { } /// Represents a Pentax MakerNote parser -pub struct PentaxParser; +/// +/// `ricoh_make` is ExifTool's `$$self{Make} =~ /^RICOH/` (Pentax.pm:3032), the +/// test that picks `FilterInfo`'s byte order. It is a property of the file's +/// IFD0, not of the MakerNote, so it is fixed when the dispatcher chooses this +/// parser -- the same body writes the record the other way round under the +/// other brand, and guessing from the model would be inventing the answer for +/// every Ricoh-branded Pentax. +#[derive(Default)] +pub struct PentaxParser { + pub ricoh_make: bool, +} impl MakerNoteParser for PentaxParser { fn manufacturer_name(&self) -> &'static str { @@ -769,7 +787,8 @@ impl PentaxParser { // Extract tags from entries for entry in entries { - if let Some((table, order)) = pentax_binary_subdir(&entry, model, &members, byte_order) + if let Some((table, order)) = + pentax_binary_subdir(&entry, model, self.ricoh_make, &members, byte_order) { let record = inline_or_offset_bytes(&entry, data, value_base, byte_order); if !record.is_empty() { @@ -778,6 +797,7 @@ impl PentaxParser { &record, order, "Pentax", + model, &mut members, tags, ); @@ -2015,6 +2035,7 @@ fn right_align_inline_value(entry: IfdEntry, byte_order: ByteOrder) -> IfdEntry fn pentax_binary_subdir( entry: &IfdEntry, model: Option<&str>, + ricoh_make: bool, members: &binary_subdir::Members, byte_order: ByteOrder, ) -> Option<(&'static BinaryTable, ByteOrder)> { @@ -2049,12 +2070,34 @@ fn pentax_binary_subdir( // `Condition => '$count == 100'` (Pentax.pm:3050). PENTAX_WB_LEVELS if count == 100 => &PENTAX_WBLEVELS, PENTAX_LENS_INFO_Q => &PENTAX_LENSINFOQ, // Pentax.pm:3095 + PENTAX_SHOT_INFO => &PENTAX_SHOTINFO, // Pentax.pm:3011 + // 0x03ff is `TempInfo` on the listed bodies and `UnknownInfo` -- a table + // with no tags in it -- on every other (Pentax.pm:3126-3134). + PENTAX_TEMP_INFO if TEMP_INFO_MODELS.holds(model) => &PENTAX_TEMPINFO, + // These three declare `ByteOrder` on the SubDirectory, so they are read + // big-endian whatever the MakerNote's own order is. ExifTool's reason, + // at Pentax.pm:2983-2986: "Most of these subdirectories are 'undef' + // format, and as such the byte ordering is not changed when changed via + // the Pentax software (which will write a little-endian TIFF on an Intel + // system)." 0x0216 repeats the warning -- "have seen makernotes changed + // to little-endian in DNG!" (Pentax.pm:2949). + PENTAX_BATTERY_INFO => return Some((&PENTAX_BATTERYINFO, ByteOrder::BigEndian)), + PENTAX_AF_INFO_RECORD => return Some((&PENTAX_AFINFO, ByteOrder::BigEndian)), + // 0x022a is one table read either way round: `LittleEndian` when + // `$$self{Make} =~ /^RICOH/`, `BigEndian` otherwise + // (Pentax.pm:3030-3042). The brand, not the model, decides. + PENTAX_FILTER_INFO => { + let order = if ricoh_make { + ByteOrder::LittleEndian + } else { + ByteOrder::BigEndian + }; + return Some((&PENTAX_FILTERINFO, order)); + } _ => return None, }; - // None of these `SubDirectory` entries carries a `ByteOrder` override, so - // each record is read in the MakerNote's own order. (`AFInfo`, - // `BatteryInfo`, `CameraSettings` and `FilterInfo` do carry one; none of - // them is transcribed here.) + // The remaining `SubDirectory` entries carry no `ByteOrder` override, so + // each record is read in the MakerNote's own order. Some((table, byte_order)) } @@ -2063,6 +2106,42 @@ fn is_k3_mark_iii(model: Option<&str>) -> bool { model.is_some_and(|m| m.contains("K-3 Mark III")) } +/// `Condition => '$$self{Model} =~ /K-(01|3|30|5|50|500)\b/'` on the 0x03ff +/// `TempInfo` alternative (Pentax.pm:3129), expanded the same way +/// `codegen_subdirs.py` expands the ones inside a table. +/// +/// The `\b` is what keeps `K-5` off a `K-50`, and it is also why a K-3 Mark III +/// *is* included: "K-3" there is followed by a space. +static TEMP_INFO_MODELS: Cond = Cond::Model { + any_of: &[ + ModelPat { + text: "K-01", + word_end: true, + }, + ModelPat { + text: "K-3", + word_end: true, + }, + ModelPat { + text: "K-30", + word_end: true, + }, + ModelPat { + text: "K-5", + word_end: true, + }, + ModelPat { + text: "K-50", + word_end: true, + }, + ModelPat { + text: "K-500", + word_end: true, + }, + ], + none_of: &[], +}; + fn inline_or_offset_bytes( entry: &IfdEntry, full_data: &[u8], @@ -2871,14 +2950,14 @@ mod tests { #[test] fn test_parser_trait_implementation() { - let parser = PentaxParser; + let parser = PentaxParser::default(); assert_eq!(parser.manufacturer_name(), "Pentax"); assert_eq!(parser.tag_prefix(), "Pentax:"); } #[test] fn test_validate_header_aoc() { - let parser = PentaxParser; + let parser = PentaxParser::default(); let valid_header = b"AOC\0extra_data_here"; assert!(parser.validate_header(valid_header)); @@ -2889,7 +2968,7 @@ mod tests { #[test] fn test_validate_header_pentax() { - let parser = PentaxParser; + let parser = PentaxParser::default(); let valid_header = b"PENTAX \0more_data"; assert!(parser.validate_header(valid_header)); @@ -2932,7 +3011,7 @@ mod tests { faces.insert("FacesDetected", 2); let le = ByteOrder::LittleEndian; let pick = |e: &IfdEntry, model, m: &binary_subdir::Members| { - pentax_binary_subdir(e, model, m, le).map(|(t, _)| t.name) + pentax_binary_subdir(e, model, false, m, le).map(|(t, _)| t.name) }; // 0x005c: `$count == 4` is SRInfo, handled elsewhere; anything else SRInfo2. @@ -2962,6 +3041,172 @@ mod tests { ); } + /// Decodes `record` the way the dispatcher would, for a named model. + fn decode_for(table: &BinaryTable, record: &[u8], model: &str) -> HashMap { + let mut tags = HashMap::new(); + let mut members = binary_subdir::Members::new(); + binary_subdir::decode_binary_subdir_with( + table, + record, + ByteOrder::BigEndian, + "Pentax", + Some(model), + &mut members, + &mut tags, + ); + tags + } + + /// `combined-samples/Pentax/PentaxK10D.jpg` tag 0x0216: the exact 6 record + /// bytes `exiftool -v3` prints, against the exact values `exiftool -a -G1 + /// -s` reports for that file. + /// + /// Every alternative in `%Pentax::BatteryInfo` is `Condition`-guarded, so + /// this is the test that the model test picks ExifTool's branch: byte 2 is + /// `BodyBatteryADNoLoad` with the K10D's calibration `PrintConv` here, and + /// two bytes of `BodyBatteryVoltage1` on a K-5. + #[test] + fn test_battery_info_matches_exiftool_on_k10d_bytes() { + let tags = decode_for( + &PENTAX_BATTERYINFO, + &[0x02, 0x41, 0xa5, 0xa0, 0x05, 0x01], + "PENTAX K10D", + ); + assert_eq!(tags["Pentax:PowerSource"], "Body Battery"); + assert_eq!(tags["Pentax:BodyBatteryState"], "Full"); + assert_eq!(tags["Pentax:GripBatteryState"], "Empty or Missing"); + assert_eq!(tags["Pentax:BodyBatteryADNoLoad"], "165 (7.3V, 28%)"); + assert_eq!(tags["Pentax:BodyBatteryADLoad"], "160 (7.0V, 23%)"); + assert_eq!(tags["Pentax:GripBatteryADNoLoad"], "5"); + assert_eq!(tags["Pentax:GripBatteryADLoad"], "1"); + // The K10D has no voltage reading at all -- those alternatives belong + // to other bodies, and emitting one here would put a plausible number + // under a real tag name. + assert!(!tags.contains_key("Pentax:BodyBatteryVoltage1")); + } + + /// The same table over `combined-samples/Pentax/PentaxK-5IIs.jpg`'s own + /// 0x0216 bytes: the *third* alternative of key 2 now applies, so bytes 2-3 + /// are one `int16u` of centivolts rather than two independent A/D readings. + #[test] + fn test_battery_info_matches_exiftool_on_k5iis_bytes() { + let tags = decode_for( + &PENTAX_BATTERYINFO, + &[ + 0xf2, 0x50, 0x02, 0xae, 0x02, 0x8f, 0x02, 0xc4, 0x02, 0xa4, 0x00, 0x00, + ], + "PENTAX K-5 II s", + ); + assert_eq!(tags["Pentax:PowerSource"], "Body Battery"); + assert_eq!(tags["Pentax:BodyBatteryState"], "Full"); + assert_eq!(tags["Pentax:BodyBatteryVoltage1"], "6.86 V"); + assert_eq!(tags["Pentax:BodyBatteryVoltage2"], "6.55 V"); + assert_eq!(tags["Pentax:BodyBatteryVoltage3"], "7.08 V"); + assert_eq!(tags["Pentax:BodyBatteryVoltage4"], "6.76 V"); + assert!(!tags.contains_key("Pentax:BodyBatteryADNoLoad")); + assert!(!tags.contains_key("Pentax:GripBatteryADNoLoad")); + } + + /// `combined-samples/Pentax/PentaxK10D.jpg` tag 0x021f, all 12 bytes. + /// + /// `AFIntegrationTime` is the `ValueConv`-then-expression-`PrintConv` case: + /// the raw 0 doubles to 0 ms, and ExifTool prints the number bare rather + /// than as "0.0". + #[test] + fn test_af_info_matches_exiftool_on_k10d_bytes() { + let tags = decode_for( + &PENTAX_AFINFO, + &[ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x0f, + ], + "PENTAX K10D", + ); + assert_eq!(tags["Pentax:AFPredictor"], "15"); + assert_eq!(tags["Pentax:AFDefocus"], "10"); + assert_eq!(tags["Pentax:AFIntegrationTime"], "0 ms"); + assert_eq!(tags["Pentax:AFPointsInFocus"], "Lower-right, Mid-right"); + } + + /// `combined-samples/Pentax/PentaxK-5IIs.jpg` tags 0x03ff, 0x0226 and + /// 0x022a -- the first 24 bytes of the 256-byte `TempInfo` record, all 11 + /// of `ShotInfo`, and the leading zeros of `FilterInfo`. + #[test] + fn test_temp_shot_and_filter_info_match_exiftool_on_k5iis_bytes() { + let temp = decode_for( + &PENTAX_TEMPINFO, + &[ + 0x00, 0x04, 0x00, 0x01, 0x82, 0x3f, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0xcd, + 0x00, 0xcd, 0x00, 0x00, 0x00, 0xfa, 0x00, 0x14, 0x00, 0x14, + ], + "PENTAX K-5 II s", + ); + assert_eq!(temp["Pentax:SensorTemperature"], "20.5 C"); + assert_eq!(temp["Pentax:SensorTemperature2"], "20.5 C"); + assert_eq!(temp["Pentax:CameraTemperature4"], "20 C"); + assert_eq!(temp["Pentax:CameraTemperature5"], "20 C"); + + let shot = decode_for( + &PENTAX_SHOTINFO, + &[ + 0xf0, 0x10, 0x7b, 0xff, 0xaa, 0xfc, 0x0e, 0x06, 0x00, 0x00, 0x00, + ], + "PENTAX K-5 II s", + ); + assert_eq!(shot["Pentax:CameraOrientation"], "Horizontal (normal)"); + + let filter = decode_for(&PENTAX_FILTERINFO, &[0x00; 8], "PENTAX K-5 II s"); + assert_eq!(filter["Pentax:SourceDirectoryIndex"], "0"); + assert_eq!(filter["Pentax:SourceFileIndex"], "0"); + } + + /// The 0x03ff alternative list: `TempInfo` only on the bodies ExifTool + /// names, and `\b` is what keeps a K-5 pattern off a K-50. + #[test] + fn test_temp_info_model_gate_respects_word_boundaries() { + let e = IfdEntry { + tag_id: PENTAX_TEMP_INFO, + field_type: 7, + value_count: 256, + value_offset: 0, + }; + let none = binary_subdir::Members::new(); + let pick = |model| { + pentax_binary_subdir(&e, model, false, &none, ByteOrder::BigEndian).map(|(t, _)| t.name) + }; + assert_eq!(pick(Some("PENTAX K-5 II s")), Some("TempInfo")); + assert_eq!(pick(Some("PENTAX K-50")), Some("TempInfo")); + assert_eq!(pick(Some("PENTAX K-500")), Some("TempInfo")); + // Not in the list: 0x03ff is `UnknownInfo`, which declares no tags. + assert_eq!(pick(Some("PENTAX K-7")), None); + assert_eq!(pick(Some("PENTAX K10D")), None); + assert_eq!(pick(None), None); + } + + /// 0x022a is one table read either way round, chosen by `$$self{Make}` + /// rather than the model (Pentax.pm:3030-3042). + #[test] + fn test_filter_info_byte_order_follows_the_brand() { + let e = IfdEntry { + tag_id: PENTAX_FILTER_INFO, + field_type: 7, + value_count: 345, + value_offset: 0, + }; + let none = binary_subdir::Members::new(); + let order = |ricoh| { + pentax_binary_subdir( + &e, + Some("PENTAX K-5 II s"), + ricoh, + &none, + ByteOrder::BigEndian, + ) + .map(|(_, o)| o) + }; + assert_eq!(order(false), Some(ByteOrder::BigEndian)); + assert_eq!(order(true), Some(ByteOrder::LittleEndian)); + } + /// `combined-samples/Pentax/PentaxK-5.jpg` tag 0x022b: the exact 8 record /// bytes `exiftool -v3` prints, and the exact values `exiftool -a -G1 -s` /// reports. This is the `ValueConv` case -- `RollAngle` is an int8s of 1 @@ -2975,6 +3220,7 @@ mod tests { &[0x21, 0x01, 0xf6, 0x00, 0x12, 0xfc, 0x01, 0x00], ByteOrder::BigEndian, "Pentax", + None, &mut members, &mut tags, ); @@ -2999,6 +3245,7 @@ mod tests { &[0x00, 0x00, 0x0b, 0x0b], ByteOrder::BigEndian, "Pentax", + None, &mut members, &mut tags, ); diff --git a/src/parsers/tiff/makernotes/pentax/print_conv.rs b/src/parsers/tiff/makernotes/pentax/print_conv.rs new file mode 100644 index 000000000..337332d44 --- /dev/null +++ b/src/parsers/tiff/makernotes/pentax/print_conv.rs @@ -0,0 +1,119 @@ +//! The expression `PrintConv`s of `%Pentax`'s binary sub-tables. +//! +//! A `PrintConv` that is a Perl expression rather than a hash is a computation, +//! so like a `ValueConv` it has to be ported rather than carried as data. Each +//! function quotes the ExifTool source it was written against and +//! `codegen_subdirs.py` binds it by that exact text, so an upstream edit stops +//! the generator instead of leaving one of these behind a real tag name. +//! +//! Every one of these runs on the value *after* `ValueConv`, which is what +//! `$val` is at `PrintConv` time. + +use crate::core::formatters::numeric_precision::perl_number; + +/// `PrintConv => 'sprintf("%.2f V", $val)'` (Pentax.pm:4868, :4923, :4932, +/// :4948, :4958, :4986 -- every `BodyBattery*`/`GripBatteryVoltage`). +pub(super) fn volts_2dp(value: f64) -> String { + format!("{value:.2} V") +} + +/// `PrintConv => 'sprintf("%d (%.1fV, %d%%)",$val,$val*8.18/186,($val-155)*100/35)'` +/// (Pentax.pm:4854 `BodyBatteryADNoLoad`, K10D/K20D). +/// +/// ExifTool's own calibration note: "DVM readings: 8.18V=186, 8.42-8.40V=192 +/// (full), 6.86V=155 (empty)" (Pentax.pm:4853). Perl's `%d` truncates toward +/// zero, so the percentage is not rounded. +pub(super) fn ad_no_load(value: f64) -> String { + let volts = value * 8.18 / 186.0; + let percent = (value - 155.0) * 100.0 / 35.0; + format!("{} ({volts:.1}V, {}%)", value as i64, percent as i64) +} + +/// `PrintConv => 'sprintf("%d (%.1fV, %d%%)",$val,$val*8.18/186,($val-152)*100/34)'` +/// (Pentax.pm:4898 `BodyBatteryADLoad`, K10D/K20D). +/// +/// The same shape as [`ad_no_load`] against a different empty-battery reading, +/// which is why it is a second function and not a parameter: the constants are +/// ExifTool's, and a shared helper would invite editing one of them. +pub(super) fn ad_load(value: f64) -> String { + let volts = value * 8.18 / 186.0; + let percent = (value - 152.0) * 100.0 / 34.0; + format!("{} ({volts:.1}V, {}%)", value as i64, percent as i64) +} + +/// `PrintConv => '"$val ms"'` (Pentax.pm:5064 `AFIntegrationTime`). +/// +/// Perl interpolates a number with its `%.15g` stringification, which is what +/// [`perl_number`] reproduces -- `format!("{value}")` would print `0` as `0` +/// but a computed `2.5` as `2.5` and a large one in a different exponent form. +pub(super) fn millis(value: f64) -> String { + format!("{} ms", perl_number(value)) +} + +/// `PrintConv => '"$val C"'` (Pentax.pm:6147 `CameraTemperature4`, :6154 +/// `CameraTemperature5`). +pub(super) fn celsius(value: f64) -> String { + format!("{} C", perl_number(value)) +} + +/// `PrintConv => 'sprintf("%.1f C", $val)'` (Pentax.pm:6131 +/// `SensorTemperature`, :6140 `SensorTemperature2`, :6164 the K-3 III's +/// `SensorTemperature`). +pub(super) fn celsius_1dp(value: f64) -> String { + format!("{value:.1} C") +} + +/// `PrintConv => '5 - $val'` (Pentax.pm:5188 `AFCSensitivity`). +/// +/// A `PrintConv` that returns a number rather than text; ExifTool prints the +/// result as-is. +pub(super) fn five_minus(value: f64) -> String { + perl_number(5.0 - value) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `exiftool -a -G1 -s combined-samples/Pentax/PentaxK-5IIs.jpg` reports + /// `BodyBatteryVoltage1 : 6.86 V` from a raw 686, and `BodyBatteryVoltage2 + /// : 6.55 V` from 655. + #[test] + fn voltages_print_two_decimals() { + assert_eq!(volts_2dp(6.86), "6.86 V"); + assert_eq!(volts_2dp(6.55), "6.55 V"); + // A whole number still carries both decimals. + assert_eq!(volts_2dp(7.0), "7.00 V"); + } + + /// ExifTool's own calibration point: a raw 155 is the empty reading, so it + /// prints 0%, and 186 is 8.18 V. + #[test] + fn ad_readings_match_exiftools_calibration() { + assert_eq!(ad_no_load(155.0), "155 (6.8V, 0%)"); + assert_eq!(ad_no_load(186.0), "186 (8.2V, 88%)"); + assert_eq!(ad_load(152.0), "152 (6.7V, 0%)"); + } + + /// Below one step the camera reports 0, which ExifTool prints as "0 ms", + /// not "0.0 ms" -- the value is a Perl number, not a formatted one. + #[test] + fn integration_time_is_a_bare_number() { + assert_eq!(millis(0.0), "0 ms"); + assert_eq!(millis(4.0), "4 ms"); + } + + #[test] + fn temperatures_keep_their_declared_precision() { + assert_eq!(celsius(31.0), "31 C"); + assert_eq!(celsius(-3.0), "-3 C"); + assert_eq!(celsius_1dp(31.4), "31.4 C"); + assert_eq!(celsius_1dp(-3.0), "-3.0 C"); + } + + #[test] + fn af_c_sensitivity_counts_down_from_five() { + assert_eq!(five_minus(0.0), "5"); + assert_eq!(five_minus(4.0), "1"); + } +} diff --git a/src/parsers/tiff/makernotes/pentax/subdir_tables.rs b/src/parsers/tiff/makernotes/pentax/subdir_tables.rs index f761057db..1c13ed8a7 100644 --- a/src/parsers/tiff/makernotes/pentax/subdir_tables.rs +++ b/src/parsers/tiff/makernotes/pentax/subdir_tables.rs @@ -1,14 +1,14 @@ //! Pentax MakerNote binary sub-directory tables. //! //! GENERATED by `tools/exiftool-tables/codegen_subdirs.py` from ExifTool -//! 13.55's in-memory tag tables. Do not edit by hand. +//! 13.59's in-memory tag tables. Do not edit by hand. //! //! The generator refuses any construct it has not been taught and names it, so //! a field that is here was reproduced exactly and a field that is missing was //! reported as missing -- neither is a guess. use crate::parsers::tiff::makernotes::shared::binary_subdir::{ - BinaryTable, Field, Fmt, PrintConv, ValueConv, + BinaryTable, Cond, Field, Fmt, ModelPat, PrintConv, ValueConv, }; const PENTAX_CONV1: &[(i64, &str)] = &[ @@ -190,6 +190,75 @@ const PENTAX_CONV16: &[(i64, &str)] = &[ (10, "Composition Adjust + Horizon Correction"), (12, "Horizon Correction"), ]; +const PENTAX_CONV17: &[(i64, &str)] = &[ + (0, "None"), + (1, "Lower-left, Bottom"), + (2, "Bottom"), + (3, "Lower-right, Bottom"), + (4, "Mid-left, Center"), + (5, "Center (horizontal)"), + (6, "Mid-right, Center"), + (7, "Upper-left, Top"), + (8, "Top"), + (9, "Upper-right, Top"), + (10, "Right"), + (11, "Lower-left, Mid-left"), + (12, "Upper-left, Mid-left"), + (13, "Bottom, Center"), + (14, "Top, Center"), + (15, "Lower-right, Mid-right"), + (16, "Upper-right, Mid-right"), + (17, "Left"), + (18, "Mid-left"), + (19, "Center (vertical)"), + (20, "Mid-right"), +]; +const PENTAX_CONV18: &[(i64, &str)] = &[(0, "Off"), (1, "Short"), (2, "Medium"), (3, "Long")]; +const PENTAX_CONV19: &[(i64, &str)] = + &[(0, "Auto"), (1, "Release Priority"), (2, "Focus Priority")]; +const PENTAX_CONV20: &[(i64, &str)] = &[(0, "Auto"), (1, "Focus Priority"), (2, "FPS Priority")]; +const PENTAX_CONV21: &[(i64, &str)] = &[(0, "Low"), (1, "Medium"), (2, "High"), (3, "Off")]; +const PENTAX_CONV22: &[(i64, &str)] = &[(0, "Type 1"), (1, "Type 2"), (2, "Type 3")]; +const PENTAX_CONV23: &[(i64, &str)] = &[ + (1, "Camera Battery"), + (2, "Body Battery"), + (3, "Grip Battery"), + (4, "External Power Supply"), +]; +const PENTAX_CONV24: &[(i64, &str)] = &[ + (1, "Body Battery"), + (2, "Grip Battery"), + (4, "External Power Supply"), +]; +const PENTAX_CONV25: &[(i64, &str)] = &[ + (1, "Empty or Missing"), + (2, "Almost Empty"), + (3, "Running Low"), + (4, "Full"), +]; +const PENTAX_CONV26: &[(i64, &str)] = &[ + (1, "Empty or Missing"), + (2, "Almost Empty"), + (3, "Running Low"), + (4, "Close to Full"), + (5, "Full"), +]; +const PENTAX_CONV27: &[(i64, &str)] = &[ + (0, "Empty or Missing"), + (1, "Almost Empty"), + (2, "Running Low"), + (3, "Half Full"), + (4, "Close to Full"), + (5, "Full"), +]; +const PENTAX_CONV28: &[(i64, &str)] = &[ + (16, "Horizontal (normal)"), + (32, "Rotate 180"), + (48, "Rotate 90 CW"), + (64, "Rotate 270 CW"), + (80, "Upwards"), + (96, "Downwards"), +]; /// `Image::ExifTool::Pentax::SRInfo2` -- 1 fields, FORMAT `int8u`. /// @@ -200,7 +269,9 @@ pub(crate) static PENTAX_SRINFO2: BinaryTable = BinaryTable { default_format: Fmt::U8, first_entry: 0, fields: &[Field { + key: "1", index: 1, + cond: Cond::Always, name: "ShakeReduction", format: None, count: 1, @@ -222,7 +293,9 @@ pub(crate) static PENTAX_FACEINFO: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "FacesDetected", format: None, count: 1, @@ -233,7 +306,9 @@ pub(crate) static PENTAX_FACEINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "2", index: 2, + cond: Cond::Always, name: "FacePosition", format: Some(Fmt::U8), count: 2, @@ -256,7 +331,9 @@ pub(crate) static PENTAX_AWBINFO: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "WhiteBalanceAutoAdjustment", format: None, count: 1, @@ -267,7 +344,9 @@ pub(crate) static PENTAX_AWBINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV2), }, Field { + key: "1", index: 1, + cond: Cond::Always, name: "TungstenAWB", format: None, count: 1, @@ -290,7 +369,9 @@ pub(crate) static PENTAX_TIMEINFO: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0.1", index: 0, + cond: Cond::Always, name: "WorldTimeLocation", format: None, count: 1, @@ -301,7 +382,9 @@ pub(crate) static PENTAX_TIMEINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV4), }, Field { + key: "0.2", index: 0, + cond: Cond::Always, name: "HometownDST", format: None, count: 1, @@ -312,7 +395,9 @@ pub(crate) static PENTAX_TIMEINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV5), }, Field { + key: "0.3", index: 0, + cond: Cond::Always, name: "DestinationDST", format: None, count: 1, @@ -323,7 +408,9 @@ pub(crate) static PENTAX_TIMEINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV5), }, Field { + key: "2", index: 2, + cond: Cond::Always, name: "HometownCity", format: None, count: 1, @@ -334,7 +421,9 @@ pub(crate) static PENTAX_TIMEINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV6), }, Field { + key: "3", index: 3, + cond: Cond::Always, name: "DestinationCity", format: None, count: 1, @@ -357,7 +446,9 @@ pub(crate) static PENTAX_LENSCORR: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "DistortionCorrection", format: None, count: 1, @@ -368,7 +459,9 @@ pub(crate) static PENTAX_LENSCORR: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV2), }, Field { + key: "1", index: 1, + cond: Cond::Always, name: "ChromaticAberrationCorrection", format: None, count: 1, @@ -379,7 +472,9 @@ pub(crate) static PENTAX_LENSCORR: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV2), }, Field { + key: "2", index: 2, + cond: Cond::Always, name: "PeripheralIlluminationCorr", format: None, count: 1, @@ -390,7 +485,9 @@ pub(crate) static PENTAX_LENSCORR: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV2), }, Field { + key: "3", index: 3, + cond: Cond::Always, name: "DiffractionCorrection", format: None, count: 1, @@ -413,7 +510,9 @@ pub(crate) static PENTAX_FLASHINFO: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "FlashStatus", format: None, count: 1, @@ -424,7 +523,9 @@ pub(crate) static PENTAX_FLASHINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV8), }, Field { + key: "1", index: 1, + cond: Cond::Always, name: "InternalFlashMode", format: None, count: 1, @@ -435,7 +536,9 @@ pub(crate) static PENTAX_FLASHINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV9), }, Field { + key: "2", index: 2, + cond: Cond::Always, name: "ExternalFlashMode", format: None, count: 1, @@ -446,7 +549,9 @@ pub(crate) static PENTAX_FLASHINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV10), }, Field { + key: "3", index: 3, + cond: Cond::Always, name: "InternalFlashStrength", format: None, count: 1, @@ -457,7 +562,9 @@ pub(crate) static PENTAX_FLASHINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "4", index: 4, + cond: Cond::Always, name: "TTL_DA_AUp", format: None, count: 1, @@ -468,7 +575,9 @@ pub(crate) static PENTAX_FLASHINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "5", index: 5, + cond: Cond::Always, name: "TTL_DA_ADown", format: None, count: 1, @@ -479,7 +588,9 @@ pub(crate) static PENTAX_FLASHINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "6", index: 6, + cond: Cond::Always, name: "TTL_DA_BUp", format: None, count: 1, @@ -490,7 +601,9 @@ pub(crate) static PENTAX_FLASHINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "7", index: 7, + cond: Cond::Always, name: "TTL_DA_BDown", format: None, count: 1, @@ -501,7 +614,9 @@ pub(crate) static PENTAX_FLASHINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "25", index: 25, + cond: Cond::Always, name: "ExternalFlashExposureComp", format: None, count: 1, @@ -512,7 +627,9 @@ pub(crate) static PENTAX_FLASHINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV11), }, Field { + key: "26", index: 26, + cond: Cond::Always, name: "ExternalFlashBounce", format: None, count: 1, @@ -535,7 +652,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "1", index: 1, + cond: Cond::Always, name: "KelvinWB_Daylight", format: Some(Fmt::U16), count: 4, @@ -546,7 +665,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "5", index: 5, + cond: Cond::Always, name: "KelvinWB_01", format: Some(Fmt::U16), count: 4, @@ -557,7 +678,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "9", index: 9, + cond: Cond::Always, name: "KelvinWB_02", format: Some(Fmt::U16), count: 4, @@ -568,7 +691,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "13", index: 13, + cond: Cond::Always, name: "KelvinWB_03", format: Some(Fmt::U16), count: 4, @@ -579,7 +704,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "17", index: 17, + cond: Cond::Always, name: "KelvinWB_04", format: Some(Fmt::U16), count: 4, @@ -590,7 +717,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "21", index: 21, + cond: Cond::Always, name: "KelvinWB_05", format: Some(Fmt::U16), count: 4, @@ -601,7 +730,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "25", index: 25, + cond: Cond::Always, name: "KelvinWB_06", format: Some(Fmt::U16), count: 4, @@ -612,7 +743,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "29", index: 29, + cond: Cond::Always, name: "KelvinWB_07", format: Some(Fmt::U16), count: 4, @@ -623,7 +756,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "33", index: 33, + cond: Cond::Always, name: "KelvinWB_08", format: Some(Fmt::U16), count: 4, @@ -634,7 +769,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "37", index: 37, + cond: Cond::Always, name: "KelvinWB_09", format: Some(Fmt::U16), count: 4, @@ -645,7 +782,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "41", index: 41, + cond: Cond::Always, name: "KelvinWB_10", format: Some(Fmt::U16), count: 4, @@ -656,7 +795,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "45", index: 45, + cond: Cond::Always, name: "KelvinWB_11", format: Some(Fmt::U16), count: 4, @@ -667,7 +808,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "49", index: 49, + cond: Cond::Always, name: "KelvinWB_12", format: Some(Fmt::U16), count: 4, @@ -678,7 +821,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "53", index: 53, + cond: Cond::Always, name: "KelvinWB_13", format: Some(Fmt::U16), count: 4, @@ -689,7 +834,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "57", index: 57, + cond: Cond::Always, name: "KelvinWB_14", format: Some(Fmt::U16), count: 4, @@ -700,7 +847,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "61", index: 61, + cond: Cond::Always, name: "KelvinWB_15", format: Some(Fmt::U16), count: 4, @@ -711,7 +860,9 @@ pub(crate) static PENTAX_KELVINWB: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "65", index: 65, + cond: Cond::Always, name: "KelvinWB_16", format: Some(Fmt::U16), count: 4, @@ -734,7 +885,9 @@ pub(crate) static PENTAX_EVSTEPINFO: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "EVSteps", format: None, count: 1, @@ -745,7 +898,9 @@ pub(crate) static PENTAX_EVSTEPINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV13), }, Field { + key: "1", index: 1, + cond: Cond::Always, name: "SensitivitySteps", format: None, count: 1, @@ -756,7 +911,9 @@ pub(crate) static PENTAX_EVSTEPINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV14), }, Field { + key: "3", index: 3, + cond: Cond::Always, name: "LiveView", format: None, count: 1, @@ -779,7 +936,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "Face1Position", format: Some(Fmt::U16), count: 2, @@ -790,7 +949,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "2", index: 2, + cond: Cond::Always, name: "Face2Position", format: Some(Fmt::U16), count: 2, @@ -801,7 +962,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "4", index: 4, + cond: Cond::Always, name: "Face3Position", format: Some(Fmt::U16), count: 2, @@ -812,7 +975,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "6", index: 6, + cond: Cond::Always, name: "Face4Position", format: Some(Fmt::U16), count: 2, @@ -823,7 +988,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "8", index: 8, + cond: Cond::Always, name: "Face5Position", format: Some(Fmt::U16), count: 2, @@ -834,7 +1001,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "10", index: 10, + cond: Cond::Always, name: "Face6Position", format: Some(Fmt::U16), count: 2, @@ -845,7 +1014,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "12", index: 12, + cond: Cond::Always, name: "Face7Position", format: Some(Fmt::U16), count: 2, @@ -856,7 +1027,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "14", index: 14, + cond: Cond::Always, name: "Face8Position", format: Some(Fmt::U16), count: 2, @@ -867,7 +1040,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "16", index: 16, + cond: Cond::Always, name: "Face9Position", format: Some(Fmt::U16), count: 2, @@ -878,7 +1053,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "18", index: 18, + cond: Cond::Always, name: "Face10Position", format: Some(Fmt::U16), count: 2, @@ -889,7 +1066,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "20", index: 20, + cond: Cond::Always, name: "Face11Position", format: Some(Fmt::U16), count: 2, @@ -900,7 +1079,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "22", index: 22, + cond: Cond::Always, name: "Face12Position", format: Some(Fmt::U16), count: 2, @@ -911,7 +1092,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "24", index: 24, + cond: Cond::Always, name: "Face13Position", format: Some(Fmt::U16), count: 2, @@ -922,7 +1105,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "26", index: 26, + cond: Cond::Always, name: "Face14Position", format: Some(Fmt::U16), count: 2, @@ -933,7 +1118,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "28", index: 28, + cond: Cond::Always, name: "Face15Position", format: Some(Fmt::U16), count: 2, @@ -944,7 +1131,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "30", index: 30, + cond: Cond::Always, name: "Face16Position", format: Some(Fmt::U16), count: 2, @@ -955,7 +1144,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "32", index: 32, + cond: Cond::Always, name: "Face17Position", format: Some(Fmt::U16), count: 2, @@ -966,7 +1157,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "34", index: 34, + cond: Cond::Always, name: "Face18Position", format: Some(Fmt::U16), count: 2, @@ -977,7 +1170,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "36", index: 36, + cond: Cond::Always, name: "Face19Position", format: Some(Fmt::U16), count: 2, @@ -988,7 +1183,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "38", index: 38, + cond: Cond::Always, name: "Face20Position", format: Some(Fmt::U16), count: 2, @@ -999,7 +1196,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "40", index: 40, + cond: Cond::Always, name: "Face21Position", format: Some(Fmt::U16), count: 2, @@ -1010,7 +1209,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "42", index: 42, + cond: Cond::Always, name: "Face22Position", format: Some(Fmt::U16), count: 2, @@ -1021,7 +1222,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "44", index: 44, + cond: Cond::Always, name: "Face23Position", format: Some(Fmt::U16), count: 2, @@ -1032,7 +1235,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "46", index: 46, + cond: Cond::Always, name: "Face24Position", format: Some(Fmt::U16), count: 2, @@ -1043,7 +1248,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "48", index: 48, + cond: Cond::Always, name: "Face25Position", format: Some(Fmt::U16), count: 2, @@ -1054,7 +1261,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "50", index: 50, + cond: Cond::Always, name: "Face26Position", format: Some(Fmt::U16), count: 2, @@ -1065,7 +1274,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "52", index: 52, + cond: Cond::Always, name: "Face27Position", format: Some(Fmt::U16), count: 2, @@ -1076,7 +1287,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "54", index: 54, + cond: Cond::Always, name: "Face28Position", format: Some(Fmt::U16), count: 2, @@ -1087,7 +1300,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "56", index: 56, + cond: Cond::Always, name: "Face29Position", format: Some(Fmt::U16), count: 2, @@ -1098,7 +1313,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "58", index: 58, + cond: Cond::Always, name: "Face30Position", format: Some(Fmt::U16), count: 2, @@ -1109,7 +1326,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "60", index: 60, + cond: Cond::Always, name: "Face31Position", format: Some(Fmt::U16), count: 2, @@ -1120,7 +1339,9 @@ pub(crate) static PENTAX_FACEPOS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "62", index: 62, + cond: Cond::Always, name: "Face32Position", format: Some(Fmt::U16), count: 2, @@ -1143,7 +1364,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "Face1Size", format: Some(Fmt::U16), count: 2, @@ -1154,7 +1377,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "2", index: 2, + cond: Cond::Always, name: "Face2Size", format: Some(Fmt::U16), count: 2, @@ -1165,7 +1390,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "4", index: 4, + cond: Cond::Always, name: "Face3Size", format: Some(Fmt::U16), count: 2, @@ -1176,7 +1403,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "6", index: 6, + cond: Cond::Always, name: "Face4Size", format: Some(Fmt::U16), count: 2, @@ -1187,7 +1416,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "8", index: 8, + cond: Cond::Always, name: "Face5Size", format: Some(Fmt::U16), count: 2, @@ -1198,7 +1429,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "10", index: 10, + cond: Cond::Always, name: "Face6Size", format: Some(Fmt::U16), count: 2, @@ -1209,7 +1442,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "12", index: 12, + cond: Cond::Always, name: "Face7Size", format: Some(Fmt::U16), count: 2, @@ -1220,7 +1455,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "14", index: 14, + cond: Cond::Always, name: "Face8Size", format: Some(Fmt::U16), count: 2, @@ -1231,7 +1468,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "16", index: 16, + cond: Cond::Always, name: "Face9Size", format: Some(Fmt::U16), count: 2, @@ -1242,7 +1481,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "18", index: 18, + cond: Cond::Always, name: "Face10Size", format: Some(Fmt::U16), count: 2, @@ -1253,7 +1494,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "20", index: 20, + cond: Cond::Always, name: "Face11Size", format: Some(Fmt::U16), count: 2, @@ -1264,7 +1507,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "22", index: 22, + cond: Cond::Always, name: "Face12Size", format: Some(Fmt::U16), count: 2, @@ -1275,7 +1520,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "24", index: 24, + cond: Cond::Always, name: "Face13Size", format: Some(Fmt::U16), count: 2, @@ -1286,7 +1533,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "26", index: 26, + cond: Cond::Always, name: "Face14Size", format: Some(Fmt::U16), count: 2, @@ -1297,7 +1546,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "28", index: 28, + cond: Cond::Always, name: "Face15Size", format: Some(Fmt::U16), count: 2, @@ -1308,7 +1559,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "30", index: 30, + cond: Cond::Always, name: "Face16Size", format: Some(Fmt::U16), count: 2, @@ -1319,7 +1572,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "32", index: 32, + cond: Cond::Always, name: "Face17Size", format: Some(Fmt::U16), count: 2, @@ -1330,7 +1585,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "34", index: 34, + cond: Cond::Always, name: "Face18Size", format: Some(Fmt::U16), count: 2, @@ -1341,7 +1598,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "36", index: 36, + cond: Cond::Always, name: "Face19Size", format: Some(Fmt::U16), count: 2, @@ -1352,7 +1611,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "38", index: 38, + cond: Cond::Always, name: "Face20Size", format: Some(Fmt::U16), count: 2, @@ -1363,7 +1624,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "40", index: 40, + cond: Cond::Always, name: "Face21Size", format: Some(Fmt::U16), count: 2, @@ -1374,7 +1637,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "42", index: 42, + cond: Cond::Always, name: "Face22Size", format: Some(Fmt::U16), count: 2, @@ -1385,7 +1650,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "44", index: 44, + cond: Cond::Always, name: "Face23Size", format: Some(Fmt::U16), count: 2, @@ -1396,7 +1663,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "46", index: 46, + cond: Cond::Always, name: "Face24Size", format: Some(Fmt::U16), count: 2, @@ -1407,7 +1676,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "48", index: 48, + cond: Cond::Always, name: "Face25Size", format: Some(Fmt::U16), count: 2, @@ -1418,7 +1689,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "50", index: 50, + cond: Cond::Always, name: "Face26Size", format: Some(Fmt::U16), count: 2, @@ -1429,7 +1702,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "52", index: 52, + cond: Cond::Always, name: "Face27Size", format: Some(Fmt::U16), count: 2, @@ -1440,7 +1715,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "54", index: 54, + cond: Cond::Always, name: "Face28Size", format: Some(Fmt::U16), count: 2, @@ -1451,7 +1728,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "56", index: 56, + cond: Cond::Always, name: "Face29Size", format: Some(Fmt::U16), count: 2, @@ -1462,7 +1741,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "58", index: 58, + cond: Cond::Always, name: "Face30Size", format: Some(Fmt::U16), count: 2, @@ -1473,7 +1754,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "60", index: 60, + cond: Cond::Always, name: "Face31Size", format: Some(Fmt::U16), count: 2, @@ -1484,7 +1767,9 @@ pub(crate) static PENTAX_FACESIZE: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "62", index: 62, + cond: Cond::Always, name: "Face32Size", format: Some(Fmt::U16), count: 2, @@ -1507,7 +1792,9 @@ pub(crate) static PENTAX_LEVELINFO: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "LevelOrientation", format: None, count: 1, @@ -1518,7 +1805,9 @@ pub(crate) static PENTAX_LEVELINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV15), }, Field { + key: "0.1", index: 0, + cond: Cond::Always, name: "CompositionAdjust", format: None, count: 1, @@ -1529,7 +1818,9 @@ pub(crate) static PENTAX_LEVELINFO: BinaryTable = BinaryTable { print_conv: PrintConv::Map(PENTAX_CONV16), }, Field { + key: "1", index: 1, + cond: Cond::Always, name: "RollAngle", format: None, count: 1, @@ -1540,7 +1831,9 @@ pub(crate) static PENTAX_LEVELINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "2", index: 2, + cond: Cond::Always, name: "PitchAngle", format: None, count: 1, @@ -1551,7 +1844,9 @@ pub(crate) static PENTAX_LEVELINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "5", index: 5, + cond: Cond::Always, name: "CompositionAdjustX", format: None, count: 1, @@ -1562,7 +1857,9 @@ pub(crate) static PENTAX_LEVELINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "6", index: 6, + cond: Cond::Always, name: "CompositionAdjustY", format: None, count: 1, @@ -1573,7 +1870,9 @@ pub(crate) static PENTAX_LEVELINFO: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "7", index: 7, + cond: Cond::Always, name: "CompositionAdjustRotation", format: None, count: 1, @@ -1596,7 +1895,9 @@ pub(crate) static PENTAX_WBLEVELS: BinaryTable = BinaryTable { first_entry: 0, fields: &[ Field { + key: "2", index: 2, + cond: Cond::Always, name: "WB_RGGBLevelsDaylight", format: Some(Fmt::U16), count: 4, @@ -1607,7 +1908,9 @@ pub(crate) static PENTAX_WBLEVELS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "11", index: 11, + cond: Cond::Always, name: "WB_RGGBLevelsShade", format: Some(Fmt::U16), count: 4, @@ -1618,7 +1921,9 @@ pub(crate) static PENTAX_WBLEVELS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "20", index: 20, + cond: Cond::Always, name: "WB_RGGBLevelsCloudy", format: Some(Fmt::U16), count: 4, @@ -1629,7 +1934,9 @@ pub(crate) static PENTAX_WBLEVELS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "29", index: 29, + cond: Cond::Always, name: "WB_RGGBLevelsTungsten", format: Some(Fmt::U16), count: 4, @@ -1640,7 +1947,9 @@ pub(crate) static PENTAX_WBLEVELS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "38", index: 38, + cond: Cond::Always, name: "WB_RGGBLevelsFluorescentD", format: Some(Fmt::U16), count: 4, @@ -1651,7 +1960,9 @@ pub(crate) static PENTAX_WBLEVELS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "47", index: 47, + cond: Cond::Always, name: "WB_RGGBLevelsFluorescentN", format: Some(Fmt::U16), count: 4, @@ -1662,7 +1973,9 @@ pub(crate) static PENTAX_WBLEVELS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "56", index: 56, + cond: Cond::Always, name: "WB_RGGBLevelsFluorescentW", format: Some(Fmt::U16), count: 4, @@ -1673,7 +1986,9 @@ pub(crate) static PENTAX_WBLEVELS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "65", index: 65, + cond: Cond::Always, name: "WB_RGGBLevelsFlash", format: Some(Fmt::U16), count: 4, @@ -1684,7 +1999,9 @@ pub(crate) static PENTAX_WBLEVELS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "74", index: 74, + cond: Cond::Always, name: "WB_RGGBLevelsFluorescentL", format: Some(Fmt::U16), count: 4, @@ -1695,7 +2012,9 @@ pub(crate) static PENTAX_WBLEVELS: BinaryTable = BinaryTable { print_conv: PrintConv::None, }, Field { + key: "92", index: 92, + cond: Cond::Always, name: "WB_RGGBLevelsUserSelected", format: Some(Fmt::U16), count: 4, @@ -1717,7 +2036,9 @@ pub(crate) static PENTAX_LENSINFOQ: BinaryTable = BinaryTable { default_format: Fmt::U8, first_entry: 0, fields: &[Field { + key: "12", index: 12, + cond: Cond::Always, name: "LensModel", format: Some(Fmt::Str(30)), count: 1, @@ -1728,3 +2049,1173 @@ pub(crate) static PENTAX_LENSINFOQ: BinaryTable = BinaryTable { print_conv: PrintConv::None, }], }; + +/// `Image::ExifTool::Pentax::AFInfo` -- 12 fields, FORMAT `int8u`. +/// +/// Transcribed from ExifTool's in-memory tag table by +/// `tools/exiftool-tables/codegen_subdirs.py`. Do not edit by hand. +pub(crate) static PENTAX_AFINFO: BinaryTable = BinaryTable { + name: "AFInfo", + default_format: Fmt::U8, + first_entry: 0, + fields: &[ + Field { + key: "4", + index: 4, + cond: Cond::Always, + name: "AFPredictor", + format: Some(Fmt::I16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + Field { + key: "6", + index: 6, + cond: Cond::Always, + name: "AFDefocus", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + Field { + key: "7", + index: 7, + cond: Cond::Always, + name: "AFIntegrationTime", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::Each(super::value_conv::times_2), + print_conv: PrintConv::Expr(super::print_conv::millis), + }, + Field { + key: "11", + index: 11, + cond: Cond::Model { + any_of: &[], + none_of: &[ + ModelPat { + text: "K-1", + word_end: true, + }, + ModelPat { + text: "K-3", + word_end: true, + }, + ModelPat { + text: "K-70", + word_end: true, + }, + ModelPat { + text: "K-S1", + word_end: true, + }, + ModelPat { + text: "K-S2", + word_end: true, + }, + ModelPat { + text: "KP", + word_end: true, + }, + ], + }, + name: "AFPointsInFocus", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV17), + }, + Field { + key: "506", + index: 506, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "LiveView", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV2), + }, + Field { + key: "509", + index: 509, + cond: Cond::ModelEq("PENTAX K-3 II"), + name: "AFHold", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV18), + }, + Field { + key: "543", + index: 543, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "FirstFrameActionInAFC", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV19), + }, + Field { + key: "544", + index: 544, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "ActionInAFCCont", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV20), + }, + Field { + key: "545", + index: 545, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "AFCHold", + format: None, + count: 1, + set_member: None, + gate: None, + mask: Some(3), + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV21), + }, + Field { + key: "545.1", + index: 545, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "AFCPointTracking", + format: None, + count: 1, + set_member: None, + gate: None, + mask: Some(12), + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV22), + }, + Field { + key: "545.2", + index: 545, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "AFCSensitivity", + format: None, + count: 1, + set_member: None, + gate: None, + mask: Some(112), + value_conv: ValueConv::None, + print_conv: PrintConv::Expr(super::print_conv::five_minus), + }, + Field { + key: "2400", + index: 2400, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "SubjectRecognition", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV2), + }, + ], +}; + +/// `Image::ExifTool::Pentax::BatteryInfo` -- 21 fields, FORMAT `int8u`. +/// +/// Transcribed from ExifTool's in-memory tag table by +/// `tools/exiftool-tables/codegen_subdirs.py`. Do not edit by hand. +pub(crate) static PENTAX_BATTERYINFO: BinaryTable = BinaryTable { + name: "BatteryInfo", + default_format: Fmt::U8, + first_entry: 0, + fields: &[ + Field { + key: "0.1", + index: 0, + cond: Cond::Model { + any_of: &[], + none_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + }, + name: "PowerSource", + format: None, + count: 1, + set_member: None, + gate: None, + mask: Some(15), + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV23), + }, + Field { + key: "0.1", + index: 0, + cond: Cond::Always, + name: "PowerSource", + format: None, + count: 1, + set_member: None, + gate: None, + mask: Some(15), + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV24), + }, + Field { + key: "1.1", + index: 1, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "*ist", + word_end: true, + }, + ModelPat { + text: "K100D", + word_end: true, + }, + ModelPat { + text: "K200D", + word_end: true, + }, + ModelPat { + text: "K10D", + word_end: true, + }, + ModelPat { + text: "GX10", + word_end: true, + }, + ModelPat { + text: "K20D", + word_end: true, + }, + ModelPat { + text: "GX20", + word_end: true, + }, + ModelPat { + text: "GX-1L", + word_end: true, + }, + ModelPat { + text: "GX-1S", + word_end: true, + }, + ModelPat { + text: "GX-1", + word_end: true, + }, + ], + none_of: &[], + }, + name: "BodyBatteryState", + format: None, + count: 1, + set_member: None, + gate: None, + mask: Some(240), + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV25), + }, + Field { + key: "1.1", + index: 1, + cond: Cond::Model { + any_of: &[], + none_of: &[ + ModelPat { + text: "K110D", + word_end: true, + }, + ModelPat { + text: "K2000", + word_end: true, + }, + ModelPat { + text: "K-m", + word_end: true, + }, + ModelPat { + text: "K-3 Mark III", + word_end: true, + }, + ], + }, + name: "BodyBatteryState", + format: None, + count: 1, + set_member: None, + gate: None, + mask: Some(240), + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV26), + }, + Field { + key: "1.2", + index: 1, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "K10D", + word_end: true, + }, + ModelPat { + text: "GX10", + word_end: true, + }, + ModelPat { + text: "K20D", + word_end: true, + }, + ModelPat { + text: "GX20", + word_end: true, + }, + ], + none_of: &[], + }, + name: "GripBatteryState", + format: None, + count: 1, + set_member: None, + gate: None, + mask: Some(15), + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV25), + }, + Field { + key: "2", + index: 2, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "K10D", + word_end: true, + }, + ModelPat { + text: "GX10", + word_end: true, + }, + ModelPat { + text: "K20D", + word_end: true, + }, + ModelPat { + text: "GX20", + word_end: true, + }, + ], + none_of: &[], + }, + name: "BodyBatteryADNoLoad", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Expr(super::print_conv::ad_no_load), + }, + Field { + key: "2", + index: 2, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "*ist", + word_end: true, + }, + ModelPat { + text: "K100D", + word_end: true, + }, + ModelPat { + text: "K200D", + word_end: true, + }, + ModelPat { + text: "GX-1L", + word_end: true, + }, + ModelPat { + text: "GX-1S", + word_end: true, + }, + ModelPat { + text: "GX-1", + word_end: true, + }, + ], + none_of: &[], + }, + name: "BodyBatteryADNoLoad", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + Field { + key: "2", + index: 2, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "645D", + word_end: true, + }, + ModelPat { + text: "645Z", + word_end: true, + }, + ModelPat { + text: "K-1", + word_end: true, + }, + ModelPat { + text: "K-01", + word_end: true, + }, + ModelPat { + text: "K-3", + word_end: true, + }, + ModelPat { + text: "K-5", + word_end: true, + }, + ModelPat { + text: "K-7", + word_end: true, + }, + ModelPat { + text: "K-30", + word_end: true, + }, + ModelPat { + text: "K-50", + word_end: true, + }, + ModelPat { + text: "K-70", + word_end: true, + }, + ModelPat { + text: "K-500", + word_end: true, + }, + ModelPat { + text: "K-r", + word_end: true, + }, + ModelPat { + text: "K-x", + word_end: true, + }, + ModelPat { + text: "K-S1", + word_end: true, + }, + ModelPat { + text: "K-S2", + word_end: true, + }, + ModelPat { + text: "KP", + word_end: true, + }, + ], + none_of: &[ModelPat { + text: "III", + word_end: false, + }], + }, + name: "BodyBatteryVoltage1", + format: Some(Fmt::U16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::Each(super::value_conv::div_100), + print_conv: PrintConv::Expr(super::print_conv::volts_2dp), + }, + Field { + key: "2", + index: 2, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "BodyBatteryState", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV27), + }, + Field { + key: "3", + index: 3, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "K10D", + word_end: true, + }, + ModelPat { + text: "GX10", + word_end: true, + }, + ModelPat { + text: "K20D", + word_end: true, + }, + ModelPat { + text: "GX20", + word_end: true, + }, + ], + none_of: &[], + }, + name: "BodyBatteryADLoad", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Expr(super::print_conv::ad_load), + }, + Field { + key: "3", + index: 3, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "*ist", + word_end: true, + }, + ModelPat { + text: "K100D", + word_end: true, + }, + ModelPat { + text: "K200D", + word_end: true, + }, + ], + none_of: &[], + }, + name: "BodyBatteryADLoad", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + Field { + key: "3", + index: 3, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "BodyBatteryPercent", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + Field { + key: "4", + index: 4, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "*ist", + word_end: true, + }, + ModelPat { + text: "K10D", + word_end: true, + }, + ModelPat { + text: "GX10", + word_end: true, + }, + ModelPat { + text: "K20D", + word_end: true, + }, + ModelPat { + text: "GX20", + word_end: true, + }, + ModelPat { + text: "GX-1L", + word_end: true, + }, + ModelPat { + text: "GX-1S", + word_end: true, + }, + ModelPat { + text: "GX-1", + word_end: true, + }, + ], + none_of: &[], + }, + name: "GripBatteryADNoLoad", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + Field { + key: "4", + index: 4, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "645D", + word_end: true, + }, + ModelPat { + text: "645Z", + word_end: true, + }, + ModelPat { + text: "K-1", + word_end: true, + }, + ModelPat { + text: "K-01", + word_end: true, + }, + ModelPat { + text: "K-3", + word_end: true, + }, + ModelPat { + text: "K-5", + word_end: true, + }, + ModelPat { + text: "K-7", + word_end: true, + }, + ModelPat { + text: "K-30", + word_end: true, + }, + ModelPat { + text: "K-50", + word_end: true, + }, + ModelPat { + text: "K-70", + word_end: true, + }, + ModelPat { + text: "K-500", + word_end: true, + }, + ModelPat { + text: "K-r", + word_end: true, + }, + ModelPat { + text: "K-x", + word_end: true, + }, + ModelPat { + text: "K-S1", + word_end: true, + }, + ModelPat { + text: "K-S2", + word_end: true, + }, + ModelPat { + text: "KP", + word_end: true, + }, + ], + none_of: &[ModelPat { + text: "III", + word_end: false, + }], + }, + name: "BodyBatteryVoltage2", + format: Some(Fmt::U16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::Each(super::value_conv::div_100), + print_conv: PrintConv::Expr(super::print_conv::volts_2dp), + }, + Field { + key: "4", + index: 4, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "BodyBatteryVoltage", + format: Some(Fmt::U32), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::Each(super::value_conv::k3_iii_voltage), + print_conv: PrintConv::Expr(super::print_conv::volts_2dp), + }, + Field { + key: "5", + index: 5, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "*ist", + word_end: true, + }, + ModelPat { + text: "K10D", + word_end: true, + }, + ModelPat { + text: "GX10", + word_end: true, + }, + ModelPat { + text: "K20D", + word_end: true, + }, + ModelPat { + text: "GX20", + word_end: true, + }, + ], + none_of: &[], + }, + name: "GripBatteryADLoad", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + Field { + key: "6", + index: 6, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "K-5", + word_end: true, + }, + ModelPat { + text: "K-r", + word_end: true, + }, + ModelPat { + text: "645D", + word_end: true, + }, + ], + none_of: &[], + }, + name: "BodyBatteryVoltage3", + format: Some(Fmt::U16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::Each(super::value_conv::div_100), + print_conv: PrintConv::Expr(super::print_conv::volts_2dp), + }, + Field { + key: "8", + index: 8, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "K-5", + word_end: true, + }, + ModelPat { + text: "K-r", + word_end: true, + }, + ], + none_of: &[], + }, + name: "BodyBatteryVoltage4", + format: Some(Fmt::U16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::Each(super::value_conv::div_100), + print_conv: PrintConv::Expr(super::print_conv::volts_2dp), + }, + Field { + key: "16", + index: 16, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "GripBatteryState", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV27), + }, + Field { + key: "17", + index: 17, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "GripBatteryPercent", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + Field { + key: "18", + index: 18, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "GripBatteryVoltage", + format: Some(Fmt::U32), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::Each(super::value_conv::k3_iii_voltage), + print_conv: PrintConv::Expr(super::print_conv::volts_2dp), + }, + ], +}; + +/// `Image::ExifTool::Pentax::TempInfo` -- 6 fields, FORMAT `int8u`. +/// +/// Transcribed from ExifTool's in-memory tag table by +/// `tools/exiftool-tables/codegen_subdirs.py`. Do not edit by hand. +pub(crate) static PENTAX_TEMPINFO: BinaryTable = BinaryTable { + name: "TempInfo", + default_format: Fmt::U8, + first_entry: 0, + fields: &[ + Field { + key: "10", + index: 10, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "ShotNumber", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::Each(super::value_conv::plus_1), + print_conv: PrintConv::None, + }, + Field { + key: "12", + index: 12, + cond: Cond::Model { + any_of: &[], + none_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + }, + name: "SensorTemperature", + format: Some(Fmt::I16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::Each(super::value_conv::div_10), + print_conv: PrintConv::Expr(super::print_conv::celsius_1dp), + }, + Field { + key: "14", + index: 14, + cond: Cond::Model { + any_of: &[], + none_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + }, + name: "SensorTemperature2", + format: Some(Fmt::I16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::Each(super::value_conv::div_10), + print_conv: PrintConv::Expr(super::print_conv::celsius_1dp), + }, + Field { + key: "20", + index: 20, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-5", + word_end: true, + }], + none_of: &[], + }, + name: "CameraTemperature4", + format: Some(Fmt::I16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Expr(super::print_conv::celsius), + }, + Field { + key: "22", + index: 22, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-5", + word_end: true, + }], + none_of: &[], + }, + name: "CameraTemperature5", + format: Some(Fmt::I16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Expr(super::print_conv::celsius), + }, + Field { + key: "42", + index: 42, + cond: Cond::Model { + any_of: &[ModelPat { + text: "K-3 Mark III", + word_end: false, + }], + none_of: &[], + }, + name: "SensorTemperature", + format: Some(Fmt::I16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::Each(super::value_conv::div_10), + print_conv: PrintConv::Expr(super::print_conv::celsius_1dp), + }, + ], +}; + +/// `Image::ExifTool::Pentax::ShotInfo` -- 1 fields, FORMAT `int8u`. +/// +/// Transcribed from ExifTool's in-memory tag table by +/// `tools/exiftool-tables/codegen_subdirs.py`. Do not edit by hand. +pub(crate) static PENTAX_SHOTINFO: BinaryTable = BinaryTable { + name: "ShotInfo", + default_format: Fmt::U8, + first_entry: 0, + fields: &[Field { + key: "1", + index: 1, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "K-5", + word_end: true, + }, + ModelPat { + text: "K-7", + word_end: true, + }, + ModelPat { + text: "K-r", + word_end: true, + }, + ModelPat { + text: "K-x", + word_end: true, + }, + ], + none_of: &[], + }, + name: "CameraOrientation", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::Map(PENTAX_CONV28), + }], +}; + +/// `Image::ExifTool::Pentax::FilterInfo` -- 2 fields, FORMAT `int8u`. +/// +/// Transcribed from ExifTool's in-memory tag table by +/// `tools/exiftool-tables/codegen_subdirs.py`. Do not edit by hand. +pub(crate) static PENTAX_FILTERINFO: BinaryTable = BinaryTable { + name: "FilterInfo", + default_format: Fmt::U8, + first_entry: 0, + fields: &[ + Field { + key: "0", + index: 0, + cond: Cond::Always, + name: "SourceDirectoryIndex", + format: Some(Fmt::U16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + Field { + key: "2", + index: 2, + cond: Cond::Always, + name: "SourceFileIndex", + format: Some(Fmt::U16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + ], +}; diff --git a/src/parsers/tiff/makernotes/pentax/value_conv.rs b/src/parsers/tiff/makernotes/pentax/value_conv.rs index 1bfe5bd90..fb62d8bea 100644 --- a/src/parsers/tiff/makernotes/pentax/value_conv.rs +++ b/src/parsers/tiff/makernotes/pentax/value_conv.rs @@ -25,6 +25,44 @@ pub(super) fn negate_half(value: f64) -> f64 { -value / 2.0 } +/// `ValueConv => '$val / 100'` (Pentax.pm:4866 `BodyBatteryVoltage1`, :4921 +/// `BodyBatteryVoltage2`, :4946 `BodyBatteryVoltage3`, :4956 +/// `BodyBatteryVoltage4`). +/// +/// The record holds centivolts, so a raw 686 is 6.86 V. +pub(super) fn div_100(value: f64) -> f64 { + value / 100.0 +} + +/// `ValueConv => '$val * 4e-8 + 0.27219'` (Pentax.pm:4930 +/// `BodyBatteryVoltage`, :4984 `GripBatteryVoltage`). +/// +/// The K-3 Mark III reports a raw 32-bit ADC count rather than centivolts. +pub(super) fn k3_iii_voltage(value: f64) -> f64 { + value * 4e-8 + 0.27219 +} + +/// `ValueConv => '$val / 10'` (Pentax.pm:6129 `SensorTemperature`, :6138 +/// `SensorTemperature2`, :6162 the K-3 III's `SensorTemperature`). +pub(super) fn div_10(value: f64) -> f64 { + value / 10.0 +} + +/// `ValueConv => '$val * 2'` (Pentax.pm:5062 `AFIntegrationTime`). +/// +/// "effective exposure time for AF sensors in 2 ms increments" +/// (Pentax.pm:5059), which is why a sub-2 ms exposure reports 0. +pub(super) fn times_2(value: f64) -> f64 { + value * 2.0 +} + +/// `ValueConv => '$val+1'` (Pentax.pm:6118 `ShotNumber`). +/// +/// "Internal representation starts at 0 for the 1st shot" (Pentax.pm:6117). +pub(super) fn plus_1(value: f64) -> f64 { + value + 1.0 +} + /// `%kelvinWB`'s `ValueConv` (Pentax.pm:837-840), shared by all 17 /// `KelvinWB_*` tags: /// diff --git a/src/parsers/tiff/makernotes/shared/binary_subdir.rs b/src/parsers/tiff/makernotes/shared/binary_subdir.rs index 4e1e900fc..fe5287223 100644 --- a/src/parsers/tiff/makernotes/shared/binary_subdir.rs +++ b/src/parsers/tiff/makernotes/shared/binary_subdir.rs @@ -30,6 +30,15 @@ //! does (ExifTool's `ReadValue`), so a truncated record degrades instead of //! reporting garbage. //! +//! * a field's `Condition`, and the arrayref of `Condition`-bearing alternatives +//! ExifTool writes when one offset means different things on different bodies. +//! ExifTool takes the *first* alternative whose condition holds and reports +//! nothing for that key when none does; a reader that ignores the condition +//! prints one body's meaning under another body's file. `%Pentax::BatteryInfo` +//! byte 2 alone is `BodyBatteryADNoLoad` on a K10D, an uncalibrated +//! `BodyBatteryADNoLoad` on a \*istD, half of `BodyBatteryVoltage1` on a K-5, +//! and `BodyBatteryState` on a K-3 III (Pentax.pm:4846-4935). +//! //! `FIRST_ENTRY` is carried on the table but deliberately does not shift any //! index: in ExifTool it only bounds the synthetic tag range `-U` walks, and the //! keys of declared tags are absolute either way. @@ -70,6 +79,81 @@ impl Fmt { } } +/// One branch of an ExifTool model regex, expanded from the pattern text by +/// `tools/exiftool-tables/codegen_subdirs.py`. +/// +/// The alternations `%Pentax` writes are finite and literal once the groups and +/// character classes are multiplied out (`/(645D|645Z|K-(1|01|...)|KP)\b/`), so +/// the generator does the expansion and refuses any pattern it cannot expand. +/// Carrying a regex engine here would mean the model test lived somewhere other +/// than ExifTool's own text. +pub(crate) struct ModelPat { + /// The literal this branch matches, searched for anywhere in the model. + pub(crate) text: &'static str, + /// The pattern ended in `\b`, so the character after the match must be a + /// non-word character or the end of the string. Without this, `/K-5\b/` + /// would also fire on a "K-5 II" -- which is a different body with a + /// different `BatteryInfo` layout. + pub(crate) word_end: bool, +} + +/// An ExifTool `Condition` on a field, over `$$self{Model}`. +#[derive(Clone, Copy)] +pub(crate) enum Cond { + /// No `Condition`: the field always applies. + Always, + /// `$$self{Model} =~ /.../`, optionally with the `and $$self{Model} !~ /.../` + /// second clause `%Pentax::BatteryInfo` uses to exclude the K-3 Mark III + /// from a pattern that would otherwise catch it (Pentax.pm:4864, :4919). + /// + /// An empty `any_of` means the condition is a bare negation. + Model { + any_of: &'static [ModelPat], + none_of: &'static [ModelPat], + }, + /// `$$self{Model} eq "..."` -- an equality, not a search. + ModelEq(&'static str), +} + +impl Cond { + /// Whether this condition holds for a file whose `Model` is `model`. + /// + /// A condition that reads `$$self{Model}` cannot hold when the model is + /// unknown: ExifTool's `$$self{Model}` is then undef and neither `=~` nor + /// `eq` matches. Reporting the field anyway would be guessing at the body. + pub(crate) fn holds(self, model: Option<&str>) -> bool { + match self { + Cond::Always => true, + Cond::ModelEq(want) => model == Some(want), + Cond::Model { any_of, none_of } => { + let Some(model) = model else { return false }; + (any_of.is_empty() || any_of.iter().any(|p| pat_matches(p, model))) + && !none_of.iter().any(|p| pat_matches(p, model)) + } + } + } +} + +/// Perl's `\w`: the character class `\b` is defined against. +const fn is_word_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +/// `$model =~ //` for one expanded branch. +fn pat_matches(pat: &ModelPat, model: &str) -> bool { + let (hay, needle) = (model.as_bytes(), pat.text.as_bytes()); + if needle.is_empty() { + return true; + } + hay.windows(needle.len()).enumerate().any(|(at, window)| { + window == needle + && (!pat.word_end + || hay + .get(at + needle.len()) + .is_none_or(|&next| !is_word_byte(next))) + }) +} + /// The `PrintConv` ExifTool applies to a field. #[derive(Clone, Copy)] pub(crate) enum PrintConv { @@ -83,6 +167,15 @@ pub(crate) enum PrintConv { /// binds it by that body's deparsed text -- an upstream edit stops the /// generator rather than leaving a stale conversion behind a real tag name. MapOr(&'static [(i64, &'static str)], fn(i64) -> String), + /// A `PrintConv` that is a Perl expression rather than a hash -- a + /// `sprintf` or a string interpolation. Like `ValueConv`, it is a + /// computation, so the vendor module carries a hand-written port and the + /// generator binds it by ExifTool's own expression text. + /// + /// It runs on the value *after* `ValueConv`, which is why it takes an `f64`: + /// `%Pentax::BatteryInfo` divides a raw 686 by 100 and then prints + /// `sprintf("%.2f V", $val)` (Pentax.pm:4866-4868). + Expr(fn(f64) -> String), } /// The `ValueConv` ExifTool applies before the `PrintConv`. @@ -102,8 +195,18 @@ pub(crate) enum ValueConv { /// One field of a `ProcessBinaryData` table. pub(crate) struct Field { + /// ExifTool's own tag key verbatim, e.g. `"545.1"`. + /// + /// This is the identity of a *tag*, which `index` is not: `545`, `545.1` and + /// `545.2` are three masked tags at one offset, while two entries both keyed + /// `2` are two `Condition`-guarded readings of the same tag and at most one + /// of them may fire. Adjacent fields sharing a key are those alternatives, + /// in ExifTool's own order. + pub(crate) key: &'static str, /// ExifTool's own tag key: an index in units of the table's `FORMAT`. pub(crate) index: i64, + /// ExifTool's `Condition` on this alternative. + pub(crate) cond: Cond, pub(crate) name: &'static str, /// Overrides the table's `FORMAT` for reading, not for locating, this field. pub(crate) format: Option, @@ -209,17 +312,21 @@ fn read_elem(record: &[u8], at: usize, fmt: Fmt, order: ByteOrder) -> Option String { match elem { Elem::Text(s) => s.clone(), - Elem::Real(v) => { + Elem::Real(v) => match conv { + PrintConv::Expr(f) => f(*v), // ExifTool prints a float with %.6g-like trimming; the integral case // is by far the common one in these tables. - if v.fract() == 0.0 && v.abs() < 1e15 { - format!("{}", *v as i64) - } else { - format!("{v}") + _ => { + if v.fract() == 0.0 && v.abs() < 1e15 { + format!("{}", *v as i64) + } else { + format!("{v}") + } } - } + }, Elem::Num(v) => match conv { PrintConv::None => v.to_string(), + PrintConv::Expr(f) => f(*v as f64), PrintConv::Map(table) => table.iter().find(|(key, _)| key == v).map_or_else( || format!("Unknown ({v})"), |(_, label)| (*label).to_string(), @@ -245,7 +352,7 @@ pub(crate) fn decode_binary_subdir( ) { // A record whose gates only read members it sets itself needs no history. let mut members = Members::new(); - decode_binary_subdir_with(table, record, order, prefix, &mut members, tags); + decode_binary_subdir_with(table, record, order, prefix, None, &mut members, tags); } /// ExifTool's `$$self{...}` slots, threaded across the sub-directories of one @@ -265,6 +372,7 @@ pub(crate) fn decode_binary_subdir_with( record: &[u8], order: ByteOrder, prefix: &str, + model: Option<&str>, members: &mut Members, tags: &mut HashMap, ) { @@ -272,7 +380,20 @@ pub(crate) fn decode_binary_subdir_with( // Generated tables are already sorted by index; ExifTool visits them in // ascending order so a DataMember is set before any gate that reads it. - for field in table.fields { + // + // Fields sharing an ExifTool key are the `Condition`-guarded alternatives of + // one tag. ExifTool takes the first whose condition holds and reports + // nothing when none does, so the group is consumed as a unit rather than + // field by field -- otherwise a body matching the second alternative would + // also be offered the first one's reading of the same bytes. + let mut rest = table.fields; + while let Some((first, tail)) = rest.split_first() { + let group_len = 1 + tail.iter().take_while(|f| f.key == first.key).count(); + let (group, tail) = rest.split_at(group_len); + rest = tail; + let Some(field) = group.iter().find(|f| f.cond.holds(model)) else { + continue; + }; if let Some((member, minimum)) = field.gate { // A gate on a member no record set is not satisfied: ExifTool's // `$$self{X}` is then undef, and `undef < n` is true in numeric @@ -320,23 +441,25 @@ pub(crate) fn decode_binary_subdir_with( continue; } - // ExifTool's order is RawConv, then ValueConv, then PrintConv. Nothing - // transcribed here carries both a ValueConv and a PrintConv -- the - // generator refuses that pairing -- so a converted value renders as the - // number it converted to. + // ExifTool's order is RawConv, then ValueConv, then PrintConv, and a + // `PrintConv` that is an expression sees the converted value: Pentax's + // `BodyBatteryVoltage1` divides the raw 686 by 100 and only then runs + // `sprintf("%.2f V", $val)`. The generator still refuses a `ValueConv` + // paired with a *hash* `PrintConv`, which would mean looking a computed + // number up in a table of raw ones. match field.value_conv { ValueConv::None => {} ValueConv::Each(f) => { parts = numbers .iter() - .map(|&v| render(&Elem::Real(f(v as f64)), PrintConv::None)) + .map(|&v| render(&Elem::Real(f(v as f64)), field.print_conv)) .collect(); } ValueConv::List(f) => { let input: Vec = numbers.iter().map(|&v| v as f64).collect(); parts = f(&input) .into_iter() - .map(|v| render(&Elem::Real(v), PrintConv::None)) + .map(|v| render(&Elem::Real(v), field.print_conv)) .collect(); } } @@ -363,7 +486,9 @@ mod tests { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "Count", format: Some(Fmt::U16), count: 1, @@ -374,7 +499,9 @@ mod tests { print_conv: PrintConv::None, }, Field { + key: "1", index: 1, + cond: Cond::Always, name: "First", format: Some(Fmt::U16), count: 4, @@ -385,7 +512,9 @@ mod tests { print_conv: PrintConv::None, }, Field { + key: "5", index: 5, + cond: Cond::Always, name: "Second", format: Some(Fmt::U16), count: 4, @@ -461,7 +590,9 @@ mod tests { default_format: Fmt::U8, first_entry: 0, fields: &[Field { + key: "0", index: 0, + cond: Cond::Always, name: "Name", format: Some(Fmt::Str(8)), count: 1, @@ -491,7 +622,9 @@ mod tests { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "Known", format: None, count: 1, @@ -502,7 +635,9 @@ mod tests { print_conv: PrintConv::Map(T_CONV), }, Field { + key: "1", index: 1, + cond: Cond::Always, name: "Other", format: None, count: 1, @@ -531,7 +666,9 @@ mod tests { first_entry: 0, fields: &[ Field { + key: "0", index: 0, + cond: Cond::Always, name: "Low", format: None, count: 1, @@ -542,7 +679,12 @@ mod tests { print_conv: PrintConv::None, }, Field { + // ExifTool's own key for a second tag at one offset: `0.1`, + // not a repeat of `0`. Two entries keyed `0` would be + // `Condition` alternatives, of which only one may fire. + key: "0.1", index: 0, + cond: Cond::Always, name: "High", format: None, count: 1, @@ -577,4 +719,193 @@ mod tests { decode_binary_subdir(&T_TABLE, &swapped, ByteOrder::BigEndian, "X", &mut tags); assert_eq!(tags.get("X:First").map(String::as_str), Some("46 81 27 27")); } + + /// Perl's `\b` after an alternation, which is what separates a `K-5` body + /// from a `K-50` -- two different `%Pentax::BatteryInfo` layouts. + #[test] + fn word_boundary_is_the_difference_between_k5_and_k50() { + static K5: Cond = Cond::Model { + any_of: &[ModelPat { + text: "K-5", + word_end: true, + }], + none_of: &[], + }; + assert!(K5.holds(Some("PENTAX K-5"))); + assert!(K5.holds(Some("PENTAX K-5 II s"))); + assert!(!K5.holds(Some("PENTAX K-50"))); + assert!(!K5.holds(Some("PENTAX K-500"))); + assert!(!K5.holds(None)); + + // Without the boundary the same literal is a plain substring search. + static ANY: Cond = Cond::Model { + any_of: &[ModelPat { + text: "K-5", + word_end: false, + }], + none_of: &[], + }; + assert!(ANY.holds(Some("PENTAX K-500"))); + } + + /// `A and $$self{Model} !~ /B/`: `none_of` vetoes a model `any_of` accepts. + #[test] + fn negated_clause_vetoes_a_matching_alternation() { + static C: Cond = Cond::Model { + any_of: &[ModelPat { + text: "K-3", + word_end: true, + }], + none_of: &[ModelPat { + text: "III", + word_end: false, + }], + }; + assert!(C.holds(Some("PENTAX K-3"))); + assert!(!C.holds(Some("PENTAX K-3 Mark III"))); + assert!(Cond::ModelEq("PENTAX K-3 II").holds(Some("PENTAX K-3 II"))); + assert!(!Cond::ModelEq("PENTAX K-3 II").holds(Some("PENTAX K-3 II s"))); + } + + /// Three alternatives of one key: the first whose condition holds wins, the + /// rest are not offered, and a model matching none reports nothing for that + /// key at all. + #[test] + fn variants_take_the_first_match_in_exiftools_order() { + static V_TABLE: BinaryTable = BinaryTable { + name: "V", + default_format: Fmt::U8, + first_entry: 0, + fields: &[ + Field { + key: "0", + index: 0, + cond: Cond::Model { + any_of: &[ModelPat { + text: "Alpha", + word_end: false, + }], + none_of: &[], + }, + name: "First", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + Field { + key: "0", + index: 0, + cond: Cond::Model { + any_of: &[ + ModelPat { + text: "Alpha", + word_end: false, + }, + ModelPat { + text: "Beta", + word_end: false, + }, + ], + none_of: &[], + }, + name: "Second", + format: None, + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + // A different key at the same offset: a masked neighbour, not + // an alternative, so it is decoded independently. + Field { + key: "0.1", + index: 0, + cond: Cond::Always, + name: "Neighbour", + format: None, + count: 1, + set_member: None, + gate: None, + mask: Some(0xf0), + value_conv: ValueConv::None, + print_conv: PrintConv::None, + }, + ], + }; + let decode = |model| { + let mut tags = HashMap::new(); + let mut members = Members::new(); + decode_binary_subdir_with( + &V_TABLE, + &[0x42], + ByteOrder::BigEndian, + "X", + model, + &mut members, + &mut tags, + ); + tags + }; + + let alpha = decode(Some("Alpha")); + assert_eq!(alpha.get("X:First").map(String::as_str), Some("66")); + assert!(!alpha.contains_key("X:Second")); + + let beta = decode(Some("Beta")); + assert_eq!(beta.get("X:Second").map(String::as_str), Some("66")); + assert!(!beta.contains_key("X:First")); + + // No alternative applies: the key produces nothing rather than the + // wrong body's reading. The neighbouring key still does. + let gamma = decode(Some("Gamma")); + assert!(!gamma.contains_key("X:First")); + assert!(!gamma.contains_key("X:Second")); + assert_eq!(gamma.get("X:Neighbour").map(String::as_str), Some("4")); + } + + /// ExifTool runs `ValueConv` and then hands the result to `PrintConv`, so + /// an expression `PrintConv` must see the converted number: a raw 686 of + /// centivolts prints "6.86 V", not "686.00 V". + #[test] + fn expression_print_conv_runs_after_the_value_conv() { + fn div_100(v: f64) -> f64 { + v / 100.0 + } + fn volts(v: f64) -> String { + format!("{v:.2} V") + } + static E_TABLE: BinaryTable = BinaryTable { + name: "E", + default_format: Fmt::U8, + first_entry: 0, + fields: &[Field { + key: "0", + index: 0, + cond: Cond::Always, + name: "Volts", + format: Some(Fmt::U16), + count: 1, + set_member: None, + gate: None, + mask: None, + value_conv: ValueConv::Each(div_100), + print_conv: PrintConv::Expr(volts), + }], + }; + let mut tags = HashMap::new(); + decode_binary_subdir( + &E_TABLE, + &[0x02, 0xae], + ByteOrder::BigEndian, + "X", + &mut tags, + ); + assert_eq!(tags.get("X:Volts").map(String::as_str), Some("6.86 V")); + } } diff --git a/tests/integration/pentax_makernotes_tests.rs b/tests/integration/pentax_makernotes_tests.rs index 6b6189eca..42864a72e 100644 --- a/tests/integration/pentax_makernotes_tests.rs +++ b/tests/integration/pentax_makernotes_tests.rs @@ -11,7 +11,7 @@ fn test_pentax_parser_trait_implementation() { use oxidex::parsers::tiff::makernotes::pentax::PentaxParser; use oxidex::parsers::tiff::makernotes::shared::MakerNoteParser; - let parser = PentaxParser; + let parser = PentaxParser::default(); assert_eq!(parser.manufacturer_name(), "Pentax"); assert_eq!(parser.tag_prefix(), "Pentax:"); } @@ -21,7 +21,7 @@ fn test_pentax_validate_header_aoc() { use oxidex::parsers::tiff::makernotes::pentax::PentaxParser; use oxidex::parsers::tiff::makernotes::shared::MakerNoteParser; - let parser = PentaxParser; + let parser = PentaxParser::default(); // Valid AOC header let valid_header = b"AOC\0\x00\x00extra_data_here"; @@ -41,7 +41,7 @@ fn test_pentax_validate_header_pentax() { use oxidex::parsers::tiff::makernotes::pentax::PentaxParser; use oxidex::parsers::tiff::makernotes::shared::MakerNoteParser; - let parser = PentaxParser; + let parser = PentaxParser::default(); // Valid PENTAX header let valid_header = b"PENTAX \0more_data_follows"; @@ -55,7 +55,7 @@ fn test_pentax_parser_empty_data() { use oxidex::parsers::tiff::makernotes::shared::MakerNoteParser; use std::collections::HashMap; - let parser = PentaxParser; + let parser = PentaxParser::default(); let mut tags = HashMap::new(); // Empty data should not cause errors @@ -71,7 +71,7 @@ fn test_pentax_parser_invalid_header() { use oxidex::parsers::tiff::makernotes::shared::MakerNoteParser; use std::collections::HashMap; - let parser = PentaxParser; + let parser = PentaxParser::default(); let mut tags = HashMap::new(); // Invalid header should return error @@ -89,7 +89,7 @@ fn test_pentax_decode_quality() { // This test verifies that the quality decoder functions work correctly // through the parser implementation - let parser = PentaxParser; + let parser = PentaxParser::default(); assert_eq!(parser.manufacturer_name(), "Pentax"); } @@ -99,6 +99,6 @@ fn test_pentax_decode_picture_modes() { use oxidex::parsers::tiff::makernotes::shared::MakerNoteParser; // Verify parser is correctly instantiated for picture mode decoding - let parser = PentaxParser; + let parser = PentaxParser::default(); assert_eq!(parser.tag_prefix(), "Pentax:"); } diff --git a/tools/exiftool-tables/codegen_subdirs.py b/tools/exiftool-tables/codegen_subdirs.py index c9fb85efe..fbf4fc7d1 100644 --- a/tools/exiftool-tables/codegen_subdirs.py +++ b/tools/exiftool-tables/codegen_subdirs.py @@ -18,18 +18,24 @@ * `Format`: a scalar int/float format, `string[N]`, `undef[N]`, or an array `fmt[N]` of a scalar format. - * `PrintConv`: absent, or a pure enum map (every key/value a plain scalar). + * `PrintConv`: absent, a pure enum map (every key/value a plain scalar), or a + Perl expression registered verbatim in `EXPR_PRINT_CONVS`. + * `ValueConv`: absent, or an expression/sub body registered verbatim. * `RawConv`: absent, or one of ExifTool's two count-gate idioms -- `$$self{X} = $val` (records a data member) and `$$self{X} < N ? undef : $val` (suppresses the tag below a count). * `Mask`. - -Anything else -- a `ValueConv`, a `Hook`, a `Condition`, a nested -`SubDirectory`, a `PrintConv` with `BITMASK`/`OTHER`/code, an arrayref of -model-conditional variants -- raises `Unsupported`, naming the table, the tag -and the offending construct. Run with `--allow-skip` to downgrade those to a -machine-logged skip line and continue; the log is the deliverable, not a -footnote. + * `Condition`: a test on `$$self{Model}` -- `=~`, `!~`, `eq`, and the + `A and B` conjunction of those -- whose regexes expand to a finite literal + alternation. An arrayref of such `Condition`-guarded alternatives becomes + several `Field`s sharing one ExifTool key, in ExifTool's order. + +Anything else -- a `Hook`, a nested `SubDirectory`, a `PrintConv` with +`BITMASK`/`OTHER`/code, a `Condition` on anything but the model, a regex with a +construct the expander has not been taught -- raises `Unsupported`, naming the +table, the tag and the offending construct. Run with `--allow-skip` to +downgrade those to a machine-logged skip line and continue; the log is the +deliverable, not a footnote. usage: dump_tables.pl Panasonic > tables.json @@ -73,6 +79,138 @@ def __init__(self, table, tag, reason): self.table, self.tag, self.reason = table, tag, reason +# -------------------------------------------------------------------------- +# `Condition` +# -------------------------------------------------------------------------- +# +# Every `Condition` on a field of a `ProcessBinaryData` table in `%Pentax` is a +# test on the camera model, and every model regex is a finite alternation of +# literals once its groups and character classes are multiplied out. Expanding +# them here rather than shipping a regex engine keeps the model test derived +# from ExifTool's own pattern text: a pattern the expander has not been taught +# stops the generator instead of silently matching nothing (or everything). + +# `$$self{Model} =~ /re/`, `!~`, or `eq "..."`; several joined by `and`. +MODEL_RE = re.compile(r'^\$\$self\{Model\}\s*(=~|!~)\s*/(.*)/$') +MODEL_EQ_RE = re.compile(r'^\$\$self\{Model\}\s*eq\s*"([^"]*)"$') + + +def expand_regex(pattern, table, key): + """A Perl regex -> (list of literal alternatives, ends-with-`\\b`). + + Handles exactly what `%Pentax` writes: literal text, `\\*` and `\\b` + escapes, `(a|b|c)` groups (nested), `[abc]` character sets, and `?` on a + group or a set. Anything else -- a quantifier on a literal, an anchor, a + class shorthand -- raises rather than expanding to something that is merely + plausible. + """ + pos = 0 + word_end = False + if pattern.endswith(r"\b"): + pattern, word_end = pattern[:-2], True + + def parse_alt(): + """alternation := seq ('|' seq)* -> list of strings""" + nonlocal pos + out = parse_seq() + while pos < len(pattern) and pattern[pos] == "|": + pos += 1 + out += parse_seq() + return out + + def parse_seq(): + """seq := atom* -> list of strings (the cross product of the atoms)""" + nonlocal pos + out = [""] + while pos < len(pattern) and pattern[pos] not in "|)": + choices = parse_atom() + out = [prefix + suffix for prefix in out for suffix in choices] + return out + + def parse_atom(): + nonlocal pos + ch = pattern[pos] + if ch == "(": + pos += 1 + inner = parse_alt() + if pos >= len(pattern) or pattern[pos] != ")": + raise Unsupported(table, key, f"unbalanced group in regex /{pattern}/") + pos += 1 + elif ch == "[": + end = pattern.find("]", pos) + if end < 0: + raise Unsupported(table, key, f"unterminated [...] in regex /{pattern}/") + body = pattern[pos + 1 : end] + if not body or not body.isalnum(): + # A range, a negation or an escape inside the class: not taught. + raise Unsupported( + table, key, f"character class [{body}] in regex /{pattern}/ is not a plain set" + ) + inner, pos = list(body), end + 1 + elif ch == "\\": + if pos + 1 >= len(pattern): + raise Unsupported(table, key, f"trailing backslash in regex /{pattern}/") + esc = pattern[pos + 1] + if esc.isalnum(): + # `\b`, `\d`, `\w`, a backreference: all change what matches. + raise Unsupported(table, key, f"escape \\{esc} in regex /{pattern}/") + inner, pos = [esc], pos + 2 + elif ch in "*+?^$.{": + raise Unsupported(table, key, f"metacharacter {ch!r} in regex /{pattern}/") + else: + inner, pos = [ch], pos + 1 + if pos < len(pattern) and pattern[pos] == "?": + # `GX-1[LS]?` -- the atom is optional, so "" is an alternative. + pos += 1 + inner = inner + [""] + return inner + + alts = parse_alt() + if pos != len(pattern): + raise Unsupported(table, key, f"unconsumed {pattern[pos:]!r} in regex /{pattern}/") + if not alts or any(a == "" for a in alts): + # An empty alternative matches every model, which is never what a + # `Condition` means. + raise Unsupported(table, key, f"regex /{pattern}/ expands to an empty alternative") + return alts, word_end + + +def field_cond(tag, table, key): + """A `Condition` as a Rust `Cond` expression.""" + cond = tag.get("Condition") + if cond is None: + return "Cond::Always" + any_of, none_of = [], [] + clauses = [c.strip() for c in re.split(r"\band\b", cond.strip())] + if len(clauses) == 1: + m = MODEL_EQ_RE.match(clauses[0]) + if m: + return f'Cond::ModelEq("{rust_str(m.group(1))}")' + positives = 0 + for clause in clauses: + m = MODEL_RE.match(clause) + if not m: + raise Unsupported(table, key, f"Condition clause not a Model test: {clause!r}") + alts, word_end = expand_regex(m.group(2), table, key) + pats = [f'ModelPat {{ text: "{rust_str(a)}", word_end: {str(word_end).lower()} }}' + for a in alts] + if m.group(1) == "=~": + # Two `=~` joined by `and` are an intersection; `Cond::Model` holds + # one alternation and would union them instead. + positives += 1 + if positives > 1: + raise Unsupported(table, key, f"Condition has two `=~` clauses: {cond!r}") + any_of.extend(pats) + else: + none_of.extend(pats) + if len(any_of) == 0 and len(none_of) == 0: + raise Unsupported(table, key, f"Condition {cond!r} has no clauses") + return ( + f"Cond::Model {{ any_of: &[{', '.join(any_of)}], " + f"none_of: &[{', '.join(none_of)}] }}" + ) + + def rust_str(s): return s.replace("\\", "\\\\").replace('"', '\\"') @@ -198,6 +336,16 @@ def normalize_deparse(text): # Pentax.pm:5734, :5740, :5760 -- RollAngle, PitchAngle, # CompositionAdjustRotation: half-degree steps, opposite sense. "-$val / 2": "negate_half", + # Pentax.pm:4866, :4921, :4946, :4956 -- battery voltages, in centivolts. + "$val / 100": "div_100", + # Pentax.pm:4930, :4984 -- the K-3 III's two voltages, a raw ADC count. + "$val * 4e-8 + 0.27219": "k3_iii_voltage", + # Pentax.pm:6129, :6138, :6162 -- SensorTemperature, tenths of a degree. + "$val / 10": "div_10", + # Pentax.pm:5062 -- AFIntegrationTime, 2 ms per step. + "$val * 2": "times_2", + # Pentax.pm:6118 -- ShotNumber, counted from zero. + "$val+1": "plus_1", } LIST_VALUE_CONVS = { # Pentax.pm:837-840 -- `%kelvinWB`, shared by all 17 KelvinWB_* tags. @@ -205,8 +353,31 @@ def normalize_deparse(text): ". ($a[2] / 8192) . ' ' . ($a[3] / 8192)); }": "kelvin_wb", } +# Translations for a `PrintConv` that is a Perl expression rather than a hash. +# +# Same discipline as the `ValueConv` registries: keyed on ExifTool's own +# expression text, so an upstream edit shows up as an unknown key rather than as +# a stale conversion behind a real tag name. The value is a Rust function of +# the *post-`ValueConv`* number, which is what `$val` is at `PrintConv` time. +EXPR_PRINT_CONVS = { + # Pentax.pm:4868, :4923, :4932, :4948, :4958, :4986 -- battery voltages. + 'sprintf("%.2f V", $val)': "volts_2dp", + # Pentax.pm:4854 -- BodyBatteryADNoLoad on the K10D/K20D. + 'sprintf("%d (%.1fV, %d%%)",$val,$val*8.18/186,($val-155)*100/35)': "ad_no_load", + # Pentax.pm:4898 -- BodyBatteryADLoad on the K10D/K20D. + 'sprintf("%d (%.1fV, %d%%)",$val,$val*8.18/186,($val-152)*100/34)': "ad_load", + # Pentax.pm:5064 -- AFIntegrationTime, in ms after its ValueConv. + '"$val ms"': "millis", + # Pentax.pm:6147, :6154 -- CameraTemperature4/5 on the K-5. + '"$val C"': "celsius", + # Pentax.pm:6131, :6140, :6164 -- SensorTemperature, one decimal. + 'sprintf("%.1f C", $val)': "celsius_1dp", + # Pentax.pm:5188 -- AFCSensitivity, counted the other way. + "5 - $val": "five_minus", +} + -def field_value_conv(tag, table, key, conv_prefix): +def field_value_conv(tag, table, key, vc_prefix): """A `ValueConv` as a Rust expression, or `ValueConv::None`.""" vc = tag.get("ValueConv") if vc is None: @@ -219,7 +390,7 @@ def field_value_conv(tag, table, key, conv_prefix): fn = SCALAR_VALUE_CONVS.get(expr) if fn is None: raise Unsupported(table, key, f"ValueConv expression not in SCALAR_VALUE_CONVS: {expr!r}") - return f"ValueConv::Each({conv_prefix}::{fn})" + return f"ValueConv::Each({vc_prefix}::{fn})" if kind == "code": source = vc.get("deparse") if not source: @@ -229,7 +400,7 @@ def field_value_conv(tag, table, key, conv_prefix): raise Unsupported( table, key, "ValueConv body is not in LIST_VALUE_CONVS: " + normalize_deparse(source) ) - return f"ValueConv::List({conv_prefix}::{fn})" + return f"ValueConv::List({vc_prefix}::{fn})" raise Unsupported(table, key, f"ValueConv kind={kind!r} is neither an expression nor a sub") @@ -254,6 +425,12 @@ def field_print_conv(tag, table, key, pool, conv_prefix): raise Unsupported(table, key, f"PrintConv has unexpected shape {type(pc).__name__}") kind = pc.get("kind") other = None + if kind == "expr": + expr = (pc.get("expr") or "").strip() + fn = EXPR_PRINT_CONVS.get(expr) + if fn is None: + raise Unsupported(table, key, f"PrintConv expression not in EXPR_PRINT_CONVS: {expr!r}") + return f"PrintConv::Expr({conv_prefix}::{fn})" if kind == "enum_partial": directives = pc.get("directives") or {} unknown = set(directives) - BENIGN_PC_DIRECTIVES - {"OTHER"} @@ -276,7 +453,7 @@ def field_print_conv(tag, table, key, pool, conv_prefix): ) other = f"{conv_prefix}::{fn}" elif kind != "enum": - raise Unsupported(table, key, f"PrintConv kind={kind!r} is not a pure enum map") + raise Unsupported(table, key, f"PrintConv kind={kind!r} is not a pure enum map or expression") entries = [] for k, v in pc.get("map", {}).items(): try: @@ -321,6 +498,8 @@ def emit(self): # changing what a reader produces. IGNORED_TAG_KEYS = { "Name", "Format", "RawConv", "PrintConv", "ValueConv", "Mask", "Notes", "Description", + # Handled by `field_cond`, which refuses any test it cannot reproduce. + "Condition", "DataMember", "Writable", "Groups", "PrintConvInv", "ValueConvInv", "Protected", "Permanent", "SeparateTable", "PrintHex", "Priority", "_shorthand", "_extra_keys", "Unknown", "Hidden", "Avoid", "Binary", @@ -328,7 +507,7 @@ def emit(self): } -def gen_table(module, tname, tbl, pool, skips, allow_skip, conv_prefix): +def gen_table(module, tname, tbl, pool, skips, allow_skip, conv_prefix, vc_prefix): meta = tbl.get("meta") or {} pp = meta.get("PROCESS_PROC") pp_name = pp.get("__name", "") if isinstance(pp, dict) else "" @@ -347,49 +526,76 @@ def gen_table(module, tname, tbl, pool, skips, allow_skip, conv_prefix): rows = [] for key in sorted(tbl["tags"], key=lambda k: parse_index(k, tname)): - tag = tbl["tags"][key] - try: - if "_variants" in tag: - raise Unsupported(tname, key, "arrayref of Condition variants") - name = tag.get("Name") - if not isinstance(name, str) or not name: - raise Unsupported(tname, key, "no Name") - if tag.get("Unknown"): - # ExifTool hides these without -U; emitting them would be a diff - # against the default output, not a gain. - skips.append((tname, key, name, "Unknown => 1 (ExifTool hides it without -U)")) + entry = tbl["tags"][key] + # An arrayref of alternatives is one tag with several + # `Condition`-guarded readings, in ExifTool's order. Each becomes a + # `Field` sharing this key; the decoder takes the first whose condition + # holds. + # + # Dropping one alternative out of the middle is not safe: ExifTool stops + # at the first match, so removing an earlier one lets a later, broader + # one answer for a body it was never meant to describe -- which is a + # wrong value under a real tag name, the one outcome worth more than a + # missing tag. So a key any of whose alternatives is refused is dropped + # whole, and every refusal is still logged. + variants = entry.get("_variants") or [entry] + group, refused = [], False + for tag in variants: + try: + name = tag.get("Name") + if not isinstance(name, str) or not name: + raise Unsupported(tname, key, "no Name") + if tag.get("Unknown"): + # ExifTool hides these without -U; emitting them would be a + # diff against the default output, not a gain. It still + # *matches*, so an alternative behind it must not take its + # place -- hence refusing the key rather than the tag. + skips.append((tname, key, name, "Unknown => 1 (ExifTool hides it without -U)")) + refused = True + continue + for k in tag: + if k not in IGNORED_TAG_KEYS: + raise Unsupported(tname, key, f"unhandled tag key {k!r}") + idx = parse_index(key, tname) + cond = field_cond(tag, tname, key) + fmt, count = field_format(tag, tname, key) + member, gate = field_raw_conv(tag, tname, key) + pc = field_print_conv(tag, tname, key, pool, conv_prefix) + vc = field_value_conv(tag, tname, key, vc_prefix) + if vc != "ValueConv::None" and pc.startswith("PrintConv::Map"): + # ExifTool runs ValueConv then PrintConv, so a hash PrintConv + # after one would be a lookup of a computed number in a table + # of raw ones. An expression PrintConv is exactly that + # composition and is allowed. + raise Unsupported(tname, key, "ValueConv combined with a hash PrintConv") + if count > 1 and pc != "PrintConv::None": + # ExifTool hands the *joined* array string to a hash + # PrintConv, so an element-wise lookup would print something + # ExifTool never does. Refuse rather than pick one of the two + # readings. + raise Unsupported(tname, key, "array Format with a hash PrintConv") + mask = tag.get("Mask") + mask_s = "None" if mask is None else f"Some({int(str(mask), 0)})" + except Unsupported as exc: + if not allow_skip: + raise + skips.append((tname, key, tag.get("Name", "?"), exc.reason)) + refused = True continue - for k in tag: - if k not in IGNORED_TAG_KEYS: - raise Unsupported(tname, key, f"unhandled tag key {k!r}") - idx = parse_index(key, tname) - fmt, count = field_format(tag, tname, key) - member, gate = field_raw_conv(tag, tname, key) - pc = field_print_conv(tag, tname, key, pool, conv_prefix) - vc = field_value_conv(tag, tname, key, conv_prefix) - if vc != "ValueConv::None" and pc != "PrintConv::None": - # ExifTool runs ValueConv then PrintConv; nothing here does both, - # and guessing the composition would be inventing an output. - raise Unsupported(tname, key, "ValueConv combined with a PrintConv") - if count > 1 and pc != "PrintConv::None": - # ExifTool hands the *joined* array string to a hash PrintConv, - # so an element-wise lookup would print something ExifTool never - # does. Refuse rather than pick one of the two readings. - raise Unsupported(tname, key, "array Format with a hash PrintConv") - mask = tag.get("Mask") - mask_s = "None" if mask is None else f"Some({int(str(mask), 0)})" - except Unsupported as exc: - if not allow_skip: - raise - skips.append((tname, key, tag.get("Name", "?"), exc.reason)) - continue - rows.append( - f" Field {{ index: {idx}, name: \"{rust_str(name)}\", " - f"format: {'None' if fmt is None else f'Some({fmt})'}, count: {count}, " - f"set_member: {'None' if member is None else f'Some(\"{member}\")'}, " - f"gate: {'None' if gate is None else f'Some((\"{gate[0]}\", {gate[1]}))'}, " - f"mask: {mask_s}, value_conv: {vc}, print_conv: {pc} }}," - ) + group.append( + f" Field {{ key: \"{key}\", index: {idx}, cond: {cond}, " + f"name: \"{rust_str(name)}\", " + f"format: {'None' if fmt is None else f'Some({fmt})'}, count: {count}, " + f"set_member: {'None' if member is None else f'Some(\"{member}\")'}, " + f"gate: {'None' if gate is None else f'Some((\"{gate[0]}\", {gate[1]}))'}, " + f"mask: {mask_s}, value_conv: {vc}, print_conv: {pc} }}," + ) + if refused and len(variants) > 1: + for text in group: + skips.append((tname, key, text.split('name: "')[1].split('"')[0], + "dropped with the rest of a partly-refused alternative list")) + group = [] + rows.extend(group) ident = re.sub(r"[^A-Za-z0-9]", "_", f"{module}_{tname}").upper() body = "\n".join(rows) @@ -416,7 +622,12 @@ def main(): ap.add_argument( "--other-conv-mod", default="super::print_conv", - help="Rust path holding the hand-written OTHER PrintConv fallbacks", + help="Rust path holding the hand-written PrintConv ports", + ) + ap.add_argument( + "--value-conv-mod", + default="super::value_conv", + help="Rust path holding the hand-written ValueConv ports", ) ap.add_argument("--allow-skip", action="store_true") ap.add_argument("-o", "--output", required=True) @@ -437,6 +648,7 @@ def main(): skips, args.allow_skip, args.other_conv_mod, + args.value_conv_mod, ) idents.append((tname, ident)) chunks.append(text) @@ -449,9 +661,16 @@ def main(): //! The generator refuses any construct it has not been taught and names it, so //! a field that is here was reproduced exactly and a field that is missing was //! reported as missing -- neither is a guess. - +""" + # `ModelPat` only appears in a `Cond::Model`, so importing it + # unconditionally would leave an unused import in a table set that has no + # `Condition` at all. + imports = ["BinaryTable", "Cond", "Field", "Fmt", "PrintConv", "ValueConv"] + if any("ModelPat" in chunk for chunk in chunks): + imports.insert(4, "ModelPat") + header += f""" use crate::parsers::tiff::makernotes::shared::binary_subdir::{{ - BinaryTable, Field, Fmt, PrintConv, ValueConv, + {", ".join(imports)}, }}; """ out = "\n\n".join([header, pool.emit()] + chunks) + "\n" From 77feca98cc79c32532d29a9d986f5999a00640da Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 21:11:28 -0500 Subject: [PATCH 09/16] feat(apple): find the MakerNote IFD, then read its binary plists `MakerNotes.pm:37-46` starts the Apple directory at `$valuePtr + 14` -- ten bytes of `Apple iOS\0`, two version bytes, then the two-byte order marker. oxidex started it at byte 10, so on every iPhone it read the order marker "MM" as an entry count of 1 and decoded one entry out of the count field and the first tag id, arriving at tag 0x4d4d, which no table has. The result was not a wrong value; it was silence. **Zero `Apple:` tags over the whole sample corpus**, against the 831 ExifTool reports. That is why the binary plists could not be reached. Four `%Apple::Main` tags hold a whole `bplist00` blob rather than a value: 0x0003 RunTime SubDirectory over %Apple::RunTime, whose PROCESS_PROC is PLIST::ProcessBinaryPLIST (Apple.pm:40-43, :324-325) 0x0040 SemanticStyle ValueConv => \&ConvertPLIST (Apple.pm:276) 0x0041 SemanticStyleRenderingVer ditto (Apple.pm:280) 0x0042 SemanticStylePreset ditto (Apple.pm:284) This adds: - `shared/binary_plist.rs`, a transcription of `Image::ExifTool::PLIST` -- `ProcessBinaryPLIST`'s trailer and offset table (PLIST.pm:398-450) and `ExtractObject`'s object grammar (PLIST.pm:260-390), plus `XMP::SerializeStruct` (XMPStruct.pl:34-69) for the `ConvertPLIST` case. Three details decide whether the output matches: * integers are read **unsigned**: `%readProc` is Get8u/16u/24u/32u/64u (PLIST.pm:30-38), so `RunTimeValue` is 235706184764708 and not a negative number. * a size that `%readProc` has no entry for returns undef rather than a guess -- so only 1/2/3/4/8-byte integers and 4/8-byte reals decode. * `SerializeStruct` walks `OrderedKeys`, which for a hash built by `ExtractObject` is a plain `sort keys`. `Apple_iPhone13Pro.jpg` stores its `SemanticStyle` keys in the order 3,1,2,0 and ExifTool prints `{_0=1,_1=0.5,_2=0,_3=2}`. Storage order would print the reverse. A plist date is decoded and then dropped: ExifTool converts it with `ConvertUnixTime($val + 11323*24*3600, 1)`, and that second argument is `$toLocal`, so the string carries the extracting machine's time zone. No Apple blob in the corpus has one, and a value that depends on the reader's clock is not one to approximate. - `%Apple::Main` itself, transcribed into `apple.rs` -- each entry's `Writable` format and its `PrintConv` verbatim, walked by the existing `shared::table_ifd`. The table's twelve `Unknown => 1` entries are absent on purpose; ExifTool reports those only under `-u`. - `Composite:RunTimeSincePowerUp` (Apple.pm:348-357), which was declared in `composite/tables.rs` with no arm in `compute`. Its `ConvertDuration` PrintConv already existed, privately, inside the MTS parser; it moves to `core::formatters::duration` rather than being copied. Three defects found on the way, each measured: - `table_ifd::print_rational` printed `%.15g`. `GetRational64s`/`u` (ExifTool.pm:6107-6120) end in `RoundFloat($num/$den, 10)`, so ExifTool prints `AccelerationVector` as `-0.9245480894`, not `-0.924548089390588`. Fixing it also gained `Olympus:FocalPlaneDiagonal` (+5) and `Olympus:DigitalZoom` (+3). - `table_ifd` had no `int64u`/`int64s` (ExifTool format codes 16 and 17). `Apple_iPhone15Pro.jpg` stores `LivePhotoVideoIndex` as `int64u[1]` = 4294967700; an int64u above `i64::MAX` is dropped rather than printed negative. - `parse_exif_subifd` kept only the *last* 0x927C entry. `Apple_iPhone6.jpg` declares two -- the camera's, and a 142-byte UTF-16 JSON blob an editor appended -- so the Apple parser was handed the wrong 142 bytes and reported nothing. Each entry is now processed in turn, as ExifTool does. Deleted `registries/apple.rs`. It declared a `FrontFacingCamera` at 0x0032 and a `PortraitMode` at 0x0020 -- `%Apple::Main` has no tag at 0x0032 at all, and 0x0020 is `ImageCaptureRequestID` -- plus enum decoders for `OISMode`, `SemanticStyle`, `SignalToNoiseRatioType` and `GreenGhostMitigationStatus`, none of which has a `PrintConv` in Apple.pm. `tests/integration.rs` never declared `apple_makernotes_tests`, so the file asserting those names never compiled; it is now declared, and rewritten against bytes dumped from real corpus files with `exiftool -v3`. Verified over /tmp/oxidex-exiftool-cache/combined-samples (4,238 files; ExifTool 13.59 reports on 4,228) against `exiftool -a -G1 -s`, keyed `Group1:Name`, case-sensitive, scored **per file**. Base is this branch's merge-base, 5a8b835c: matched 393,562 -> 394,451 (+889 across 55 files) regressions 0 -- matched-set diff per file, not totals files worsened 0; extra keys unchanged at 108,146 Apple: keys emitted 0 -> 831, every one byte-identical to ExifTool of the +889: 267 plist-derived, 622 unlocked by the IFD start, 8 Olympus Apple_iPhone13Pro.jpg 126 -> 155 Apple_iPhone13ProMax.jpg 99 -> 127 Apple_iPhone15Pro.jpg 89 -> 116 Apple_iPhone12Pro.jpg 125 -> 152 Nothing is left over: every `Apple:` tag ExifTool reports for any corpus file, and `Composite:RunTimeSincePowerUp`, now matches on every file. Co-Authored-By: Claude Opus 5 --- api/oxidex.h | 11 + src/composite/compute.rs | 14 + src/core/formatters/duration.rs | 78 + src/core/formatters/mod.rs | 2 + src/core/tiff_helpers.rs | 12 +- src/parsers/tiff/makernotes/apple.rs | 1308 ++++++----------- .../tiff/makernotes/registries/apple.rs | 376 ----- src/parsers/tiff/makernotes/registries/mod.rs | 11 +- .../tiff/makernotes/shared/binary_plist.rs | 566 +++++++ src/parsers/tiff/makernotes/shared/mod.rs | 2 + .../tiff/makernotes/shared/table_ifd.rs | 45 +- src/parsers/video/mts.rs | 29 +- tests/integration.rs | 7 + tests/integration/apple_makernotes_tests.rs | 485 +++--- 14 files changed, 1398 insertions(+), 1548 deletions(-) create mode 100644 src/core/formatters/duration.rs delete mode 100644 src/parsers/tiff/makernotes/registries/apple.rs create mode 100644 src/parsers/tiff/makernotes/shared/binary_plist.rs diff --git a/api/oxidex.h b/api/oxidex.h index 35979e870..f37cd5227 100644 --- a/api/oxidex.h +++ b/api/oxidex.h @@ -3621,6 +3621,17 @@ #define TIFF_IFD 13 +/* + `int64u`, format code 16 in ExifTool's `@formatName` (Exif.pm). Apple + stores `0x0017 LivePhotoVideoIndex` in it on newer iPhones. + */ +#define TIFF_LONG8 16 + +/* + `int64s`, format code 17. + */ +#define TIFF_SLONG8 17 + #define AFINFO 0 #define AFSTATUS15 1 diff --git a/src/composite/compute.rs b/src/composite/compute.rs index 6b1e253c2..4c929b660 100644 --- a/src/composite/compute.rs +++ b/src/composite/compute.rs @@ -11,6 +11,7 @@ //! //! Adding one function here fixes that tag for *every* format at once, which is //! why this layer is worth building before chasing per-format gaps. +use crate::core::formatters::duration::convert_duration; use crate::core::formatters::exif_print_conv::print_exposure_time; /// Inputs to a composite: `require` values followed by `desire` values, in the @@ -760,6 +761,19 @@ pub fn compute(module: &str, name: &str, i: Inputs, make: Option<&str>) -> Optio Computed::new(sf.to_string(), format!("{sf:.1}")) } + // Apple.pm Composite::RunTimeSincePowerUp (Apple.pm:348-357): + // require: 0) Apple:RunTimeValue, 1) Apple:RunTimeScale + // ValueConv: `$val[1] ? $val[0] / $val[1] : undef` + // PrintConv: `ConvertDuration($val)` + ("Apple", "RunTimeSincePowerUp") => { + let (value, scale) = (f(get(i, 0))?, f(get(i, 1))?); + if scale == 0.0 { + return None; + } + let seconds = value / scale; + Computed::new(seconds.to_string(), convert_duration(seconds)) + } + // Canon.pm Composite::DriveMode: // `$val[0] ? 0 : ($val[1] ? 1 : 2)` ("Canon", "DriveMode") => { diff --git a/src/core/formatters/duration.rs b/src/core/formatters/duration.rs new file mode 100644 index 000000000..3f845dce8 --- /dev/null +++ b/src/core/formatters/duration.rs @@ -0,0 +1,78 @@ +//! ExifTool's `ConvertDuration`, the PrintConv shared by every composite that +//! reports an elapsed time in seconds. + +/// `Image::ExifTool::ConvertDuration` (ExifTool.pm:6877-6895). +/// +/// ```text +/// return '0 s' if $time == 0; +/// my $sign = ($time > 0 ? '' : (($time = -$time), '-')); +/// return sprintf("$sign%.2f s", $time) if $time < 30; +/// $time += 0.5; # to round off to nearest second +/// my $h = int($time / 3600); $time -= $h * 3600; +/// my $m = int($time / 60); $time -= $m * 60; +/// if ($h > 24) { my $d = int($h / 24); $h -= $d * 24; $sign = "$sign$d days "; } +/// return sprintf("$sign%d:%.2d:%.2d", $h, $m, int($time)); +/// ``` +/// +/// Note the `$h > 24` -- not `>= 24` -- so exactly one day of runtime prints as +/// `24:00:00` rather than `1 days 0:00:00`. +pub fn convert_duration(seconds: f64) -> String { + if seconds == 0.0 { + return "0 s".to_string(); + } + let (sign, mut time) = if seconds > 0.0 { + ("", seconds) + } else { + ("-", -seconds) + }; + if time < 30.0 { + return format!("{sign}{time:.2} s"); + } + time += 0.5; // round off to the nearest second + let mut hours = (time / 3600.0) as i64; + time -= hours as f64 * 3600.0; + let minutes = (time / 60.0) as i64; + time -= minutes as f64 * 60.0; + + let mut prefix = sign.to_string(); + if hours > 24 { + let days = hours / 24; + hours -= days * 24; + prefix = format!("{sign}{days} days "); + } + format!("{prefix}{hours}:{minutes:02}:{:02}", time as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_exiftools_apple_runtime_output() { + // Values quoted from `exiftool -a -G1 -s` over the sample corpus, with + // the RunTimeValue/RunTimeScale pair each was computed from. + // Apple_iPhone13Pro.jpg 235706184764708 / 1e9 + assert_eq!(convert_duration(235_706.184_764_708), "2 days 17:28:26"); + // Apple_iPhone11Pro.jpg 5805702950166 / 1e9 + assert_eq!(convert_duration(5_805.702_950_166), "1:36:46"); + // Apple_iPhone6s.jpg 1481457911333 / 1e9 + assert_eq!(convert_duration(1_481.457_911_333), "0:24:41"); + // Apple_iPadPro_12.9-inch_4th_generation.jpg 166430433427583 / 1e9 + assert_eq!(convert_duration(166_430.433_427_583), "1 days 22:13:50"); + } + + #[test] + fn handles_the_short_and_zero_branches() { + assert_eq!(convert_duration(0.0), "0 s"); + assert_eq!(convert_duration(12.345), "12.35 s"); + assert_eq!(convert_duration(-12.345), "-12.35 s"); + assert_eq!(convert_duration(-3600.0), "-1:00:00"); + } + + #[test] + fn one_full_day_stays_in_hours() { + // `$h > 24`, so 24 hours exactly does not become "1 days". + assert_eq!(convert_duration(24.0 * 3600.0), "24:00:00"); + assert_eq!(convert_duration(25.0 * 3600.0), "1 days 1:00:00"); + } +} diff --git a/src/core/formatters/mod.rs b/src/core/formatters/mod.rs index cc4fad3bb..cb37008d4 100644 --- a/src/core/formatters/mod.rs +++ b/src/core/formatters/mod.rs @@ -5,6 +5,7 @@ pub mod audio_encoding; pub mod cfa_pattern; +pub mod duration; pub mod exif_enums; pub mod exif_print_conv; pub mod exposure_program; @@ -24,6 +25,7 @@ pub mod ycbcr_subsampling; // Re-export main formatting functions for convenience pub use audio_encoding::audio_encoding_name; pub use cfa_pattern::decode_cfa_pattern; +pub use duration::convert_duration; pub use exif_enums::{ format_color_space, format_components_configuration, format_compression, format_contrast, format_custom_rendered, format_digital_zoom_ratio, format_exposure_mode, format_file_source, diff --git a/src/core/tiff_helpers.rs b/src/core/tiff_helpers.rs index ffc2acdea..7f69fefbf 100644 --- a/src/core/tiff_helpers.rs +++ b/src/core/tiff_helpers.rs @@ -513,7 +513,13 @@ pub fn parse_exif_subifd( ) { if let Ok(exif_tags) = parse_ifd(reader, offset, byte_order) { // Track MakerNote and InteroperabilityIFD pointer in EXIF IFD - let mut exif_makernote_data: Option<&[u8]> = None; + // An EXIF IFD may declare 0x927C more than once -- an editor that + // appends its own private block leaves the camera's in place, and + // ExifTool processes each entry in turn. Keeping only the last one + // meant `Apple_iPhone6.jpg`, whose second 0x927C is a UTF-16 JSON blob + // written by an editing app, reached the Apple parser with the wrong + // 142 bytes and reported nothing at all. + let mut exif_makernote_data: Vec<&[u8]> = Vec::new(); let mut interop_ifd_offset: Option = None; // First pass: convert tags and capture special pointers @@ -523,7 +529,7 @@ pub fn parse_exif_subifd( // Check for MakerNote in EXIF IFD (tag 0x927C) if *tag_id == MAKERNOTE { - exif_makernote_data = Some(bytes); + exif_makernote_data.push(bytes); } // Check for InteroperabilityIFDPointer (tag 0xA005) @@ -560,7 +566,7 @@ pub fn parse_exif_subifd( // is given the enclosing TIFF block as well as the payload, because a // MakerNote's value offsets are measured from the TIFF header and // routinely address bytes past the payload's declared end. - if let Some(makernote_bytes) = exif_makernote_data { + for makernote_bytes in exif_makernote_data { let ctx = makernote_context( reader, offset, diff --git a/src/parsers/tiff/makernotes/apple.rs b/src/parsers/tiff/makernotes/apple.rs index 917736e25..e46d48adf 100644 --- a/src/parsers/tiff/makernotes/apple.rs +++ b/src/parsers/tiff/makernotes/apple.rs @@ -1,260 +1,381 @@ -//! Apple (iPhone/iPad) MakerNote parser +//! Apple (iPhone / iPad) MakerNotes. //! -//! Parses Apple-specific EXIF MakerNote tags containing computational photography -//! settings, multi-camera data, and iOS-specific metadata. +//! # Where the directory is //! -//! ## Supported Features -//! - HDR processing mode and HDR Headroom/Gain -//! - Portrait Mode and depth data -//! - Live Photo status and video index -//! - Scene detection and Scene Flags -//! - Multi-camera lens identification -//! - Semantic Styles (Photographic Styles) with presets -//! - Smart HDR version -//! - Night Mode -//! - AF performance, confidence, and measured depth -//! - Signal-to-noise ratio metrics -//! - Color temperature and correction matrix -//! - Focus position and distance range -//! - Image processing flags and quality hints -//! - Photo identifiers and content IDs -//! - Green ghost mitigation status +//! `MakerNotes.pm:37-46` dispatches an EXIF 0x927C value beginning `Apple iOS\0` +//! to `%Image::ExifTool::Apple::Main` with //! -//! ## Format Support -//! Apple MakerNotes come in two formats: -//! 1. **IFD Format**: Standard TIFF IFD structure (older devices, standard EXIF) -//! 2. **BPLIST Format**: Binary plist with "Apple iOS\x00" header (newer devices) +//! ```text +//! Start => '$valuePtr + 14', +//! Base => '$start - 14', +//! ByteOrder => 'Unknown', +//! ``` //! -//! ## Architecture -//! Apple's MakerNotes use a proprietary binary format with Apple-specific tags. -//! Unlike traditional camera manufacturers, Apple stores significant computational -//! photography metadata including AI-powered scene analysis and processing flags. - -#![allow(dead_code)] -#![allow(unused_imports)] - -use crate::const_decoder; -use crate::io::EndianReader; -use crate::parsers::tiff::ifd_parser::{ByteOrder, IfdEntry}; -use crate::parsers::tiff::makernotes::shared::ifd_parser_base::{ - IfdParserConfig, parse_ifd_entries, -}; -use crate::parsers::tiff::makernotes::shared::value_extractors::{ - extract_i16_value, extract_i32_value, extract_string_with_byteorder, extract_u32_value, -}; +//! so the IFD begins **14** bytes into the value -- `Apple iOS\0` is ten bytes, +//! then two version bytes and the two-byte order marker -- and `Base` resolves +//! to the start of the value, which is what the entries' offsets are measured +//! from. A reader that starts the IFD at byte 10 instead reads the order marker +//! as an entry count and then decodes the first entry out of the count field and +//! the first tag: on `Apple_iPhone13Pro.jpg` that yields a single entry with tag +//! id 0x4d4d ("MM") which no table has, so the file produced *no* Apple tag at +//! all while ExifTool reported 28. +//! +//! `ByteOrder => 'Unknown'` is resolved by `Exif.pm:6982-6993`, which reads the +//! entry count in the enclosing file's order and flips only when it is +//! implausibly large; [`resolve_byte_order`] is that test. +//! +//! # The binary plists +//! +//! Four of the tags do not hold a value, they hold a `bplist00` blob: +//! `0x0003 RunTime` is a `SubDirectory` over `%Apple::RunTime` and +//! `0x0040`/`0x0041`/`0x0042` carry `ValueConv => \&ConvertPLIST`. Both are +//! handled by [`super::shared::binary_plist`], which transcribes +//! `Image::ExifTool::PLIST`. +//! +//! # Tags this reports +//! +//! Every row of [`APPLE_MAIN`] is one entry of `%Apple::Main` (Apple.pm:30-320), +//! with that entry's `Writable` format and its `PrintConv` verbatim. The +//! table's `Unknown => 1` entries (`AEMatrix`, `ImageProcessingFlags`, +//! `QualityHint`, `ImageCaptureRequestID`, `SceneFlags`, +//! `SignalToNoiseRatioType`, `ColorCorrectionMatrix`, +//! `GreenGhostMitigationStatus` and the four `Apple_0x00xx` placeholders) are +//! absent on purpose: ExifTool reports them only under `-u`. + use std::collections::HashMap; use super::shared::MakerNoteParser; -use super::shared::array_extractors::extract_i16_array; - -// ============================================================================ -// APPLE MAKERNOTE TAG IDS -// ============================================================================ -// Tag IDs based on ExifTool's Apple.pm and reverse-engineering efforts. -// Apple uses a mix of IFD-based tags and binary plist structures. - -// Core identification tags -const APPLE_MAKERNOTE_VERSION: u16 = 0x0001; // MakerNote version string -const APPLE_AE_MATRIX: u16 = 0x0002; // AE (Auto Exposure) matrix data -const APPLE_RUN_TIME: u16 = 0x0003; // Runtime information (plist) -const APPLE_AE_STABLE: u16 = 0x0004; // AE stability flag -const APPLE_AE_TARGET: u16 = 0x0005; // AE target exposure value -const APPLE_AE_AVERAGE: u16 = 0x0006; // AE average value -const APPLE_AF_STABLE: u16 = 0x0007; // AF stability flag -const APPLE_ACCELERATION_VECTOR: u16 = 0x0008; // Device orientation/acceleration - -// HDR and image processing tags -const APPLE_HDR_IMAGE_TYPE: u16 = 0x000A; // HDR processing mode -const APPLE_BURST_UUID: u16 = 0x000B; // Burst mode unique ID -const APPLE_FOCUS_DISTANCE_RANGE: u16 = 0x000C; // Focus distance range (min/max) -const APPLE_OIS_MODE: u16 = 0x000F; // Optical Image Stabilization mode - -// Content and image identification -const APPLE_CONTENT_IDENTIFIER: u16 = 0x0011; // Media content identifier (UUID) -const APPLE_IMAGE_CAPTURE_TYPE: u16 = 0x0014; // Type of capture (photo/portrait/etc.) -const APPLE_IMAGE_UNIQUE_ID: u16 = 0x0015; // Unique image identifier -const APPLE_LIVE_PHOTO_VIDEO_INDEX: u16 = 0x0017; // Live Photo video frame index -const APPLE_IMAGE_PROCESSING_FLAGS: u16 = 0x0019; // Processing flags bitmask -const APPLE_QUALITY_HINT: u16 = 0x001A; // Quality hint value - -// Noise and signal analysis -const APPLE_LUMINANCE_NOISE_AMPLITUDE: u16 = 0x001D; // Measured luminance noise -const APPLE_PHOTOS_APP_FEATURE_FLAGS: u16 = 0x001F; // Photos.app feature flags - -// HDR headroom and capture request -const APPLE_IMAGE_CAPTURE_REQUEST_ID: u16 = 0x0020; // Capture request identifier -const APPLE_HDR_HEADROOM: u16 = 0x0021; // HDR headroom value (EV) -const APPLE_AF_PERFORMANCE: u16 = 0x0023; // AF performance metrics - -// Scene analysis -const APPLE_SCENE_FLAGS: u16 = 0x0025; // Scene detection flags -const APPLE_SIGNAL_TO_NOISE_RATIO_TYPE: u16 = 0x0026; // SNR measurement type -const APPLE_SIGNAL_TO_NOISE_RATIO: u16 = 0x0027; // Signal-to-noise ratio value - -// Photo identifiers and camera info -const APPLE_PHOTO_IDENTIFIER: u16 = 0x002B; // Photo identifier string -const APPLE_COLOR_TEMPERATURE: u16 = 0x002D; // Color temperature (Kelvin) -const APPLE_CAMERA_TYPE: u16 = 0x002E; // Camera type identifier -const APPLE_FOCUS_POSITION: u16 = 0x002F; // Focus position value -const APPLE_HDR_GAIN: u16 = 0x0030; // HDR gain value - -// Front-facing camera flag -const APPLE_FRONT_FACING_CAMERA: u16 = 0x0032; - -// Advanced AF and processing tags -const APPLE_AF_MEASURED_DEPTH: u16 = 0x0038; // AF measured depth (LiDAR) -const APPLE_AF_CONFIDENCE: u16 = 0x003D; // AF confidence level -const APPLE_COLOR_CORRECTION_MATRIX: u16 = 0x003E; // Color correction matrix -const APPLE_GREEN_GHOST_MITIGATION_STATUS: u16 = 0x003F; // Lens flare mitigation - -// Semantic Style tags (Photographic Styles - iOS 15+) -const APPLE_SEMANTIC_STYLE: u16 = 0x0040; // Active photographic style -const APPLE_SEMANTIC_STYLE_RENDERING_VER: u16 = 0x0041; // Style rendering version -const APPLE_SEMANTIC_STYLE_PRESET: u16 = 0x0042; // Style preset identifier - -// Apple signature markers -const APPLE_SIGNATURE: &[u8] = b"Apple iOS"; -const BPLIST_MAGIC: &[u8] = b"bplist"; - -// ============================================================================ -// TAG VALUE DECODERS -// ============================================================================ -// Decoders for Apple MakerNote tag values. These convert numeric values to -// human-readable strings based on ExifTool's Apple.pm definitions. - -// Decodes Apple HDR image type -// Values observed from various iPhone models -const_decoder! { - pub DECODE_HDR_TYPE, i16, [ - (0, "Off"), - (1, "HDR"), - (2, "HDR (Original)"), - (3, "Auto HDR"), - (4, "Smart HDR"), - (5, "Smart HDR 2"), - (6, "Smart HDR 3"), - (7, "Smart HDR 4"), - (8, "Smart HDR 5"), - ] -} - -// Decodes Portrait Mode effect type (Depth Effect) -const_decoder! { - pub DECODE_PORTRAIT_MODE, i16, [ - (0, "Off"), - (1, "Natural Light"), - (2, "Studio Light"), - (3, "Contour Light"), - (4, "Stage Light"), - (5, "Stage Light Mono"), - (6, "High-Key Light Mono"), - ] -} - -// Decodes scene detection type from AI analysis -const_decoder! { - pub DECODE_SCENE_TYPE, i16, [ - (0, "None"), - (1, "Sunset/Sunrise"), - (2, "Blue Sky"), - (3, "Snow"), - (4, "Foliage"), - (5, "Beach"), - (6, "Night"), - (7, "Fireworks"), - (8, "Food"), - (9, "Pet"), - (10, "Document"), - (11, "QR Code"), - (12, "Portrait"), - ] +use super::shared::binary_plist; +use super::shared::table_ifd::{self, Conv, OlyVal, TagDef, ftype, read_ifd}; +use crate::parsers::tiff::ifd_parser::ByteOrder; +use crate::parsers::tiff::makernotes::makernote_context::MakerNoteContext; + +/// `Condition => '$$valPt =~ /^Apple iOS\0/'` (MakerNotes.pm:39). +const APPLE_SIGNATURE: &[u8] = b"Apple iOS\x00"; + +/// `Start => '$valuePtr + 14'` (MakerNotes.pm:42). +const IFD_START: usize = 14; + +/// `%Image::ExifTool::Apple::RunTime` (Apple.pm:324-345): a `bplist00` +/// dictionary whose keys name tags. +const RUNTIME_KEYS: &[(&str, &str)] = &[ + ("timescale", "RunTimeScale"), + ("epoch", "RunTimeEpoch"), + ("value", "RunTimeValue"), + ("flags", "RunTimeFlags"), +]; + +/// `RunTimeFlags`' `PrintConv => { BITMASK => { ... } }` (Apple.pm:336-343). +const RUNTIME_FLAG_BITS: &[(u32, &str)] = &[ + (0, "Valid"), + (1, "Has been rounded"), + (2, "Positive infinity"), + (3, "Negative infinity"), + (4, "Indefinite"), +]; + +/// `%Image::ExifTool::Apple::Main` (Apple.pm:30-320), default-visible entries. +static APPLE_MAIN: &[TagDef] = &[ + // 0x0001 Writable => 'int32s' + TagDef::raw(0x0001, "MakerNoteVersion"), + // 0x0004 PrintConv => { 0 => 'No', 1 => 'Yes' } + TagDef::lookup(0x0004, "AEStable", &[(0, "No"), (1, "Yes")]), + TagDef::raw(0x0005, "AETarget"), + TagDef::raw(0x0006, "AEAverage"), + TagDef::lookup(0x0007, "AFStable", &[(0, "No"), (1, "Yes")]), + // 0x0008 Writable => 'rational64s', Count => 3 + TagDef::raw(0x0008, "AccelerationVector"), + // 0x000a PrintConv => { 3 => 'HDR Image', 4 => 'Original Image' } + TagDef::lookup( + 0x000a, + "HDRImageType", + &[(3, "HDR Image"), (4, "Original Image")], + ), + TagDef::text(0x000b, "BurstUUID"), + TagDef { + id: 0x000c, + name: "FocusDistanceRange", + force_type: None, + conv: Conv::Func(print_focus_distance_range), + }, + // 0x000f has no PrintConv in Apple.pm -- "seen: 2,3,5" is a comment + TagDef::raw(0x000f, "OISMode"), + TagDef::text(0x0011, "ContentIdentifier"), + // 0x0014 PrintConv (Apple.pm:127-132, #forum15096 / #forum16044) + TagDef::lookup( + 0x0014, + "ImageCaptureType", + &[ + (1, "ProRAW"), + (2, "Portrait"), + (10, "Photo"), + (11, "Manual Focus"), + (12, "Scene"), + ], + ), + TagDef::text(0x0015, "ImageUniqueID"), + // 0x0017 has no Writable: the stored field type governs, and real files + // use both int32s and int64u for it. + TagDef::raw(0x0017, "LivePhotoVideoIndex"), + TagDef::raw(0x001d, "LuminanceNoiseAmplitude"), + TagDef::raw(0x001f, "PhotosAppFeatureFlags"), + TagDef::raw(0x0021, "HDRHeadroom"), + TagDef { + id: 0x0023, + name: "AFPerformance", + force_type: None, + conv: Conv::Func(print_af_performance), + }, + TagDef::raw(0x0027, "SignalToNoiseRatio"), + TagDef::text(0x002b, "PhotoIdentifier"), + TagDef::raw(0x002d, "ColorTemperature"), + // 0x002e PrintConv (Apple.pm:219-223) + TagDef::lookup( + 0x002e, + "CameraType", + &[(0, "Back Wide Angle"), (1, "Back Normal"), (6, "Front")], + ), + TagDef::raw(0x002f, "FocusPosition"), + TagDef::raw(0x0030, "HDRGain"), + TagDef::raw(0x0038, "AFMeasuredDepth"), + TagDef::raw(0x003d, "AFConfidence"), +]; + +/// `0x000c FocusDistanceRange`'s PrintConv (Apple.pm:98-101): +/// +/// ```text +/// my @a = split ' ', $val; +/// sprintf('%.2f - %.2f m', $a[0] <= $a[1] ? @a : reverse @a); +/// ``` +/// +/// `$val` is the value form, so a `rational64s` pair has already become two +/// quotients by the time the sprintf sees it. +fn print_focus_distance_range(val: &OlyVal) -> Option { + let OlyVal::Rat(r) = val else { return None }; + if r.len() < 2 { + return None; + } + let q = |(n, d): (i64, i64)| { + if d == 0 { + None + } else { + Some(n as f64 / d as f64) + } + }; + let (a, b) = (q(r[0])?, q(r[1])?); + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + Some(format!("{lo:.2} - {hi:.2} m")) } -// Decodes semantic style (Photographic Style - iOS 15+) -const_decoder! { - pub DECODE_SEMANTIC_STYLE, i16, [ - (0, "Standard"), - (1, "Rich Contrast"), - (2, "Vibrant"), - (3, "Warm"), - (4, "Cool"), - ] +/// `0x0023 AFPerformance`'s PrintConv (Apple.pm:187): +/// +/// ```text +/// my @a=split " ",$val; sprintf("%d %d %d",$a[0],$a[1]>>28,$a[1]&0xfffffff) +/// ``` +/// +/// Perl's `>>` and `&` promote their operands to a 64-bit unsigned, so a +/// negative second element shifts as its two's-complement bit pattern rather +/// than sign-extending. +fn print_af_performance(val: &OlyVal) -> Option { + let ints = val.ints()?; + if ints.len() < 2 { + return None; + } + let b = ints[1] as u64; + Some(format!("{} {} {}", ints[0], b >> 28, b & 0xfff_ffff)) } -// Decodes lens model for multi-camera iPhones -const_decoder! { - pub DECODE_LENS_MODEL, i16, [ - (0, "Wide (Main Camera)"), - (1, "Telephoto"), - (2, "Ultra Wide"), - (3, "Front Camera"), - (4, "Telephoto 2x"), - (5, "Telephoto 3x"), - (6, "Telephoto 5x"), - ] +/// `ByteOrder => 'Unknown'` as `Exif.pm:6982-6993` resolves it: read the entry +/// count in the enclosing file's order, and flip only when the high byte is +/// non-zero *and* larger than the low byte, which no plausible entry count is. +fn resolve_byte_order(data: &[u8], ifd_start: usize, file_order: ByteOrder) -> ByteOrder { + let Some(bytes) = data.get(ifd_start..ifd_start + 2) else { + return file_order; + }; + let num = match file_order { + ByteOrder::BigEndian => u16::from_be_bytes([bytes[0], bytes[1]]), + ByteOrder::LittleEndian => u16::from_le_bytes([bytes[0], bytes[1]]), + }; + if num & 0xff00 != 0 && (num >> 8) > (num & 0xff) { + match file_order { + ByteOrder::BigEndian => ByteOrder::LittleEndian, + ByteOrder::LittleEndian => ByteOrder::BigEndian, + } + } else { + file_order + } } -// Decodes camera type identifier -const_decoder! { - pub DECODE_CAMERA_TYPE, i16, [ - (1, "Back Normal"), - (2, "Back Wide"), - (3, "Back Ultra Wide"), - (4, "Back Telephoto"), - (5, "Back Telephoto 2x"), - (6, "Front"), - (7, "Front TrueDepth"), - ] +/// `ExtractObject`'s tag-name generation for a dictionary key with no entry in +/// the table (PLIST.pm:363-368): +/// +/// ```text +/// $name =~ s/([^A-Za-z])([a-z])/$1\u$2/g; # capitalize words +/// $name =~ tr/-_a-zA-Z0-9//dc; # remove illegal characters +/// $name = 'Tag'.ucfirst($name) if length($name) < 2 or $name =~ /^[-0-9]/; +/// ... { Name => ucfirst($name) } +/// ``` +fn generated_tag_name(key: &str) -> String { + let chars: Vec = key.chars().collect(); + let mut name = String::with_capacity(key.len()); + for (i, &c) in chars.iter().enumerate() { + let prev_non_alpha = i > 0 && !chars[i - 1].is_ascii_alphabetic(); + if prev_non_alpha && c.is_ascii_lowercase() { + name.push(c.to_ascii_uppercase()); + } else { + name.push(c); + } + } + name.retain(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + let needs_prefix = name.chars().count() < 2 + || name + .chars() + .next() + .is_some_and(|c| c == '-' || c.is_ascii_digit()); + if needs_prefix { + let mut c = name.chars(); + name = match c.next() { + Some(f) => format!("Tag{}{}", f.to_ascii_uppercase(), c.as_str()), + None => "Tag".to_string(), + }; + } + let mut c = name.chars(); + match c.next() { + Some(f) => format!("{}{}", f.to_ascii_uppercase(), c.as_str()), + None => name, + } } -// Decodes OIS (Optical Image Stabilization) mode -const_decoder! { - pub DECODE_OIS_MODE, i16, [ - (0, "Off"), - (1, "On"), - (2, "Cinematic Mode"), - (3, "Action Mode"), - ] +/// `ExifTool::DecodeBits` (ExifTool.pm:6385-6407) over a 32-bit value. +fn decode_bits(value: u64, lookup: &[(u32, &str)]) -> String { + let mut parts = Vec::new(); + for i in 0..32 { + if value & (1u64 << i) == 0 { + continue; + } + match lookup.iter().find(|(bit, _)| *bit == i) { + Some((_, name)) => parts.push((*name).to_string()), + None => parts.push(format!("[{i}]")), + } + } + if parts.is_empty() { + return "(none)".to_string(); + } + parts.join(", ") } -// Decodes image capture type -const_decoder! { - pub DECODE_IMAGE_CAPTURE_TYPE, i16, [ - (0, "Photo"), - (1, "Portrait"), - (2, "Panorama"), - (3, "Live Photo"), - (4, "Night Mode"), - (5, "ProRAW"), - (6, "Cinematic"), - (10, "Screenshot"), - ] +/// Descend into `0x0003 RunTime`: a `bplist00` dictionary each of whose keys +/// names a tag in `%Apple::RunTime`. +fn read_runtime(blob: &[u8], tags: &mut HashMap) { + for (key, obj) in binary_plist::parse_dict(blob) { + // "next if ref($obj) eq 'HASH'" (PLIST.pm:362) -- and an aggregate has + // no scalar rendering to report either way. + let Some(scalar) = obj.scalar() else { continue }; + match RUNTIME_KEYS + .iter() + .find(|(k, _)| *k == key) + .map(|(_, name)| *name) + { + Some("RunTimeFlags") => { + let binary_plist::PlistValue::Int(bits) = obj else { + continue; + }; + tags.insert( + "Apple:RunTimeFlags".to_string(), + decode_bits(bits, RUNTIME_FLAG_BITS), + ); + } + Some(name) => { + tags.insert(format!("Apple:{name}"), scalar); + } + // A key the table does not name still becomes a tag, under the name + // ExifTool generates for it (PLIST.pm:359-372). + None => { + tags.insert(format!("Apple:{}", generated_tag_name(&key)), scalar); + } + } + } } -// Decodes green ghost mitigation status -const_decoder! { - pub DECODE_GREEN_GHOST_MITIGATION, i16, [ - (0, "Off"), - (1, "Applied"), - (2, "Detected"), - ] +/// Read the Apple MakerNote IFD out of `data`, whose index 0 is the first byte +/// of the `Apple iOS\0` signature -- the base its entries' offsets are measured +/// from (`Base => '$start - 14'`). +fn parse_apple_ifd(data: &[u8], file_order: ByteOrder, tags: &mut HashMap) { + if data.len() < IFD_START + 2 || !data.starts_with(APPLE_SIGNATURE) { + return; + } + let order = resolve_byte_order(data, IFD_START, file_order); + let Some(entries) = read_ifd(data, IFD_START, order) else { + return; + }; + let floor = IFD_START + 2 + entries.len() * 12 + 4; + for entry in &entries { + match entry.tag_id { + // 0x0003 SubDirectory => { TagTable => 'Apple::RunTime' } + 0x0003 => { + if let Some(blob) = raw_bytes(data, entry, order, floor) { + read_runtime(&blob, tags); + } + } + // ValueConv => \&ConvertPLIST (Apple.pm:276, :280, :284) + 0x0040 | 0x0041 | 0x0042 => { + let name = match entry.tag_id { + 0x0040 => "SemanticStyle", + 0x0041 => "SemanticStyleRenderingVer", + _ => "SemanticStylePreset", + }; + if let Some(blob) = raw_bytes(data, entry, order, floor) + && let Some(printed) = binary_plist::convert_plist(&blob) + { + tags.insert(format!("Apple:{name}"), printed); + } + } + id => { + let Some(def) = APPLE_MAIN.iter().find(|d| d.id == id) else { + continue; + }; + let Some(val) = table_ifd::decode_entry_with_floor( + data, + entry, + Some(0), + order, + def.force_type, + floor, + ) else { + continue; + }; + if let Some(printed) = table_ifd::apply_conv(def, &val) { + tags.insert(format!("Apple:{}", def.name), printed); + } + } + } + } } -// Decodes signal-to-noise ratio measurement type -const_decoder! { - pub DECODE_SNR_TYPE, i16, [ - (0, "None"), - (1, "Luminance"), - (2, "Chrominance"), - (3, "Combined"), - ] +/// The entry's payload as raw bytes, for the tags whose value is a whole +/// `bplist00` blob rather than a number. +fn raw_bytes( + data: &[u8], + entry: &table_ifd::RawEntry, + order: ByteOrder, + floor: usize, +) -> Option> { + // Forcing `undef` keeps every byte: the blob is not a string and must not + // be cut at its first NUL. + match table_ifd::decode_entry_with_floor( + data, + entry, + Some(0), + order, + Some(ftype::TIFF_UNDEF), + floor, + ) { + Some(OlyVal::Bytes(b)) => Some(b), + _ => None, + } } -/// Apple MakerNote parser implementation -/// -/// Supports two Apple MakerNote formats: -/// 1. IFD Format - Standard TIFF IFD structure used in older devices -/// 2. BPLIST Format - Binary plist with "Apple iOS\0" header (newer devices) +/// Apple MakerNote parser. pub struct AppleParser; impl Default for AppleParser { @@ -264,488 +385,10 @@ impl Default for AppleParser { } impl AppleParser { - /// Creates a new Apple parser instance + /// Creates a new Apple parser instance. pub fn new() -> Self { AppleParser } - - /// Checks if the data starts with a binary plist header - /// - /// Apple MakerNotes on newer devices use "Apple iOS\0" followed by bplist data. - fn is_bplist_format(data: &[u8]) -> bool { - // Check for "Apple iOS\0" header followed by bplist - if data.len() >= 16 - && &data[0..9] == APPLE_SIGNATURE - && data[9] == 0 - && &data[10..16] == BPLIST_MAGIC - { - return true; - } - - // Also check for direct bplist format - if data.len() >= 6 && &data[0..6] == BPLIST_MAGIC { - return true; - } - - false - } - - /// Parse BPLIST format Apple MakerNotes - /// - /// BPLIST format stores key-value pairs in Apple's binary property list format. - /// This is a simplified parser that extracts common keys. - fn parse_bplist(&self, data: &[u8], tags: &mut HashMap) -> Result<(), String> { - // Determine offset to bplist data - let bplist_offset = if data.len() >= 10 && &data[0..9] == APPLE_SIGNATURE && data[9] == 0 { - 10 // Skip "Apple iOS\0" - } else if data.len() >= 6 && &data[0..6] == BPLIST_MAGIC { - 0 // Direct bplist - } else { - return Err("Invalid BPLIST header".to_string()); - }; - - let bplist_data = &data[bplist_offset..]; - - // Verify bplist magic - if bplist_data.len() < 8 || &bplist_data[0..6] != BPLIST_MAGIC { - return Err("Missing bplist magic".to_string()); - } - - // Add format indicator tag - tags.insert("Apple:MakerNoteFormat".to_string(), "BPLIST".to_string()); - - // Extract bplist version (bytes 6-7 after magic) - let version = String::from_utf8_lossy(&bplist_data[6..8]); - tags.insert("Apple:BPLISTVersion".to_string(), version.to_string()); - - // Binary plist parsing is complex - for now we note that full parsing - // would require implementing the full bplist specification. - // The plist trailer is in the last 32 bytes. - if bplist_data.len() >= 40 { - let trailer_offset = bplist_data.len() - 32; - let trailer = &bplist_data[trailer_offset..]; - - // Trailer: [unused:6][offset_size:1][ref_size:1][num_objects:8][top:8][offset_table:8] - let offset_size = trailer[6] as usize; - let ref_size = trailer[7] as usize; - - if offset_size > 0 && offset_size <= 8 && ref_size > 0 && ref_size <= 8 { - // Read number of objects (big-endian u64 at offset 8-15 of trailer) - let num_objects = Self::read_be_u64(&trailer[8..16]); - tags.insert("Apple:BPLISTObjects".to_string(), num_objects.to_string()); - } - } - - Ok(()) - } - - /// Read a big-endian u64 from bytes - fn read_be_u64(bytes: &[u8]) -> u64 { - if bytes.len() < 8 { - return 0; - } - u64::from_be_bytes([ - bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], - ]) - } - - /// Parse a single IFD entry and extract tag value - /// - /// Uses the registry-based approach for consistent decoding of tag values. - fn parse_entry( - &self, - entry: &IfdEntry, - data: &[u8], - byte_order: ByteOrder, - tags: &mut HashMap, - ) { - use super::registries::apple::{apple_registry, decode_facing_camera, decode_night_mode}; - - let registry = apple_registry(); - let tag_id = entry.tag_id; - - match tag_id { - // ================================================================ - // INTEGER TAGS WITH DECODERS - // ================================================================ - - // HDR-related tags - APPLE_HDR_IMAGE_TYPE => { - if let Some(value) = extract_i16_value(entry, data, byte_order) { - let decoded = DECODE_HDR_TYPE.decode(value); - tags.insert("Apple:HDRImageType".to_string(), decoded.to_string()); - } - } - - // OIS Mode - APPLE_OIS_MODE => { - if let Some(value) = extract_i16_value(entry, data, byte_order) { - let decoded = DECODE_OIS_MODE.decode(value); - tags.insert("Apple:OISMode".to_string(), decoded.to_string()); - } - } - - // Image capture type - APPLE_IMAGE_CAPTURE_TYPE => { - if let Some(value) = extract_i16_value(entry, data, byte_order) { - let decoded = DECODE_IMAGE_CAPTURE_TYPE.decode(value); - tags.insert("Apple:ImageCaptureType".to_string(), decoded.to_string()); - } - } - - // Camera type - APPLE_CAMERA_TYPE => { - if let Some(value) = extract_i16_value(entry, data, byte_order) { - let decoded = DECODE_CAMERA_TYPE.decode(value); - tags.insert("Apple:CameraType".to_string(), decoded.to_string()); - } - } - - // Semantic style (Photographic Styles) - APPLE_SEMANTIC_STYLE => { - if let Some(value) = extract_i16_value(entry, data, byte_order) { - let decoded = DECODE_SEMANTIC_STYLE.decode(value); - tags.insert("Apple:SemanticStyle".to_string(), decoded.to_string()); - } - } - - // Green ghost mitigation - APPLE_GREEN_GHOST_MITIGATION_STATUS => { - if let Some(value) = extract_i16_value(entry, data, byte_order) { - let decoded = DECODE_GREEN_GHOST_MITIGATION.decode(value); - tags.insert( - "Apple:GreenGhostMitigationStatus".to_string(), - decoded.to_string(), - ); - } - } - - // SNR type - APPLE_SIGNAL_TO_NOISE_RATIO_TYPE => { - if let Some(value) = extract_i16_value(entry, data, byte_order) { - let decoded = DECODE_SNR_TYPE.decode(value); - tags.insert( - "Apple:SignalToNoiseRatioType".to_string(), - decoded.to_string(), - ); - } - } - - // ================================================================ - // INTEGER TAGS (raw values) - // ================================================================ - - // AE/AF stability flags (boolean-like) - APPLE_AE_STABLE => { - if let Some(value) = extract_i16_value(entry, data, byte_order) { - let decoded = if value != 0 { "Yes" } else { "No" }; - tags.insert("Apple:AEStable".to_string(), decoded.to_string()); - } - } - - APPLE_AF_STABLE => { - if let Some(value) = extract_i16_value(entry, data, byte_order) { - let decoded = if value != 0 { "Yes" } else { "No" }; - tags.insert("Apple:AFStable".to_string(), decoded.to_string()); - } - } - - // Front-facing camera flag - APPLE_FRONT_FACING_CAMERA => { - if let Some(value) = extract_i16_value(entry, data, byte_order) { - tags.insert( - "Apple:FrontFacingCamera".to_string(), - decode_facing_camera(value), - ); - } - } - - // AE target/average (exposure values) - APPLE_AE_TARGET => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - tags.insert("Apple:AETarget".to_string(), value.to_string()); - } - } - - APPLE_AE_AVERAGE => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - tags.insert("Apple:AEAverage".to_string(), value.to_string()); - } - } - - // Color temperature (Kelvin) - APPLE_COLOR_TEMPERATURE => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - tags.insert("Apple:ColorTemperature".to_string(), format!("{} K", value)); - } - } - - // Focus position (integer) - APPLE_FOCUS_POSITION => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - tags.insert("Apple:FocusPosition".to_string(), value.to_string()); - } - } - - // AF confidence (percentage-like) - APPLE_AF_CONFIDENCE => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - tags.insert("Apple:AFConfidence".to_string(), value.to_string()); - } - } - - // AF measured depth (millimeters, from LiDAR) - APPLE_AF_MEASURED_DEPTH => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - tags.insert("Apple:AFMeasuredDepth".to_string(), format!("{} mm", value)); - } - } - - // Signal-to-noise ratio - APPLE_SIGNAL_TO_NOISE_RATIO => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - // SNR is often stored as fixed-point, display as decimal - let snr_db = (value as f64) / 100.0; - tags.insert( - "Apple:SignalToNoiseRatio".to_string(), - format!("{:.2} dB", snr_db), - ); - } - } - - // HDR headroom (EV) - APPLE_HDR_HEADROOM => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - let headroom_ev = (value as f64) / 1000.0; - tags.insert( - "Apple:HDRHeadroom".to_string(), - format!("{:.2} EV", headroom_ev), - ); - } - } - - // HDR gain - APPLE_HDR_GAIN => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - let gain = (value as f64) / 1000.0; - tags.insert("Apple:HDRGain".to_string(), format!("{:.3}", gain)); - } - } - - // Luminance noise amplitude - APPLE_LUMINANCE_NOISE_AMPLITUDE => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - let amplitude = (value as f64) / 10000.0; - tags.insert( - "Apple:LuminanceNoiseAmplitude".to_string(), - format!("{:.4}", amplitude), - ); - } - } - - // Quality hint - APPLE_QUALITY_HINT => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - tags.insert("Apple:QualityHint".to_string(), value.to_string()); - } - } - - // Image processing flags (bitmask) - APPLE_IMAGE_PROCESSING_FLAGS => { - if let Some(value) = extract_u32_value(entry, data, byte_order) { - tags.insert( - "Apple:ImageProcessingFlags".to_string(), - format!("0x{:08X}", value), - ); - } - } - - // Photos app feature flags - APPLE_PHOTOS_APP_FEATURE_FLAGS => { - if let Some(value) = extract_u32_value(entry, data, byte_order) { - tags.insert( - "Apple:PhotosAppFeatureFlags".to_string(), - format!("0x{:08X}", value), - ); - } - } - - // Scene flags - APPLE_SCENE_FLAGS => { - if let Some(value) = extract_u32_value(entry, data, byte_order) { - tags.insert("Apple:SceneFlags".to_string(), format!("0x{:08X}", value)); - } - } - - // AF performance metrics - APPLE_AF_PERFORMANCE => { - if let Some(value) = extract_u32_value(entry, data, byte_order) { - tags.insert( - "Apple:AFPerformance".to_string(), - format!("0x{:08X}", value), - ); - } - } - - // Semantic style versions - APPLE_SEMANTIC_STYLE_RENDERING_VER => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - tags.insert( - "Apple:SemanticStyleRenderingVer".to_string(), - value.to_string(), - ); - } - } - - APPLE_SEMANTIC_STYLE_PRESET => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - tags.insert("Apple:SemanticStylePreset".to_string(), value.to_string()); - } - } - - // Live Photo video index - APPLE_LIVE_PHOTO_VIDEO_INDEX => { - if let Some(value) = extract_i32_value(entry, data, byte_order) { - tags.insert("Apple:LivePhotoVideoIndex".to_string(), value.to_string()); - // Indicate this is a Live Photo - tags.insert("Apple:LivePhoto".to_string(), "Yes".to_string()); - } - } - - // ================================================================ - // STRING TAGS - // ================================================================ - - // MakerNote version - APPLE_MAKERNOTE_VERSION => { - if let Some(s) = extract_string_with_byteorder(entry, data, byte_order) { - tags.insert("Apple:MakerNoteVersion".to_string(), s); - } - } - - // Burst UUID - APPLE_BURST_UUID => { - if let Some(s) = extract_string_with_byteorder(entry, data, byte_order) { - tags.insert("Apple:BurstUUID".to_string(), s); - } - } - - // Content identifier (media UUID) - APPLE_CONTENT_IDENTIFIER => { - if let Some(s) = extract_string_with_byteorder(entry, data, byte_order) { - tags.insert("Apple:ContentIdentifier".to_string(), s); - } - } - - // Image unique ID - APPLE_IMAGE_UNIQUE_ID => { - if let Some(s) = extract_string_with_byteorder(entry, data, byte_order) { - tags.insert("Apple:ImageUniqueID".to_string(), s); - } - } - - // Photo identifier - APPLE_PHOTO_IDENTIFIER => { - if let Some(s) = extract_string_with_byteorder(entry, data, byte_order) { - tags.insert("Apple:PhotoIdentifier".to_string(), s); - } - } - - // Image capture request ID - APPLE_IMAGE_CAPTURE_REQUEST_ID => { - if let Some(s) = extract_string_with_byteorder(entry, data, byte_order) { - tags.insert("Apple:ImageCaptureRequestIdentifier".to_string(), s); - } - } - - // ================================================================ - // ARRAY/COMPLEX TAGS - // ================================================================ - - // Focus distance range (min/max in meters) - APPLE_FOCUS_DISTANCE_RANGE => { - if let Some(values) = extract_i16_array(entry, data, byte_order) - && values.len() >= 2 - { - let near = (values[0] as f64) / 100.0; - let far = (values[1] as f64) / 100.0; - tags.insert( - "Apple:FocusDistanceRange".to_string(), - format!("{:.2} - {:.2} m", near, far), - ); - } - } - - // Acceleration vector (X, Y, Z) - APPLE_ACCELERATION_VECTOR => { - if let Some(values) = extract_i16_array(entry, data, byte_order) - && values.len() >= 3 - { - // Values are typically in fixed-point format - let x = (values[0] as f64) / 1000.0; - let y = (values[1] as f64) / 1000.0; - let z = (values[2] as f64) / 1000.0; - tags.insert( - "Apple:AccelerationVector".to_string(), - format!("({:.3}, {:.3}, {:.3})", x, y, z), - ); - } - } - - // AE matrix (complex array) - APPLE_AE_MATRIX => { - if let Some(values) = extract_i16_array(entry, data, byte_order) - && !values.is_empty() - { - // Format as array of values - let formatted: Vec = values.iter().map(|v| v.to_string()).collect(); - tags.insert("Apple:AEMatrix".to_string(), formatted.join(" ")); - } - } - - // Color correction matrix - APPLE_COLOR_CORRECTION_MATRIX => { - if let Some(values) = extract_i16_array(entry, data, byte_order) - && !values.is_empty() - { - // 3x3 matrix stored as 9 values - let formatted: Vec = values - .iter() - .map(|v| format!("{:.4}", (*v as f64) / 10000.0)) - .collect(); - tags.insert( - "Apple:ColorCorrectionMatrix".to_string(), - formatted.join(" "), - ); - } - } - - // RunTime (complex plist structure, store as raw for now) - APPLE_RUN_TIME => { - // RunTime is typically a binary plist embedded in the tag - // For now, indicate it's present - if entry.value_count > 0 { - tags.insert("Apple:RunTime".to_string(), "(binary plist)".to_string()); - } - } - - // ================================================================ - // FALLBACK: Unknown tags - // ================================================================ - _ => { - // For unknown tags, check if they're in the registry for a name - if let Some(tag_name) = registry.get_tag_name(tag_id) { - // Try to extract as integer or string - if let Some(value) = extract_i32_value(entry, data, byte_order) { - tags.insert(format!("Apple:{}", tag_name), value.to_string()); - } else if let Some(s) = extract_string_with_byteorder(entry, data, byte_order) { - tags.insert(format!("Apple:{}", tag_name), s); - } - } - // Unknown tags not in registry are silently skipped - } - } - } } impl MakerNoteParser for AppleParser { @@ -763,184 +406,141 @@ impl MakerNoteParser for AppleParser { byte_order: ByteOrder, tags: &mut HashMap, ) -> Result<(), String> { - // Check if this is BPLIST format - if Self::is_bplist_format(data) { - return self.parse_bplist(data, tags); - } - - // Otherwise, parse as standard IFD format - let config = IfdParserConfig { - signature: Some(APPLE_SIGNATURE), - signature_offset: 10, // "Apple iOS" (9) + 1 padding byte = 10 - max_entries: 500, - }; + parse_apple_ifd(data, byte_order, tags); + Ok(()) + } - parse_ifd_entries(data, byte_order, &config, |entry, _ifd_data| { - // Pass full data buffer to parse_entry as it expects absolute offsets - self.parse_entry(entry, data, byte_order, tags); - }) + fn parse_with_context( + &self, + ctx: &MakerNoteContext<'_>, + byte_order: ByteOrder, + _model: Option<&str>, + tags: &mut HashMap, + ) -> Result<(), String> { + // An Apple entry's offsets are measured from the start of the MakerNote + // value, but nothing bounds them by its declared length, so the window + // -- the payload extended to the end of the enclosing TIFF block, same + // index 0 -- is the reach ExifTool has. + parse_apple_ifd(ctx.window(), byte_order, tags); + Ok(()) } fn validate_header(&self, data: &[u8]) -> bool { - // Accept BPLIST format - if Self::is_bplist_format(data) { - return true; - } - - // Accept data with Apple signature - if data.len() >= 9 && &data[0..9] == APPLE_SIGNATURE { - return true; - } + data.starts_with(APPLE_SIGNATURE) + } +} - // Also accept if it looks like valid IFD data - if data.len() >= 2 { - let reader = EndianReader::little_endian(data); - let entry_count = reader.u16_at(0).unwrap_or(0); - if entry_count > 0 && entry_count < 500 { - return true; - } - } +/// Public entry point for Apple MakerNotes parsing. +pub fn parse_apple_makernotes( + data: &[u8], + byte_order: ByteOrder, + tags: &mut HashMap, +) { + parse_apple_ifd(data, byte_order, tags); +} - false - } +/// Whether `data` is an Apple MakerNote. +pub fn is_apple_makernote(data: &[u8]) -> bool { + data.starts_with(APPLE_SIGNATURE) } #[cfg(test)] mod tests { + use super::super::shared::table_ifd::print_rational; use super::*; - #[test] - fn test_decode_hdr_type() { - assert_eq!(DECODE_HDR_TYPE.decode(0), "Off"); - assert_eq!(DECODE_HDR_TYPE.decode(1), "HDR"); - assert_eq!(DECODE_HDR_TYPE.decode(4), "Smart HDR"); - assert_eq!(DECODE_HDR_TYPE.decode(8), "Smart HDR 5"); - } + /// The first 16 bytes of `Apple_iPhone13Pro.jpg`'s MakerNote value: + /// `Apple iOS\0`, then `00 01`, then the order marker `MM`, then the entry + /// count `00 31` = 49. + const HEADER: &[u8] = b"Apple iOS\x00\x00\x01MM\x00\x31"; #[test] - fn test_decode_portrait_mode() { - assert_eq!(DECODE_PORTRAIT_MODE.decode(0), "Off"); - assert_eq!(DECODE_PORTRAIT_MODE.decode(1), "Natural Light"); - assert_eq!(DECODE_PORTRAIT_MODE.decode(4), "Stage Light"); + fn ifd_starts_fourteen_bytes_in() { + // Byte 14 is where the count lives; byte 10 is the version word. + assert_eq!(&HEADER[IFD_START..], &[0x00, 0x31]); } #[test] - fn test_decode_scene_type() { - assert_eq!(DECODE_SCENE_TYPE.decode(0), "None"); - assert_eq!(DECODE_SCENE_TYPE.decode(6), "Night"); - assert_eq!(DECODE_SCENE_TYPE.decode(8), "Food"); - assert_eq!(DECODE_SCENE_TYPE.decode(11), "QR Code"); - } - - #[test] - fn test_decode_semantic_style() { - assert_eq!(DECODE_SEMANTIC_STYLE.decode(0), "Standard"); - assert_eq!(DECODE_SEMANTIC_STYLE.decode(2), "Vibrant"); - } - - #[test] - fn test_decode_lens_model() { - assert_eq!(DECODE_LENS_MODEL.decode(0), "Wide (Main Camera)"); - assert_eq!(DECODE_LENS_MODEL.decode(1), "Telephoto"); - assert_eq!(DECODE_LENS_MODEL.decode(2), "Ultra Wide"); - assert_eq!(DECODE_LENS_MODEL.decode(6), "Telephoto 5x"); - } - - #[test] - fn test_decode_camera_type() { - assert_eq!(DECODE_CAMERA_TYPE.decode(1), "Back Normal"); - assert_eq!(DECODE_CAMERA_TYPE.decode(6), "Front"); - } - - #[test] - fn test_decode_ois_mode() { - assert_eq!(DECODE_OIS_MODE.decode(0), "Off"); - assert_eq!(DECODE_OIS_MODE.decode(1), "On"); - assert_eq!(DECODE_OIS_MODE.decode(3), "Action Mode"); + fn byte_order_follows_exiftools_entry_count_test() { + // 0x0031 read big-endian: high byte zero, so the file order stands. + assert_eq!( + resolve_byte_order(HEADER, IFD_START, ByteOrder::BigEndian), + ByteOrder::BigEndian + ); + // The same bytes read little-endian give 0x3100: high byte non-zero and + // larger than the low byte, so ExifTool flips. + assert_eq!( + resolve_byte_order(HEADER, IFD_START, ByteOrder::LittleEndian), + ByteOrder::BigEndian + ); } #[test] - fn test_decode_image_capture_type() { - assert_eq!(DECODE_IMAGE_CAPTURE_TYPE.decode(0), "Photo"); - assert_eq!(DECODE_IMAGE_CAPTURE_TYPE.decode(1), "Portrait"); - assert_eq!(DECODE_IMAGE_CAPTURE_TYPE.decode(4), "Night Mode"); + fn validate_header_requires_the_signature() { + let parser = AppleParser::new(); + assert!(parser.validate_header(HEADER)); + assert!(!parser.validate_header(b"Nikon\x00\x02\x00\x00\x00II\x2a\x00")); + assert!(!parser.validate_header(&[0x05, 0x00])); } #[test] - fn test_apple_parser_trait() { - let parser = AppleParser::new(); - assert_eq!(parser.manufacturer_name(), "Apple"); - assert_eq!(parser.tag_prefix(), "Apple:"); + fn af_performance_splits_the_second_word() { + // Apple_iPhone13Pro.jpg: int32s[2] = 682, 268435509 -> "682 1 53" + let v = OlyVal::Int(vec![682, 268_435_509]); + assert_eq!(print_af_performance(&v).as_deref(), Some("682 1 53")); + // Apple_iPadPro_12.9-inch_4th_generation.jpg -> "2033332 6 0" + let v = OlyVal::Int(vec![2_033_332, 1_610_612_736]); + assert_eq!(print_af_performance(&v).as_deref(), Some("2033332 6 0")); } #[test] - fn test_validate_header_with_signature() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - data.extend_from_slice(b"Apple iOS"); - data.extend_from_slice(&[0x05, 0x00]); // 5 entries - - assert!(parser.validate_header(&data)); + fn focus_distance_range_sorts_its_pair() { + // Apple_iPhone13Pro.jpg: rational64s[2] = 515/128, 37/256, which + // ExifTool prints as "0.14 - 4.02 m". + let v = OlyVal::Rat(vec![(515, 128), (37, 256)]); + assert_eq!( + print_focus_distance_range(&v).as_deref(), + Some("0.14 - 4.02 m") + ); } #[test] - fn test_validate_header_without_signature() { - let parser = AppleParser::new(); - let data = vec![0x05, 0x00]; // Just entry count - - assert!(parser.validate_header(&data)); + fn runtime_flags_decode_as_a_bitmask() { + assert_eq!(decode_bits(1, RUNTIME_FLAG_BITS), "Valid"); + assert_eq!(decode_bits(3, RUNTIME_FLAG_BITS), "Valid, Has been rounded"); + assert_eq!(decode_bits(0, RUNTIME_FLAG_BITS), "(none)"); + assert_eq!(decode_bits(1 << 7, RUNTIME_FLAG_BITS), "[7]"); } #[test] - fn test_validate_header_bplist() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - data.extend_from_slice(b"Apple iOS"); - data.push(0x00); - data.extend_from_slice(b"bplist00"); - - assert!(parser.validate_header(&data)); + fn generated_names_follow_exiftools_rule() { + assert_eq!(generated_tag_name("timescale"), "Timescale"); + assert_eq!(generated_tag_name("some_key"), "Some_Key"); + assert_eq!(generated_tag_name("a"), "TagA"); + // `s/([^A-Za-z])([a-z])/$1\u$2/` capitalises the letter after the + // digit before the `Tag` prefix goes on: + // `perl -e '$_="9lives"; s/([^A-Za-z])([a-z])/$1\u$2/g; print'` gives 9Lives. + assert_eq!(generated_tag_name("9lives"), "Tag9Lives"); } #[test] - fn test_parse_hdr_tag() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - // Create minimal IFD with one entry - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - - // HDR tag entry (tag=0x000A, type=3 (SHORT), count=1, value=4 (Smart HDR)) - data.extend_from_slice(&[0x0A, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x04, 0x00, 0x00, 0x00]); // Value: 4 (inline) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!( - tags.get("Apple:HDRImageType"), - Some(&"Smart HDR".to_string()) - ); + fn every_table_row_is_a_distinct_apple_pm_id() { + let mut ids: Vec = APPLE_MAIN.iter().map(|d| d.id).collect(); + ids.sort_unstable(); + let mut deduped = ids.clone(); + deduped.dedup(); + assert_eq!(ids, deduped, "duplicate tag id in APPLE_MAIN"); + // The four ids handled outside the table are not in it. + for id in [0x0003u16, 0x0040, 0x0041, 0x0042] { + assert!(!ids.contains(&id)); + } } #[test] - fn test_is_bplist_format() { - // Test direct bplist - let direct_bplist = b"bplist00data"; - assert!(AppleParser::is_bplist_format(direct_bplist)); - - // Test Apple iOS header + bplist - let mut apple_bplist = Vec::new(); - apple_bplist.extend_from_slice(b"Apple iOS"); - apple_bplist.push(0x00); - apple_bplist.extend_from_slice(b"bplist00"); - assert!(AppleParser::is_bplist_format(&apple_bplist)); - - // Test non-bplist data - let ifd_data = vec![0x05, 0x00]; - assert!(!AppleParser::is_bplist_format(&ifd_data)); + fn rational_printing_matches_exiftool() { + // HDRGain = 0 (0/1) on Apple_iPhone13Pro.jpg + assert_eq!(print_rational(0, 1), "0"); + // HDRGain = 0.00989481714 (1349/136334) on Apple_iPhone15Pro.jpg + assert_eq!(print_rational(1349, 136_334), "0.00989481714"); } } diff --git a/src/parsers/tiff/makernotes/registries/apple.rs b/src/parsers/tiff/makernotes/registries/apple.rs deleted file mode 100644 index 5081db2b5..000000000 --- a/src/parsers/tiff/makernotes/registries/apple.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! Apple (iPhone/iPad) tag registry -//! -//! This module provides a declarative registry for Apple MakerNote tags, -//! consolidating tag definitions and decoders for cleaner parser implementation. -//! -//! ## Tag Categories -//! - Core identification (MakerNote version, runtime) -//! - Computational Photography (HDR, Portrait Mode, Night Mode) -//! - Multi-camera metadata (lens identification, camera type) -//! - Scene detection and semantic styles -//! - Live Photo and burst mode metadata -//! - AF performance and depth measurement (LiDAR) -//! - Signal-to-noise ratio and noise analysis -//! - Color temperature and correction -//! - Image processing flags - -use super::super::shared::tag_registry::TagRegistry; - -// Re-export existing decoders from apple.rs to avoid duplication -// These decoders are already defined using const_decoder! macro -use super::super::apple::{ - DECODE_CAMERA_TYPE, DECODE_GREEN_GHOST_MITIGATION, DECODE_HDR_TYPE, DECODE_IMAGE_CAPTURE_TYPE, - DECODE_OIS_MODE, DECODE_SEMANTIC_STYLE, DECODE_SNR_TYPE, -}; - -// ============================================================================ -// APPLE MAKERNOTE TAG IDS -// ============================================================================ -// Comprehensive list of Apple MakerNote tags - -// Core identification tags -const APPLE_MAKERNOTE_VERSION: u16 = 0x0001; -const APPLE_AE_MATRIX: u16 = 0x0002; -const APPLE_RUN_TIME: u16 = 0x0003; -const APPLE_AE_STABLE: u16 = 0x0004; -const APPLE_AE_TARGET: u16 = 0x0005; -const APPLE_AE_AVERAGE: u16 = 0x0006; -const APPLE_AF_STABLE: u16 = 0x0007; -const APPLE_ACCELERATION_VECTOR: u16 = 0x0008; - -// HDR and image processing tags -const APPLE_HDR_IMAGE_TYPE: u16 = 0x000A; -const APPLE_BURST_UUID: u16 = 0x000B; -const APPLE_FOCUS_DISTANCE_RANGE: u16 = 0x000C; -const APPLE_OIS_MODE: u16 = 0x000F; - -// Content and image identification -const APPLE_CONTENT_IDENTIFIER: u16 = 0x0011; -const APPLE_IMAGE_CAPTURE_TYPE: u16 = 0x0014; -const APPLE_IMAGE_UNIQUE_ID: u16 = 0x0015; -const APPLE_LIVE_PHOTO_VIDEO_INDEX: u16 = 0x0017; -const APPLE_IMAGE_PROCESSING_FLAGS: u16 = 0x0019; -const APPLE_QUALITY_HINT: u16 = 0x001A; - -// Noise and signal analysis -const APPLE_LUMINANCE_NOISE_AMPLITUDE: u16 = 0x001D; -const APPLE_PHOTOS_APP_FEATURE_FLAGS: u16 = 0x001F; - -// HDR headroom and capture request -const APPLE_IMAGE_CAPTURE_REQUEST_ID: u16 = 0x0020; -const APPLE_HDR_HEADROOM: u16 = 0x0021; -const APPLE_AF_PERFORMANCE: u16 = 0x0023; - -// Scene analysis -const APPLE_SCENE_FLAGS: u16 = 0x0025; -const APPLE_SIGNAL_TO_NOISE_RATIO_TYPE: u16 = 0x0026; -const APPLE_SIGNAL_TO_NOISE_RATIO: u16 = 0x0027; - -// Photo identifiers and camera info -const APPLE_PHOTO_IDENTIFIER: u16 = 0x002B; -const APPLE_COLOR_TEMPERATURE: u16 = 0x002D; -const APPLE_CAMERA_TYPE: u16 = 0x002E; -const APPLE_FOCUS_POSITION: u16 = 0x002F; -const APPLE_HDR_GAIN: u16 = 0x0030; - -// Front-facing camera flag -const APPLE_FRONT_FACING_CAMERA: u16 = 0x0032; - -// Advanced AF and processing tags -const APPLE_AF_MEASURED_DEPTH: u16 = 0x0038; -const APPLE_AF_CONFIDENCE: u16 = 0x003D; -const APPLE_COLOR_CORRECTION_MATRIX: u16 = 0x003E; -const APPLE_GREEN_GHOST_MITIGATION_STATUS: u16 = 0x003F; - -// Semantic Style tags (Photographic Styles - iOS 15+) -const APPLE_SEMANTIC_STYLE: u16 = 0x0040; -const APPLE_SEMANTIC_STYLE_RENDERING_VER: u16 = 0x0041; -const APPLE_SEMANTIC_STYLE_PRESET: u16 = 0x0042; - -// ============================================================================ -// TAG REGISTRY -// ============================================================================ - -/// Creates the Apple tag registry with all tag definitions and decoders -/// -/// This registry provides a centralized, declarative definition of all Apple -/// MakerNote tags, replacing scattered match statements with a clean lookup table. -/// -/// # Returns -/// A TagRegistry instance with all Apple tags registered -/// -/// # Example -/// ```ignore -/// let registry = apple_registry(); -/// let hdr_type = registry.decode_i16(0x000A, 4); // "Smart HDR" -/// ``` -pub fn apple_registry() -> TagRegistry { - TagRegistry::new() - // ================================================================ - // Core identification tags - // ================================================================ - .register_raw(APPLE_MAKERNOTE_VERSION, "MakerNoteVersion") - .register_raw(APPLE_AE_MATRIX, "AEMatrix") - .register_raw(APPLE_RUN_TIME, "RunTime") - .register_raw(APPLE_AE_STABLE, "AEStable") - .register_raw(APPLE_AE_TARGET, "AETarget") - .register_raw(APPLE_AE_AVERAGE, "AEAverage") - .register_raw(APPLE_AF_STABLE, "AFStable") - .register_raw(APPLE_ACCELERATION_VECTOR, "AccelerationVector") - // ================================================================ - // HDR and image processing tags - // ================================================================ - .register_simple_i16(APPLE_HDR_IMAGE_TYPE, "HDRImageType", &DECODE_HDR_TYPE) - .register_raw(APPLE_BURST_UUID, "BurstUUID") - .register_raw(APPLE_FOCUS_DISTANCE_RANGE, "FocusDistanceRange") - .register_simple_i16(APPLE_OIS_MODE, "OISMode", &DECODE_OIS_MODE) - // ================================================================ - // Content and image identification - // ================================================================ - .register_raw(APPLE_CONTENT_IDENTIFIER, "ContentIdentifier") - .register_simple_i16( - APPLE_IMAGE_CAPTURE_TYPE, - "ImageCaptureType", - &DECODE_IMAGE_CAPTURE_TYPE, - ) - .register_raw(APPLE_IMAGE_UNIQUE_ID, "ImageUniqueID") - .register_raw(APPLE_LIVE_PHOTO_VIDEO_INDEX, "LivePhotoVideoIndex") - .register_raw(APPLE_IMAGE_PROCESSING_FLAGS, "ImageProcessingFlags") - .register_raw(APPLE_QUALITY_HINT, "QualityHint") - // ================================================================ - // Noise and signal analysis - // ================================================================ - .register_raw(APPLE_LUMINANCE_NOISE_AMPLITUDE, "LuminanceNoiseAmplitude") - .register_raw(APPLE_PHOTOS_APP_FEATURE_FLAGS, "PhotosAppFeatureFlags") - // ================================================================ - // HDR headroom and capture request - // ================================================================ - .register_raw( - APPLE_IMAGE_CAPTURE_REQUEST_ID, - "ImageCaptureRequestIdentifier", - ) - .register_raw(APPLE_HDR_HEADROOM, "HDRHeadroom") - .register_raw(APPLE_AF_PERFORMANCE, "AFPerformance") - // ================================================================ - // Scene analysis - // ================================================================ - .register_raw(APPLE_SCENE_FLAGS, "SceneFlags") - .register_simple_i16( - APPLE_SIGNAL_TO_NOISE_RATIO_TYPE, - "SignalToNoiseRatioType", - &DECODE_SNR_TYPE, - ) - .register_raw(APPLE_SIGNAL_TO_NOISE_RATIO, "SignalToNoiseRatio") - // ================================================================ - // Photo identifiers and camera info - // ================================================================ - .register_raw(APPLE_PHOTO_IDENTIFIER, "PhotoIdentifier") - .register_raw(APPLE_COLOR_TEMPERATURE, "ColorTemperature") - .register_simple_i16(APPLE_CAMERA_TYPE, "CameraType", &DECODE_CAMERA_TYPE) - .register_raw(APPLE_FOCUS_POSITION, "FocusPosition") - .register_raw(APPLE_HDR_GAIN, "HDRGain") - // ================================================================ - // Front-facing camera - // ================================================================ - .register_raw(APPLE_FRONT_FACING_CAMERA, "FrontFacingCamera") - // ================================================================ - // Advanced AF and processing tags - // ================================================================ - .register_raw(APPLE_AF_MEASURED_DEPTH, "AFMeasuredDepth") - .register_raw(APPLE_AF_CONFIDENCE, "AFConfidence") - .register_raw(APPLE_COLOR_CORRECTION_MATRIX, "ColorCorrectionMatrix") - .register_simple_i16( - APPLE_GREEN_GHOST_MITIGATION_STATUS, - "GreenGhostMitigationStatus", - &DECODE_GREEN_GHOST_MITIGATION, - ) - // ================================================================ - // Semantic Style tags (Photographic Styles) - // ================================================================ - .register_simple_i16( - APPLE_SEMANTIC_STYLE, - "SemanticStyle", - &DECODE_SEMANTIC_STYLE, - ) - .register_raw( - APPLE_SEMANTIC_STYLE_RENDERING_VER, - "SemanticStyleRenderingVer", - ) - .register_raw(APPLE_SEMANTIC_STYLE_PRESET, "SemanticStylePreset") -} - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -/// Decode front-facing camera flag to human-readable string -/// -/// # Arguments -/// * `value` - Camera flag (1 = Front, 0 = Back) -/// -/// # Returns -/// "Front" or "Back" based on flag value -#[inline] -pub fn decode_facing_camera(value: i16) -> String { - if value == 1 { - "Front".to_string() - } else { - "Back".to_string() - } -} - -/// Decode night mode flag to human-readable string -/// -/// # Arguments -/// * `value` - Night mode flag (>0 = On, 0 = Off) -/// -/// # Returns -/// "On" or "Off" based on flag value -#[inline] -pub fn decode_night_mode(value: i16) -> String { - if value > 0 { - "On".to_string() - } else { - "Off".to_string() - } -} - -/// Format runtime flags as hexadecimal string -/// -/// # Arguments -/// * `value` - 32-bit runtime flags -/// -/// # Returns -/// Hexadecimal representation (e.g., "0x00001234") -#[inline] -pub fn format_runtime_flags(value: u32) -> String { - format!("0x{:08X}", value) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_registry_creation() { - let registry = apple_registry(); - assert!(!registry.is_empty()); - assert!(registry.len() >= 35); // At least 35 tags registered now - } - - #[test] - fn test_hdr_type_decoding() { - let registry = apple_registry(); - assert_eq!(registry.decode_i16(APPLE_HDR_IMAGE_TYPE, 0), "Off"); - assert_eq!(registry.decode_i16(APPLE_HDR_IMAGE_TYPE, 4), "Smart HDR"); - assert_eq!(registry.decode_i16(APPLE_HDR_IMAGE_TYPE, 8), "Smart HDR 5"); - } - - #[test] - fn test_camera_type_decoding() { - let registry = apple_registry(); - assert_eq!(registry.decode_i16(APPLE_CAMERA_TYPE, 1), "Back Normal"); - assert_eq!(registry.decode_i16(APPLE_CAMERA_TYPE, 6), "Front"); - } - - #[test] - fn test_ois_mode_decoding() { - let registry = apple_registry(); - assert_eq!(registry.decode_i16(APPLE_OIS_MODE, 0), "Off"); - assert_eq!(registry.decode_i16(APPLE_OIS_MODE, 1), "On"); - assert_eq!(registry.decode_i16(APPLE_OIS_MODE, 3), "Action Mode"); - } - - #[test] - fn test_image_capture_type_decoding() { - let registry = apple_registry(); - assert_eq!(registry.decode_i16(APPLE_IMAGE_CAPTURE_TYPE, 0), "Photo"); - assert_eq!(registry.decode_i16(APPLE_IMAGE_CAPTURE_TYPE, 1), "Portrait"); - assert_eq!( - registry.decode_i16(APPLE_IMAGE_CAPTURE_TYPE, 4), - "Night Mode" - ); - } - - #[test] - fn test_semantic_style_decoding() { - let registry = apple_registry(); - assert_eq!(registry.decode_i16(APPLE_SEMANTIC_STYLE, 0), "Standard"); - assert_eq!(registry.decode_i16(APPLE_SEMANTIC_STYLE, 2), "Vibrant"); - } - - #[test] - fn test_snr_type_decoding() { - let registry = apple_registry(); - assert_eq!( - registry.decode_i16(APPLE_SIGNAL_TO_NOISE_RATIO_TYPE, 0), - "None" - ); - assert_eq!( - registry.decode_i16(APPLE_SIGNAL_TO_NOISE_RATIO_TYPE, 1), - "Luminance" - ); - } - - #[test] - fn test_green_ghost_mitigation_decoding() { - let registry = apple_registry(); - assert_eq!( - registry.decode_i16(APPLE_GREEN_GHOST_MITIGATION_STATUS, 0), - "Off" - ); - assert_eq!( - registry.decode_i16(APPLE_GREEN_GHOST_MITIGATION_STATUS, 1), - "Applied" - ); - } - - #[test] - fn test_tag_names() { - let registry = apple_registry(); - assert_eq!( - registry.get_tag_name(APPLE_HDR_IMAGE_TYPE), - Some("HDRImageType") - ); - assert_eq!(registry.get_tag_name(APPLE_CAMERA_TYPE), Some("CameraType")); - assert_eq!( - registry.get_tag_name(APPLE_LIVE_PHOTO_VIDEO_INDEX), - Some("LivePhotoVideoIndex") - ); - assert_eq!( - registry.get_tag_name(APPLE_SEMANTIC_STYLE), - Some("SemanticStyle") - ); - assert_eq!( - registry.get_tag_name(APPLE_AF_MEASURED_DEPTH), - Some("AFMeasuredDepth") - ); - } - - #[test] - fn test_raw_tags() { - let registry = apple_registry(); - // Raw tags should return value as-is - assert_eq!(registry.decode_i16(APPLE_FOCUS_POSITION, 150), "150"); - assert_eq!(registry.decode_i16(APPLE_HDR_HEADROOM, 2500), "2500"); - } - - #[test] - fn test_helper_facing_camera() { - assert_eq!(decode_facing_camera(1), "Front"); - assert_eq!(decode_facing_camera(0), "Back"); - } - - #[test] - fn test_helper_night_mode() { - assert_eq!(decode_night_mode(0), "Off"); - assert_eq!(decode_night_mode(1), "On"); - assert_eq!(decode_night_mode(5), "On"); - } - - #[test] - fn test_helper_runtime_flags() { - assert_eq!(format_runtime_flags(0x12345678), "0x12345678"); - assert_eq!(format_runtime_flags(0), "0x00000000"); - } -} diff --git a/src/parsers/tiff/makernotes/registries/mod.rs b/src/parsers/tiff/makernotes/registries/mod.rs index 8cdf945f6..e4c3046f1 100644 --- a/src/parsers/tiff/makernotes/registries/mod.rs +++ b/src/parsers/tiff/makernotes/registries/mod.rs @@ -9,8 +9,14 @@ // declared here, so it never compiled; see `makernotes::google` for the real // parser. Likewise no `nikon` registry: `makernotes::nikon` and its submodules // carry the real per-table id mapping, and the registry copy was never -// declared either.) -pub mod apple; +// declared either. +// +// No `apple` registry either. It carried a `FrontFacingCamera` at 0x0032 and a +// `PortraitMode` at 0x0020 -- `%Apple::Main` has no tag at 0x0032 at all, and +// 0x0020 is `ImageCaptureRequestID` -- plus enum decoders for `OISMode`, +// `SemanticStyle`, `SignalToNoiseRatioType` and `GreenGhostMitigationStatus`, +// none of which has a `PrintConv` in Apple.pm. `makernotes::apple` now carries +// the table transcribed from `%Apple::Main` itself.) pub mod canon; pub mod captureone; // Capture One migration complete (Batch 4, Task 4.2) pub mod nikoncapture; @@ -71,7 +77,6 @@ pub mod indesign; pub mod reconyx; pub mod scalado; -pub use apple::apple_registry; pub use canon::canon_registry; // Batch 1 exports diff --git a/src/parsers/tiff/makernotes/shared/binary_plist.rs b/src/parsers/tiff/makernotes/shared/binary_plist.rs new file mode 100644 index 000000000..7dcc74ae9 --- /dev/null +++ b/src/parsers/tiff/makernotes/shared/binary_plist.rs @@ -0,0 +1,566 @@ +//! Apple binary property lists (`bplist00`), for MakerNote tags whose value is +//! a serialised plist rather than a number. +//! +//! Several Apple MakerNote tags do not hold a value at all: they hold a whole +//! `bplist00` blob that ExifTool unpacks. `%Apple::Main` reaches it two ways +//! (Apple.pm): +//! +//! * `0x0003 RunTime` is a `SubDirectory` over `%Apple::RunTime`, whose +//! `PROCESS_PROC` is `Image::ExifTool::PLIST::ProcessBinaryPLIST` +//! (Apple.pm:40-43, :324-325). The blob's top object is a dictionary and each +//! of its keys selects a tag in that table -- `timescale` is `RunTimeScale`, +//! `epoch` is `RunTimeEpoch`, `value` is `RunTimeValue` and `flags` is +//! `RunTimeFlags` (Apple.pm:332-336). The pointer itself is never a value. +//! * `0x0040 SemanticStyle`, `0x0041 SemanticStyleRenderingVer` and +//! `0x0042 SemanticStylePreset` carry `ValueConv => \&ConvertPLIST` +//! (Apple.pm:276, :280, :284). `ConvertPLIST` (Apple.pm:367-380) decodes the +//! blob and, when the result is a dictionary and the `Struct` option is off, +//! flattens it with `XMP::SerializeStruct` -- which is why ExifTool prints +//! `SemanticStyle` as `{_0=1,_1=0.5,_2=0,_3=2}`. +//! +//! This module is a transcription of the object grammar in +//! `Image::ExifTool::PLIST::ExtractObject` and the trailer parsing in +//! `ProcessBinaryPLIST` (PLIST.pm:260-395 and :398-450). The parts that matter +//! for matching ExifTool byte for byte: +//! +//! * `SetByteOrder('MM')` -- a binary plist is big-endian regardless of the +//! byte order of the file that carries it (PLIST.pm:406). +//! * integers are read *unsigned*. `%readProc` maps every integer size to +//! `Get8u`/`Get16u`/`Get24u`/`Get32u`/`Get64u` (PLIST.pm:30-38), so an +//! 8-byte integer with the high bit set prints as a large positive number, +//! not a negative one. +//! * only the sizes in `%readProc` decode. An integer marker with a size nibble +//! above 3 selects a 16-byte read that has no entry there, and `ExtractObject` +//! returns undef rather than guessing (PLIST.pm:272-274). Reals are the same +//! with the `+ 0x100` keys, so only 4- and 8-byte reals decode. +//! * `$topObj >= $numObj` is rejected outright (PLIST.pm:424), as is any +//! object reference at or past the end of the offset table (PLIST.pm:320). + +use std::collections::BTreeMap; + +use crate::core::formatters::numeric_precision::perl_number; + +/// A decoded plist object. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum PlistValue { + /// Marker `0x00`. ExifTool yields the literal string ``. + Null, + /// Markers `0x08` / `0x09`, which ExifTool yields as `True` / `False`. + Bool(bool), + /// Marker `0x0f`. ExifTool yields the literal string ``. + Fill, + /// An integer, read unsigned as ExifTool's `%readProc` does. + Int(u64), + Real(f64), + /// A date. Deliberately carries no rendering: ExifTool converts it with + /// `ConvertUnixTime($val + 11323 * 24 * 3600, 1)` (PLIST.pm:279), and that + /// second argument is `$toLocal`, so the printed string carries the *local* + /// time zone of the machine that ran the extraction. A value that depends + /// on the reader's clock settings cannot be reproduced, so a date is + /// dropped rather than approximated. + Date, + Data(Vec), + Str(String), + /// A UID, rendered as ExifTool's `%readProc` integer or, failing that, its + /// hex (PLIST.pm:281-292). + Uid(String), + Array(Vec), + /// Key/value pairs in the order the dictionary stores them. + Dict(Vec<(String, PlistValue)>), +} + +impl PlistValue { + /// The scalar rendering ExifTool gives an object when it becomes a tag + /// value. `None` for the aggregate and date cases, which never reach a + /// tag directly. + pub(crate) fn scalar(&self) -> Option { + match self { + PlistValue::Null => Some("".to_string()), + PlistValue::Bool(true) => Some("True".to_string()), + PlistValue::Bool(false) => Some("False".to_string()), + PlistValue::Fill => Some("".to_string()), + PlistValue::Int(n) => Some(n.to_string()), + PlistValue::Real(f) => Some(perl_number(*f)), + PlistValue::Str(s) => Some(s.clone()), + PlistValue::Uid(s) => Some(s.clone()), + PlistValue::Data(_) | PlistValue::Date | PlistValue::Array(_) | PlistValue::Dict(_) => { + None + } + } + } +} + +/// `%readProc` (PLIST.pm:30-38): the integer sizes that have a reader. Any +/// other size makes `ExtractObject` return undef. +const fn int_size_supported(size: usize) -> bool { + matches!(size, 1 | 2 | 3 | 4 | 8) +} + +/// A cursor over the blob, standing in for the `File::RandomAccess` handle +/// `ProcessBinaryPLIST` builds over the data (PLIST.pm:407-411). +struct Reader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let end = self.pos.checked_add(n)?; + let out = self.data.get(self.pos..end)?; + self.pos = end; + Some(out) + } + + fn seek(&mut self, pos: usize) -> Option<()> { + // ExifTool's `$raf->Seek` past the end still succeeds; the following + // read is what fails. Refusing here is equivalent and simpler. + if pos > self.data.len() { + return None; + } + self.pos = pos; + Some(()) + } +} + +/// Read a big-endian unsigned integer of `size` bytes (`%readProc`). +fn read_uint(bytes: &[u8]) -> u64 { + let mut v: u64 = 0; + for &b in bytes { + v = (v << 8) | u64::from(b); + } + v +} + +struct Ctx<'a> { + reader: Reader<'a>, + /// Offset of every object, indexed by object number. + table: Vec, + ref_size: usize, +} + +/// Recursion guard. ExifTool bounds the dictionary depth through the length of +/// the accumulated tag path (`length $parent > 1000`, PLIST.pm:327-330); a +/// plain depth counter bounds the same recursion without carrying the path. +const MAX_DEPTH: usize = 64; + +/// `ExtractObject` (PLIST.pm:260-390). +fn extract(ctx: &mut Ctx<'_>, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + let marker = *ctx.reader.take(1)?.first()?; + let ty = marker >> 4; + let mut size = usize::from(marker & 0x0f); + + match ty { + // null / bool / fill (PLIST.pm:269-270) + 0 => match size { + 0x00 => Some(PlistValue::Null), + 0x08 => Some(PlistValue::Bool(true)), + 0x09 => Some(PlistValue::Bool(false)), + 0x0f => Some(PlistValue::Fill), + _ => None, + }, + // int (PLIST.pm:271-274) + 1 => { + let n = 1usize << size; + if !int_size_supported(n) { + return None; + } + Some(PlistValue::Int(read_uint(ctx.reader.take(n)?))) + } + // real: only the `0x104`/`0x108` entries of %readProc exist + 2 => { + let n = 1usize << size; + let b = ctx.reader.take(n)?; + match n { + 4 => Some(PlistValue::Real(f64::from(f32::from_be_bytes([ + b[0], b[1], b[2], b[3], + ])))), + 8 => Some(PlistValue::Real(f64::from_be_bytes([ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ]))), + _ => None, + } + } + // date: same readers as a real, then ExifTool's local-time conversion + 3 => { + let n = 1usize << size; + let _ = ctx.reader.take(n)?; + if n == 4 || n == 8 { + Some(PlistValue::Date) + } else { + None + } + } + // UID (PLIST.pm:281-292) + 8 => { + size += 1; + let b = ctx.reader.take(size)?; + if int_size_supported(size) { + Some(PlistValue::Uid(read_uint(b).to_string())) + } else { + // ExifTool renders a 16-byte UID as an ASF GUID and anything + // else as `"0x" . unpack 'H*'`. A GUID has a byte order of its + // own that no Apple MakerNote in the corpus exercises, so only + // the hex form is reproduced and the GUID case is declined. + if size == 16 { + return None; + } + let mut s = String::with_capacity(2 + size * 2); + s.push_str("0x"); + for byte in b { + s.push_str(&format!("{byte:02x}")); + } + Some(PlistValue::Uid(s)) + } + } + 4 | 5 | 6 | 10 | 12 | 13 => { + // `0x0f` means the count lives in a following integer object + // (PLIST.pm:294-298). + if size == 0x0f { + let Some(PlistValue::Int(n)) = extract(ctx, depth + 1) else { + return None; + }; + size = usize::try_from(n).ok()?; + } + match ty { + // data + 4 => Some(PlistValue::Data(ctx.reader.take(size)?.to_vec())), + // ASCII string + 5 => { + let b = ctx.reader.take(size)?; + Some(PlistValue::Str( + b.iter().map(|&c| char::from(c)).collect::(), + )) + } + // UCS-2BE string + 6 => { + let b = ctx.reader.take(size.checked_mul(2)?)?; + let units: Vec = b + .chunks_exact(2) + .map(|c| u16::from_be_bytes([c[0], c[1]])) + .collect(); + Some(PlistValue::Str(String::from_utf16_lossy(&units))) + } + // array (10), set (12) and dict (13) store a list of references + _ => extract_collection(ctx, ty, size, depth), + } + } + _ => None, + } +} + +/// The array/set/dict branch of `ExtractObject` (PLIST.pm:310-380). +fn extract_collection(ctx: &mut Ctx<'_>, ty: u8, size: usize, depth: usize) -> Option { + let num = if ty == 13 { size.checked_mul(2)? } else { size }; + let len = num.checked_mul(ctx.ref_size)?; + let buf = ctx.reader.take(len)?.to_vec(); + let mut refs = Vec::with_capacity(num); + for i in 0..num { + let r = read_uint(&buf[i * ctx.ref_size..(i + 1) * ctx.ref_size]); + let r = usize::try_from(r).ok()?; + // `return 0 if $ref >= @$table` (PLIST.pm:320) + if r >= ctx.table.len() { + return None; + } + refs.push(r); + } + if ty == 13 { + let mut entries = Vec::with_capacity(size); + for i in 0..size { + ctx.reader.seek(ctx.table[refs[i]])?; + let key = extract(ctx, depth + 1); + // "silently ignore bad dict entries" (PLIST.pm:337) + let Some(key) = key.and_then(|k| k.scalar()).filter(|k| !k.is_empty()) else { + continue; + }; + ctx.reader.seek(ctx.table[refs[i + size]])?; + let Some(obj) = extract(ctx, depth + 1) else { + continue; + }; + entries.push((key, obj)); + } + Some(PlistValue::Dict(entries)) + } else { + let mut items = Vec::with_capacity(refs.len()); + for r in refs { + ctx.reader.seek(ctx.table[r])?; + let Some(v) = extract(ctx, depth + 1) else { + continue; + }; + // "next unless defined $val and ref $val ne 'HASH'" (PLIST.pm:378) + if matches!(v, PlistValue::Dict(_)) { + continue; + } + items.push(v); + } + Some(PlistValue::Array(items)) + } +} + +/// `ProcessBinaryPLIST` (PLIST.pm:398-450): parse the trailer, load the offset +/// table and extract the top object. +/// +/// Returns `None` for anything ExifTool would have returned 0 for, so a blob we +/// cannot read produces no tag rather than a wrong one. +pub(crate) fn parse(data: &[u8]) -> Option { + // `$raf->Seek(-32,2) and $raf->Read($buff,32)==32 or return 0` + if data.len() < 32 { + return None; + } + let trailer = &data[data.len() - 32..]; + let int_size = usize::from(trailer[6]); + let ref_size = usize::from(trailer[7]); + let num_obj = read_uint(&trailer[8..16]); + let top_obj = read_uint(&trailer[16..24]); + let table_off = usize::try_from(read_uint(&trailer[24..32])).ok()?; + + // `return 0 if $topObj >= $numObj` (PLIST.pm:424) + if top_obj >= num_obj { + return None; + } + // `my $intProc = $readProc{$intSize} or return 0` (PLIST.pm:425-426) + if !int_size_supported(int_size) || !int_size_supported(ref_size) { + return None; + } + let num_obj = usize::try_from(num_obj).ok()?; + let top_obj = usize::try_from(top_obj).ok()?; + + let table_size = num_obj.checked_mul(int_size)?; + let table_end = table_off.checked_add(table_size)?; + if table_end > data.len() { + return None; + } + let mut table = Vec::with_capacity(num_obj); + for i in 0..num_obj { + let off = table_off + i * int_size; + table.push(usize::try_from(read_uint(&data[off..off + int_size])).ok()?); + } + + let start = *table.get(top_obj)?; + let mut ctx = Ctx { + reader: Reader { data, pos: 0 }, + table, + ref_size, + }; + ctx.reader.seek(start)?; + extract(&mut ctx, 0) +} + +/// The key rewriting `ExtractObject` applies when a dictionary is decoded +/// *without* a tag table -- the `ConvertPLIST` case (PLIST.pm:355-361). +/// +/// A key that is not made purely of `[-_a-zA-Z0-9]` becomes `Tag` for its +/// position in the dictionary, and one that does not start with a letter or an +/// underscore is prefixed with one. That second rule is why ExifTool prints +/// Apple's `SemanticStyle` keys `0`..`3` as `_0`..`_3`. +fn struct_field_name(key: &str, index: usize) -> String { + let valid = !key.is_empty() + && key + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'); + if !valid { + return format!("Tag{index}"); + } + let first = key.as_bytes()[0]; + if first.is_ascii_alphabetic() || first == b'_' { + key.to_string() + } else { + format!("_{key}") + } +} + +/// `XMP::SerializeStruct` (XMPStruct.pl:34-69) with the default +/// (non-JSON) `StructFormat`. +/// +/// `ket` is the closing bracket of the enclosing container, which joins `,` and +/// `|` in the set of characters a scalar has to escape. +fn serialize_value(v: &PlistValue, ket: Option) -> String { + match v { + PlistValue::Dict(entries) => { + // `Image::ExifTool::OrderedKeys` returns `sort keys %$hash` unless + // the hash carries an explicit ordering, and a hash built by + // `ExtractObject` never does -- which is why ExifTool prints + // Apple's `SemanticStyle` as `_0,_1,_2,_3` even though the + // dictionary stores the keys in the order `3,1,2,0`. + let mut map: BTreeMap = BTreeMap::new(); + for (i, (k, val)) in entries.iter().enumerate() { + // "$$val{$key} = $obj if defined $obj" -- a later duplicate + // wins, as it would in a Perl hash. + map.insert(struct_field_name(k, i), val); + } + let body: Vec = map + .iter() + .map(|(k, val)| format!("{k}={}", serialize_value(val, Some('}')))) + .collect(); + format!("{{{}}}", body.join(",")) + } + PlistValue::Array(items) => { + let body: Vec = items + .iter() + .map(|item| serialize_value(item, Some(']'))) + .collect(); + format!("[{}]", body.join(",")) + } + PlistValue::Date | PlistValue::Data(_) => String::new(), + other => match other.scalar() { + Some(s) => escape_scalar(&s, ket), + // `$rtnVal = ''` for an undefined item (XMPStruct.pl:66) + None => String::new(), + }, + } +} + +/// The scalar escape of `SerializeStruct` (XMPStruct.pl:57-62): `,` and `|` +/// always, the enclosing closing bracket when there is one, and a leading +/// space, `[` or `{`. +fn escape_scalar(s: &str, ket: Option) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if c == ',' || c == '|' || Some(c) == ket { + out.push('|'); + } + out.push(c); + } + let leading_escape = out + .chars() + .next() + .is_some_and(|c| c.is_whitespace() || c == '[' || c == '{'); + if leading_escape { + out.insert(0, '|'); + } + out +} + +/// `ConvertPLIST` (Apple.pm:367-380): decode the blob, and flatten a dictionary +/// result the way ExifTool does when the `Struct` option is off. +/// +/// `None` means ExifTool would not have produced a printable value, so the tag +/// is omitted rather than given an approximation. +pub(crate) fn convert_plist(data: &[u8]) -> Option { + match parse(data)? { + v @ (PlistValue::Dict(_) | PlistValue::Array(_)) => Some(serialize_value(&v, None)), + other => other.scalar(), + } +} + +/// Decode a blob whose top object is a dictionary, for the `SubDirectory` case +/// where each key selects a tag in an ExifTool table. +/// +/// Returns the dictionary entries in storage order. A blob whose top object is +/// not a dictionary yields nothing, matching `ExtractObject`, which only calls +/// `HandleTag` from its dictionary branch. +pub(crate) fn parse_dict(data: &[u8]) -> Vec<(String, PlistValue)> { + match parse(data) { + Some(PlistValue::Dict(entries)) => entries, + _ => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The `RunTime` blob of `Apple_iPhone13Pro.jpg`, tag 0x0003, exactly as + /// `exiftool -v3` dumps it. ExifTool reports `RunTimeFlags = 1`, + /// `RunTimeValue = 235706184764708`, `RunTimeScale = 1000000000` and + /// `RunTimeEpoch = 0` from it. + const RUNTIME_BLOB: &[u8] = &[ + 0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xd4, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, + 0x07, 0x08, 0x55, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x55, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x59, + 0x74, 0x69, 0x6d, 0x65, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x55, 0x65, 0x70, 0x6f, 0x63, 0x68, + 0x10, 0x01, 0x13, 0x00, 0x00, 0xd6, 0x5f, 0x9f, 0x6a, 0x0d, 0x24, 0x12, 0x3b, 0x9a, 0xca, + 0x00, 0x10, 0x00, 0x08, 0x11, 0x17, 0x1d, 0x27, 0x2d, 0x2f, 0x38, 0x3d, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, + ]; + + /// The `SemanticStyle` blob of the same file, tag 0x0040. ExifTool prints + /// `{_0=1,_1=0.5,_2=0,_3=2}`. + const SEMANTIC_STYLE_BLOB: &[u8] = &[ + 0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xd4, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, + 0x07, 0x08, 0x51, 0x33, 0x51, 0x31, 0x51, 0x32, 0x51, 0x30, 0x10, 0x02, 0x22, 0x3f, 0x00, + 0x00, 0x00, 0x22, 0x00, 0x00, 0x00, 0x00, 0x10, 0x01, 0x08, 0x11, 0x13, 0x15, 0x17, 0x19, + 0x1b, 0x20, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x27, + ]; + + /// The `SemanticStyleRenderingVer` blob of the same file, tag 0x0041: a + /// whole plist whose only object is the boolean ExifTool prints as `True`. + const BOOL_BLOB: &[u8] = &[ + 0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, + ]; + + #[test] + fn runtime_dict_matches_exiftool() { + let entries = parse_dict(RUNTIME_BLOB); + let printed: Vec<(String, String)> = entries + .iter() + .map(|(k, v)| (k.clone(), v.scalar().unwrap())) + .collect(); + assert_eq!( + printed, + vec![ + ("flags".to_string(), "1".to_string()), + ("value".to_string(), "235706184764708".to_string()), + ("timescale".to_string(), "1000000000".to_string()), + ("epoch".to_string(), "0".to_string()), + ] + ); + } + + #[test] + fn semantic_style_serializes_in_sorted_key_order() { + // The dictionary stores the keys as 3, 1, 2, 0; ExifTool's OrderedKeys + // falls back to a plain sort, so the printed order is _0.._3. + assert_eq!( + convert_plist(SEMANTIC_STYLE_BLOB).as_deref(), + Some("{_0=1,_1=0.5,_2=0,_3=2}") + ); + } + + #[test] + fn lone_boolean_prints_as_true() { + assert_eq!(convert_plist(BOOL_BLOB).as_deref(), Some("True")); + } + + #[test] + fn rejects_a_truncated_or_bogus_trailer() { + assert!(parse(b"bplist00").is_none()); + // topObj >= numObj + let mut bad = RUNTIME_BLOB.to_vec(); + let n = bad.len(); + bad[n - 16..n - 8].copy_from_slice(&9u64.to_be_bytes()); + assert!(parse(&bad).is_none()); + // an unreadable integer size + let mut bad = RUNTIME_BLOB.to_vec(); + let n = bad.len(); + bad[n - 32 + 6] = 5; + assert!(parse(&bad).is_none()); + } + + #[test] + fn escapes_the_characters_serializestruct_does() { + assert_eq!(escape_scalar("a,b", Some('}')), "a|,b"); + assert_eq!(escape_scalar("a|b", Some('}')), "a||b"); + assert_eq!(escape_scalar("a}b", Some('}')), "a|}b"); + assert_eq!(escape_scalar("a}b", None), "a}b"); + // The leading-bracket rule adds exactly one `|`; `{` is not in the + // character class the first substitution escapes. + assert_eq!(escape_scalar("{x", Some('}')), "|{x"); + assert_eq!(escape_scalar(" x", Some(']')), "| x"); + } + + #[test] + fn generates_struct_field_names_the_way_exiftool_does() { + assert_eq!(struct_field_name("0", 3), "_0"); + assert_eq!(struct_field_name("flags", 0), "flags"); + assert_eq!(struct_field_name("_x", 0), "_x"); + assert_eq!(struct_field_name("a b", 2), "Tag2"); + assert_eq!(struct_field_name("", 1), "Tag1"); + } +} diff --git a/src/parsers/tiff/makernotes/shared/mod.rs b/src/parsers/tiff/makernotes/shared/mod.rs index 98cb76426..8ac3fa7d1 100644 --- a/src/parsers/tiff/makernotes/shared/mod.rs +++ b/src/parsers/tiff/makernotes/shared/mod.rs @@ -27,6 +27,8 @@ pub mod array_extractors; /// Array schema system for declarative CameraSettings-style array parsing pub mod array_schemas; +/// ExifTool's `Image::ExifTool::PLIST` for MakerNote tags holding a `bplist00` +pub mod binary_plist; /// ExifTool's `ProcessBinaryData` for MakerNote binary sub-directories pub mod binary_subdir; /// Low-level byte parsing helper functions diff --git a/src/parsers/tiff/makernotes/shared/table_ifd.rs b/src/parsers/tiff/makernotes/shared/table_ifd.rs index 7c2e00684..37b069c64 100644 --- a/src/parsers/tiff/makernotes/shared/table_ifd.rs +++ b/src/parsers/tiff/makernotes/shared/table_ifd.rs @@ -40,6 +40,11 @@ pub(crate) mod ftype { pub const TIFF_FLOAT: u16 = 11; pub const TIFF_DOUBLE: u16 = 12; pub const TIFF_IFD: u16 = 13; + /// `int64u`, format code 16 in ExifTool's `@formatName` (Exif.pm). Apple + /// stores `0x0017 LivePhotoVideoIndex` in it on newer iPhones. + pub const TIFF_LONG8: u16 = 16; + /// `int64s`, format code 17. + pub const TIFF_SLONG8: u16 = 17; } /// Size in bytes of one element of each TIFF field type. @@ -48,7 +53,11 @@ pub fn type_size(t: u16) -> usize { ftype::TIFF_BYTE | ftype::TIFF_ASCII | ftype::TIFF_SBYTE | ftype::TIFF_UNDEF => 1, ftype::TIFF_SHORT | ftype::TIFF_SSHORT => 2, ftype::TIFF_LONG | ftype::TIFF_SLONG | ftype::TIFF_FLOAT | ftype::TIFF_IFD => 4, - ftype::TIFF_RATIONAL | ftype::TIFF_SRATIONAL | ftype::TIFF_DOUBLE => 8, + ftype::TIFF_RATIONAL + | ftype::TIFF_SRATIONAL + | ftype::TIFF_DOUBLE + | ftype::TIFF_LONG8 + | ftype::TIFF_SLONG8 => 8, _ => 0, } } @@ -163,7 +172,13 @@ pub fn decode_text(bytes: &[u8]) -> String { } /// ExifTool renders a rational as its quotient (`inf` / `undef` when the -/// denominator is zero), then Perl stringifies that number with `%.15g`. +/// denominator is zero), rounded to ten significant digits. +/// +/// `GetRational64s`/`GetRational64u` (ExifTool.pm:6107-6120) both end in +/// `RoundFloat($ratNumer / $ratDenom, 10)`, and `RoundFloat` (ExifTool.pm:5960) +/// is `sprintf("%.${sig}g", $val)`. Printing the full `%.15g` expansion instead +/// is a visible mismatch on any quotient that does not terminate: ExifTool +/// prints `AccelerationVector` as `-0.9245480894`, not `-0.924548089390588`. pub fn print_rational(num: i64, den: i64) -> String { if den == 0 { return if num == 0 { @@ -175,7 +190,7 @@ pub fn print_rational(num: i64, den: i64) -> String { if num % den == 0 { return (num / den).to_string(); } - fmt_g15(num as f64 / den as f64) + crate::core::formatters::numeric_precision::exiftool_rational_number(num as f64 / den as f64) } /// Format a float the way Perl stringifies one: `%.15g`, trailing zeros gone. @@ -718,6 +733,23 @@ pub fn decode_bytes(bytes: &[u8], ft: u16, order: ByteOrder) -> Option { }) .collect(), )), + ftype::TIFF_LONG8 | ftype::TIFF_SLONG8 => { + let mut out = Vec::with_capacity(n); + for i in 0..n { + let hi = rd32(&bytes[i * 8..]) as u64; + let lo = rd32(&bytes[i * 8 + 4..]) as u64; + let raw = if le { (lo << 32) | hi } else { (hi << 32) | lo }; + if ft == ftype::TIFF_SLONG8 { + out.push(raw as i64); + } else { + // An int64u above i64::MAX has no representation here, and + // printing it as a negative would be worse than omitting + // it, so the whole value is dropped instead. + out.push(i64::try_from(raw).ok()?); + } + } + Some(OlyVal::Int(out)) + } _ => None, } } @@ -763,7 +795,12 @@ mod tests { assert_eq!(print_rational(2160, 100), "21.6"); assert_eq!(print_rational(203, 256), "0.79296875"); assert_eq!(print_rational(0, 1), "0"); - assert_eq!(print_rational(1, 3), "0.333333333333333"); + // `RoundFloat($num/$den, 10)`, not the full `%.15g` expansion: + // `perl -e 'printf "%.10g", 1/3'` prints 0.3333333333. + assert_eq!(print_rational(1, 3), "0.3333333333"); + // Apple_iPhone13Pro.jpg's AccelerationVector[0], -48487/52444, which + // `exiftool -a -G1 -s` prints as -0.9245480894. + assert_eq!(print_rational(-48487, 52444), "-0.9245480894"); assert_eq!(print_rational(1, 0), "inf"); assert_eq!(print_rational(0, 0), "undef"); } diff --git a/src/parsers/video/mts.rs b/src/parsers/video/mts.rs index 8a8604dab..322d36155 100644 --- a/src/parsers/video/mts.rs +++ b/src/parsers/video/mts.rs @@ -47,6 +47,7 @@ use std::collections::HashMap; use super::h264; +use crate::core::formatters::duration::convert_duration; use crate::core::{FileFormat, FileReader, FormatParser, MetadataMap, TagValue}; use crate::error::{ExifToolError, Result}; @@ -871,34 +872,6 @@ fn format_significant_3(value: f64) -> String { } } -/// ExifTool's `ConvertDuration`. -fn convert_duration(seconds: f64) -> String { - if seconds == 0.0 { - return "0 s".to_string(); - } - let (sign, mut time) = if seconds > 0.0 { - ("", seconds) - } else { - ("-", -seconds) - }; - if time < 30.0 { - return format!("{}{:.2} s", sign, time); - } - time += 0.5; // round off to the nearest second - let mut hours = (time / 3600.0) as i64; - time -= hours as f64 * 3600.0; - let minutes = (time / 60.0) as i64; - time -= minutes as f64 * 60.0; - - let mut prefix = sign.to_string(); - if hours > 24 { - let days = hours / 24; - hours -= days * 24; - prefix = format!("{}{} days ", sign, days); - } - format!("{}{}:{:02}:{:02}", prefix, hours, minutes, time as i64) -} - #[cfg(test)] mod tests { use super::*; diff --git a/tests/integration.rs b/tests/integration.rs index 93dffdc3e..789437033 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -91,6 +91,13 @@ mod sigma_makernotes_tests; #[path = "integration/phaseone_makernotes_tests.rs"] mod phaseone_makernotes_tests; +// This one was never declared, so `tests/integration/apple_makernotes_tests.rs` +// never compiled and never ran -- which is how it kept asserting an +// `Apple:PortraitMode` at 0x0020, an `Apple:LensModel` at 0x0035 and an +// `Apple:FacingCamera` at 0x0032, none of which is a tag `%Apple::Main` has. +#[path = "integration/apple_makernotes_tests.rs"] +mod apple_makernotes_tests; + #[path = "integration/format_detection.rs"] mod format_detection; diff --git a/tests/integration/apple_makernotes_tests.rs b/tests/integration/apple_makernotes_tests.rs index 195880e1a..3067aa183 100644 --- a/tests/integration/apple_makernotes_tests.rs +++ b/tests/integration/apple_makernotes_tests.rs @@ -1,333 +1,258 @@ -//! Integration tests for Apple (iPhone/iPad) MakerNotes parser +//! Integration tests for the Apple (iPhone / iPad) MakerNote parser. //! -//! Tests the Apple MakerNotes parsing functionality including: -//! - MakerNoteParser trait implementation -//! - Header validation -//! - Tag extraction from synthetic test data -//! - HDR mode detection -//! - Portrait Mode effects -//! - Live Photo status -//! - Multi-camera lens identification -//! - Semantic Styles +//! Every expected value here is what `exiftool -a -G1 -s` prints for the bytes +//! being fed in; the bytes themselves are copied from real corpus files, dumped +//! with `exiftool -v3`. Nothing is asserted that ExifTool does not report. use oxidex::parsers::tiff::ifd_parser::ByteOrder; use oxidex::parsers::tiff::makernotes::apple::AppleParser; use oxidex::parsers::tiff::makernotes::shared::MakerNoteParser; use std::collections::HashMap; -#[test] -fn test_apple_parser_trait() { - let parser = AppleParser::new(); - assert_eq!(parser.manufacturer_name(), "Apple"); - assert_eq!(parser.tag_prefix(), "Apple:"); +/// `Apple_iPhone13Pro.jpg`'s tag 0x0003 (`undef[104]`) verbatim. ExifTool +/// reports `RunTimeFlags = Valid`, `RunTimeValue = 235706184764708`, +/// `RunTimeScale = 1000000000` and `RunTimeEpoch = 0` from it. +const RUNTIME_BLOB: &[u8] = &[ + 0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xd4, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x55, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x55, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x59, 0x74, 0x69, + 0x6d, 0x65, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x55, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x10, 0x01, 0x13, + 0x00, 0x00, 0xd6, 0x5f, 0x9f, 0x6a, 0x0d, 0x24, 0x12, 0x3b, 0x9a, 0xca, 0x00, 0x10, 0x00, 0x08, + 0x11, 0x17, 0x1d, 0x27, 0x2d, 0x2f, 0x38, 0x3d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, +]; + +/// The same file's tag 0x0040 (`undef[80]`). ExifTool prints +/// `SemanticStyle = {_0=1,_1=0.5,_2=0,_3=2}`. +const SEMANTIC_STYLE_BLOB: &[u8] = &[ + 0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xd4, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x51, 0x33, 0x51, 0x31, 0x51, 0x32, 0x51, 0x30, 0x10, 0x02, 0x22, 0x3f, 0x00, 0x00, 0x00, + 0x22, 0x00, 0x00, 0x00, 0x00, 0x10, 0x01, 0x08, 0x11, 0x13, 0x15, 0x17, 0x19, 0x1b, 0x20, 0x25, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x27, +]; + +/// One IFD entry: `(tag, field type, count, inline bytes or out-of-line value)`. +enum Entry { + /// Four bytes or fewer, stored in the entry's own value field. + Inline(u16, u16, u32, [u8; 4]), + /// Longer, appended after the directory and addressed by offset. + Offset(u16, u16, u32, &'static [u8]), } -#[test] -fn test_apple_validate_header_with_signature() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - data.extend_from_slice(b"Apple iOS"); - data.extend_from_slice(&[0x00]); // Padding - data.extend_from_slice(&[0x05, 0x00]); // 5 entries - - assert!(parser.validate_header(&data)); +/// Assemble an Apple MakerNote value: `Apple iOS\0`, the two version bytes, the +/// `MM` order marker, then the IFD at byte 14 -- exactly the layout +/// `MakerNotes.pm:37-46` describes. +fn apple_makernote(entries: &[Entry]) -> Vec { + let n = entries.len(); + let mut out: Vec = Vec::new(); + out.extend_from_slice(b"Apple iOS\x00"); + out.extend_from_slice(&[0x00, 0x01]); + out.extend_from_slice(b"MM"); + out.extend_from_slice(&(n as u16).to_be_bytes()); + // 14 + 2 header, 12 bytes per entry, then the 4-byte next-IFD pointer. + let mut value_pos = 16 + n * 12 + 4; + let mut tail: Vec = Vec::new(); + for e in entries { + match e { + Entry::Inline(tag, ft, count, bytes) => { + out.extend_from_slice(&tag.to_be_bytes()); + out.extend_from_slice(&ft.to_be_bytes()); + out.extend_from_slice(&count.to_be_bytes()); + out.extend_from_slice(bytes); + } + Entry::Offset(tag, ft, count, bytes) => { + out.extend_from_slice(&tag.to_be_bytes()); + out.extend_from_slice(&ft.to_be_bytes()); + out.extend_from_slice(&count.to_be_bytes()); + out.extend_from_slice(&(value_pos as u32).to_be_bytes()); + tail.extend_from_slice(bytes); + value_pos += bytes.len(); + } + } + } + out.extend_from_slice(&[0, 0, 0, 0]); // next IFD + out.extend_from_slice(&tail); + out } -#[test] -fn test_apple_validate_header_without_signature() { - let parser = AppleParser::new(); - let data = vec![0x05, 0x00]; // Just entry count - - assert!(parser.validate_header(&data)); +fn parse(entries: &[Entry]) -> HashMap { + let data = apple_makernote(entries); + let mut tags = HashMap::new(); + AppleParser::new() + .parse(&data, ByteOrder::BigEndian, &mut tags) + .expect("Apple parse"); + tags } +const INT32S: u16 = 9; +const UNDEF: u16 = 7; +const SRATIONAL: u16 = 10; + #[test] -fn test_apple_hdr_image_type_off() { +fn parser_identity() { let parser = AppleParser::new(); - let mut data = Vec::new(); - - // Create minimal IFD with one entry - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - - // HDR tag entry (tag=0x000A, type=3 (SHORT), count=1, value=0 (Off)) - data.extend_from_slice(&[0x0A, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // Value: 0 (inline) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:HDRImageType"), Some(&"Off".to_string())); + assert_eq!(parser.manufacturer_name(), "Apple"); + assert_eq!(parser.tag_prefix(), "Apple:"); } #[test] -fn test_apple_hdr_image_type_smart_hdr() { +fn validate_header_requires_the_apple_ios_signature() { let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x0A, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x04, 0x00, 0x00, 0x00]); // Value: 4 (Smart HDR) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:HDRImageType"), Some(&"Smart HDR".to_string())); + assert!(parser.validate_header(b"Apple iOS\x00\x00\x01MM\x00\x31")); + // MakerNotes.pm:39 keys on the signature, not on a plausible entry count. + assert!(!parser.validate_header(&[0x05, 0x00])); + assert!(!parser.validate_header(b"Nikon\x00\x02\x00\x00\x00II\x2a\x00")); } #[test] -fn test_apple_portrait_mode_natural_light() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x20, 0x00]); // Tag: Portrait Data - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Value: 1 (Natural Light) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); +fn descends_into_the_runtime_binary_plist() { + // Apple.pm:40-43 makes 0x0003 a SubDirectory over %Apple::RunTime, whose + // PROCESS_PROC is PLIST::ProcessBinaryPLIST. + let tags = parse(&[Entry::Offset(0x0003, UNDEF, 104, RUNTIME_BLOB)]); assert_eq!( - tags.get("Apple:PortraitMode"), - Some(&"Natural Light".to_string()) + tags.get("Apple:RunTimeFlags").map(String::as_str), + Some("Valid") ); -} - -#[test] -fn test_apple_portrait_mode_stage_light() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x20, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x04, 0x00, 0x00, 0x00]); // Value: 4 (Stage Light) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); assert_eq!( - tags.get("Apple:PortraitMode"), - Some(&"Stage Light".to_string()) + tags.get("Apple:RunTimeValue").map(String::as_str), + Some("235706184764708") ); -} - -#[test] -fn test_apple_lens_model_wide() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x35, 0x00]); // Tag: Lens Model - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // Value: 0 (Wide) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); assert_eq!( - tags.get("Apple:LensModel"), - Some(&"Wide (Main Camera)".to_string()) + tags.get("Apple:RunTimeScale").map(String::as_str), + Some("1000000000") ); -} - -#[test] -fn test_apple_lens_model_telephoto() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x35, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Value: 1 (Telephoto) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:LensModel"), Some(&"Telephoto".to_string())); -} - -#[test] -fn test_apple_lens_model_ultra_wide() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x35, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x02, 0x00, 0x00, 0x00]); // Value: 2 (Ultra Wide) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:LensModel"), Some(&"Ultra Wide".to_string())); -} - -#[test] -fn test_apple_semantic_style_standard() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x2E, 0x00]); // Tag: Semantic Style - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // Value: 0 (Standard) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); assert_eq!( - tags.get("Apple:SemanticStyle"), - Some(&"Standard".to_string()) + tags.get("Apple:RunTimeEpoch").map(String::as_str), + Some("0") ); + // The pointer itself is never a value. + assert!(!tags.contains_key("Apple:RunTime")); + assert_eq!(tags.len(), 4); } #[test] -fn test_apple_semantic_style_vibrant() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x2E, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x02, 0x00, 0x00, 0x00]); // Value: 2 (Vibrant) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:SemanticStyle"), Some(&"Vibrant".to_string())); +fn serializes_the_semantic_style_plist_dictionary() { + // Apple.pm:276 -- ValueConv => \&ConvertPLIST, then SerializeStruct. + let tags = parse(&[Entry::Offset(0x0040, UNDEF, 80, SEMANTIC_STYLE_BLOB)]); + assert_eq!( + tags.get("Apple:SemanticStyle").map(String::as_str), + Some("{_0=1,_1=0.5,_2=0,_3=2}") + ); } #[test] -fn test_apple_night_mode_on() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x39, 0x00]); // Tag: Night Mode - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Value: 1 (On) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:NightMode"), Some(&"On".to_string())); +fn reads_the_scalar_tags_apple_pm_declares() { + let tags = parse(&[ + // MakerNoteVersion = 14 (Apple_iPhone13Pro.jpg) + Entry::Inline(0x0001, INT32S, 1, [0, 0, 0, 14]), + // AEStable = Yes; Apple.pm:47 PrintConv => { 0 => 'No', 1 => 'Yes' } + Entry::Inline(0x0004, INT32S, 1, [0, 0, 0, 1]), + // AETarget = 198 + Entry::Inline(0x0005, INT32S, 1, [0, 0, 0, 198]), + // ImageCaptureType = Scene; Apple.pm:131 12 => 'Scene' + Entry::Inline(0x0014, INT32S, 1, [0, 0, 0, 12]), + // CameraType = Back Normal; Apple.pm:221 1 => 'Back Normal' + Entry::Inline(0x002e, INT32S, 1, [0, 0, 0, 1]), + // OISMode has no PrintConv in Apple.pm, so it stays numeric + Entry::Inline(0x000f, INT32S, 1, [0, 0, 0, 2]), + ]); + assert_eq!( + tags.get("Apple:MakerNoteVersion").map(String::as_str), + Some("14") + ); + assert_eq!(tags.get("Apple:AEStable").map(String::as_str), Some("Yes")); + assert_eq!(tags.get("Apple:AETarget").map(String::as_str), Some("198")); + assert_eq!( + tags.get("Apple:ImageCaptureType").map(String::as_str), + Some("Scene") + ); + assert_eq!( + tags.get("Apple:CameraType").map(String::as_str), + Some("Back Normal") + ); + assert_eq!(tags.get("Apple:OISMode").map(String::as_str), Some("2")); } #[test] -fn test_apple_scene_detection_food() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x3C, 0x00]); // Tag: Scene Detection - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x08, 0x00, 0x00, 0x00]); // Value: 8 (Food) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:SceneDetection"), Some(&"Food".to_string())); +fn rationals_are_rounded_to_ten_significant_digits() { + // Apple_iPhone13Pro.jpg's AccelerationVector, rational64s[3], which + // ExifTool prints as "-0.9245480894 0.00592365628 0.2826257348" -- + // GetRational64s ends in RoundFloat($num/$den, 10). + static VECTOR: &[u8] = &[ + 0xff, 0xff, 0x42, 0x99, 0x00, 0x00, 0xcc, 0xdc, 0x00, 0x00, 0x0c, 0xcb, 0x00, 0x08, 0x6f, + 0xa4, 0x00, 0x00, 0x10, 0xe7, 0x00, 0x00, 0x3b, 0xce, + ]; + // The same file's FocusDistanceRange, rational64s[2] = 515/128 and 37/256, + // which Apple.pm:98-101 sorts and prints as "0.14 - 4.02 m". + static RANGE: &[u8] = &[ + 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x25, 0x00, 0x00, 0x01, + 0x00, + ]; + let tags = parse(&[ + Entry::Offset(0x0008, SRATIONAL, 3, VECTOR), + Entry::Offset(0x000c, SRATIONAL, 2, RANGE), + ]); + assert_eq!( + tags.get("Apple:AccelerationVector").map(String::as_str), + Some("-0.9245480894 0.00592365628 0.2826257348") + ); + assert_eq!( + tags.get("Apple:FocusDistanceRange").map(String::as_str), + Some("0.14 - 4.02 m") + ); } #[test] -fn test_apple_front_facing_camera() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x32, 0x00]); // Tag: Front Facing Camera - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Value: 1 (Front) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:FacingCamera"), Some(&"Front".to_string())); +fn af_performance_reports_three_numbers_from_two_words() { + // Apple_iPhone13Pro.jpg: int32s[2] = 682, 268435509. Apple.pm:187 splits + // the second word into its top nibble and low 28 bits: "682 1 53". + static PERF: &[u8] = &[0x00, 0x00, 0x02, 0xaa, 0x10, 0x00, 0x00, 0x35]; + let tags = parse(&[Entry::Offset(0x0023, INT32S, 2, PERF)]); + assert_eq!( + tags.get("Apple:AFPerformance").map(String::as_str), + Some("682 1 53") + ); } #[test] -fn test_apple_multiple_tags() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - // Create IFD with multiple entries - data.extend_from_slice(&[0x03, 0x00]); // 3 entries - - // HDR tag - data.extend_from_slice(&[0x0A, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x04, 0x00, 0x00, 0x00]); // Value: 4 (Smart HDR) - - // Lens Model tag - data.extend_from_slice(&[0x35, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Value: 1 (Telephoto) - - // Night Mode tag - data.extend_from_slice(&[0x39, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Value: 1 (On) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.len(), 3); - assert_eq!(tags.get("Apple:HDRImageType"), Some(&"Smart HDR".to_string())); - assert_eq!(tags.get("Apple:LensModel"), Some(&"Telephoto".to_string())); - assert_eq!(tags.get("Apple:NightMode"), Some(&"On".to_string())); +fn reads_a_sixty_four_bit_live_photo_video_index() { + // Apple_iPhone15Pro.jpg stores 0x0017 as int64u[1] = 4294967700, which + // ExifTool reports in full. Format code 16 is ExifTool's `int64u`. + static IDX: &[u8] = &[0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x94]; + let tags = parse(&[Entry::Offset(0x0017, 16, 1, IDX)]); + assert_eq!( + tags.get("Apple:LivePhotoVideoIndex").map(String::as_str), + Some("4294967700") + ); } #[test] -fn test_apple_invalid_data_too_short() { - let parser = AppleParser::new(); - let data = vec![0x01]; // Too short - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_err()); +fn unknown_and_unlisted_ids_report_nothing() { + let tags = parse(&[ + // 0x0002 AEMatrix is Unknown => 1, so ExifTool hides it by default. + Entry::Inline(0x0002, INT32S, 1, [0, 0, 0, 1]), + // 0x0032 is not a tag %Apple::Main has at all. + Entry::Inline(0x0032, INT32S, 1, [0, 0, 0, 1]), + // 0x0035 likewise -- the old registry called it LensModel. + Entry::Inline(0x0035, INT32S, 1, [0, 0, 0, 1]), + ]); + assert!(tags.is_empty(), "unexpected tags: {tags:?}"); } #[test] -fn test_apple_invalid_entry_count() { +fn a_directory_that_is_not_apples_yields_nothing() { let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x00, 0x02]); // 512 entries (invalid - too many) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_err()); + for data in [ + vec![0x01u8], + vec![0x00, 0x02], + b"Apple iOS\x00".to_vec(), + b"Apple iOS\x00\x00\x01MM\x00\x00".to_vec(), // zero entries + ] { + let mut tags = HashMap::new(); + parser + .parse(&data, ByteOrder::BigEndian, &mut tags) + .expect("parse must not error"); + assert!(tags.is_empty(), "unexpected tags from {data:?}: {tags:?}"); + } } From 084d302c902d0c24a141595c19ce366c79394531 Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 21:52:02 -0500 Subject: [PATCH 10/16] feat(vrd): read the CanonVRD Ver2 picture-style block A .VRD recipe written by DPP 2.0 or later carries a second edit section after the fixed 0x272-byte VRD1 record. oxidex read VRD1 and stopped, so combined-samples/CanonVRD.vrd reported 43 of the 108 tags ExifTool does; the missing 65 are the whole of %CanonVRD::Ver2's picture-style block -- PictureStyle, IsCustomPictureStyle, and a nine-tag group per style. Reaching them needs two things. ProcessEditData sizes the three %CanonVRD::Edit sections three different ways (CanonVRD.pm:1596-1610), and the middle one, VRDStampTool, takes its length from an int32u at its own start; skipping it wholesale lands VRD2 four bytes early. And the record itself is FORMAT => 'int16s', so a tag ID is an index rather than a byte offset and the values are signed -- StandardRawColorTone reads -4 on this file, which unsigned would print as 65532. No table is transcribed for this. src/exiftool_tables already carries CanonVRD::Ver2 dumped from ExifTool's own in-memory hash, layout and PrintConv enums included, so the decoder reads that. Ver2 is only read as far as index 0x54. Past it ExifTool leans on ValueConv -- $val/0x400 rendered as a percentage, $val/10, $val/100 -- plus a DataMember-gated SubDirectory at 0xe0 and VRDVersion-conditional branches at 0x5e-0x60. The generator drops a conversion it cannot reproduce exactly, which leaves the raw value behind a real tag name, so emitting those would print a confident wrong number rather than nothing. They stay unread; a test asserts every entry below the bound is a bare int16s or an integer enum, so a future ExifTool release cannot quietly move one across it. Measured on combined-samples/CanonVRD.vrd: 43 matched -> 108 matched, 65 missing -> 0, value-diffs 0, extras 0. The 43 tags already matched are matched by the same values (set comparison, not counts), and ExifTool.jpg and CanonRaw.crw are byte-identical before and after. Co-Authored-By: Claude Opus 5 --- src/parsers/canon_vrd/mod.rs | 122 ++++++++++++++-- src/parsers/canon_vrd/ver2.rs | 266 ++++++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+), 16 deletions(-) create mode 100644 src/parsers/canon_vrd/ver2.rs diff --git a/src/parsers/canon_vrd/mod.rs b/src/parsers/canon_vrd/mod.rs index 3efd5ef8b..ab04f7dde 100644 --- a/src/parsers/canon_vrd/mod.rs +++ b/src/parsers/canon_vrd/mod.rs @@ -15,20 +15,17 @@ //! CanonVRD.pm:2148). Between header and footer sit typed blocks, each an //! int32u type followed by an int32u length. //! -//! Two blocks are decoded here. The `EditData` block (0xffff00f4) yields the -//! `VRD1` section -- the fixed 0x272-byte version 1 record whose 43 tags are -//! `%CanonVRD::Ver1` -- and the `Edit4Data` block (0xffff00f7) is the DPP -//! version 4 "DR4" directory, handled by [`dr4`]. The blocks this module -//! deliberately skips are: +//! Two blocks are decoded here. The `EditData` block (0xffff00f4) carries the +//! sections of `%CanonVRD::Edit`: the fixed 0x272-byte `VRD1` record whose 43 +//! tags are `%CanonVRD::Ver1` ([`ver1_table`]), and the `VRD2` record that DPP +//! 2.0 and later append, whose picture-style tags are `%CanonVRD::Ver2` +//! ([`ver2`]). The `Edit4Data` block (0xffff00f7) is the DPP version 4 "DR4" +//! directory, handled by [`dr4`]. The blocks this module deliberately skips +//! are: //! //! * 0xffff00f5 `IHLData` -- embedded TIFF/EXIF plus preview JPEGs //! * 0xffff00f6 `XMP` -- an XMP packet //! -//! and, inside `EditData`, the `VRDStampTool` and `VRD2` sections (DPP 2.0 and -//! later). No file in the ExifTool sample corpus exercises those paths from a -//! JPEG, so decoding them here could not be verified against ExifTool and is -//! left undone rather than guessed. -//! //! ExifTool reaches the trailer by peeling off whatever trailers follow it one //! at a time and passing the accumulated offset to `ProcessCanonVRD`. oxidex //! has no trailer chain -- see [`crate::parsers::trailer`] -- so this module @@ -39,6 +36,7 @@ pub mod dr4; mod ver1_table; +mod ver2; use crate::core::formatters::numeric_precision::{perl_g, perl_number}; use crate::core::{FileReader, MetadataMap, TagValue}; @@ -281,8 +279,19 @@ fn blocks(trailer: &[u8]) -> Vec<(u32, &[u8])> { /// /// The edit data is a sequence of length-prefixed records, but only record 0 /// carries tags (`next if $recNum`, CanonVRD.pm:1579), so the later records are -/// not walked. Record 0 is then divided into the sections of `%CanonVRD::Edit`, -/// of which the first is the fixed-size `VRD1`. +/// not walked. Record 0 is then divided into the three sections of +/// `%CanonVRD::Edit` (CanonVRD.pm:101-122), each sized a different way: +/// +/// | Index | Name | `Size` | How long it is | +/// |-------|----------------|-------------|---------------------------------| +/// | 0 | `VRD1` | `0x272` | the constant | +/// | 1 | `VRDStampTool` | `0` | an int32u at the section start | +/// | 2 | `VRD2` | `undef` | whatever is left of the record | +/// +/// The walk has to be exact even for the section this module does not decode: +/// `VRDStampTool` is what puts `VRD2` at its true offset, and it is empty far +/// more often than not (its length word reads 0 in combined-samples/ +/// CanonVRD.vrd, which is why `VRD2` starts 4 bytes after `VRD1` ends). fn parse_edit_data(block: &[u8], metadata: &mut MetadataMap) { let Some(rec_len) = be_u32(block, 0).map(|n| n as usize) else { return; @@ -290,10 +299,33 @@ fn parse_edit_data(block: &[u8], metadata: &mut MetadataMap) { let Some(record) = block.get(4..4 + rec_len) else { return; }; - // `%CanonVRD::Edit` index 0: VRD1, Size => 0x272. `$subLen > $maxLen and - // $subLen = $maxLen` truncates it against a short record. - let vrd1 = &record[..VRD1_SIZE.min(record.len())]; - parse_ver1(vrd1, metadata); + + // Index 0, `Size => 0x272`. `$subLen > $maxLen and $subLen = $maxLen` + // truncates every section against a short record. + let vrd1_len = VRD1_SIZE.min(record.len()); + parse_ver1(&record[..vrd1_len], metadata); + let mut sub_start = vrd1_len; + + // Index 1, `Size => 0`: defined but false, so the length is an int32u at + // the section start and the section body follows the length word. ExifTool + // stops the walk outright when that word does not fit + // (`last unless $subStart + 4 <= $recLen`). + let Some(stamp_len) = be_u32(record, sub_start).map(|n| n as usize) else { + return; + }; + // `$maxLen` is taken before the length word is skipped. + let stamp_len = stamp_len.min(record.len() - sub_start); + sub_start += 4; + // The StampTool section itself is not decoded -- `%CanonVRD::StampTool` + // holds one tag, `StampToolCount`, and no file in the sample corpus carries + // a non-empty section to check it against. + sub_start += stamp_len; + + // Index 2, `Size => undef`: the rest of the record. + let Some(vrd2) = record.get(sub_start..) else { + return; + }; + ver2::parse_ver2(vrd2, metadata); } /// `%CanonVRD::Ver1` read as `ProcessBinaryData` would (big-endian). @@ -727,6 +759,64 @@ mod tests { assert!(m.get("CanonVRD:WorkColorSpace").is_none()); } + /// The whole point of walking `VRDStampTool` is to land `VRD2` on the right + /// byte, so build the record combined-samples/CanonVRD.vrd actually has -- + /// `VRD1`, an empty stamp section, then the real 178-byte `VRD2` -- and + /// check both sections come out at once. + #[test] + fn test_edit_record_reaches_ver2_past_the_stamp_section() { + let mut record = exiftool_jpg_vrd1(); + // %CanonVRD::Edit index 1: the length word, reading 0 as it does in + // that file, and no body. + record.extend_from_slice(&0u32.to_be_bytes()); + record.extend_from_slice(&ver2::CANONVRD_VRD_VER2); + + let mut file = b"\xff\xd8\xff\xd9".to_vec(); + file.extend_from_slice(&trailer(&edit_block(&record))); + let m = parse_canon_vrd_trailer(&file); + + // The 43 VRD1 tags are untouched... + assert_eq!(m.get_string("CanonVRD:VRDVersion"), Some("1.0.0")); + assert_eq!(m.get_string("CanonVRD:WorkColorSpace"), Some("sRGB")); + // ...and the 65 VRD2 tags now land alongside them. + assert_eq!(m.get_string("CanonVRD:PictureStyle"), Some("Standard")); + assert_eq!(m.get_integer("CanonVRD:StandardRawColorTone"), Some(-4)); + assert_eq!(m.get_integer("CanonVRD:CustomOutputShadowPoint"), Some(0)); + assert_eq!(m.len(), 43 + 65); + } + + /// A non-empty stamp section pushes `VRD2` along by its own length as well + /// as by the length word, which is the arithmetic a fixed offset would get + /// wrong. + #[test] + fn test_a_non_empty_stamp_section_shifts_ver2() { + let mut record = exiftool_jpg_vrd1(); + let stamp = [0xabu8; 12]; + record.extend_from_slice(&(stamp.len() as u32).to_be_bytes()); + record.extend_from_slice(&stamp); + record.extend_from_slice(&ver2::CANONVRD_VRD_VER2); + + let mut file = b"\xff\xd8\xff\xd9".to_vec(); + file.extend_from_slice(&trailer(&edit_block(&record))); + let m = parse_canon_vrd_trailer(&file); + + assert_eq!(m.get_string("CanonVRD:PictureStyle"), Some("Standard")); + assert_eq!(m.get_integer("CanonVRD:StandardRawColorTone"), Some(-4)); + assert_eq!(m.len(), 43 + 65); + } + + /// A record that stops at the end of `VRD1` has no stamp length word at + /// all, which is where ExifTool abandons the section walk rather than + /// reading past the record. + #[test] + fn test_record_ending_at_ver1_yields_no_ver2_tags() { + let mut file = b"\xff\xd8\xff\xd9".to_vec(); + file.extend_from_slice(&trailer(&edit_block(&exiftool_jpg_vrd1()))); + let m = parse_canon_vrd_trailer(&file); + assert_eq!(m.len(), 43); + assert!(m.get("CanonVRD:PictureStyle").is_none()); + } + #[test] fn test_vrd_version_formatting() { assert_eq!(vrd_version("100"), "1.0.0"); diff --git a/src/parsers/canon_vrd/ver2.rs b/src/parsers/canon_vrd/ver2.rs new file mode 100644 index 000000000..7647bb5b7 --- /dev/null +++ b/src/parsers/canon_vrd/ver2.rs @@ -0,0 +1,266 @@ +//! `%Image::ExifTool::CanonVRD::Ver2` -- the DPP 2.0 picture-style block. +//! +//! CanonVRD.pm:485-974. Unlike [`super::ver1_table`], nothing here is +//! transcribed: the layout comes from `exiftool_tables::find_table("CanonVRD", +//! "Ver2")`, which the generator dumped out of ExifTool's own in-memory hash. +//! That table already carries what this record needs -- `FORMAT => 'int16s'` +//! (so a tag ID is an index, and the byte offset is twice it), `FIRST_ENTRY => +//! 0`, and the `PrintConv` enums -- so re-deriving it by hand would only add a +//! second, weaker copy. See `docs/TRANSCRIPTION.md`. +//! +//! # Why this stops at index 0x54 +//! +//! The generated schema describes byte layout and the conversions the +//! transcription pipeline could reproduce *exactly*; a `ValueConv` it could not +//! reproduce is dropped, leaving `PrintConv::None` and the raw value. That is +//! the right default for the generator but it is not safe to emit blindly, +//! because the entries after this record's DPP 2.0 section lean on `ValueConv` +//! heavily and the dropped conversion is invisible at the call site: +//! +//! * 0x66-0x68 `ChromaticAberration`, `DistortionCorrection`, +//! `PeripheralIllumination` -- `$val / 0x400` then `sprintf("%.0f%%")`, so +//! ExifTool prints "100%" where the raw value is 1024 (CanonVRD.pm:726-748). +//! * 0x75-0x85 the `*RawHighlight` / `*RawShadow` pairs -- `$val / 10` +//! (CanonVRD.pm:786-880). +//! * 0x8b `AngleAdj` -- `$val / 100` (CanonVRD.pm:881-886). +//! * 0x69 `AberrationCorrectionDistance` and 0xde `DLOShootingDistance` -- a +//! `RawConv` that suppresses 0x7fff entirely, then `1 - $val / 0x400` +//! (CanonVRD.pm:752-759, 941-949). +//! * 0xe0 `DLOInfo` -- a `SubDirectory` gated on the `DLOOn` DataMember with a +//! `Hook` that advances `$varSize`, which the generated schema records as a +//! plain int16s field (CanonVRD.pm:957-962). +//! * 0x5e-0x60 the noise-reduction tags -- `Condition`al lists keyed on +//! `VRDVersion`, which the generator dropped rather than pick a branch. +//! +//! Emitting any of those from the raw value would put a confident wrong number +//! under a real ExifTool tag name, which is the one failure mode AGENTS.md +//! rules out. They are left unread instead. Index 0x54 is the last entry before +//! that starts: CanonVRD.pm:606 ends the DPP 2.0 record at index 0x59, and its +//! remaining entries are `Unknown => 1` (0x45-0x4b) or the `var_int16u` +//! `CustomPictureStyleData` at 0x58, none of which ExifTool reports by default. + +use crate::core::{MetadataMap, TagValue}; +use crate::exiftool_tables::{DecodedValue, PrintConv, decode_binary_table, find_table}; +use crate::io::ByteOrder; + +/// Last `%Ver2` index whose value the generated table describes losslessly. +/// +/// `CustomOutputShadowPoint` (CanonVRD.pm:604). See the module comment for why +/// the later entries are not read. +const LAST_LOSSLESS_INDEX: i64 = 0x54; + +/// Reads `%CanonVRD::Ver2` as `ProcessBinaryData` would. +/// +/// The record is big-endian like the rest of the trailer +/// (`SetByteOrder('MM')`, CanonVRD.pm:2148). `decode_binary_table` already +/// refuses a field whose bytes fall outside `record`, which is how a short +/// section -- the 178-byte DPP 2.0 form this file carries, against a table that +/// runs to index 0xe9 -- drops its later tags exactly as ExifTool does. +pub(super) fn parse_ver2(record: &[u8], metadata: &mut MetadataMap) { + let Some(table) = find_table("CanonVRD", "Ver2") else { + return; + }; + for decoded in decode_binary_table(table, record, ByteOrder::Big) { + let field = decoded.field; + if field.index > LAST_LOSSLESS_INDEX { + continue; + } + // Every in-scope entry takes the record's int16s FORMAT; anything else + // would mean the table moved under us. + let DecodedValue::Integer(raw) = &decoded.raw else { + continue; + }; + let raw = *raw; + let value = match field.print_conv { + // No conversion: ExifTool prints the int16s as it stands. + PrintConv::None => TagValue::Integer(raw), + PrintConv::IntEnum(_) => TagValue::String( + decoded + .apply_print_conv_to_raw() + // ExifTool's fallback for a value the hash does not list. + .unwrap_or_else(|| format!("Unknown ({raw})")), + ), + // Unreachable below LAST_LOSSLESS_INDEX, and guessing at a + // conversion this module has not accounted for is exactly what the + // index bound exists to prevent. + PrintConv::StrEnum(_) | PrintConv::Expr(_) => continue, + }; + metadata.insert(format!("CanonVRD:{}", field.name), value); + } +} + +/// The `VRD2` section of combined-samples/CanonVRD.vrd, byte for byte: file +/// offset 0x29e, the 178 bytes ExifTool reports as `[BinaryData directory, 178 +/// bytes, Big-endian]` under `VRD2 (SubDirectory)` in `exiftool -v3`. +/// +/// [`super`] composes it into a whole edit record to check the section walk. +#[cfg(test)] +pub(super) const CANONVRD_VRD_VER2: [u8; 178] = [ + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x0f, 0xff, 0x00, 0x00, 0x0f, 0xff, 0x00, 0x00, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x0f, 0xff, 0x00, 0x00, 0x0f, 0xff, 0x00, 0x00, 0x00, 0x04, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x0f, 0xff, 0x00, 0x06, 0x0f, 0xff, 0x00, 0x00, 0x00, 0x04, + 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x0f, 0xff, 0x00, 0x06, 0x0f, 0xff, 0x00, 0x00, + 0x00, 0x04, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x0f, 0xff, 0x00, 0x06, 0x0f, 0xff, + 0x00, 0x00, 0x00, 0x04, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x0f, 0xff, 0x00, 0x06, + 0x0f, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x04, 0x0f, 0xff, + 0x00, 0x00, 0x0f, 0xff, 0x00, 0x00, 0x00, 0x04, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, + 0x0f, 0xff, 0x00, 0x06, 0x0f, 0xff, 0x00, 0x00, 0x00, 0x04, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x07, 0x0f, 0xff, 0x00, 0x06, 0x0f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::exiftool_tables::Fmt; + + /// Every assertion is `exiftool -a -G1 -s combined-samples/CanonVRD.vrd` + /// (ExifTool 13.55), value for value. These 65 tags are the whole of what + /// the DPP 2.0 section of that file reports. + #[test] + fn canonvrd_vrd_ver2_matches_exiftool() { + let mut m = MetadataMap::new(); + parse_ver2(&CANONVRD_VRD_VER2, &mut m); + + // The two tags that name the record's subject. + assert_eq!(m.get_string("CanonVRD:PictureStyle"), Some("Standard")); + assert_eq!(m.get_string("CanonVRD:IsCustomPictureStyle"), Some("No")); + + // Five styles share a nine-tag shape and a "Raw" infix. Standard's + // ColorTone is negative, which is the whole reason the record's FORMAT + // of int16s matters: read unsigned it would print 65532. + for (style, color_tone, saturation, sharpness, raw_shadow) in [ + ("Standard", -4, 0, 1, 0), + ("Portrait", 4, 2, 7, 6), + ("Landscape", 4, 2, 7, 6), + ("Neutral", 4, 2, 7, 6), + ("Faithful", 4, 2, 7, 6), + ] { + let get = |suffix: &str| m.get_integer(&format!("CanonVRD:{style}{suffix}")); + assert_eq!(get("RawColorTone"), Some(color_tone), "{style}"); + assert_eq!(get("RawSaturation"), Some(saturation), "{style}"); + assert_eq!(get("RawContrast"), Some(0), "{style}"); + assert_eq!( + m.get_string(&format!("CanonVRD:{style}RawLinear")), + Some("No"), + "{style}" + ); + assert_eq!(get("RawSharpness"), Some(sharpness), "{style}"); + assert_eq!(get("RawHighlightPoint"), Some(4095), "{style}"); + assert_eq!(get("RawShadowPoint"), Some(raw_shadow), "{style}"); + assert_eq!(get("OutputHighlightPoint"), Some(4095), "{style}"); + assert_eq!(get("OutputShadowPoint"), Some(0), "{style}"); + } + + // Monochrome swaps ColorTone and Saturation for the two filter enums, + // both of which are keyed by negative values. + assert_eq!( + m.get_string("CanonVRD:MonochromeFilterEffect"), + Some("Yellow") + ); + assert_eq!( + m.get_string("CanonVRD:MonochromeToningEffect"), + Some("Purple") + ); + assert_eq!(m.get_integer("CanonVRD:MonochromeContrast"), Some(3)); + assert_eq!(m.get_string("CanonVRD:MonochromeLinear"), Some("No")); + assert_eq!(m.get_integer("CanonVRD:MonochromeSharpness"), Some(4)); + assert_eq!( + m.get_integer("CanonVRD:MonochromeRawHighlightPoint"), + Some(4095) + ); + assert_eq!(m.get_integer("CanonVRD:MonochromeRawShadowPoint"), Some(0)); + assert_eq!( + m.get_integer("CanonVRD:MonochromeOutputHighlightPoint"), + Some(4095) + ); + assert_eq!( + m.get_integer("CanonVRD:MonochromeOutputShadowPoint"), + Some(0) + ); + + // Custom drops the "Raw" from its first three names. + assert_eq!(m.get_integer("CanonVRD:CustomColorTone"), Some(4)); + assert_eq!(m.get_integer("CanonVRD:CustomSaturation"), Some(2)); + assert_eq!(m.get_integer("CanonVRD:CustomContrast"), Some(0)); + assert_eq!(m.get_string("CanonVRD:CustomLinear"), Some("No")); + assert_eq!(m.get_integer("CanonVRD:CustomSharpness"), Some(7)); + assert_eq!( + m.get_integer("CanonVRD:CustomRawHighlightPoint"), + Some(4095) + ); + assert_eq!(m.get_integer("CanonVRD:CustomRawShadowPoint"), Some(6)); + assert_eq!( + m.get_integer("CanonVRD:CustomOutputHighlightPoint"), + Some(4095) + ); + assert_eq!(m.get_integer("CanonVRD:CustomOutputShadowPoint"), Some(0)); + + // ExifTool reports exactly these 65 tags from this section: nothing + // past index 0x54 fits in 178 bytes, and the entries it skips inside + // the section are its own `Unknown => 1` ones. + assert_eq!(m.len(), 65); + } + + /// The bound this module draws is only sound if every entry below it is a + /// bare int16s or an integer enum. An ExifTool release that added a + /// `ValueConv` under the bound would otherwise start emitting raw values + /// under real tag names without any test noticing. + #[test] + fn no_entry_below_the_bound_needs_a_conversion_we_drop() { + let table = find_table("CanonVRD", "Ver2").expect("generated CanonVRD::Ver2"); + assert_eq!(table.default_format, Fmt::Int16s); + assert_eq!(table.first_entry, 0); + + let mut in_scope = 0; + for field in table.fields { + if field.index > LAST_LOSSLESS_INDEX { + continue; + } + in_scope += 1; + assert!(field.sub.is_none(), "{} is a bit field", field.name); + assert_eq!(field.count, 1, "{} is an array", field.name); + assert!( + field.format.is_none(), + "{} overrides the record format", + field.name + ); + assert!( + matches!(field.print_conv, PrintConv::None | PrintConv::IntEnum(_)), + "{} carries a conversion this module does not apply", + field.name + ); + } + assert_eq!(in_scope, 65, "the DPP 2.0 picture-style block is 65 tags"); + } + + /// A section shorter than the full DPP 2.0 record must lose its later tags + /// rather than read past the end, which is what ExifTool's own bounds check + /// does for the truncated sections real files carry. + #[test] + fn a_short_section_drops_its_later_tags() { + let mut m = MetadataMap::new(); + parse_ver2(&CANONVRD_VRD_VER2[..0x20], &mut m); + assert_eq!(m.get_string("CanonVRD:PictureStyle"), Some("Standard")); + assert_eq!(m.get_integer("CanonVRD:StandardRawColorTone"), Some(-4)); + // StandardRawLinear sits at index 0x10, i.e. byte 0x20. + assert!(m.get("CanonVRD:StandardRawLinear").is_none()); + + let mut empty = MetadataMap::new(); + parse_ver2(&[], &mut empty); + assert!(empty.is_empty()); + } + + /// A value absent from a PrintConv hash prints ExifTool's fallback rather + /// than being dropped or silently rendered as the raw number. + #[test] + fn a_value_outside_a_printconv_hash_reports_unknown() { + let mut record = CANONVRD_VRD_VER2; + // PictureStyle is index 0x02, so byte 4. The hash runs 0 through 7. + record[4..6].copy_from_slice(&42i16.to_be_bytes()); + let mut m = MetadataMap::new(); + parse_ver2(&record, &mut m); + assert_eq!(m.get_string("CanonVRD:PictureStyle"), Some("Unknown (42)")); + } +} From b0cf2fdfb38f547fadd3e230a2bf4db9ffde8d9d Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 21:52:27 -0500 Subject: [PATCH 11/16] feat(lfp): read Lytro light-field metadata oxidex named the LFP file type but had no FileFormat variant, so format_dispatch fell through to the unsupported arm and the file yielded no tags at all. This adds the variant, the \x89LFP magic that routes to a parser, and a reader transcribed from Image::ExifTool::Lytro (Lytro.pm 1.04). The container walk follows ProcessLFP (Lytro.pm:134-174): 16-byte segment headers, a big-endian length, an 80-byte sha1 ExifTool discards, then a body that is either JSON metadata or an embedded JPEG, padded to 16 bytes. Tag names come from ExtractTags (Lytro.pm:104-128), which flattens the JSON with ucfirst on each key, drops punctuation while upcasing what follows, and strips a leading Devices. Numbers keep their source token text. These files carry more precision than an f64 roundtrip preserves -- "gamma" : 0.41666001081466674805 is the literal bytes on disk -- and ExifTool reports such values unchanged because it never reformats what it did not compute. Only the tags carrying a ValueConv or PrintConv are parsed to f64. Measured on the ExifTool corpus sample (per file, keyed Group1:Name, File/System excluded): Lytro.lfp matched 0 -> 95 missing 95 -> 0 extra 0 -> 0 That is 85 Lytro tags plus the 10 Composite tags, which the existing composite engine derives once the base tags exist. Zero regressions: the matched key sets for CanonVRD.vrd, HTML.html and LNK.lnk are unchanged. Co-Authored-By: Claude Opus 5 --- src/core/file_format.rs | 5 + src/core/format_dispatch.rs | 2 + src/parsers/detection/signatures.rs | 5 + src/parsers/specialized/lytro.rs | 1017 +++++++++++++++++++++++++++ src/parsers/specialized/mod.rs | 2 + 5 files changed, 1031 insertions(+) create mode 100644 src/parsers/specialized/lytro.rs diff --git a/src/core/file_format.rs b/src/core/file_format.rs index dc1915a43..7cc292eec 100644 --- a/src/core/file_format.rs +++ b/src/core/file_format.rs @@ -252,6 +252,9 @@ pub enum FileFormat { /// Windows shortcut (.lnk) LNK, + /// Lytro Light Field Picture (.lfp, .lfr) + LFP, + /// SQLite database (.db, .sqlite, .sqlite3) SQLite, @@ -378,6 +381,7 @@ impl FileFormat { FileFormat::ICS => "iCalendar", FileFormat::EML => "EML", FileFormat::TXT => "TXT", + FileFormat::LFP => "LFP", FileFormat::LNK => "Windows Shortcut", FileFormat::SQLite => "SQLite", FileFormat::Prefetch => "Windows Prefetch", @@ -478,6 +482,7 @@ impl FileFormat { FileFormat::ICS => &["ics", "ical"], FileFormat::EML => &["eml", "email"], FileFormat::TXT => &["txt", "text"], + FileFormat::LFP => &["lfp", "lfr"], FileFormat::LNK => &["lnk"], FileFormat::SQLite => &["db", "sqlite", "sqlite3"], FileFormat::Prefetch => &["pf"], diff --git a/src/core/format_dispatch.rs b/src/core/format_dispatch.rs index 0805400f2..8b6b2a610 100644 --- a/src/core/format_dispatch.rs +++ b/src/core/format_dispatch.rs @@ -59,6 +59,7 @@ use crate::parsers::specialized::fits::parse_fits_metadata; use crate::parsers::specialized::gltf::parse_gltf_metadata; use crate::parsers::specialized::hdf5::parse_hdf5_metadata; use crate::parsers::specialized::lnk::parse_lnk_metadata; +use crate::parsers::specialized::lytro::parse_lytro_metadata; use crate::parsers::specialized::obj::parse_obj_metadata; use crate::parsers::specialized::pcap::parse_pcap_metadata; use crate::parsers::specialized::plist::parse_plist_metadata; @@ -171,6 +172,7 @@ pub fn dispatch_format_parser(reader: &dyn FileReader, format: FileFormat) -> Re FileFormat::VCF => convert_string_error(parse_vcf_metadata(reader), "VCF"), FileFormat::TXT => convert_string_error(parse_txt_metadata(reader), "TXT"), FileFormat::LNK => convert_string_error(parse_lnk_metadata(reader), "LNK"), + FileFormat::LFP => convert_string_error(parse_lytro_metadata(reader), "LFP"), FileFormat::SQLite => convert_string_error(parse_sqlite_metadata(reader), "SQLite"), FileFormat::ICS => convert_string_error(parse_ics_metadata(reader), "ICS"), FileFormat::EML => convert_string_error(parse_eml_metadata(reader), "EML"), diff --git a/src/parsers/detection/signatures.rs b/src/parsers/detection/signatures.rs index f26ba533a..54a536d40 100644 --- a/src/parsers/detection/signatures.rs +++ b/src/parsers/detection/signatures.rs @@ -68,6 +68,11 @@ pub static SIMPLE_SIGNATURES: &[Signature] = &[ signature!(b"\x76\x2F\x31\x01", 0, FileFormat::EXR), signature!(b"\x42\x50\x47\xFB", 0, FileFormat::BPG), signature!(b"\xFF\x0A", 0, FileFormat::JXL), + // Lytro Light Field Picture. ExifTool.pm gives the magic as + // `\x89LFP\x0d\x0a\x1a\x0a`, which Lytro.pm:142 re-checks before reading the + // container. `filetype::tables` already names the format; this entry is what + // routes the file to a parser. + signature!(b"\x89LFP\x0d\x0a\x1a\x0a", 0, FileFormat::LFP), // Audio/Video formats signature!(b"fLaC", 0, FileFormat::FLAC), signature!(b"ID3", 0, FileFormat::MP3), diff --git a/src/parsers/specialized/lytro.rs b/src/parsers/specialized/lytro.rs new file mode 100644 index 000000000..7bd407cb7 --- /dev/null +++ b/src/parsers/specialized/lytro.rs @@ -0,0 +1,1017 @@ +//! Lytro Light Field Picture (LFP) container reader. +//! +//! Transcribed from ExifTool's `lib/Image/ExifTool/Lytro.pm` (version 1.04). +//! Line references below are to that file. +//! +//! # Container +//! +//! An LFP file is a 16-byte header (`\x89LFP\x0d\x0a\x1a\x0a` plus a version +//! word) followed by a chain of self-describing sections (`ProcessLFP`, +//! Lytro.pm:134). Each section is a 16-byte record header whose first three +//! bytes are `\x89LF` and whose last four are a big-endian payload length, an +//! 80-byte SHA-1 identifier, the payload itself, then padding to the next +//! 16-byte boundary. A payload beginning `{` + whitespace + `"` is JSON +//! metadata; one beginning `\xff\xd8\xff` is an embedded JPEG preview. +//! +//! # Tag names +//! +//! ExifTool flattens the JSON object graph into tag names rather than +//! declaring a fixed table: `ExtractTags` (Lytro.pm:104) concatenates +//! `ucfirst` of each key down the path, so `mla.sensorOffset.x` becomes +//! `MlaSensorOffsetX`. A path that matches one of the entries transcribed in +//! [`TAG_TABLE`] takes that entry's ExifTool name and conversion; everything +//! else is emitted under a name derived by [`derive_name`]. This is why the +//! table below is short even though the format yields ~85 tags -- the table is +//! the exception list, not the tag list. +//! +//! # Numeric text +//! +//! `Image::ExifTool::Import::ReadJSONObject` returns numbers, `true`, `false` +//! and `null` as their *raw source text* (Import.pm:234-238 assembles the token +//! by `substr` and never numifies it). A tag with no ValueConv therefore prints +//! the literal characters from the file, which is how ExifTool reports +//! `AccelerometerX` as `-0.039215687662363052368` -- 21 significant digits, far +//! more than a double survives. [`JsonValue::Raw`] preserves that text verbatim +//! so those tags are copied, never reformatted. Only the handful of tags with a +//! ValueConv or PrintConv go through `f64`. + +#![allow(dead_code)] + +use crate::core::{FileFormat, FileReader, FormatParser, MetadataMap, TagValue}; +use crate::error::{ExifToolError, Result}; + +/// File header magic (Lytro.pm:142). +const LFP_MAGIC: &[u8] = b"\x89LFP\x0d\x0a\x1a\x0a"; + +/// Section record header magic (Lytro.pm:147). +const SECTION_MAGIC: &[u8] = b"\x89LF"; + +/// Bytes in a section record header: 12 bytes of type/version plus a +/// big-endian u32 length (Lytro.pm:146-148). +const SECTION_HEADER_LEN: usize = 16; + +/// Bytes of SHA-1 identifier following each section header (Lytro.pm:150). +const SECTION_ID_LEN: usize = 80; + +/// Payload size above which ExifTool seeks past the section instead of +/// buffering it (Lytro.pm:155). +const MAX_BUFFERED_SECTION: u32 = 20_000_000; + +// --------------------------------------------------------------------------- +// Minimal ordered JSON reader +// --------------------------------------------------------------------------- + +/// A JSON value in the shape `ReadJSONObject` produces. +/// +/// Objects keep their keys in document order because ExifTool walks them with +/// `OrderedKeys` (Lytro.pm:109), and order decides which of two same-named tags +/// survives: `modes.regionOfInterestArray` holds two objects that both define +/// `type`, and ExifTool reports the later one. +#[derive(Debug, Clone, PartialEq)] +enum JsonValue { + /// A quoted string, with escapes already resolved. + Str(String), + /// A number, `true`, `false` or `null`, kept as the exact source text. + Raw(String), + /// An array, in document order. + Array(Vec), + /// An object, in document order. + Object(Vec<(String, JsonValue)>), +} + +/// Recursive-descent reader for the JSON subset LFP files carry. +struct JsonReader<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> JsonReader<'a> { + fn new(text: &'a str) -> Self { + JsonReader { + bytes: text.as_bytes(), + pos: 0, + } + } + + fn skip_ws(&mut self) { + while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() { + self.pos += 1; + } + } + + fn peek(&mut self) -> Option { + self.skip_ws(); + self.bytes.get(self.pos).copied() + } + + fn expect(&mut self, want: u8) -> Option<()> { + if self.peek()? == want { + self.pos += 1; + Some(()) + } else { + None + } + } + + fn parse_value(&mut self) -> Option { + match self.peek()? { + b'{' => self.parse_object(), + b'[' => self.parse_array(), + b'"' => self.parse_string().map(JsonValue::Str), + _ => self.parse_raw(), + } + } + + fn parse_object(&mut self) -> Option { + self.expect(b'{')?; + let mut entries = Vec::new(); + if self.peek()? == b'}' { + self.pos += 1; + return Some(JsonValue::Object(entries)); + } + loop { + let key = self.parse_string()?; + self.expect(b':')?; + let value = self.parse_value()?; + entries.push((key, value)); + match self.peek()? { + b',' => self.pos += 1, + b'}' => { + self.pos += 1; + return Some(JsonValue::Object(entries)); + } + _ => return None, + } + } + } + + fn parse_array(&mut self) -> Option { + self.expect(b'[')?; + let mut items = Vec::new(); + if self.peek()? == b']' { + self.pos += 1; + return Some(JsonValue::Array(items)); + } + loop { + items.push(self.parse_value()?); + match self.peek()? { + b',' => self.pos += 1, + b']' => { + self.pos += 1; + return Some(JsonValue::Array(items)); + } + _ => return None, + } + } + } + + fn parse_string(&mut self) -> Option { + self.expect(b'"')?; + let mut out = String::new(); + loop { + let c = *self.bytes.get(self.pos)?; + self.pos += 1; + match c { + b'"' => return Some(out), + b'\\' => { + let esc = *self.bytes.get(self.pos)?; + self.pos += 1; + match esc { + // Import.pm:222 maps exactly this set; any other + // escaped character stands for itself. + b't' => out.push('\t'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'u' => { + let hex = self.bytes.get(self.pos..self.pos + 4)?; + let hex = std::str::from_utf8(hex).ok()?; + let code = u32::from_str_radix(hex, 16).ok()?; + self.pos += 4; + out.push(char::from_u32(code)?); + } + other => out.push(other as char), + } + } + _ => { + // Copy the whole UTF-8 sequence, not just the lead byte. + let start = self.pos - 1; + let len = utf8_len(c); + let seq = self.bytes.get(start..start + len)?; + out.push_str(std::str::from_utf8(seq).ok()?); + self.pos = start + len; + } + } + } + } + + /// Read a number, `true`, `false` or `null` as literal text. + /// + /// Import.pm:235 terminates the token on whitespace, `:`, `,`, `}` or `]` + /// and keeps everything before it, which is reproduced here so the exact + /// digits from the file reach the tag value. + fn parse_raw(&mut self) -> Option { + self.skip_ws(); + let start = self.pos; + while let Some(&c) = self.bytes.get(self.pos) { + if c.is_ascii_whitespace() || matches!(c, b':' | b',' | b'}' | b']') { + break; + } + self.pos += 1; + } + if self.pos == start { + return None; + } + let text = std::str::from_utf8(self.bytes.get(start..self.pos)?).ok()?; + Some(JsonValue::Raw(text.to_string())) + } +} + +/// Length in bytes of the UTF-8 sequence beginning with `lead`. +fn utf8_len(lead: u8) -> usize { + match lead { + 0x00..=0x7f => 1, + 0xc0..=0xdf => 2, + 0xe0..=0xef => 3, + 0xf0..=0xf7 => 4, + // A continuation or invalid byte: consume one byte so the reader + // always advances rather than looping. + _ => 1, + } +} + +/// Parse a complete JSON document. +fn parse_json(text: &str) -> Option { + let mut reader = JsonReader::new(text); + reader.parse_value() +} + +// --------------------------------------------------------------------------- +// Tag table +// --------------------------------------------------------------------------- + +/// The value conversion a table entry applies. +/// +/// Each variant names the ExifTool ValueConv/PrintConv pair it reproduces; the +/// bodies live in [`convert`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Conv { + /// No ValueConv and no PrintConv: the source text is the value. + Raw, + /// `XMP::ConvertXMPDate` then `ConvertDateTime` (Lytro.pm:55-56). + XmpDate, + /// `Exif::PrintFNumber` (Lytro.pm:60). + FNumber, + /// `$val * 1000` metres to mm, printed `%.1f mm` (Lytro.pm:64-65). + FocalLength, + /// `sprintf("%.1f C",$val)` (Lytro.pm:69, :73). + Celsius, + /// `Exif::PrintExposureTime` (Lytro.pm:77, :81). + ExposureTime, + /// `25.4 / $val / 1000`, metres per pixel to pixels per inch (Lytro.pm:86). + FocalPlaneRes, + /// `sprintf("%+.1f", $val)` (Lytro.pm:90-91). + ExposureBias, + /// `PrintConv => { 1 => 'Horizontal (normal)' }` (Lytro.pm:95-97). + Orientation, +} + +/// One transcribed entry of `%Image::ExifTool::Lytro::Main`. +struct LytroTag { + /// The flattened JSON path ExifTool uses as the table key. + key: &'static str, + /// The ExifTool tag name (`Name`, or the key itself when none is given). + name: &'static str, + /// The conversion ExifTool declares for this entry. + conv: Conv, +} + +/// `%Image::ExifTool::Lytro::Main`, Lytro.pm:42-98. +/// +/// `JSONMetadata` and `EmbeddedImage` are handled by the container walk rather +/// than the JSON flattener, so they are not listed here. +/// +/// Entries without a `Name` in the Perl table (the two exposure biases) keep +/// their key as the tag name, which is what ExifTool does. +static TAG_TABLE: &[LytroTag] = &[ + LytroTag { + key: "Type", + name: "CameraType", + conv: Conv::Raw, + }, + LytroTag { + key: "CameraMake", + name: "Make", + conv: Conv::Raw, + }, + LytroTag { + key: "CameraModel", + name: "Model", + conv: Conv::Raw, + }, + LytroTag { + key: "CameraSerialNumber", + name: "SerialNumber", + conv: Conv::Raw, + }, + LytroTag { + key: "CameraFirmware", + name: "FirmwareVersion", + conv: Conv::Raw, + }, + LytroTag { + key: "DevicesAccelerometerSampleArrayTime", + name: "AccelerometerTime", + conv: Conv::Raw, + }, + LytroTag { + key: "DevicesAccelerometerSampleArrayX", + name: "AccelerometerX", + conv: Conv::Raw, + }, + LytroTag { + key: "DevicesAccelerometerSampleArrayY", + name: "AccelerometerY", + conv: Conv::Raw, + }, + LytroTag { + key: "DevicesAccelerometerSampleArrayZ", + name: "AccelerometerZ", + conv: Conv::Raw, + }, + LytroTag { + key: "DevicesClockZuluTime", + name: "DateTimeOriginal", + conv: Conv::XmpDate, + }, + LytroTag { + key: "DevicesLensFNumber", + name: "FNumber", + conv: Conv::FNumber, + }, + LytroTag { + key: "DevicesLensFocalLength", + name: "FocalLength", + conv: Conv::FocalLength, + }, + LytroTag { + key: "DevicesLensTemperature", + name: "LensTemperature", + conv: Conv::Celsius, + }, + LytroTag { + key: "DevicesSocTemperature", + name: "SocTemperature", + conv: Conv::Celsius, + }, + LytroTag { + key: "DevicesShutterFrameExposureDuration", + name: "FrameExposureTime", + conv: Conv::ExposureTime, + }, + LytroTag { + key: "DevicesShutterPixelExposureDuration", + name: "ExposureTime", + conv: Conv::ExposureTime, + }, + LytroTag { + key: "DevicesSensorPixelPitch", + name: "FocalPlaneXResolution", + conv: Conv::FocalPlaneRes, + }, + LytroTag { + key: "DevicesSensorSensorSerial", + name: "SensorSerialNumber", + conv: Conv::Raw, + }, + LytroTag { + key: "DevicesSensorIso", + name: "ISO", + conv: Conv::Raw, + }, + LytroTag { + key: "ImageLimitExposureBias", + name: "ImageLimitExposureBias", + conv: Conv::ExposureBias, + }, + LytroTag { + key: "ImageModulationExposureBias", + name: "ImageModulationExposureBias", + conv: Conv::ExposureBias, + }, + LytroTag { + key: "ImageOrientation", + name: "Orientation", + conv: Conv::Orientation, + }, +]; + +/// Look up a flattened path in the transcribed table. +fn table_entry(key: &str) -> Option<&'static LytroTag> { + TAG_TABLE.iter().find(|t| t.key == key) +} + +// --------------------------------------------------------------------------- +// Name derivation for paths not in the table +// --------------------------------------------------------------------------- + +/// The literal ExifTool strips from generated names (Lytro.pm:116). +const VENDOR_INFIX: &str = "ParametersVendorContentComLytroTags"; + +/// Derive the tag name for a flattened path with no table entry. +/// +/// Reproduces Lytro.pm:115-118: +/// +/// ```text +/// ($name = $tag) =~ s/[^-_a-zA-Z0-9](.?)/\U$1/g; +/// $name =~ s/ParametersVendorContentComLytroTags//; +/// $tagInfo{Groups} = { 2 => 'Image' } unless $name =~ s/^Devices//; +/// ``` +/// +/// The first substitution deletes each character outside `[-_a-zA-Z0-9]` and +/// upper-cases whatever follows it, turning the JSON key `com.lytro.tags` into +/// `ComLytroTags`. The `Devices` prefix is stripped by the third line's +/// side effect, which is why `devices.mla.lensPitch` reports as `MlaLensPitch`. +fn derive_name(path: &str) -> String { + let chars: Vec = path.chars().collect(); + let mut name = String::with_capacity(path.len()); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c == '-' || c == '_' || c.is_ascii_alphanumeric() { + name.push(c); + i += 1; + continue; + } + // Drop the separator and absorb the next character upper-cased. + // Perl's `.` does not match a newline, so a newline is left in place + // for the next iteration to drop on its own. + i += 1; + if let Some(&next) = chars.get(i) + && next != '\n' + { + name.extend(next.to_uppercase()); + i += 1; + } + } + + if let Some(at) = name.find(VENDOR_INFIX) { + name.replace_range(at..at + VENDOR_INFIX.len(), ""); + } + if let Some(rest) = name.strip_prefix("Devices") { + name = rest.to_string(); + } + name +} + +/// Perl's `ucfirst`: upper-case the first character, leave the rest alone. +fn ucfirst(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + +// --------------------------------------------------------------------------- +// Conversions +// --------------------------------------------------------------------------- + +/// The print form of a tag plus, when they differ, the full-precision +/// ValueConv form the Composite layer must consume. +struct Converted { + /// What ExifTool prints. + print: String, + /// ExifTool's ValueConv value, when it is not the printed string. + /// + /// `Composite:FocalLength35efl` multiplies the *unrounded* focal length by + /// the scale factor; feeding it the printed `6.4 mm` instead of + /// 6.4499998092651363 shifts the 35 mm equivalent from 43.0 mm to 42.6 mm. + value: Option, +} + +impl Converted { + fn print_only(print: String) -> Self { + Converted { print, value: None } + } + + fn with_value(print: String, value: String) -> Self { + Converted { + print, + value: Some(value), + } + } +} + +/// Format a double the way Perl stringifies one: `%.15g`, trailing zeros +/// trimmed. +/// +/// `FocalPlaneXResolution` has a ValueConv and no PrintConv, so ExifTool prints +/// the raw NV and Perl's default stringification decides the digits -- +/// `18142.8574518282`, not the 17 digits Rust's `to_string` would emit. +fn perl_number(value: f64) -> String { + if !value.is_finite() { + return value.to_string(); + } + if value == 0.0 { + return "0".to_string(); + } + + let exponent = value.abs().log10().floor() as i32; + // %g switches to exponential outside [-4, precision). + if exponent < -4 || exponent >= 15 { + let mantissa = format!("{:.*e}", 14, value); + let (digits, exp) = mantissa.split_once('e').unwrap_or((mantissa.as_str(), "0")); + let digits = trim_fraction(digits); + let exp: i32 = exp.parse().unwrap_or(0); + return format!( + "{digits}e{}{:02}", + if exp < 0 { '-' } else { '+' }, + exp.abs() + ); + } + + let decimals = (14 - exponent).max(0) as usize; + trim_fraction(&format!("{value:.decimals$}")) +} + +/// Strip the trailing zeros (and then a bare trailing dot) from a decimal. +fn trim_fraction(s: &str) -> String { + if !s.contains('.') { + return s.to_string(); + } + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +/// `Image::ExifTool::XMP::ConvertXMPDate` (XMP.pm:3383). +/// +/// Rewrites `2012-04-12T14:10:55.000Z` as `2012:04:12 14:10:55.000Z`. The +/// PrintConv is `$self->ConvertDateTime($val)`, which without `-d` returns its +/// argument unchanged, so this single step is the whole conversion. +fn convert_xmp_date(val: &str) -> String { + // ^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}:\d{2})(:\d{2})?\s*(\S*)$ + let b = val.as_bytes(); + let digits = |from: usize, n: usize| { + b.get(from..from + n) + .is_some_and(|s| s.iter().all(u8::is_ascii_digit)) + }; + let matches_shape = b.len() >= 16 + && digits(0, 4) + && b[4] == b'-' + && digits(5, 2) + && b[7] == b'-' + && digits(8, 2) + && (b[10] == b'T' || b[10] == b' ') + && digits(11, 2) + && b[13] == b':' + && digits(14, 2); + if !matches_shape { + // ExifTool's second branch: a bare date has its separators swapped. + if b.len() >= 4 && digits(0, 4) { + return val.replace('-', ":"); + } + return val.to_string(); + } + + let mut rest = &val[16..]; + let mut seconds = ""; + if rest.len() >= 3 + && rest.as_bytes()[0] == b':' + && rest.as_bytes()[1].is_ascii_digit() + && rest.as_bytes()[2].is_ascii_digit() + { + seconds = &rest[..3]; + rest = &rest[3..]; + } + // `\s*(\S*)$` keeps only a trailing run with no interior whitespace. + let trailing = rest.trim_start(); + if trailing.chars().any(char::is_whitespace) { + return val.to_string(); + } + format!( + "{}:{}:{} {}{}{}", + &val[0..4], + &val[5..7], + &val[8..10], + &val[11..16], + seconds, + trailing + ) +} + +/// `Image::ExifTool::Exif::PrintFNumber`: `%.2f` below 1, `%.1f` at or above. +fn print_f_number(val: f64) -> String { + if val > 0.0 && val < 1.0 { + format!("{val:.2}") + } else { + format!("{val:.1}") + } +} + +/// `Image::ExifTool::Exif::PrintExposureTime`. +/// +/// ```text +/// if ($secs < 0.25001 and $secs > 0) { +/// return sprintf("1/%d",int(0.5 + 1/$secs)); +/// } +/// $_ = sprintf("%.1f",$secs); +/// s/\.0$//; +/// ``` +fn print_exposure_time(secs: f64) -> String { + if secs > 0.0 && secs < 0.250_01 { + return format!("1/{}", (0.5 + 1.0 / secs) as i64); + } + let s = format!("{secs:.1}"); + s.strip_suffix(".0").unwrap_or(&s).to_string() +} + +/// Apply a table entry's conversion to the raw JSON text. +/// +/// A value that is not the number the conversion expects is passed through +/// untouched, which is what Perl's numeric operators would effectively do for +/// a string, and keeps a malformed file from producing a fabricated number. +fn convert(conv: Conv, raw: &str) -> Converted { + let number = raw.parse::().ok(); + match (conv, number) { + (Conv::Raw, _) => Converted::print_only(raw.to_string()), + (Conv::XmpDate, _) => Converted::print_only(convert_xmp_date(raw)), + (Conv::FNumber, Some(v)) => Converted::with_value(print_f_number(v), raw.to_string()), + (Conv::FocalLength, Some(v)) => { + let mm = v * 1000.0; + // The value form never reaches output, so it keeps every bit + // rather than Perl's 15 printed digits. + Converted::with_value(format!("{mm:.1} mm"), mm.to_string()) + } + (Conv::Celsius, Some(v)) => Converted::print_only(format!("{v:.1} C")), + (Conv::ExposureTime, Some(v)) => { + Converted::with_value(print_exposure_time(v), raw.to_string()) + } + (Conv::FocalPlaneRes, Some(v)) if v != 0.0 => { + let ppi = 25.4 / v / 1000.0; + Converted::print_only(perl_number(ppi)) + } + (Conv::ExposureBias, Some(v)) => Converted::print_only(format!("{v:+.1}")), + (Conv::Orientation, _) => Converted::print_only(match raw { + "1" => "Horizontal (normal)".to_string(), + // ExifTool's default for a PrintConv hash with no matching key. + other => format!("Unknown ({other})"), + }), + // Numeric conversion wanted but the text is not a number. + (_, None) | (Conv::FocalPlaneRes, Some(_)) => Converted::print_only(raw.to_string()), + } +} + +// --------------------------------------------------------------------------- +// Flattening +// --------------------------------------------------------------------------- + +/// Accumulates tags in document order, applying ExifTool's List semantics. +#[derive(Default)] +struct Collector { + /// `Lytro:Name` keys in first-seen order. + order: Vec, + /// Every value recorded for a key, in order. + values: Vec<(String, Vec)>, + /// The ValueConv form of the most recent value, when it differs. + forms: Vec<(String, String)>, +} + +impl Collector { + fn slot(&mut self, key: &str) -> usize { + if let Some(i) = self.values.iter().position(|(k, _)| k == key) { + return i; + } + self.order.push(key.to_string()); + self.values.push((key.to_string(), Vec::new())); + self.values.len() - 1 + } + + /// Record one value. + /// + /// ExifTool appends to a List tag and replaces a non-List one, so a JSON + /// array of scalars accumulates while two objects that define the same + /// field leave only the later value (Lytro.pm:111-125). + fn push(&mut self, key: &str, value: TagValue, form: Option, list: bool) { + let i = self.slot(key); + if list { + self.values[i].1.push(value); + } else { + self.values[i].1 = vec![value]; + } + self.forms.retain(|(k, _)| k != key); + if let Some(form) = form { + self.forms.push((key.to_string(), form)); + } + } + + /// Write the collected tags into a metadata map. + /// + /// A List tag holding a single value is a scalar in ExifTool, not a + /// one-element array; only a second value promotes it. `JSONMetadata` (3 + /// blocks here) is an array, `PictureDerivationArray` (1 entry) is not. + fn finish(self, metadata: &mut MetadataMap) { + for (key, mut values) in self.values { + let value = match values.len() { + 0 => continue, + 1 => values.remove(0), + _ => TagValue::Array(values), + }; + metadata.insert(key.clone(), value); + } + for (key, form) in self.forms { + metadata.set_value_form(key, form); + } + } +} + +/// `Image::ExifTool::Lytro::ExtractTags` (Lytro.pm:104). +/// +/// Walks the object graph, concatenating `ucfirst` of each key onto `parent`. +/// An array of objects recurses once per element under the same path, which is +/// how `frameArray[0].frame.metadataRef` flattens to +/// `PictureFrameArrayFrameMetadataRef` with no index in the name. +fn extract_tags(node: &JsonValue, parent: &str, out: &mut Collector) { + let JsonValue::Object(entries) = node else { + return; + }; + for (key, value) in entries { + let path = format!("{parent}{}", ucfirst(key)); + let (items, is_list): (&[JsonValue], bool) = match value { + JsonValue::Array(items) => (items.as_slice(), true), + other => (std::slice::from_ref(other), false), + }; + for item in items { + if matches!(item, JsonValue::Object(_)) { + extract_tags(item, &path, out); + continue; + } + let raw = match item { + JsonValue::Str(s) => s.as_str(), + JsonValue::Raw(s) => s.as_str(), + // An array of arrays has no ExifTool representation. + _ => continue, + }; + emit(&path, raw, is_list, out); + } + } +} + +/// Resolve one flattened path to its ExifTool name and value, then record it. +fn emit(path: &str, raw: &str, is_list: bool, out: &mut Collector) { + let (name, converted) = match table_entry(path) { + Some(tag) => (tag.name.to_string(), convert(tag.conv, raw)), + None => (derive_name(path), Converted::print_only(raw.to_string())), + }; + let key = format!("Lytro:{name}"); + out.push( + &key, + TagValue::String(converted.print), + converted.value, + is_list, + ); +} + +// --------------------------------------------------------------------------- +// Container walk +// --------------------------------------------------------------------------- + +/// Does this payload look like the JSON metadata ExifTool accepts? +/// +/// Lytro.pm:160 tests `/^\{\s+"/` -- an opening brace, at least one space, then +/// a quote. An embedded JPEG is recognised separately by its SOI marker. +fn is_json_payload(payload: &[u8]) -> bool { + let Some(rest) = payload.strip_prefix(b"{") else { + return false; + }; + let spaces = rest + .iter() + .position(|c| !c.is_ascii_whitespace()) + .unwrap_or(rest.len()); + spaces > 0 && rest.get(spaces) == Some(&b'"') +} + +/// Parser for Lytro Light Field Picture files. +pub struct LytroParser; + +impl LytroParser { + /// Check the 8-byte file header (Lytro.pm:142). + pub fn verify_signature(reader: &dyn FileReader) -> Result { + if reader.size() < LFP_MAGIC.len() as u64 { + return Ok(false); + } + let header = reader.read(0, LFP_MAGIC.len())?; + Ok(header == LFP_MAGIC) + } +} + +impl FormatParser for LytroParser { + fn parse(&self, reader: &dyn FileReader) -> Result { + if !Self::verify_signature(reader)? { + return Err(ExifToolError::parse_error("Invalid LFP signature")); + } + + let size = reader.size() as usize; + let data = reader.read(0, size)?; + let mut metadata = MetadataMap::new(); + let mut collector = Collector::default(); + + // The 16-byte file header is followed directly by the first section. + let mut offset = SECTION_HEADER_LEN; + while offset + SECTION_HEADER_LEN <= data.len() { + let Some(header) = data.get(offset..offset + SECTION_HEADER_LEN) else { + break; + }; + if !header.starts_with(SECTION_MAGIC) { + // ExifTool warns 'LFP format error' and stops. + break; + } + let length = u32::from_be_bytes([header[12], header[13], header[14], header[15]]); + if length & 0x8000_0000 != 0 { + // 'Invalid LFP segment size' (Lytro.pm:149). + break; + } + offset += SECTION_HEADER_LEN; + + // The 80-byte SHA-1 identifier is read and discarded. + if offset + SECTION_ID_LEN > data.len() { + break; + } + offset += SECTION_ID_LEN; + + // ExifTool seeks past an oversized section rather than buffering + // it (Lytro.pm:155); such a section is image data, never metadata. + let buffered = length <= MAX_BUFFERED_SECTION; + let length = length as usize; + if buffered { + let Some(payload) = data.get(offset..offset + length) else { + break; + }; + read_section(payload, &mut collector); + } + offset += length; + + // Sections are padded up to the next 16-byte boundary. + let pad = SECTION_HEADER_LEN - (length % SECTION_HEADER_LEN); + if pad != SECTION_HEADER_LEN { + offset += pad; + } + } + + collector.finish(&mut metadata); + Ok(metadata) + } + + fn supports_format(&self, format: FileFormat) -> bool { + format == FileFormat::LFP + } +} + +/// Handle one section payload (Lytro.pm:160-167). +fn read_section(payload: &[u8], collector: &mut Collector) { + if is_json_payload(payload) { + collector.push( + "Lytro:JSONMetadata", + TagValue::Binary(payload.to_vec()), + None, + true, + ); + if let Ok(text) = std::str::from_utf8(payload) + && let Some(root) = parse_json(text) + { + extract_tags(&root, "", collector); + } + } else if payload.starts_with(b"\xff\xd8\xff") { + collector.push( + "Lytro:EmbeddedImage", + TagValue::Binary(payload.to_vec()), + None, + false, + ); + } +} + +/// Parse metadata from a Lytro LFP file. +/// +/// # Errors +/// +/// Returns an error string if the file does not carry the LFP signature or +/// cannot be read. +pub fn parse_lytro_metadata(reader: &dyn FileReader) -> std::result::Result { + LytroParser.parse(reader).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_names_the_way_exiftool_does() { + // Lytro.pm:115 collapses the separator and upper-cases what follows. + assert_eq!( + derive_name("PictureFrameArrayParametersVendorContentCom.lytro.tagsDarkFrame"), + "PictureFrameArrayDarkFrame" + ); + // Lytro.pm:118 strips the `Devices` prefix. + assert_eq!(derive_name("DevicesMlaLensPitch"), "MlaLensPitch"); + assert_eq!(derive_name("DevicesSensorMosaicTile"), "SensorMosaicTile"); + // A path with neither feature is unchanged. + assert_eq!(derive_name("ImageWidth"), "ImageWidth"); + } + + #[test] + fn ucfirst_matches_perl() { + assert_eq!(ucfirst("lensPitch"), "LensPitch"); + assert_eq!(ucfirst("com.lytro.tags"), "Com.lytro.tags"); + assert_eq!(ucfirst(""), ""); + } + + #[test] + fn json_reader_keeps_number_text_verbatim() { + // The point of the reader: 21 significant digits survive, because + // ExifTool never numifies them either. + let doc = parse_json("{\n\t\"x\" : -0.039215687662363052368\n}").expect("parses"); + let JsonValue::Object(entries) = doc else { + panic!("expected an object"); + }; + assert_eq!( + entries, + vec![( + "x".to_string(), + JsonValue::Raw("-0.039215687662363052368".to_string()) + )] + ); + } + + #[test] + fn json_reader_preserves_key_order_and_empty_containers() { + let doc = parse_json("{\"b\":[],\"a\":{},\"c\":[1,2]}").expect("parses"); + let JsonValue::Object(entries) = doc else { + panic!("expected an object"); + }; + let keys: Vec<&str> = entries.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(keys, vec!["b", "a", "c"]); + assert_eq!(entries[0].1, JsonValue::Array(vec![])); + assert_eq!(entries[1].1, JsonValue::Object(vec![])); + } + + #[test] + fn converts_the_zulu_timestamp_like_convert_xmp_date() { + assert_eq!( + convert_xmp_date("2012-04-12T14:10:55.000Z"), + "2012:04:12 14:10:55.000Z" + ); + // Seconds are optional in the pattern. + assert_eq!(convert_xmp_date("2012-04-12T14:10Z"), "2012:04:12 14:10Z"); + // Anything else falls through the second branch or is left alone. + assert_eq!(convert_xmp_date("not a date"), "not a date"); + } + + #[test] + fn perl_number_uses_fifteen_significant_digits() { + // ExifTool prints FocalPlaneXResolution straight from the NV. + assert_eq!( + perl_number(25.4 / 1.3999999761581419596e-06 / 1000.0), + "18142.8574518282" + ); + assert_eq!(perl_number(1.0), "1"); + assert_eq!(perl_number(0.0), "0"); + assert_eq!(perl_number(1.5e-7), "1.5e-07"); + } + + #[test] + fn print_conversions_match_exiftool() { + assert_eq!(print_f_number(1.9099999666213989258), "1.9"); + assert_eq!(print_f_number(0.95), "0.95"); + assert_eq!(print_exposure_time(0.0040000001899898052216), "1/250"); + assert_eq!(print_exposure_time(2.0), "2"); + assert_eq!(print_exposure_time(1.5), "1.5"); + } + + #[test] + fn exposure_bias_keeps_its_sign() { + assert_eq!(convert(Conv::ExposureBias, "0").print, "+0.0"); + assert_eq!( + convert(Conv::ExposureBias, "-1.152003169059753418").print, + "-1.2" + ); + } + + #[test] + fn focal_length_keeps_a_full_precision_value_form() { + // The printed value rounds to one decimal; the Composite layer needs + // the unrounded millimetres or FocalLength35efl lands on 42.6 mm. + let c = convert(Conv::FocalLength, "0.0064499998092651363371"); + assert_eq!(c.print, "6.4 mm"); + assert_eq!(c.value.as_deref(), Some("6.449999809265137")); + } + + #[test] + fn json_payload_gate_matches_the_perl_regex() { + assert!(is_json_payload(b"{\n\t\"picture\" : {")); + assert!(is_json_payload(b"{ \"a\":1}")); + // No whitespace between the brace and the quote: ExifTool skips it. + assert!(!is_json_payload(b"{\"a\":1}")); + assert!(!is_json_payload(b"\xff\xd8\xffnot json")); + } +} diff --git a/src/parsers/specialized/mod.rs b/src/parsers/specialized/mod.rs index 9c13158ae..71d0d72d6 100644 --- a/src/parsers/specialized/mod.rs +++ b/src/parsers/specialized/mod.rs @@ -12,6 +12,7 @@ pub mod fits; pub mod gltf; pub mod hdf5; pub mod lnk; +pub mod lytro; pub mod obj; pub mod pcap; pub mod plist; @@ -28,6 +29,7 @@ pub use fits::FITSParser; pub use gltf::GLTFParser; pub use hdf5::HDF5Parser; pub use lnk::LNKParser; +pub use lytro::{LytroParser, parse_lytro_metadata}; pub use obj::OBJParser; pub use pcap::PCAPParser; pub use plist::PlistParser; From 927281f51b587d4bf8bab0af1fdc064b596d1bbe Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 21:54:14 -0500 Subject: [PATCH 12/16] fix(exif): print ExposureTime through ExifTool's PrintExposureTime `ExifIFD:ExposureTime` was rendered by a hand-written formatter in `tag_conversion.rs` that split at one second. ExifTool splits at a quarter second (Exif.pm:5606, reached from the tag's `PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at Exif.pm:1824): sub PrintExposureTime($) { my $secs = shift; return $secs unless Image::ExifTool::IsFloat($secs); if ($secs < 0.25001 and $secs > 0) { return sprintf("1/%d",int(0.5 + 1/$secs)); } $_ = sprintf("%.1f",$secs); s/\.0$//; return $_; } Three divergences, established by executing that subroutine from the installed ExifTool 13.55 rather than by reading it: seconds ExifTool was now 0.5555555556 0.6 1/2 0.6 0.769230769 0.8 1/1 0.8 0.8 0.8 1/1 0.8 4.0 4 4.0 4 30.0 30 30.0 30 0 0 1/18446744073709551615 0 The first class is the damaging one: every exposure in [0.25001, 1) printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s exposure is a perfectly plausible shutter speed, so nothing downstream could tell it was wrong. The second is the trailing `.0` that `s/\.0$//` strips. The third divided by a zero `$secs`. `core::formatters::print_exposure_time` is already a verified port of the subroutine -- it reproduces all fourteen probe values exactly -- so this deletes the private copy and calls it. #340 consolidated sixteen copies of PrintExposureTime but did not reach this one, which is the copy that renders the EXIF ExposureTime tag itself. Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus, keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80" cannot silently become 1.8: differing (file,tag) pairs 40,917 -> 40,814 (-103) fixed 109 ExifIFD:ExposureTime 78 Composite:ShutterSpeed 20 Composite:LightValue 10 IFD0:ExposureTime 1 newly differing 6 The six are all `Composite:LightValue`, and they are collateral from a separate, pre-existing defect: the composite layer parses the *printed* ExposureTime string instead of the ValueConv seconds, so it now rounds from the correct `0.6` where it used to round from `1/2`. Ten other LightValue files move the right way for the same reason (net -4). The same defect is visible in `Composite:ISO`, which consumes the PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712 (Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a composite-layer fix and is left for its own change. Two ExposureTime classes remain out of scope here, both in the rational pipeline rather than the PrintConv: ExifTool rounds rational64u to ten significant figures before the conversion (ExifTool.pm:6114), which is why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a zero denominator should print `undef`. Co-Authored-By: Claude Opus 5 --- src/core/tag_conversion.rs | 50 ++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/src/core/tag_conversion.rs b/src/core/tag_conversion.rs index 443ca863e..8f8f001f3 100644 --- a/src/core/tag_conversion.rs +++ b/src/core/tag_conversion.rs @@ -11,9 +11,9 @@ //! - Utility functions for reading multi-byte values in different byte orders use crate::core::TagValue; -use crate::core::formatters::exiftool_rational_number; +use crate::core::formatters::{exiftool_rational_number, print_exposure_time}; use crate::core::operations_helpers::{ - gcd, is_datetime_string, is_printable_ascii, parse_exif_datetime, read_i32, read_u16, read_u32, + is_datetime_string, is_printable_ascii, parse_exif_datetime, read_i32, read_u16, read_u32, }; use crate::parsers::common::exif_types::ExifType; use crate::parsers::tiff::ifd_parser::ByteOrder; @@ -335,27 +335,31 @@ fn handle_rational_type( } } - // Special handling for ExposureTime - format as fraction string - // Match ExifTool's behavior: 1/N format for exposures < 1 second, decimal for >= 1 second + // ExposureTime (Exif.pm:1824) carries + // `PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'`, whose + // breakpoint is a quarter of a second, not one second (Exif.pm:5606): + // + // if ($secs < 0.25001 and $secs > 0) { + // return sprintf("1/%d",int(0.5 + 1/$secs)); + // } + // $_ = sprintf("%.1f",$secs); + // s/\.0$//; + // + // The hand-written formatter that used to live here split at 1.0 instead, + // so every exposure in [0.25001, 1) printed as a fraction ExifTool never + // prints -- 5/9 s came out `1/2` where ExifTool says `0.6`, and 0.8 s came + // out `1/1` where ExifTool says `0.8`. Both readings are plausible shutter + // speeds, so nothing downstream could tell they were wrong. It also left + // the trailing `.0` that `s/\.0$//` removes (`4.0` for ExifTool's `4`) and + // divided by a zero `$secs`, printing `1/18446744073709551615`. + // + // `print_exposure_time` is the verified port of that subroutine; use it + // rather than keeping a seventeenth private copy. (#340 consolidated the + // other sixteen but did not reach this one, which is the copy that renders + // the EXIF ExposureTime tag itself.) if tag_id == EXPOSURE_TIME && denominator != 0 { - let val = numerator as f64 / denominator as f64; - if val >= 1.0 { - // Show as decimal for exposure >= 1 second (e.g., "2.0") - return TagValue::new_string(format!("{:.1}", val)); - } else { - // For exposures < 1 second, try to simplify first - let gcd_value = gcd(numerator, denominator); - let simplified_num = numerator / gcd_value; - let simplified_den = denominator / gcd_value; - if simplified_num == 1 { - // Already in 1/N form after simplification - return TagValue::new_string(format!("1/{}", simplified_den)); - } else { - // Approximate to 1/N form like ExifTool does - let approx_denom = (1.0 / val).round() as u64; - return TagValue::new_string(format!("1/{}", approx_denom)); - } - } + let secs = numerator as f64 / denominator as f64; + return TagValue::new_string(print_exposure_time(secs)); } // TIFF RATIONAL (type 5) is UNSIGNED, but `TagValue::Rational` stores @@ -771,7 +775,7 @@ fn heuristic_bytes_to_tag_value(bytes: &[u8], byte_order: ByteOrder) -> TagValue // UTILITY FUNCTIONS // ============================================================================ // Note: Utility functions (read_u16, read_u32, read_i32, is_datetime_string, -// parse_exif_datetime, gcd) are imported from operations_helpers module +// parse_exif_datetime) are imported from operations_helpers module // to avoid duplication. /// Formats a GPS numeric value for ExifTool compatibility. From cb5c7e0fb8822c9392e73f50a219535924423922 Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 22:13:02 -0500 Subject: [PATCH 13/16] fix(leica): route the "LEICA CAMERA AG" MakerNote to Panasonic::Main The D-Lux 7, D-Lux 8 and V-Lux 5 are Panasonic-built and sign their MakerNote "LEICA CAMERA AG\0". ExifTool dispatches that signature with `MakerNoteLeica10` (MakerNotes.pm:724-731), which points at `Image::ExifTool::Panasonic::Main` -- not at any Leica table: Name => 'MakerNoteLeica10', # used by the D-Lux7 Condition => '$$valPt =~ /^LEICA CAMERA AG\0/', TagTable => 'Image::ExifTool::Panasonic::Main', Start => '$valuePtr + 18', oxidex instead handed the payload to the Leica parser, which claimed the header as `LeicaLayout::LongHeader`, skipped 15 bytes of it and decoded nothing -- its own comment recorded that "no corresponding ExifTool table has been identified". The result was silent: all three bodies reported zero MakerNote tags where ExifTool reports ~100, with no warning. The signature is 16 bytes and the IFD begins at 18, so two pad bytes sit between them. LeicaD-Lux7.jpg opens 4c 45 49 43 41 20 43 41 4d 45 52 41 20 41 47 00 00 00 9d 00 -- "LEICA CAMERA AG\0", two NULs, then the 157-entry count. `MakerNoteLeica10` declares no `Base`, so its out-of-line value offsets are TIFF-header-relative exactly as `MakerNotePanasonic`'s are and need no adjustment. `LongHeader` is removed rather than left dead: recognising a signature this parser has no table for shadowed the parser that does. Verified against ExifTool 13.59 on the 4,238-file corpus, scored per file and keyed Group1:Name: Leica/LeicaD-Lux7.jpg 0 -> 80 MakerNote tags (+66 matching ExifTool) Leica/LeicaD-Lux8.jpg 0 -> 80 (+65) Leica/LeicaV-Lux5.jpg 0 -> 80 (+66) +197 newly-matching keys, 0 regressions across all 4,238 files (matched-key sets diffed per file, before vs after). exiftool -G1 -s Leica/LeicaD-Lux7.jpg [Panasonic] ImageQuality : RAW [Panasonic] InternalSerialNumber : (XFL) 2018:09:06 no. 0007 [Panasonic] WhiteBalance : Auto oxidex now prints the same three values. Note this PR fixes dispatch only. The ~13 remaining value differences per file (AFPointPosition, RollAngle, PhotoStyle, ...) are pre-existing `Panasonic::Main` conversion gaps, not new: measured on origin/main they already differ on up to 212 of the 303 Panasonic JPEGs that oxidex already parsed. Tracked separately. Co-Authored-By: Claude Opus 5 --- src/parsers/tiff/makernote_dispatcher.rs | 9 +++ src/parsers/tiff/makernotes/leica.rs | 35 ++++++----- src/parsers/tiff/makernotes/panasonic.rs | 31 +++++++++- .../integration/panasonic_makernotes_tests.rs | 58 +++++++++++++++++++ 4 files changed, 117 insertions(+), 16 deletions(-) diff --git a/src/parsers/tiff/makernote_dispatcher.rs b/src/parsers/tiff/makernote_dispatcher.rs index fef3bbe0f..8ddfca681 100644 --- a/src/parsers/tiff/makernote_dispatcher.rs +++ b/src/parsers/tiff/makernote_dispatcher.rs @@ -225,6 +225,15 @@ pub fn dispatch_makernote_with_context_and_values( // `Leica2`..`Leica10` layouts, which key on the "Leica Camera AG" // prefix instead (MakerNotes.pm:611 onward). "leica" => Some(Box::new(panasonic::PanasonicParser)), + // `MakerNoteLeica10` (MakerNotes.pm:724-731) is keyed on the signature + // alone -- `Condition => '$$valPt =~ /^LEICA CAMERA AG\0/'` -- and + // routes to `Panasonic::Main`, not to any `Leica2`..`Leica9` table, so + // it has to be separated from its Make-mates before they are. The + // D-Lux 7/D-Lux 8/V-Lux 5 are Panasonic-built and ExifTool prints + // their tags under "MakerNotes:Panasonic". + "leica camera ag" if panasonic::is_leica10_makernote(data) => { + Some(Box::new(panasonic::PanasonicParser)) + } "leica camera ag" => Some(Box::new(leica::LeicaMakerNoteParser)), // Sigma is absent on purpose. Its MakerNote entries store value offsets // relative to the enclosing TIFF header, so nothing handed only the diff --git a/src/parsers/tiff/makernotes/leica.rs b/src/parsers/tiff/makernotes/leica.rs index 05af74057..f28b28be9 100644 --- a/src/parsers/tiff/makernotes/leica.rs +++ b/src/parsers/tiff/makernotes/leica.rs @@ -217,9 +217,14 @@ const_decoder!( const_decoder!(L4_DECODE_JPEG_QUALITY, i32, [(94, "Basic"), (97, "Fine"),]); // Leica MakerNote header signature -// Leica typically uses "LEICA\0\0\0" or "LEICA CAMERA AG" headers +// Leica typically uses "LEICA\0\0\0" headers. +// +// The other long-form signature a Leica body writes, "LEICA CAMERA AG\0", is +// deliberately absent: ExifTool's `MakerNoteLeica10` routes it to +// `Panasonic::Main`, not to any Leica table, so it is recognised by +// `makernotes::panasonic::is_leica10_makernote` and decoded by the Panasonic +// parser. Claiming it here decoded nothing and shadowed the parser that can. const LEICA_HEADER_SHORT: &[u8] = b"LEICA\0\0\0"; -const LEICA_HEADER_LONG: &[u8] = b"LEICA CAMERA AG"; /// `MakerNoteLeica4`'s signature: the M9 and M Monochrom write "LEICA0\x03\0" /// (ExifTool MakerNotes.pm:639-648, which matches on `^LEICA0`). const LEICA_HEADER_LEICA4: &[u8] = b"LEICA0"; @@ -263,10 +268,6 @@ enum LeicaLayout { /// `MakerNoteLeica9`, written by the M10, M11 and S bodies /// (MakerNotes.pm:713-722). Leica9, - /// The long "LEICA CAMERA AG" header this parser has always skipped 15 - /// bytes of. No corresponding ExifTool table has been identified; no - /// tags are decoded for this layout. - LongHeader, } impl LeicaLayout { @@ -282,7 +283,6 @@ impl LeicaLayout { | LeicaLayout::Leica6 | LeicaLayout::Leica9 => 8, LeicaLayout::Leica3 => 0, - LeicaLayout::LongHeader => 15, } } } @@ -305,9 +305,6 @@ fn leica_layout(data: &[u8]) -> Option { if data.starts_with(LEICA_HEADER_LEICA4) { return Some(LeicaLayout::Leica4); } - if data.len() >= 15 && &data[0..15] == LEICA_HEADER_LONG { - return Some(LeicaLayout::LongHeader); - } if data.len() >= 8 && data[0..5] == *b"LEICA" && data[5] == 0 @@ -761,7 +758,7 @@ impl LeicaMakerNoteParser { LeicaLayout::Leica9 => self.decode_leica9_entry(&entry, values, byte_order, tags), // No ExifTool table is known to correspond to this header; // decoding tag ids against it would be a guess. - LeicaLayout::Leica4 | LeicaLayout::LongHeader => {} + LeicaLayout::Leica4 => {} } } @@ -1376,9 +1373,19 @@ mod tests { let valid_short = b"LEICA\0\0\0extra data"; assert!(is_leica_makernote(valid_short)); - // Valid long LEICA CAMERA AG header - let valid_long = b"LEICA CAMERA AG extra data"; - assert!(is_leica_makernote(valid_long)); + // "LEICA CAMERA AG" is NOT this parser's to claim. ExifTool's + // `MakerNoteLeica10` matches `^LEICA CAMERA AG\0` and routes it to + // `Panasonic::Main` (MakerNotes.pm:724-731), so the Panasonic parser + // decodes it and this one must decline -- it has no table for those + // tag ids and previously returned true only to emit nothing. + let leica10 = b"LEICA CAMERA AG\0\0\0\x9d\0\x01\0\x03\0"; + assert!(!is_leica_makernote(leica10)); + assert!(crate::parsers::tiff::makernotes::panasonic::is_leica10_makernote(leica10)); + + // The bare prefix without the terminating NUL is not a Leica10 header + // either, and is still not a Leica layout. + let not_leica10 = b"LEICA CAMERA AG extra data"; + assert!(!is_leica_makernote(not_leica10)); // Invalid header let invalid = b"CANON\0\x00\x00\x00\x00\x00\x00"; diff --git a/src/parsers/tiff/makernotes/panasonic.rs b/src/parsers/tiff/makernotes/panasonic.rs index 8c58dd589..78605b87b 100644 --- a/src/parsers/tiff/makernotes/panasonic.rs +++ b/src/parsers/tiff/makernotes/panasonic.rs @@ -59,13 +59,40 @@ const PANASONIC_HEADER: &[u8] = b"Panasonic\0\0\0"; /// real Panasonic body -- with the IFD starting 8 bytes in, not 12. const LEICA_UNNUMBERED_HEADER: &[u8] = b"LEICA\0\0\0"; -/// Returns the byte offset of this payload's IFD, or `None` if neither the -/// Panasonic nor the unnumbered-Leica header matches. +/// The `MakerNoteLeica10` header (MakerNotes.pm:724-731). Leica's Panasonic-built +/// compacts -- the D-Lux 7, D-Lux 8 and V-Lux 5 -- sign "LEICA CAMERA AG\0" and +/// ExifTool points them at `Panasonic::Main`, the same table and "Panasonic:" +/// group a real Panasonic body uses: +/// +/// ```text +/// Name => 'MakerNoteLeica10', # used by the D-Lux7 +/// Condition => '$$valPt =~ /^LEICA CAMERA AG\0/', +/// TagTable => 'Image::ExifTool::Panasonic::Main', +/// Start => '$valuePtr + 18', +/// ``` +/// +/// The signature is 16 bytes and the IFD begins at 18, so two pad bytes sit +/// between them (`LeicaD-Lux7.jpg` reads `...41 47 00 00 00 9d 00` -- "AG\0", +/// `00 00`, then the 157-entry count). `MakerNoteLeica10` declares no `Base`, +/// so its out-of-line value offsets are measured from the enclosing TIFF header +/// exactly as `MakerNotePanasonic`'s are and need no adjustment here. +const LEICA10_HEADER: &[u8] = b"LEICA CAMERA AG\0"; + +/// True for a `MakerNoteLeica10` payload -- the signature ExifTool routes to +/// `Panasonic::Main` rather than to any of the `Leica2`..`Leica9` tables. +pub fn is_leica10_makernote(data: &[u8]) -> bool { + data.len() >= LEICA10_HEADER.len() + 2 && data.starts_with(LEICA10_HEADER) +} + +/// Returns the byte offset of this payload's IFD, or `None` if none of the +/// Panasonic, unnumbered-Leica or Leica10 headers matches. fn panasonic_ifd_offset(data: &[u8]) -> Option { if data.len() >= 12 && &data[0..12] == PANASONIC_HEADER { Some(12) } else if data.len() >= 8 && &data[0..8] == LEICA_UNNUMBERED_HEADER { Some(8) + } else if is_leica10_makernote(data) { + Some(18) } else { None } diff --git a/tests/integration/panasonic_makernotes_tests.rs b/tests/integration/panasonic_makernotes_tests.rs index 1bbbf4605..600ccb616 100644 --- a/tests/integration/panasonic_makernotes_tests.rs +++ b/tests/integration/panasonic_makernotes_tests.rs @@ -334,3 +334,61 @@ fn test_panasonic_lens_type_is_a_string() { Some(&"LEICA DG 12-60/F2.8-4.0".to_string()) ); } + +/// `MakerNoteLeica10` (MakerNotes.pm:724-731) is dispatched on its signature +/// alone -- `Condition => '$$valPt =~ /^LEICA CAMERA AG\0/'` -- and routes to +/// `Image::ExifTool::Panasonic::Main` with `Start => '$valuePtr + 18'`, not to +/// any `Leica2`..`Leica9` table. It is what Leica's Panasonic-built compacts +/// write: the D-Lux 7, D-Lux 8 and V-Lux 5. +/// +/// The signature is 16 bytes and the IFD begins at 18, so two pad bytes sit +/// between them. `LeicaD-Lux7.jpg`'s MakerNote opens +/// `4c 45 49 43 41 20 43 41 4d 45 52 41 20 41 47 00 00 00 9d 00 01 00 03 00` +/// -- "LEICA CAMERA AG\0", two NULs, a 157-entry count, then tag 0x0001 as a +/// SHORT. Before this was recognised, the Leica parser claimed the header, +/// skipped 15 bytes of it and decoded nothing, so all three bodies reported +/// zero MakerNote tags where ExifTool reports ~100. +#[test] +fn test_leica10_header_routes_to_panasonic_main() { + use oxidex::parsers::tiff::ifd_parser::ByteOrder; + use oxidex::parsers::tiff::makernotes::panasonic::{ + PanasonicParser, is_leica10_makernote, parse_panasonic_makernotes, + }; + use oxidex::parsers::tiff::makernotes::shared::MakerNoteParser; + use std::collections::HashMap; + + let mut data = Vec::new(); + data.extend_from_slice(b"LEICA CAMERA AG\0"); // 16-byte signature + data.extend_from_slice(&[0x00, 0x00]); // two pad bytes -> IFD at +18 + data.extend_from_slice(&[0x01, 0x00]); // 1 entry + data.extend_from_slice(&[0x01, 0x00]); // tag 0x0001 ImageQuality + data.extend_from_slice(&[0x03, 0x00]); // type: SHORT + data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // count 1 + data.extend_from_slice(&[0x07, 0x00, 0x00, 0x00]); // inline value 7 = RAW + data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // next IFD + + assert!(is_leica10_makernote(&data)); + assert!(PanasonicParser.validate_header(&data)); + + let mut tags = HashMap::new(); + parse_panasonic_makernotes(&data, ByteOrder::LittleEndian, &mut tags); + + // ExifTool prints `[Panasonic] ImageQuality : RAW` for LeicaD-Lux7.jpg. + assert_eq!( + tags.get("Panasonic:ImageQuality"), + Some(&"RAW".to_string()), + "Leica10 payload must decode against Panasonic::Main" + ); +} + +/// The Leica10 signature requires its terminating NUL. ExifTool's Condition is +/// `/^LEICA CAMERA AG\0/`, so a payload that merely begins with the words is +/// not a Leica10 MakerNote and must not be given the +18 IFD offset. +#[test] +fn test_leica10_requires_terminating_nul() { + use oxidex::parsers::tiff::makernotes::panasonic::is_leica10_makernote; + + assert!(!is_leica10_makernote(b"LEICA CAMERA AG extra data")); + // ...and the bare signature with no room for an IFD is not one either. + assert!(!is_leica10_makernote(b"LEICA CAMERA AG\0")); +} From 072421db172503fbe270718187d68ac761c9761c Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 22:34:50 -0500 Subject: [PATCH 14/16] test(leica): assert LEICA CAMERA AG is declined, matching Leica10 routing The inline unit test in leica.rs was updated to the new behaviour but this integration copy still asserted the old one, so Build & Test failed on the very change it was meant to cover. ExifTool's MakerNoteLeica10 (MakerNotes.pm:724-731) matches the signature alone -- Condition => '$$valPt =~ /^LEICA CAMERA AG\0/' -- and routes to Panasonic::Main at Start => '$valuePtr + 18', never to a Leica table. So is_leica_makernote must decline it. Co-Authored-By: Claude Opus 5 --- tests/integration/leica_makernotes_tests.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/integration/leica_makernotes_tests.rs b/tests/integration/leica_makernotes_tests.rs index 1d4c0528a..1059e077d 100644 --- a/tests/integration/leica_makernotes_tests.rs +++ b/tests/integration/leica_makernotes_tests.rs @@ -24,12 +24,21 @@ fn test_leica_header_validation_short() { } #[test] -fn test_leica_header_validation_long() { +fn test_leica_camera_ag_is_not_claimed_by_this_parser() { use oxidex::parsers::tiff::makernotes::leica::is_leica_makernote; - // Test valid long "LEICA CAMERA AG" header - let valid_header = b"LEICA CAMERA AG\x00\x00\x10"; - assert!(is_leica_makernote(valid_header)); + // "LEICA CAMERA AG\0" belongs to ExifTool's `MakerNoteLeica10` + // (MakerNotes.pm:724-731), which is keyed on the signature alone -- + // `Condition => '$$valPt =~ /^LEICA CAMERA AG\0/'` -- and routes to + // `Panasonic::Main` at `Start => '$valuePtr + 18'`, not to any + // `Leica2`..`Leica9` table. So this parser must decline it. + let leica10 = b"LEICA CAMERA AG\x00\x00\x10"; + assert!(!is_leica_makernote(leica10)); + + // Without the terminating NUL it is not a Leica10 header either, and it is + // still not one of this parser's layouts. + let not_leica10 = b"LEICA CAMERA AG extra data"; + assert!(!is_leica_makernote(not_leica10)); } #[test] From 52c58a2157cec2dfde41bb850c87b1916fe6986a Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 22:14:11 -0500 Subject: [PATCH 15/16] fix(panasonic): drop six fabricated MakerNote registry entries `registries/panasonic.rs` declared 93 tags. Six did not trace to `%Image::ExifTool::Panasonic::Main`, and between them oxidex emitted 1,248 tag instances across the 479-file Panasonic corpus that ExifTool reports none of: 0x003E TextStamp2 -> TextStamp (Panasonic.pm:809) 0x8008 TextStamp3 -> TextStamp (Panasonic.pm:1568) 0x8009 TextStamp4 -> TextStamp (Panasonic.pm:1574) 0x8007 FlashFired -> deleted (Panasonic.pm:1563, commented out) 0x8010 BabyAge2 -> deleted (id is `BabyAge`, Panasonic.pm:1580) 0x8012 Transform2 -> deleted (id is `Transform`, Panasonic.pm:1587) ExifTool gives four different ids the same name `TextStamp` and two ids the name `BabyAge`; inventing `Foo2`/`Foo3`/`Foo4` to disambiguate them produces names that exist in zero ExifTool source files and can never match real output. 0x003E/0x8008/0x8009 carry ExifTool's own PrintConv ({1=>'Off', 2=>'On'}) so they are renamed in place. 0x8010 and 0x8012 are omitted rather than renamed: 0x8010 is a `string` with a sentinel PrintConv but was registered as a bare integer, and 0x8012 is an `int16s` `Count => 2` whose PrintConv keys are integer *pairs* ('0 0' => 'Off', '-3 2' => 'Slim High'), which a single-value On/Off decoder cannot express. 0x0033 and 0x0059 already deliver the real `BabyAge` and `Transform` names. 0x8007 is disabled upstream: `#0x8007 => { #PH - questionable [disabled because it conflicts with EXIF in too many samples]`. Two enum decoders were also invented on real tags, printing confidently wrong values under real ExifTool names: 0x0077 BurstSpeed int16u, "images per second", no PrintConv (Panasonic.pm:1094). Printed "Low" where ExifTool prints "0", in 91 files. 0x009D InternalNDFilter rational64u, no PrintConv (Panasonic.pm:1247). Printed "Unknown (4620)" where ExifTool prints "0". Both decoders are removed; `BURST_SPEED` and `INTERNAL_ND_FILTER` in the parser had no other caller and are deleted. Measured per file over 479 Panasonic samples, ExifTool 13.59 vs oxidex, keyed Group1:Name (JSON parsed with parse_float=str): matched 44668 -> 44759 (+91, BurstSpeed now agrees) value_diff 1848 -> 1757 (-91) missing 8057 -> 8057 (unchanged) extra 12725 -> 11477 (-1248) No file lost a matched key. InternalNDFilter still disagrees (76 files): oxidex prints the entry's value_offset rather than dereferencing the rational. That is the same pre-existing gap that makes `AFPointPosition` print "3254" for ExifTool's "0.47 0.48", and is not addressed here. Co-Authored-By: Claude Opus 5 --- src/parsers/tiff/makernotes/panasonic.rs | 13 +++-- .../tiff/makernotes/registries/panasonic.rs | 47 +++++++++++++------ 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/src/parsers/tiff/makernotes/panasonic.rs b/src/parsers/tiff/makernotes/panasonic.rs index 78605b87b..3ea713efe 100644 --- a/src/parsers/tiff/makernotes/panasonic.rs +++ b/src/parsers/tiff/makernotes/panasonic.rs @@ -308,11 +308,9 @@ const_decoder!(pub COLOR_MODE, [(0, "Normal"), (1, "Natural"), (2, "Vivid"),] ); -// Internal ND filter decoder - maps values to ND filter settings -const_decoder!(pub INTERNAL_ND_FILTER, - i32, - [(0, "Off"), (1, "On"), (2, "Auto"),] -); +// InternalNDFilter (0x009D) has no PrintConv in ExifTool: Panasonic.pm:1247 +// declares only `Writable => 'rational64u'`, so the value is reported as-is. +// The Off/On/Auto decoder that used to live here was invented. // Intelligent exposure decoder - maps values to iExposure modes const_decoder!(pub INTELLIGENT_EXPOSURE, @@ -424,8 +422,9 @@ const_decoder!(pub FLASH_WARNING, i32, [(0, "No"), (1, "Yes (flash required but disabled)"),] ); -// Burst speed decoder (tag 0x0077) -const_decoder!(pub BURST_SPEED, i32, [(0, "Low"), (1, "Mid"), (2, "High"),]); +// BurstSpeed (0x0077) has no PrintConv in ExifTool: Panasonic.pm:1094 declares +// `Writable => 'int16u'` with `Notes => 'images per second'`. The Low/Mid/High +// decoder that used to live here was invented and printed "Low" for 0 fps. // Clear retouch decoder (tag 0x007C) const_decoder!(pub CLEAR_RETOUCH, i32, [(0, "Off"), (1, "On"),]); diff --git a/src/parsers/tiff/makernotes/registries/panasonic.rs b/src/parsers/tiff/makernotes/registries/panasonic.rs index ee7de448c..d29de769b 100644 --- a/src/parsers/tiff/makernotes/registries/panasonic.rs +++ b/src/parsers/tiff/makernotes/registries/panasonic.rs @@ -12,17 +12,17 @@ //! - Lens and sensor data (LensType, ImageStabilization, AFAreaMode) //! - Supplementary information (Audio, TextStamp, Location, BabyAge) -use super::super::shared::{generic_decoders::*, tag_registry::TagRegistry}; +use super::super::shared::tag_registry::TagRegistry; // Re-export decoders from panasonic.rs // These decoders are defined using const_decoder! macros in the main parser use super::super::panasonic::{ - AF_ASSIST_LAMP, AUDIO, BRACKET_SETTINGS, BURST_MODE, BURST_SPEED, CLEAR_RETOUCH, COLOR_EFFECT, - COLOR_MODE, CONTRAST_MODE, CONVERSION_LENS, FILM_MODE, FLASH_CURTAIN, FLASH_WARNING, - FOCUS_MODE, HDR, IMAGE_QUALITY, IMAGE_STABILIZATION, INTELLIGENT_D_RANGE, INTELLIGENT_EXPOSURE, - INTELLIGENT_RESOLUTION, INTERNAL_ND_FILTER, LONG_EXPOSURE_NR, MACRO_MODE, NOISE_REDUCTION, - OPTICAL_ZOOM_MODE, PHOTO_STYLE, ROTATION, SELF_TIMER, SHADING_COMPENSATION, SHOOTING_MODE, - SHUTTER_TYPE, SWEEP_PANORAMA_DIRECTION, TEXT_STAMP, TIMER_RECORDING, TOUCH_AE, WHITE_BALANCE, + AF_ASSIST_LAMP, AUDIO, BRACKET_SETTINGS, BURST_MODE, CLEAR_RETOUCH, COLOR_EFFECT, COLOR_MODE, + CONTRAST_MODE, CONVERSION_LENS, FILM_MODE, FLASH_CURTAIN, FLASH_WARNING, FOCUS_MODE, HDR, + IMAGE_QUALITY, IMAGE_STABILIZATION, INTELLIGENT_D_RANGE, INTELLIGENT_EXPOSURE, + INTELLIGENT_RESOLUTION, LONG_EXPOSURE_NR, MACRO_MODE, NOISE_REDUCTION, OPTICAL_ZOOM_MODE, + PHOTO_STYLE, ROTATION, SELF_TIMER, SHADING_COMPENSATION, SHOOTING_MODE, SHUTTER_TYPE, + SWEEP_PANORAMA_DIRECTION, TEXT_STAMP, TIMER_RECORDING, TOUCH_AE, WHITE_BALANCE, WORLD_TIME_LOCATION, }; @@ -96,7 +96,7 @@ pub fn panasonic_registry() -> TagRegistry { .register_integer_tag(0x003C, "ProgramISO", None) // AdvancedSceneType has no PrintConv in ExifTool; it stays numeric .register_integer_tag(0x003D, "AdvancedSceneType", None) - .register_enum_tag_required(0x003E, "TextStamp2", &TEXT_STAMP) + .register_enum_tag_required(0x003E, "TextStamp", &TEXT_STAMP) .register_integer_tag(0x003F, "FacesDetected", None) .register_integer_tag(0x0044, "ColorTempKelvin", None) .register_enum_tag_required(0x0045, "BracketSettings", &BRACKET_SETTINGS) @@ -119,7 +119,10 @@ pub fn panasonic_registry() -> TagRegistry { .register_integer_tag(0x0060, "LensFirmwareVersion", None) .register_enum_tag_required(0x0062, "FlashWarning", &FLASH_WARNING) .register_enum_tag_required(0x0070, "IntelligentResolution", &INTELLIGENT_RESOLUTION) - .register_enum_tag_required(0x0077, "BurstSpeed", &BURST_SPEED) + // BurstSpeed is a plain count, not an enum: Panasonic.pm:1094 declares + // `Writable => 'int16u', Notes => 'images per second'` and no PrintConv. + // A Low/Mid/High decoder printed "Low" where ExifTool prints "0". + .register_integer_tag(0x0077, "BurstSpeed", None) .register_enum_tag_required(0x0079, "IntelligentD-Range", &INTELLIGENT_D_RANGE) .register_enum_tag_required(0x007C, "ClearRetouch", &CLEAR_RETOUCH) .register_integer_tag(0x0086, "ManometerPressure", None) @@ -134,7 +137,7 @@ pub fn panasonic_registry() -> TagRegistry { .register_enum_tag_required(0x0093, "SweepPanoramaDirection", &SWEEP_PANORAMA_DIRECTION) .register_integer_tag(0x0094, "SweepPanoramaFieldOfView", None) .register_enum_tag_required(0x0096, "TimerRecording", &TIMER_RECORDING) - .register_enum_tag_required(0x009D, "InternalNDFilter", &INTERNAL_ND_FILTER) + .register_raw(0x009D, "InternalNDFilter") .register_enum_tag_required(0x009E, "HDR", &HDR) .register_enum_tag_required(0x009F, "ShutterType", &SHUTTER_TYPE) .register_integer_tag(0x00A3, "ClearRetouchValue", None) @@ -155,11 +158,25 @@ pub fn panasonic_registry() -> TagRegistry { .register_integer_tag(0x8004, "WBRedLevel", None) .register_integer_tag(0x8005, "WBGreenLevel", None) .register_integer_tag(0x8006, "WBBlueLevel", None) - .register_enum_tag_required(0x8007, "FlashFired", &ON_OFF_I32) - .register_enum_tag_required(0x8008, "TextStamp3", &TEXT_STAMP) - .register_enum_tag_required(0x8009, "TextStamp4", &TEXT_STAMP) - .register_integer_tag(0x8010, "BabyAge2", None) - .register_enum_tag_required(0x8012, "Transform2", &ON_OFF_I32) + // 0x8007 is not a tag. Panasonic.pm:1563-1567 carries it commented out: + // `#0x8007 => { #PH - questionable [disabled because it conflicts with + // EXIF in too many samples]`. ExifTool reports no `FlashFired` for any + // Panasonic file, so neither may we. + // + // 0x8008 and 0x8009 are both plain `TextStamp` in ExifTool + // (Panasonic.pm:1568, :1574), same PrintConv as 0x3b and 0x3e. + .register_enum_tag_required(0x8008, "TextStamp", &TEXT_STAMP) + .register_enum_tag_required(0x8009, "TextStamp", &TEXT_STAMP) + // 0x8010 is `BabyAge` (Panasonic.pm:1580), not `BabyAge2`: a `string` + // with `PrintConv => '$val eq "9999:99:99 00:00:00" ? "(not set)" : $val'`. + // It was registered as a bare integer, which cannot produce that value, and + // 0x0033 already delivers a matching `Panasonic:BabyAge`. Omitted rather + // than renamed, so no wrong value ships under the real name. + // + // 0x8012 is `Transform` (Panasonic.pm:1587), not `Transform2`: `int16s` + // `Count => 2` whose PrintConv keys are integer *pairs* + // ('0 0' => 'Off', '-3 2' => 'Slim High', ...). A one-value On/Off decoder + // cannot express that, so it is omitted too. } /// Panasonic SceneMode (0x8001). From 2bbf9532277eeb8ff407151ad2dddfe62131e66b Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 22:18:12 -0500 Subject: [PATCH 16/16] feat(olympus): read the OM System MakerNote and the TextInfo sub-record Measured against ExifTool 13.59 over the 318-file Olympus corpus, scored per file on `Group1:Name`. Three entangled `Olympus::Main` gaps: 1. `MakerNoteOlympus3`. The OM-1, OM-3, OM-5, OM-1 Mark II and TG-7 write an "OM SYSTEM\0" header (MakerNotes.pm:589-597: `Start => '$valuePtr + 16'`, `Base => '$start - 16'`). The dispatcher already routed them here on `Make = "OM Digital Solutions"`; only `validate_header` rejected them, so those five files yielded zero Olympus tags. Adding the signature took them from 400 matched / 858 missing to 1101 matched / 154 missing -- +701 tag instances, no matched key lost. 2. `Olympus::TextInfo` (0x0208). 129 of the 318 files carry a short ASCII record -- `[pictureInfo] Resolution=3 [Camera Info] Type=SR951` -- which ExifTool scans with `APP12::ProcessAPP12` (Olympus.pm:1574). The separator is a space, not CR/LF, so the existing APP12 readers in `parsers::jpeg::app_segments` would see one token and emit nothing; `text_info::scan` ports the tokenizer at APP12.pm:262 instead. 125 files x {Resolution, CameraType} = 250 tag instances. 3. `Main` 0x0207 `CameraType` and 0x0201 `Quality`, which the table walk cannot express: `CameraType` is a `DataMember` that `Quality`'s PrintConv consults (Olympus.pm:725), and `TextInfo` carries a second `CameraType` that overwrites it. The FE240/SP510UZ/u730/u1000 placeholder is padded to "NORMAL ", which is not `eq "NORMAL"`, so the Condition passes and ExifTool really does print `Unknown (NORMAL)` before TextInfo replaces it -- reproduced rather than special-cased. Also replaces `TagDef::raw(0x0821, "ISOAutoSettings")`, which printed the raw "0 0" where ExifTool prints "n/a; n/a", with the two-element list PrintConv transcribed from ExifTool's own loaded table. wip: the release binary and test run were cut short by machine load, so the whole-corpus after-measurement is not yet recorded. Per-item deltas above are measured; item 1 was verified end to end against a built binary, items 2 and 3 compile and are unit-tested but unmeasured. Co-Authored-By: Claude Opus 5 --- src/parsers/tiff/makernotes/olympus.rs | 191 ++++++++++- src/parsers/tiff/makernotes/olympus/tables.rs | 47 ++- .../tiff/makernotes/olympus/text_info.rs | 298 ++++++++++++++++++ 3 files changed, 532 insertions(+), 4 deletions(-) create mode 100644 src/parsers/tiff/makernotes/olympus/text_info.rs diff --git a/src/parsers/tiff/makernotes/olympus.rs b/src/parsers/tiff/makernotes/olympus.rs index 4815b703d..1c5c3ae1a 100644 --- a/src/parsers/tiff/makernotes/olympus.rs +++ b/src/parsers/tiff/makernotes/olympus.rs @@ -23,6 +23,7 @@ // Submodules for extended tag parsing pub mod lookups; pub mod tables; +pub mod text_info; use crate::const_decoder; use crate::error::{ExifToolError, Result}; @@ -71,6 +72,13 @@ const OLYMPUS_THUMBNAIL_IMAGE: u16 = 0x0104; const OLYMPUS_BODY_FIRMWARE_VERSION: u16 = 0x0404; const OLYMPUS_LENS_MODEL: u16 = 0x0206; +// `Olympus::Main` tags whose conversions depend on each other, so the plain +// table walk cannot carry them. (The block above numbers the same directory +// from a different origin and must not be reused for these.) +const MAIN_QUALITY: u16 = 0x0201; +const MAIN_CAMERA_TYPE: u16 = 0x0207; +const MAIN_TEXT_INFO: u16 = 0x0208; + // Olympus MakerNote header signatures // Type 2 (newer cameras): "OLYMPUS\0II" or "OLYMPUS\0MM" (10 bytes) followed by offset const OLYMPUS_HEADER: &[u8] = b"OLYMPUS\0II"; @@ -81,6 +89,21 @@ const OLYMPUS_HEADER_BE: &[u8] = b"OLYMPUS\0MM"; // `data[0..8] == LITERAL` comparison could never be true and every type-1 // Olympus JPEG (163 of the 315 in the corpus) was rejected outright. const OLYMPUS_HEADER_TYPE1: &[u8] = b"OLYMP\x00"; +// Type 3 (OM System bodies -- OM-1, OM-3, OM-5, OM-1 Mark II, TG-7). The +// header is "OM SYSTEM\0" padded to 12 bytes, then "II"/"MM" and a version +// word, so the directory starts 16 bytes in: +// +// ```text +// MakerNotes.pm:589 Name => 'MakerNoteOlympus3', +// MakerNotes.pm:591 Condition => '$$valPt =~ /^OM SYSTEM\0/', +// MakerNotes.pm:594 Start => '$valuePtr + 16', +// MakerNotes.pm:595 Base => '$start - 16', +// ``` +// +// These bodies report `Make` as "OM Digital Solutions", which the dispatcher +// already routes here; only the signature check rejected them, so every OM +// System JPEG yielded no Olympus tags at all. +const OLYMPUS_HEADER_TYPE3: &[u8] = b"OM SYSTEM\x00"; // Sub-IFD pointer tag IDs - these point to nested IFD structures const OLYMPUS_EQUIPMENT_SUBIFD: u16 = 0x2010; @@ -373,6 +396,12 @@ impl MakerNoteParser for OlympusParser { return true; } + // Check for Type 3 headers: "OM SYSTEM\0" plus padding, byte order and + // a version word. + if data.len() >= 16 && data.starts_with(OLYMPUS_HEADER_TYPE3) { + return true; + } + false } @@ -440,9 +469,10 @@ impl OlympusParser { return Ok(()); }; - // Type 2 ("OLYMPUS\0II") stores value offsets relative to the - // MakerNote itself (ExifTool: `Base => '$start - 12'`), so no - // correction is needed. Type 1 ("OLYMP\0") measures them from the TIFF + // Type 2 ("OLYMPUS\0II") and type 3 ("OM SYSTEM\0") store value offsets + // relative to the MakerNote itself (ExifTool: `Base => '$start - 12'` + // and `'$start - 16'`), so no correction is needed. Type 1 + // ("OLYMP\0") measures them from the TIFF // header, so the correction is exactly minus the block's own // TIFF-relative offset -- and when the caller could not supply it, // out-of-line values stay unread. A structural guess is not safe here: @@ -512,10 +542,154 @@ impl OlympusParser { } } + parse_camera_type_and_quality(data, ifd_start, &entries, base, effective_byte_order, tags); + Ok(()) } } +/// `Olympus::Main` 0x0201 `Quality`, 0x0207 `CameraType` and the 0x0208 +/// `TextInfo` sub-directory, which the plain table walk cannot express. +/// +/// The three are entangled: `CameraType` is a `DataMember` that `Quality`'s +/// `PrintConv` consults, and `TextInfo` carries a second `CameraType` that +/// overwrites the first. +/// +/// ```text +/// Olympus.pm:762 0x0207 => { #PH (was incorrectly FirmwareVersion, ref 1/3) +/// Olympus.pm:763 Name => 'CameraType', +/// Olympus.pm:764 Condition => '$$valPt ne "NORMAL"', # FE240, SP510, u730 and u1000 write this +/// Olympus.pm:766 DataMember => 'CameraType', +/// Olympus.pm:767 RawConv => '$self->{CameraType} = $val', +/// Olympus.pm:769 ValueConv => '$val =~ s/\s+$//; $val', # ("SX151 " has trailing space) +/// Olympus.pm:771 PrintConv => \%olympusCameraTypes, +/// Olympus.pm:775 0x0208 => { +/// Olympus.pm:776 Name => 'TextInfo', +/// Olympus.pm:778 TagTable => 'Image::ExifTool::Olympus::TextInfo', +/// ``` +/// +/// The FE240/SP510UZ/u730/u1000 bodies pad their placeholder to `"NORMAL "`, +/// which is *not* `eq "NORMAL"` -- so ExifTool does extract it, prints +/// `Unknown (NORMAL)`, and then `TextInfo` overwrites both the tag and the +/// data member with the real body code. +fn parse_camera_type_and_quality( + data: &[u8], + ifd_start: usize, + entries: &[ifd::RawEntry], + base: Option, + order: ByteOrder, + tags: &mut HashMap, +) { + let floor = ifd_start + 2 + entries.len() * 12 + 4; + // ExifTool's `$$self{CameraType}`, tracked in extraction order. + let mut camera_type: Option = None; + let mut quality: Option = None; + + for entry in entries { + let decode = || ifd::decode_entry_with_floor(data, entry, base, order, None, floor); + match entry.tag_id { + MAIN_QUALITY => { + quality = decode().and_then(|v| v.ints().and_then(|n| n.first().copied())); + } + MAIN_CAMERA_TYPE => { + let Some(val) = decode() else { continue }; + let ifd::OlyVal::Bytes(raw) = &val else { + continue; + }; + // `Condition => '$$valPt ne "NORMAL"'` tests the raw value. + if raw.as_slice() == b"NORMAL" { + continue; + } + let Some(text) = val.as_string() else { + continue; + }; + // RawConv runs before ValueConv, so the data member keeps the + // trailing padding that ValueConv strips for display. + camera_type = Some(text.clone()); + tags.insert( + "Olympus:CameraType".to_string(), + ifd::list_lookup_or_unknown(lookups::CAMERA_TYPE2, text.trim_end()), + ); + } + MAIN_TEXT_INFO => { + let Some(val) = decode() else { continue }; + let ifd::OlyVal::Bytes(raw) = &val else { + continue; + }; + if let Some(found) = text_info::parse(raw, tags) { + camera_type = Some(found); + } + } + _ => {} + } + } + + if let Some(quality) = quality { + tags.insert( + "Olympus:Quality".to_string(), + print_quality(quality, camera_type.as_deref()), + ); + } +} + +/// `Olympus::Main` 0x0201 `Quality`. +/// +/// ```text +/// Olympus.pm:708 PrintConv => sub { +/// Olympus.pm:709 my ($val, $self) = @_; +/// Olympus.pm:710 my %t1 = ( # all SX camera types except SX151 +/// Olympus.pm:716 my %t2 = ( # all other types (except D4322, ref 22) +/// Olympus.pm:725 my $conv = $self->{CameraType} =~ /^(SX(?!151\b)|D4322)/ ? \%t1 : \%t2; +/// Olympus.pm:726 return $$conv{$val} ? $$conv{$val} : "Unknown ($val)"; +/// ``` +fn print_quality(value: i64, camera_type: Option<&str>) -> String { + const SX: &[(i64, &str)] = &[ + (0, "SQ (Low)"), + (1, "HQ (Normal)"), + (2, "SHQ (Fine)"), + (6, "RAW"), + ]; + const OTHER: &[(i64, &str)] = &[ + (1, "SQ (Low)"), + (2, "HQ (Normal)"), + (3, "SHQ (Fine)"), + (4, "RAW"), + (5, "Medium-Fine"), + (6, "Small-Fine"), + (33, "Uncompressed"), + ]; + let map = if uses_sx_quality_table(camera_type) { + SX + } else { + OTHER + }; + ifd::lookup_or_unknown(map, value) +} + +/// Perl's `$self->{CameraType} =~ /^(SX(?!151\b)|D4322)/`. +/// +/// An unset data member never matches, which is the `%t2` branch. +fn uses_sx_quality_table(camera_type: Option<&str>) -> bool { + let Some(camera_type) = camera_type else { + return false; + }; + if camera_type.starts_with("D4322") { + return true; + } + let Some(rest) = camera_type.strip_prefix("SX") else { + return false; + }; + // `(?!151\b)`: "SX151" is excluded only when a word boundary follows the + // digits, so the padded "SX151 " is excluded but "SX1518" would not be. + match rest.strip_prefix("151") { + Some(after) => after + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_'), + None => true, + } +} + /// Perl's `/\b/` against a model string: `needle` must appear and must /// not be followed by another word character. `E-1` therefore matches `E-1` /// but not `E-10` or `E-100RS`. @@ -627,6 +801,17 @@ fn detect_header_type_and_offsets( } } + // Check Type 3 headers: ExifTool's `Start => '$valuePtr + 16'`. The byte + // order marker sits at offset 12, after the NUL-padded signature. + if data.len() >= 16 && data.starts_with(OLYMPUS_HEADER_TYPE3) { + let order = match &data[12..14] { + b"II" => ByteOrder::LittleEndian, + b"MM" => ByteOrder::BigEndian, + _ => default_byte_order, + }; + return Ok((16, order)); + } + // Check Type 1 headers: ExifTool's `Start => '$valuePtr + 8'`. if data.len() >= 8 && &data[0..6] == OLYMPUS_HEADER_TYPE1 { return Ok((8, default_byte_order)); diff --git a/src/parsers/tiff/makernotes/olympus/tables.rs b/src/parsers/tiff/makernotes/olympus/tables.rs index 3190a0db6..441db4c5e 100644 --- a/src/parsers/tiff/makernotes/olympus/tables.rs +++ b/src/parsers/tiff/makernotes/olympus/tables.rs @@ -783,6 +783,46 @@ static CS_GRADATION_HEAD: &[(&str, &str)] = &[ static CS_ART_FILTER_LIST: &[ElemConv] = &[ElemConv::Map(FILTERS)]; +/// `CameraSettings` 0x0821 `ISOAutoSettings` -- "2 numbers: 1. Default +/// sensitivty, 2. Maximum sensitivity", each converted through the same +/// sensitivity hash (`Olympus.pm:2683` declares it twice, once per element). +static CS_ISO_AUTO_SENSITIVITY: &[(i64, &str)] = &[ + (0x0000, "n/a"), + (0x0600, "200"), + (0x0655, "250"), + (0x06aa, "320"), + (0x0700, "400"), + (0x0755, "500"), + (0x07aa, "640"), + (0x0800, "800"), + (0x0855, "1000"), + (0x08aa, "1250"), + (0x0900, "1600"), + (0x0955, "2000"), + (0x09aa, "2500"), + (0x0a00, "3200"), + (0x0a55, "4000"), + (0x0aaa, "5000"), + (0x0b00, "6400"), + (0x0b55, "8000"), + (0x0baa, "10000"), + (0x0c00, "12800"), + (0x0c55, "16000"), + (0x0caa, "20000"), + (0x0d00, "25600"), + (0x0d55, "32000"), + (0x0daa, "40000"), + (0x0e00, "51200"), + (0x0e55, "64000"), + (0x0eaa, "80000"), + (0x0f00, "102400"), +]; + +static CS_ISO_AUTO_SETTINGS_LIST: &[ElemConv] = &[ + ElemConv::Map(CS_ISO_AUTO_SENSITIVITY), + ElemConv::Map(CS_ISO_AUTO_SENSITIVITY), +]; + static CS_ART_FILTER_EFFECT_LIST: &[ElemConv] = &[ ElemConv::Map(FILTERS), ElemConv::Raw, @@ -1147,7 +1187,12 @@ pub static CAMERA_SETTINGS: &[TagDef] = &[ (4, "On, S-IS Auto"), ], ), - TagDef::raw(0x0821, "ISOAutoSettings"), + TagDef { + id: 0x0821, + name: "ISOAutoSettings", + force_type: None, + conv: Conv::List(CS_ISO_AUTO_SETTINGS_LIST), + }, TagDef::func(0x0900, "ManometerPressure", print_manometer_pressure), TagDef::func(0x0901, "ManometerReading", print_manometer_reading), TagDef::lookup(0x0902, "ExtendedWBDetect", OFF_ON), diff --git a/src/parsers/tiff/makernotes/olympus/text_info.rs b/src/parsers/tiff/makernotes/olympus/text_info.rs new file mode 100644 index 000000000..a9cb4cee7 --- /dev/null +++ b/src/parsers/tiff/makernotes/olympus/text_info.rs @@ -0,0 +1,298 @@ +//! `Olympus::TextInfo` -- the MakerNote 0x0208 sub-directory. +//! +//! Older Olympus bodies store a short ASCII record inside the MakerNote: +//! +//! ```text +//! [pictureInfo] Resolution=3 [Camera Info] Type=SR951\0 +//! ``` +//! +//! ExifTool runs it through the same scanner as the JPEG APP12 "Picture Info" +//! segment: +//! +//! ```text +//! Olympus.pm:1573 %Image::ExifTool::Olympus::TextInfo = ( +//! Olympus.pm:1574 PROCESS_PROC => \&Image::ExifTool::APP12::ProcessAPP12, +//! Olympus.pm:1576 This information is in text format (similar to APP12 information, but with +//! Olympus.pm:1577 spaces instead of linefeeds). +//! ``` +//! +//! Those spaces are why this needs the real scanner rather than the +//! delimiter-splitting readers in `parsers::jpeg::app_segments`: the whole +//! record above is a single "line", so splitting on CR/LF/NUL yields one token +//! and no tags at all. +//! +//! The table itself names two fields and accepts anything else: +//! +//! ```text +//! Olympus.pm:1578 any information found here will be extracted, even if the tag is not listed. +//! Olympus.pm:1581 Resolution => { }, +//! Olympus.pm:1582 Type => { +//! Olympus.pm:1583 Name => 'CameraType', +//! Olympus.pm:1585 DataMember => 'CameraType', +//! Olympus.pm:1586 RawConv => '$self->{CameraType} = $val', +//! Olympus.pm:1588 PrintConv => \%olympusCameraTypes, +//! ``` + +use std::collections::HashMap; + +use super::ifd::list_lookup_or_unknown; +use super::lookups::CAMERA_TYPE2; + +/// Perl's `\w` under ASCII semantics. +fn is_word(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +/// One character of ExifTool's `[\w#-]+` field-name class. +fn is_name_byte(b: u8) -> bool { + is_word(b) || b == b'#' || b == b'-' +} + +/// Perl's `\s`: space, tab, newline, form feed, carriage return and (since +/// 5.18) vertical tab. +fn is_space(b: u8) -> bool { + matches!(b, b' ' | b'\t' | b'\n' | 0x0b | 0x0c | b'\r') +} + +/// A `[\w#-]+=` run starting at `at`. +fn field_name_ends_with_eq(data: &[u8], at: usize) -> bool { + let mut j = at; + while j < data.len() && is_name_byte(data[j]) { + j += 1; + } + j > at && j < data.len() && data[j] == b'=' +} + +/// ExifTool's value terminator: `(?=\s*([\n\r\0]|[\w#-]+=|\[|$))`. +/// +/// The lookahead is zero-width, so only whether *some* `\s*` length satisfies +/// it matters; this tries them shortest-first rather than Perl's greedy order. +fn value_ends_at(data: &[u8], at: usize) -> bool { + let mut s = at; + loop { + // `$` -- end of string, or immediately before a string-final newline. + if s >= data.len() { + return true; + } + if matches!(data[s], b'\n' | b'\r' | 0) || data[s] == b'[' { + return true; + } + if field_name_ends_with_eq(data, s) { + return true; + } + if is_space(data[s]) { + s += 1; + } else { + return false; + } + } +} + +/// Port of ExifTool's `ProcessAPP12` scanner. +/// +/// ```text +/// APP12.pm:262 while ($$dataPt =~ /(\[.*?\]|[\w#-]+=[\x20-\x7e]+?(?=\s*([\n\r\0]|[\w#-]+=|\[|$)))/g) { +/// ``` +/// +/// Section headers (`[Camera Info]`) are consumed and dropped -- ExifTool uses +/// them only to pick a family-2 group for dynamically added tags, which is not +/// modelled here. The returned pairs are in the order they appear. +pub fn scan(data: &[u8]) -> Vec<(String, String)> { + let mut out = Vec::new(); + let mut pos = 0usize; + while pos < data.len() { + // `\[.*?\]` -- non-greedy, and `.` never matches a newline. + if data[pos] == b'[' { + if let Some(end) = (pos + 1..data.len()) + .take_while(|&i| data[i] != b'\n') + .find(|&i| data[i] == b']') + { + pos = end + 1; + continue; + } + } + + // `[\w#-]+=` -- the greedy run must land exactly on the '='; shorter + // backtracked runs always end on another name byte, never on '='. + let mut name_end = pos; + while name_end < data.len() && is_name_byte(data[name_end]) { + name_end += 1; + } + if name_end > pos && name_end < data.len() && data[name_end] == b'=' { + // `[\x20-\x7e]+?` -- at least one printable byte, grown until the + // terminator lookahead succeeds. + let value_start = name_end + 1; + let mut value_end = value_start; + let matched = loop { + if value_end >= data.len() || !(0x20..=0x7e).contains(&data[value_end]) { + break None; + } + value_end += 1; + if value_ends_at(data, value_end) { + break Some(value_end); + } + }; + if let Some(value_end) = matched { + out.push(( + String::from_utf8_lossy(&data[pos..name_end]).into_owned(), + String::from_utf8_lossy(&data[value_start..value_end]).into_owned(), + )); + pos = value_end; + continue; + } + } + + pos += 1; + } + out +} + +/// Perl's `ucfirst`: upper-case the first character, leave the rest alone. +fn ucfirst(value: &str) -> String { + let mut chars = value.chars(); + match chars.next() { + Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(), + None => String::new(), + } +} + +/// The name ExifTool gives a field the table does not list. +/// +/// ```text +/// APP12.pm:278 $tagInfo = { Name => ucfirst $tag }; +/// ExifTool.pm:9234 $name =~ tr/-_a-zA-Z0-9//dc; # remove illegal characters +/// ExifTool.pm:9235 $name = ucfirst $name; # capitalize first letter +/// ExifTool.pm:9243 $name = "Tag$name" if length($name) < 2 or $name !~ /^[A-Z]/i; +/// ``` +fn dynamic_tag_name(field: &str) -> Option { + let stripped: String = ucfirst(field) + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '_') + .collect(); + let mut name = ucfirst(&stripped); + if name.is_empty() { + return None; + } + if name.len() < 2 || !name.starts_with(|ch: char| ch.is_ascii_alphabetic()) { + name = format!("Tag{}", name); + } + Some(name) +} + +/// Extract one `Olympus::TextInfo` record into `Olympus:*` tags. +/// +/// Returns the raw `Type` value, which ExifTool records as the `CameraType` +/// data member and later consults when converting `Quality`. +pub fn parse(data: &[u8], tags: &mut HashMap) -> Option { + let mut camera_type = None; + for (field, value) in scan(data) { + match field.as_str() { + // `Type => { Name => 'CameraType', PrintConv => \%olympusCameraTypes }` + "Type" => { + tags.insert( + "Olympus:CameraType".to_string(), + list_lookup_or_unknown(CAMERA_TYPE2, &value), + ); + camera_type = Some(value); + } + // `Resolution => { }` -- no conversion of any kind. + "Resolution" => { + tags.insert("Olympus:Resolution".to_string(), value); + } + _ => { + if let Some(name) = dynamic_tag_name(&field) { + tags.insert(format!("Olympus:{}", name), value); + } + } + } + } + camera_type +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scans_the_space_separated_olympus_record() { + // OlympusC2000Z.jpg, MakerNote tag 0x0208. + let pairs = scan(b"[pictureInfo] Resolution=3 [Camera Info] Type=SR951\0"); + assert_eq!( + pairs, + vec![ + ("Resolution".to_string(), "3".to_string()), + ("Type".to_string(), "SR951".to_string()), + ] + ); + } + + #[test] + fn a_bracket_terminates_a_value_without_intervening_space() { + // OlympusSX351 bodies write "Resolution=11[Camera Info]" with no gap; + // the `\[` branch of the lookahead is what stops the value at "11". + let pairs = scan(b"[pictureInfo] Resolution=11[Camera Info] Type=SX351\0"); + assert_eq!(pairs[0], ("Resolution".to_string(), "11".to_string())); + assert_eq!(pairs[1], ("Type".to_string(), "SX351".to_string())); + } + + #[test] + fn values_may_contain_spaces() { + // `[\x20-\x7e]+?` admits spaces, so only the lookahead ends the value. + let pairs = scan(b"ID=OLYMPUS DIGITAL CAMERA\0"); + assert_eq!( + pairs, + vec![("ID".to_string(), "OLYMPUS DIGITAL CAMERA".to_string())] + ); + } + + #[test] + fn trailing_nul_padding_is_not_part_of_the_value() { + let pairs = scan(b"[Camera Info] Type=D4406\0\0\0\0\0\0\0\0\0"); + assert_eq!(pairs, vec![("Type".to_string(), "D4406".to_string())]); + } + + #[test] + fn an_empty_field_produces_no_pair() { + // `[\x20-\x7e]+?` needs at least one printable byte. + assert_eq!(scan(b"Serial#=\0Type=DCHT\0").len(), 1); + } + + #[test] + fn type_is_renamed_and_converted_through_the_camera_type_hash() { + let mut tags = HashMap::new(); + let member = parse( + b"[pictureInfo] Resolution=3 [Camera Info] Type=SR951\0", + &mut tags, + ); + + assert_eq!( + tags.get("Olympus:CameraType").map(String::as_str), + Some("C2000Z") + ); + assert_eq!( + tags.get("Olympus:Resolution").map(String::as_str), + Some("3") + ); + // The data member is the raw field value, not the converted name. + assert_eq!(member.as_deref(), Some("SR951")); + } + + #[test] + fn an_unlisted_body_code_prints_exiftools_unknown_form() { + let mut tags = HashMap::new(); + parse(b"[Camera Info] Type=ZZ999\0", &mut tags); + assert_eq!( + tags.get("Olympus:CameraType").map(String::as_str), + Some("Unknown (ZZ999)") + ); + } + + #[test] + fn unlisted_fields_are_added_under_their_ucfirst_name() { + let mut tags = HashMap::new(); + parse(b"[pictureInfo] shtr=1000 Q=96\0", &mut tags); + assert_eq!(tags.get("Olympus:Shtr").map(String::as_str), Some("1000")); + // ExifTool.pm:9243 prefixes names shorter than two characters. + assert_eq!(tags.get("Olympus:TagQ").map(String::as_str), Some("96")); + } +}