Skip to content

refactor(core): one PrintExposureTime instead of sixteen - #340

Merged
swackhamer merged 1 commit into
mainfrom
salvage/lens-duplicate-impls
Aug 1, 2026
Merged

refactor(core): one PrintExposureTime instead of sixteen#340
swackhamer merged 1 commit into
mainfrom
salvage/lens-duplicate-impls

Conversation

@swackhamer

@swackhamer swackhamer commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Image::ExifTool::Exif::PrintExposureTime had been ported into sixteen
separate modules. Four of the copies printed a different string than ExifTool
for the same number of seconds. Every copy shipped with its own unit tests
asserting its own output, so all sixteen were green.

# Exif.pm:5606
sub PrintExposureTime($)
{
    my $secs = shift;
    return $secs unless Image::ExifTool::IsFloat($secs);
    if ($secs < 0.25001 and $secs > 0) {
        return sprintf("1/%d",int(0.5 + 1/$secs));
    }
    $_ = sprintf("%.1f",$secs);
    s/\.0$//;
    return $_;
}

The four divergences

All of them are in the branch above 0.25 s; the reciprocal branch was right
everywhere.

1. sony/binary.rs never rounded to one decimal

It rendered the slow branch with print_float — ten decimal places — instead
of sprintf("%.1f"). Two tables reach it, both of which derive seconds from an
APEX exponent, so the reachable values are irrational and the full quotient
came out:

tag ValueConv raw values wrong example
Sony:ShutterSpeedSetting 2**(6-$val/8) 56 / 256 ExifTool 58.7, oxidex 58.6882587651
Minolta:ShutterSpeed 2**((48-$val)/8) 56 / 256 ExifTool 11.3, oxidex 11.3137084990

(minolta_tables.rs:10 imported print_exposure_time from sony::binary, so
the Minolta tables inherited the Sony bug.)

2/3/4. pdf/mod.rs, sigma.rs, sony/binary_data.rs dropped s/\.0$//

They replaced the trailing-zero regex with an secs == secs.trunc() test taken
before formatting. That is a different question. 29.96 is not an integer, but
%.1f rounds it to "30.0" — ExifTool's regex then strips the .0 and prints
30, while these three printed 30.0.

Over [0.25 s, 40 s] sampled at 1e-4 that is 39,449 of 375,000 inputs.
Among 1/8-stop APEX shutter speeds it is 2^4.25 (19.03 s → 19 vs 19.0) and
2^6.375 (83.00 s → 83 vs 83.0).

Reached by Sigma:ExposureTime (0x0032 and the microsecond string at 0x0033),
the PDF/XMP APEX ShutterSpeedValue path, and Sony's Pc::ExposureTimeOrBulb.

5. composite/compute.rs open-coded the rounding

(v*10.0+0.5).floor()/10.0 is round-half-up; %.1f is round-half-to-even on
the exact binary value. Composite:ShutterSpeed of 0.35 s printed 0.4 where
ExifTool prints 0.3; 2.25 s printed 2.3 where ExifTool prints 2.2.
198 of 375,000 inputs over the same range.

What this PR does

Adds core::formatters::exif_print_conv with the one port plus the &str
wrapper the two APP12 text segments need for Exif.pm:5609's
return $secs unless IsFloat, and deletes all sixteen local copies —
including the eleven that were already equivalent, because a helper that
exists sixteen times is a helper that will diverge again.

18 files changed, 162 insertions(+), 260 deletions(-)

Verification

  • Ground truth. All 22 assertions in the new module were checked by calling
    Image::ExifTool::Exif::PrintExposureTime from ExifTool 13.55 directly, not
    by reading the Perl. Both divergence counts above were produced the same way.

  • No regressions. oxidex -a -e run under fd8a7322 and under this branch
    over all 4,240 files of the combined-samples corpus, diffed as matched
    sets of Group:Name: Value lines rather than by totals:

    corpus files compared      : 4240
    total tag lines emitted    : 529212   (files emitting nothing: 4)
    files with any diff        : 4221     <- all but one are File:FileAccessDate
    files with a real diff     : 1
    

    File:FileAccessDate moves on every file because the harness reads each one
    twice; it is atime churn, not output. The single real change is
    Samsung/SamsungDigimax340.jpg, Composite:ShutterSpeed 7.3 -> 7.2
    the composite/compute.rs rounding fix above, landing on the value %.1f
    produces for 7.25.

    That tag is still wrong against ExifTool (1/152), for an unrelated and
    pre-existing reason this diff surfaced: oxidex feeds the composite the raw
    stored APEX ShutterSpeedValue (7.25) instead of its ValueConv'd seconds
    (Exif.pm:2322, 2**(-$val)), which it does apply correctly when emitting
    ExifIFD:ShutterSpeedValue. Composite:Aperture on the same file has the
    same defect (4.5 vs 4.7). Filed separately; parity for those tags was a
    miss before this PR and is a miss after it.

    Correction: an earlier revision of this description reported
    files whose tag SET differs: 0. That run passed -G1, which oxidex does not
    accept, so both binaries emitted nothing and the comparison was vacuous. The
    numbers above are from the re-run, which carries an emitted-line count
    precisely so an all-empty run cannot pass again.

  • cargo fmt --all, cargo clippy --workspace clean, cargo test --workspace
    all green. CI on the merge head: Build & Test, Lint & Audit, Verify Generated
    Tables, CodeQL and docs all success.

Note that the corpus contains no file that trips any of the four bugs — every
Sony/Minolta shutter setting in it is in the reciprocal branch, and no
ExposureTime lands on the rounding boundary. That is precisely why sixteen
copies could drift for this long: the guard has to be a unit test pinned to
ExifTool's own function, which is what the new module carries.

Follow-ups this pass did not take

Sized, with the divergence already measured:

  1. perl_number × 6 ({:.6} truncation). canon.rs and
    canon/camera_info.rs hold byte-identical format!("{:.6}", v) copies of
    Perl's %.15g; measured against Perl over 3,263 realistic values they are
    wrong on 1,028. custom_functions2.rs uses an (-6..15) exponent
    bucket where C's %g uses -4: wrong on 315. h264.rs uses Rust's
    {} (shortest round-trip, 17 digits): wrong on 1,015, though its single
    call site's 128-value domain happens to agree. table_ifd.rs::fmt_g15 takes
    log10().floor() before rounding and so mis-buckets values that round
    across a power of ten. The canonical
    core::formatters::numeric_precision::perl_number is wrong on 1 — the
    Perl IV-integer case at ≥1e15, unreachable for metadata. ~5 files.
  2. The parity harness measures a formatter the library never ships.
    src/bin/tag-comparison/extraction/oxidex_extractor.rs:19 imports
    format_with_unit and needs_unit_suffix from core::value_formatter,
    whose format_with_unit is a naive format!("{} mm", value). The library
    uses core::formatters::unit_suffixes, which applies Exif.pm:2401's
    sprintf("%.1f mm",$val). So EXIF:FocalLength is 50.0 mm in the product
    and 50 mm in the measurement. One-line import change, but it moves the
    reported coverage number, so it wants its own PR.
  3. print_f_number × 4composite/compute.rs:400 applies {:.1} when
    $val <= 0, where Exif.pm:5620's and $val > 0 guard returns the value
    untouched: 0 prints as 0.0.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

`Image::ExifTool::Exif::PrintExposureTime` (Exif.pm:5606) had been ported
into sixteen separate modules -- pdf, raw, minolta, sigma, nikon, canon,
canon/camera_info, pentax, sony/binary, sony/binary_data, phaseone, h264,
two APP12 segments, core/exiftool_compat and composite/compute. Four of
those copies printed a different string than ExifTool for the same seconds,
and because each one carried its own unit tests asserting its own output,
the suite was green on all sixteen.

    sub PrintExposureTime($)
    {
        my $secs = shift;
        return $secs unless Image::ExifTool::IsFloat($secs);
        if ($secs < 0.25001 and $secs > 0) {
            return sprintf("1/%d",int(0.5 + 1/$secs));
        }
        $_ = sprintf("%.1f",$secs);
        s/\.0$//;
        return $_;
    }

The divergences, all in the branch above 0.25 s:

* `sony/binary.rs` rendered it with `print_float` -- ten decimal places --
  instead of `sprintf("%.1f")`. `Sony:ShutterSpeedSetting` is
  `2**(6-$val/8)`, so 56 of the 256 reachable raw values printed the full
  quotient: `58.6882587651` where ExifTool prints `58.7`, `4.7568284600`
  where ExifTool prints `4.8`.
* `pdf/mod.rs`, `sigma.rs` and `sony/binary_data.rs` replaced `s/\.0$//`
  with an `secs == secs.trunc()` test taken *before* formatting. That is a
  different question: 29.96 is not an integer but `%.1f` rounds it to
  "30.0", so those three printed `30.0` against ExifTool's `30`. Over
  [0.25 s, 40 s] sampled at 1e-4 that is 39,449 of 375,000 inputs; among
  1/8-stop APEX shutter speeds it is 2^4.25 (19.03 s) and 2^6.375 (83.00 s).
* `composite/compute.rs` open-coded the rounding as
  `(v*10.0+0.5).floor()/10.0` and printed the result with Rust's `{}`.
  That is round-half-up where `%.1f` is round-half-to-even, so an exposure
  of 2.25 s printed `2.3` against ExifTool's `2.2`.

The eleven remaining copies were already equivalent; they are deleted for
the same reason -- a helper that exists sixteen times is a helper that will
diverge again.

The new `core::formatters::exif_print_conv` carries the one port, plus the
`&str` wrapper the two APP12 text segments need for `Exif.pm:5609`'s
"return $secs unless IsFloat". Its 22 assertions were checked by calling
`Image::ExifTool::Exif::PrintExposureTime` from ExifTool 13.55 directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@swackhamer
swackhamer merged commit 5eb44f0 into main Aug 1, 2026
6 checks passed
swackhamer added a commit that referenced this pull request Aug 1, 2026
`print_fraction` was ported nine times. Four of the nine printed a
different string than ExifTool, and each of the four had a unit test
asserting its own output.

Which ones were wrong was settled the way #340 settled PrintExposureTime:
by calling `Image::ExifTool::Exif::PrintFraction` in the installed 13.55
Perl over every 1/32, 1/8, 1/6, 1/3 and 1/2 EV step real cameras write,
plus %g boundary values, rather than by reading Exif.pm and reimplementing
it. Over the 1,538 swept inputs with |val| <= 20:

    panasonic.rs            {:+}      1377 wrong
    nikon/binary_data.rs    {:+.3}     476 wrong
    raw/metadata.rs         {:+.3}     430 wrong
    minolta_tables.rs       {:+.3}     430 wrong
    pdf / canon / minolta_makernote / nikon/value_reader / flash_info   0

The failure is always the last branch, Exif.pm:5436 `sprintf("%+.3g",$val)`.
`%+.3g` is three SIGNIFICANT digits; `{:+.3}` is three DECIMAL places. They
agree only while the value has one digit before the point, which is exactly
the range the four copies' own tests covered:

    input     {:+.3}     ExifTool
    -19.875   -19.875    -19.9
    -18.750   -18.750    -18.8
      1.3264  +1.326     +1.33

`panasonic.rs` used a bare `{:+}` and printed `-19.875198750000003` where
ExifTool prints `-19.9`.

Why the tests stayed green: `raw/metadata.rs`'s test is named
`print_fraction_matches_perl` and `minolta_tables.rs`'s
`print_fraction_matches_exiftool`, but between them they assert only 0,
1, -2, +-1/2 and +-1/3 -- every one of which returns from the integer, /2
or /3 branch. Not a single input in either test reaches the branch that
was wrong.

The replacement defers its `%g` to `numeric_precision::perl_g`, the one
`%g` implementation, because the exponent `%g` switches on is the one the
value has AFTER rounding -- three of the old copies derived it from
`log10` of the unrounded value instead. It was checked against the Perl
over 5,197 inputs with zero divergences.

Also wires the conversion in where ExifTool has it and oxidex did not:
0x9204 ExposureCompensation/ExposureBiasValue (Exif.pm:2347) was falling
through to Rule 20's plain %.10g quotient.

Deletes five helpers left with no callers (`format_signed_g3`, `sign_char`
and `trim_zeros` in pdf, `format_g3` in nikon/value_reader,
`format_significant_3` in canon); `allow(dead_code)` in two of those files
had been hiding them from the compiler.

Measured on 148 real files against `exiftool -a -G1 -s`, keyed on the full
Group:Name, both branches built from b0b86fc:

    match 8761 -> 8764   value_diff 399 -> 396   missing 2081 (unchanged)
    exact-match coverage 77.9379% -> 77.9646%  (+0.0267pp)

All three are ExifIFD:ExposureCompensation; no key regressed. The corpus
impact is small because it holds no Panasonic, Minolta or Nikon-binary
samples to exercise the three worst copies -- the 5,197-input Perl sweep,
not this corpus, is the evidence that they were wrong.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
swackhamer added a commit that referenced this pull request Aug 2, 2026
`ExifIFD:ExposureTime` was rendered by a hand-written formatter in
`tag_conversion.rs` that split at one second. ExifTool splits at a
quarter second (Exif.pm:5606, reached from the tag's
`PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at
Exif.pm:1824):

    sub PrintExposureTime($)
    {
        my $secs = shift;
        return $secs unless Image::ExifTool::IsFloat($secs);
        if ($secs < 0.25001 and $secs > 0) {
            return sprintf("1/%d",int(0.5 + 1/$secs));
        }
        $_ = sprintf("%.1f",$secs);
        s/\.0$//;
        return $_;
    }

Three divergences, established by executing that subroutine from the
installed ExifTool 13.55 rather than by reading it:

  seconds        ExifTool   was       now
  0.5555555556   0.6        1/2       0.6
  0.769230769    0.8        1/1       0.8
  0.8            0.8        1/1       0.8
  4.0            4          4.0       4
  30.0           30         30.0      30
  0              0          1/18446744073709551615   0

The first class is the damaging one: every exposure in [0.25001, 1)
printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s
exposure is a perfectly plausible shutter speed, so nothing downstream
could tell it was wrong. The second is the trailing `.0` that `s/\.0$//`
strips. The third divided by a zero `$secs`.

`core::formatters::print_exposure_time` is already a verified port of
the subroutine -- it reproduces all fourteen probe values exactly -- so
this deletes the private copy and calls it. #340 consolidated sixteen
copies of PrintExposureTime but did not reach this one, which is the
copy that renders the EXIF ExposureTime tag itself.

Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus,
keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80"
cannot silently become 1.8:

    differing (file,tag) pairs   40,917 -> 40,814   (-103)
      fixed                                    109
        ExifIFD:ExposureTime                    78
        Composite:ShutterSpeed                  20
        Composite:LightValue                    10
        IFD0:ExposureTime                        1
      newly differing                            6

The six are all `Composite:LightValue`, and they are collateral from a
separate, pre-existing defect: the composite layer parses the *printed*
ExposureTime string instead of the ValueConv seconds, so it now rounds
from the correct `0.6` where it used to round from `1/2`. Ten other
LightValue files move the right way for the same reason (net -4). The
same defect is visible in `Composite:ISO`, which consumes the
PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712
(Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a
composite-layer fix and is left for its own change.

Two ExposureTime classes remain out of scope here, both in the rational
pipeline rather than the PrintConv: ExifTool rounds rational64u to ten
significant figures before the conversion (ExifTool.pm:6114), which is
why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a
zero denominator should print `undef`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
swackhamer added a commit that referenced this pull request Aug 2, 2026
)

* fix(composite): preserve raw APEX conversion precision

* fix(fleet): require current-head PR approval

* fix(tables): decode repeated scalar fields

* fix(exif): one Compression table, dumped from ExifTool's own Perl

Three transcriptions of `%Image::ExifTool::Exif::compression` (Exif.pm:213)
lived in this tree, carrying 50, 40 and 53 ids and disagreeing on the spelling
of codes all three claimed to know:

  core/formatters/exif_enums.rs   32766 => "Next"
  parsers/tiff/tiff_enums.rs      32766 => "Next"
  parsers/pdf/mod.rs              32766 => "NeXt or Sony ARW Compressed 2"

ExifTool 13.59 says:

    32766 => 'NeXt or Sony ARW Compressed 2', #3/Milos

Rather than read the Perl and re-implement it -- the way three divergent copies
got written in the first place -- the hash was dumped straight out of the Perl
symbol table:

    perl -Ilib -MImage::ExifTool::Exif -e '
      my $h = \%Image::ExifTool::Exif::compression;
      for my $v (0..70000) {
        print "$v\t", (exists $h->{$v} ? $h->{$v} : "Unknown ($v)"), "\n" }'

and each copy scored over all 70,001 inputs:

    exif_enums::format_compression   50 ids   10 wrong
    tiff_enums 0x0103 arm            40 ids   15 wrong
    pdf COMPRESSION_LABELS           53 ids    1 wrong
    consolidated table               53 ids    0 wrong

The wrong answers, all now fixed: `9` read `JBIG B&W` (13.59 renamed it
`JBIG B&W or VC-5`); `32766` read `Next`; `33003`, `33004` and `33005` all
collapsed to `Aperio JPEG 2000 YCbCr`, where 33005 is `Aperio JPEG 2000 RGB`
and 33004 is not an ExifTool key at all; `34926`/`34927` dropped the ` (old)`
suffix that separates them from the LibTiff 4.7 codes; and `50000` `Zstd`,
`50001` `WebP`, `50002` `JPEG XL (old)`, `52546` `JPEG XL`, plus `32772`,
`33003`, `34887`, `34925`, `34933`, `34934` on the TIFF path, were absent.

`compression_label` returns `Option<&'static str>` so each caller keeps its
established unknown-value behaviour: `format_compression` renders `Unknown (N)`
the way `PrintConv` does, while the TIFF and PDF paths still return `None`
rather than invent a label ExifTool never prints.

Why the old tests stayed green: `test_compression` asserted 1, 6 and 7 -- three
codes all three tables agreed on -- and `tiff_enums.rs` had no test module at
all. Not one input reached a wrong branch. The new tests assert every one of the
15 codes that was measurably wrong; reverting the table makes 5 of them fail.

Corpus check (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):
393,343 correct before and after, matched-set delta fixed=0 regressed=0. The
corpus stores only Compression 1/4/5/6/7/34713 plus two codes ExifTool does not
name, so it exercises none of the divergent ids -- the divergence is measured
against the Perl, and the corpus proves the consolidation costs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): Flash is a lookup table, not a bitfield (#393)

Exif.pm 0x9209 is `PrintConv => \%flash` -- a flat 27-key hash -- plus
`Flags => 'PrintHex'`. `core::exif_enums::decode_flash` instead synthesised the
label from the byte's bit fields (fired / strobe return / mode / function
present / red-eye). Those bits explain how ExifTool's 27 codes were *chosen*;
they are not how the tag is rendered, and 229 of the 256 byte values are not
valid Flash codes at all.

Scored against `%Image::ExifTool::Exif::flash` dumped out of the Perl symbol
table, over every input in 0..=255:

    core::exif_enums::decode_flash    236 wrong / 256
    consolidated table                  0 wrong / 256

Two verbatim copies of the correct table already existed -- `FLASH_LABELS` in
`parsers::pdf` and the `0x9209` arm of `parsers::raw::metadata` -- so the tree
held three Flash decoders, and only the one on the main EXIF path was wrong.
All three now share `core::formatters::exif_enums::flash_label`.

Three classes of wrong answer, all corpus-visible:

  - codes ExifTool leaves unknown got a plausible label. 0x38 and 0x28 both
    came back `No flash function`; ExifTool prints `Unknown (0x38)` and
    `Unknown (0x28)` -- 60 files.
  - codes it did know were mis-worded: 0x49 gave `On, Fired, Red-eye reduction`
    where the hash says `On, Red-eye reduction` -- 23 files. Same for 0x0d,
    0x0f, 0x4d, 0x4f, 0x50.
  - `format_flash`'s unknown form was decimal; `PrintHex` makes it hex.

Why the old test stayed green: `test_flash_decoding` had 13 assertions, each
annotated with the bit layout it was deriving -- and one of them,
`decode_flash(0x49) == "On, Fired, Red-eye reduction"`, asserted a string
ExifTool never prints. The test encoded the same wrong model as the code. It is
rewritten to check the hash, and three new tests cover the mis-worded codes, the
hex unknown form, and the 27-of-256 key count; reverting the table fails four of
them.

Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):

    BEFORE correct=393343  valdiff=17058  score=77.5097%
    AFTER  correct=393467  valdiff=16934  score=77.5341%
    MATCHED-SET DELTA  fixed=124  regressed=0
    PER-FILE  124 files +1, 0 down     FORMAT  JPEG 390485 -> 390609 (+124)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): FocalPlaneResolutionUnit decodes instead of printing its code (#395)

Exif.pm 0xa210 declares a PrintConv:

    0xa210 => {
        Name => 'FocalPlaneResolutionUnit',
        Notes => 'values 1, 4 and 5 are not standard EXIF',
        PrintConv => { 1 => 'None', 2 => 'inches', 3 => 'cm',
                       4 => 'mm', 5 => 'um' },
    },

The tree already held that table -- in `parsers::pdf`, reachable only from a
PDF's embedded TIFF thumbnail. The main EXIF path had no arm for the tag at all,
so `format_tag_value` returned the raw integer and 1,098 files in the sample
corpus reported `2` and `3` where ExifTool reports `inches` and `cm`.

The table moves to `core::formatters::exif_enums` next to the Compression and
Flash tables, PDF reads it from there, and the `exiftool_compat` dispatch gains
the arm it was missing.

`Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an
exclusion: ExifTool decodes all five, and the corpus holds 4 files at `4` and 1
at `5`. 0xa210 carries no `PrintHex`, so unnamed codes stay decimal --
`Unknown (0)`, which 4 corpus files report.

Safe for the composites: `Exif ScaleFactor35efl` -- which gates
FocalLength35efl, CircleOfConfusion, HyperfocalDistance, FOV and DOF -- already
matches either spelling (`Some("3") | Some("cm")`). Measured: zero `Composite:*`
values changed anywhere in the corpus.

A unit test on the new function would not have caught this: the table already
existed and was already correct. The regression test asserts the *dispatch*,
through `format_tag_value`; deleting the new arm fails it with
`left: Integer(1), right: String("None")`.

Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):

    BEFORE correct=393467  valdiff=16934  score=77.5341%
    AFTER  correct=394565  valdiff=15836  score=77.7505%
    MATCHED-SET DELTA  fixed=1098  regressed=0
    PER-FILE  1098 files +1, 0 down
    FORMAT    JPEG +1094, DNG +1, CR2 +1, RAF +1, BPG +1

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(write): report the dropped write instead of printing success

`plan_exif_write` may emit only one IFD record per tag id, so when two
metadata-map keys resolve to the same id the second cannot be written. The
guard that enforced this used `continue`, discarding the caller's edit while
the CLI still printed "1 image files updated" and exited 0:

    $ oxidex -ExifIFD:ExposureBiasValue=-0.5 photo.jpg   # 0x9204
        1 image files updated                            # exit 0
    $ exiftool -a -G1 -s -ExposureCompensation photo.jpg
    [ExifIFD]  ExposureCompensation : -3/2                # unchanged

Before #368 this failed loudly with a type error; #368 fixed the typing and the
failure went quiet.

ExifTool refuses the same command. `ExposureBiasValue` is not a writable tag
name in any of its tables -- only a Notes remark on 0x9204 ("called
ExposureBiasValue by the EXIF spec", Exif.pm:2369) and an XMP property whose
tag name is again ExposureCompensation (XMP.pm:2096-2097); `TagLookup.pm` has
no `exposurebiasvalue` key, so `TagExists` fails and `SetNewValue` returns
"Tag '...' is not defined" (Writer.pl:581-584) with exit 1.

A collision that would lose an edit is now refused with a message naming both
keys and the id; a collision that loses nothing -- the same value already
planned under another spelling, which is what the documented `-EXIF:Tag=value`
syntax produces -- is still skipped silently.

Two further defects fall out of the same guard:

* `-EXIF:ExposureTime=1/250` wrote 0x829A into *both* IFD0 and ExifIFD. An
  "EXIF:" key names the tag family, not a physical IFD, so the collision search
  had to span IFD0/ExifIFD/GPS rather than only the IFD0 fallback.

* `-ExifIFD:ExposureCompensation=-0.5` serialized ASCII "-0.5" into a
  rational64s tag, because the registry declared 0x9204 `String`. Corrected,
  along with 46 other EXIF tags whose declared type disagreed with ExifTool's
  `Writable`, by adding the missing `type:` to the YAML tag database. The set
  was derived mechanically from ExifTool 13.59's own Perl tables and restricted
  to scalar numeric/date tags with no ValueConv and no blob flag -- multi-value,
  ValueConv and `undef`-storage tags are deliberately untouched, since for those
  ExifTool's accepted input type comes from PrintConvInv/ValueConvInv rather
  than the storage code (FileSource's Integer, for one, is already correct).

ModifyDate and CreateDate are among the 47: both now normalize
`2024-01-15T10:30:00` to ExifTool's stored `2024:01:15 10:30:00`.

#358's write path is untouched: a binary built from origin/main and one built
from this branch produce byte-identical output on 24 files (9 corpus RAW/TIFF/
PNG/JPS + 12 corpus JPEGs + 3 fixtures) for an ordinary `-IFD0:Artist=` write,
and all 11 files from #358's table still accept writes with no metadata lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(pentax): enter the five MakerNote sub-directories gated on the camera model

`exiftool -v2` descends into 563 of the corpus's files with a Pentax or FujiFilm
MakerNote. Comparing per file against `exiftool -a -G1 -s` (ExifTool 13.59),
five of the directories it enters produced no oxidex row at all -- not a wrong
value, not a missing-tag report, nothing, because a directory that is never
entered has no rows to be missing:

  tag     ExifTool table                files  tags
  0x0216  %Pentax::BatteryInfo             30   129
  0x021f  %Pentax::AFInfo                  28   111
  0x022a  %Pentax::FilterInfo              18    36
  0x03ff  %Pentax::TempInfo                 8    22
  0x0226  %Pentax::ShotInfo                 6     6

These are plain `ProcessBinaryData` records under a MakerNote oxidex already
reads, so the transcription is the generator's job. What kept them out is that
almost every field in them carries an ExifTool `Condition` on the camera model,
and `%Pentax::BatteryInfo` is an arrayref of such alternatives at nearly every
offset -- the construct `codegen_subdirs.py` logged as "arrayref of Condition
variants" and refused. Byte 2 of that record alone is `BodyBatteryADNoLoad` on a
K10D with one calibration `PrintConv`, an uncalibrated `BodyBatteryADNoLoad` on
a *istD, half of an `int16u` `BodyBatteryVoltage1` on a K-5, and
`BodyBatteryState` on a K-3 III (Pentax.pm:4846-4935). Read without the
condition it is one number under four different real tag names.

So the generator grew a vocabulary rather than a special case:

- `Condition` over `$$self{Model}` -- `=~`, `!~`, `eq`, and the `A and B`
  conjunction Pentax.pm:4864 uses to hold the K-3 III out of a pattern that
  would otherwise catch it. The regexes are expanded to literal alternations at
  generation time (32 branches, `\*ist` and `GX-1[LS]?` and
  `K-(1|01|3|5|7|30|50|70|500|r|x|S[12])` included) by a parser that raises on
  any construct it has not been taught, so the model test stays derived from
  ExifTool's own pattern text instead of a hand-typed list.
- alternatives as several `Field`s sharing one ExifTool key, in ExifTool's
  order; the decoder takes the first whose condition holds and reports nothing
  when none does. A key any of whose alternatives is refused is dropped whole --
  removing an earlier one would let a later, broader one answer for a body it
  was never meant to describe.
- `PrintConv` as a Perl expression, and `ValueConv` composed with it, which is
  what makes a raw 686 of centivolts print "6.86 V" rather than "686.00 V".
- `Field` now carries ExifTool's own key text, because `545`, `545.1` and
  `545.2` are three masked tags at one offset while two entries keyed `2` are
  two readings of one tag.

0x021f and 0x0216 declare `ByteOrder => 'BigEndian'` on the SubDirectory and are
read that way whatever the MakerNote's own order is (Pentax.pm:2983-2986,
:2949). 0x022a is one table read either way round, chosen by
`$$self{Make} =~ /^RICOH/` (Pentax.pm:3032) -- the brand, not the model -- so
`PentaxParser` now carries that test from the dispatcher rather than guessing it
from the model.

Every constant is quoted from Pentax.pm in the code; the unit tests replay the
exact record bytes `exiftool -v3` prints for PentaxK10D.jpg and PentaxK-5IIs.jpg
against the exact values `exiftool -a -G1 -s` reports for those files.
Regenerating reproduces the committed tables byte for byte, and the FujiFilm and
Panasonic tables are field-identical to before apart from the two new columns.

Measured per file over /tmp/oxidex-exiftool-cache/combined-samples against
ExifTool 13.59, keyed `Group1:Name`, both binaries built from this tree:

  563 Pentax/FujiFilm files
  matched  45,133 -> 45,437   (+304 across 31 files)
  missing  12,375 -> 12,065
  regressions 0

  PentaxK-5IIs.jpg   197 -> 214
  PentaxK-5.jpg      187 -> 204
  PentaxK-r.jpg      184 -> 197
  PentaxK10D.jpg     148 -> 159

Six values on Pentax_istD.jpg are wrong, and are named here rather than
buried: that file's MakerNote offsets need ExifTool's `FixBase` correction
(ExifTool resolves its 0x0216 pointer 16 bytes below the TIFF header), which
oxidex does not implement -- so all of its out-of-line values are misread today,
75 of them before this change. The six new ones are the same pre-existing fault
reaching six more names, not a new one.

Still not entered, with the generator's own reason: `%Pentax::CameraSettings`
(a hand-written arm already owns 0x0205 and covers tags the generator refuses),
`FilterInfo`'s 20 `DigitalFilterNN` fields (a `RawConv` unpack idiom),
`%Pentax::AEInfo`/`AEInfo2`/`AEInfo3` (`PentaxEv` conversions),
`%Pentax::CAFPointInfo` (`DecodeAFPoints`), and every `BITMASK` `PrintConv`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(apple): find the MakerNote IFD, then read its binary plists

`MakerNotes.pm:37-46` starts the Apple directory at `$valuePtr + 14` -- ten
bytes of `Apple iOS\0`, two version bytes, then the two-byte order marker.
oxidex started it at byte 10, so on every iPhone it read the order marker
"MM" as an entry count of 1 and decoded one entry out of the count field and
the first tag id, arriving at tag 0x4d4d, which no table has. The result was
not a wrong value; it was silence. **Zero `Apple:` tags over the whole sample
corpus**, against the 831 ExifTool reports.

That is why the binary plists could not be reached. Four `%Apple::Main` tags
hold a whole `bplist00` blob rather than a value:

  0x0003 RunTime                    SubDirectory over %Apple::RunTime, whose
                                    PROCESS_PROC is PLIST::ProcessBinaryPLIST
                                    (Apple.pm:40-43, :324-325)
  0x0040 SemanticStyle              ValueConv => \&ConvertPLIST (Apple.pm:276)
  0x0041 SemanticStyleRenderingVer  ditto (Apple.pm:280)
  0x0042 SemanticStylePreset        ditto (Apple.pm:284)

This adds:

- `shared/binary_plist.rs`, a transcription of `Image::ExifTool::PLIST` --
  `ProcessBinaryPLIST`'s trailer and offset table (PLIST.pm:398-450) and
  `ExtractObject`'s object grammar (PLIST.pm:260-390), plus
  `XMP::SerializeStruct` (XMPStruct.pl:34-69) for the `ConvertPLIST` case.
  Three details decide whether the output matches:

    * integers are read **unsigned**: `%readProc` is Get8u/16u/24u/32u/64u
      (PLIST.pm:30-38), so `RunTimeValue` is 235706184764708 and not a
      negative number.
    * a size that `%readProc` has no entry for returns undef rather than a
      guess -- so only 1/2/3/4/8-byte integers and 4/8-byte reals decode.
    * `SerializeStruct` walks `OrderedKeys`, which for a hash built by
      `ExtractObject` is a plain `sort keys`. `Apple_iPhone13Pro.jpg` stores
      its `SemanticStyle` keys in the order 3,1,2,0 and ExifTool prints
      `{_0=1,_1=0.5,_2=0,_3=2}`. Storage order would print the reverse.

  A plist date is decoded and then dropped: ExifTool converts it with
  `ConvertUnixTime($val + 11323*24*3600, 1)`, and that second argument is
  `$toLocal`, so the string carries the extracting machine's time zone. No
  Apple blob in the corpus has one, and a value that depends on the reader's
  clock is not one to approximate.

- `%Apple::Main` itself, transcribed into `apple.rs` -- each entry's
  `Writable` format and its `PrintConv` verbatim, walked by the existing
  `shared::table_ifd`. The table's twelve `Unknown => 1` entries are absent
  on purpose; ExifTool reports those only under `-u`.

- `Composite:RunTimeSincePowerUp` (Apple.pm:348-357), which was declared in
  `composite/tables.rs` with no arm in `compute`. Its `ConvertDuration`
  PrintConv already existed, privately, inside the MTS parser; it moves to
  `core::formatters::duration` rather than being copied.

Three defects found on the way, each measured:

- `table_ifd::print_rational` printed `%.15g`. `GetRational64s`/`u`
  (ExifTool.pm:6107-6120) end in `RoundFloat($num/$den, 10)`, so ExifTool
  prints `AccelerationVector` as `-0.9245480894`, not `-0.924548089390588`.
  Fixing it also gained `Olympus:FocalPlaneDiagonal` (+5) and
  `Olympus:DigitalZoom` (+3).
- `table_ifd` had no `int64u`/`int64s` (ExifTool format codes 16 and 17).
  `Apple_iPhone15Pro.jpg` stores `LivePhotoVideoIndex` as `int64u[1]` =
  4294967700; an int64u above `i64::MAX` is dropped rather than printed
  negative.
- `parse_exif_subifd` kept only the *last* 0x927C entry. `Apple_iPhone6.jpg`
  declares two -- the camera's, and a 142-byte UTF-16 JSON blob an editor
  appended -- so the Apple parser was handed the wrong 142 bytes and reported
  nothing. Each entry is now processed in turn, as ExifTool does.

Deleted `registries/apple.rs`. It declared a `FrontFacingCamera` at 0x0032
and a `PortraitMode` at 0x0020 -- `%Apple::Main` has no tag at 0x0032 at all,
and 0x0020 is `ImageCaptureRequestID` -- plus enum decoders for `OISMode`,
`SemanticStyle`, `SignalToNoiseRatioType` and `GreenGhostMitigationStatus`,
none of which has a `PrintConv` in Apple.pm. `tests/integration.rs` never
declared `apple_makernotes_tests`, so the file asserting those names never
compiled; it is now declared, and rewritten against bytes dumped from real
corpus files with `exiftool -v3`.

Verified over /tmp/oxidex-exiftool-cache/combined-samples (4,238 files;
ExifTool 13.59 reports on 4,228) against `exiftool -a -G1 -s`, keyed
`Group1:Name`, case-sensitive, scored **per file**. Base is this branch's
merge-base, 5a8b835:

  matched  393,562 -> 394,451   (+889 across 55 files)
  regressions 0 -- matched-set diff per file, not totals
  files worsened 0; extra keys unchanged at 108,146

    Apple: keys emitted        0 -> 831, every one byte-identical to ExifTool
    of the +889:  267 plist-derived, 622 unlocked by the IFD start, 8 Olympus

    Apple_iPhone13Pro.jpg   126 -> 155
    Apple_iPhone13ProMax.jpg 99 -> 127
    Apple_iPhone15Pro.jpg    89 -> 116
    Apple_iPhone12Pro.jpg   125 -> 152

Nothing is left over: every `Apple:` tag ExifTool reports for any corpus file,
and `Composite:RunTimeSincePowerUp`, now matches on every file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(vrd): read the CanonVRD Ver2 picture-style block

A .VRD recipe written by DPP 2.0 or later carries a second edit section
after the fixed 0x272-byte VRD1 record. oxidex read VRD1 and stopped, so
combined-samples/CanonVRD.vrd reported 43 of the 108 tags ExifTool does;
the missing 65 are the whole of %CanonVRD::Ver2's picture-style block --
PictureStyle, IsCustomPictureStyle, and a nine-tag group per style.

Reaching them needs two things. ProcessEditData sizes the three
%CanonVRD::Edit sections three different ways (CanonVRD.pm:1596-1610),
and the middle one, VRDStampTool, takes its length from an int32u at its
own start; skipping it wholesale lands VRD2 four bytes early. And the
record itself is FORMAT => 'int16s', so a tag ID is an index rather than
a byte offset and the values are signed -- StandardRawColorTone reads -4
on this file, which unsigned would print as 65532.

No table is transcribed for this. src/exiftool_tables already carries
CanonVRD::Ver2 dumped from ExifTool's own in-memory hash, layout and
PrintConv enums included, so the decoder reads that.

Ver2 is only read as far as index 0x54. Past it ExifTool leans on
ValueConv -- $val/0x400 rendered as a percentage, $val/10, $val/100 --
plus a DataMember-gated SubDirectory at 0xe0 and VRDVersion-conditional
branches at 0x5e-0x60. The generator drops a conversion it cannot
reproduce exactly, which leaves the raw value behind a real tag name, so
emitting those would print a confident wrong number rather than nothing.
They stay unread; a test asserts every entry below the bound is a bare
int16s or an integer enum, so a future ExifTool release cannot quietly
move one across it.

Measured on combined-samples/CanonVRD.vrd: 43 matched -> 108 matched,
65 missing -> 0, value-diffs 0, extras 0. The 43 tags already matched
are matched by the same values (set comparison, not counts), and
ExifTool.jpg and CanonRaw.crw are byte-identical before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(lfp): read Lytro light-field metadata

oxidex named the LFP file type but had no FileFormat variant, so
format_dispatch fell through to the unsupported arm and the file yielded
no tags at all. This adds the variant, the \x89LFP magic that routes to a
parser, and a reader transcribed from Image::ExifTool::Lytro (Lytro.pm 1.04).

The container walk follows ProcessLFP (Lytro.pm:134-174): 16-byte segment
headers, a big-endian length, an 80-byte sha1 ExifTool discards, then a
body that is either JSON metadata or an embedded JPEG, padded to 16 bytes.
Tag names come from ExtractTags (Lytro.pm:104-128), which flattens the JSON
with ucfirst on each key, drops punctuation while upcasing what follows,
and strips a leading Devices.

Numbers keep their source token text. These files carry more precision than
an f64 roundtrip preserves -- "gamma" : 0.41666001081466674805 is the
literal bytes on disk -- and ExifTool reports such values unchanged because
it never reformats what it did not compute. Only the tags carrying a
ValueConv or PrintConv are parsed to f64.

Measured on the ExifTool corpus sample (per file, keyed Group1:Name,
File/System excluded):

  Lytro.lfp   matched 0 -> 95   missing 95 -> 0   extra 0 -> 0

That is 85 Lytro tags plus the 10 Composite tags, which the existing
composite engine derives once the base tags exist. Zero regressions: the
matched key sets for CanonVRD.vrd, HTML.html and LNK.lnk are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): print ExposureTime through ExifTool's PrintExposureTime

`ExifIFD:ExposureTime` was rendered by a hand-written formatter in
`tag_conversion.rs` that split at one second. ExifTool splits at a
quarter second (Exif.pm:5606, reached from the tag's
`PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at
Exif.pm:1824):

    sub PrintExposureTime($)
    {
        my $secs = shift;
        return $secs unless Image::ExifTool::IsFloat($secs);
        if ($secs < 0.25001 and $secs > 0) {
            return sprintf("1/%d",int(0.5 + 1/$secs));
        }
        $_ = sprintf("%.1f",$secs);
        s/\.0$//;
        return $_;
    }

Three divergences, established by executing that subroutine from the
installed ExifTool 13.55 rather than by reading it:

  seconds        ExifTool   was       now
  0.5555555556   0.6        1/2       0.6
  0.769230769    0.8        1/1       0.8
  0.8            0.8        1/1       0.8
  4.0            4          4.0       4
  30.0           30         30.0      30
  0              0          1/18446744073709551615   0

The first class is the damaging one: every exposure in [0.25001, 1)
printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s
exposure is a perfectly plausible shutter speed, so nothing downstream
could tell it was wrong. The second is the trailing `.0` that `s/\.0$//`
strips. The third divided by a zero `$secs`.

`core::formatters::print_exposure_time` is already a verified port of
the subroutine -- it reproduces all fourteen probe values exactly -- so
this deletes the private copy and calls it. #340 consolidated sixteen
copies of PrintExposureTime but did not reach this one, which is the
copy that renders the EXIF ExposureTime tag itself.

Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus,
keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80"
cannot silently become 1.8:

    differing (file,tag) pairs   40,917 -> 40,814   (-103)
      fixed                                    109
        ExifIFD:ExposureTime                    78
        Composite:ShutterSpeed                  20
        Composite:LightValue                    10
        IFD0:ExposureTime                        1
      newly differing                            6

The six are all `Composite:LightValue`, and they are collateral from a
separate, pre-existing defect: the composite layer parses the *printed*
ExposureTime string instead of the ValueConv seconds, so it now rounds
from the correct `0.6` where it used to round from `1/2`. Ten other
LightValue files move the right way for the same reason (net -4). The
same defect is visible in `Composite:ISO`, which consumes the
PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712
(Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a
composite-layer fix and is left for its own change.

Two ExposureTime classes remain out of scope here, both in the rational
pipeline rather than the PrintConv: ExifTool rounds rational64u to ten
significant figures before the conversion (ExifTool.pm:6114), which is
why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a
zero denominator should print `undef`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
swackhamer added a commit that referenced this pull request Aug 2, 2026
…399)

* fix(composite): preserve raw APEX conversion precision

* fix(fleet): require current-head PR approval

* fix(tables): decode repeated scalar fields

* fix(exif): one Compression table, dumped from ExifTool's own Perl

Three transcriptions of `%Image::ExifTool::Exif::compression` (Exif.pm:213)
lived in this tree, carrying 50, 40 and 53 ids and disagreeing on the spelling
of codes all three claimed to know:

  core/formatters/exif_enums.rs   32766 => "Next"
  parsers/tiff/tiff_enums.rs      32766 => "Next"
  parsers/pdf/mod.rs              32766 => "NeXt or Sony ARW Compressed 2"

ExifTool 13.59 says:

    32766 => 'NeXt or Sony ARW Compressed 2', #3/Milos

Rather than read the Perl and re-implement it -- the way three divergent copies
got written in the first place -- the hash was dumped straight out of the Perl
symbol table:

    perl -Ilib -MImage::ExifTool::Exif -e '
      my $h = \%Image::ExifTool::Exif::compression;
      for my $v (0..70000) {
        print "$v\t", (exists $h->{$v} ? $h->{$v} : "Unknown ($v)"), "\n" }'

and each copy scored over all 70,001 inputs:

    exif_enums::format_compression   50 ids   10 wrong
    tiff_enums 0x0103 arm            40 ids   15 wrong
    pdf COMPRESSION_LABELS           53 ids    1 wrong
    consolidated table               53 ids    0 wrong

The wrong answers, all now fixed: `9` read `JBIG B&W` (13.59 renamed it
`JBIG B&W or VC-5`); `32766` read `Next`; `33003`, `33004` and `33005` all
collapsed to `Aperio JPEG 2000 YCbCr`, where 33005 is `Aperio JPEG 2000 RGB`
and 33004 is not an ExifTool key at all; `34926`/`34927` dropped the ` (old)`
suffix that separates them from the LibTiff 4.7 codes; and `50000` `Zstd`,
`50001` `WebP`, `50002` `JPEG XL (old)`, `52546` `JPEG XL`, plus `32772`,
`33003`, `34887`, `34925`, `34933`, `34934` on the TIFF path, were absent.

`compression_label` returns `Option<&'static str>` so each caller keeps its
established unknown-value behaviour: `format_compression` renders `Unknown (N)`
the way `PrintConv` does, while the TIFF and PDF paths still return `None`
rather than invent a label ExifTool never prints.

Why the old tests stayed green: `test_compression` asserted 1, 6 and 7 -- three
codes all three tables agreed on -- and `tiff_enums.rs` had no test module at
all. Not one input reached a wrong branch. The new tests assert every one of the
15 codes that was measurably wrong; reverting the table makes 5 of them fail.

Corpus check (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):
393,343 correct before and after, matched-set delta fixed=0 regressed=0. The
corpus stores only Compression 1/4/5/6/7/34713 plus two codes ExifTool does not
name, so it exercises none of the divergent ids -- the divergence is measured
against the Perl, and the corpus proves the consolidation costs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): Flash is a lookup table, not a bitfield (#393)

Exif.pm 0x9209 is `PrintConv => \%flash` -- a flat 27-key hash -- plus
`Flags => 'PrintHex'`. `core::exif_enums::decode_flash` instead synthesised the
label from the byte's bit fields (fired / strobe return / mode / function
present / red-eye). Those bits explain how ExifTool's 27 codes were *chosen*;
they are not how the tag is rendered, and 229 of the 256 byte values are not
valid Flash codes at all.

Scored against `%Image::ExifTool::Exif::flash` dumped out of the Perl symbol
table, over every input in 0..=255:

    core::exif_enums::decode_flash    236 wrong / 256
    consolidated table                  0 wrong / 256

Two verbatim copies of the correct table already existed -- `FLASH_LABELS` in
`parsers::pdf` and the `0x9209` arm of `parsers::raw::metadata` -- so the tree
held three Flash decoders, and only the one on the main EXIF path was wrong.
All three now share `core::formatters::exif_enums::flash_label`.

Three classes of wrong answer, all corpus-visible:

  - codes ExifTool leaves unknown got a plausible label. 0x38 and 0x28 both
    came back `No flash function`; ExifTool prints `Unknown (0x38)` and
    `Unknown (0x28)` -- 60 files.
  - codes it did know were mis-worded: 0x49 gave `On, Fired, Red-eye reduction`
    where the hash says `On, Red-eye reduction` -- 23 files. Same for 0x0d,
    0x0f, 0x4d, 0x4f, 0x50.
  - `format_flash`'s unknown form was decimal; `PrintHex` makes it hex.

Why the old test stayed green: `test_flash_decoding` had 13 assertions, each
annotated with the bit layout it was deriving -- and one of them,
`decode_flash(0x49) == "On, Fired, Red-eye reduction"`, asserted a string
ExifTool never prints. The test encoded the same wrong model as the code. It is
rewritten to check the hash, and three new tests cover the mis-worded codes, the
hex unknown form, and the 27-of-256 key count; reverting the table fails four of
them.

Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):

    BEFORE correct=393343  valdiff=17058  score=77.5097%
    AFTER  correct=393467  valdiff=16934  score=77.5341%
    MATCHED-SET DELTA  fixed=124  regressed=0
    PER-FILE  124 files +1, 0 down     FORMAT  JPEG 390485 -> 390609 (+124)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): FocalPlaneResolutionUnit decodes instead of printing its code (#395)

Exif.pm 0xa210 declares a PrintConv:

    0xa210 => {
        Name => 'FocalPlaneResolutionUnit',
        Notes => 'values 1, 4 and 5 are not standard EXIF',
        PrintConv => { 1 => 'None', 2 => 'inches', 3 => 'cm',
                       4 => 'mm', 5 => 'um' },
    },

The tree already held that table -- in `parsers::pdf`, reachable only from a
PDF's embedded TIFF thumbnail. The main EXIF path had no arm for the tag at all,
so `format_tag_value` returned the raw integer and 1,098 files in the sample
corpus reported `2` and `3` where ExifTool reports `inches` and `cm`.

The table moves to `core::formatters::exif_enums` next to the Compression and
Flash tables, PDF reads it from there, and the `exiftool_compat` dispatch gains
the arm it was missing.

`Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an
exclusion: ExifTool decodes all five, and the corpus holds 4 files at `4` and 1
at `5`. 0xa210 carries no `PrintHex`, so unnamed codes stay decimal --
`Unknown (0)`, which 4 corpus files report.

Safe for the composites: `Exif ScaleFactor35efl` -- which gates
FocalLength35efl, CircleOfConfusion, HyperfocalDistance, FOV and DOF -- already
matches either spelling (`Some("3") | Some("cm")`). Measured: zero `Composite:*`
values changed anywhere in the corpus.

A unit test on the new function would not have caught this: the table already
existed and was already correct. The regression test asserts the *dispatch*,
through `format_tag_value`; deleting the new arm fails it with
`left: Integer(1), right: String("None")`.

Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):

    BEFORE correct=393467  valdiff=16934  score=77.5341%
    AFTER  correct=394565  valdiff=15836  score=77.7505%
    MATCHED-SET DELTA  fixed=1098  regressed=0
    PER-FILE  1098 files +1, 0 down
    FORMAT    JPEG +1094, DNG +1, CR2 +1, RAF +1, BPG +1

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(write): report the dropped write instead of printing success

`plan_exif_write` may emit only one IFD record per tag id, so when two
metadata-map keys resolve to the same id the second cannot be written. The
guard that enforced this used `continue`, discarding the caller's edit while
the CLI still printed "1 image files updated" and exited 0:

    $ oxidex -ExifIFD:ExposureBiasValue=-0.5 photo.jpg   # 0x9204
        1 image files updated                            # exit 0
    $ exiftool -a -G1 -s -ExposureCompensation photo.jpg
    [ExifIFD]  ExposureCompensation : -3/2                # unchanged

Before #368 this failed loudly with a type error; #368 fixed the typing and the
failure went quiet.

ExifTool refuses the same command. `ExposureBiasValue` is not a writable tag
name in any of its tables -- only a Notes remark on 0x9204 ("called
ExposureBiasValue by the EXIF spec", Exif.pm:2369) and an XMP property whose
tag name is again ExposureCompensation (XMP.pm:2096-2097); `TagLookup.pm` has
no `exposurebiasvalue` key, so `TagExists` fails and `SetNewValue` returns
"Tag '...' is not defined" (Writer.pl:581-584) with exit 1.

A collision that would lose an edit is now refused with a message naming both
keys and the id; a collision that loses nothing -- the same value already
planned under another spelling, which is what the documented `-EXIF:Tag=value`
syntax produces -- is still skipped silently.

Two further defects fall out of the same guard:

* `-EXIF:ExposureTime=1/250` wrote 0x829A into *both* IFD0 and ExifIFD. An
  "EXIF:" key names the tag family, not a physical IFD, so the collision search
  had to span IFD0/ExifIFD/GPS rather than only the IFD0 fallback.

* `-ExifIFD:ExposureCompensation=-0.5` serialized ASCII "-0.5" into a
  rational64s tag, because the registry declared 0x9204 `String`. Corrected,
  along with 46 other EXIF tags whose declared type disagreed with ExifTool's
  `Writable`, by adding the missing `type:` to the YAML tag database. The set
  was derived mechanically from ExifTool 13.59's own Perl tables and restricted
  to scalar numeric/date tags with no ValueConv and no blob flag -- multi-value,
  ValueConv and `undef`-storage tags are deliberately untouched, since for those
  ExifTool's accepted input type comes from PrintConvInv/ValueConvInv rather
  than the storage code (FileSource's Integer, for one, is already correct).

ModifyDate and CreateDate are among the 47: both now normalize
`2024-01-15T10:30:00` to ExifTool's stored `2024:01:15 10:30:00`.

#358's write path is untouched: a binary built from origin/main and one built
from this branch produce byte-identical output on 24 files (9 corpus RAW/TIFF/
PNG/JPS + 12 corpus JPEGs + 3 fixtures) for an ordinary `-IFD0:Artist=` write,
and all 11 files from #358's table still accept writes with no metadata lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(pentax): enter the five MakerNote sub-directories gated on the camera model

`exiftool -v2` descends into 563 of the corpus's files with a Pentax or FujiFilm
MakerNote. Comparing per file against `exiftool -a -G1 -s` (ExifTool 13.59),
five of the directories it enters produced no oxidex row at all -- not a wrong
value, not a missing-tag report, nothing, because a directory that is never
entered has no rows to be missing:

  tag     ExifTool table                files  tags
  0x0216  %Pentax::BatteryInfo             30   129
  0x021f  %Pentax::AFInfo                  28   111
  0x022a  %Pentax::FilterInfo              18    36
  0x03ff  %Pentax::TempInfo                 8    22
  0x0226  %Pentax::ShotInfo                 6     6

These are plain `ProcessBinaryData` records under a MakerNote oxidex already
reads, so the transcription is the generator's job. What kept them out is that
almost every field in them carries an ExifTool `Condition` on the camera model,
and `%Pentax::BatteryInfo` is an arrayref of such alternatives at nearly every
offset -- the construct `codegen_subdirs.py` logged as "arrayref of Condition
variants" and refused. Byte 2 of that record alone is `BodyBatteryADNoLoad` on a
K10D with one calibration `PrintConv`, an uncalibrated `BodyBatteryADNoLoad` on
a *istD, half of an `int16u` `BodyBatteryVoltage1` on a K-5, and
`BodyBatteryState` on a K-3 III (Pentax.pm:4846-4935). Read without the
condition it is one number under four different real tag names.

So the generator grew a vocabulary rather than a special case:

- `Condition` over `$$self{Model}` -- `=~`, `!~`, `eq`, and the `A and B`
  conjunction Pentax.pm:4864 uses to hold the K-3 III out of a pattern that
  would otherwise catch it. The regexes are expanded to literal alternations at
  generation time (32 branches, `\*ist` and `GX-1[LS]?` and
  `K-(1|01|3|5|7|30|50|70|500|r|x|S[12])` included) by a parser that raises on
  any construct it has not been taught, so the model test stays derived from
  ExifTool's own pattern text instead of a hand-typed list.
- alternatives as several `Field`s sharing one ExifTool key, in ExifTool's
  order; the decoder takes the first whose condition holds and reports nothing
  when none does. A key any of whose alternatives is refused is dropped whole --
  removing an earlier one would let a later, broader one answer for a body it
  was never meant to describe.
- `PrintConv` as a Perl expression, and `ValueConv` composed with it, which is
  what makes a raw 686 of centivolts print "6.86 V" rather than "686.00 V".
- `Field` now carries ExifTool's own key text, because `545`, `545.1` and
  `545.2` are three masked tags at one offset while two entries keyed `2` are
  two readings of one tag.

0x021f and 0x0216 declare `ByteOrder => 'BigEndian'` on the SubDirectory and are
read that way whatever the MakerNote's own order is (Pentax.pm:2983-2986,
:2949). 0x022a is one table read either way round, chosen by
`$$self{Make} =~ /^RICOH/` (Pentax.pm:3032) -- the brand, not the model -- so
`PentaxParser` now carries that test from the dispatcher rather than guessing it
from the model.

Every constant is quoted from Pentax.pm in the code; the unit tests replay the
exact record bytes `exiftool -v3` prints for PentaxK10D.jpg and PentaxK-5IIs.jpg
against the exact values `exiftool -a -G1 -s` reports for those files.
Regenerating reproduces the committed tables byte for byte, and the FujiFilm and
Panasonic tables are field-identical to before apart from the two new columns.

Measured per file over /tmp/oxidex-exiftool-cache/combined-samples against
ExifTool 13.59, keyed `Group1:Name`, both binaries built from this tree:

  563 Pentax/FujiFilm files
  matched  45,133 -> 45,437   (+304 across 31 files)
  missing  12,375 -> 12,065
  regressions 0

  PentaxK-5IIs.jpg   197 -> 214
  PentaxK-5.jpg      187 -> 204
  PentaxK-r.jpg      184 -> 197
  PentaxK10D.jpg     148 -> 159

Six values on Pentax_istD.jpg are wrong, and are named here rather than
buried: that file's MakerNote offsets need ExifTool's `FixBase` correction
(ExifTool resolves its 0x0216 pointer 16 bytes below the TIFF header), which
oxidex does not implement -- so all of its out-of-line values are misread today,
75 of them before this change. The six new ones are the same pre-existing fault
reaching six more names, not a new one.

Still not entered, with the generator's own reason: `%Pentax::CameraSettings`
(a hand-written arm already owns 0x0205 and covers tags the generator refuses),
`FilterInfo`'s 20 `DigitalFilterNN` fields (a `RawConv` unpack idiom),
`%Pentax::AEInfo`/`AEInfo2`/`AEInfo3` (`PentaxEv` conversions),
`%Pentax::CAFPointInfo` (`DecodeAFPoints`), and every `BITMASK` `PrintConv`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(apple): find the MakerNote IFD, then read its binary plists

`MakerNotes.pm:37-46` starts the Apple directory at `$valuePtr + 14` -- ten
bytes of `Apple iOS\0`, two version bytes, then the two-byte order marker.
oxidex started it at byte 10, so on every iPhone it read the order marker
"MM" as an entry count of 1 and decoded one entry out of the count field and
the first tag id, arriving at tag 0x4d4d, which no table has. The result was
not a wrong value; it was silence. **Zero `Apple:` tags over the whole sample
corpus**, against the 831 ExifTool reports.

That is why the binary plists could not be reached. Four `%Apple::Main` tags
hold a whole `bplist00` blob rather than a value:

  0x0003 RunTime                    SubDirectory over %Apple::RunTime, whose
                                    PROCESS_PROC is PLIST::ProcessBinaryPLIST
                                    (Apple.pm:40-43, :324-325)
  0x0040 SemanticStyle              ValueConv => \&ConvertPLIST (Apple.pm:276)
  0x0041 SemanticStyleRenderingVer  ditto (Apple.pm:280)
  0x0042 SemanticStylePreset        ditto (Apple.pm:284)

This adds:

- `shared/binary_plist.rs`, a transcription of `Image::ExifTool::PLIST` --
  `ProcessBinaryPLIST`'s trailer and offset table (PLIST.pm:398-450) and
  `ExtractObject`'s object grammar (PLIST.pm:260-390), plus
  `XMP::SerializeStruct` (XMPStruct.pl:34-69) for the `ConvertPLIST` case.
  Three details decide whether the output matches:

    * integers are read **unsigned**: `%readProc` is Get8u/16u/24u/32u/64u
      (PLIST.pm:30-38), so `RunTimeValue` is 235706184764708 and not a
      negative number.
    * a size that `%readProc` has no entry for returns undef rather than a
      guess -- so only 1/2/3/4/8-byte integers and 4/8-byte reals decode.
    * `SerializeStruct` walks `OrderedKeys`, which for a hash built by
      `ExtractObject` is a plain `sort keys`. `Apple_iPhone13Pro.jpg` stores
      its `SemanticStyle` keys in the order 3,1,2,0 and ExifTool prints
      `{_0=1,_1=0.5,_2=0,_3=2}`. Storage order would print the reverse.

  A plist date is decoded and then dropped: ExifTool converts it with
  `ConvertUnixTime($val + 11323*24*3600, 1)`, and that second argument is
  `$toLocal`, so the string carries the extracting machine's time zone. No
  Apple blob in the corpus has one, and a value that depends on the reader's
  clock is not one to approximate.

- `%Apple::Main` itself, transcribed into `apple.rs` -- each entry's
  `Writable` format and its `PrintConv` verbatim, walked by the existing
  `shared::table_ifd`. The table's twelve `Unknown => 1` entries are absent
  on purpose; ExifTool reports those only under `-u`.

- `Composite:RunTimeSincePowerUp` (Apple.pm:348-357), which was declared in
  `composite/tables.rs` with no arm in `compute`. Its `ConvertDuration`
  PrintConv already existed, privately, inside the MTS parser; it moves to
  `core::formatters::duration` rather than being copied.

Three defects found on the way, each measured:

- `table_ifd::print_rational` printed `%.15g`. `GetRational64s`/`u`
  (ExifTool.pm:6107-6120) end in `RoundFloat($num/$den, 10)`, so ExifTool
  prints `AccelerationVector` as `-0.9245480894`, not `-0.924548089390588`.
  Fixing it also gained `Olympus:FocalPlaneDiagonal` (+5) and
  `Olympus:DigitalZoom` (+3).
- `table_ifd` had no `int64u`/`int64s` (ExifTool format codes 16 and 17).
  `Apple_iPhone15Pro.jpg` stores `LivePhotoVideoIndex` as `int64u[1]` =
  4294967700; an int64u above `i64::MAX` is dropped rather than printed
  negative.
- `parse_exif_subifd` kept only the *last* 0x927C entry. `Apple_iPhone6.jpg`
  declares two -- the camera's, and a 142-byte UTF-16 JSON blob an editor
  appended -- so the Apple parser was handed the wrong 142 bytes and reported
  nothing. Each entry is now processed in turn, as ExifTool does.

Deleted `registries/apple.rs`. It declared a `FrontFacingCamera` at 0x0032
and a `PortraitMode` at 0x0020 -- `%Apple::Main` has no tag at 0x0032 at all,
and 0x0020 is `ImageCaptureRequestID` -- plus enum decoders for `OISMode`,
`SemanticStyle`, `SignalToNoiseRatioType` and `GreenGhostMitigationStatus`,
none of which has a `PrintConv` in Apple.pm. `tests/integration.rs` never
declared `apple_makernotes_tests`, so the file asserting those names never
compiled; it is now declared, and rewritten against bytes dumped from real
corpus files with `exiftool -v3`.

Verified over /tmp/oxidex-exiftool-cache/combined-samples (4,238 files;
ExifTool 13.59 reports on 4,228) against `exiftool -a -G1 -s`, keyed
`Group1:Name`, case-sensitive, scored **per file**. Base is this branch's
merge-base, 5a8b835:

  matched  393,562 -> 394,451   (+889 across 55 files)
  regressions 0 -- matched-set diff per file, not totals
  files worsened 0; extra keys unchanged at 108,146

    Apple: keys emitted        0 -> 831, every one byte-identical to ExifTool
    of the +889:  267 plist-derived, 622 unlocked by the IFD start, 8 Olympus

    Apple_iPhone13Pro.jpg   126 -> 155
    Apple_iPhone13ProMax.jpg 99 -> 127
    Apple_iPhone15Pro.jpg    89 -> 116
    Apple_iPhone12Pro.jpg   125 -> 152

Nothing is left over: every `Apple:` tag ExifTool reports for any corpus file,
and `Composite:RunTimeSincePowerUp`, now matches on every file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(vrd): read the CanonVRD Ver2 picture-style block

A .VRD recipe written by DPP 2.0 or later carries a second edit section
after the fixed 0x272-byte VRD1 record. oxidex read VRD1 and stopped, so
combined-samples/CanonVRD.vrd reported 43 of the 108 tags ExifTool does;
the missing 65 are the whole of %CanonVRD::Ver2's picture-style block --
PictureStyle, IsCustomPictureStyle, and a nine-tag group per style.

Reaching them needs two things. ProcessEditData sizes the three
%CanonVRD::Edit sections three different ways (CanonVRD.pm:1596-1610),
and the middle one, VRDStampTool, takes its length from an int32u at its
own start; skipping it wholesale lands VRD2 four bytes early. And the
record itself is FORMAT => 'int16s', so a tag ID is an index rather than
a byte offset and the values are signed -- StandardRawColorTone reads -4
on this file, which unsigned would print as 65532.

No table is transcribed for this. src/exiftool_tables already carries
CanonVRD::Ver2 dumped from ExifTool's own in-memory hash, layout and
PrintConv enums included, so the decoder reads that.

Ver2 is only read as far as index 0x54. Past it ExifTool leans on
ValueConv -- $val/0x400 rendered as a percentage, $val/10, $val/100 --
plus a DataMember-gated SubDirectory at 0xe0 and VRDVersion-conditional
branches at 0x5e-0x60. The generator drops a conversion it cannot
reproduce exactly, which leaves the raw value behind a real tag name, so
emitting those would print a confident wrong number rather than nothing.
They stay unread; a test asserts every entry below the bound is a bare
int16s or an integer enum, so a future ExifTool release cannot quietly
move one across it.

Measured on combined-samples/CanonVRD.vrd: 43 matched -> 108 matched,
65 missing -> 0, value-diffs 0, extras 0. The 43 tags already matched
are matched by the same values (set comparison, not counts), and
ExifTool.jpg and CanonRaw.crw are byte-identical before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(lfp): read Lytro light-field metadata

oxidex named the LFP file type but had no FileFormat variant, so
format_dispatch fell through to the unsupported arm and the file yielded
no tags at all. This adds the variant, the \x89LFP magic that routes to a
parser, and a reader transcribed from Image::ExifTool::Lytro (Lytro.pm 1.04).

The container walk follows ProcessLFP (Lytro.pm:134-174): 16-byte segment
headers, a big-endian length, an 80-byte sha1 ExifTool discards, then a
body that is either JSON metadata or an embedded JPEG, padded to 16 bytes.
Tag names come from ExtractTags (Lytro.pm:104-128), which flattens the JSON
with ucfirst on each key, drops punctuation while upcasing what follows,
and strips a leading Devices.

Numbers keep their source token text. These files carry more precision than
an f64 roundtrip preserves -- "gamma" : 0.41666001081466674805 is the
literal bytes on disk -- and ExifTool reports such values unchanged because
it never reformats what it did not compute. Only the tags carrying a
ValueConv or PrintConv are parsed to f64.

Measured on the ExifTool corpus sample (per file, keyed Group1:Name,
File/System excluded):

  Lytro.lfp   matched 0 -> 95   missing 95 -> 0   extra 0 -> 0

That is 85 Lytro tags plus the 10 Composite tags, which the existing
composite engine derives once the base tags exist. Zero regressions: the
matched key sets for CanonVRD.vrd, HTML.html and LNK.lnk are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): print ExposureTime through ExifTool's PrintExposureTime

`ExifIFD:ExposureTime` was rendered by a hand-written formatter in
`tag_conversion.rs` that split at one second. ExifTool splits at a
quarter second (Exif.pm:5606, reached from the tag's
`PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at
Exif.pm:1824):

    sub PrintExposureTime($)
    {
        my $secs = shift;
        return $secs unless Image::ExifTool::IsFloat($secs);
        if ($secs < 0.25001 and $secs > 0) {
            return sprintf("1/%d",int(0.5 + 1/$secs));
        }
        $_ = sprintf("%.1f",$secs);
        s/\.0$//;
        return $_;
    }

Three divergences, established by executing that subroutine from the
installed ExifTool 13.55 rather than by reading it:

  seconds        ExifTool   was       now
  0.5555555556   0.6        1/2       0.6
  0.769230769    0.8        1/1       0.8
  0.8            0.8        1/1       0.8
  4.0            4          4.0       4
  30.0           30         30.0      30
  0              0          1/18446744073709551615   0

The first class is the damaging one: every exposure in [0.25001, 1)
printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s
exposure is a perfectly plausible shutter speed, so nothing downstream
could tell it was wrong. The second is the trailing `.0` that `s/\.0$//`
strips. The third divided by a zero `$secs`.

`core::formatters::print_exposure_time` is already a verified port of
the subroutine -- it reproduces all fourteen probe values exactly -- so
this deletes the private copy and calls it. #340 consolidated sixteen
copies of PrintExposureTime but did not reach this one, which is the
copy that renders the EXIF ExposureTime tag itself.

Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus,
keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80"
cannot silently become 1.8:

    differing (file,tag) pairs   40,917 -> 40,814   (-103)
      fixed                                    109
        ExifIFD:ExposureTime                    78
        Composite:ShutterSpeed                  20
        Composite:LightValue                    10
        IFD0:ExposureTime                        1
      newly differing                            6

The six are all `Composite:LightValue`, and they are collateral from a
separate, pre-existing defect: the composite layer parses the *printed*
ExposureTime string instead of the ValueConv seconds, so it now rounds
from the correct `0.6` where it used to round from `1/2`. Ten other
LightValue files move the right way for the same reason (net -4). The
same defect is visible in `Composite:ISO`, which consumes the
PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712
(Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a
composite-layer fix and is left for its own change.

Two ExposureTime classes remain out of scope here, both in the rational
pipeline rather than the PrintConv: ExifTool rounds rational64u to ten
significant figures before the conversion (ExifTool.pm:6114), which is
why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a
zero denominator should print `undef`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(leica): route the "LEICA CAMERA AG" MakerNote to Panasonic::Main

The D-Lux 7, D-Lux 8 and V-Lux 5 are Panasonic-built and sign their
MakerNote "LEICA CAMERA AG\0". ExifTool dispatches that signature with
`MakerNoteLeica10` (MakerNotes.pm:724-731), which points at
`Image::ExifTool::Panasonic::Main` -- not at any Leica table:

    Name      => 'MakerNoteLeica10', # used by the D-Lux7
    Condition => '$$valPt =~ /^LEICA CAMERA AG\0/',
    TagTable  => 'Image::ExifTool::Panasonic::Main',
    Start     => '$valuePtr + 18',

oxidex instead handed the payload to the Leica parser, which claimed the
header as `LeicaLayout::LongHeader`, skipped 15 bytes of it and decoded
nothing -- its own comment recorded that "no corresponding ExifTool table
has been identified". The result was silent: all three bodies reported
zero MakerNote tags where ExifTool reports ~100, with no warning.

The signature is 16 bytes and the IFD begins at 18, so two pad bytes sit
between them. LeicaD-Lux7.jpg opens

    4c 45 49 43 41 20 43 41 4d 45 52 41 20 41 47 00  00 00  9d 00

-- "LEICA CAMERA AG\0", two NULs, then the 157-entry count.
`MakerNoteLeica10` declares no `Base`, so its out-of-line value offsets
are TIFF-header-relative exactly as `MakerNotePanasonic`'s are and need
no adjustment.

`LongHeader` is removed rather than left dead: recognising a signature
this parser has no table for shadowed the parser that does.

Verified against ExifTool 13.59 on the 4,238-file corpus, scored per file
and keyed Group1:Name:

  Leica/LeicaD-Lux7.jpg   0 -> 80 MakerNote tags  (+66 matching ExifTool)
  Leica/LeicaD-Lux8.jpg   0 -> 80                 (+65)
  Leica/LeicaV-Lux5.jpg   0 -> 80                 (+66)

  +197 newly-matching keys, 0 regressions across all 4,238 files
  (matched-key sets diffed per file, before vs after).

  exiftool -G1 -s Leica/LeicaD-Lux7.jpg
      [Panasonic] ImageQuality         : RAW
      [Panasonic] InternalSerialNumber : (XFL) 2018:09:06 no. 0007
      [Panasonic] WhiteBalance         : Auto
  oxidex now prints the same three values.

Note this PR fixes dispatch only. The ~13 remaining value differences per
file (AFPointPosition, RollAngle, PhotoStyle, ...) are pre-existing
`Panasonic::Main` conversion gaps, not new: measured on origin/main they
already differ on up to 212 of the 303 Panasonic JPEGs that oxidex
already parsed. Tracked separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(leica): assert LEICA CAMERA AG is declined, matching Leica10 routing

The inline unit test in leica.rs was updated to the new behaviour but this
integration copy still asserted the old one, so Build & Test failed on the
very change it was meant to cover.

ExifTool's MakerNoteLeica10 (MakerNotes.pm:724-731) matches the signature
alone -- Condition => '$$valPt =~ /^LEICA CAMERA AG\0/' -- and routes to
Panasonic::Main at Start => '$valuePtr + 18', never to a Leica table. So
is_leica_makernote must decline it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
swackhamer added a commit that referenced this pull request Aug 2, 2026
* fix(composite): preserve raw APEX conversion precision

* fix(fleet): require current-head PR approval

* fix(tables): decode repeated scalar fields

* fix(exif): one Compression table, dumped from ExifTool's own Perl

Three transcriptions of `%Image::ExifTool::Exif::compression` (Exif.pm:213)
lived in this tree, carrying 50, 40 and 53 ids and disagreeing on the spelling
of codes all three claimed to know:

  core/formatters/exif_enums.rs   32766 => "Next"
  parsers/tiff/tiff_enums.rs      32766 => "Next"
  parsers/pdf/mod.rs              32766 => "NeXt or Sony ARW Compressed 2"

ExifTool 13.59 says:

    32766 => 'NeXt or Sony ARW Compressed 2', #3/Milos

Rather than read the Perl and re-implement it -- the way three divergent copies
got written in the first place -- the hash was dumped straight out of the Perl
symbol table:

    perl -Ilib -MImage::ExifTool::Exif -e '
      my $h = \%Image::ExifTool::Exif::compression;
      for my $v (0..70000) {
        print "$v\t", (exists $h->{$v} ? $h->{$v} : "Unknown ($v)"), "\n" }'

and each copy scored over all 70,001 inputs:

    exif_enums::format_compression   50 ids   10 wrong
    tiff_enums 0x0103 arm            40 ids   15 wrong
    pdf COMPRESSION_LABELS           53 ids    1 wrong
    consolidated table               53 ids    0 wrong

The wrong answers, all now fixed: `9` read `JBIG B&W` (13.59 renamed it
`JBIG B&W or VC-5`); `32766` read `Next`; `33003`, `33004` and `33005` all
collapsed to `Aperio JPEG 2000 YCbCr`, where 33005 is `Aperio JPEG 2000 RGB`
and 33004 is not an ExifTool key at all; `34926`/`34927` dropped the ` (old)`
suffix that separates them from the LibTiff 4.7 codes; and `50000` `Zstd`,
`50001` `WebP`, `50002` `JPEG XL (old)`, `52546` `JPEG XL`, plus `32772`,
`33003`, `34887`, `34925`, `34933`, `34934` on the TIFF path, were absent.

`compression_label` returns `Option<&'static str>` so each caller keeps its
established unknown-value behaviour: `format_compression` renders `Unknown (N)`
the way `PrintConv` does, while the TIFF and PDF paths still return `None`
rather than invent a label ExifTool never prints.

Why the old tests stayed green: `test_compression` asserted 1, 6 and 7 -- three
codes all three tables agreed on -- and `tiff_enums.rs` had no test module at
all. Not one input reached a wrong branch. The new tests assert every one of the
15 codes that was measurably wrong; reverting the table makes 5 of them fail.

Corpus check (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):
393,343 correct before and after, matched-set delta fixed=0 regressed=0. The
corpus stores only Compression 1/4/5/6/7/34713 plus two codes ExifTool does not
name, so it exercises none of the divergent ids -- the divergence is measured
against the Perl, and the corpus proves the consolidation costs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): Flash is a lookup table, not a bitfield (#393)

Exif.pm 0x9209 is `PrintConv => \%flash` -- a flat 27-key hash -- plus
`Flags => 'PrintHex'`. `core::exif_enums::decode_flash` instead synthesised the
label from the byte's bit fields (fired / strobe return / mode / function
present / red-eye). Those bits explain how ExifTool's 27 codes were *chosen*;
they are not how the tag is rendered, and 229 of the 256 byte values are not
valid Flash codes at all.

Scored against `%Image::ExifTool::Exif::flash` dumped out of the Perl symbol
table, over every input in 0..=255:

    core::exif_enums::decode_flash    236 wrong / 256
    consolidated table                  0 wrong / 256

Two verbatim copies of the correct table already existed -- `FLASH_LABELS` in
`parsers::pdf` and the `0x9209` arm of `parsers::raw::metadata` -- so the tree
held three Flash decoders, and only the one on the main EXIF path was wrong.
All three now share `core::formatters::exif_enums::flash_label`.

Three classes of wrong answer, all corpus-visible:

  - codes ExifTool leaves unknown got a plausible label. 0x38 and 0x28 both
    came back `No flash function`; ExifTool prints `Unknown (0x38)` and
    `Unknown (0x28)` -- 60 files.
  - codes it did know were mis-worded: 0x49 gave `On, Fired, Red-eye reduction`
    where the hash says `On, Red-eye reduction` -- 23 files. Same for 0x0d,
    0x0f, 0x4d, 0x4f, 0x50.
  - `format_flash`'s unknown form was decimal; `PrintHex` makes it hex.

Why the old test stayed green: `test_flash_decoding` had 13 assertions, each
annotated with the bit layout it was deriving -- and one of them,
`decode_flash(0x49) == "On, Fired, Red-eye reduction"`, asserted a string
ExifTool never prints. The test encoded the same wrong model as the code. It is
rewritten to check the hash, and three new tests cover the mis-worded codes, the
hex unknown form, and the 27-of-256 key count; reverting the table fails four of
them.

Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):

    BEFORE correct=393343  valdiff=17058  score=77.5097%
    AFTER  correct=393467  valdiff=16934  score=77.5341%
    MATCHED-SET DELTA  fixed=124  regressed=0
    PER-FILE  124 files +1, 0 down     FORMAT  JPEG 390485 -> 390609 (+124)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): FocalPlaneResolutionUnit decodes instead of printing its code (#395)

Exif.pm 0xa210 declares a PrintConv:

    0xa210 => {
        Name => 'FocalPlaneResolutionUnit',
        Notes => 'values 1, 4 and 5 are not standard EXIF',
        PrintConv => { 1 => 'None', 2 => 'inches', 3 => 'cm',
                       4 => 'mm', 5 => 'um' },
    },

The tree already held that table -- in `parsers::pdf`, reachable only from a
PDF's embedded TIFF thumbnail. The main EXIF path had no arm for the tag at all,
so `format_tag_value` returned the raw integer and 1,098 files in the sample
corpus reported `2` and `3` where ExifTool reports `inches` and `cm`.

The table moves to `core::formatters::exif_enums` next to the Compression and
Flash tables, PDF reads it from there, and the `exiftool_compat` dispatch gains
the arm it was missing.

`Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an
exclusion: ExifTool decodes all five, and the corpus holds 4 files at `4` and 1
at `5`. 0xa210 carries no `PrintHex`, so unnamed codes stay decimal --
`Unknown (0)`, which 4 corpus files report.

Safe for the composites: `Exif ScaleFactor35efl` -- which gates
FocalLength35efl, CircleOfConfusion, HyperfocalDistance, FOV and DOF -- already
matches either spelling (`Some("3") | Some("cm")`). Measured: zero `Composite:*`
values changed anywhere in the corpus.

A unit test on the new function would not have caught this: the table already
existed and was already correct. The regression test asserts the *dispatch*,
through `format_tag_value`; deleting the new arm fails it with
`left: Integer(1), right: String("None")`.

Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):

    BEFORE correct=393467  valdiff=16934  score=77.5341%
    AFTER  correct=394565  valdiff=15836  score=77.7505%
    MATCHED-SET DELTA  fixed=1098  regressed=0
    PER-FILE  1098 files +1, 0 down
    FORMAT    JPEG +1094, DNG +1, CR2 +1, RAF +1, BPG +1

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(write): report the dropped write instead of printing success

`plan_exif_write` may emit only one IFD record per tag id, so when two
metadata-map keys resolve to the same id the second cannot be written. The
guard that enforced this used `continue`, discarding the caller's edit while
the CLI still printed "1 image files updated" and exited 0:

    $ oxidex -ExifIFD:ExposureBiasValue=-0.5 photo.jpg   # 0x9204
        1 image files updated                            # exit 0
    $ exiftool -a -G1 -s -ExposureCompensation photo.jpg
    [ExifIFD]  ExposureCompensation : -3/2                # unchanged

Before #368 this failed loudly with a type error; #368 fixed the typing and the
failure went quiet.

ExifTool refuses the same command. `ExposureBiasValue` is not a writable tag
name in any of its tables -- only a Notes remark on 0x9204 ("called
ExposureBiasValue by the EXIF spec", Exif.pm:2369) and an XMP property whose
tag name is again ExposureCompensation (XMP.pm:2096-2097); `TagLookup.pm` has
no `exposurebiasvalue` key, so `TagExists` fails and `SetNewValue` returns
"Tag '...' is not defined" (Writer.pl:581-584) with exit 1.

A collision that would lose an edit is now refused with a message naming both
keys and the id; a collision that loses nothing -- the same value already
planned under another spelling, which is what the documented `-EXIF:Tag=value`
syntax produces -- is still skipped silently.

Two further defects fall out of the same guard:

* `-EXIF:ExposureTime=1/250` wrote 0x829A into *both* IFD0 and ExifIFD. An
  "EXIF:" key names the tag family, not a physical IFD, so the collision search
  had to span IFD0/ExifIFD/GPS rather than only the IFD0 fallback.

* `-ExifIFD:ExposureCompensation=-0.5` serialized ASCII "-0.5" into a
  rational64s tag, because the registry declared 0x9204 `String`. Corrected,
  along with 46 other EXIF tags whose declared type disagreed with ExifTool's
  `Writable`, by adding the missing `type:` to the YAML tag database. The set
  was derived mechanically from ExifTool 13.59's own Perl tables and restricted
  to scalar numeric/date tags with no ValueConv and no blob flag -- multi-value,
  ValueConv and `undef`-storage tags are deliberately untouched, since for those
  ExifTool's accepted input type comes from PrintConvInv/ValueConvInv rather
  than the storage code (FileSource's Integer, for one, is already correct).

ModifyDate and CreateDate are among the 47: both now normalize
`2024-01-15T10:30:00` to ExifTool's stored `2024:01:15 10:30:00`.

#358's write path is untouched: a binary built from origin/main and one built
from this branch produce byte-identical output on 24 files (9 corpus RAW/TIFF/
PNG/JPS + 12 corpus JPEGs + 3 fixtures) for an ordinary `-IFD0:Artist=` write,
and all 11 files from #358's table still accept writes with no metadata lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(pentax): enter the five MakerNote sub-directories gated on the camera model

`exiftool -v2` descends into 563 of the corpus's files with a Pentax or FujiFilm
MakerNote. Comparing per file against `exiftool -a -G1 -s` (ExifTool 13.59),
five of the directories it enters produced no oxidex row at all -- not a wrong
value, not a missing-tag report, nothing, because a directory that is never
entered has no rows to be missing:

  tag     ExifTool table                files  tags
  0x0216  %Pentax::BatteryInfo             30   129
  0x021f  %Pentax::AFInfo                  28   111
  0x022a  %Pentax::FilterInfo              18    36
  0x03ff  %Pentax::TempInfo                 8    22
  0x0226  %Pentax::ShotInfo                 6     6

These are plain `ProcessBinaryData` records under a MakerNote oxidex already
reads, so the transcription is the generator's job. What kept them out is that
almost every field in them carries an ExifTool `Condition` on the camera model,
and `%Pentax::BatteryInfo` is an arrayref of such alternatives at nearly every
offset -- the construct `codegen_subdirs.py` logged as "arrayref of Condition
variants" and refused. Byte 2 of that record alone is `BodyBatteryADNoLoad` on a
K10D with one calibration `PrintConv`, an uncalibrated `BodyBatteryADNoLoad` on
a *istD, half of an `int16u` `BodyBatteryVoltage1` on a K-5, and
`BodyBatteryState` on a K-3 III (Pentax.pm:4846-4935). Read without the
condition it is one number under four different real tag names.

So the generator grew a vocabulary rather than a special case:

- `Condition` over `$$self{Model}` -- `=~`, `!~`, `eq`, and the `A and B`
  conjunction Pentax.pm:4864 uses to hold the K-3 III out of a pattern that
  would otherwise catch it. The regexes are expanded to literal alternations at
  generation time (32 branches, `\*ist` and `GX-1[LS]?` and
  `K-(1|01|3|5|7|30|50|70|500|r|x|S[12])` included) by a parser that raises on
  any construct it has not been taught, so the model test stays derived from
  ExifTool's own pattern text instead of a hand-typed list.
- alternatives as several `Field`s sharing one ExifTool key, in ExifTool's
  order; the decoder takes the first whose condition holds and reports nothing
  when none does. A key any of whose alternatives is refused is dropped whole --
  removing an earlier one would let a later, broader one answer for a body it
  was never meant to describe.
- `PrintConv` as a Perl expression, and `ValueConv` composed with it, which is
  what makes a raw 686 of centivolts print "6.86 V" rather than "686.00 V".
- `Field` now carries ExifTool's own key text, because `545`, `545.1` and
  `545.2` are three masked tags at one offset while two entries keyed `2` are
  two readings of one tag.

0x021f and 0x0216 declare `ByteOrder => 'BigEndian'` on the SubDirectory and are
read that way whatever the MakerNote's own order is (Pentax.pm:2983-2986,
:2949). 0x022a is one table read either way round, chosen by
`$$self{Make} =~ /^RICOH/` (Pentax.pm:3032) -- the brand, not the model -- so
`PentaxParser` now carries that test from the dispatcher rather than guessing it
from the model.

Every constant is quoted from Pentax.pm in the code; the unit tests replay the
exact record bytes `exiftool -v3` prints for PentaxK10D.jpg and PentaxK-5IIs.jpg
against the exact values `exiftool -a -G1 -s` reports for those files.
Regenerating reproduces the committed tables byte for byte, and the FujiFilm and
Panasonic tables are field-identical to before apart from the two new columns.

Measured per file over /tmp/oxidex-exiftool-cache/combined-samples against
ExifTool 13.59, keyed `Group1:Name`, both binaries built from this tree:

  563 Pentax/FujiFilm files
  matched  45,133 -> 45,437   (+304 across 31 files)
  missing  12,375 -> 12,065
  regressions 0

  PentaxK-5IIs.jpg   197 -> 214
  PentaxK-5.jpg      187 -> 204
  PentaxK-r.jpg      184 -> 197
  PentaxK10D.jpg     148 -> 159

Six values on Pentax_istD.jpg are wrong, and are named here rather than
buried: that file's MakerNote offsets need ExifTool's `FixBase` correction
(ExifTool resolves its 0x0216 pointer 16 bytes below the TIFF header), which
oxidex does not implement -- so all of its out-of-line values are misread today,
75 of them before this change. The six new ones are the same pre-existing fault
reaching six more names, not a new one.

Still not entered, with the generator's own reason: `%Pentax::CameraSettings`
(a hand-written arm already owns 0x0205 and covers tags the generator refuses),
`FilterInfo`'s 20 `DigitalFilterNN` fields (a `RawConv` unpack idiom),
`%Pentax::AEInfo`/`AEInfo2`/`AEInfo3` (`PentaxEv` conversions),
`%Pentax::CAFPointInfo` (`DecodeAFPoints`), and every `BITMASK` `PrintConv`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(apple): find the MakerNote IFD, then read its binary plists

`MakerNotes.pm:37-46` starts the Apple directory at `$valuePtr + 14` -- ten
bytes of `Apple iOS\0`, two version bytes, then the two-byte order marker.
oxidex started it at byte 10, so on every iPhone it read the order marker
"MM" as an entry count of 1 and decoded one entry out of the count field and
the first tag id, arriving at tag 0x4d4d, which no table has. The result was
not a wrong value; it was silence. **Zero `Apple:` tags over the whole sample
corpus**, against the 831 ExifTool reports.

That is why the binary plists could not be reached. Four `%Apple::Main` tags
hold a whole `bplist00` blob rather than a value:

  0x0003 RunTime                    SubDirectory over %Apple::RunTime, whose
                                    PROCESS_PROC is PLIST::ProcessBinaryPLIST
                                    (Apple.pm:40-43, :324-325)
  0x0040 SemanticStyle              ValueConv => \&ConvertPLIST (Apple.pm:276)
  0x0041 SemanticStyleRenderingVer  ditto (Apple.pm:280)
  0x0042 SemanticStylePreset        ditto (Apple.pm:284)

This adds:

- `shared/binary_plist.rs`, a transcription of `Image::ExifTool::PLIST` --
  `ProcessBinaryPLIST`'s trailer and offset table (PLIST.pm:398-450) and
  `ExtractObject`'s object grammar (PLIST.pm:260-390), plus
  `XMP::SerializeStruct` (XMPStruct.pl:34-69) for the `ConvertPLIST` case.
  Three details decide whether the output matches:

    * integers are read **unsigned**: `%readProc` is Get8u/16u/24u/32u/64u
      (PLIST.pm:30-38), so `RunTimeValue` is 235706184764708 and not a
      negative number.
    * a size that `%readProc` has no entry for returns undef rather than a
      guess -- so only 1/2/3/4/8-byte integers and 4/8-byte reals decode.
    * `SerializeStruct` walks `OrderedKeys`, which for a hash built by
      `ExtractObject` is a plain `sort keys`. `Apple_iPhone13Pro.jpg` stores
      its `SemanticStyle` keys in the order 3,1,2,0 and ExifTool prints
      `{_0=1,_1=0.5,_2=0,_3=2}`. Storage order would print the reverse.

  A plist date is decoded and then dropped: ExifTool converts it with
  `ConvertUnixTime($val + 11323*24*3600, 1)`, and that second argument is
  `$toLocal`, so the string carries the extracting machine's time zone. No
  Apple blob in the corpus has one, and a value that depends on the reader's
  clock is not one to approximate.

- `%Apple::Main` itself, transcribed into `apple.rs` -- each entry's
  `Writable` format and its `PrintConv` verbatim, walked by the existing
  `shared::table_ifd`. The table's twelve `Unknown => 1` entries are absent
  on purpose; ExifTool reports those only under `-u`.

- `Composite:RunTimeSincePowerUp` (Apple.pm:348-357), which was declared in
  `composite/tables.rs` with no arm in `compute`. Its `ConvertDuration`
  PrintConv already existed, privately, inside the MTS parser; it moves to
  `core::formatters::duration` rather than being copied.

Three defects found on the way, each measured:

- `table_ifd::print_rational` printed `%.15g`. `GetRational64s`/`u`
  (ExifTool.pm:6107-6120) end in `RoundFloat($num/$den, 10)`, so ExifTool
  prints `AccelerationVector` as `-0.9245480894`, not `-0.924548089390588`.
  Fixing it also gained `Olympus:FocalPlaneDiagonal` (+5) and
  `Olympus:DigitalZoom` (+3).
- `table_ifd` had no `int64u`/`int64s` (ExifTool format codes 16 and 17).
  `Apple_iPhone15Pro.jpg` stores `LivePhotoVideoIndex` as `int64u[1]` =
  4294967700; an int64u above `i64::MAX` is dropped rather than printed
  negative.
- `parse_exif_subifd` kept only the *last* 0x927C entry. `Apple_iPhone6.jpg`
  declares two -- the camera's, and a 142-byte UTF-16 JSON blob an editor
  appended -- so the Apple parser was handed the wrong 142 bytes and reported
  nothing. Each entry is now processed in turn, as ExifTool does.

Deleted `registries/apple.rs`. It declared a `FrontFacingCamera` at 0x0032
and a `PortraitMode` at 0x0020 -- `%Apple::Main` has no tag at 0x0032 at all,
and 0x0020 is `ImageCaptureRequestID` -- plus enum decoders for `OISMode`,
`SemanticStyle`, `SignalToNoiseRatioType` and `GreenGhostMitigationStatus`,
none of which has a `PrintConv` in Apple.pm. `tests/integration.rs` never
declared `apple_makernotes_tests`, so the file asserting those names never
compiled; it is now declared, and rewritten against bytes dumped from real
corpus files with `exiftool -v3`.

Verified over /tmp/oxidex-exiftool-cache/combined-samples (4,238 files;
ExifTool 13.59 reports on 4,228) against `exiftool -a -G1 -s`, keyed
`Group1:Name`, case-sensitive, scored **per file**. Base is this branch's
merge-base, 5a8b835:

  matched  393,562 -> 394,451   (+889 across 55 files)
  regressions 0 -- matched-set diff per file, not totals
  files worsened 0; extra keys unchanged at 108,146

    Apple: keys emitted        0 -> 831, every one byte-identical to ExifTool
    of the +889:  267 plist-derived, 622 unlocked by the IFD start, 8 Olympus

    Apple_iPhone13Pro.jpg   126 -> 155
    Apple_iPhone13ProMax.jpg 99 -> 127
    Apple_iPhone15Pro.jpg    89 -> 116
    Apple_iPhone12Pro.jpg   125 -> 152

Nothing is left over: every `Apple:` tag ExifTool reports for any corpus file,
and `Composite:RunTimeSincePowerUp`, now matches on every file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(vrd): read the CanonVRD Ver2 picture-style block

A .VRD recipe written by DPP 2.0 or later carries a second edit section
after the fixed 0x272-byte VRD1 record. oxidex read VRD1 and stopped, so
combined-samples/CanonVRD.vrd reported 43 of the 108 tags ExifTool does;
the missing 65 are the whole of %CanonVRD::Ver2's picture-style block --
PictureStyle, IsCustomPictureStyle, and a nine-tag group per style.

Reaching them needs two things. ProcessEditData sizes the three
%CanonVRD::Edit sections three different ways (CanonVRD.pm:1596-1610),
and the middle one, VRDStampTool, takes its length from an int32u at its
own start; skipping it wholesale lands VRD2 four bytes early. And the
record itself is FORMAT => 'int16s', so a tag ID is an index rather than
a byte offset and the values are signed -- StandardRawColorTone reads -4
on this file, which unsigned would print as 65532.

No table is transcribed for this. src/exiftool_tables already carries
CanonVRD::Ver2 dumped from ExifTool's own in-memory hash, layout and
PrintConv enums included, so the decoder reads that.

Ver2 is only read as far as index 0x54. Past it ExifTool leans on
ValueConv -- $val/0x400 rendered as a percentage, $val/10, $val/100 --
plus a DataMember-gated SubDirectory at 0xe0 and VRDVersion-conditional
branches at 0x5e-0x60. The generator drops a conversion it cannot
reproduce exactly, which leaves the raw value behind a real tag name, so
emitting those would print a confident wrong number rather than nothing.
They stay unread; a test asserts every entry below the bound is a bare
int16s or an integer enum, so a future ExifTool release cannot quietly
move one across it.

Measured on combined-samples/CanonVRD.vrd: 43 matched -> 108 matched,
65 missing -> 0, value-diffs 0, extras 0. The 43 tags already matched
are matched by the same values (set comparison, not counts), and
ExifTool.jpg and CanonRaw.crw are byte-identical before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(lfp): read Lytro light-field metadata

oxidex named the LFP file type but had no FileFormat variant, so
format_dispatch fell through to the unsupported arm and the file yielded
no tags at all. This adds the variant, the \x89LFP magic that routes to a
parser, and a reader transcribed from Image::ExifTool::Lytro (Lytro.pm 1.04).

The container walk follows ProcessLFP (Lytro.pm:134-174): 16-byte segment
headers, a big-endian length, an 80-byte sha1 ExifTool discards, then a
body that is either JSON metadata or an embedded JPEG, padded to 16 bytes.
Tag names come from ExtractTags (Lytro.pm:104-128), which flattens the JSON
with ucfirst on each key, drops punctuation while upcasing what follows,
and strips a leading Devices.

Numbers keep their source token text. These files carry more precision than
an f64 roundtrip preserves -- "gamma" : 0.41666001081466674805 is the
literal bytes on disk -- and ExifTool reports such values unchanged because
it never reformats what it did not compute. Only the tags carrying a
ValueConv or PrintConv are parsed to f64.

Measured on the ExifTool corpus sample (per file, keyed Group1:Name,
File/System excluded):

  Lytro.lfp   matched 0 -> 95   missing 95 -> 0   extra 0 -> 0

That is 85 Lytro tags plus the 10 Composite tags, which the existing
composite engine derives once the base tags exist. Zero regressions: the
matched key sets for CanonVRD.vrd, HTML.html and LNK.lnk are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): print ExposureTime through ExifTool's PrintExposureTime

`ExifIFD:ExposureTime` was rendered by a hand-written formatter in
`tag_conversion.rs` that split at one second. ExifTool splits at a
quarter second (Exif.pm:5606, reached from the tag's
`PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at
Exif.pm:1824):

    sub PrintExposureTime($)
    {
        my $secs = shift;
        return $secs unless Image::ExifTool::IsFloat($secs);
        if ($secs < 0.25001 and $secs > 0) {
            return sprintf("1/%d",int(0.5 + 1/$secs));
        }
        $_ = sprintf("%.1f",$secs);
        s/\.0$//;
        return $_;
    }

Three divergences, established by executing that subroutine from the
installed ExifTool 13.55 rather than by reading it:

  seconds        ExifTool   was       now
  0.5555555556   0.6        1/2       0.6
  0.769230769    0.8        1/1       0.8
  0.8            0.8        1/1       0.8
  4.0            4          4.0       4
  30.0           30         30.0      30
  0              0          1/18446744073709551615   0

The first class is the damaging one: every exposure in [0.25001, 1)
printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s
exposure is a perfectly plausible shutter speed, so nothing downstream
could tell it was wrong. The second is the trailing `.0` that `s/\.0$//`
strips. The third divided by a zero `$secs`.

`core::formatters::print_exposure_time` is already a verified port of
the subroutine -- it reproduces all fourteen probe values exactly -- so
this deletes the private copy and calls it. #340 consolidated sixteen
copies of PrintExposureTime but did not reach this one, which is the
copy that renders the EXIF ExposureTime tag itself.

Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus,
keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80"
cannot silently become 1.8:

    differing (file,tag) pairs   40,917 -> 40,814   (-103)
      fixed                                    109
        ExifIFD:ExposureTime                    78
        Composite:ShutterSpeed                  20
        Composite:LightValue                    10
        IFD0:ExposureTime                        1
      newly differing                            6

The six are all `Composite:LightValue`, and they are collateral from a
separate, pre-existing defect: the composite layer parses the *printed*
ExposureTime string instead of the ValueConv seconds, so it now rounds
from the correct `0.6` where it used to round from `1/2`. Ten other
LightValue files move the right way for the same reason (net -4). The
same defect is visible in `Composite:ISO`, which consumes the
PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712
(Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a
composite-layer fix and is left for its own change.

Two ExposureTime classes remain out of scope here, both in the rational
pipeline rather than the PrintConv: ExifTool rounds rational64u to ten
significant figures before the conversion (ExifTool.pm:6114), which is
why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a
zero denominator should print `undef`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(leica): route the "LEICA CAMERA AG" MakerNote to Panasonic::Main

The D-Lux 7, D-Lux 8 and V-Lux 5 are Panasonic-built and sign their
MakerNote "LEICA CAMERA AG\0". ExifTool dispatches that signature with
`MakerNoteLeica10` (MakerNotes.pm:724-731), which points at
`Image::ExifTool::Panasonic::Main` -- not at any Leica table:

    Name      => 'MakerNoteLeica10', # used by the D-Lux7
    Condition => '$$valPt =~ /^LEICA CAMERA AG\0/',
    TagTable  => 'Image::ExifTool::Panasonic::Main',
    Start     => '$valuePtr + 18',

oxidex instead handed the payload to the Leica parser, which claimed the
header as `LeicaLayout::LongHeader`, skipped 15 bytes of it and decoded
nothing -- its own comment recorded that "no corresponding ExifTool table
has been identified". The result was silent: all three bodies reported
zero MakerNote tags where ExifTool reports ~100, with no warning.

The signature is 16 bytes and the IFD begins at 18, so two pad bytes sit
between them. LeicaD-Lux7.jpg opens

    4c 45 49 43 41 20 43 41 4d 45 52 41 20 41 47 00  00 00  9d 00

-- "LEICA CAMERA AG\0", two NULs, then the 157-entry count.
`MakerNoteLeica10` declares no `Base`, so its out-of-line value offsets
are TIFF-header-relative exactly as `MakerNotePanasonic`'s are and need
no adjustment.

`LongHeader` is removed rather than left dead: recognising a signature
this parser has no table for shadowed the parser that does.

Verified against ExifTool 13.59 on the 4,238-file corpus, scored per file
and keyed Group1:Name:

  Leica/LeicaD-Lux7.jpg   0 -> 80 MakerNote tags  (+66 matching ExifTool)
  Leica/LeicaD-Lux8.jpg   0 -> 80                 (+65)
  Leica/LeicaV-Lux5.jpg   0 -> 80                 (+66)

  +197 newly-matching keys, 0 regressions across all 4,238 files
  (matched-key sets diffed per file, before vs after).

  exiftool -G1 -s Leica/LeicaD-Lux7.jpg
      [Panasonic] ImageQuality         : RAW
      [Panasonic] InternalSerialNumber : (XFL) 2018:09:06 no. 0007
      [Panasonic] WhiteBalance         : Auto
  oxidex now prints the same three values.

Note this PR fixes dispatch only. The ~13 remaining value differences per
file (AFPointPosition, RollAngle, PhotoStyle, ...) are pre-existing
`Panasonic::Main` conversion gaps, not new: measured on origin/main they
already differ on up to 212 of the 303 Panasonic JPEGs that oxidex
already parsed. Tracked separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(leica): assert LEICA CAMERA AG is declined, matching Leica10 routing

The inline unit test in leica.rs was updated to the new behaviour but this
integration copy still asserted the old one, so Build & Test failed on the
very change it was meant to cover.

ExifTool's MakerNoteLeica10 (MakerNotes.pm:724-731) matches the signature
alone -- Condition => '$$valPt =~ /^LEICA CAMERA AG\0/' -- and routes to
Panasonic::Main at Start => '$valuePtr + 18', never to a Leica table. So
is_leica_makernote must decline it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(panasonic): drop six fabricated MakerNote registry entries

`registries/panasonic.rs` declared 93 tags. Six did not trace to
`%Image::ExifTool::Panasonic::Main`, and between them oxidex emitted
1,248 tag instances across the 479-file Panasonic corpus that ExifTool
reports none of:

  0x003E TextStamp2 -> TextStamp   (Panasonic.pm:809)
  0x8008 TextStamp3 -> TextStamp   (Panasonic.pm:1568)
  0x8009 TextStamp4 -> TextStamp   (Panasonic.pm:1574)
  0x8007 FlashFired -> deleted     (Panasonic.pm:1563, commented out)
  0x8010 BabyAge2   -> deleted     (id is `BabyAge`, Panasonic.pm:1580)
  0x8012 Transform2 -> deleted     (id is `Transform`, Panasonic.pm:1587)

ExifTool gives four different ids the same name `TextStamp` and two ids
the name `BabyAge`; inventing `Foo2`/`Foo3`/`Foo4` to disambiguate them
produces names that exist in zero ExifTool source files and can never
match real output. 0x003E/0x8008/0x8009 carry ExifTool's own PrintConv
({1=>'Off', 2=>'On'}) so they are renamed in place. 0x8010 and 0x8012
are omitted rather than renamed: 0x8010 is a `string` with a sentinel
PrintConv but was registered as a bare integer, and 0x8012 is an
`int16s` `Count => 2` whose PrintConv keys are integer *pairs*
('0 0' => 'Off', '-3 2' => 'Slim High'), which a single-value On/Off
decoder cannot express. 0x0033 and 0x0059 already deliver the real
`BabyAge` and `Transform` names. 0x8007 is disabled upstream:
`#0x8007 => { #PH - questionable [disabled because it conflicts with
EXIF in too many samples]`.

Two enum decoders were also invented on real tags, printing confidently
wrong values under real ExifTool names:

  0x0077 BurstSpeed       int16u, "images per second", no PrintConv
                          (Panasonic.pm:1094). Printed "Low" where
                          ExifTool prints "0", in 91 files.
  0x009D InternalNDFilter rational64u, no PrintConv
                          (Panasonic.pm:1247). Printed
                          "Unknown (4620)" where ExifTool prints "0".

Both decoders are removed; `BURST_SPEED` and `INTERNAL_ND_FILTER` in
the parser had no other caller and are deleted.

Measured per file over 479 Panasonic samples, ExifTool 13.59 vs oxidex,
keyed Group1:Name (JSON parsed with parse_float=str):

  matched     44668 -> 44759  (+91, BurstSpeed now agrees)
  value_diff   1848 -> 1757   (-91)
  missing      8057 -> 8057   (unchanged)
  extra       12725 -> 11477  (-1248)

No file lost a matched key.

InternalNDFilter still disagrees (76 files): oxidex prints the entry's
value_offset rather than dereferencing the rational. That is the same
pre-existing gap that makes `AFPointPosition` print "3254" for
ExifTool's "0.47 0.48", and is not addressed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
swackhamer added a commit that referenced this pull request Aug 2, 2026
* fix(composite): preserve raw APEX conversion precision

* fix(fleet): require current-head PR approval

* fix(tables): decode repeated scalar fields

* fix(exif): one Compression table, dumped from ExifTool's own Perl

Three transcriptions of `%Image::ExifTool::Exif::compression` (Exif.pm:213)
lived in this tree, carrying 50, 40 and 53 ids and disagreeing on the spelling
of codes all three claimed to know:

  core/formatters/exif_enums.rs   32766 => "Next"
  parsers/tiff/tiff_enums.rs      32766 => "Next"
  parsers/pdf/mod.rs              32766 => "NeXt or Sony ARW Compressed 2"

ExifTool 13.59 says:

    32766 => 'NeXt or Sony ARW Compressed 2', #3/Milos

Rather than read the Perl and re-implement it -- the way three divergent copies
got written in the first place -- the hash was dumped straight out of the Perl
symbol table:

    perl -Ilib -MImage::ExifTool::Exif -e '
      my $h = \%Image::ExifTool::Exif::compression;
      for my $v (0..70000) {
        print "$v\t", (exists $h->{$v} ? $h->{$v} : "Unknown ($v)"), "\n" }'

and each copy scored over all 70,001 inputs:

    exif_enums::format_compression   50 ids   10 wrong
    tiff_enums 0x0103 arm            40 ids   15 wrong
    pdf COMPRESSION_LABELS           53 ids    1 wrong
    consolidated table               53 ids    0 wrong

The wrong answers, all now fixed: `9` read `JBIG B&W` (13.59 renamed it
`JBIG B&W or VC-5`); `32766` read `Next`; `33003`, `33004` and `33005` all
collapsed to `Aperio JPEG 2000 YCbCr`, where 33005 is `Aperio JPEG 2000 RGB`
and 33004 is not an ExifTool key at all; `34926`/`34927` dropped the ` (old)`
suffix that separates them from the LibTiff 4.7 codes; and `50000` `Zstd`,
`50001` `WebP`, `50002` `JPEG XL (old)`, `52546` `JPEG XL`, plus `32772`,
`33003`, `34887`, `34925`, `34933`, `34934` on the TIFF path, were absent.

`compression_label` returns `Option<&'static str>` so each caller keeps its
established unknown-value behaviour: `format_compression` renders `Unknown (N)`
the way `PrintConv` does, while the TIFF and PDF paths still return `None`
rather than invent a label ExifTool never prints.

Why the old tests stayed green: `test_compression` asserted 1, 6 and 7 -- three
codes all three tables agreed on -- and `tiff_enums.rs` had no test module at
all. Not one input reached a wrong branch. The new tests assert every one of the
15 codes that was measurably wrong; reverting the table makes 5 of them fail.

Corpus check (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):
393,343 correct before and after, matched-set delta fixed=0 regressed=0. The
corpus stores only Compression 1/4/5/6/7/34713 plus two codes ExifTool does not
name, so it exercises none of the divergent ids -- the divergence is measured
against the Perl, and the corpus proves the consolidation costs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): Flash is a lookup table, not a bitfield (#393)

Exif.pm 0x9209 is `PrintConv => \%flash` -- a flat 27-key hash -- plus
`Flags => 'PrintHex'`. `core::exif_enums::decode_flash` instead synthesised the
label from the byte's bit fields (fired / strobe return / mode / function
present / red-eye). Those bits explain how ExifTool's 27 codes were *chosen*;
they are not how the tag is rendered, and 229 of the 256 byte values are not
valid Flash codes at all.

Scored against `%Image::ExifTool::Exif::flash` dumped out of the Perl symbol
table, over every input in 0..=255:

    core::exif_enums::decode_flash    236 wrong / 256
    consolidated table                  0 wrong / 256

Two verbatim copies of the correct table already existed -- `FLASH_LABELS` in
`parsers::pdf` and the `0x9209` arm of `parsers::raw::metadata` -- so the tree
held three Flash decoders, and only the one on the main EXIF path was wrong.
All three now share `core::formatters::exif_enums::flash_label`.

Three classes of wrong answer, all corpus-visible:

  - codes ExifTool leaves unknown got a plausible label. 0x38 and 0x28 both
    came back `No flash function`; ExifTool prints `Unknown (0x38)` and
    `Unknown (0x28)` -- 60 files.
  - codes it did know were mis-worded: 0x49 gave `On, Fired, Red-eye reduction`
    where the hash says `On, Red-eye reduction` -- 23 files. Same for 0x0d,
    0x0f, 0x4d, 0x4f, 0x50.
  - `format_flash`'s unknown form was decimal; `PrintHex` makes it hex.

Why the old test stayed green: `test_flash_decoding` had 13 assertions, each
annotated with the bit layout it was deriving -- and one of them,
`decode_flash(0x49) == "On, Fired, Red-eye reduction"`, asserted a string
ExifTool never prints. The test encoded the same wrong model as the code. It is
rewritten to check the hash, and three new tests cover the mis-worded codes, the
hex unknown form, and the 27-of-256 key count; reverting the table fails four of
them.

Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):

    BEFORE correct=393343  valdiff=17058  score=77.5097%
    AFTER  correct=393467  valdiff=16934  score=77.5341%
    MATCHED-SET DELTA  fixed=124  regressed=0
    PER-FILE  124 files +1, 0 down     FORMAT  JPEG 390485 -> 390609 (+124)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): FocalPlaneResolutionUnit decodes instead of printing its code (#395)

Exif.pm 0xa210 declares a PrintConv:

    0xa210 => {
        Name => 'FocalPlaneResolutionUnit',
        Notes => 'values 1, 4 and 5 are not standard EXIF',
        PrintConv => { 1 => 'None', 2 => 'inches', 3 => 'cm',
                       4 => 'mm', 5 => 'um' },
    },

The tree already held that table -- in `parsers::pdf`, reachable only from a
PDF's embedded TIFF thumbnail. The main EXIF path had no arm for the tag at all,
so `format_tag_value` returned the raw integer and 1,098 files in the sample
corpus reported `2` and `3` where ExifTool reports `inches` and `cm`.

The table moves to `core::formatters::exif_enums` next to the Compression and
Flash tables, PDF reads it from there, and the `exiftool_compat` dispatch gains
the arm it was missing.

`Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an
exclusion: ExifTool decodes all five, and the corpus holds 4 files at `4` and 1
at `5`. 0xa210 carries no `PrintHex`, so unnamed codes stay decimal --
`Unknown (0)`, which 4 corpus files report.

Safe for the composites: `Exif ScaleFactor35efl` -- which gates
FocalLength35efl, CircleOfConfusion, HyperfocalDistance, FOV and DOF -- already
matches either spelling (`Some("3") | Some("cm")`). Measured: zero `Composite:*`
values changed anywhere in the corpus.

A unit test on the new function would not have caught this: the table already
existed and was already correct. The regression test asserts the *dispatch*,
through `format_tag_value`; deleting the new arm fails it with
`left: Integer(1), right: String("None")`.

Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):

    BEFORE correct=393467  valdiff=16934  score=77.5341%
    AFTER  correct=394565  valdiff=15836  score=77.7505%
    MATCHED-SET DELTA  fixed=1098  regressed=0
    PER-FILE  1098 files +1, 0 down
    FORMAT    JPEG +1094, DNG +1, CR2 +1, RAF +1, BPG +1

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(write): report the dropped write instead of printing success

`plan_exif_write` may emit only one IFD record per tag id, so when two
metadata-map keys resolve to the same id the second cannot be written. The
guard that enforced this used `continue`, discarding the caller's edit while
the CLI still printed "1 image files updated" and exited 0:

    $ oxidex -ExifIFD:ExposureBiasValue=-0.5 photo.jpg   # 0x9204
        1 image files updated                            # exit 0
    $ exiftool -a -G1 -s -ExposureCompensation photo.jpg
    [ExifIFD]  ExposureCompensation : -3/2                # unchanged

Before #368 this failed loudly with a type error; #368 fixed the typing and the
failure went quiet.

ExifTool refuses the same command. `ExposureBiasValue` is not a writable tag
name in any of its tables -- only a Notes remark on 0x9204 ("called
ExposureBiasValue by the EXIF spec", Exif.pm:2369) and an XMP property whose
tag name is again ExposureCompensation (XMP.pm:2096-2097); `TagLookup.pm` has
no `exposurebiasvalue` key, so `TagExists` fails and `SetNewValue` returns
"Tag '...' is not defined" (Writer.pl:581-584) with exit 1.

A collision that would lose an edit is now refused with a message naming both
keys and the id; a collision that loses nothing -- the same value already
planned under another spelling, which is what the documented `-EXIF:Tag=value`
syntax produces -- is still skipped silently.

Two further defects fall out of the same guard:

* `-EXIF:ExposureTime=1/250` wrote 0x829A into *both* IFD0 and ExifIFD. An
  "EXIF:" key names the tag family, not a physical IFD, so the collision search
  had to span IFD0/ExifIFD/GPS rather than only the IFD0 fallback.

* `-ExifIFD:ExposureCompensation=-0.5` serialized ASCII "-0.5" into a
  rational64s tag, because the registry declared 0x9204 `String`. Corrected,
  along with 46 other EXIF tags whose declared type disagreed with ExifTool's
  `Writable`, by adding the missing `type:` to the YAML tag database. The set
  was derived mechanically from ExifTool 13.59's own Perl tables and restricted
  to scalar numeric/date tags with no ValueConv and no blob flag -- multi-value,
  ValueConv and `undef`-storage tags are deliberately untouched, since for those
  ExifTool's accepted input type comes from PrintConvInv/ValueConvInv rather
  than the storage code (FileSource's Integer, for one, is already correct).

ModifyDate and CreateDate are among the 47: both now normalize
`2024-01-15T10:30:00` to ExifTool's stored `2024:01:15 10:30:00`.

#358's write path is untouched: a binary built from origin/main and one built
from this branch produce byte-identical output on 24 files (9 corpus RAW/TIFF/
PNG/JPS + 12 corpus JPEGs + 3 fixtures) for an ordinary `-IFD0:Artist=` write,
and all 11 files from #358's table still accept writes with no metadata lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(pentax): enter the five MakerNote sub-directories gated on the camera model

`exiftool -v2` descends into 563 of the corpus's files with a Pentax or FujiFilm
MakerNote. Comparing per file against `exiftool -a -G1 -s` (ExifTool 13.59),
five of the directories it enters produced no oxidex row at all -- not a wrong
value, not a missing-tag report, nothing, because a directory that is never
entered has no rows to be missing:

  tag     ExifTool table                files  tags
  0x0216  %Pentax::BatteryInfo             30   129
  0x021f  %Pentax::AFInfo                  28   111
  0x022a  %Pentax::FilterInfo              18    36
  0x03ff  %Pentax::TempInfo                 8    22
  0x0226  %Pentax::ShotInfo                 6     6

These are plain `ProcessBinaryData` records under a MakerNote oxidex already
reads, so the transcription is the generator's job. What kept them out is that
almost every field in them carries an ExifTool `Condition` on the camera model,
and `%Pentax::BatteryInfo` is an arrayref of such alternatives at nearly every
offset -- the construct `codegen_subdirs.py` logged as "arrayref of Condition
variants" and refused. Byte 2 of that record alone is `BodyBatteryADNoLoad` on a
K10D with one calibration `PrintConv`, an uncalibrated `BodyBatteryADNoLoad` on
a *istD, half of an `int16u` `BodyBatteryVoltage1` on a K-5, and
`BodyBatteryState` on a K-3 III (Pentax.pm:4846-4935). Read without the
condition it is one number under four different real tag names.

So the generator grew a vocabulary rather than a special case:

- `Condition` over `$$self{Model}` -- `=~`, `!~`, `eq`, and the `A and B`
  conjunction Pentax.pm:4864 uses to hold the K-3 III out of a pattern that
  would otherwise catch it. The regexes are expanded to literal alternations at
  generation time (32 branches, `\*ist` and `GX-1[LS]?` and
  `K-(1|01|3|5|7|30|50|70|500|r|x|S[12])` included) by a parser that raises on
  any construct it has not been taught, so the model test stays derived from
  ExifTool's own pattern text instead of a hand-typed list.
- alternatives as several `Field`s sharing one ExifTool key, in ExifTool's
  order; the decoder takes the first whose condition holds and reports nothing
  when none does. A key any of whose alternatives is refused is dropped whole --
  removing an earlier one would let a later, broader one answer for a body it
  was never meant to describe.
- `PrintConv` as a Perl expression, and `ValueConv` composed with it, which is
  what makes a raw 686 of centivolts print "6.86 V" rather than "686.00 V".
- `Field` now carries ExifTool's own key text, because `545`, `545.1` and
  `545.2` are three masked tags at one offset while two entries keyed `2` are
  two readings of one tag.

0x021f and 0x0216 declare `ByteOrder => 'BigEndian'` on the SubDirectory and are
read that way whatever the MakerNote's own order is (Pentax.pm:2983-2986,
:2949). 0x022a is one table read either way round, chosen by
`$$self{Make} =~ /^RICOH/` (Pentax.pm:3032) -- the brand, not the model -- so
`PentaxParser` now carries that test from the dispatcher rather than guessing it
from the model.

Every constant is quoted from Pentax.pm in the code; the unit tests replay the
exact record bytes `exiftool -v3` prints for PentaxK10D.jpg and PentaxK-5IIs.jpg
against the exact values `exiftool -a -G1 -s` reports for those files.
Regenerating reproduces the committed tables byte for byte, and the FujiFilm and
Panasonic tables are field-identical to before apart from the two new columns.

Measured per file over /tmp/oxidex-exiftool-cache/combined-samples against
ExifTool 13.59, keyed `Group1:Name`, both binaries built from this tree:

  563 Pentax/FujiFilm files
  matched  45,133 -> 45,437   (+304 across 31 files)
  missing  12,375 -> 12,065
  regressions 0

  PentaxK-5IIs.jpg   197 -> 214
  PentaxK-5.jpg      187 -> 204
  PentaxK-r.jpg      184 -> 197
  PentaxK10D.jpg     148 -> 159

Six values on Pentax_istD.jpg are wrong, and are named here rather than
buried: that file's MakerNote offsets need ExifTool's `FixBase` correction
(ExifTool resolves its 0x0216 pointer 16 bytes below the TIFF header), which
oxidex does not implement -- so all of its out-of-line values are misread today,
75 of them before this change. The six new ones are the same pre-existing fault
reaching six more names, not a new one.

Still not entered, with the generator's own reason: `%Pentax::CameraSettings`
(a hand-written arm already owns 0x0205 and covers tags the generator refuses),
`FilterInfo`'s 20 `DigitalFilterNN` fields (a `RawConv` unpack idiom),
`%Pentax::AEInfo`/`AEInfo2`/`AEInfo3` (`PentaxEv` conversions),
`%Pentax::CAFPointInfo` (`DecodeAFPoints`), and every `BITMASK` `PrintConv`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(apple): find the MakerNote IFD, then read its binary plists

`MakerNotes.pm:37-46` starts the Apple directory at `$valuePtr + 14` -- ten
bytes of `Apple iOS\0`, two version bytes, then the two-byte order marker.
oxidex started it at byte 10, so on every iPhone it read the order marker
"MM" as an entry count of 1 and decoded one entry out of the count field and
the first tag id, arriving at tag 0x4d4d, which no table has. The result was
not a wrong value; it was silence. **Zero `Apple:` tags over the whole sample
corpus**, against the 831 ExifTool reports.

That is why the binary plists could not be reached. Four `%Apple::Main` tags
hold a whole `bplist00` blob rather than a value:

  0x0003 RunTime                    SubDirectory over %Apple::RunTime, whose
                                    PROCESS_PROC is PLIST::ProcessBinaryPLIST
                                    (Apple.pm:40-43, :324-325)
  0x0040 SemanticStyle              ValueConv => \&ConvertPLIST (Apple.pm:276)
  0x0041 SemanticStyleRenderingVer  ditto (Apple.pm:280)
  0x0042 SemanticStylePreset        ditto (Apple.pm:284)

This adds:

- `shared/binary_plist.rs`, a transcription of `Image::ExifTool::PLIST` --
  `ProcessBinaryPLIST`'s trailer and offset table (PLIST.pm:398-450) and
  `ExtractObject`'s object grammar (PLIST.pm:260-390), plus
  `XMP::SerializeStruct` (XMPStruct.pl:34-69) for the `ConvertPLIST` case.
  Three details decide whether the output matches:

    * integers are read **unsigned**: `%readProc` is Get8u/16u/24u/32u/64u
      (PLIST.pm:30-38), so `RunTimeValue` is 235706184764708 and not a
      negative number.
    * a size that `%readProc` has no entry for returns undef rather than a
      guess -- so only 1/2/3/4/8-byte integers and 4/8-byte reals decode.
    * `SerializeStruct` walks `OrderedKeys`, which for a hash built by
      `ExtractObject` is a plain `sort keys`. `Apple_iPhone13Pro.jpg` stores
      its `SemanticStyle` keys in the order 3,1,2,0 and ExifTool prints
      `{_0=1,_1=0.5,_2=0,_3=2}`. Storage order would print the reverse.

  A plist date is decoded and then dropped: ExifTool converts it with
  `ConvertUnixTime($val + 11323*24*3600, 1)`, and that second argument is
  `$toLocal`, so the string carries the extracting machine's time zone. No
  Apple blob in the corpus has one, and a value that depends on the reader's
  clock is not one to approximate.

- `%Apple::Main` itself, transcribed into `apple.rs` -- each entry's
  `Writable` format and its `PrintConv` verbatim, walked by the existing
  `shared::table_ifd`. The table's twelve `Unknown => 1` entries are absent
  on purpose; ExifTool reports those only under `-u`.

- `Composite:RunTimeSincePowerUp` (Apple.pm:348-357), which was declared in
  `composite/tables.rs` with no arm in `compute`. Its `ConvertDuration`
  PrintConv already existed, privately, inside the MTS parser; it moves to
  `core::formatters::duration` rather than being copied.

Three defects found on the way, each measured:

- `table_ifd::print_rational` printed `%.15g`. `GetRational64s`/`u`
  (ExifTool.pm:6107-6120) end in `RoundFloat($num/$den, 10)`, so ExifTool
  prints `AccelerationVector` as `-0.9245480894`, not `-0.924548089390588`.
  Fixing it also gained `Olympus:FocalPlaneDiagonal` (+5) and
  `Olympus:DigitalZoom` (+3).
- `table_ifd` had no `int64u`/`int64s` (ExifTool format codes 16 and 17).
  `Apple_iPhone15Pro.jpg` stores `LivePhotoVideoIndex` as `int64u[1]` =
  4294967700; an int64u above `i64::MAX` is dropped rather than printed
  negative.
- `parse_exif_subifd` kept only the *last* 0x927C entry. `Apple_iPhone6.jpg`
  declares two -- the camera's, and a 142-byte UTF-16 JSON blob an editor
  appended -- so the Apple parser was handed the wrong 142 bytes and reported
  nothing. Each entry is now processed in turn, as ExifTool does.

Deleted `registries/apple.rs`. It declared a `FrontFacingCamera` at 0x0032
and a `PortraitMode` at 0x0020 -- `%Apple::Main` has no tag at 0x0032 at all,
and 0x0020 is `ImageCaptureRequestID` -- plus enum decoders for `OISMode`,
`SemanticStyle`, `SignalToNoiseRatioType` and `GreenGhostMitigationStatus`,
none of which has a `PrintConv` in Apple.pm. `tests/integration.rs` never
declared `apple_makernotes_tests`, so the file asserting those names never
compiled; it is now declared, and rewritten against bytes dumped from real
corpus files with `exiftool -v3`.

Verified over /tmp/oxidex-exiftool-cache/combined-samples (4,238 files;
ExifTool 13.59 reports on 4,228) against `exiftool -a -G1 -s`, keyed
`Group1:Name`, case-sensitive, scored **per file**. Base is this branch's
merge-base, 5a8b835:

  matched  393,562 -> 394,451   (+889 across 55 files)
  regressions 0 -- matched-set diff per file, not totals
  files worsened 0; extra keys unchanged at 108,146

    Apple: keys emitted        0 -> 831, every one byte-identical to ExifTool
    of the +889:  267 plist-derived, 622 unlocked by the IFD start, 8 Olympus

    Apple_iPhone13Pro.jpg   126 -> 155
    Apple_iPhone13ProMax.jpg 99 -> 127
    Apple_iPhone15Pro.jpg    89 -> 116
    Apple_iPhone12Pro.jpg   125 -> 152

Nothing is left over: every `Apple:` tag ExifTool reports for any corpus file,
and `Composite:RunTimeSincePowerUp`, now matches on every file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(vrd): read the CanonVRD Ver2 picture-style block

A .VRD recipe written by DPP 2.0 or later carries a second edit section
after the fixed 0x272-byte VRD1 record. oxidex read VRD1 and stopped, so
combined-samples/CanonVRD.vrd reported 43 of the 108 tags ExifTool does;
the missing 65 are the whole of %CanonVRD::Ver2's picture-style block --
PictureStyle, IsCustomPictureStyle, and a nine-tag group per style.

Reaching them needs two things. ProcessEditData sizes the three
%CanonVRD::Edit sections three different ways (CanonVRD.pm:1596-1610),
and the middle one, VRDStampTool, takes its length from an int32u at its
own start; skipping it wholesale lands VRD2 four bytes early. And the
record itself is FORMAT => 'int16s', so a tag ID is an index rather than
a byte offset and the values are signed -- StandardRawColorTone reads -4
on this file, which unsigned would print as 65532.

No table is transcribed for this. src/exiftool_tables already carries
CanonVRD::Ver2 dumped from ExifTool's own in-memory hash, layout and
PrintConv enums included, so the decoder reads that.

Ver2 is only read as far as index 0x54. Past it ExifTool leans on
ValueConv -- $val/0x400 rendered as a percentage, $val/10, $val/100 --
plus a DataMember-gated SubDirectory at 0xe0 and VRDVersion-conditional
branches at 0x5e-0x60. The generator drops a conversion it cannot
reproduce exactly, which leaves the raw value behind a real tag name, so
emitting those would print a confident wrong number rather than nothing.
They stay unread; a test asserts every entry below the bound is a bare
int16s or an integer enum, so a future ExifTool release cannot quietly
move one across it.

Measured on combined-samples/CanonVRD.vrd: 43 matched -> 108 matched,
65 missing -> 0, value-diffs 0, extras 0. The 43 tags already matched
are matched by the same values (set comparison, not counts), and
ExifTool.jpg and CanonRaw.crw are byte-identical before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(lfp): read Lytro light-field metadata

oxidex named the LFP file type but had no FileFormat variant, so
format_dispatch fell through to the unsupported arm and the file yielded
no tags at all. This adds the variant, the \x89LFP magic that routes to a
parser, and a reader transcribed from Image::ExifTool::Lytro (Lytro.pm 1.04).

The container walk follows ProcessLFP (Lytro.pm:134-174): 16-byte segment
headers, a big-endian length, an 80-byte sha1 ExifTool discards, then a
body that is either JSON metadata or an embedded JPEG, padded to 16 bytes.
Tag names come from ExtractTags (Lytro.pm:104-128), which flattens the JSON
with ucfirst on each key, drops punctuation while upcasing what follows,
and strips a leading Devices.

Numbers keep their source token text. These files carry more precision than
an f64 roundtrip preserves -- "gamma" : 0.41666001081466674805 is the
literal bytes on disk -- and ExifTool reports such values unchanged because
it never reformats what it did not compute. Only the tags carrying a
ValueConv or PrintConv are parsed to f64.

Measured on the ExifTool corpus sample (per file, keyed Group1:Name,
File/System excluded):

  Lytro.lfp   matched 0 -> 95   missing 95 -> 0   extra 0 -> 0

That is 85 Lytro tags plus the 10 Composite tags, which the existing
composite engine derives once the base tags exist. Zero regressions: the
matched key sets for CanonVRD.vrd, HTML.html and LNK.lnk are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): print ExposureTime through ExifTool's PrintExposureTime

`ExifIFD:ExposureTime` was rendered by a hand-written formatter in
`tag_conversion.rs` that split at one second. ExifTool splits at a
quarter second (Exif.pm:5606, reached from the tag's
`PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at
Exif.pm:1824):

    sub PrintExposureTime($)
    {
        my $secs = shift;
        return $secs unless Image::ExifTool::IsFloat($secs);
        if ($secs < 0.25001 and $secs > 0) {
            return sprintf("1/%d",int(0.5 + 1/$secs));
        }
        $_ = sprintf("%.1f",$secs);
        s/\.0$//;
        return $_;
    }

Three divergences, established by executing that subroutine from the
installed ExifTool 13.55 rather than by reading it:

  seconds        ExifTool   was       now
  0.5555555556   0.6        1/2       0.6
  0.769230769    0.8        1/1       0.8
  0.8            0.8        1/1       0.8
  4.0            4          4.0       4
  30.0           30         30.0      30
  0              0          1/18446744073709551615   0

The first class is the damaging one: every exposure in [0.25001, 1)
printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s
exposure is a perfectly plausible shutter speed, so nothing downstream
could tell it was wrong. The second is the trailing `.0` that `s/\.0$//`
strips. The third divided by a zero `$secs`.

`core::formatters::print_exposure_time` is already a verified port of
the subroutine -- it reproduces all fourteen probe values exactly -- so
this deletes the private copy and calls it. #340 consolidated sixteen
copies of PrintExposureTime but did not reach this one, which is the
copy that renders the EXIF ExposureTime tag itself.

Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus,
keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80"
cannot silently become 1.8:

    differing (file,tag) pairs   40,917 -> 40,814   (-103)
      fixed                                    109
        ExifIFD:ExposureTime                    78
        Composite:ShutterSpeed                  20
        Composite:LightValue                    10
        IFD0:ExposureTime                        1
      newly differing                            6

The six are all `Composite:LightValue`, and they are collateral from a
separate, pre-existing defect: the composite layer parses the *printed*
ExposureTime string instead of the ValueConv seconds, so it now rounds
from the correct `0.6` where it used to round from `1/2`. Ten other
LightValue files move the right way for the same reason (net -4). The
same defect is visible in `Composite:ISO`, which consumes the
PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712
(Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a
composite-layer fix and is left for its own change.

Two ExposureTime classes remain out of scope here, both in the rational
pipeline rather than the PrintConv: ExifTool rounds rational64u to ten
significant figures before the conversion (ExifTool.pm:6114), which is
why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a
zero denominator should print `undef`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(leica): route the "LEICA CAMERA AG" MakerNote to Panasonic::Main

The D-Lux 7, D-Lux 8 and V-Lux 5 are Panasonic-built and sign their
MakerNote "LEICA CAMERA AG\0". ExifTool dispatches that signature with
`MakerNoteLeica10` (MakerNotes.pm:724-731), which points at
`Image::ExifTool::Panasonic::Main` -- not at any Leica table:

    Name      => 'MakerNoteLeica10', # used by the D-Lux7
    Condition => '$$valPt =~ /^LEICA CAMERA AG\0/',
    TagTable  => 'Image::ExifTool::Panasonic::Main',
    Start     => '$valuePtr + 18',

oxidex instead handed the payload to the Leica parser, which claimed the
header as `LeicaLayout::LongHeader`, skipped 15 bytes of it and decoded
nothing -- its own comment recorded that "no corresponding ExifTool table
has been identified". The result was silent: all three bodies reported
zero MakerNote tags where ExifTool reports ~100, with no warning.

The signature is 16 bytes and the IFD begins at 18, so two pad bytes sit
between them. LeicaD-Lux7.jpg opens

    4c 45 49 43 41 20 43 41 4d 45 52 41 20 41 47 00  00 00  9d 00

-- "LEICA CAMERA AG\0", two NULs, then the 157-entry count.
`MakerNoteLeica10` declares no `Base`, so its out-of-line value offsets
are TIFF-header-relative exactly as `MakerNotePanasonic`'s are and need
no adjustment.

`LongHeader` is removed rather than left dead: recognising a signature
this parser has no table for shadowed the parser that does.

Verified against ExifTool 13.59 on the 4,238-file corpus, scored per file
and keyed Group1:Name:

  Leica/LeicaD-Lux7.jpg   0 -> 80 MakerNote tags  (+66 matching ExifTool)
  Leica/LeicaD-Lux8.jpg   0 -> 80                 (+65)
  Leica/LeicaV-Lux5.jpg   0 -> 80                 (+66)

  +197 newly-matching keys, 0 regressions across all 4,238 files
  (matched-key sets diffed per file, before vs after).

  exiftool -G1 -s Leica/LeicaD-Lux7.jpg
      [Panasonic] ImageQuality         : RAW
      [Panasonic] InternalSerialNumber : (XFL) 2018:09:06 no. 0007
      [Panasonic] WhiteBalance         : Auto
  oxidex now prints the same three values.

Note this PR fixes dispatch only. The ~13 remaining value differences per
file (AFPointPosition, RollAngle, PhotoStyle, ...) are pre-existing
`Panasonic::Main` conversion gaps, not new: measured on origin/main they
already differ on up to 212 of the 303 Panasonic JPEGs that oxidex
already parsed. Tracked separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(leica): assert LEICA CAMERA AG is declined, matching Leica10 routing

The inline unit test in leica.rs was updated to the new behaviour but this
integration copy still asserted the old one, so Build & Test failed on the
very change it was meant to cover.

ExifTool's MakerNoteLeica10 (MakerNotes.pm:724-731) matches the signature
alone -- Condition => '$$valPt =~ /^LEICA CAMERA AG\0/' -- and routes to
Panasonic::Main at Start => '$valuePtr + 18', never to a Leica table. So
is_leica_makernote must decline it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(panasonic): drop six fabricated MakerNote registry entries

`registries/panasonic.rs` declared 93 tags. Six did not trace to
`%Image::ExifTool::Panasonic::Main`, and between them oxidex emitted
1,248 tag instances across the 479-file Panasonic corpus that ExifTool
reports none of:

  0x003E TextStamp2 -> TextStamp   (Panasonic.pm:809)
  0x8008 TextStamp3 -> TextStamp   (Panasonic.pm:1568)
  0x8009 TextStamp4 -> TextStamp   (Panasonic.pm:1574)
  0x8007 FlashFired -> deleted     (Panasonic.pm:1563, commented out)
  0x8010 BabyAge2   -> deleted     (id is `BabyAge`, Panasonic.pm:1580)
  0x8012 Transform2 -> deleted     (id is `Transform`, Panasonic.pm:1587)

ExifTool gives four different ids the same name `TextStamp` and two ids
the name `BabyAge`; inventing `Foo2`/`Foo3`/`Foo4` to disambiguate them
produces names that exist in zero ExifTool source files and can never
match real output. 0x003E/0x8008/0x8009 carry ExifTool's own PrintConv
({1=>'Off', 2=>'On'}) so they are renamed in place. 0x8010 and 0x8012
are omitted rather than renamed: 0x8010 is a `string` with a sentinel
PrintConv but was registered as a bare integer, and 0x8012 is an
`int16s` `Count => 2` whose PrintConv keys are integer *pairs*
('0 0' => 'Off', '-3 2' => 'Slim High'), which a single-value On/Off
decoder cannot express. 0x0033 and 0x0059 already deliver the real
`BabyAge` and `Transform` names. 0x8007 is disabled upstream:
`#0x8007 => { #PH - questionable [disabled because it conflicts with
EXIF in too many samples]`.

Two enum decoders were also invented on real tags, printing confidently
wrong values under real ExifTool names:

  0x0077 BurstSpeed       int16u, "images per second", no PrintConv
                          (Panasonic.pm:1094). Printed "Low" where
                          ExifTool prints "0", in 91 files.
  0x009D InternalNDFilter rational64u, no PrintConv
                          (Panasonic.pm:1247). Printed
                          "Unknown (4620)" where ExifTool prints "0".

Both decoders are removed; `BURST_SPEED` and `INTERNAL_ND_FILTER` in
the parser had no other caller and are deleted.

Measured per file over 479 Panasonic samples, ExifTool 13.59 vs oxidex,
keyed Group1:Name (JSON parsed with parse_float=str):

  matched     44668 -> 44759  (+91, BurstSpeed now agrees)
  value_diff   1848 -> 1757   (-91)
  missing      8057 -> 8057   (unchanged)
  extra       12725 -> 11477  (-1248)

No file lost a matched key.

InternalNDFilter still disagrees (76 files): oxidex prints the entry's
value_offset rather than dereferencing the rational. That is the same
pre-existing gap that makes `AFPointPosition` print "3254" for
ExifTool's "0.47 0.48", and is not addressed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(olympus): read the OM System MakerNote and the TextInfo sub-record

Measured against ExifTool 13.59 over the 318-file Olympus corpus, scored
per file on `Group1:Name`. Three entangled `Olympus::Main` gaps:

1. `MakerNoteOlympus3`. The OM-1, OM-3, OM-5, OM-1 Mark II and TG-7 write
   an "OM SYSTEM\0" header (MakerNotes.pm:589-597: `Start => '$valuePtr +
   16'`, `Base => '$start - 16'`). The dispatcher already routed them here
   on `Make = "OM Digital Solutions"`; only `validate_header` rejected
   them, so those five files yielded zero Olympus tags. Adding the
   signature took them from 400 matched / 858 missing to 1101 matched /
   154 missing -- +701 tag instances, no matched key lost.

2. `Olympus::TextInfo` (0x0208). 129 of the 318 files carry a short ASCII
   record -- `[pictureInfo] Resolution=3 [Camera Info] Type=SR951` --
   which ExifTool scans with `APP12::ProcessAPP12` (Olympus.pm:1574).
   The separator is a space, not CR/LF, so the existing APP12 readers in
   `parsers::jpeg::app_segments` would see one token and emit nothing;
   `text_info::scan` ports the tokenizer at APP12.pm:262 instead.
   125 files x {Resolution, CameraType} = 250 tag instances.

3. `Main` 0x0207 `CameraType` and 0x0201 `Quality`, which the table walk
   cannot express: `CameraType` is a `DataMember` that `Quality`'s
   PrintConv consults (Olympus.pm:725), and `TextInfo` carries a second
   `CameraType` that overwrites it. The FE240/SP510UZ/u730/u1000
   placeholder is padded to "NORMAL  ", which is not `eq "NORMAL"`, so
   the Condition passes and ExifTool really does print `Unknown (NORMAL)`
   before TextInfo replaces it -- reproduced rather than special-cased.

Also replaces `TagDef::raw(0x0821, "ISOAutoSettings")`, which printed the
raw "0 0" where ExifTool prints "n/a; n/a", with the two-element list
PrintConv transcribed from ExifTool's own loaded table.

wip: the release binary and test run were cut short by machine load, so
the whole-corpus after-measurement is not yet recorded. Per-item deltas
above are measured; item 1 was verified end to end against a built
binary, items 2 and 3 compile and are unit-tested but unmeasured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(lnk): emit ExifTool's LNK tag names and groups

The LNK parser is reachable -- format_dispatch.rs:173 sends FileFormat::LNK
to parse_lnk_metadata -- but it reported invented, ungrouped names:
CreationTime, Name, IconLocation, VolumeSerialNumber, MACAddress,
DroidFileID, DroidVolumeID, KnownFolderID, LinkFlagsDescription,
HasPropertyStore. ExifTool names none of those, so all 43 tags it extracts
from a Windows shortcut scored as MISSING. Measured on
combined-samples/LNK.lnk: MATCHED 0/43 before, 43/43 after, EXTRA 0.

Rewrite the module against Image::ExifTool::LNK (LNK.pm 1.18), citing the
Perl line for each transcribed table and conversion. Three structures the
old parser never walked are now decoded: ProcessItemID with its 0xbeef
extension split and 0x20/0x30/0x40 ID-range folding (TargetInfo and
Beef0004), %LNK::ConsoleData (17 tags), and %LNK::TrackerData. LinkInfo
gains DriveType, DriveSerialNumber, VolumeLabel, CommonPathSuffix and its
Unicode form, NetName, DeviceName and the 68-entry NetProviderType table.

Every PrintConv comes out of LNK.pm rather than being approximated, and a
record whose conversion cannot be reproduced exactly is omitted rather than
reported under a real ExifTool name. Still open on purpose: the ItemID
sub-types other than TargetInfo/Beef0004, ConsoleFEData's CodePage (needs
%Microsoft::codePage, untranscribed here) and EnvVarData.

Differential-testing 150 truncated and byte-flipped shortcuts against real
ExifTool raised agreement from 69% to 93% and exposed three defects, each
fixed and covered by a test:

  - a short string read aborted the walk, where LNK.pm:1806 tests
    $raf->Read for truth rather than for the full count and continues
  - Decode(..., 'UTF16') stops at the first null, so a padded field
    reports only the text before the padding
  - chrono's %Y prefixes a five-digit year with '+'; sprintf("%4d:...")
    does not

tests/forensic/lnk_tests.rs asserted the old invented names, so 15 failing
tests are rewritten against ExifTool's names and exact PrintConv strings,
plus a new test that fails on any ungrouped or invented tag. Two fixtures
that were structurally malformed were corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
swackhamer added a commit that referenced this pull request Aug 2, 2026
…rd (#401)

* fix(composite): preserve raw APEX conversion precision

* fix(fleet): require current-head PR approval

* fix(tables): decode repeated scalar fields

* fix(exif): one Compression table, dumped from ExifTool's own Perl

Three transcriptions of `%Image::ExifTool::Exif::compression` (Exif.pm:213)
lived in this tree, carrying 50, 40 and 53 ids and disagreeing on the spelling
of codes all three claimed to know:

  core/formatters/exif_enums.rs   32766 => "Next"
  parsers/tiff/tiff_enums.rs      32766 => "Next"
  parsers/pdf/mod.rs              32766 => "NeXt or Sony ARW Compressed 2"

ExifTool 13.59 says:

    32766 => 'NeXt or Sony ARW Compressed 2', #3/Milos

Rather than read the Perl and re-implement it -- the way three divergent copies
got written in the first place -- the hash was dumped straight out of the Perl
symbol table:

    perl -Ilib -MImage::ExifTool::Exif -e '
      my $h = \%Image::ExifTool::Exif::compression;
      for my $v (0..70000) {
        print "$v\t", (exists $h->{$v} ? $h->{$v} : "Unknown ($v)"), "\n" }'

and each copy scored over all 70,001 inputs:

    exif_enums::format_compression   50 ids   10 wrong
    tiff_enums 0x0103 arm            40 ids   15 wrong
    pdf COMPRESSION_LABELS           53 ids    1 wrong
    consolidated table               53 ids    0 wrong

The wrong answers, all now fixed: `9` read `JBIG B&W` (13.59 renamed it
`JBIG B&W or VC-5`); `32766` read `Next`; `33003`, `33004` and `33005` all
collapsed to `Aperio JPEG 2000 YCbCr`, where 33005 is `Aperio JPEG 2000 RGB`
and 33004 is not an ExifTool key at all; `34926`/`34927` dropped the ` (old)`
suffix that separates them from the LibTiff 4.7 codes; and `50000` `Zstd`,
`50001` `WebP`, `50002` `JPEG XL (old)`, `52546` `JPEG XL`, plus `32772`,
`33003`, `34887`, `34925`, `34933`, `34934` on the TIFF path, were absent.

`compression_label` returns `Option<&'static str>` so each caller keeps its
established unknown-value behaviour: `format_compression` renders `Unknown (N)`
the way `PrintConv` does, while the TIFF and PDF paths still return `None`
rather than invent a label ExifTool never prints.

Why the old tests stayed green: `test_compression` asserted 1, 6 and 7 -- three
codes all three tables agreed on -- and `tiff_enums.rs` had no test module at
all. Not one input reached a wrong branch. The new tests assert every one of the
15 codes that was measurably wrong; reverting the table makes 5 of them fail.

Corpus check (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):
393,343 correct before and after, matched-set delta fixed=0 regressed=0. The
corpus stores only Compression 1/4/5/6/7/34713 plus two codes ExifTool does not
name, so it exercises none of the divergent ids -- the divergence is measured
against the Perl, and the corpus proves the consolidation costs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): Flash is a lookup table, not a bitfield (#393)

Exif.pm 0x9209 is `PrintConv => \%flash` -- a flat 27-key hash -- plus
`Flags => 'PrintHex'`. `core::exif_enums::decode_flash` instead synthesised the
label from the byte's bit fields (fired / strobe return / mode / function
present / red-eye). Those bits explain how ExifTool's 27 codes were *chosen*;
they are not how the tag is rendered, and 229 of the 256 byte values are not
valid Flash codes at all.

Scored against `%Image::ExifTool::Exif::flash` dumped out of the Perl symbol
table, over every input in 0..=255:

    core::exif_enums::decode_flash    236 wrong / 256
    consolidated table                  0 wrong / 256

Two verbatim copies of the correct table already existed -- `FLASH_LABELS` in
`parsers::pdf` and the `0x9209` arm of `parsers::raw::metadata` -- so the tree
held three Flash decoders, and only the one on the main EXIF path was wrong.
All three now share `core::formatters::exif_enums::flash_label`.

Three classes of wrong answer, all corpus-visible:

  - codes ExifTool leaves unknown got a plausible label. 0x38 and 0x28 both
    came back `No flash function`; ExifTool prints `Unknown (0x38)` and
    `Unknown (0x28)` -- 60 files.
  - codes it did know were mis-worded: 0x49 gave `On, Fired, Red-eye reduction`
    where the hash says `On, Red-eye reduction` -- 23 files. Same for 0x0d,
    0x0f, 0x4d, 0x4f, 0x50.
  - `format_flash`'s unknown form was decimal; `PrintHex` makes it hex.

Why the old test stayed green: `test_flash_decoding` had 13 assertions, each
annotated with the bit layout it was deriving -- and one of them,
`decode_flash(0x49) == "On, Fired, Red-eye reduction"`, asserted a string
ExifTool never prints. The test encoded the same wrong model as the code. It is
rewritten to check the hash, and three new tests cover the mis-worded codes, the
hex unknown form, and the 27-of-256 key count; reverting the table fails four of
them.

Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):

    BEFORE correct=393343  valdiff=17058  score=77.5097%
    AFTER  correct=393467  valdiff=16934  score=77.5341%
    MATCHED-SET DELTA  fixed=124  regressed=0
    PER-FILE  124 files +1, 0 down     FORMAT  JPEG 390485 -> 390609 (+124)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): FocalPlaneResolutionUnit decodes instead of printing its code (#395)

Exif.pm 0xa210 declares a PrintConv:

    0xa210 => {
        Name => 'FocalPlaneResolutionUnit',
        Notes => 'values 1, 4 and 5 are not standard EXIF',
        PrintConv => { 1 => 'None', 2 => 'inches', 3 => 'cm',
                       4 => 'mm', 5 => 'um' },
    },

The tree already held that table -- in `parsers::pdf`, reachable only from a
PDF's embedded TIFF thumbnail. The main EXIF path had no arm for the tag at all,
so `format_tag_value` returned the raw integer and 1,098 files in the sample
corpus reported `2` and `3` where ExifTool reports `inches` and `cm`.

The table moves to `core::formatters::exif_enums` next to the Compression and
Flash tables, PDF reads it from there, and the `exiftool_compat` dispatch gains
the arm it was missing.

`Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an
exclusion: ExifTool decodes all five, and the corpus holds 4 files at `4` and 1
at `5`. 0xa210 carries no `PrintHex`, so unnamed codes stay decimal --
`Unknown (0)`, which 4 corpus files report.

Safe for the composites: `Exif ScaleFactor35efl` -- which gates
FocalLength35efl, CircleOfConfusion, HyperfocalDistance, FOV and DOF -- already
matches either spelling (`Some("3") | Some("cm")`). Measured: zero `Composite:*`
values changed anywhere in the corpus.

A unit test on the new function would not have caught this: the table already
existed and was already correct. The regression test asserts the *dispatch*,
through `format_tag_value`; deleting the new arm fails it with
`left: Integer(1), right: String("None")`.

Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):

    BEFORE correct=393467  valdiff=16934  score=77.5341%
    AFTER  correct=394565  valdiff=15836  score=77.7505%
    MATCHED-SET DELTA  fixed=1098  regressed=0
    PER-FILE  1098 files +1, 0 down
    FORMAT    JPEG +1094, DNG +1, CR2 +1, RAF +1, BPG +1

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(write): report the dropped write instead of printing success

`plan_exif_write` may emit only one IFD record per tag id, so when two
metadata-map keys resolve to the same id the second cannot be written. The
guard that enforced this used `continue`, discarding the caller's edit while
the CLI still printed "1 image files updated" and exited 0:

    $ oxidex -ExifIFD:ExposureBiasValue=-0.5 photo.jpg   # 0x9204
        1 image files updated                            # exit 0
    $ exiftool -a -G1 -s -ExposureCompensation photo.jpg
    [ExifIFD]  ExposureCompensation : -3/2                # unchanged

Before #368 this failed loudly with a type error; #368 fixed the typing and the
failure went quiet.

ExifTool refuses the same command. `ExposureBiasValue` is not a writable tag
name in any of its tables -- only a Notes remark on 0x9204 ("called
ExposureBiasValue by the EXIF spec", Exif.pm:2369) and an XMP property whose
tag name is again ExposureCompensation (XMP.pm:2096-2097); `TagLookup.pm` has
no `exposurebiasvalue` key, so `TagExists` fails and `SetNewValue` returns
"Tag '...' is not defined" (Writer.pl:581-584) with exit 1.

A collision that would lose an edit is now refused with a message naming both
keys and the id; a collision that loses nothing -- the same value already
planned under another spelling, which is what the documented `-EXIF:Tag=value`
syntax produces -- is still skipped silently.

Two further defects fall out of the same guard:

* `-EXIF:ExposureTime=1/250` wrote 0x829A into *both* IFD0 and ExifIFD. An
  "EXIF:" key names the tag family, not a physical IFD, so the collision search
  had to span IFD0/ExifIFD/GPS rather than only the IFD0 fallback.

* `-ExifIFD:ExposureCompensation=-0.5` serialized ASCII "-0.5" into a
  rational64s tag, because the registry declared 0x9204 `String`. Corrected,
  along with 46 other EXIF tags whose declared type disagreed with ExifTool's
  `Writable`, by adding the missing `type:` to the YAML tag database. The set
  was derived mechanically from ExifTool 13.59's own Perl tables and restricted
  to scalar numeric/date tags with no ValueConv and no blob flag -- multi-value,
  ValueConv and `undef`-storage tags are deliberately untouched, since for those
  ExifTool's accepted input type comes from PrintConvInv/ValueConvInv rather
  than the storage code (FileSource's Integer, for one, is already correct).

ModifyDate and CreateDate are among the 47: both now normalize
`2024-01-15T10:30:00` to ExifTool's stored `2024:01:15 10:30:00`.

#358's write path is untouched: a binary built from origin/main and one built
from this branch produce byte-identical output on 24 files (9 corpus RAW/TIFF/
PNG/JPS + 12 corpus JPEGs + 3 fixtures) for an ordinary `-IFD0:Artist=` write,
and all 11 files from #358's table still accept writes with no metadata lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(pentax): enter the five MakerNote sub-directories gated on the camera model

`exiftool -v2` descends into 563 of the corpus's files with a Pentax or FujiFilm
MakerNote. Comparing per file against `exiftool -a -G1 -s` (ExifTool 13.59),
five of the directories it enters produced no oxidex row at all -- not a wrong
value, not a missing-tag report, nothing, because a directory that is never
entered has no rows to be missing:

  tag     ExifTool table                files  tags
  0x0216  %Pentax::BatteryInfo             30   129
  0x021f  %Pentax::AFInfo                  28   111
  0x022a  %Pentax::FilterInfo              18    36
  0x03ff  %Pentax::TempInfo                 8    22
  0x0226  %Pentax::ShotInfo                 6     6

These are plain `ProcessBinaryData` records under a MakerNote oxidex already
reads, so the transcription is the generator's job. What kept them out is that
almost every field in them carries an ExifTool `Condition` on the camera model,
and `%Pentax::BatteryInfo` is an arrayref of such alternatives at nearly every
offset -- the construct `codegen_subdirs.py` logged as "arrayref of Condition
variants" and refused. Byte 2 of that record alone is `BodyBatteryADNoLoad` on a
K10D with one calibration `PrintConv`, an uncalibrated `BodyBatteryADNoLoad` on
a *istD, half of an `int16u` `BodyBatteryVoltage1` on a K-5, and
`BodyBatteryState` on a K-3 III (Pentax.pm:4846-4935). Read without the
condition it is one number under four different real tag names.

So the generator grew a vocabulary rather than a special case:

- `Condition` over `$$self{Model}` -- `=~`, `!~`, `eq`, and the `A and B`
  conjunction Pentax.pm:4864 uses to hold the K-3 III out of a pattern that
  would otherwise catch it. The regexes are expanded to literal alternations at
  generation time (32 branches, `\*ist` and `GX-1[LS]?` and
  `K-(1|01|3|5|7|30|50|70|500|r|x|S[12])` included) by a parser that raises on
  any construct it has not been taught, so the model test stays derived from
  ExifTool's own pattern text instead of a hand-typed list.
- alternatives as several `Field`s sharing one ExifTool key, in ExifTool's
  order; the decoder takes the first whose condition holds and reports nothing
  when none does. A key any of whose alternatives is refused is dropped whole --
  removing an earlier one would let a later, broader one answer for a body it
  was never meant to describe.
- `PrintConv` as a Perl expression, and `ValueConv` composed with it, which is
  what makes a raw 686 of centivolts print "6.86 V" rather than "686.00 V".
- `Field` now carries ExifTool's own key text, because `545`, `545.1` and
  `545.2` are three masked tags at one offset while two entries keyed `2` are
  two readings of one tag.

0x021f and 0x0216 declare `ByteOrder => 'BigEndian'` on the SubDirectory and are
read that way whatever the MakerNote's own order is (Pentax.pm:2983-2986,
:2949). 0x022a is one table read either way round, chosen by
`$$self{Make} =~ /^RICOH/` (Pentax.pm:3032) -- the brand, not the model -- so
`PentaxParser` now carries that test from the dispatcher rather than guessing it
from the model.

Every constant is quoted from Pentax.pm in the code; the unit tests replay the
exact record bytes `exiftool -v3` prints for PentaxK10D.jpg and PentaxK-5IIs.jpg
against the exact values `exiftool -a -G1 -s` reports for those files.
Regenerating reproduces the committed tables byte for byte, and the FujiFilm and
Panasonic tables are field-identical to before apart from the two new columns.

Measured per file over /tmp/oxidex-exiftool-cache/combined-samples against
ExifTool 13.59, keyed `Group1:Name`, both binaries built from this tree:

  563 Pentax/FujiFilm files
  matched  45,133 -> 45,437   (+304 across 31 files)
  missing  12,375 -> 12,065
  regressions 0

  PentaxK-5IIs.jpg   197 -> 214
  PentaxK-5.jpg      187 -> 204
  PentaxK-r.jpg      184 -> 197
  PentaxK10D.jpg     148 -> 159

Six values on Pentax_istD.jpg are wrong, and are named here rather than
buried: that file's MakerNote offsets need ExifTool's `FixBase` correction
(ExifTool resolves its 0x0216 pointer 16 bytes below the TIFF header), which
oxidex does not implement -- so all of its out-of-line values are misread today,
75 of them before this change. The six new ones are the same pre-existing fault
reaching six more names, not a new one.

Still not entered, with the generator's own reason: `%Pentax::CameraSettings`
(a hand-written arm already owns 0x0205 and covers tags the generator refuses),
`FilterInfo`'s 20 `DigitalFilterNN` fields (a `RawConv` unpack idiom),
`%Pentax::AEInfo`/`AEInfo2`/`AEInfo3` (`PentaxEv` conversions),
`%Pentax::CAFPointInfo` (`DecodeAFPoints`), and every `BITMASK` `PrintConv`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(apple): find the MakerNote IFD, then read its binary plists

`MakerNotes.pm:37-46` starts the Apple directory at `$valuePtr + 14` -- ten
bytes of `Apple iOS\0`, two version bytes, then the two-byte order marker.
oxidex started it at byte 10, so on every iPhone it read the order marker
"MM" as an entry count of 1 and decoded one entry out of the count field and
the first tag id, arriving at tag 0x4d4d, which no table has. The result was
not a wrong value; it was silence. **Zero `Apple:` tags over the whole sample
corpus**, against the 831 ExifTool reports.

That is why the binary plists could not be reached. Four `%Apple::Main` tags
hold a whole `bplist00` blob rather than a value:

  0x0003 RunTime                    SubDirectory over %Apple::RunTime, whose
                                    PROCESS_PROC is PLIST::ProcessBinaryPLIST
                                    (Apple.pm:40-43, :324-325)
  0x0040 SemanticStyle              ValueConv => \&ConvertPLIST (Apple.pm:276)
  0x0041 SemanticStyleRenderingVer  ditto (Apple.pm:280)
  0x0042 SemanticStylePreset        ditto (Apple.pm:284)

This adds:

- `shared/binary_plist.rs`, a transcription of `Image::ExifTool::PLIST` --
  `ProcessBinaryPLIST`'s trailer and offset table (PLIST.pm:398-450) and
  `ExtractObject`'s object grammar (PLIST.pm:260-390), plus
  `XMP::SerializeStruct` (XMPStruct.pl:34-69) for the `ConvertPLIST` case.
  Three details decide whether the output matches:

    * integers are read **unsigned**: `%readProc` is Get8u/16u/24u/32u/64u
      (PLIST.pm:30-38), so `RunTimeValue` is 235706184764708 and not a
      negative number.
    * a size that `%readProc` has no entry for returns undef rather than a
      guess -- so only 1/2/3/4/8-byte integers and 4/8-byte reals decode.
    * `SerializeStruct` walks `OrderedKeys`, which for a hash built by
      `ExtractObject` is a plain `sort keys`. `Apple_iPhone13Pro.jpg` stores
      its `SemanticStyle` keys in the order 3,1,2,0 and ExifTool prints
      `{_0=1,_1=0.5,_2=0,_3=2}`. Storage order would print the reverse.

  A plist date is decoded and then dropped: ExifTool converts it with
  `ConvertUnixTime($val + 11323*24*3600, 1)`, and that second argument is
  `$toLocal`, so the string carries the extracting machine's time zone. No
  Apple blob in the corpus has one, and a value that depends on the reader's
  clock is not one to approximate.

- `%Apple::Main` itself, transcribed into `apple.rs` -- each entry's
  `Writable` format and its `PrintConv` verbatim, walked by the existing
  `shared::table_ifd`. The table's twelve `Unknown => 1` entries are absent
  on purpose; ExifTool reports those only under `-u`.

- `Composite:RunTimeSincePowerUp` (Apple.pm:348-357), which was declared in
  `composite/tables.rs` with no arm in `compute`. Its `ConvertDuration`
  PrintConv already existed, privately, inside the MTS parser; it moves to
  `core::formatters::duration` rather than being copied.

Three defects found on the way, each measured:

- `table_ifd::print_rational` printed `%.15g`. `GetRational64s`/`u`
  (ExifTool.pm:6107-6120) end in `RoundFloat($num/$den, 10)`, so ExifTool
  prints `AccelerationVector` as `-0.9245480894`, not `-0.924548089390588`.
  Fixing it also gained `Olympus:FocalPlaneDiagonal` (+5) and
  `Olympus:DigitalZoom` (+3).
- `table_ifd` had no `int64u`/`int64s` (ExifTool format codes 16 and 17).
  `Apple_iPhone15Pro.jpg` stores `LivePhotoVideoIndex` as `int64u[1]` =
  4294967700; an int64u above `i64::MAX` is dropped rather than printed
  negative.
- `parse_exif_subifd` kept only the *last* 0x927C entry. `Apple_iPhone6.jpg`
  declares two -- the camera's, and a 142-byte UTF-16 JSON blob an editor
  appended -- so the Apple parser was handed the wrong 142 bytes and reported
  nothing. Each entry is now processed in turn, as ExifTool does.

Deleted `registries/apple.rs`. It declared a `FrontFacingCamera` at 0x0032
and a `PortraitMode` at 0x0020 -- `%Apple::Main` has no tag at 0x0032 at all,
and 0x0020 is `ImageCaptureRequestID` -- plus enum decoders for `OISMode`,
`SemanticStyle`, `SignalToNoiseRatioType` and `GreenGhostMitigationStatus`,
none of which has a `PrintConv` in Apple.pm. `tests/integration.rs` never
declared `apple_makernotes_tests`, so the file asserting those names never
compiled; it is now declared, and rewritten against bytes dumped from real
corpus files with `exiftool -v3`.

Verified over /tmp/oxidex-exiftool-cache/combined-samples (4,238 files;
ExifTool 13.59 reports on 4,228) against `exiftool -a -G1 -s`, keyed
`Group1:Name`, case-sensitive, scored **per file**. Base is this branch's
merge-base, 5a8b835:

  matched  393,562 -> 394,451   (+889 across 55 files)
  regressions 0 -- matched-set diff per file, not totals
  files worsened 0; extra keys unchanged at 108,146

    Apple: keys emitted        0 -> 831, every one byte-identical to ExifTool
    of the +889:  267 plist-derived, 622 unlocked by the IFD start, 8 Olympus

    Apple_iPhone13Pro.jpg   126 -> 155
    Apple_iPhone13ProMax.jpg 99 -> 127
    Apple_iPhone15Pro.jpg    89 -> 116
    Apple_iPhone12Pro.jpg   125 -> 152

Nothing is left over: every `Apple:` tag ExifTool reports for any corpus file,
and `Composite:RunTimeSincePowerUp`, now matches on every file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(vrd): read the CanonVRD Ver2 picture-style block

A .VRD recipe written by DPP 2.0 or later carries a second edit section
after the fixed 0x272-byte VRD1 record. oxidex read VRD1 and stopped, so
combined-samples/CanonVRD.vrd reported 43 of the 108 tags ExifTool does;
the missing 65 are the whole of %CanonVRD::Ver2's picture-style block --
PictureStyle, IsCustomPictureStyle, and a nine-tag group per style.

Reaching them needs two things. ProcessEditData sizes the three
%CanonVRD::Edit sections three different ways (CanonVRD.pm:1596-1610),
and the middle one, VRDStampTool, takes its length from an int32u at its
own start; skipping it wholesale lands VRD2 four bytes early. And the
record itself is FORMAT => 'int16s', so a tag ID is an index rather than
a byte offset and the values are signed -- StandardRawColorTone reads -4
on this file, which unsigned would print as 65532.

No table is transcribed for this. src/exiftool_tables already carries
CanonVRD::Ver2 dumped from ExifTool's own in-memory hash, layout and
PrintConv enums included, so the decoder reads that.

Ver2 is only read as far as index 0x54. Past it ExifTool leans on
ValueConv -- $val/0x400 rendered as a percentage, $val/10, $val/100 --
plus a DataMember-gated SubDirectory at 0xe0 and VRDVersion-conditional
branches at 0x5e-0x60. The generator drops a conversion it cannot
reproduce exactly, which leaves the raw value behind a real tag name, so
emitting those would print a confident wrong number rather than nothing.
They stay unread; a test asserts every entry below the bound is a bare
int16s or an integer enum, so a future ExifTool release cannot quietly
move one across it.

Measured on combined-samples/CanonVRD.vrd: 43 matched -> 108 matched,
65 missing -> 0, value-diffs 0, extras 0. The 43 tags already matched
are matched by the same values (set comparison, not counts), and
ExifTool.jpg and CanonRaw.crw are byte-identical before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(lfp): read Lytro light-field metadata

oxidex named the LFP file type but had no FileFormat variant, so
format_dispatch fell through to the unsupported arm and the file yielded
no tags at all. This adds the variant, the \x89LFP magic that routes to a
parser, and a reader transcribed from Image::ExifTool::Lytro (Lytro.pm 1.04).

The container walk follows ProcessLFP (Lytro.pm:134-174): 16-byte segment
headers, a big-endian length, an 80-byte sha1 ExifTool discards, then a
body that is either JSON metadata or an embedded JPEG, padded to 16 bytes.
Tag names come from ExtractTags (Lytro.pm:104-128), which flattens the JSON
with ucfirst on each key, drops punctuation while upcasing what follows,
and strips a leading Devices.

Numbers keep their source token text. These files carry more precision than
an f64 roundtrip preserves -- "gamma" : 0.41666001081466674805 is the
literal bytes on disk -- and ExifTool reports such values unchanged because
it never reformats what it did not compute. Only the tags carrying a
ValueConv or PrintConv are parsed to f64.

Measured on the ExifTool corpus sample (per file, keyed Group1:Name,
File/System excluded):

  Lytro.lfp   matched 0 -> 95   missing 95 -> 0   extra 0 -> 0

That is 85 Lytro tags plus the 10 Composite tags, which the existing
composite engine derives once the base tags exist. Zero regressions: the
matched key sets for CanonVRD.vrd, HTML.html and LNK.lnk are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(exif): print ExposureTime through ExifTool's PrintExposureTime

`ExifIFD:ExposureTime` was rendered by a hand-written formatter in
`tag_conversion.rs` that split at one second. ExifTool splits at a
quarter second (Exif.pm:5606, reached from the tag's
`PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at
Exif.pm:1824):

    sub PrintExposureTime($)
    {
        my $secs = shift;
        return $secs unless Image::ExifTool::IsFloat($secs);
        if ($secs < 0.25001 and $secs > 0) {
            return sprintf("1/%d",int(0.5 + 1/$secs));
        }
        $_ = sprintf("%.1f",$secs);
        s/\.0$//;
        return $_;
    }

Three divergences, established by executing that subroutine from the
installed ExifTool 13.55 rather than by reading it:

  seconds        ExifTool   was       now
  0.5555555556   0.6        1/2       0.6
  0.769230769    0.8        1/1       0.8
  0.8            0.8        1/1       0.8
  4.0            4          4.0       4
  30.0           30         30.0      30
  0              0          1/18446744073709551615   0

The first class is the damaging one: every exposure in [0.25001, 1)
printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s
exposure is a perfectly plausible shutter speed, so nothing downstream
could tell it was wrong. The second is the trailing `.0` that `s/\.0$//`
strips. The third divided by a zero `$secs`.

`core::formatters::print_exposure_time` is already a verified port of
the subroutine -- it reproduces all fourteen probe values exactly -- so
this deletes the private copy and calls it. #340 consolidated sixteen
copies of PrintExposureTime but did not reach this one, which is the
copy that renders the EXIF ExposureTime tag itself.

Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus,
keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80"
cannot silently become 1.8:

    differing (file,tag) pairs   40,917 -> 40,814   (-103)
      fixed                                    109
        ExifIFD:ExposureTime                    78
        Composite:ShutterSpeed                  20
        Composite:LightValue                    10
        IFD0:ExposureTime                        1
      newly differing                            6

The six are all `Composite:LightValue`, and they are collateral from a
separate, pre-existing defect: the composite layer parses the *printed*
ExposureTime string instead of the ValueConv seconds, so it now rounds
from the correct `0.6` where it used to round from `1/2`. Ten other
LightValue files move the right way for the same reason (net -4). The
same defect is visible in `Composite:ISO`, which consumes the
PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712
(Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a
composite-layer fix and is left for its own change.

Two ExposureTime classes remain out of scope here, both in the rational
pipeline rather than the PrintConv: ExifTool rounds rational64u to ten
significant figures before the conversion (ExifTool.pm:6114), which is
why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a
zero denominator should print `undef`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(leica): route the "LEICA CAMERA AG" MakerNote to Panasonic::Main

The D-Lux 7, D-Lux 8 and V-Lux 5 are Panasonic-built and sign their
MakerNote "LEICA CAMERA AG\0". ExifTool dispatches that signature with
`MakerNoteLeica10` (MakerNotes.pm:724-731), which points at
`Image::ExifTool::Panasonic::Main` -- not at any Leica table:

    Name      => 'MakerNoteLeica10', # used by the D-Lux7
    Condition => '$$valPt =~ /^LEICA CAMERA AG\0/',
    TagTable  => 'Image::ExifTool::Panasonic::Main',
    Start     => '$valuePtr + 18',

oxidex instead handed the payload to the Leica parser, which claimed the
header as `LeicaLayout::LongHeader`, skipped 15 bytes of it and decoded
nothing -- its own comment recorded that "no corresponding ExifTool table
has been identified". The result was silent: all three bodies reported
zero MakerNote tags where ExifTool reports ~100, with no warning.

The signature is 16 bytes and the IFD begins at 18, so two pad bytes sit
between them. LeicaD-Lux7.jpg opens

    4c 45 49 43 41 20 43 41 4d 45 52 41 20 41 47 00  00 00  9d 00

-- "LEICA CAMERA AG\0", two NULs, then the 157-entry count.
`MakerNoteLeica10` declares no `Base`, so its out-of-line value offsets
are TIFF-header-relative exactly as `MakerNotePanasonic`'s are and need
no adjustment.

`LongHeader` is removed rather than left dead: recognising a signature
this parser has no table for shadowed the parser that does.

Verified against ExifTool 13.59 on the 4,238-file corpus, scored per file
and keyed Group1:Name:

  Leica/LeicaD-Lux7.jpg   0 -> 80 MakerNote tags  (+66 matching ExifTool)
  Leica/LeicaD-Lux8.jpg   0 -> 80                 (+65)
  Leica/LeicaV-Lux5.jpg   0 -> 80                 (+66)

  +197 newly-matching keys, 0 regressions across all 4,238 files
  (matched-key sets diffed per file, before vs after).

  exiftool -G1 -s Leica/LeicaD-Lux7.jpg
      [Panasonic] ImageQuality         : RAW
      [Panasonic] InternalSerialNumber : (XFL) 2018:09:06 no. 0007
      [Panasonic] WhiteBalance         : Auto
  oxidex now prints the same three values.

Note this PR fixes dispatch only. The ~13 remaining value differences per
file (AFPointPosition, RollAngle, PhotoStyle, ...) are pre-existing
`Panasonic::Main` conversion gaps, not new: measured on origin/main they
already differ on up to 212 of the 303 Panasonic JPEGs that oxidex
already parsed. Tracked separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(leica): assert LEICA CAMERA AG is declined, matching Leica10 routing

The inline unit test in leica.rs was updated to the new behaviour but this
integration copy still asserted the old one, so Build & Test failed on the
very change it was meant to cover.

ExifTool's MakerNoteLeica10 (MakerNotes.pm:724-731) matches the signature
alone -- Condition => '$$valPt =~ /^LEICA CAMERA AG\0/' -- and routes to
Panasonic::Main at Start => '$valuePtr + 18', never to a Leica table. So
is_leica_makernote must decline it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(panasonic): drop six fabricated MakerNote registry entries

`registries/panasonic.rs` declared 93 tags. Six did not trace to
`%Image::ExifTool::Panasonic::Main`, and between them oxidex emitted
1,248 tag instances across the 479-file Panasonic corpus that ExifTool
reports none of:

  0x003E TextStamp2 -> TextStamp   (Panasonic.pm:809)
  0x8008 TextStamp3 -> TextStamp   (Panasonic.pm:1568)
  0x8009 TextStamp4 -> TextStamp   (Panasonic.pm:1574)
  0x8007 FlashFired -> deleted     (Panasonic.pm:1563, commented out)
  0x8010 BabyAge2   -> deleted     (id is `BabyAge`, Panasonic.pm:1580)
  0x8012 Transform2 -> deleted     (id is `Transform`, Panasonic.pm:1587)

ExifTool gives four different ids the same name `TextStamp` and two ids
the name `BabyAge`; inventing `Foo2`/`Foo3`/`Foo4` to disambiguate them
produces names that exist in zero ExifTool source files and can never
match real output. 0x003E/0x8008/0x8009 carry ExifTool's own PrintConv
({1=>'Off', 2=>'On'}) so they are renamed in place. 0x8010 and 0x8012
are omitted rather than renamed: 0x8010 is a `string` with a sentinel
PrintConv but was registered as a bare integer, and 0x8012 is an
`int16s` `Count => 2` whose PrintConv keys are integer *pairs*
('0 0' => 'Off', '-3 2' => 'Slim High'), which a single-value On/Off
decoder cannot express. 0x0033 and 0x0059 already deliver the real
`BabyAge` and `Transform` names. 0x8007 is disabled upstream:
`#0x8007 => { #PH - questionable [disabled because it conflicts with
EXIF in too many samples]`.

Two enum decoders were also invented on real tags, printing confidently
wrong values under real ExifTool names:

  0x0077 BurstSpeed       int16u, "images per second", no PrintConv
                          (Panasonic.pm:1094). Printed "Low" where
                          ExifTool prints "0", in 91 files.
  0x009D InternalNDFilter rational64u, no PrintConv
                          (Panasonic.pm:1247). Printed
                          "Unknown (4620)" where ExifTool prints "0".

Both decoders are removed; `BURST_SPEED` and `INTERNAL_ND_FILTER` in
the parser had no other caller and are deleted.

Measured per file over 479 Panasonic samples, ExifTool 13.59 vs oxidex,
keyed Group1:Name (JSON parsed with parse_float=str):

  matched     44668 -> 44759  (+91, BurstSpeed now agrees)
  value_diff   1848 -> 1757   (-91)
  missing      8057 -> 8057   (unchanged)
  extra       12725 -> 11477  (-1248)

No file lost a matched key.

InternalNDFilter still disagrees (76 files): oxidex prints the entry's
value_offset rather than dereferencing the rational. That is the same
pre-existing gap that makes `AFPointPosition` print "3254" for
ExifTool's "0.47 0.48", and is not addressed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(olympus): read the OM System MakerNote and the TextInfo sub-record

Measured against ExifTool 13.59 over the 318-file Olympus corpus, scored
per file on `Group1:Name`. Three entangled `Olympus::Main` gaps:

1. `MakerNoteOlympus3`. The OM-1, OM-3, OM-5, OM-1 Mark II and TG-7 write
   an "OM SYSTEM\0" header (MakerNotes.pm:589-597: `Start => '$valuePtr +
   16'`, `Base => '$start - 16'`). The dispatcher already routed them here
   on `Make = "OM Digital Solutions"`; only `validate_header` rejected
   them, so those five files yielded zero Olympus tags. Adding the
   signature took them from 400 matched / 858 missing to 1101 matched /
   154 missing -- +701 tag instances, no matched key lost.

2. `Olympus::TextInfo` (0x0208). 129 of the 318 files carry a short ASCII
   record -- `[pictureInfo] Resolution=3 [Camera Info] Type=SR951` --
   which ExifTool scans with `APP12::ProcessAPP12` (Olympus.pm:1574).
   The separator is a space, not CR/LF, so the existing APP12 readers in
   `parsers::jpeg::app_segments` would see one token and emit nothing;
   `text_info::scan` ports the tokenizer at APP12.pm:262 instead.
   125 files x {Resolution, CameraType} = 250 tag instances.

3. `Main` 0x0207 `CameraType` and 0x0201 `Quality`, which the table walk
   cannot express: `CameraType` is a `DataMember` that `Quality`'s
   PrintConv consults (Olympus.pm:725), and `TextInfo` carries a second
   `CameraType` that overwrites it. The FE240/SP510UZ/u730/u1000
   placeholder is padded to "NORMAL  ", which is not `eq "NORMAL"`, so
   the Condition passes and ExifTool really does print `Unknown (NORMAL)`
   before TextInfo replaces it -- reproduced rather than special-cased.

Also replaces `TagDef::raw(0x0821, "ISOAutoSettings")`, which printed the
raw "0 0" where ExifTool prints "n/a; n/a", with the two-element list
PrintConv transcribed from ExifTool's own loaded table.

wip: the release binary and test run were cut short by machine load, so
the whole-corpus after-measurement is not yet recorded. Per-item deltas
above are measured; item 1 was verified end to end against a built
binary, items 2 and 3 compile and are unit-tested but unmeasured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant