fix(exif): Flash is a lookup table, not a bitfield - #393
Merged
swackhamer merged 1 commit intoAug 2, 2026
Conversation
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>
This was referenced Aug 2, 2026
swackhamer
added a commit
that referenced
this pull request
Aug 2, 2026
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>
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>
swackhamer
added a commit
that referenced
this pull request
Aug 2, 2026
…lutionUnit table (#390) * 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> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Exif.pm
0x9209isPrintConv => \%flash— a flat 27-key hash — plusFlags => 'PrintHex'.core::exif_enums::decode_flashinstead 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.
How the survivor was established
%Image::ExifTool::Exif::flashdumped straight out of the Perl symbol table, every input in0..=255scored:core::exif_enums::decode_flash(bitwise)Two verbatim copies of the correct table already existed —
FLASH_LABELSinparsers::pdfand the0x9209arm ofparsers::raw::metadata— so the tree held three Flash decoders, and only the one on the main EXIF path was wrong. All three now sharecore::formatters::exif_enums::flash_label.Three classes of wrong answer, all corpus-visible
No flash function(0x38, 0x28)Unknown (0x38),Unknown (0x28)On, Fired, Red-eye reduction(0x49)On, Red-eye reductionPrintHex⇒ hexSame mis-wording for
0x0d,0x0f,0x4d,0x4f,0x50.Why the old test stayed green
test_flash_decodinghad 13 assertions, each annotated with the bit layout it was deriving — and one of them asserted a string ExifTool never prints:The test encoded the same wrong model as the code, so it could not catch it. 23 corpus files reported that exact string.
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. Verified by mutation: restoring the bitwise answers fails four tests, including the pre-existing
raw::metadata::…::flash_print_conv_matches_exiftool_table.Corpus check
4,238 files, ExifTool 13.59
-a -G1 -s, keyedGroup:Name, case-sensitive. Both sides asserted non-vacuous.Spot-check,
FujiFilm/FujiFilmFinePixF605EXR.jpg:Gates:
cargo fmt --all,cargo clippy --workspace(clean),cargo test --workspace(3364 + all integration suites passing).Co-Authored-By: Claude Opus 5 noreply@anthropic.com