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/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/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 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/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/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/exiftool_compat.rs b/src/core/exiftool_compat.rs index d3d5cffba..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() @@ -772,10 +783,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 +864,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 +892,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 // ============================================================================= @@ -1466,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/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/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/exif_enums.rs b/src/core/formatters/exif_enums.rs index 21bdc7688..0648bf2b3 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 @@ -167,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 { @@ -182,59 +305,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 +571,97 @@ 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 + /// 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/core/formatters/mod.rs b/src/core/formatters/mod.rs index d9492f3ee..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,12 +25,13 @@ 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, - 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/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. 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/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), + ]), + ); } } 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)")); + } +} 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/pdf/mod.rs b/src/parsers/pdf/mod.rs index 1cb5fbdc5..49b88fa4c 100644 --- a/src/parsers/pdf/mod.rs +++ b/src/parsers/pdf/mod.rs @@ -57,6 +57,9 @@ pub mod shared; pub mod signature_parser; pub mod xmp_extractor; +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}; @@ -501,115 +504,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 -/// (`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. -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 @@ -738,11 +632,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 +734,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())); } @@ -1066,7 +956,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())); } @@ -1075,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", 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', 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; diff --git a/src/parsers/tiff/makernote_dispatcher.rs b/src/parsers/tiff/makernote_dispatcher.rs index 6a5ef720c..8ddfca681 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); } @@ -220,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/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/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/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/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")); + } +} diff --git a/src/parsers/tiff/makernotes/panasonic.rs b/src/parsers/tiff/makernotes/panasonic.rs index 8c58dd589..3ea713efe 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 } @@ -281,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, @@ -397,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/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/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/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). 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/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/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/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}" + ); + } + } +} 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/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..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; @@ -120,5 +127,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/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:?}"); + } } 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", + ); +} 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] 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")); +} 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"