Skip to content

fix(cli): parse -TAG=VALUE into the tag's declared type - #368

Merged
swackhamer merged 1 commit into
mainfrom
fix/cli-typed-tag-values
Aug 1, 2026
Merged

fix(cli): parse -TAG=VALUE into the tag's declared type#368
swackhamer merged 1 commit into
mainfrom
fix/cli-typed-tag-values

Conversation

@swackhamer

Copy link
Copy Markdown
Collaborator

The defect

The CLI built every -TAG=VALUE as a String, so no Integer, Rational or DateTime tag could be
set from the command line on any format, JPEG included
:

$ oxidex -ExifIFD:ISO=800 photo.jpg
Error: Invalid value for tag 'ExifIFD:ISO': Type mismatch: expected Integer but got String

Two entry points, one root cause. src/main.rs:146 wrapped every value as TagValue::String; the
batch path (cli::batch_processor::parse_tag_value) guessed a type from the value's shape, so
-IFD0:Artist=800 became Integer(800) and was rejected the other way round. The write path
validates against the type the tag declares, which the registry already carries.

Pre-existing and independent of #358 — verified against origin/main (92e4eaf4).

The fix

src/cli/value_parser.rs resolves the declared type from get_tag_descriptor and parses the
argument into it before it reaches the writer. Both entry points call it.

ExifTool's accepted input forms (established from source, not guessed)

A -TAG=VALUE string in ExifTool 13.55 travels SetNewValueConvInv (Writer.pl:2963) → the
table's CHECK_PROC, which for EXIF/TIFF is CheckValue (Writer.pl:6842-6907). The accepted
shapes are the predicates at ExifTool.pm:5924-5933:

sub IsFloat($) {
    return 1 if $_[0] =~ /^[+-]?(?=\d|\.\d)\d*(\.\d*)?([Ee]([+-]?\d+))?$/;
    return 0 unless $_[0] =~ /^[+-]?(?=\d|,\d)\d*(,\d*)?([Ee]([+-]?\d+))?$/;
    $_[0] =~ tr/,/./;   # but translate ',' to '.'
    return 1;
}
sub IsInt($)      { return scalar($_[0] =~ /^[+-]?\d+$/); }
sub IsHex($)      { return scalar($_[0] =~ /^(0x)?[0-9a-f]{1,8}$/i); }
sub IsRational($) { return scalar($_[0] =~ m{^[-+]?\d+/\d+$}); }
Declared type Accepted ExifTool source
Integer 123, +123; 0x7b and bare 7b (IsHex); 122.6 rounded half-away-from-zero Writer.pl:6873-6883
Rational 1/250; 0.004, 5.6, +5.6, 1e1, 5,6; inf; undef Writer.pl:6888-6900, 5203-5205
Float 5.6, +5.6, 1e1, 5,6no fraction form Writer.pl:6888-6900
DateTime 2024:01:15 10:30:00, 2024-01-15T10:30:00, 20240115103000, 2024:01:15 10:30, trailing Z / +05:00 / .25, now Writer.pl:5012-5151
String anything (CheckValue's string/undef branch imposes no shape) Writer.pl:6847-6858
Binary the argument's literal bytes Writer.pl:6847-6858

Key lines quoted in the module doc comment:

# Writer.pl:6875-6883 (integer)
unless (IsInt($val)) {
    if (IsHex($val)) { $val = $$valPtr = hex($val); }
    else {
        return 'Not an integer' unless IsFloat($val) and $count == 1;
        $val = $$valPtr = int($val + ($val < 0 ? -0.5 : 0.5));
    }
}
# Writer.pl:6890-6899 (rational/float/double)
unless (IsFloat($val)) {
    if ($format =~ /^rational/) {
        next if $val eq 'inf' or $val eq 'undef';
        if ($val =~ m{^([-+]?\d+)/(\d+)$}) { ... }
    }
    return 'Not a floating point number';
}
# Writer.pl:5203-5205 (Rationalize)
return (1, 0) if $val eq 'inf';
return (0, 0) if $val eq 'undef';
return ($1,$2) if $val =~ m{^([-+]?\d+)/(\d+)$}; # accept fractional values
# Writer.pl:5149 (InverseDateTime)
$rtnVal or warn "Invalid date/time (use YYYY:mm:dd HH:MM:SS[.ss][+/-HH:MM|Z])\n";

Rationalize's continued-fraction algorithm (Writer.pl:5182-5228) is ported, so oxidex produces
ExifTool's exact denominators: 5.628/5, 0.0041/250, -0.5-1/2.

On an unparseable value

The write is refused, the file is left byte-identical, and the exit status is non-zero. There is no
fall back to String — that is the same bug with a friendlier face, and it is what ExifTool does
too (Warning: Not an integer for ... / Nothing to do.). Error wording follows ExifTool's:
Not an integer, Not a floating point number, Month '13' out of range 1..12,
Invalid date/time (use YYYY:mm:dd HH:MM:SS[.ss][+/-HH:MM|Z]).

Verification — ExifTool read-back on copies

Every write below was applied to a copy; the value was then read with
exiftool -a -G1 -s [-n], not with oxidex's own reader.

JPEG (Canon EOS-1D X, corpus copy):

Command main this branch
-ExifIFD:ISO=800 ✗ type mismatch ExifTool: 800
-ExifIFD:FNumber=5.6 ✗ type mismatch ExifTool: 5.6
-ExifIFD:FNumber=1/250 ✗ type mismatch ExifTool -n: 0.004
-ExifIFD:ExposureTime=1/250 ✗ type mismatch ExifTool: 1/250
-ExifIFD:ExposureTime=0.004 ✗ type mismatch ExifTool: 1/250
-IFD0:XResolution=300 ✗ type mismatch ExifTool: 300
-ExifIFD:DateTimeOriginal=2024-01-15T10:30:00 ✗ type mismatch ExifTool: 2024:01:15 10:30:00
-IFD0:Artist=Ada Lovelace ✓ (unchanged)
-IFD0:Orientation=6 ✗ type mismatch ExifTool: Rotate 90 CW (-n: 6)

Non-JPEG — one Integer / Rational / DateTime / String each, all read back with ExifTool:
NEF, DNG, CR2, IIQ, RW2 (corpus copies) and tests/fixtures/tiff/complex/big_endian_001.tif
(that fixture carries no ExifIFD, so it proves Integer + Rational + String; NEF and DNG prove
DateTime as well).

Nothing else moved. Full exiftool -a -G1 -s -n dumps before/after:

  • JPEG, -ExifIFD:ISO=800: 382 tags before, 382 after; only ISO, the derived
    Composite:LightValue and IFD1:ThumbnailOffset differ. Thumbnail bytes and the MakerNote block
    are byte-identical (cmp clean) — the offset moves because the surgical writer repacks the EXIF
    segment, which it already did on main.
  • NEF, -ExifIFD:FNumber=5.6: 254 tags before, 254 after; only FNumber and the derived
    Composite:Aperture / Composite:LightValue differ.

Corpus integrity. /tmp/oxidex-exiftool-cache/combined-samples/ was never written to. All 4240
files hashed at session start and again at the end: identical. Cross-checked against the source
tarballs (samples-*.tar.gz extracted fresh): 4044/4044 matched, 0 mismatched, before and after.

Tests

  • 16 unit tests in src/cli/value_parser.rs — each shape predicate against its ExifTool regex, the
    Rationalize port against known ExifTool outputs, and every accept/reject case verified against
    the real exiftool binary first.
  • 18 integration tests in tests/integration/cli_typed_value_tests.rs driving the real binary
    over repo-fixture copies: one tag of each type on JPEG and TIFF; the TIFF cases additionally
    assert the serialized field type (RATIONAL vs ASCII vs SHORT) via scan_exif_entries,
    so a string "300" cannot masquerade as the rational 300/1; batch mode; and unparseable values
    failing loudly with the file left byte-identical.

Gates: cargo fmt --all clean, cargo clippy --workspace clean, cargo test --workspace green
(3316 lib + 522 integration, 0 failures).

Types still not fully settable, and why

  • Struct — refused explicitly; there is no command-line syntax for a nested structure and
    exif_surgical::tag_value_to_field rejects TagValue::Struct anyway.
  • EXIF:ModifyDate / EXIF:CreateDate — the registry declares these String (not
    DateTime), so a value like 2024-01-15T10:30:00 is stored verbatim instead of being normalized
    to ExifTool's 2024:01:15 10:30:00. Registry data gap, not a parser gap; the space-separated form
    is normalized anyway because cli::args::parse_date_shift claims it first. Filed separately.
  • Alias tag keys-ExifIFD:ExposureBiasValue=-0.5 (EXIF 0x9204, which the reader surfaces as
    ExposureCompensation) now reaches the writer correctly typed, but plan_exif_write's
    duplicate-tag-id guard drops it silently while the CLI still reports success. Pre-existing writer
    behaviour that was previously masked by the type error this PR removes. Filed separately; not
    fixed here because loosening that guard risks the write regressions fix(write): land the non-JPEG write path (issue #20) #358 closed.
  • XMP-only tags (e.g. the Float-typed XMP:Exposure2012) parse correctly but the JPEG writer
    does not write XMP — a pre-existing writer gap, unchanged by this PR.

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

The CLI wrapped every `-TAG=VALUE` as `TagValue::String` (`main.rs`) or guessed
a type from the value's own shape (`batch_processor::parse_tag_value`). The
write path validates against the type the registry *declares*, so every
Integer, Rational and DateTime tag was unsettable from the command line on
every format:

    $ oxidex -ExifIFD:ISO=800 photo.jpg
    Error: Invalid value for tag 'ExifIFD:ISO':
           Type mismatch: expected Integer but got String

Values are now parsed into the declared type before they reach the writer, by
`cli::value_parser::parse_cli_tag_value`.

The accepted input forms are ports of ExifTool 13.55, not invented ones. A
`-TAG=VALUE` string in ExifTool travels `SetNewValue` -> `ConvInv`
(Writer.pl:2963) -> the table's CHECK_PROC, which for EXIF/TIFF is `CheckValue`
(Writer.pl:6842-6907); the shapes it accepts are `IsInt` / `IsHex` / `IsFloat`
(ExifTool.pm:5924-5933), and the conversions are `Rationalize`
(Writer.pl:5200-5228) and `InverseDateTime` (Writer.pl:5012-5151):

  Integer   123, +123 (IsInt); 0x7b and bare 7b (IsHex); 122.6 rounded
            half-away-from-zero (IsFloat)      Writer.pl:6873-6883
  Rational  1/250 (IsRational); 0.004, 5.6, 1e1, 5,6 (IsFloat); inf; undef
                                               Writer.pl:6888-6900, 5203-5205
  Float     5.6, +5.6, 1e1, 5,6 -- no fraction form  Writer.pl:6888-6900
  DateTime  2024:01:15 10:30:00, 2024-01-15T10:30:00, 20240115103000,
            2024:01:15 10:30, trailing Z / +05:00 / .25, now
                                               Writer.pl:5012-5151
  String    anything                           Writer.pl:6847-6858
  Binary    the argument's literal bytes       Writer.pl:6847-6858

A value that will not parse for the declared type is refused and the file is
left untouched. It is never downgraded to `TagValue::String` -- that is the
same bug wearing a friendlier face, and it is also what ExifTool does
("Warning: Not an integer for ...", "Nothing to do.").

Proven by ExifTool read-back on copies (`exiftool -a -G1 -s`), one tag of each
type on JPEG (Canon EOS-1D X) and on the TIFF-structured formats PR #358 made
writable (TIFF fixture, NEF, DNG, CR2, IIQ, RW2): every value round-trips and
every untouched tag, the thumbnail and the MakerNote block stay byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@swackhamer
swackhamer merged commit a7d9055 into main Aug 1, 2026
5 checks passed
swackhamer added a commit that referenced this pull request Aug 2, 2026
`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>
swackhamer added a commit that referenced this pull request Aug 2, 2026
…amera model (#392)

* 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>

---------

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>

---------

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>

---------

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>

---------

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>

---------

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