refactor(core): one PrintExposureTime instead of sixteen - #340
Merged
Conversation
`Image::ExifTool::Exif::PrintExposureTime` (Exif.pm:5606) had been ported
into sixteen separate modules -- pdf, raw, minolta, sigma, nikon, canon,
canon/camera_info, pentax, sony/binary, sony/binary_data, phaseone, h264,
two APP12 segments, core/exiftool_compat and composite/compute. Four of
those copies printed a different string than ExifTool for the same seconds,
and because each one carried its own unit tests asserting its own output,
the suite was green on all sixteen.
sub PrintExposureTime($)
{
my $secs = shift;
return $secs unless Image::ExifTool::IsFloat($secs);
if ($secs < 0.25001 and $secs > 0) {
return sprintf("1/%d",int(0.5 + 1/$secs));
}
$_ = sprintf("%.1f",$secs);
s/\.0$//;
return $_;
}
The divergences, all in the branch above 0.25 s:
* `sony/binary.rs` rendered it with `print_float` -- ten decimal places --
instead of `sprintf("%.1f")`. `Sony:ShutterSpeedSetting` is
`2**(6-$val/8)`, so 56 of the 256 reachable raw values printed the full
quotient: `58.6882587651` where ExifTool prints `58.7`, `4.7568284600`
where ExifTool prints `4.8`.
* `pdf/mod.rs`, `sigma.rs` and `sony/binary_data.rs` replaced `s/\.0$//`
with an `secs == secs.trunc()` test taken *before* formatting. That is a
different question: 29.96 is not an integer but `%.1f` rounds it to
"30.0", so those three printed `30.0` against ExifTool's `30`. Over
[0.25 s, 40 s] sampled at 1e-4 that is 39,449 of 375,000 inputs; among
1/8-stop APEX shutter speeds it is 2^4.25 (19.03 s) and 2^6.375 (83.00 s).
* `composite/compute.rs` open-coded the rounding as
`(v*10.0+0.5).floor()/10.0` and printed the result with Rust's `{}`.
That is round-half-up where `%.1f` is round-half-to-even, so an exposure
of 2.25 s printed `2.3` against ExifTool's `2.2`.
The eleven remaining copies were already equivalent; they are deleted for
the same reason -- a helper that exists sixteen times is a helper that will
diverge again.
The new `core::formatters::exif_print_conv` carries the one port, plus the
`&str` wrapper the two APP12 text segments need for `Exif.pm:5609`'s
"return $secs unless IsFloat". Its 22 assertions were checked by calling
`Image::ExifTool::Exif::PrintExposureTime` from ExifTool 13.55 directly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
swackhamer
added a commit
that referenced
this pull request
Aug 1, 2026
`print_fraction` was ported nine times. Four of the nine printed a different string than ExifTool, and each of the four had a unit test asserting its own output. Which ones were wrong was settled the way #340 settled PrintExposureTime: by calling `Image::ExifTool::Exif::PrintFraction` in the installed 13.55 Perl over every 1/32, 1/8, 1/6, 1/3 and 1/2 EV step real cameras write, plus %g boundary values, rather than by reading Exif.pm and reimplementing it. Over the 1,538 swept inputs with |val| <= 20: panasonic.rs {:+} 1377 wrong nikon/binary_data.rs {:+.3} 476 wrong raw/metadata.rs {:+.3} 430 wrong minolta_tables.rs {:+.3} 430 wrong pdf / canon / minolta_makernote / nikon/value_reader / flash_info 0 The failure is always the last branch, Exif.pm:5436 `sprintf("%+.3g",$val)`. `%+.3g` is three SIGNIFICANT digits; `{:+.3}` is three DECIMAL places. They agree only while the value has one digit before the point, which is exactly the range the four copies' own tests covered: input {:+.3} ExifTool -19.875 -19.875 -19.9 -18.750 -18.750 -18.8 1.3264 +1.326 +1.33 `panasonic.rs` used a bare `{:+}` and printed `-19.875198750000003` where ExifTool prints `-19.9`. Why the tests stayed green: `raw/metadata.rs`'s test is named `print_fraction_matches_perl` and `minolta_tables.rs`'s `print_fraction_matches_exiftool`, but between them they assert only 0, 1, -2, +-1/2 and +-1/3 -- every one of which returns from the integer, /2 or /3 branch. Not a single input in either test reaches the branch that was wrong. The replacement defers its `%g` to `numeric_precision::perl_g`, the one `%g` implementation, because the exponent `%g` switches on is the one the value has AFTER rounding -- three of the old copies derived it from `log10` of the unrounded value instead. It was checked against the Perl over 5,197 inputs with zero divergences. Also wires the conversion in where ExifTool has it and oxidex did not: 0x9204 ExposureCompensation/ExposureBiasValue (Exif.pm:2347) was falling through to Rule 20's plain %.10g quotient. Deletes five helpers left with no callers (`format_signed_g3`, `sign_char` and `trim_zeros` in pdf, `format_g3` in nikon/value_reader, `format_significant_3` in canon); `allow(dead_code)` in two of those files had been hiding them from the compiler. Measured on 148 real files against `exiftool -a -G1 -s`, keyed on the full Group:Name, both branches built from b0b86fc: match 8761 -> 8764 value_diff 399 -> 396 missing 2081 (unchanged) exact-match coverage 77.9379% -> 77.9646% (+0.0267pp) All three are ExifIFD:ExposureCompensation; no key regressed. The corpus impact is small because it holds no Panasonic, Minolta or Nikon-binary samples to exercise the three worst copies -- the 5,197-input Perl sweep, not this corpus, is the evidence that they were wrong. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
swackhamer
added a commit
that referenced
this pull request
Aug 2, 2026
`ExifIFD:ExposureTime` was rendered by a hand-written formatter in
`tag_conversion.rs` that split at one second. ExifTool splits at a
quarter second (Exif.pm:5606, reached from the tag's
`PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at
Exif.pm:1824):
sub PrintExposureTime($)
{
my $secs = shift;
return $secs unless Image::ExifTool::IsFloat($secs);
if ($secs < 0.25001 and $secs > 0) {
return sprintf("1/%d",int(0.5 + 1/$secs));
}
$_ = sprintf("%.1f",$secs);
s/\.0$//;
return $_;
}
Three divergences, established by executing that subroutine from the
installed ExifTool 13.55 rather than by reading it:
seconds ExifTool was now
0.5555555556 0.6 1/2 0.6
0.769230769 0.8 1/1 0.8
0.8 0.8 1/1 0.8
4.0 4 4.0 4
30.0 30 30.0 30
0 0 1/18446744073709551615 0
The first class is the damaging one: every exposure in [0.25001, 1)
printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s
exposure is a perfectly plausible shutter speed, so nothing downstream
could tell it was wrong. The second is the trailing `.0` that `s/\.0$//`
strips. The third divided by a zero `$secs`.
`core::formatters::print_exposure_time` is already a verified port of
the subroutine -- it reproduces all fourteen probe values exactly -- so
this deletes the private copy and calls it. #340 consolidated sixteen
copies of PrintExposureTime but did not reach this one, which is the
copy that renders the EXIF ExposureTime tag itself.
Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus,
keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80"
cannot silently become 1.8:
differing (file,tag) pairs 40,917 -> 40,814 (-103)
fixed 109
ExifIFD:ExposureTime 78
Composite:ShutterSpeed 20
Composite:LightValue 10
IFD0:ExposureTime 1
newly differing 6
The six are all `Composite:LightValue`, and they are collateral from a
separate, pre-existing defect: the composite layer parses the *printed*
ExposureTime string instead of the ValueConv seconds, so it now rounds
from the correct `0.6` where it used to round from `1/2`. Ten other
LightValue files move the right way for the same reason (net -4). The
same defect is visible in `Composite:ISO`, which consumes the
PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712
(Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a
composite-layer fix and is left for its own change.
Two ExposureTime classes remain out of scope here, both in the rational
pipeline rather than the PrintConv: ExifTool rounds rational64u to ten
significant figures before the conversion (ExifTool.pm:6114), which is
why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a
zero denominator should print `undef`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
swackhamer
added a commit
that referenced
this pull request
Aug 2, 2026
) * fix(composite): preserve raw APEX conversion precision * fix(fleet): require current-head PR approval * fix(tables): decode repeated scalar fields * fix(exif): one Compression table, dumped from ExifTool's own Perl Three transcriptions of `%Image::ExifTool::Exif::compression` (Exif.pm:213) lived in this tree, carrying 50, 40 and 53 ids and disagreeing on the spelling of codes all three claimed to know: core/formatters/exif_enums.rs 32766 => "Next" parsers/tiff/tiff_enums.rs 32766 => "Next" parsers/pdf/mod.rs 32766 => "NeXt or Sony ARW Compressed 2" ExifTool 13.59 says: 32766 => 'NeXt or Sony ARW Compressed 2', #3/Milos Rather than read the Perl and re-implement it -- the way three divergent copies got written in the first place -- the hash was dumped straight out of the Perl symbol table: perl -Ilib -MImage::ExifTool::Exif -e ' my $h = \%Image::ExifTool::Exif::compression; for my $v (0..70000) { print "$v\t", (exists $h->{$v} ? $h->{$v} : "Unknown ($v)"), "\n" }' and each copy scored over all 70,001 inputs: exif_enums::format_compression 50 ids 10 wrong tiff_enums 0x0103 arm 40 ids 15 wrong pdf COMPRESSION_LABELS 53 ids 1 wrong consolidated table 53 ids 0 wrong The wrong answers, all now fixed: `9` read `JBIG B&W` (13.59 renamed it `JBIG B&W or VC-5`); `32766` read `Next`; `33003`, `33004` and `33005` all collapsed to `Aperio JPEG 2000 YCbCr`, where 33005 is `Aperio JPEG 2000 RGB` and 33004 is not an ExifTool key at all; `34926`/`34927` dropped the ` (old)` suffix that separates them from the LibTiff 4.7 codes; and `50000` `Zstd`, `50001` `WebP`, `50002` `JPEG XL (old)`, `52546` `JPEG XL`, plus `32772`, `33003`, `34887`, `34925`, `34933`, `34934` on the TIFF path, were absent. `compression_label` returns `Option<&'static str>` so each caller keeps its established unknown-value behaviour: `format_compression` renders `Unknown (N)` the way `PrintConv` does, while the TIFF and PDF paths still return `None` rather than invent a label ExifTool never prints. Why the old tests stayed green: `test_compression` asserted 1, 6 and 7 -- three codes all three tables agreed on -- and `tiff_enums.rs` had no test module at all. Not one input reached a wrong branch. The new tests assert every one of the 15 codes that was measurably wrong; reverting the table makes 5 of them fail. Corpus check (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`): 393,343 correct before and after, matched-set delta fixed=0 regressed=0. The corpus stores only Compression 1/4/5/6/7/34713 plus two codes ExifTool does not name, so it exercises none of the divergent ids -- the divergence is measured against the Perl, and the corpus proves the consolidation costs nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(exif): Flash is a lookup table, not a bitfield (#393) Exif.pm 0x9209 is `PrintConv => \%flash` -- a flat 27-key hash -- plus `Flags => 'PrintHex'`. `core::exif_enums::decode_flash` instead synthesised the label from the byte's bit fields (fired / strobe return / mode / function present / red-eye). Those bits explain how ExifTool's 27 codes were *chosen*; they are not how the tag is rendered, and 229 of the 256 byte values are not valid Flash codes at all. Scored against `%Image::ExifTool::Exif::flash` dumped out of the Perl symbol table, over every input in 0..=255: core::exif_enums::decode_flash 236 wrong / 256 consolidated table 0 wrong / 256 Two verbatim copies of the correct table already existed -- `FLASH_LABELS` in `parsers::pdf` and the `0x9209` arm of `parsers::raw::metadata` -- so the tree held three Flash decoders, and only the one on the main EXIF path was wrong. All three now share `core::formatters::exif_enums::flash_label`. Three classes of wrong answer, all corpus-visible: - codes ExifTool leaves unknown got a plausible label. 0x38 and 0x28 both came back `No flash function`; ExifTool prints `Unknown (0x38)` and `Unknown (0x28)` -- 60 files. - codes it did know were mis-worded: 0x49 gave `On, Fired, Red-eye reduction` where the hash says `On, Red-eye reduction` -- 23 files. Same for 0x0d, 0x0f, 0x4d, 0x4f, 0x50. - `format_flash`'s unknown form was decimal; `PrintHex` makes it hex. Why the old test stayed green: `test_flash_decoding` had 13 assertions, each annotated with the bit layout it was deriving -- and one of them, `decode_flash(0x49) == "On, Fired, Red-eye reduction"`, asserted a string ExifTool never prints. The test encoded the same wrong model as the code. It is rewritten to check the hash, and three new tests cover the mis-worded codes, the hex unknown form, and the 27-of-256 key count; reverting the table fails four of them. Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`): BEFORE correct=393343 valdiff=17058 score=77.5097% AFTER correct=393467 valdiff=16934 score=77.5341% MATCHED-SET DELTA fixed=124 regressed=0 PER-FILE 124 files +1, 0 down FORMAT JPEG 390485 -> 390609 (+124) Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(exif): FocalPlaneResolutionUnit decodes instead of printing its code (#395) Exif.pm 0xa210 declares a PrintConv: 0xa210 => { Name => 'FocalPlaneResolutionUnit', Notes => 'values 1, 4 and 5 are not standard EXIF', PrintConv => { 1 => 'None', 2 => 'inches', 3 => 'cm', 4 => 'mm', 5 => 'um' }, }, The tree already held that table -- in `parsers::pdf`, reachable only from a PDF's embedded TIFF thumbnail. The main EXIF path had no arm for the tag at all, so `format_tag_value` returned the raw integer and 1,098 files in the sample corpus reported `2` and `3` where ExifTool reports `inches` and `cm`. The table moves to `core::formatters::exif_enums` next to the Compression and Flash tables, PDF reads it from there, and the `exiftool_compat` dispatch gains the arm it was missing. `Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an exclusion: ExifTool decodes all five, and the corpus holds 4 files at `4` and 1 at `5`. 0xa210 carries no `PrintHex`, so unnamed codes stay decimal -- `Unknown (0)`, which 4 corpus files report. Safe for the composites: `Exif ScaleFactor35efl` -- which gates FocalLength35efl, CircleOfConfusion, HyperfocalDistance, FOV and DOF -- already matches either spelling (`Some("3") | Some("cm")`). Measured: zero `Composite:*` values changed anywhere in the corpus. A unit test on the new function would not have caught this: the table already existed and was already correct. The regression test asserts the *dispatch*, through `format_tag_value`; deleting the new arm fails it with `left: Integer(1), right: String("None")`. Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`): BEFORE correct=393467 valdiff=16934 score=77.5341% AFTER correct=394565 valdiff=15836 score=77.7505% MATCHED-SET DELTA fixed=1098 regressed=0 PER-FILE 1098 files +1, 0 down FORMAT JPEG +1094, DNG +1, CR2 +1, RAF +1, BPG +1 Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(write): report the dropped write instead of printing success `plan_exif_write` may emit only one IFD record per tag id, so when two metadata-map keys resolve to the same id the second cannot be written. The guard that enforced this used `continue`, discarding the caller's edit while the CLI still printed "1 image files updated" and exited 0: $ oxidex -ExifIFD:ExposureBiasValue=-0.5 photo.jpg # 0x9204 1 image files updated # exit 0 $ exiftool -a -G1 -s -ExposureCompensation photo.jpg [ExifIFD] ExposureCompensation : -3/2 # unchanged Before #368 this failed loudly with a type error; #368 fixed the typing and the failure went quiet. ExifTool refuses the same command. `ExposureBiasValue` is not a writable tag name in any of its tables -- only a Notes remark on 0x9204 ("called ExposureBiasValue by the EXIF spec", Exif.pm:2369) and an XMP property whose tag name is again ExposureCompensation (XMP.pm:2096-2097); `TagLookup.pm` has no `exposurebiasvalue` key, so `TagExists` fails and `SetNewValue` returns "Tag '...' is not defined" (Writer.pl:581-584) with exit 1. A collision that would lose an edit is now refused with a message naming both keys and the id; a collision that loses nothing -- the same value already planned under another spelling, which is what the documented `-EXIF:Tag=value` syntax produces -- is still skipped silently. Two further defects fall out of the same guard: * `-EXIF:ExposureTime=1/250` wrote 0x829A into *both* IFD0 and ExifIFD. An "EXIF:" key names the tag family, not a physical IFD, so the collision search had to span IFD0/ExifIFD/GPS rather than only the IFD0 fallback. * `-ExifIFD:ExposureCompensation=-0.5` serialized ASCII "-0.5" into a rational64s tag, because the registry declared 0x9204 `String`. Corrected, along with 46 other EXIF tags whose declared type disagreed with ExifTool's `Writable`, by adding the missing `type:` to the YAML tag database. The set was derived mechanically from ExifTool 13.59's own Perl tables and restricted to scalar numeric/date tags with no ValueConv and no blob flag -- multi-value, ValueConv and `undef`-storage tags are deliberately untouched, since for those ExifTool's accepted input type comes from PrintConvInv/ValueConvInv rather than the storage code (FileSource's Integer, for one, is already correct). ModifyDate and CreateDate are among the 47: both now normalize `2024-01-15T10:30:00` to ExifTool's stored `2024:01:15 10:30:00`. #358's write path is untouched: a binary built from origin/main and one built from this branch produce byte-identical output on 24 files (9 corpus RAW/TIFF/ PNG/JPS + 12 corpus JPEGs + 3 fixtures) for an ordinary `-IFD0:Artist=` write, and all 11 files from #358's table still accept writes with no metadata lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(pentax): enter the five MakerNote sub-directories gated on the camera model `exiftool -v2` descends into 563 of the corpus's files with a Pentax or FujiFilm MakerNote. Comparing per file against `exiftool -a -G1 -s` (ExifTool 13.59), five of the directories it enters produced no oxidex row at all -- not a wrong value, not a missing-tag report, nothing, because a directory that is never entered has no rows to be missing: tag ExifTool table files tags 0x0216 %Pentax::BatteryInfo 30 129 0x021f %Pentax::AFInfo 28 111 0x022a %Pentax::FilterInfo 18 36 0x03ff %Pentax::TempInfo 8 22 0x0226 %Pentax::ShotInfo 6 6 These are plain `ProcessBinaryData` records under a MakerNote oxidex already reads, so the transcription is the generator's job. What kept them out is that almost every field in them carries an ExifTool `Condition` on the camera model, and `%Pentax::BatteryInfo` is an arrayref of such alternatives at nearly every offset -- the construct `codegen_subdirs.py` logged as "arrayref of Condition variants" and refused. Byte 2 of that record alone is `BodyBatteryADNoLoad` on a K10D with one calibration `PrintConv`, an uncalibrated `BodyBatteryADNoLoad` on a *istD, half of an `int16u` `BodyBatteryVoltage1` on a K-5, and `BodyBatteryState` on a K-3 III (Pentax.pm:4846-4935). Read without the condition it is one number under four different real tag names. So the generator grew a vocabulary rather than a special case: - `Condition` over `$$self{Model}` -- `=~`, `!~`, `eq`, and the `A and B` conjunction Pentax.pm:4864 uses to hold the K-3 III out of a pattern that would otherwise catch it. The regexes are expanded to literal alternations at generation time (32 branches, `\*ist` and `GX-1[LS]?` and `K-(1|01|3|5|7|30|50|70|500|r|x|S[12])` included) by a parser that raises on any construct it has not been taught, so the model test stays derived from ExifTool's own pattern text instead of a hand-typed list. - alternatives as several `Field`s sharing one ExifTool key, in ExifTool's order; the decoder takes the first whose condition holds and reports nothing when none does. A key any of whose alternatives is refused is dropped whole -- removing an earlier one would let a later, broader one answer for a body it was never meant to describe. - `PrintConv` as a Perl expression, and `ValueConv` composed with it, which is what makes a raw 686 of centivolts print "6.86 V" rather than "686.00 V". - `Field` now carries ExifTool's own key text, because `545`, `545.1` and `545.2` are three masked tags at one offset while two entries keyed `2` are two readings of one tag. 0x021f and 0x0216 declare `ByteOrder => 'BigEndian'` on the SubDirectory and are read that way whatever the MakerNote's own order is (Pentax.pm:2983-2986, :2949). 0x022a is one table read either way round, chosen by `$$self{Make} =~ /^RICOH/` (Pentax.pm:3032) -- the brand, not the model -- so `PentaxParser` now carries that test from the dispatcher rather than guessing it from the model. Every constant is quoted from Pentax.pm in the code; the unit tests replay the exact record bytes `exiftool -v3` prints for PentaxK10D.jpg and PentaxK-5IIs.jpg against the exact values `exiftool -a -G1 -s` reports for those files. Regenerating reproduces the committed tables byte for byte, and the FujiFilm and Panasonic tables are field-identical to before apart from the two new columns. Measured per file over /tmp/oxidex-exiftool-cache/combined-samples against ExifTool 13.59, keyed `Group1:Name`, both binaries built from this tree: 563 Pentax/FujiFilm files matched 45,133 -> 45,437 (+304 across 31 files) missing 12,375 -> 12,065 regressions 0 PentaxK-5IIs.jpg 197 -> 214 PentaxK-5.jpg 187 -> 204 PentaxK-r.jpg 184 -> 197 PentaxK10D.jpg 148 -> 159 Six values on Pentax_istD.jpg are wrong, and are named here rather than buried: that file's MakerNote offsets need ExifTool's `FixBase` correction (ExifTool resolves its 0x0216 pointer 16 bytes below the TIFF header), which oxidex does not implement -- so all of its out-of-line values are misread today, 75 of them before this change. The six new ones are the same pre-existing fault reaching six more names, not a new one. Still not entered, with the generator's own reason: `%Pentax::CameraSettings` (a hand-written arm already owns 0x0205 and covers tags the generator refuses), `FilterInfo`'s 20 `DigitalFilterNN` fields (a `RawConv` unpack idiom), `%Pentax::AEInfo`/`AEInfo2`/`AEInfo3` (`PentaxEv` conversions), `%Pentax::CAFPointInfo` (`DecodeAFPoints`), and every `BITMASK` `PrintConv`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(apple): find the MakerNote IFD, then read its binary plists `MakerNotes.pm:37-46` starts the Apple directory at `$valuePtr + 14` -- ten bytes of `Apple iOS\0`, two version bytes, then the two-byte order marker. oxidex started it at byte 10, so on every iPhone it read the order marker "MM" as an entry count of 1 and decoded one entry out of the count field and the first tag id, arriving at tag 0x4d4d, which no table has. The result was not a wrong value; it was silence. **Zero `Apple:` tags over the whole sample corpus**, against the 831 ExifTool reports. That is why the binary plists could not be reached. Four `%Apple::Main` tags hold a whole `bplist00` blob rather than a value: 0x0003 RunTime SubDirectory over %Apple::RunTime, whose PROCESS_PROC is PLIST::ProcessBinaryPLIST (Apple.pm:40-43, :324-325) 0x0040 SemanticStyle ValueConv => \&ConvertPLIST (Apple.pm:276) 0x0041 SemanticStyleRenderingVer ditto (Apple.pm:280) 0x0042 SemanticStylePreset ditto (Apple.pm:284) This adds: - `shared/binary_plist.rs`, a transcription of `Image::ExifTool::PLIST` -- `ProcessBinaryPLIST`'s trailer and offset table (PLIST.pm:398-450) and `ExtractObject`'s object grammar (PLIST.pm:260-390), plus `XMP::SerializeStruct` (XMPStruct.pl:34-69) for the `ConvertPLIST` case. Three details decide whether the output matches: * integers are read **unsigned**: `%readProc` is Get8u/16u/24u/32u/64u (PLIST.pm:30-38), so `RunTimeValue` is 235706184764708 and not a negative number. * a size that `%readProc` has no entry for returns undef rather than a guess -- so only 1/2/3/4/8-byte integers and 4/8-byte reals decode. * `SerializeStruct` walks `OrderedKeys`, which for a hash built by `ExtractObject` is a plain `sort keys`. `Apple_iPhone13Pro.jpg` stores its `SemanticStyle` keys in the order 3,1,2,0 and ExifTool prints `{_0=1,_1=0.5,_2=0,_3=2}`. Storage order would print the reverse. A plist date is decoded and then dropped: ExifTool converts it with `ConvertUnixTime($val + 11323*24*3600, 1)`, and that second argument is `$toLocal`, so the string carries the extracting machine's time zone. No Apple blob in the corpus has one, and a value that depends on the reader's clock is not one to approximate. - `%Apple::Main` itself, transcribed into `apple.rs` -- each entry's `Writable` format and its `PrintConv` verbatim, walked by the existing `shared::table_ifd`. The table's twelve `Unknown => 1` entries are absent on purpose; ExifTool reports those only under `-u`. - `Composite:RunTimeSincePowerUp` (Apple.pm:348-357), which was declared in `composite/tables.rs` with no arm in `compute`. Its `ConvertDuration` PrintConv already existed, privately, inside the MTS parser; it moves to `core::formatters::duration` rather than being copied. Three defects found on the way, each measured: - `table_ifd::print_rational` printed `%.15g`. `GetRational64s`/`u` (ExifTool.pm:6107-6120) end in `RoundFloat($num/$den, 10)`, so ExifTool prints `AccelerationVector` as `-0.9245480894`, not `-0.924548089390588`. Fixing it also gained `Olympus:FocalPlaneDiagonal` (+5) and `Olympus:DigitalZoom` (+3). - `table_ifd` had no `int64u`/`int64s` (ExifTool format codes 16 and 17). `Apple_iPhone15Pro.jpg` stores `LivePhotoVideoIndex` as `int64u[1]` = 4294967700; an int64u above `i64::MAX` is dropped rather than printed negative. - `parse_exif_subifd` kept only the *last* 0x927C entry. `Apple_iPhone6.jpg` declares two -- the camera's, and a 142-byte UTF-16 JSON blob an editor appended -- so the Apple parser was handed the wrong 142 bytes and reported nothing. Each entry is now processed in turn, as ExifTool does. Deleted `registries/apple.rs`. It declared a `FrontFacingCamera` at 0x0032 and a `PortraitMode` at 0x0020 -- `%Apple::Main` has no tag at 0x0032 at all, and 0x0020 is `ImageCaptureRequestID` -- plus enum decoders for `OISMode`, `SemanticStyle`, `SignalToNoiseRatioType` and `GreenGhostMitigationStatus`, none of which has a `PrintConv` in Apple.pm. `tests/integration.rs` never declared `apple_makernotes_tests`, so the file asserting those names never compiled; it is now declared, and rewritten against bytes dumped from real corpus files with `exiftool -v3`. Verified over /tmp/oxidex-exiftool-cache/combined-samples (4,238 files; ExifTool 13.59 reports on 4,228) against `exiftool -a -G1 -s`, keyed `Group1:Name`, case-sensitive, scored **per file**. Base is this branch's merge-base, 5a8b835: matched 393,562 -> 394,451 (+889 across 55 files) regressions 0 -- matched-set diff per file, not totals files worsened 0; extra keys unchanged at 108,146 Apple: keys emitted 0 -> 831, every one byte-identical to ExifTool of the +889: 267 plist-derived, 622 unlocked by the IFD start, 8 Olympus Apple_iPhone13Pro.jpg 126 -> 155 Apple_iPhone13ProMax.jpg 99 -> 127 Apple_iPhone15Pro.jpg 89 -> 116 Apple_iPhone12Pro.jpg 125 -> 152 Nothing is left over: every `Apple:` tag ExifTool reports for any corpus file, and `Composite:RunTimeSincePowerUp`, now matches on every file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(vrd): read the CanonVRD Ver2 picture-style block A .VRD recipe written by DPP 2.0 or later carries a second edit section after the fixed 0x272-byte VRD1 record. oxidex read VRD1 and stopped, so combined-samples/CanonVRD.vrd reported 43 of the 108 tags ExifTool does; the missing 65 are the whole of %CanonVRD::Ver2's picture-style block -- PictureStyle, IsCustomPictureStyle, and a nine-tag group per style. Reaching them needs two things. ProcessEditData sizes the three %CanonVRD::Edit sections three different ways (CanonVRD.pm:1596-1610), and the middle one, VRDStampTool, takes its length from an int32u at its own start; skipping it wholesale lands VRD2 four bytes early. And the record itself is FORMAT => 'int16s', so a tag ID is an index rather than a byte offset and the values are signed -- StandardRawColorTone reads -4 on this file, which unsigned would print as 65532. No table is transcribed for this. src/exiftool_tables already carries CanonVRD::Ver2 dumped from ExifTool's own in-memory hash, layout and PrintConv enums included, so the decoder reads that. Ver2 is only read as far as index 0x54. Past it ExifTool leans on ValueConv -- $val/0x400 rendered as a percentage, $val/10, $val/100 -- plus a DataMember-gated SubDirectory at 0xe0 and VRDVersion-conditional branches at 0x5e-0x60. The generator drops a conversion it cannot reproduce exactly, which leaves the raw value behind a real tag name, so emitting those would print a confident wrong number rather than nothing. They stay unread; a test asserts every entry below the bound is a bare int16s or an integer enum, so a future ExifTool release cannot quietly move one across it. Measured on combined-samples/CanonVRD.vrd: 43 matched -> 108 matched, 65 missing -> 0, value-diffs 0, extras 0. The 43 tags already matched are matched by the same values (set comparison, not counts), and ExifTool.jpg and CanonRaw.crw are byte-identical before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(lfp): read Lytro light-field metadata oxidex named the LFP file type but had no FileFormat variant, so format_dispatch fell through to the unsupported arm and the file yielded no tags at all. This adds the variant, the \x89LFP magic that routes to a parser, and a reader transcribed from Image::ExifTool::Lytro (Lytro.pm 1.04). The container walk follows ProcessLFP (Lytro.pm:134-174): 16-byte segment headers, a big-endian length, an 80-byte sha1 ExifTool discards, then a body that is either JSON metadata or an embedded JPEG, padded to 16 bytes. Tag names come from ExtractTags (Lytro.pm:104-128), which flattens the JSON with ucfirst on each key, drops punctuation while upcasing what follows, and strips a leading Devices. Numbers keep their source token text. These files carry more precision than an f64 roundtrip preserves -- "gamma" : 0.41666001081466674805 is the literal bytes on disk -- and ExifTool reports such values unchanged because it never reformats what it did not compute. Only the tags carrying a ValueConv or PrintConv are parsed to f64. Measured on the ExifTool corpus sample (per file, keyed Group1:Name, File/System excluded): Lytro.lfp matched 0 -> 95 missing 95 -> 0 extra 0 -> 0 That is 85 Lytro tags plus the 10 Composite tags, which the existing composite engine derives once the base tags exist. Zero regressions: the matched key sets for CanonVRD.vrd, HTML.html and LNK.lnk are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(exif): print ExposureTime through ExifTool's PrintExposureTime `ExifIFD:ExposureTime` was rendered by a hand-written formatter in `tag_conversion.rs` that split at one second. ExifTool splits at a quarter second (Exif.pm:5606, reached from the tag's `PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at Exif.pm:1824): sub PrintExposureTime($) { my $secs = shift; return $secs unless Image::ExifTool::IsFloat($secs); if ($secs < 0.25001 and $secs > 0) { return sprintf("1/%d",int(0.5 + 1/$secs)); } $_ = sprintf("%.1f",$secs); s/\.0$//; return $_; } Three divergences, established by executing that subroutine from the installed ExifTool 13.55 rather than by reading it: seconds ExifTool was now 0.5555555556 0.6 1/2 0.6 0.769230769 0.8 1/1 0.8 0.8 0.8 1/1 0.8 4.0 4 4.0 4 30.0 30 30.0 30 0 0 1/18446744073709551615 0 The first class is the damaging one: every exposure in [0.25001, 1) printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s exposure is a perfectly plausible shutter speed, so nothing downstream could tell it was wrong. The second is the trailing `.0` that `s/\.0$//` strips. The third divided by a zero `$secs`. `core::formatters::print_exposure_time` is already a verified port of the subroutine -- it reproduces all fourteen probe values exactly -- so this deletes the private copy and calls it. #340 consolidated sixteen copies of PrintExposureTime but did not reach this one, which is the copy that renders the EXIF ExposureTime tag itself. Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus, keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80" cannot silently become 1.8: differing (file,tag) pairs 40,917 -> 40,814 (-103) fixed 109 ExifIFD:ExposureTime 78 Composite:ShutterSpeed 20 Composite:LightValue 10 IFD0:ExposureTime 1 newly differing 6 The six are all `Composite:LightValue`, and they are collateral from a separate, pre-existing defect: the composite layer parses the *printed* ExposureTime string instead of the ValueConv seconds, so it now rounds from the correct `0.6` where it used to round from `1/2`. Ten other LightValue files move the right way for the same reason (net -4). The same defect is visible in `Composite:ISO`, which consumes the PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712 (Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a composite-layer fix and is left for its own change. Two ExposureTime classes remain out of scope here, both in the rational pipeline rather than the PrintConv: ExifTool rounds rational64u to ten significant figures before the conversion (ExifTool.pm:6114), which is why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a zero denominator should print `undef`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
swackhamer
added a commit
that referenced
this pull request
Aug 2, 2026
…399) * fix(composite): preserve raw APEX conversion precision * fix(fleet): require current-head PR approval * fix(tables): decode repeated scalar fields * fix(exif): one Compression table, dumped from ExifTool's own Perl Three transcriptions of `%Image::ExifTool::Exif::compression` (Exif.pm:213) lived in this tree, carrying 50, 40 and 53 ids and disagreeing on the spelling of codes all three claimed to know: core/formatters/exif_enums.rs 32766 => "Next" parsers/tiff/tiff_enums.rs 32766 => "Next" parsers/pdf/mod.rs 32766 => "NeXt or Sony ARW Compressed 2" ExifTool 13.59 says: 32766 => 'NeXt or Sony ARW Compressed 2', #3/Milos Rather than read the Perl and re-implement it -- the way three divergent copies got written in the first place -- the hash was dumped straight out of the Perl symbol table: perl -Ilib -MImage::ExifTool::Exif -e ' my $h = \%Image::ExifTool::Exif::compression; for my $v (0..70000) { print "$v\t", (exists $h->{$v} ? $h->{$v} : "Unknown ($v)"), "\n" }' and each copy scored over all 70,001 inputs: exif_enums::format_compression 50 ids 10 wrong tiff_enums 0x0103 arm 40 ids 15 wrong pdf COMPRESSION_LABELS 53 ids 1 wrong consolidated table 53 ids 0 wrong The wrong answers, all now fixed: `9` read `JBIG B&W` (13.59 renamed it `JBIG B&W or VC-5`); `32766` read `Next`; `33003`, `33004` and `33005` all collapsed to `Aperio JPEG 2000 YCbCr`, where 33005 is `Aperio JPEG 2000 RGB` and 33004 is not an ExifTool key at all; `34926`/`34927` dropped the ` (old)` suffix that separates them from the LibTiff 4.7 codes; and `50000` `Zstd`, `50001` `WebP`, `50002` `JPEG XL (old)`, `52546` `JPEG XL`, plus `32772`, `33003`, `34887`, `34925`, `34933`, `34934` on the TIFF path, were absent. `compression_label` returns `Option<&'static str>` so each caller keeps its established unknown-value behaviour: `format_compression` renders `Unknown (N)` the way `PrintConv` does, while the TIFF and PDF paths still return `None` rather than invent a label ExifTool never prints. Why the old tests stayed green: `test_compression` asserted 1, 6 and 7 -- three codes all three tables agreed on -- and `tiff_enums.rs` had no test module at all. Not one input reached a wrong branch. The new tests assert every one of the 15 codes that was measurably wrong; reverting the table makes 5 of them fail. Corpus check (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`): 393,343 correct before and after, matched-set delta fixed=0 regressed=0. The corpus stores only Compression 1/4/5/6/7/34713 plus two codes ExifTool does not name, so it exercises none of the divergent ids -- the divergence is measured against the Perl, and the corpus proves the consolidation costs nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(exif): Flash is a lookup table, not a bitfield (#393) Exif.pm 0x9209 is `PrintConv => \%flash` -- a flat 27-key hash -- plus `Flags => 'PrintHex'`. `core::exif_enums::decode_flash` instead synthesised the label from the byte's bit fields (fired / strobe return / mode / function present / red-eye). Those bits explain how ExifTool's 27 codes were *chosen*; they are not how the tag is rendered, and 229 of the 256 byte values are not valid Flash codes at all. Scored against `%Image::ExifTool::Exif::flash` dumped out of the Perl symbol table, over every input in 0..=255: core::exif_enums::decode_flash 236 wrong / 256 consolidated table 0 wrong / 256 Two verbatim copies of the correct table already existed -- `FLASH_LABELS` in `parsers::pdf` and the `0x9209` arm of `parsers::raw::metadata` -- so the tree held three Flash decoders, and only the one on the main EXIF path was wrong. All three now share `core::formatters::exif_enums::flash_label`. Three classes of wrong answer, all corpus-visible: - codes ExifTool leaves unknown got a plausible label. 0x38 and 0x28 both came back `No flash function`; ExifTool prints `Unknown (0x38)` and `Unknown (0x28)` -- 60 files. - codes it did know were mis-worded: 0x49 gave `On, Fired, Red-eye reduction` where the hash says `On, Red-eye reduction` -- 23 files. Same for 0x0d, 0x0f, 0x4d, 0x4f, 0x50. - `format_flash`'s unknown form was decimal; `PrintHex` makes it hex. Why the old test stayed green: `test_flash_decoding` had 13 assertions, each annotated with the bit layout it was deriving -- and one of them, `decode_flash(0x49) == "On, Fired, Red-eye reduction"`, asserted a string ExifTool never prints. The test encoded the same wrong model as the code. It is rewritten to check the hash, and three new tests cover the mis-worded codes, the hex unknown form, and the 27-of-256 key count; reverting the table fails four of them. Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`): BEFORE correct=393343 valdiff=17058 score=77.5097% AFTER correct=393467 valdiff=16934 score=77.5341% MATCHED-SET DELTA fixed=124 regressed=0 PER-FILE 124 files +1, 0 down FORMAT JPEG 390485 -> 390609 (+124) Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(exif): FocalPlaneResolutionUnit decodes instead of printing its code (#395) Exif.pm 0xa210 declares a PrintConv: 0xa210 => { Name => 'FocalPlaneResolutionUnit', Notes => 'values 1, 4 and 5 are not standard EXIF', PrintConv => { 1 => 'None', 2 => 'inches', 3 => 'cm', 4 => 'mm', 5 => 'um' }, }, The tree already held that table -- in `parsers::pdf`, reachable only from a PDF's embedded TIFF thumbnail. The main EXIF path had no arm for the tag at all, so `format_tag_value` returned the raw integer and 1,098 files in the sample corpus reported `2` and `3` where ExifTool reports `inches` and `cm`. The table moves to `core::formatters::exif_enums` next to the Compression and Flash tables, PDF reads it from there, and the `exiftool_compat` dispatch gains the arm it was missing. `Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an exclusion: ExifTool decodes all five, and the corpus holds 4 files at `4` and 1 at `5`. 0xa210 carries no `PrintHex`, so unnamed codes stay decimal -- `Unknown (0)`, which 4 corpus files report. Safe for the composites: `Exif ScaleFactor35efl` -- which gates FocalLength35efl, CircleOfConfusion, HyperfocalDistance, FOV and DOF -- already matches either spelling (`Some("3") | Some("cm")`). Measured: zero `Composite:*` values changed anywhere in the corpus. A unit test on the new function would not have caught this: the table already existed and was already correct. The regression test asserts the *dispatch*, through `format_tag_value`; deleting the new arm fails it with `left: Integer(1), right: String("None")`. Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`): BEFORE correct=393467 valdiff=16934 score=77.5341% AFTER correct=394565 valdiff=15836 score=77.7505% MATCHED-SET DELTA fixed=1098 regressed=0 PER-FILE 1098 files +1, 0 down FORMAT JPEG +1094, DNG +1, CR2 +1, RAF +1, BPG +1 Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(write): report the dropped write instead of printing success `plan_exif_write` may emit only one IFD record per tag id, so when two metadata-map keys resolve to the same id the second cannot be written. The guard that enforced this used `continue`, discarding the caller's edit while the CLI still printed "1 image files updated" and exited 0: $ oxidex -ExifIFD:ExposureBiasValue=-0.5 photo.jpg # 0x9204 1 image files updated # exit 0 $ exiftool -a -G1 -s -ExposureCompensation photo.jpg [ExifIFD] ExposureCompensation : -3/2 # unchanged Before #368 this failed loudly with a type error; #368 fixed the typing and the failure went quiet. ExifTool refuses the same command. `ExposureBiasValue` is not a writable tag name in any of its tables -- only a Notes remark on 0x9204 ("called ExposureBiasValue by the EXIF spec", Exif.pm:2369) and an XMP property whose tag name is again ExposureCompensation (XMP.pm:2096-2097); `TagLookup.pm` has no `exposurebiasvalue` key, so `TagExists` fails and `SetNewValue` returns "Tag '...' is not defined" (Writer.pl:581-584) with exit 1. A collision that would lose an edit is now refused with a message naming both keys and the id; a collision that loses nothing -- the same value already planned under another spelling, which is what the documented `-EXIF:Tag=value` syntax produces -- is still skipped silently. Two further defects fall out of the same guard: * `-EXIF:ExposureTime=1/250` wrote 0x829A into *both* IFD0 and ExifIFD. An "EXIF:" key names the tag family, not a physical IFD, so the collision search had to span IFD0/ExifIFD/GPS rather than only the IFD0 fallback. * `-ExifIFD:ExposureCompensation=-0.5` serialized ASCII "-0.5" into a rational64s tag, because the registry declared 0x9204 `String`. Corrected, along with 46 other EXIF tags whose declared type disagreed with ExifTool's `Writable`, by adding the missing `type:` to the YAML tag database. The set was derived mechanically from ExifTool 13.59's own Perl tables and restricted to scalar numeric/date tags with no ValueConv and no blob flag -- multi-value, ValueConv and `undef`-storage tags are deliberately untouched, since for those ExifTool's accepted input type comes from PrintConvInv/ValueConvInv rather than the storage code (FileSource's Integer, for one, is already correct). ModifyDate and CreateDate are among the 47: both now normalize `2024-01-15T10:30:00` to ExifTool's stored `2024:01:15 10:30:00`. #358's write path is untouched: a binary built from origin/main and one built from this branch produce byte-identical output on 24 files (9 corpus RAW/TIFF/ PNG/JPS + 12 corpus JPEGs + 3 fixtures) for an ordinary `-IFD0:Artist=` write, and all 11 files from #358's table still accept writes with no metadata lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(pentax): enter the five MakerNote sub-directories gated on the camera model `exiftool -v2` descends into 563 of the corpus's files with a Pentax or FujiFilm MakerNote. Comparing per file against `exiftool -a -G1 -s` (ExifTool 13.59), five of the directories it enters produced no oxidex row at all -- not a wrong value, not a missing-tag report, nothing, because a directory that is never entered has no rows to be missing: tag ExifTool table files tags 0x0216 %Pentax::BatteryInfo 30 129 0x021f %Pentax::AFInfo 28 111 0x022a %Pentax::FilterInfo 18 36 0x03ff %Pentax::TempInfo 8 22 0x0226 %Pentax::ShotInfo 6 6 These are plain `ProcessBinaryData` records under a MakerNote oxidex already reads, so the transcription is the generator's job. What kept them out is that almost every field in them carries an ExifTool `Condition` on the camera model, and `%Pentax::BatteryInfo` is an arrayref of such alternatives at nearly every offset -- the construct `codegen_subdirs.py` logged as "arrayref of Condition variants" and refused. Byte 2 of that record alone is `BodyBatteryADNoLoad` on a K10D with one calibration `PrintConv`, an uncalibrated `BodyBatteryADNoLoad` on a *istD, half of an `int16u` `BodyBatteryVoltage1` on a K-5, and `BodyBatteryState` on a K-3 III (Pentax.pm:4846-4935). Read without the condition it is one number under four different real tag names. So the generator grew a vocabulary rather than a special case: - `Condition` over `$$self{Model}` -- `=~`, `!~`, `eq`, and the `A and B` conjunction Pentax.pm:4864 uses to hold the K-3 III out of a pattern that would otherwise catch it. The regexes are expanded to literal alternations at generation time (32 branches, `\*ist` and `GX-1[LS]?` and `K-(1|01|3|5|7|30|50|70|500|r|x|S[12])` included) by a parser that raises on any construct it has not been taught, so the model test stays derived from ExifTool's own pattern text instead of a hand-typed list. - alternatives as several `Field`s sharing one ExifTool key, in ExifTool's order; the decoder takes the first whose condition holds and reports nothing when none does. A key any of whose alternatives is refused is dropped whole -- removing an earlier one would let a later, broader one answer for a body it was never meant to describe. - `PrintConv` as a Perl expression, and `ValueConv` composed with it, which is what makes a raw 686 of centivolts print "6.86 V" rather than "686.00 V". - `Field` now carries ExifTool's own key text, because `545`, `545.1` and `545.2` are three masked tags at one offset while two entries keyed `2` are two readings of one tag. 0x021f and 0x0216 declare `ByteOrder => 'BigEndian'` on the SubDirectory and are read that way whatever the MakerNote's own order is (Pentax.pm:2983-2986, :2949). 0x022a is one table read either way round, chosen by `$$self{Make} =~ /^RICOH/` (Pentax.pm:3032) -- the brand, not the model -- so `PentaxParser` now carries that test from the dispatcher rather than guessing it from the model. Every constant is quoted from Pentax.pm in the code; the unit tests replay the exact record bytes `exiftool -v3` prints for PentaxK10D.jpg and PentaxK-5IIs.jpg against the exact values `exiftool -a -G1 -s` reports for those files. Regenerating reproduces the committed tables byte for byte, and the FujiFilm and Panasonic tables are field-identical to before apart from the two new columns. Measured per file over /tmp/oxidex-exiftool-cache/combined-samples against ExifTool 13.59, keyed `Group1:Name`, both binaries built from this tree: 563 Pentax/FujiFilm files matched 45,133 -> 45,437 (+304 across 31 files) missing 12,375 -> 12,065 regressions 0 PentaxK-5IIs.jpg 197 -> 214 PentaxK-5.jpg 187 -> 204 PentaxK-r.jpg 184 -> 197 PentaxK10D.jpg 148 -> 159 Six values on Pentax_istD.jpg are wrong, and are named here rather than buried: that file's MakerNote offsets need ExifTool's `FixBase` correction (ExifTool resolves its 0x0216 pointer 16 bytes below the TIFF header), which oxidex does not implement -- so all of its out-of-line values are misread today, 75 of them before this change. The six new ones are the same pre-existing fault reaching six more names, not a new one. Still not entered, with the generator's own reason: `%Pentax::CameraSettings` (a hand-written arm already owns 0x0205 and covers tags the generator refuses), `FilterInfo`'s 20 `DigitalFilterNN` fields (a `RawConv` unpack idiom), `%Pentax::AEInfo`/`AEInfo2`/`AEInfo3` (`PentaxEv` conversions), `%Pentax::CAFPointInfo` (`DecodeAFPoints`), and every `BITMASK` `PrintConv`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(apple): find the MakerNote IFD, then read its binary plists `MakerNotes.pm:37-46` starts the Apple directory at `$valuePtr + 14` -- ten bytes of `Apple iOS\0`, two version bytes, then the two-byte order marker. oxidex started it at byte 10, so on every iPhone it read the order marker "MM" as an entry count of 1 and decoded one entry out of the count field and the first tag id, arriving at tag 0x4d4d, which no table has. The result was not a wrong value; it was silence. **Zero `Apple:` tags over the whole sample corpus**, against the 831 ExifTool reports. That is why the binary plists could not be reached. Four `%Apple::Main` tags hold a whole `bplist00` blob rather than a value: 0x0003 RunTime SubDirectory over %Apple::RunTime, whose PROCESS_PROC is PLIST::ProcessBinaryPLIST (Apple.pm:40-43, :324-325) 0x0040 SemanticStyle ValueConv => \&ConvertPLIST (Apple.pm:276) 0x0041 SemanticStyleRenderingVer ditto (Apple.pm:280) 0x0042 SemanticStylePreset ditto (Apple.pm:284) This adds: - `shared/binary_plist.rs`, a transcription of `Image::ExifTool::PLIST` -- `ProcessBinaryPLIST`'s trailer and offset table (PLIST.pm:398-450) and `ExtractObject`'s object grammar (PLIST.pm:260-390), plus `XMP::SerializeStruct` (XMPStruct.pl:34-69) for the `ConvertPLIST` case. Three details decide whether the output matches: * integers are read **unsigned**: `%readProc` is Get8u/16u/24u/32u/64u (PLIST.pm:30-38), so `RunTimeValue` is 235706184764708 and not a negative number. * a size that `%readProc` has no entry for returns undef rather than a guess -- so only 1/2/3/4/8-byte integers and 4/8-byte reals decode. * `SerializeStruct` walks `OrderedKeys`, which for a hash built by `ExtractObject` is a plain `sort keys`. `Apple_iPhone13Pro.jpg` stores its `SemanticStyle` keys in the order 3,1,2,0 and ExifTool prints `{_0=1,_1=0.5,_2=0,_3=2}`. Storage order would print the reverse. A plist date is decoded and then dropped: ExifTool converts it with `ConvertUnixTime($val + 11323*24*3600, 1)`, and that second argument is `$toLocal`, so the string carries the extracting machine's time zone. No Apple blob in the corpus has one, and a value that depends on the reader's clock is not one to approximate. - `%Apple::Main` itself, transcribed into `apple.rs` -- each entry's `Writable` format and its `PrintConv` verbatim, walked by the existing `shared::table_ifd`. The table's twelve `Unknown => 1` entries are absent on purpose; ExifTool reports those only under `-u`. - `Composite:RunTimeSincePowerUp` (Apple.pm:348-357), which was declared in `composite/tables.rs` with no arm in `compute`. Its `ConvertDuration` PrintConv already existed, privately, inside the MTS parser; it moves to `core::formatters::duration` rather than being copied. Three defects found on the way, each measured: - `table_ifd::print_rational` printed `%.15g`. `GetRational64s`/`u` (ExifTool.pm:6107-6120) end in `RoundFloat($num/$den, 10)`, so ExifTool prints `AccelerationVector` as `-0.9245480894`, not `-0.924548089390588`. Fixing it also gained `Olympus:FocalPlaneDiagonal` (+5) and `Olympus:DigitalZoom` (+3). - `table_ifd` had no `int64u`/`int64s` (ExifTool format codes 16 and 17). `Apple_iPhone15Pro.jpg` stores `LivePhotoVideoIndex` as `int64u[1]` = 4294967700; an int64u above `i64::MAX` is dropped rather than printed negative. - `parse_exif_subifd` kept only the *last* 0x927C entry. `Apple_iPhone6.jpg` declares two -- the camera's, and a 142-byte UTF-16 JSON blob an editor appended -- so the Apple parser was handed the wrong 142 bytes and reported nothing. Each entry is now processed in turn, as ExifTool does. Deleted `registries/apple.rs`. It declared a `FrontFacingCamera` at 0x0032 and a `PortraitMode` at 0x0020 -- `%Apple::Main` has no tag at 0x0032 at all, and 0x0020 is `ImageCaptureRequestID` -- plus enum decoders for `OISMode`, `SemanticStyle`, `SignalToNoiseRatioType` and `GreenGhostMitigationStatus`, none of which has a `PrintConv` in Apple.pm. `tests/integration.rs` never declared `apple_makernotes_tests`, so the file asserting those names never compiled; it is now declared, and rewritten against bytes dumped from real corpus files with `exiftool -v3`. Verified over /tmp/oxidex-exiftool-cache/combined-samples (4,238 files; ExifTool 13.59 reports on 4,228) against `exiftool -a -G1 -s`, keyed `Group1:Name`, case-sensitive, scored **per file**. Base is this branch's merge-base, 5a8b835: matched 393,562 -> 394,451 (+889 across 55 files) regressions 0 -- matched-set diff per file, not totals files worsened 0; extra keys unchanged at 108,146 Apple: keys emitted 0 -> 831, every one byte-identical to ExifTool of the +889: 267 plist-derived, 622 unlocked by the IFD start, 8 Olympus Apple_iPhone13Pro.jpg 126 -> 155 Apple_iPhone13ProMax.jpg 99 -> 127 Apple_iPhone15Pro.jpg 89 -> 116 Apple_iPhone12Pro.jpg 125 -> 152 Nothing is left over: every `Apple:` tag ExifTool reports for any corpus file, and `Composite:RunTimeSincePowerUp`, now matches on every file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(vrd): read the CanonVRD Ver2 picture-style block A .VRD recipe written by DPP 2.0 or later carries a second edit section after the fixed 0x272-byte VRD1 record. oxidex read VRD1 and stopped, so combined-samples/CanonVRD.vrd reported 43 of the 108 tags ExifTool does; the missing 65 are the whole of %CanonVRD::Ver2's picture-style block -- PictureStyle, IsCustomPictureStyle, and a nine-tag group per style. Reaching them needs two things. ProcessEditData sizes the three %CanonVRD::Edit sections three different ways (CanonVRD.pm:1596-1610), and the middle one, VRDStampTool, takes its length from an int32u at its own start; skipping it wholesale lands VRD2 four bytes early. And the record itself is FORMAT => 'int16s', so a tag ID is an index rather than a byte offset and the values are signed -- StandardRawColorTone reads -4 on this file, which unsigned would print as 65532. No table is transcribed for this. src/exiftool_tables already carries CanonVRD::Ver2 dumped from ExifTool's own in-memory hash, layout and PrintConv enums included, so the decoder reads that. Ver2 is only read as far as index 0x54. Past it ExifTool leans on ValueConv -- $val/0x400 rendered as a percentage, $val/10, $val/100 -- plus a DataMember-gated SubDirectory at 0xe0 and VRDVersion-conditional branches at 0x5e-0x60. The generator drops a conversion it cannot reproduce exactly, which leaves the raw value behind a real tag name, so emitting those would print a confident wrong number rather than nothing. They stay unread; a test asserts every entry below the bound is a bare int16s or an integer enum, so a future ExifTool release cannot quietly move one across it. Measured on combined-samples/CanonVRD.vrd: 43 matched -> 108 matched, 65 missing -> 0, value-diffs 0, extras 0. The 43 tags already matched are matched by the same values (set comparison, not counts), and ExifTool.jpg and CanonRaw.crw are byte-identical before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(lfp): read Lytro light-field metadata oxidex named the LFP file type but had no FileFormat variant, so format_dispatch fell through to the unsupported arm and the file yielded no tags at all. This adds the variant, the \x89LFP magic that routes to a parser, and a reader transcribed from Image::ExifTool::Lytro (Lytro.pm 1.04). The container walk follows ProcessLFP (Lytro.pm:134-174): 16-byte segment headers, a big-endian length, an 80-byte sha1 ExifTool discards, then a body that is either JSON metadata or an embedded JPEG, padded to 16 bytes. Tag names come from ExtractTags (Lytro.pm:104-128), which flattens the JSON with ucfirst on each key, drops punctuation while upcasing what follows, and strips a leading Devices. Numbers keep their source token text. These files carry more precision than an f64 roundtrip preserves -- "gamma" : 0.41666001081466674805 is the literal bytes on disk -- and ExifTool reports such values unchanged because it never reformats what it did not compute. Only the tags carrying a ValueConv or PrintConv are parsed to f64. Measured on the ExifTool corpus sample (per file, keyed Group1:Name, File/System excluded): Lytro.lfp matched 0 -> 95 missing 95 -> 0 extra 0 -> 0 That is 85 Lytro tags plus the 10 Composite tags, which the existing composite engine derives once the base tags exist. Zero regressions: the matched key sets for CanonVRD.vrd, HTML.html and LNK.lnk are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(exif): print ExposureTime through ExifTool's PrintExposureTime `ExifIFD:ExposureTime` was rendered by a hand-written formatter in `tag_conversion.rs` that split at one second. ExifTool splits at a quarter second (Exif.pm:5606, reached from the tag's `PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at Exif.pm:1824): sub PrintExposureTime($) { my $secs = shift; return $secs unless Image::ExifTool::IsFloat($secs); if ($secs < 0.25001 and $secs > 0) { return sprintf("1/%d",int(0.5 + 1/$secs)); } $_ = sprintf("%.1f",$secs); s/\.0$//; return $_; } Three divergences, established by executing that subroutine from the installed ExifTool 13.55 rather than by reading it: seconds ExifTool was now 0.5555555556 0.6 1/2 0.6 0.769230769 0.8 1/1 0.8 0.8 0.8 1/1 0.8 4.0 4 4.0 4 30.0 30 30.0 30 0 0 1/18446744073709551615 0 The first class is the damaging one: every exposure in [0.25001, 1) printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s exposure is a perfectly plausible shutter speed, so nothing downstream could tell it was wrong. The second is the trailing `.0` that `s/\.0$//` strips. The third divided by a zero `$secs`. `core::formatters::print_exposure_time` is already a verified port of the subroutine -- it reproduces all fourteen probe values exactly -- so this deletes the private copy and calls it. #340 consolidated sixteen copies of PrintExposureTime but did not reach this one, which is the copy that renders the EXIF ExposureTime tag itself. Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus, keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80" cannot silently become 1.8: differing (file,tag) pairs 40,917 -> 40,814 (-103) fixed 109 ExifIFD:ExposureTime 78 Composite:ShutterSpeed 20 Composite:LightValue 10 IFD0:ExposureTime 1 newly differing 6 The six are all `Composite:LightValue`, and they are collateral from a separate, pre-existing defect: the composite layer parses the *printed* ExposureTime string instead of the ValueConv seconds, so it now rounds from the correct `0.6` where it used to round from `1/2`. Ten other LightValue files move the right way for the same reason (net -4). The same defect is visible in `Composite:ISO`, which consumes the PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712 (Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a composite-layer fix and is left for its own change. Two ExposureTime classes remain out of scope here, both in the rational pipeline rather than the PrintConv: ExifTool rounds rational64u to ten significant figures before the conversion (ExifTool.pm:6114), which is why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a zero denominator should print `undef`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(leica): route the "LEICA CAMERA AG" MakerNote to Panasonic::Main The D-Lux 7, D-Lux 8 and V-Lux 5 are Panasonic-built and sign their MakerNote "LEICA CAMERA AG\0". ExifTool dispatches that signature with `MakerNoteLeica10` (MakerNotes.pm:724-731), which points at `Image::ExifTool::Panasonic::Main` -- not at any Leica table: Name => 'MakerNoteLeica10', # used by the D-Lux7 Condition => '$$valPt =~ /^LEICA CAMERA AG\0/', TagTable => 'Image::ExifTool::Panasonic::Main', Start => '$valuePtr + 18', oxidex instead handed the payload to the Leica parser, which claimed the header as `LeicaLayout::LongHeader`, skipped 15 bytes of it and decoded nothing -- its own comment recorded that "no corresponding ExifTool table has been identified". The result was silent: all three bodies reported zero MakerNote tags where ExifTool reports ~100, with no warning. The signature is 16 bytes and the IFD begins at 18, so two pad bytes sit between them. LeicaD-Lux7.jpg opens 4c 45 49 43 41 20 43 41 4d 45 52 41 20 41 47 00 00 00 9d 00 -- "LEICA CAMERA AG\0", two NULs, then the 157-entry count. `MakerNoteLeica10` declares no `Base`, so its out-of-line value offsets are TIFF-header-relative exactly as `MakerNotePanasonic`'s are and need no adjustment. `LongHeader` is removed rather than left dead: recognising a signature this parser has no table for shadowed the parser that does. Verified against ExifTool 13.59 on the 4,238-file corpus, scored per file and keyed Group1:Name: Leica/LeicaD-Lux7.jpg 0 -> 80 MakerNote tags (+66 matching ExifTool) Leica/LeicaD-Lux8.jpg 0 -> 80 (+65) Leica/LeicaV-Lux5.jpg 0 -> 80 (+66) +197 newly-matching keys, 0 regressions across all 4,238 files (matched-key sets diffed per file, before vs after). exiftool -G1 -s Leica/LeicaD-Lux7.jpg [Panasonic] ImageQuality : RAW [Panasonic] InternalSerialNumber : (XFL) 2018:09:06 no. 0007 [Panasonic] WhiteBalance : Auto oxidex now prints the same three values. Note this PR fixes dispatch only. The ~13 remaining value differences per file (AFPointPosition, RollAngle, PhotoStyle, ...) are pre-existing `Panasonic::Main` conversion gaps, not new: measured on origin/main they already differ on up to 212 of the 303 Panasonic JPEGs that oxidex already parsed. Tracked separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(leica): assert LEICA CAMERA AG is declined, matching Leica10 routing The inline unit test in leica.rs was updated to the new behaviour but this integration copy still asserted the old one, so Build & Test failed on the very change it was meant to cover. ExifTool's MakerNoteLeica10 (MakerNotes.pm:724-731) matches the signature alone -- Condition => '$$valPt =~ /^LEICA CAMERA AG\0/' -- and routes to Panasonic::Main at Start => '$valuePtr + 18', never to a Leica table. So is_leica_makernote must decline it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
swackhamer
added a commit
that referenced
this pull request
Aug 2, 2026
* fix(composite): preserve raw APEX conversion precision
* fix(fleet): require current-head PR approval
* fix(tables): decode repeated scalar fields
* fix(exif): one Compression table, dumped from ExifTool's own Perl
Three transcriptions of `%Image::ExifTool::Exif::compression` (Exif.pm:213)
lived in this tree, carrying 50, 40 and 53 ids and disagreeing on the spelling
of codes all three claimed to know:
core/formatters/exif_enums.rs 32766 => "Next"
parsers/tiff/tiff_enums.rs 32766 => "Next"
parsers/pdf/mod.rs 32766 => "NeXt or Sony ARW Compressed 2"
ExifTool 13.59 says:
32766 => 'NeXt or Sony ARW Compressed 2', #3/Milos
Rather than read the Perl and re-implement it -- the way three divergent copies
got written in the first place -- the hash was dumped straight out of the Perl
symbol table:
perl -Ilib -MImage::ExifTool::Exif -e '
my $h = \%Image::ExifTool::Exif::compression;
for my $v (0..70000) {
print "$v\t", (exists $h->{$v} ? $h->{$v} : "Unknown ($v)"), "\n" }'
and each copy scored over all 70,001 inputs:
exif_enums::format_compression 50 ids 10 wrong
tiff_enums 0x0103 arm 40 ids 15 wrong
pdf COMPRESSION_LABELS 53 ids 1 wrong
consolidated table 53 ids 0 wrong
The wrong answers, all now fixed: `9` read `JBIG B&W` (13.59 renamed it
`JBIG B&W or VC-5`); `32766` read `Next`; `33003`, `33004` and `33005` all
collapsed to `Aperio JPEG 2000 YCbCr`, where 33005 is `Aperio JPEG 2000 RGB`
and 33004 is not an ExifTool key at all; `34926`/`34927` dropped the ` (old)`
suffix that separates them from the LibTiff 4.7 codes; and `50000` `Zstd`,
`50001` `WebP`, `50002` `JPEG XL (old)`, `52546` `JPEG XL`, plus `32772`,
`33003`, `34887`, `34925`, `34933`, `34934` on the TIFF path, were absent.
`compression_label` returns `Option<&'static str>` so each caller keeps its
established unknown-value behaviour: `format_compression` renders `Unknown (N)`
the way `PrintConv` does, while the TIFF and PDF paths still return `None`
rather than invent a label ExifTool never prints.
Why the old tests stayed green: `test_compression` asserted 1, 6 and 7 -- three
codes all three tables agreed on -- and `tiff_enums.rs` had no test module at
all. Not one input reached a wrong branch. The new tests assert every one of the
15 codes that was measurably wrong; reverting the table makes 5 of them fail.
Corpus check (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):
393,343 correct before and after, matched-set delta fixed=0 regressed=0. The
corpus stores only Compression 1/4/5/6/7/34713 plus two codes ExifTool does not
name, so it exercises none of the divergent ids -- the divergence is measured
against the Perl, and the corpus proves the consolidation costs nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(exif): Flash is a lookup table, not a bitfield (#393)
Exif.pm 0x9209 is `PrintConv => \%flash` -- a flat 27-key hash -- plus
`Flags => 'PrintHex'`. `core::exif_enums::decode_flash` instead synthesised the
label from the byte's bit fields (fired / strobe return / mode / function
present / red-eye). Those bits explain how ExifTool's 27 codes were *chosen*;
they are not how the tag is rendered, and 229 of the 256 byte values are not
valid Flash codes at all.
Scored against `%Image::ExifTool::Exif::flash` dumped out of the Perl symbol
table, over every input in 0..=255:
core::exif_enums::decode_flash 236 wrong / 256
consolidated table 0 wrong / 256
Two verbatim copies of the correct table already existed -- `FLASH_LABELS` in
`parsers::pdf` and the `0x9209` arm of `parsers::raw::metadata` -- so the tree
held three Flash decoders, and only the one on the main EXIF path was wrong.
All three now share `core::formatters::exif_enums::flash_label`.
Three classes of wrong answer, all corpus-visible:
- codes ExifTool leaves unknown got a plausible label. 0x38 and 0x28 both
came back `No flash function`; ExifTool prints `Unknown (0x38)` and
`Unknown (0x28)` -- 60 files.
- codes it did know were mis-worded: 0x49 gave `On, Fired, Red-eye reduction`
where the hash says `On, Red-eye reduction` -- 23 files. Same for 0x0d,
0x0f, 0x4d, 0x4f, 0x50.
- `format_flash`'s unknown form was decimal; `PrintHex` makes it hex.
Why the old test stayed green: `test_flash_decoding` had 13 assertions, each
annotated with the bit layout it was deriving -- and one of them,
`decode_flash(0x49) == "On, Fired, Red-eye reduction"`, asserted a string
ExifTool never prints. The test encoded the same wrong model as the code. It is
rewritten to check the hash, and three new tests cover the mis-worded codes, the
hex unknown form, and the 27-of-256 key count; reverting the table fails four of
them.
Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):
BEFORE correct=393343 valdiff=17058 score=77.5097%
AFTER correct=393467 valdiff=16934 score=77.5341%
MATCHED-SET DELTA fixed=124 regressed=0
PER-FILE 124 files +1, 0 down FORMAT JPEG 390485 -> 390609 (+124)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(exif): FocalPlaneResolutionUnit decodes instead of printing its code (#395)
Exif.pm 0xa210 declares a PrintConv:
0xa210 => {
Name => 'FocalPlaneResolutionUnit',
Notes => 'values 1, 4 and 5 are not standard EXIF',
PrintConv => { 1 => 'None', 2 => 'inches', 3 => 'cm',
4 => 'mm', 5 => 'um' },
},
The tree already held that table -- in `parsers::pdf`, reachable only from a
PDF's embedded TIFF thumbnail. The main EXIF path had no arm for the tag at all,
so `format_tag_value` returned the raw integer and 1,098 files in the sample
corpus reported `2` and `3` where ExifTool reports `inches` and `cm`.
The table moves to `core::formatters::exif_enums` next to the Compression and
Flash tables, PDF reads it from there, and the `exiftool_compat` dispatch gains
the arm it was missing.
`Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an
exclusion: ExifTool decodes all five, and the corpus holds 4 files at `4` and 1
at `5`. 0xa210 carries no `PrintHex`, so unnamed codes stay decimal --
`Unknown (0)`, which 4 corpus files report.
Safe for the composites: `Exif ScaleFactor35efl` -- which gates
FocalLength35efl, CircleOfConfusion, HyperfocalDistance, FOV and DOF -- already
matches either spelling (`Some("3") | Some("cm")`). Measured: zero `Composite:*`
values changed anywhere in the corpus.
A unit test on the new function would not have caught this: the table already
existed and was already correct. The regression test asserts the *dispatch*,
through `format_tag_value`; deleting the new arm fails it with
`left: Integer(1), right: String("None")`.
Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):
BEFORE correct=393467 valdiff=16934 score=77.5341%
AFTER correct=394565 valdiff=15836 score=77.7505%
MATCHED-SET DELTA fixed=1098 regressed=0
PER-FILE 1098 files +1, 0 down
FORMAT JPEG +1094, DNG +1, CR2 +1, RAF +1, BPG +1
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(write): report the dropped write instead of printing success
`plan_exif_write` may emit only one IFD record per tag id, so when two
metadata-map keys resolve to the same id the second cannot be written. The
guard that enforced this used `continue`, discarding the caller's edit while
the CLI still printed "1 image files updated" and exited 0:
$ oxidex -ExifIFD:ExposureBiasValue=-0.5 photo.jpg # 0x9204
1 image files updated # exit 0
$ exiftool -a -G1 -s -ExposureCompensation photo.jpg
[ExifIFD] ExposureCompensation : -3/2 # unchanged
Before #368 this failed loudly with a type error; #368 fixed the typing and the
failure went quiet.
ExifTool refuses the same command. `ExposureBiasValue` is not a writable tag
name in any of its tables -- only a Notes remark on 0x9204 ("called
ExposureBiasValue by the EXIF spec", Exif.pm:2369) and an XMP property whose
tag name is again ExposureCompensation (XMP.pm:2096-2097); `TagLookup.pm` has
no `exposurebiasvalue` key, so `TagExists` fails and `SetNewValue` returns
"Tag '...' is not defined" (Writer.pl:581-584) with exit 1.
A collision that would lose an edit is now refused with a message naming both
keys and the id; a collision that loses nothing -- the same value already
planned under another spelling, which is what the documented `-EXIF:Tag=value`
syntax produces -- is still skipped silently.
Two further defects fall out of the same guard:
* `-EXIF:ExposureTime=1/250` wrote 0x829A into *both* IFD0 and ExifIFD. An
"EXIF:" key names the tag family, not a physical IFD, so the collision search
had to span IFD0/ExifIFD/GPS rather than only the IFD0 fallback.
* `-ExifIFD:ExposureCompensation=-0.5` serialized ASCII "-0.5" into a
rational64s tag, because the registry declared 0x9204 `String`. Corrected,
along with 46 other EXIF tags whose declared type disagreed with ExifTool's
`Writable`, by adding the missing `type:` to the YAML tag database. The set
was derived mechanically from ExifTool 13.59's own Perl tables and restricted
to scalar numeric/date tags with no ValueConv and no blob flag -- multi-value,
ValueConv and `undef`-storage tags are deliberately untouched, since for those
ExifTool's accepted input type comes from PrintConvInv/ValueConvInv rather
than the storage code (FileSource's Integer, for one, is already correct).
ModifyDate and CreateDate are among the 47: both now normalize
`2024-01-15T10:30:00` to ExifTool's stored `2024:01:15 10:30:00`.
#358's write path is untouched: a binary built from origin/main and one built
from this branch produce byte-identical output on 24 files (9 corpus RAW/TIFF/
PNG/JPS + 12 corpus JPEGs + 3 fixtures) for an ordinary `-IFD0:Artist=` write,
and all 11 files from #358's table still accept writes with no metadata lost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(pentax): enter the five MakerNote sub-directories gated on the camera model
`exiftool -v2` descends into 563 of the corpus's files with a Pentax or FujiFilm
MakerNote. Comparing per file against `exiftool -a -G1 -s` (ExifTool 13.59),
five of the directories it enters produced no oxidex row at all -- not a wrong
value, not a missing-tag report, nothing, because a directory that is never
entered has no rows to be missing:
tag ExifTool table files tags
0x0216 %Pentax::BatteryInfo 30 129
0x021f %Pentax::AFInfo 28 111
0x022a %Pentax::FilterInfo 18 36
0x03ff %Pentax::TempInfo 8 22
0x0226 %Pentax::ShotInfo 6 6
These are plain `ProcessBinaryData` records under a MakerNote oxidex already
reads, so the transcription is the generator's job. What kept them out is that
almost every field in them carries an ExifTool `Condition` on the camera model,
and `%Pentax::BatteryInfo` is an arrayref of such alternatives at nearly every
offset -- the construct `codegen_subdirs.py` logged as "arrayref of Condition
variants" and refused. Byte 2 of that record alone is `BodyBatteryADNoLoad` on a
K10D with one calibration `PrintConv`, an uncalibrated `BodyBatteryADNoLoad` on
a *istD, half of an `int16u` `BodyBatteryVoltage1` on a K-5, and
`BodyBatteryState` on a K-3 III (Pentax.pm:4846-4935). Read without the
condition it is one number under four different real tag names.
So the generator grew a vocabulary rather than a special case:
- `Condition` over `$$self{Model}` -- `=~`, `!~`, `eq`, and the `A and B`
conjunction Pentax.pm:4864 uses to hold the K-3 III out of a pattern that
would otherwise catch it. The regexes are expanded to literal alternations at
generation time (32 branches, `\*ist` and `GX-1[LS]?` and
`K-(1|01|3|5|7|30|50|70|500|r|x|S[12])` included) by a parser that raises on
any construct it has not been taught, so the model test stays derived from
ExifTool's own pattern text instead of a hand-typed list.
- alternatives as several `Field`s sharing one ExifTool key, in ExifTool's
order; the decoder takes the first whose condition holds and reports nothing
when none does. A key any of whose alternatives is refused is dropped whole --
removing an earlier one would let a later, broader one answer for a body it
was never meant to describe.
- `PrintConv` as a Perl expression, and `ValueConv` composed with it, which is
what makes a raw 686 of centivolts print "6.86 V" rather than "686.00 V".
- `Field` now carries ExifTool's own key text, because `545`, `545.1` and
`545.2` are three masked tags at one offset while two entries keyed `2` are
two readings of one tag.
0x021f and 0x0216 declare `ByteOrder => 'BigEndian'` on the SubDirectory and are
read that way whatever the MakerNote's own order is (Pentax.pm:2983-2986,
:2949). 0x022a is one table read either way round, chosen by
`$$self{Make} =~ /^RICOH/` (Pentax.pm:3032) -- the brand, not the model -- so
`PentaxParser` now carries that test from the dispatcher rather than guessing it
from the model.
Every constant is quoted from Pentax.pm in the code; the unit tests replay the
exact record bytes `exiftool -v3` prints for PentaxK10D.jpg and PentaxK-5IIs.jpg
against the exact values `exiftool -a -G1 -s` reports for those files.
Regenerating reproduces the committed tables byte for byte, and the FujiFilm and
Panasonic tables are field-identical to before apart from the two new columns.
Measured per file over /tmp/oxidex-exiftool-cache/combined-samples against
ExifTool 13.59, keyed `Group1:Name`, both binaries built from this tree:
563 Pentax/FujiFilm files
matched 45,133 -> 45,437 (+304 across 31 files)
missing 12,375 -> 12,065
regressions 0
PentaxK-5IIs.jpg 197 -> 214
PentaxK-5.jpg 187 -> 204
PentaxK-r.jpg 184 -> 197
PentaxK10D.jpg 148 -> 159
Six values on Pentax_istD.jpg are wrong, and are named here rather than
buried: that file's MakerNote offsets need ExifTool's `FixBase` correction
(ExifTool resolves its 0x0216 pointer 16 bytes below the TIFF header), which
oxidex does not implement -- so all of its out-of-line values are misread today,
75 of them before this change. The six new ones are the same pre-existing fault
reaching six more names, not a new one.
Still not entered, with the generator's own reason: `%Pentax::CameraSettings`
(a hand-written arm already owns 0x0205 and covers tags the generator refuses),
`FilterInfo`'s 20 `DigitalFilterNN` fields (a `RawConv` unpack idiom),
`%Pentax::AEInfo`/`AEInfo2`/`AEInfo3` (`PentaxEv` conversions),
`%Pentax::CAFPointInfo` (`DecodeAFPoints`), and every `BITMASK` `PrintConv`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(apple): find the MakerNote IFD, then read its binary plists
`MakerNotes.pm:37-46` starts the Apple directory at `$valuePtr + 14` -- ten
bytes of `Apple iOS\0`, two version bytes, then the two-byte order marker.
oxidex started it at byte 10, so on every iPhone it read the order marker
"MM" as an entry count of 1 and decoded one entry out of the count field and
the first tag id, arriving at tag 0x4d4d, which no table has. The result was
not a wrong value; it was silence. **Zero `Apple:` tags over the whole sample
corpus**, against the 831 ExifTool reports.
That is why the binary plists could not be reached. Four `%Apple::Main` tags
hold a whole `bplist00` blob rather than a value:
0x0003 RunTime SubDirectory over %Apple::RunTime, whose
PROCESS_PROC is PLIST::ProcessBinaryPLIST
(Apple.pm:40-43, :324-325)
0x0040 SemanticStyle ValueConv => \&ConvertPLIST (Apple.pm:276)
0x0041 SemanticStyleRenderingVer ditto (Apple.pm:280)
0x0042 SemanticStylePreset ditto (Apple.pm:284)
This adds:
- `shared/binary_plist.rs`, a transcription of `Image::ExifTool::PLIST` --
`ProcessBinaryPLIST`'s trailer and offset table (PLIST.pm:398-450) and
`ExtractObject`'s object grammar (PLIST.pm:260-390), plus
`XMP::SerializeStruct` (XMPStruct.pl:34-69) for the `ConvertPLIST` case.
Three details decide whether the output matches:
* integers are read **unsigned**: `%readProc` is Get8u/16u/24u/32u/64u
(PLIST.pm:30-38), so `RunTimeValue` is 235706184764708 and not a
negative number.
* a size that `%readProc` has no entry for returns undef rather than a
guess -- so only 1/2/3/4/8-byte integers and 4/8-byte reals decode.
* `SerializeStruct` walks `OrderedKeys`, which for a hash built by
`ExtractObject` is a plain `sort keys`. `Apple_iPhone13Pro.jpg` stores
its `SemanticStyle` keys in the order 3,1,2,0 and ExifTool prints
`{_0=1,_1=0.5,_2=0,_3=2}`. Storage order would print the reverse.
A plist date is decoded and then dropped: ExifTool converts it with
`ConvertUnixTime($val + 11323*24*3600, 1)`, and that second argument is
`$toLocal`, so the string carries the extracting machine's time zone. No
Apple blob in the corpus has one, and a value that depends on the reader's
clock is not one to approximate.
- `%Apple::Main` itself, transcribed into `apple.rs` -- each entry's
`Writable` format and its `PrintConv` verbatim, walked by the existing
`shared::table_ifd`. The table's twelve `Unknown => 1` entries are absent
on purpose; ExifTool reports those only under `-u`.
- `Composite:RunTimeSincePowerUp` (Apple.pm:348-357), which was declared in
`composite/tables.rs` with no arm in `compute`. Its `ConvertDuration`
PrintConv already existed, privately, inside the MTS parser; it moves to
`core::formatters::duration` rather than being copied.
Three defects found on the way, each measured:
- `table_ifd::print_rational` printed `%.15g`. `GetRational64s`/`u`
(ExifTool.pm:6107-6120) end in `RoundFloat($num/$den, 10)`, so ExifTool
prints `AccelerationVector` as `-0.9245480894`, not `-0.924548089390588`.
Fixing it also gained `Olympus:FocalPlaneDiagonal` (+5) and
`Olympus:DigitalZoom` (+3).
- `table_ifd` had no `int64u`/`int64s` (ExifTool format codes 16 and 17).
`Apple_iPhone15Pro.jpg` stores `LivePhotoVideoIndex` as `int64u[1]` =
4294967700; an int64u above `i64::MAX` is dropped rather than printed
negative.
- `parse_exif_subifd` kept only the *last* 0x927C entry. `Apple_iPhone6.jpg`
declares two -- the camera's, and a 142-byte UTF-16 JSON blob an editor
appended -- so the Apple parser was handed the wrong 142 bytes and reported
nothing. Each entry is now processed in turn, as ExifTool does.
Deleted `registries/apple.rs`. It declared a `FrontFacingCamera` at 0x0032
and a `PortraitMode` at 0x0020 -- `%Apple::Main` has no tag at 0x0032 at all,
and 0x0020 is `ImageCaptureRequestID` -- plus enum decoders for `OISMode`,
`SemanticStyle`, `SignalToNoiseRatioType` and `GreenGhostMitigationStatus`,
none of which has a `PrintConv` in Apple.pm. `tests/integration.rs` never
declared `apple_makernotes_tests`, so the file asserting those names never
compiled; it is now declared, and rewritten against bytes dumped from real
corpus files with `exiftool -v3`.
Verified over /tmp/oxidex-exiftool-cache/combined-samples (4,238 files;
ExifTool 13.59 reports on 4,228) against `exiftool -a -G1 -s`, keyed
`Group1:Name`, case-sensitive, scored **per file**. Base is this branch's
merge-base, 5a8b835:
matched 393,562 -> 394,451 (+889 across 55 files)
regressions 0 -- matched-set diff per file, not totals
files worsened 0; extra keys unchanged at 108,146
Apple: keys emitted 0 -> 831, every one byte-identical to ExifTool
of the +889: 267 plist-derived, 622 unlocked by the IFD start, 8 Olympus
Apple_iPhone13Pro.jpg 126 -> 155
Apple_iPhone13ProMax.jpg 99 -> 127
Apple_iPhone15Pro.jpg 89 -> 116
Apple_iPhone12Pro.jpg 125 -> 152
Nothing is left over: every `Apple:` tag ExifTool reports for any corpus file,
and `Composite:RunTimeSincePowerUp`, now matches on every file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(vrd): read the CanonVRD Ver2 picture-style block
A .VRD recipe written by DPP 2.0 or later carries a second edit section
after the fixed 0x272-byte VRD1 record. oxidex read VRD1 and stopped, so
combined-samples/CanonVRD.vrd reported 43 of the 108 tags ExifTool does;
the missing 65 are the whole of %CanonVRD::Ver2's picture-style block --
PictureStyle, IsCustomPictureStyle, and a nine-tag group per style.
Reaching them needs two things. ProcessEditData sizes the three
%CanonVRD::Edit sections three different ways (CanonVRD.pm:1596-1610),
and the middle one, VRDStampTool, takes its length from an int32u at its
own start; skipping it wholesale lands VRD2 four bytes early. And the
record itself is FORMAT => 'int16s', so a tag ID is an index rather than
a byte offset and the values are signed -- StandardRawColorTone reads -4
on this file, which unsigned would print as 65532.
No table is transcribed for this. src/exiftool_tables already carries
CanonVRD::Ver2 dumped from ExifTool's own in-memory hash, layout and
PrintConv enums included, so the decoder reads that.
Ver2 is only read as far as index 0x54. Past it ExifTool leans on
ValueConv -- $val/0x400 rendered as a percentage, $val/10, $val/100 --
plus a DataMember-gated SubDirectory at 0xe0 and VRDVersion-conditional
branches at 0x5e-0x60. The generator drops a conversion it cannot
reproduce exactly, which leaves the raw value behind a real tag name, so
emitting those would print a confident wrong number rather than nothing.
They stay unread; a test asserts every entry below the bound is a bare
int16s or an integer enum, so a future ExifTool release cannot quietly
move one across it.
Measured on combined-samples/CanonVRD.vrd: 43 matched -> 108 matched,
65 missing -> 0, value-diffs 0, extras 0. The 43 tags already matched
are matched by the same values (set comparison, not counts), and
ExifTool.jpg and CanonRaw.crw are byte-identical before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(lfp): read Lytro light-field metadata
oxidex named the LFP file type but had no FileFormat variant, so
format_dispatch fell through to the unsupported arm and the file yielded
no tags at all. This adds the variant, the \x89LFP magic that routes to a
parser, and a reader transcribed from Image::ExifTool::Lytro (Lytro.pm 1.04).
The container walk follows ProcessLFP (Lytro.pm:134-174): 16-byte segment
headers, a big-endian length, an 80-byte sha1 ExifTool discards, then a
body that is either JSON metadata or an embedded JPEG, padded to 16 bytes.
Tag names come from ExtractTags (Lytro.pm:104-128), which flattens the JSON
with ucfirst on each key, drops punctuation while upcasing what follows,
and strips a leading Devices.
Numbers keep their source token text. These files carry more precision than
an f64 roundtrip preserves -- "gamma" : 0.41666001081466674805 is the
literal bytes on disk -- and ExifTool reports such values unchanged because
it never reformats what it did not compute. Only the tags carrying a
ValueConv or PrintConv are parsed to f64.
Measured on the ExifTool corpus sample (per file, keyed Group1:Name,
File/System excluded):
Lytro.lfp matched 0 -> 95 missing 95 -> 0 extra 0 -> 0
That is 85 Lytro tags plus the 10 Composite tags, which the existing
composite engine derives once the base tags exist. Zero regressions: the
matched key sets for CanonVRD.vrd, HTML.html and LNK.lnk are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(exif): print ExposureTime through ExifTool's PrintExposureTime
`ExifIFD:ExposureTime` was rendered by a hand-written formatter in
`tag_conversion.rs` that split at one second. ExifTool splits at a
quarter second (Exif.pm:5606, reached from the tag's
`PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at
Exif.pm:1824):
sub PrintExposureTime($)
{
my $secs = shift;
return $secs unless Image::ExifTool::IsFloat($secs);
if ($secs < 0.25001 and $secs > 0) {
return sprintf("1/%d",int(0.5 + 1/$secs));
}
$_ = sprintf("%.1f",$secs);
s/\.0$//;
return $_;
}
Three divergences, established by executing that subroutine from the
installed ExifTool 13.55 rather than by reading it:
seconds ExifTool was now
0.5555555556 0.6 1/2 0.6
0.769230769 0.8 1/1 0.8
0.8 0.8 1/1 0.8
4.0 4 4.0 4
30.0 30 30.0 30
0 0 1/18446744073709551615 0
The first class is the damaging one: every exposure in [0.25001, 1)
printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s
exposure is a perfectly plausible shutter speed, so nothing downstream
could tell it was wrong. The second is the trailing `.0` that `s/\.0$//`
strips. The third divided by a zero `$secs`.
`core::formatters::print_exposure_time` is already a verified port of
the subroutine -- it reproduces all fourteen probe values exactly -- so
this deletes the private copy and calls it. #340 consolidated sixteen
copies of PrintExposureTime but did not reach this one, which is the
copy that renders the EXIF ExposureTime tag itself.
Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus,
keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80"
cannot silently become 1.8:
differing (file,tag) pairs 40,917 -> 40,814 (-103)
fixed 109
ExifIFD:ExposureTime 78
Composite:ShutterSpeed 20
Composite:LightValue 10
IFD0:ExposureTime 1
newly differing 6
The six are all `Composite:LightValue`, and they are collateral from a
separate, pre-existing defect: the composite layer parses the *printed*
ExposureTime string instead of the ValueConv seconds, so it now rounds
from the correct `0.6` where it used to round from `1/2`. Ten other
LightValue files move the right way for the same reason (net -4). The
same defect is visible in `Composite:ISO`, which consumes the
PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712
(Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a
composite-layer fix and is left for its own change.
Two ExposureTime classes remain out of scope here, both in the rational
pipeline rather than the PrintConv: ExifTool rounds rational64u to ten
significant figures before the conversion (ExifTool.pm:6114), which is
why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a
zero denominator should print `undef`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(leica): route the "LEICA CAMERA AG" MakerNote to Panasonic::Main
The D-Lux 7, D-Lux 8 and V-Lux 5 are Panasonic-built and sign their
MakerNote "LEICA CAMERA AG\0". ExifTool dispatches that signature with
`MakerNoteLeica10` (MakerNotes.pm:724-731), which points at
`Image::ExifTool::Panasonic::Main` -- not at any Leica table:
Name => 'MakerNoteLeica10', # used by the D-Lux7
Condition => '$$valPt =~ /^LEICA CAMERA AG\0/',
TagTable => 'Image::ExifTool::Panasonic::Main',
Start => '$valuePtr + 18',
oxidex instead handed the payload to the Leica parser, which claimed the
header as `LeicaLayout::LongHeader`, skipped 15 bytes of it and decoded
nothing -- its own comment recorded that "no corresponding ExifTool table
has been identified". The result was silent: all three bodies reported
zero MakerNote tags where ExifTool reports ~100, with no warning.
The signature is 16 bytes and the IFD begins at 18, so two pad bytes sit
between them. LeicaD-Lux7.jpg opens
4c 45 49 43 41 20 43 41 4d 45 52 41 20 41 47 00 00 00 9d 00
-- "LEICA CAMERA AG\0", two NULs, then the 157-entry count.
`MakerNoteLeica10` declares no `Base`, so its out-of-line value offsets
are TIFF-header-relative exactly as `MakerNotePanasonic`'s are and need
no adjustment.
`LongHeader` is removed rather than left dead: recognising a signature
this parser has no table for shadowed the parser that does.
Verified against ExifTool 13.59 on the 4,238-file corpus, scored per file
and keyed Group1:Name:
Leica/LeicaD-Lux7.jpg 0 -> 80 MakerNote tags (+66 matching ExifTool)
Leica/LeicaD-Lux8.jpg 0 -> 80 (+65)
Leica/LeicaV-Lux5.jpg 0 -> 80 (+66)
+197 newly-matching keys, 0 regressions across all 4,238 files
(matched-key sets diffed per file, before vs after).
exiftool -G1 -s Leica/LeicaD-Lux7.jpg
[Panasonic] ImageQuality : RAW
[Panasonic] InternalSerialNumber : (XFL) 2018:09:06 no. 0007
[Panasonic] WhiteBalance : Auto
oxidex now prints the same three values.
Note this PR fixes dispatch only. The ~13 remaining value differences per
file (AFPointPosition, RollAngle, PhotoStyle, ...) are pre-existing
`Panasonic::Main` conversion gaps, not new: measured on origin/main they
already differ on up to 212 of the 303 Panasonic JPEGs that oxidex
already parsed. Tracked separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(leica): assert LEICA CAMERA AG is declined, matching Leica10 routing
The inline unit test in leica.rs was updated to the new behaviour but this
integration copy still asserted the old one, so Build & Test failed on the
very change it was meant to cover.
ExifTool's MakerNoteLeica10 (MakerNotes.pm:724-731) matches the signature
alone -- Condition => '$$valPt =~ /^LEICA CAMERA AG\0/' -- and routes to
Panasonic::Main at Start => '$valuePtr + 18', never to a Leica table. So
is_leica_makernote must decline it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(panasonic): drop six fabricated MakerNote registry entries
`registries/panasonic.rs` declared 93 tags. Six did not trace to
`%Image::ExifTool::Panasonic::Main`, and between them oxidex emitted
1,248 tag instances across the 479-file Panasonic corpus that ExifTool
reports none of:
0x003E TextStamp2 -> TextStamp (Panasonic.pm:809)
0x8008 TextStamp3 -> TextStamp (Panasonic.pm:1568)
0x8009 TextStamp4 -> TextStamp (Panasonic.pm:1574)
0x8007 FlashFired -> deleted (Panasonic.pm:1563, commented out)
0x8010 BabyAge2 -> deleted (id is `BabyAge`, Panasonic.pm:1580)
0x8012 Transform2 -> deleted (id is `Transform`, Panasonic.pm:1587)
ExifTool gives four different ids the same name `TextStamp` and two ids
the name `BabyAge`; inventing `Foo2`/`Foo3`/`Foo4` to disambiguate them
produces names that exist in zero ExifTool source files and can never
match real output. 0x003E/0x8008/0x8009 carry ExifTool's own PrintConv
({1=>'Off', 2=>'On'}) so they are renamed in place. 0x8010 and 0x8012
are omitted rather than renamed: 0x8010 is a `string` with a sentinel
PrintConv but was registered as a bare integer, and 0x8012 is an
`int16s` `Count => 2` whose PrintConv keys are integer *pairs*
('0 0' => 'Off', '-3 2' => 'Slim High'), which a single-value On/Off
decoder cannot express. 0x0033 and 0x0059 already deliver the real
`BabyAge` and `Transform` names. 0x8007 is disabled upstream:
`#0x8007 => { #PH - questionable [disabled because it conflicts with
EXIF in too many samples]`.
Two enum decoders were also invented on real tags, printing confidently
wrong values under real ExifTool names:
0x0077 BurstSpeed int16u, "images per second", no PrintConv
(Panasonic.pm:1094). Printed "Low" where
ExifTool prints "0", in 91 files.
0x009D InternalNDFilter rational64u, no PrintConv
(Panasonic.pm:1247). Printed
"Unknown (4620)" where ExifTool prints "0".
Both decoders are removed; `BURST_SPEED` and `INTERNAL_ND_FILTER` in
the parser had no other caller and are deleted.
Measured per file over 479 Panasonic samples, ExifTool 13.59 vs oxidex,
keyed Group1:Name (JSON parsed with parse_float=str):
matched 44668 -> 44759 (+91, BurstSpeed now agrees)
value_diff 1848 -> 1757 (-91)
missing 8057 -> 8057 (unchanged)
extra 12725 -> 11477 (-1248)
No file lost a matched key.
InternalNDFilter still disagrees (76 files): oxidex prints the entry's
value_offset rather than dereferencing the rational. That is the same
pre-existing gap that makes `AFPointPosition` print "3254" for
ExifTool's "0.47 0.48", and is not addressed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
swackhamer
added a commit
that referenced
this pull request
Aug 2, 2026
* fix(composite): preserve raw APEX conversion precision
* fix(fleet): require current-head PR approval
* fix(tables): decode repeated scalar fields
* fix(exif): one Compression table, dumped from ExifTool's own Perl
Three transcriptions of `%Image::ExifTool::Exif::compression` (Exif.pm:213)
lived in this tree, carrying 50, 40 and 53 ids and disagreeing on the spelling
of codes all three claimed to know:
core/formatters/exif_enums.rs 32766 => "Next"
parsers/tiff/tiff_enums.rs 32766 => "Next"
parsers/pdf/mod.rs 32766 => "NeXt or Sony ARW Compressed 2"
ExifTool 13.59 says:
32766 => 'NeXt or Sony ARW Compressed 2', #3/Milos
Rather than read the Perl and re-implement it -- the way three divergent copies
got written in the first place -- the hash was dumped straight out of the Perl
symbol table:
perl -Ilib -MImage::ExifTool::Exif -e '
my $h = \%Image::ExifTool::Exif::compression;
for my $v (0..70000) {
print "$v\t", (exists $h->{$v} ? $h->{$v} : "Unknown ($v)"), "\n" }'
and each copy scored over all 70,001 inputs:
exif_enums::format_compression 50 ids 10 wrong
tiff_enums 0x0103 arm 40 ids 15 wrong
pdf COMPRESSION_LABELS 53 ids 1 wrong
consolidated table 53 ids 0 wrong
The wrong answers, all now fixed: `9` read `JBIG B&W` (13.59 renamed it
`JBIG B&W or VC-5`); `32766` read `Next`; `33003`, `33004` and `33005` all
collapsed to `Aperio JPEG 2000 YCbCr`, where 33005 is `Aperio JPEG 2000 RGB`
and 33004 is not an ExifTool key at all; `34926`/`34927` dropped the ` (old)`
suffix that separates them from the LibTiff 4.7 codes; and `50000` `Zstd`,
`50001` `WebP`, `50002` `JPEG XL (old)`, `52546` `JPEG XL`, plus `32772`,
`33003`, `34887`, `34925`, `34933`, `34934` on the TIFF path, were absent.
`compression_label` returns `Option<&'static str>` so each caller keeps its
established unknown-value behaviour: `format_compression` renders `Unknown (N)`
the way `PrintConv` does, while the TIFF and PDF paths still return `None`
rather than invent a label ExifTool never prints.
Why the old tests stayed green: `test_compression` asserted 1, 6 and 7 -- three
codes all three tables agreed on -- and `tiff_enums.rs` had no test module at
all. Not one input reached a wrong branch. The new tests assert every one of the
15 codes that was measurably wrong; reverting the table makes 5 of them fail.
Corpus check (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):
393,343 correct before and after, matched-set delta fixed=0 regressed=0. The
corpus stores only Compression 1/4/5/6/7/34713 plus two codes ExifTool does not
name, so it exercises none of the divergent ids -- the divergence is measured
against the Perl, and the corpus proves the consolidation costs nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(exif): Flash is a lookup table, not a bitfield (#393)
Exif.pm 0x9209 is `PrintConv => \%flash` -- a flat 27-key hash -- plus
`Flags => 'PrintHex'`. `core::exif_enums::decode_flash` instead synthesised the
label from the byte's bit fields (fired / strobe return / mode / function
present / red-eye). Those bits explain how ExifTool's 27 codes were *chosen*;
they are not how the tag is rendered, and 229 of the 256 byte values are not
valid Flash codes at all.
Scored against `%Image::ExifTool::Exif::flash` dumped out of the Perl symbol
table, over every input in 0..=255:
core::exif_enums::decode_flash 236 wrong / 256
consolidated table 0 wrong / 256
Two verbatim copies of the correct table already existed -- `FLASH_LABELS` in
`parsers::pdf` and the `0x9209` arm of `parsers::raw::metadata` -- so the tree
held three Flash decoders, and only the one on the main EXIF path was wrong.
All three now share `core::formatters::exif_enums::flash_label`.
Three classes of wrong answer, all corpus-visible:
- codes ExifTool leaves unknown got a plausible label. 0x38 and 0x28 both
came back `No flash function`; ExifTool prints `Unknown (0x38)` and
`Unknown (0x28)` -- 60 files.
- codes it did know were mis-worded: 0x49 gave `On, Fired, Red-eye reduction`
where the hash says `On, Red-eye reduction` -- 23 files. Same for 0x0d,
0x0f, 0x4d, 0x4f, 0x50.
- `format_flash`'s unknown form was decimal; `PrintHex` makes it hex.
Why the old test stayed green: `test_flash_decoding` had 13 assertions, each
annotated with the bit layout it was deriving -- and one of them,
`decode_flash(0x49) == "On, Fired, Red-eye reduction"`, asserted a string
ExifTool never prints. The test encoded the same wrong model as the code. It is
rewritten to check the hash, and three new tests cover the mis-worded codes, the
hex unknown form, and the 27-of-256 key count; reverting the table fails four of
them.
Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):
BEFORE correct=393343 valdiff=17058 score=77.5097%
AFTER correct=393467 valdiff=16934 score=77.5341%
MATCHED-SET DELTA fixed=124 regressed=0
PER-FILE 124 files +1, 0 down FORMAT JPEG 390485 -> 390609 (+124)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(exif): FocalPlaneResolutionUnit decodes instead of printing its code (#395)
Exif.pm 0xa210 declares a PrintConv:
0xa210 => {
Name => 'FocalPlaneResolutionUnit',
Notes => 'values 1, 4 and 5 are not standard EXIF',
PrintConv => { 1 => 'None', 2 => 'inches', 3 => 'cm',
4 => 'mm', 5 => 'um' },
},
The tree already held that table -- in `parsers::pdf`, reachable only from a
PDF's embedded TIFF thumbnail. The main EXIF path had no arm for the tag at all,
so `format_tag_value` returned the raw integer and 1,098 files in the sample
corpus reported `2` and `3` where ExifTool reports `inches` and `cm`.
The table moves to `core::formatters::exif_enums` next to the Compression and
Flash tables, PDF reads it from there, and the `exiftool_compat` dispatch gains
the arm it was missing.
`Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an
exclusion: ExifTool decodes all five, and the corpus holds 4 files at `4` and 1
at `5`. 0xa210 carries no `PrintHex`, so unnamed codes stay decimal --
`Unknown (0)`, which 4 corpus files report.
Safe for the composites: `Exif ScaleFactor35efl` -- which gates
FocalLength35efl, CircleOfConfusion, HyperfocalDistance, FOV and DOF -- already
matches either spelling (`Some("3") | Some("cm")`). Measured: zero `Composite:*`
values changed anywhere in the corpus.
A unit test on the new function would not have caught this: the table already
existed and was already correct. The regression test asserts the *dispatch*,
through `format_tag_value`; deleting the new arm fails it with
`left: Integer(1), right: String("None")`.
Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`):
BEFORE correct=393467 valdiff=16934 score=77.5341%
AFTER correct=394565 valdiff=15836 score=77.7505%
MATCHED-SET DELTA fixed=1098 regressed=0
PER-FILE 1098 files +1, 0 down
FORMAT JPEG +1094, DNG +1, CR2 +1, RAF +1, BPG +1
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(write): report the dropped write instead of printing success
`plan_exif_write` may emit only one IFD record per tag id, so when two
metadata-map keys resolve to the same id the second cannot be written. The
guard that enforced this used `continue`, discarding the caller's edit while
the CLI still printed "1 image files updated" and exited 0:
$ oxidex -ExifIFD:ExposureBiasValue=-0.5 photo.jpg # 0x9204
1 image files updated # exit 0
$ exiftool -a -G1 -s -ExposureCompensation photo.jpg
[ExifIFD] ExposureCompensation : -3/2 # unchanged
Before #368 this failed loudly with a type error; #368 fixed the typing and the
failure went quiet.
ExifTool refuses the same command. `ExposureBiasValue` is not a writable tag
name in any of its tables -- only a Notes remark on 0x9204 ("called
ExposureBiasValue by the EXIF spec", Exif.pm:2369) and an XMP property whose
tag name is again ExposureCompensation (XMP.pm:2096-2097); `TagLookup.pm` has
no `exposurebiasvalue` key, so `TagExists` fails and `SetNewValue` returns
"Tag '...' is not defined" (Writer.pl:581-584) with exit 1.
A collision that would lose an edit is now refused with a message naming both
keys and the id; a collision that loses nothing -- the same value already
planned under another spelling, which is what the documented `-EXIF:Tag=value`
syntax produces -- is still skipped silently.
Two further defects fall out of the same guard:
* `-EXIF:ExposureTime=1/250` wrote 0x829A into *both* IFD0 and ExifIFD. An
"EXIF:" key names the tag family, not a physical IFD, so the collision search
had to span IFD0/ExifIFD/GPS rather than only the IFD0 fallback.
* `-ExifIFD:ExposureCompensation=-0.5` serialized ASCII "-0.5" into a
rational64s tag, because the registry declared 0x9204 `String`. Corrected,
along with 46 other EXIF tags whose declared type disagreed with ExifTool's
`Writable`, by adding the missing `type:` to the YAML tag database. The set
was derived mechanically from ExifTool 13.59's own Perl tables and restricted
to scalar numeric/date tags with no ValueConv and no blob flag -- multi-value,
ValueConv and `undef`-storage tags are deliberately untouched, since for those
ExifTool's accepted input type comes from PrintConvInv/ValueConvInv rather
than the storage code (FileSource's Integer, for one, is already correct).
ModifyDate and CreateDate are among the 47: both now normalize
`2024-01-15T10:30:00` to ExifTool's stored `2024:01:15 10:30:00`.
#358's write path is untouched: a binary built from origin/main and one built
from this branch produce byte-identical output on 24 files (9 corpus RAW/TIFF/
PNG/JPS + 12 corpus JPEGs + 3 fixtures) for an ordinary `-IFD0:Artist=` write,
and all 11 files from #358's table still accept writes with no metadata lost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(pentax): enter the five MakerNote sub-directories gated on the camera model
`exiftool -v2` descends into 563 of the corpus's files with a Pentax or FujiFilm
MakerNote. Comparing per file against `exiftool -a -G1 -s` (ExifTool 13.59),
five of the directories it enters produced no oxidex row at all -- not a wrong
value, not a missing-tag report, nothing, because a directory that is never
entered has no rows to be missing:
tag ExifTool table files tags
0x0216 %Pentax::BatteryInfo 30 129
0x021f %Pentax::AFInfo 28 111
0x022a %Pentax::FilterInfo 18 36
0x03ff %Pentax::TempInfo 8 22
0x0226 %Pentax::ShotInfo 6 6
These are plain `ProcessBinaryData` records under a MakerNote oxidex already
reads, so the transcription is the generator's job. What kept them out is that
almost every field in them carries an ExifTool `Condition` on the camera model,
and `%Pentax::BatteryInfo` is an arrayref of such alternatives at nearly every
offset -- the construct `codegen_subdirs.py` logged as "arrayref of Condition
variants" and refused. Byte 2 of that record alone is `BodyBatteryADNoLoad` on a
K10D with one calibration `PrintConv`, an uncalibrated `BodyBatteryADNoLoad` on
a *istD, half of an `int16u` `BodyBatteryVoltage1` on a K-5, and
`BodyBatteryState` on a K-3 III (Pentax.pm:4846-4935). Read without the
condition it is one number under four different real tag names.
So the generator grew a vocabulary rather than a special case:
- `Condition` over `$$self{Model}` -- `=~`, `!~`, `eq`, and the `A and B`
conjunction Pentax.pm:4864 uses to hold the K-3 III out of a pattern that
would otherwise catch it. The regexes are expanded to literal alternations at
generation time (32 branches, `\*ist` and `GX-1[LS]?` and
`K-(1|01|3|5|7|30|50|70|500|r|x|S[12])` included) by a parser that raises on
any construct it has not been taught, so the model test stays derived from
ExifTool's own pattern text instead of a hand-typed list.
- alternatives as several `Field`s sharing one ExifTool key, in ExifTool's
order; the decoder takes the first whose condition holds and reports nothing
when none does. A key any of whose alternatives is refused is dropped whole --
removing an earlier one would let a later, broader one answer for a body it
was never meant to describe.
- `PrintConv` as a Perl expression, and `ValueConv` composed with it, which is
what makes a raw 686 of centivolts print "6.86 V" rather than "686.00 V".
- `Field` now carries ExifTool's own key text, because `545`, `545.1` and
`545.2` are three masked tags at one offset while two entries keyed `2` are
two readings of one tag.
0x021f and 0x0216 declare `ByteOrder => 'BigEndian'` on the SubDirectory and are
read that way whatever the MakerNote's own order is (Pentax.pm:2983-2986,
:2949). 0x022a is one table read either way round, chosen by
`$$self{Make} =~ /^RICOH/` (Pentax.pm:3032) -- the brand, not the model -- so
`PentaxParser` now carries that test from the dispatcher rather than guessing it
from the model.
Every constant is quoted from Pentax.pm in the code; the unit tests replay the
exact record bytes `exiftool -v3` prints for PentaxK10D.jpg and PentaxK-5IIs.jpg
against the exact values `exiftool -a -G1 -s` reports for those files.
Regenerating reproduces the committed tables byte for byte, and the FujiFilm and
Panasonic tables are field-identical to before apart from the two new columns.
Measured per file over /tmp/oxidex-exiftool-cache/combined-samples against
ExifTool 13.59, keyed `Group1:Name`, both binaries built from this tree:
563 Pentax/FujiFilm files
matched 45,133 -> 45,437 (+304 across 31 files)
missing 12,375 -> 12,065
regressions 0
PentaxK-5IIs.jpg 197 -> 214
PentaxK-5.jpg 187 -> 204
PentaxK-r.jpg 184 -> 197
PentaxK10D.jpg 148 -> 159
Six values on Pentax_istD.jpg are wrong, and are named here rather than
buried: that file's MakerNote offsets need ExifTool's `FixBase` correction
(ExifTool resolves its 0x0216 pointer 16 bytes below the TIFF header), which
oxidex does not implement -- so all of its out-of-line values are misread today,
75 of them before this change. The six new ones are the same pre-existing fault
reaching six more names, not a new one.
Still not entered, with the generator's own reason: `%Pentax::CameraSettings`
(a hand-written arm already owns 0x0205 and covers tags the generator refuses),
`FilterInfo`'s 20 `DigitalFilterNN` fields (a `RawConv` unpack idiom),
`%Pentax::AEInfo`/`AEInfo2`/`AEInfo3` (`PentaxEv` conversions),
`%Pentax::CAFPointInfo` (`DecodeAFPoints`), and every `BITMASK` `PrintConv`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(apple): find the MakerNote IFD, then read its binary plists
`MakerNotes.pm:37-46` starts the Apple directory at `$valuePtr + 14` -- ten
bytes of `Apple iOS\0`, two version bytes, then the two-byte order marker.
oxidex started it at byte 10, so on every iPhone it read the order marker
"MM" as an entry count of 1 and decoded one entry out of the count field and
the first tag id, arriving at tag 0x4d4d, which no table has. The result was
not a wrong value; it was silence. **Zero `Apple:` tags over the whole sample
corpus**, against the 831 ExifTool reports.
That is why the binary plists could not be reached. Four `%Apple::Main` tags
hold a whole `bplist00` blob rather than a value:
0x0003 RunTime SubDirectory over %Apple::RunTime, whose
PROCESS_PROC is PLIST::ProcessBinaryPLIST
(Apple.pm:40-43, :324-325)
0x0040 SemanticStyle ValueConv => \&ConvertPLIST (Apple.pm:276)
0x0041 SemanticStyleRenderingVer ditto (Apple.pm:280)
0x0042 SemanticStylePreset ditto (Apple.pm:284)
This adds:
- `shared/binary_plist.rs`, a transcription of `Image::ExifTool::PLIST` --
`ProcessBinaryPLIST`'s trailer and offset table (PLIST.pm:398-450) and
`ExtractObject`'s object grammar (PLIST.pm:260-390), plus
`XMP::SerializeStruct` (XMPStruct.pl:34-69) for the `ConvertPLIST` case.
Three details decide whether the output matches:
* integers are read **unsigned**: `%readProc` is Get8u/16u/24u/32u/64u
(PLIST.pm:30-38), so `RunTimeValue` is 235706184764708 and not a
negative number.
* a size that `%readProc` has no entry for returns undef rather than a
guess -- so only 1/2/3/4/8-byte integers and 4/8-byte reals decode.
* `SerializeStruct` walks `OrderedKeys`, which for a hash built by
`ExtractObject` is a plain `sort keys`. `Apple_iPhone13Pro.jpg` stores
its `SemanticStyle` keys in the order 3,1,2,0 and ExifTool prints
`{_0=1,_1=0.5,_2=0,_3=2}`. Storage order would print the reverse.
A plist date is decoded and then dropped: ExifTool converts it with
`ConvertUnixTime($val + 11323*24*3600, 1)`, and that second argument is
`$toLocal`, so the string carries the extracting machine's time zone. No
Apple blob in the corpus has one, and a value that depends on the reader's
clock is not one to approximate.
- `%Apple::Main` itself, transcribed into `apple.rs` -- each entry's
`Writable` format and its `PrintConv` verbatim, walked by the existing
`shared::table_ifd`. The table's twelve `Unknown => 1` entries are absent
on purpose; ExifTool reports those only under `-u`.
- `Composite:RunTimeSincePowerUp` (Apple.pm:348-357), which was declared in
`composite/tables.rs` with no arm in `compute`. Its `ConvertDuration`
PrintConv already existed, privately, inside the MTS parser; it moves to
`core::formatters::duration` rather than being copied.
Three defects found on the way, each measured:
- `table_ifd::print_rational` printed `%.15g`. `GetRational64s`/`u`
(ExifTool.pm:6107-6120) end in `RoundFloat($num/$den, 10)`, so ExifTool
prints `AccelerationVector` as `-0.9245480894`, not `-0.924548089390588`.
Fixing it also gained `Olympus:FocalPlaneDiagonal` (+5) and
`Olympus:DigitalZoom` (+3).
- `table_ifd` had no `int64u`/`int64s` (ExifTool format codes 16 and 17).
`Apple_iPhone15Pro.jpg` stores `LivePhotoVideoIndex` as `int64u[1]` =
4294967700; an int64u above `i64::MAX` is dropped rather than printed
negative.
- `parse_exif_subifd` kept only the *last* 0x927C entry. `Apple_iPhone6.jpg`
declares two -- the camera's, and a 142-byte UTF-16 JSON blob an editor
appended -- so the Apple parser was handed the wrong 142 bytes and reported
nothing. Each entry is now processed in turn, as ExifTool does.
Deleted `registries/apple.rs`. It declared a `FrontFacingCamera` at 0x0032
and a `PortraitMode` at 0x0020 -- `%Apple::Main` has no tag at 0x0032 at all,
and 0x0020 is `ImageCaptureRequestID` -- plus enum decoders for `OISMode`,
`SemanticStyle`, `SignalToNoiseRatioType` and `GreenGhostMitigationStatus`,
none of which has a `PrintConv` in Apple.pm. `tests/integration.rs` never
declared `apple_makernotes_tests`, so the file asserting those names never
compiled; it is now declared, and rewritten against bytes dumped from real
corpus files with `exiftool -v3`.
Verified over /tmp/oxidex-exiftool-cache/combined-samples (4,238 files;
ExifTool 13.59 reports on 4,228) against `exiftool -a -G1 -s`, keyed
`Group1:Name`, case-sensitive, scored **per file**. Base is this branch's
merge-base, 5a8b835:
matched 393,562 -> 394,451 (+889 across 55 files)
regressions 0 -- matched-set diff per file, not totals
files worsened 0; extra keys unchanged at 108,146
Apple: keys emitted 0 -> 831, every one byte-identical to ExifTool
of the +889: 267 plist-derived, 622 unlocked by the IFD start, 8 Olympus
Apple_iPhone13Pro.jpg 126 -> 155
Apple_iPhone13ProMax.jpg 99 -> 127
Apple_iPhone15Pro.jpg 89 -> 116
Apple_iPhone12Pro.jpg 125 -> 152
Nothing is left over: every `Apple:` tag ExifTool reports for any corpus file,
and `Composite:RunTimeSincePowerUp`, now matches on every file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(vrd): read the CanonVRD Ver2 picture-style block
A .VRD recipe written by DPP 2.0 or later carries a second edit section
after the fixed 0x272-byte VRD1 record. oxidex read VRD1 and stopped, so
combined-samples/CanonVRD.vrd reported 43 of the 108 tags ExifTool does;
the missing 65 are the whole of %CanonVRD::Ver2's picture-style block --
PictureStyle, IsCustomPictureStyle, and a nine-tag group per style.
Reaching them needs two things. ProcessEditData sizes the three
%CanonVRD::Edit sections three different ways (CanonVRD.pm:1596-1610),
and the middle one, VRDStampTool, takes its length from an int32u at its
own start; skipping it wholesale lands VRD2 four bytes early. And the
record itself is FORMAT => 'int16s', so a tag ID is an index rather than
a byte offset and the values are signed -- StandardRawColorTone reads -4
on this file, which unsigned would print as 65532.
No table is transcribed for this. src/exiftool_tables already carries
CanonVRD::Ver2 dumped from ExifTool's own in-memory hash, layout and
PrintConv enums included, so the decoder reads that.
Ver2 is only read as far as index 0x54. Past it ExifTool leans on
ValueConv -- $val/0x400 rendered as a percentage, $val/10, $val/100 --
plus a DataMember-gated SubDirectory at 0xe0 and VRDVersion-conditional
branches at 0x5e-0x60. The generator drops a conversion it cannot
reproduce exactly, which leaves the raw value behind a real tag name, so
emitting those would print a confident wrong number rather than nothing.
They stay unread; a test asserts every entry below the bound is a bare
int16s or an integer enum, so a future ExifTool release cannot quietly
move one across it.
Measured on combined-samples/CanonVRD.vrd: 43 matched -> 108 matched,
65 missing -> 0, value-diffs 0, extras 0. The 43 tags already matched
are matched by the same values (set comparison, not counts), and
ExifTool.jpg and CanonRaw.crw are byte-identical before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(lfp): read Lytro light-field metadata
oxidex named the LFP file type but had no FileFormat variant, so
format_dispatch fell through to the unsupported arm and the file yielded
no tags at all. This adds the variant, the \x89LFP magic that routes to a
parser, and a reader transcribed from Image::ExifTool::Lytro (Lytro.pm 1.04).
The container walk follows ProcessLFP (Lytro.pm:134-174): 16-byte segment
headers, a big-endian length, an 80-byte sha1 ExifTool discards, then a
body that is either JSON metadata or an embedded JPEG, padded to 16 bytes.
Tag names come from ExtractTags (Lytro.pm:104-128), which flattens the JSON
with ucfirst on each key, drops punctuation while upcasing what follows,
and strips a leading Devices.
Numbers keep their source token text. These files carry more precision than
an f64 roundtrip preserves -- "gamma" : 0.41666001081466674805 is the
literal bytes on disk -- and ExifTool reports such values unchanged because
it never reformats what it did not compute. Only the tags carrying a
ValueConv or PrintConv are parsed to f64.
Measured on the ExifTool corpus sample (per file, keyed Group1:Name,
File/System excluded):
Lytro.lfp matched 0 -> 95 missing 95 -> 0 extra 0 -> 0
That is 85 Lytro tags plus the 10 Composite tags, which the existing
composite engine derives once the base tags exist. Zero regressions: the
matched key sets for CanonVRD.vrd, HTML.html and LNK.lnk are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(exif): print ExposureTime through ExifTool's PrintExposureTime
`ExifIFD:ExposureTime` was rendered by a hand-written formatter in
`tag_conversion.rs` that split at one second. ExifTool splits at a
quarter second (Exif.pm:5606, reached from the tag's
`PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at
Exif.pm:1824):
sub PrintExposureTime($)
{
my $secs = shift;
return $secs unless Image::ExifTool::IsFloat($secs);
if ($secs < 0.25001 and $secs > 0) {
return sprintf("1/%d",int(0.5 + 1/$secs));
}
$_ = sprintf("%.1f",$secs);
s/\.0$//;
return $_;
}
Three divergences, established by executing that subroutine from the
installed ExifTool 13.55 rather than by reading it:
seconds ExifTool was now
0.5555555556 0.6 1/2 0.6
0.769230769 0.8 1/1 0.8
0.8 0.8 1/1 0.8
4.0 4 4.0 4
30.0 30 30.0 30
0 0 1/18446744073709551615 0
The first class is the damaging one: every exposure in [0.25001, 1)
printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s
exposure is a perfectly plausible shutter speed, so nothing downstream
could tell it was wrong. The second is the trailing `.0` that `s/\.0$//`
strips. The third divided by a zero `$secs`.
`core::formatters::print_exposure_time` is already a verified port of
the subroutine -- it reproduces all fourteen probe values exactly -- so
this deletes the private copy and calls it. #340 consolidated sixteen
copies of PrintExposureTime but did not reach this one, which is the
copy that renders the EXIF ExposureTime tag itself.
Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus,
keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80"
cannot silently become 1.8:
differing (file,tag) pairs 40,917 -> 40,814 (-103)
fixed 109
ExifIFD:ExposureTime 78
Composite:ShutterSpeed 20
Composite:LightValue 10
IFD0:ExposureTime 1
newly differing 6
The six are all `Composite:LightValue`, and they are collateral from a
separate, pre-existing defect: the composite layer parses the *printed*
ExposureTime string instead of the ValueConv seconds, so it now rounds
from the correct `0.6` where it used to round from `1/2`. Ten other
LightValue files move the right way for the same reason (net -4). The
same defect is visible in `Composite:ISO`, which consumes the
PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712
(Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a
composite-layer fix and is left for its own change.
Two ExposureTime classes remain out of scope here, both in the rational
pipeline rather than the PrintConv: ExifTool rounds rational64u to ten
significant figures before the conversion (ExifTool.pm:6114), which is
why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a
zero denominator should print `undef`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(leica): route the "LEICA CAMERA AG" MakerNote to Panasonic::Main
The D-Lux 7, D-Lux 8 and V-Lux 5 are Panasonic-built and sign their
MakerNote "LEICA CAMERA AG\0". ExifTool dispatches that signature with
`MakerNoteLeica10` (MakerNotes.pm:724-731), which points at
`Image::ExifTool::Panasonic::Main` -- not at any Leica table:
Name => 'MakerNoteLeica10', # used by the D-Lux7
Condition => '$$valPt =~ /^LEICA CAMERA AG\0/',
TagTable => 'Image::ExifTool::Panasonic::Main',
Start => '$valuePtr + 18',
oxidex instead handed the payload to the Leica parser, which claimed the
header as `LeicaLayout::LongHeader`, skipped 15 bytes of it and decoded
nothing -- its own comment recorded that "no corresponding ExifTool table
has been identified". The result was silent: all three bodies reported
zero MakerNote tags where ExifTool reports ~100, with no warning.
The signature is 16 bytes and the IFD begins at 18, so two pad bytes sit
between them. LeicaD-Lux7.jpg opens
4c 45 49 43 41 20 43 41 4d 45 52 41 20 41 47 00 00 00 9d 00
-- "LEICA CAMERA AG\0", two NULs, then the 157-entry count.
`MakerNoteLeica10` declares no `Base`, so its out-of-line value offsets
are TIFF-header-relative exactly as `MakerNotePanasonic`'s are and need
no adjustment.
`LongHeader` is removed rather than left dead: recognising a signature
this parser has no table for shadowed the parser that does.
Verified against ExifTool 13.59 on the 4,238-file corpus, scored per file
and keyed Group1:Name:
Leica/LeicaD-Lux7.jpg 0 -> 80 MakerNote tags (+66 matching ExifTool)
Leica/LeicaD-Lux8.jpg 0 -> 80 (+65)
Leica/LeicaV-Lux5.jpg 0 -> 80 (+66)
+197 newly-matching keys, 0 regressions across all 4,238 files
(matched-key sets diffed per file, before vs after).
exiftool -G1 -s Leica/LeicaD-Lux7.jpg
[Panasonic] ImageQuality : RAW
[Panasonic] InternalSerialNumber : (XFL) 2018:09:06 no. 0007
[Panasonic] WhiteBalance : Auto
oxidex now prints the same three values.
Note this PR fixes dispatch only. The ~13 remaining value differences per
file (AFPointPosition, RollAngle, PhotoStyle, ...) are pre-existing
`Panasonic::Main` conversion gaps, not new: measured on origin/main they
already differ on up to 212 of the 303 Panasonic JPEGs that oxidex
already parsed. Tracked separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(leica): assert LEICA CAMERA AG is declined, matching Leica10 routing
The inline unit test in leica.rs was updated to the new behaviour but this
integration copy still asserted the old one, so Build & Test failed on the
very change it was meant to cover.
ExifTool's MakerNoteLeica10 (MakerNotes.pm:724-731) matches the signature
alone -- Condition => '$$valPt =~ /^LEICA CAMERA AG\0/' -- and routes to
Panasonic::Main at Start => '$valuePtr + 18', never to a Leica table. So
is_leica_makernote must decline it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(panasonic): drop six fabricated MakerNote registry entries
`registries/panasonic.rs` declared 93 tags. Six did not trace to
`%Image::ExifTool::Panasonic::Main`, and between them oxidex emitted
1,248 tag instances across the 479-file Panasonic corpus that ExifTool
reports none of:
0x003E TextStamp2 -> TextStamp (Panasonic.pm:809)
0x8008 TextStamp3 -> TextStamp (Panasonic.pm:1568)
0x8009 TextStamp4 -> TextStamp (Panasonic.pm:1574)
0x8007 FlashFired -> deleted (Panasonic.pm:1563, commented out)
0x8010 BabyAge2 -> deleted (id is `BabyAge`, Panasonic.pm:1580)
0x8012 Transform2 -> deleted (id is `Transform`, Panasonic.pm:1587)
ExifTool gives four different ids the same name `TextStamp` and two ids
the name `BabyAge`; inventing `Foo2`/`Foo3`/`Foo4` to disambiguate them
produces names that exist in zero ExifTool source files and can never
match real output. 0x003E/0x8008/0x8009 carry ExifTool's own PrintConv
({1=>'Off', 2=>'On'}) so they are renamed in place. 0x8010 and 0x8012
are omitted rather than renamed: 0x8010 is a `string` with a sentinel
PrintConv but was registered as a bare integer, and 0x8012 is an
`int16s` `Count => 2` whose PrintConv keys are integer *pairs*
('0 0' => 'Off', '-3 2' => 'Slim High'), which a single-value On/Off
decoder cannot express. 0x0033 and 0x0059 already deliver the real
`BabyAge` and `Transform` names. 0x8007 is disabled upstream:
`#0x8007 => { #PH - questionable [disabled because it conflicts with
EXIF in too many samples]`.
Two enum decoders were also invented on real tags, printing confidently
wrong values under real ExifTool names:
0x0077 BurstSpeed int16u, "images per second", no PrintConv
(Panasonic.pm:1094). Printed "Low" where
ExifTool prints "0", in 91 files.
0x009D InternalNDFilter rational64u, no PrintConv
(Panasonic.pm:1247). Printed
"Unknown (4620)" where ExifTool prints "0".
Both decoders are removed; `BURST_SPEED` and `INTERNAL_ND_FILTER` in
the parser had no other caller and are deleted.
Measured per file over 479 Panasonic samples, ExifTool 13.59 vs oxidex,
keyed Group1:Name (JSON parsed with parse_float=str):
matched 44668 -> 44759 (+91, BurstSpeed now agrees)
value_diff 1848 -> 1757 (-91)
missing 8057 -> 8057 (unchanged)
extra 12725 -> 11477 (-1248)
No file lost a matched key.
InternalNDFilter still disagrees (76 files): oxidex prints the entry's
value_offset rather than dereferencing the rational. That is the same
pre-existing gap that makes `AFPointPosition` print "3254" for
ExifTool's "0.47 0.48", and is not addressed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(olympus): read the OM System MakerNote and the TextInfo sub-record
Measured against ExifTool 13.59 over the 318-file Olympus corpus, scored
per file on `Group1:Name`. Three entangled `Olympus::Main` gaps:
1. `MakerNoteOlympus3`. The OM-1, OM-3, OM-5, OM-1 Mark II and TG-7 write
an "OM SYSTEM\0" header (MakerNotes.pm:589-597: `Start => '$valuePtr +
16'`, `Base => '$start - 16'`). The dispatcher already routed them here
on `Make = "OM Digital Solutions"`; only `validate_header` rejected
them, so those five files yielded zero Olympus tags. Adding the
signature took them from 400 matched / 858 missing to 1101 matched /
154 missing -- +701 tag instances, no matched key lost.
2. `Olympus::TextInfo` (0x0208). 129 of the 318 files carry a short ASCII
record -- `[pictureInfo] Resolution=3 [Camera Info] Type=SR951` --
which ExifTool scans with `APP12::ProcessAPP12` (Olympus.pm:1574).
The separator is a space, not CR/LF, so the existing APP12 readers in
`parsers::jpeg::app_segments` would see one token and emit nothing;
`text_info::scan` ports the tokenizer at APP12.pm:262 instead.
125 files x {Resolution, CameraType} = 250 tag instances.
3. `Main` 0x0207 `CameraType` and 0x0201 `Quality`, which the table walk
cannot express: `CameraType` is a `DataMember` that `Quality`'s
PrintConv consults (Olympus.pm:725), and `TextInfo` carries a second
`CameraType` that overwrites it. The FE240/SP510UZ/u730/u1000
placeholder is padded to "NORMAL ", which is not `eq "NORMAL"`, so
the Condition passes and ExifTool really does print `Unknown (NORMAL)`
before TextInfo replaces it -- reproduced rather than special-cased.
Also replaces `TagDef::raw(0x0821, "ISOAutoSettings")`, which printed the
raw "0 0" where ExifTool prints "n/a; n/a", with the two-element list
PrintConv transcribed from ExifTool's own loaded table.
wip: the release binary and test run were cut short by machine load, so
the whole-corpus after-measurement is not yet recorded. Per-item deltas
above are measured; item 1 was verified end to end against a built
binary, items 2 and 3 compile and are unit-tested but unmeasured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(lnk): emit ExifTool's LNK tag names and groups
The LNK parser is reachable -- format_dispatch.rs:173 sends FileFormat::LNK
to parse_lnk_metadata -- but it reported invented, ungrouped names:
CreationTime, Name, IconLocation, VolumeSerialNumber, MACAddress,
DroidFileID, DroidVolumeID, KnownFolderID, LinkFlagsDescription,
HasPropertyStore. ExifTool names none of those, so all 43 tags it extracts
from a Windows shortcut scored as MISSING. Measured on
combined-samples/LNK.lnk: MATCHED 0/43 before, 43/43 after, EXTRA 0.
Rewrite the module against Image::ExifTool::LNK (LNK.pm 1.18), citing the
Perl line for each transcribed table and conversion. Three structures the
old parser never walked are now decoded: ProcessItemID with its 0xbeef
extension split and 0x20/0x30/0x40 ID-range folding (TargetInfo and
Beef0004), %LNK::ConsoleData (17 tags), and %LNK::TrackerData. LinkInfo
gains DriveType, DriveSerialNumber, VolumeLabel, CommonPathSuffix and its
Unicode form, NetName, DeviceName and the 68-entry NetProviderType table.
Every PrintConv comes out of LNK.pm rather than being approximated, and a
record whose conversion cannot be reproduced exactly is omitted rather than
reported under a real ExifTool name. Still open on purpose: the ItemID
sub-types other than TargetInfo/Beef0004, ConsoleFEData's CodePage (needs
%Microsoft::codePage, untranscribed here) and EnvVarData.
Differential-testing 150 truncated and byte-flipped shortcuts against real
ExifTool raised agreement from 69% to 93% and exposed three defects, each
fixed and covered by a test:
- a short string read aborted the walk, where LNK.pm:1806 tests
$raf->Read for truth rather than for the full count and continues
- Decode(..., 'UTF16') stops at the first null, so a padded field
reports only the text before the padding
- chrono's %Y prefixes a five-digit year with '+'; sprintf("%4d:...")
does not
tests/forensic/lnk_tests.rs asserted the old invented names, so 15 failing
tests are rewritten against ExifTool's names and exact PrintConv strings,
plus a new test that fails on any ungrouped or invented tag. Two fixtures
that were structurally malformed were corrected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
swackhamer
added a commit
that referenced
this pull request
Aug 2, 2026
…rd (#401) * fix(composite): preserve raw APEX conversion precision * fix(fleet): require current-head PR approval * fix(tables): decode repeated scalar fields * fix(exif): one Compression table, dumped from ExifTool's own Perl Three transcriptions of `%Image::ExifTool::Exif::compression` (Exif.pm:213) lived in this tree, carrying 50, 40 and 53 ids and disagreeing on the spelling of codes all three claimed to know: core/formatters/exif_enums.rs 32766 => "Next" parsers/tiff/tiff_enums.rs 32766 => "Next" parsers/pdf/mod.rs 32766 => "NeXt or Sony ARW Compressed 2" ExifTool 13.59 says: 32766 => 'NeXt or Sony ARW Compressed 2', #3/Milos Rather than read the Perl and re-implement it -- the way three divergent copies got written in the first place -- the hash was dumped straight out of the Perl symbol table: perl -Ilib -MImage::ExifTool::Exif -e ' my $h = \%Image::ExifTool::Exif::compression; for my $v (0..70000) { print "$v\t", (exists $h->{$v} ? $h->{$v} : "Unknown ($v)"), "\n" }' and each copy scored over all 70,001 inputs: exif_enums::format_compression 50 ids 10 wrong tiff_enums 0x0103 arm 40 ids 15 wrong pdf COMPRESSION_LABELS 53 ids 1 wrong consolidated table 53 ids 0 wrong The wrong answers, all now fixed: `9` read `JBIG B&W` (13.59 renamed it `JBIG B&W or VC-5`); `32766` read `Next`; `33003`, `33004` and `33005` all collapsed to `Aperio JPEG 2000 YCbCr`, where 33005 is `Aperio JPEG 2000 RGB` and 33004 is not an ExifTool key at all; `34926`/`34927` dropped the ` (old)` suffix that separates them from the LibTiff 4.7 codes; and `50000` `Zstd`, `50001` `WebP`, `50002` `JPEG XL (old)`, `52546` `JPEG XL`, plus `32772`, `33003`, `34887`, `34925`, `34933`, `34934` on the TIFF path, were absent. `compression_label` returns `Option<&'static str>` so each caller keeps its established unknown-value behaviour: `format_compression` renders `Unknown (N)` the way `PrintConv` does, while the TIFF and PDF paths still return `None` rather than invent a label ExifTool never prints. Why the old tests stayed green: `test_compression` asserted 1, 6 and 7 -- three codes all three tables agreed on -- and `tiff_enums.rs` had no test module at all. Not one input reached a wrong branch. The new tests assert every one of the 15 codes that was measurably wrong; reverting the table makes 5 of them fail. Corpus check (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`): 393,343 correct before and after, matched-set delta fixed=0 regressed=0. The corpus stores only Compression 1/4/5/6/7/34713 plus two codes ExifTool does not name, so it exercises none of the divergent ids -- the divergence is measured against the Perl, and the corpus proves the consolidation costs nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(exif): Flash is a lookup table, not a bitfield (#393) Exif.pm 0x9209 is `PrintConv => \%flash` -- a flat 27-key hash -- plus `Flags => 'PrintHex'`. `core::exif_enums::decode_flash` instead synthesised the label from the byte's bit fields (fired / strobe return / mode / function present / red-eye). Those bits explain how ExifTool's 27 codes were *chosen*; they are not how the tag is rendered, and 229 of the 256 byte values are not valid Flash codes at all. Scored against `%Image::ExifTool::Exif::flash` dumped out of the Perl symbol table, over every input in 0..=255: core::exif_enums::decode_flash 236 wrong / 256 consolidated table 0 wrong / 256 Two verbatim copies of the correct table already existed -- `FLASH_LABELS` in `parsers::pdf` and the `0x9209` arm of `parsers::raw::metadata` -- so the tree held three Flash decoders, and only the one on the main EXIF path was wrong. All three now share `core::formatters::exif_enums::flash_label`. Three classes of wrong answer, all corpus-visible: - codes ExifTool leaves unknown got a plausible label. 0x38 and 0x28 both came back `No flash function`; ExifTool prints `Unknown (0x38)` and `Unknown (0x28)` -- 60 files. - codes it did know were mis-worded: 0x49 gave `On, Fired, Red-eye reduction` where the hash says `On, Red-eye reduction` -- 23 files. Same for 0x0d, 0x0f, 0x4d, 0x4f, 0x50. - `format_flash`'s unknown form was decimal; `PrintHex` makes it hex. Why the old test stayed green: `test_flash_decoding` had 13 assertions, each annotated with the bit layout it was deriving -- and one of them, `decode_flash(0x49) == "On, Fired, Red-eye reduction"`, asserted a string ExifTool never prints. The test encoded the same wrong model as the code. It is rewritten to check the hash, and three new tests cover the mis-worded codes, the hex unknown form, and the 27-of-256 key count; reverting the table fails four of them. Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`): BEFORE correct=393343 valdiff=17058 score=77.5097% AFTER correct=393467 valdiff=16934 score=77.5341% MATCHED-SET DELTA fixed=124 regressed=0 PER-FILE 124 files +1, 0 down FORMAT JPEG 390485 -> 390609 (+124) Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(exif): FocalPlaneResolutionUnit decodes instead of printing its code (#395) Exif.pm 0xa210 declares a PrintConv: 0xa210 => { Name => 'FocalPlaneResolutionUnit', Notes => 'values 1, 4 and 5 are not standard EXIF', PrintConv => { 1 => 'None', 2 => 'inches', 3 => 'cm', 4 => 'mm', 5 => 'um' }, }, The tree already held that table -- in `parsers::pdf`, reachable only from a PDF's embedded TIFF thumbnail. The main EXIF path had no arm for the tag at all, so `format_tag_value` returned the raw integer and 1,098 files in the sample corpus reported `2` and `3` where ExifTool reports `inches` and `cm`. The table moves to `core::formatters::exif_enums` next to the Compression and Flash tables, PDF reads it from there, and the `exiftool_compat` dispatch gains the arm it was missing. `Notes => 'values 1, 4 and 5 are not standard EXIF'` is a note, not an exclusion: ExifTool decodes all five, and the corpus holds 4 files at `4` and 1 at `5`. 0xa210 carries no `PrintHex`, so unnamed codes stay decimal -- `Unknown (0)`, which 4 corpus files report. Safe for the composites: `Exif ScaleFactor35efl` -- which gates FocalLength35efl, CircleOfConfusion, HyperfocalDistance, FOV and DOF -- already matches either spelling (`Some("3") | Some("cm")`). Measured: zero `Composite:*` values changed anywhere in the corpus. A unit test on the new function would not have caught this: the table already existed and was already correct. The regression test asserts the *dispatch*, through `format_tag_value`; deleting the new arm fails it with `left: Integer(1), right: String("None")`. Corpus (4,238 files, exiftool 13.59 `-a -G1 -s`, keyed `Group:Name`): BEFORE correct=393467 valdiff=16934 score=77.5341% AFTER correct=394565 valdiff=15836 score=77.7505% MATCHED-SET DELTA fixed=1098 regressed=0 PER-FILE 1098 files +1, 0 down FORMAT JPEG +1094, DNG +1, CR2 +1, RAF +1, BPG +1 Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(write): report the dropped write instead of printing success `plan_exif_write` may emit only one IFD record per tag id, so when two metadata-map keys resolve to the same id the second cannot be written. The guard that enforced this used `continue`, discarding the caller's edit while the CLI still printed "1 image files updated" and exited 0: $ oxidex -ExifIFD:ExposureBiasValue=-0.5 photo.jpg # 0x9204 1 image files updated # exit 0 $ exiftool -a -G1 -s -ExposureCompensation photo.jpg [ExifIFD] ExposureCompensation : -3/2 # unchanged Before #368 this failed loudly with a type error; #368 fixed the typing and the failure went quiet. ExifTool refuses the same command. `ExposureBiasValue` is not a writable tag name in any of its tables -- only a Notes remark on 0x9204 ("called ExposureBiasValue by the EXIF spec", Exif.pm:2369) and an XMP property whose tag name is again ExposureCompensation (XMP.pm:2096-2097); `TagLookup.pm` has no `exposurebiasvalue` key, so `TagExists` fails and `SetNewValue` returns "Tag '...' is not defined" (Writer.pl:581-584) with exit 1. A collision that would lose an edit is now refused with a message naming both keys and the id; a collision that loses nothing -- the same value already planned under another spelling, which is what the documented `-EXIF:Tag=value` syntax produces -- is still skipped silently. Two further defects fall out of the same guard: * `-EXIF:ExposureTime=1/250` wrote 0x829A into *both* IFD0 and ExifIFD. An "EXIF:" key names the tag family, not a physical IFD, so the collision search had to span IFD0/ExifIFD/GPS rather than only the IFD0 fallback. * `-ExifIFD:ExposureCompensation=-0.5` serialized ASCII "-0.5" into a rational64s tag, because the registry declared 0x9204 `String`. Corrected, along with 46 other EXIF tags whose declared type disagreed with ExifTool's `Writable`, by adding the missing `type:` to the YAML tag database. The set was derived mechanically from ExifTool 13.59's own Perl tables and restricted to scalar numeric/date tags with no ValueConv and no blob flag -- multi-value, ValueConv and `undef`-storage tags are deliberately untouched, since for those ExifTool's accepted input type comes from PrintConvInv/ValueConvInv rather than the storage code (FileSource's Integer, for one, is already correct). ModifyDate and CreateDate are among the 47: both now normalize `2024-01-15T10:30:00` to ExifTool's stored `2024:01:15 10:30:00`. #358's write path is untouched: a binary built from origin/main and one built from this branch produce byte-identical output on 24 files (9 corpus RAW/TIFF/ PNG/JPS + 12 corpus JPEGs + 3 fixtures) for an ordinary `-IFD0:Artist=` write, and all 11 files from #358's table still accept writes with no metadata lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(pentax): enter the five MakerNote sub-directories gated on the camera model `exiftool -v2` descends into 563 of the corpus's files with a Pentax or FujiFilm MakerNote. Comparing per file against `exiftool -a -G1 -s` (ExifTool 13.59), five of the directories it enters produced no oxidex row at all -- not a wrong value, not a missing-tag report, nothing, because a directory that is never entered has no rows to be missing: tag ExifTool table files tags 0x0216 %Pentax::BatteryInfo 30 129 0x021f %Pentax::AFInfo 28 111 0x022a %Pentax::FilterInfo 18 36 0x03ff %Pentax::TempInfo 8 22 0x0226 %Pentax::ShotInfo 6 6 These are plain `ProcessBinaryData` records under a MakerNote oxidex already reads, so the transcription is the generator's job. What kept them out is that almost every field in them carries an ExifTool `Condition` on the camera model, and `%Pentax::BatteryInfo` is an arrayref of such alternatives at nearly every offset -- the construct `codegen_subdirs.py` logged as "arrayref of Condition variants" and refused. Byte 2 of that record alone is `BodyBatteryADNoLoad` on a K10D with one calibration `PrintConv`, an uncalibrated `BodyBatteryADNoLoad` on a *istD, half of an `int16u` `BodyBatteryVoltage1` on a K-5, and `BodyBatteryState` on a K-3 III (Pentax.pm:4846-4935). Read without the condition it is one number under four different real tag names. So the generator grew a vocabulary rather than a special case: - `Condition` over `$$self{Model}` -- `=~`, `!~`, `eq`, and the `A and B` conjunction Pentax.pm:4864 uses to hold the K-3 III out of a pattern that would otherwise catch it. The regexes are expanded to literal alternations at generation time (32 branches, `\*ist` and `GX-1[LS]?` and `K-(1|01|3|5|7|30|50|70|500|r|x|S[12])` included) by a parser that raises on any construct it has not been taught, so the model test stays derived from ExifTool's own pattern text instead of a hand-typed list. - alternatives as several `Field`s sharing one ExifTool key, in ExifTool's order; the decoder takes the first whose condition holds and reports nothing when none does. A key any of whose alternatives is refused is dropped whole -- removing an earlier one would let a later, broader one answer for a body it was never meant to describe. - `PrintConv` as a Perl expression, and `ValueConv` composed with it, which is what makes a raw 686 of centivolts print "6.86 V" rather than "686.00 V". - `Field` now carries ExifTool's own key text, because `545`, `545.1` and `545.2` are three masked tags at one offset while two entries keyed `2` are two readings of one tag. 0x021f and 0x0216 declare `ByteOrder => 'BigEndian'` on the SubDirectory and are read that way whatever the MakerNote's own order is (Pentax.pm:2983-2986, :2949). 0x022a is one table read either way round, chosen by `$$self{Make} =~ /^RICOH/` (Pentax.pm:3032) -- the brand, not the model -- so `PentaxParser` now carries that test from the dispatcher rather than guessing it from the model. Every constant is quoted from Pentax.pm in the code; the unit tests replay the exact record bytes `exiftool -v3` prints for PentaxK10D.jpg and PentaxK-5IIs.jpg against the exact values `exiftool -a -G1 -s` reports for those files. Regenerating reproduces the committed tables byte for byte, and the FujiFilm and Panasonic tables are field-identical to before apart from the two new columns. Measured per file over /tmp/oxidex-exiftool-cache/combined-samples against ExifTool 13.59, keyed `Group1:Name`, both binaries built from this tree: 563 Pentax/FujiFilm files matched 45,133 -> 45,437 (+304 across 31 files) missing 12,375 -> 12,065 regressions 0 PentaxK-5IIs.jpg 197 -> 214 PentaxK-5.jpg 187 -> 204 PentaxK-r.jpg 184 -> 197 PentaxK10D.jpg 148 -> 159 Six values on Pentax_istD.jpg are wrong, and are named here rather than buried: that file's MakerNote offsets need ExifTool's `FixBase` correction (ExifTool resolves its 0x0216 pointer 16 bytes below the TIFF header), which oxidex does not implement -- so all of its out-of-line values are misread today, 75 of them before this change. The six new ones are the same pre-existing fault reaching six more names, not a new one. Still not entered, with the generator's own reason: `%Pentax::CameraSettings` (a hand-written arm already owns 0x0205 and covers tags the generator refuses), `FilterInfo`'s 20 `DigitalFilterNN` fields (a `RawConv` unpack idiom), `%Pentax::AEInfo`/`AEInfo2`/`AEInfo3` (`PentaxEv` conversions), `%Pentax::CAFPointInfo` (`DecodeAFPoints`), and every `BITMASK` `PrintConv`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(apple): find the MakerNote IFD, then read its binary plists `MakerNotes.pm:37-46` starts the Apple directory at `$valuePtr + 14` -- ten bytes of `Apple iOS\0`, two version bytes, then the two-byte order marker. oxidex started it at byte 10, so on every iPhone it read the order marker "MM" as an entry count of 1 and decoded one entry out of the count field and the first tag id, arriving at tag 0x4d4d, which no table has. The result was not a wrong value; it was silence. **Zero `Apple:` tags over the whole sample corpus**, against the 831 ExifTool reports. That is why the binary plists could not be reached. Four `%Apple::Main` tags hold a whole `bplist00` blob rather than a value: 0x0003 RunTime SubDirectory over %Apple::RunTime, whose PROCESS_PROC is PLIST::ProcessBinaryPLIST (Apple.pm:40-43, :324-325) 0x0040 SemanticStyle ValueConv => \&ConvertPLIST (Apple.pm:276) 0x0041 SemanticStyleRenderingVer ditto (Apple.pm:280) 0x0042 SemanticStylePreset ditto (Apple.pm:284) This adds: - `shared/binary_plist.rs`, a transcription of `Image::ExifTool::PLIST` -- `ProcessBinaryPLIST`'s trailer and offset table (PLIST.pm:398-450) and `ExtractObject`'s object grammar (PLIST.pm:260-390), plus `XMP::SerializeStruct` (XMPStruct.pl:34-69) for the `ConvertPLIST` case. Three details decide whether the output matches: * integers are read **unsigned**: `%readProc` is Get8u/16u/24u/32u/64u (PLIST.pm:30-38), so `RunTimeValue` is 235706184764708 and not a negative number. * a size that `%readProc` has no entry for returns undef rather than a guess -- so only 1/2/3/4/8-byte integers and 4/8-byte reals decode. * `SerializeStruct` walks `OrderedKeys`, which for a hash built by `ExtractObject` is a plain `sort keys`. `Apple_iPhone13Pro.jpg` stores its `SemanticStyle` keys in the order 3,1,2,0 and ExifTool prints `{_0=1,_1=0.5,_2=0,_3=2}`. Storage order would print the reverse. A plist date is decoded and then dropped: ExifTool converts it with `ConvertUnixTime($val + 11323*24*3600, 1)`, and that second argument is `$toLocal`, so the string carries the extracting machine's time zone. No Apple blob in the corpus has one, and a value that depends on the reader's clock is not one to approximate. - `%Apple::Main` itself, transcribed into `apple.rs` -- each entry's `Writable` format and its `PrintConv` verbatim, walked by the existing `shared::table_ifd`. The table's twelve `Unknown => 1` entries are absent on purpose; ExifTool reports those only under `-u`. - `Composite:RunTimeSincePowerUp` (Apple.pm:348-357), which was declared in `composite/tables.rs` with no arm in `compute`. Its `ConvertDuration` PrintConv already existed, privately, inside the MTS parser; it moves to `core::formatters::duration` rather than being copied. Three defects found on the way, each measured: - `table_ifd::print_rational` printed `%.15g`. `GetRational64s`/`u` (ExifTool.pm:6107-6120) end in `RoundFloat($num/$den, 10)`, so ExifTool prints `AccelerationVector` as `-0.9245480894`, not `-0.924548089390588`. Fixing it also gained `Olympus:FocalPlaneDiagonal` (+5) and `Olympus:DigitalZoom` (+3). - `table_ifd` had no `int64u`/`int64s` (ExifTool format codes 16 and 17). `Apple_iPhone15Pro.jpg` stores `LivePhotoVideoIndex` as `int64u[1]` = 4294967700; an int64u above `i64::MAX` is dropped rather than printed negative. - `parse_exif_subifd` kept only the *last* 0x927C entry. `Apple_iPhone6.jpg` declares two -- the camera's, and a 142-byte UTF-16 JSON blob an editor appended -- so the Apple parser was handed the wrong 142 bytes and reported nothing. Each entry is now processed in turn, as ExifTool does. Deleted `registries/apple.rs`. It declared a `FrontFacingCamera` at 0x0032 and a `PortraitMode` at 0x0020 -- `%Apple::Main` has no tag at 0x0032 at all, and 0x0020 is `ImageCaptureRequestID` -- plus enum decoders for `OISMode`, `SemanticStyle`, `SignalToNoiseRatioType` and `GreenGhostMitigationStatus`, none of which has a `PrintConv` in Apple.pm. `tests/integration.rs` never declared `apple_makernotes_tests`, so the file asserting those names never compiled; it is now declared, and rewritten against bytes dumped from real corpus files with `exiftool -v3`. Verified over /tmp/oxidex-exiftool-cache/combined-samples (4,238 files; ExifTool 13.59 reports on 4,228) against `exiftool -a -G1 -s`, keyed `Group1:Name`, case-sensitive, scored **per file**. Base is this branch's merge-base, 5a8b835: matched 393,562 -> 394,451 (+889 across 55 files) regressions 0 -- matched-set diff per file, not totals files worsened 0; extra keys unchanged at 108,146 Apple: keys emitted 0 -> 831, every one byte-identical to ExifTool of the +889: 267 plist-derived, 622 unlocked by the IFD start, 8 Olympus Apple_iPhone13Pro.jpg 126 -> 155 Apple_iPhone13ProMax.jpg 99 -> 127 Apple_iPhone15Pro.jpg 89 -> 116 Apple_iPhone12Pro.jpg 125 -> 152 Nothing is left over: every `Apple:` tag ExifTool reports for any corpus file, and `Composite:RunTimeSincePowerUp`, now matches on every file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(vrd): read the CanonVRD Ver2 picture-style block A .VRD recipe written by DPP 2.0 or later carries a second edit section after the fixed 0x272-byte VRD1 record. oxidex read VRD1 and stopped, so combined-samples/CanonVRD.vrd reported 43 of the 108 tags ExifTool does; the missing 65 are the whole of %CanonVRD::Ver2's picture-style block -- PictureStyle, IsCustomPictureStyle, and a nine-tag group per style. Reaching them needs two things. ProcessEditData sizes the three %CanonVRD::Edit sections three different ways (CanonVRD.pm:1596-1610), and the middle one, VRDStampTool, takes its length from an int32u at its own start; skipping it wholesale lands VRD2 four bytes early. And the record itself is FORMAT => 'int16s', so a tag ID is an index rather than a byte offset and the values are signed -- StandardRawColorTone reads -4 on this file, which unsigned would print as 65532. No table is transcribed for this. src/exiftool_tables already carries CanonVRD::Ver2 dumped from ExifTool's own in-memory hash, layout and PrintConv enums included, so the decoder reads that. Ver2 is only read as far as index 0x54. Past it ExifTool leans on ValueConv -- $val/0x400 rendered as a percentage, $val/10, $val/100 -- plus a DataMember-gated SubDirectory at 0xe0 and VRDVersion-conditional branches at 0x5e-0x60. The generator drops a conversion it cannot reproduce exactly, which leaves the raw value behind a real tag name, so emitting those would print a confident wrong number rather than nothing. They stay unread; a test asserts every entry below the bound is a bare int16s or an integer enum, so a future ExifTool release cannot quietly move one across it. Measured on combined-samples/CanonVRD.vrd: 43 matched -> 108 matched, 65 missing -> 0, value-diffs 0, extras 0. The 43 tags already matched are matched by the same values (set comparison, not counts), and ExifTool.jpg and CanonRaw.crw are byte-identical before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(lfp): read Lytro light-field metadata oxidex named the LFP file type but had no FileFormat variant, so format_dispatch fell through to the unsupported arm and the file yielded no tags at all. This adds the variant, the \x89LFP magic that routes to a parser, and a reader transcribed from Image::ExifTool::Lytro (Lytro.pm 1.04). The container walk follows ProcessLFP (Lytro.pm:134-174): 16-byte segment headers, a big-endian length, an 80-byte sha1 ExifTool discards, then a body that is either JSON metadata or an embedded JPEG, padded to 16 bytes. Tag names come from ExtractTags (Lytro.pm:104-128), which flattens the JSON with ucfirst on each key, drops punctuation while upcasing what follows, and strips a leading Devices. Numbers keep their source token text. These files carry more precision than an f64 roundtrip preserves -- "gamma" : 0.41666001081466674805 is the literal bytes on disk -- and ExifTool reports such values unchanged because it never reformats what it did not compute. Only the tags carrying a ValueConv or PrintConv are parsed to f64. Measured on the ExifTool corpus sample (per file, keyed Group1:Name, File/System excluded): Lytro.lfp matched 0 -> 95 missing 95 -> 0 extra 0 -> 0 That is 85 Lytro tags plus the 10 Composite tags, which the existing composite engine derives once the base tags exist. Zero regressions: the matched key sets for CanonVRD.vrd, HTML.html and LNK.lnk are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(exif): print ExposureTime through ExifTool's PrintExposureTime `ExifIFD:ExposureTime` was rendered by a hand-written formatter in `tag_conversion.rs` that split at one second. ExifTool splits at a quarter second (Exif.pm:5606, reached from the tag's `PrintConv => 'Image::ExifTool::Exif::PrintExposureTime($val)'` at Exif.pm:1824): sub PrintExposureTime($) { my $secs = shift; return $secs unless Image::ExifTool::IsFloat($secs); if ($secs < 0.25001 and $secs > 0) { return sprintf("1/%d",int(0.5 + 1/$secs)); } $_ = sprintf("%.1f",$secs); s/\.0$//; return $_; } Three divergences, established by executing that subroutine from the installed ExifTool 13.55 rather than by reading it: seconds ExifTool was now 0.5555555556 0.6 1/2 0.6 0.769230769 0.8 1/1 0.8 0.8 0.8 1/1 0.8 4.0 4 4.0 4 30.0 30 30.0 30 0 0 1/18446744073709551615 0 The first class is the damaging one: every exposure in [0.25001, 1) printed as a fraction ExifTool never emits, and `1/2` for a 5/9 s exposure is a perfectly plausible shutter speed, so nothing downstream could tell it was wrong. The second is the trailing `.0` that `s/\.0$//` strips. The third divided by a zero `$secs`. `core::formatters::print_exposure_time` is already a verified port of the subroutine -- it reproduces all fourteen probe values exactly -- so this deletes the private copy and calls it. #340 consolidated sixteen copies of PrintExposureTime but did not reach this one, which is the copy that renders the EXIF ExposureTime tag itself. Measured against `exiftool -a -G1 -s` over the 4,238-file sample corpus, keyed Group1:Name, with ExifTool's JSON parsed as strings so "1.80" cannot silently become 1.8: differing (file,tag) pairs 40,917 -> 40,814 (-103) fixed 109 ExifIFD:ExposureTime 78 Composite:ShutterSpeed 20 Composite:LightValue 10 IFD0:ExposureTime 1 newly differing 6 The six are all `Composite:LightValue`, and they are collateral from a separate, pre-existing defect: the composite layer parses the *printed* ExposureTime string instead of the ValueConv seconds, so it now rounds from the correct `0.6` where it used to round from `1/2`. Ten other LightValue files move the right way for the same reason (net -4). The same defect is visible in `Composite:ISO`, which consumes the PrintConv'd `Canon:AutoISO` 283 rather than the ValueConv 282.842712 (Canon.pm:2782) and so prints 142 where ExifTool prints 141. That is a composite-layer fix and is left for its own change. Two ExposureTime classes remain out of scope here, both in the rational pipeline rather than the PrintConv: ExifTool rounds rational64u to ten significant figures before the conversion (ExifTool.pm:6114), which is why it prints `1/331` for 10/3315 where oxidex prints `1/332`; and a zero denominator should print `undef`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(leica): route the "LEICA CAMERA AG" MakerNote to Panasonic::Main The D-Lux 7, D-Lux 8 and V-Lux 5 are Panasonic-built and sign their MakerNote "LEICA CAMERA AG\0". ExifTool dispatches that signature with `MakerNoteLeica10` (MakerNotes.pm:724-731), which points at `Image::ExifTool::Panasonic::Main` -- not at any Leica table: Name => 'MakerNoteLeica10', # used by the D-Lux7 Condition => '$$valPt =~ /^LEICA CAMERA AG\0/', TagTable => 'Image::ExifTool::Panasonic::Main', Start => '$valuePtr + 18', oxidex instead handed the payload to the Leica parser, which claimed the header as `LeicaLayout::LongHeader`, skipped 15 bytes of it and decoded nothing -- its own comment recorded that "no corresponding ExifTool table has been identified". The result was silent: all three bodies reported zero MakerNote tags where ExifTool reports ~100, with no warning. The signature is 16 bytes and the IFD begins at 18, so two pad bytes sit between them. LeicaD-Lux7.jpg opens 4c 45 49 43 41 20 43 41 4d 45 52 41 20 41 47 00 00 00 9d 00 -- "LEICA CAMERA AG\0", two NULs, then the 157-entry count. `MakerNoteLeica10` declares no `Base`, so its out-of-line value offsets are TIFF-header-relative exactly as `MakerNotePanasonic`'s are and need no adjustment. `LongHeader` is removed rather than left dead: recognising a signature this parser has no table for shadowed the parser that does. Verified against ExifTool 13.59 on the 4,238-file corpus, scored per file and keyed Group1:Name: Leica/LeicaD-Lux7.jpg 0 -> 80 MakerNote tags (+66 matching ExifTool) Leica/LeicaD-Lux8.jpg 0 -> 80 (+65) Leica/LeicaV-Lux5.jpg 0 -> 80 (+66) +197 newly-matching keys, 0 regressions across all 4,238 files (matched-key sets diffed per file, before vs after). exiftool -G1 -s Leica/LeicaD-Lux7.jpg [Panasonic] ImageQuality : RAW [Panasonic] InternalSerialNumber : (XFL) 2018:09:06 no. 0007 [Panasonic] WhiteBalance : Auto oxidex now prints the same three values. Note this PR fixes dispatch only. The ~13 remaining value differences per file (AFPointPosition, RollAngle, PhotoStyle, ...) are pre-existing `Panasonic::Main` conversion gaps, not new: measured on origin/main they already differ on up to 212 of the 303 Panasonic JPEGs that oxidex already parsed. Tracked separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(leica): assert LEICA CAMERA AG is declined, matching Leica10 routing The inline unit test in leica.rs was updated to the new behaviour but this integration copy still asserted the old one, so Build & Test failed on the very change it was meant to cover. ExifTool's MakerNoteLeica10 (MakerNotes.pm:724-731) matches the signature alone -- Condition => '$$valPt =~ /^LEICA CAMERA AG\0/' -- and routes to Panasonic::Main at Start => '$valuePtr + 18', never to a Leica table. So is_leica_makernote must decline it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(panasonic): drop six fabricated MakerNote registry entries `registries/panasonic.rs` declared 93 tags. Six did not trace to `%Image::ExifTool::Panasonic::Main`, and between them oxidex emitted 1,248 tag instances across the 479-file Panasonic corpus that ExifTool reports none of: 0x003E TextStamp2 -> TextStamp (Panasonic.pm:809) 0x8008 TextStamp3 -> TextStamp (Panasonic.pm:1568) 0x8009 TextStamp4 -> TextStamp (Panasonic.pm:1574) 0x8007 FlashFired -> deleted (Panasonic.pm:1563, commented out) 0x8010 BabyAge2 -> deleted (id is `BabyAge`, Panasonic.pm:1580) 0x8012 Transform2 -> deleted (id is `Transform`, Panasonic.pm:1587) ExifTool gives four different ids the same name `TextStamp` and two ids the name `BabyAge`; inventing `Foo2`/`Foo3`/`Foo4` to disambiguate them produces names that exist in zero ExifTool source files and can never match real output. 0x003E/0x8008/0x8009 carry ExifTool's own PrintConv ({1=>'Off', 2=>'On'}) so they are renamed in place. 0x8010 and 0x8012 are omitted rather than renamed: 0x8010 is a `string` with a sentinel PrintConv but was registered as a bare integer, and 0x8012 is an `int16s` `Count => 2` whose PrintConv keys are integer *pairs* ('0 0' => 'Off', '-3 2' => 'Slim High'), which a single-value On/Off decoder cannot express. 0x0033 and 0x0059 already deliver the real `BabyAge` and `Transform` names. 0x8007 is disabled upstream: `#0x8007 => { #PH - questionable [disabled because it conflicts with EXIF in too many samples]`. Two enum decoders were also invented on real tags, printing confidently wrong values under real ExifTool names: 0x0077 BurstSpeed int16u, "images per second", no PrintConv (Panasonic.pm:1094). Printed "Low" where ExifTool prints "0", in 91 files. 0x009D InternalNDFilter rational64u, no PrintConv (Panasonic.pm:1247). Printed "Unknown (4620)" where ExifTool prints "0". Both decoders are removed; `BURST_SPEED` and `INTERNAL_ND_FILTER` in the parser had no other caller and are deleted. Measured per file over 479 Panasonic samples, ExifTool 13.59 vs oxidex, keyed Group1:Name (JSON parsed with parse_float=str): matched 44668 -> 44759 (+91, BurstSpeed now agrees) value_diff 1848 -> 1757 (-91) missing 8057 -> 8057 (unchanged) extra 12725 -> 11477 (-1248) No file lost a matched key. InternalNDFilter still disagrees (76 files): oxidex prints the entry's value_offset rather than dereferencing the rational. That is the same pre-existing gap that makes `AFPointPosition` print "3254" for ExifTool's "0.47 0.48", and is not addressed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(olympus): read the OM System MakerNote and the TextInfo sub-record Measured against ExifTool 13.59 over the 318-file Olympus corpus, scored per file on `Group1:Name`. Three entangled `Olympus::Main` gaps: 1. `MakerNoteOlympus3`. The OM-1, OM-3, OM-5, OM-1 Mark II and TG-7 write an "OM SYSTEM\0" header (MakerNotes.pm:589-597: `Start => '$valuePtr + 16'`, `Base => '$start - 16'`). The dispatcher already routed them here on `Make = "OM Digital Solutions"`; only `validate_header` rejected them, so those five files yielded zero Olympus tags. Adding the signature took them from 400 matched / 858 missing to 1101 matched / 154 missing -- +701 tag instances, no matched key lost. 2. `Olympus::TextInfo` (0x0208). 129 of the 318 files carry a short ASCII record -- `[pictureInfo] Resolution=3 [Camera Info] Type=SR951` -- which ExifTool scans with `APP12::ProcessAPP12` (Olympus.pm:1574). The separator is a space, not CR/LF, so the existing APP12 readers in `parsers::jpeg::app_segments` would see one token and emit nothing; `text_info::scan` ports the tokenizer at APP12.pm:262 instead. 125 files x {Resolution, CameraType} = 250 tag instances. 3. `Main` 0x0207 `CameraType` and 0x0201 `Quality`, which the table walk cannot express: `CameraType` is a `DataMember` that `Quality`'s PrintConv consults (Olympus.pm:725), and `TextInfo` carries a second `CameraType` that overwrites it. The FE240/SP510UZ/u730/u1000 placeholder is padded to "NORMAL ", which is not `eq "NORMAL"`, so the Condition passes and ExifTool really does print `Unknown (NORMAL)` before TextInfo replaces it -- reproduced rather than special-cased. Also replaces `TagDef::raw(0x0821, "ISOAutoSettings")`, which printed the raw "0 0" where ExifTool prints "n/a; n/a", with the two-element list PrintConv transcribed from ExifTool's own loaded table. wip: the release binary and test run were cut short by machine load, so the whole-corpus after-measurement is not yet recorded. Per-item deltas above are measured; item 1 was verified end to end against a built binary, items 2 and 3 compile and are unit-tested but unmeasured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.
Image::ExifTool::Exif::PrintExposureTimehad been ported into sixteenseparate modules. Four of the copies printed a different string than ExifTool
for the same number of seconds. Every copy shipped with its own unit tests
asserting its own output, so all sixteen were green.
The four divergences
All of them are in the branch above 0.25 s; the reciprocal branch was right
everywhere.
1.
sony/binary.rsnever rounded to one decimalIt rendered the slow branch with
print_float— ten decimal places — insteadof
sprintf("%.1f"). Two tables reach it, both of which derive seconds from anAPEX exponent, so the reachable values are irrational and the full quotient
came out:
Sony:ShutterSpeedSetting2**(6-$val/8)58.7, oxidex58.6882587651Minolta:ShutterSpeed2**((48-$val)/8)11.3, oxidex11.3137084990(
minolta_tables.rs:10importedprint_exposure_timefromsony::binary, sothe Minolta tables inherited the Sony bug.)
2/3/4.
pdf/mod.rs,sigma.rs,sony/binary_data.rsdroppeds/\.0$//They replaced the trailing-zero regex with an
secs == secs.trunc()test takenbefore formatting. That is a different question. 29.96 is not an integer, but
%.1frounds it to"30.0"— ExifTool's regex then strips the.0and prints30, while these three printed30.0.Over
[0.25 s, 40 s]sampled at 1e-4 that is 39,449 of 375,000 inputs.Among 1/8-stop APEX shutter speeds it is 2^4.25 (19.03 s →
19vs19.0) and2^6.375 (83.00 s →
83vs83.0).Reached by
Sigma:ExposureTime(0x0032 and the microsecond string at 0x0033),the PDF/XMP APEX
ShutterSpeedValuepath, and Sony'sPc::ExposureTimeOrBulb.5.
composite/compute.rsopen-coded the rounding(v*10.0+0.5).floor()/10.0is round-half-up;%.1fis round-half-to-even onthe exact binary value.
Composite:ShutterSpeedof 0.35 s printed0.4whereExifTool prints
0.3; 2.25 s printed2.3where ExifTool prints2.2.198 of 375,000 inputs over the same range.
What this PR does
Adds
core::formatters::exif_print_convwith the one port plus the&strwrapper the two APP12 text segments need for
Exif.pm:5609'sreturn $secs unless IsFloat, and deletes all sixteen local copies —including the eleven that were already equivalent, because a helper that
exists sixteen times is a helper that will diverge again.
Verification
Ground truth. All 22 assertions in the new module were checked by calling
Image::ExifTool::Exif::PrintExposureTimefrom ExifTool 13.55 directly, notby reading the Perl. Both divergence counts above were produced the same way.
No regressions.
oxidex -a -erun underfd8a7322and under this branchover all 4,240 files of the combined-samples corpus, diffed as matched
sets of
Group:Name: Valuelines rather than by totals:File:FileAccessDatemoves on every file because the harness reads each onetwice; it is atime churn, not output. The single real change is
Samsung/SamsungDigimax340.jpg,Composite:ShutterSpeed7.3->7.2—the
composite/compute.rsrounding fix above, landing on the value%.1fproduces for 7.25.
That tag is still wrong against ExifTool (
1/152), for an unrelated andpre-existing reason this diff surfaced: oxidex feeds the composite the raw
stored APEX
ShutterSpeedValue(7.25) instead of itsValueConv'd seconds(
Exif.pm:2322,2**(-$val)), which it does apply correctly when emittingExifIFD:ShutterSpeedValue.Composite:Apertureon the same file has thesame defect (
4.5vs4.7). Filed separately; parity for those tags was amiss before this PR and is a miss after it.
Correction: an earlier revision of this description reported
files whose tag SET differs: 0. That run passed-G1, which oxidex does notaccept, so both binaries emitted nothing and the comparison was vacuous. The
numbers above are from the re-run, which carries an emitted-line count
precisely so an all-empty run cannot pass again.
cargo fmt --all,cargo clippy --workspaceclean,cargo test --workspaceall green. CI on the merge head: Build & Test, Lint & Audit, Verify Generated
Tables, CodeQL and docs all success.
Note that the corpus contains no file that trips any of the four bugs — every
Sony/Minolta shutter setting in it is in the reciprocal branch, and no
ExposureTimelands on the rounding boundary. That is precisely why sixteencopies could drift for this long: the guard has to be a unit test pinned to
ExifTool's own function, which is what the new module carries.
Follow-ups this pass did not take
Sized, with the divergence already measured:
perl_number× 6 ({:.6}truncation).canon.rsandcanon/camera_info.rshold byte-identicalformat!("{:.6}", v)copies ofPerl's
%.15g; measured against Perl over 3,263 realistic values they arewrong on 1,028.
custom_functions2.rsuses an(-6..15)exponentbucket where C's
%guses-4: wrong on 315.h264.rsuses Rust's{}(shortest round-trip, 17 digits): wrong on 1,015, though its singlecall site's 128-value domain happens to agree.
table_ifd.rs::fmt_g15takeslog10().floor()before rounding and so mis-buckets values that roundacross a power of ten. The canonical
core::formatters::numeric_precision::perl_numberis wrong on 1 — thePerl IV-integer case at ≥1e15, unreachable for metadata. ~5 files.
src/bin/tag-comparison/extraction/oxidex_extractor.rs:19importsformat_with_unitandneeds_unit_suffixfromcore::value_formatter,whose
format_with_unitis a naiveformat!("{} mm", value). The libraryuses
core::formatters::unit_suffixes, which applies Exif.pm:2401'ssprintf("%.1f mm",$val). SoEXIF:FocalLengthis50.0 mmin the productand
50 mmin the measurement. One-line import change, but it moves thereported coverage number, so it wants its own PR.
print_f_number× 4 —composite/compute.rs:400applies{:.1}when$val <= 0, whereExif.pm:5620'sand $val > 0guard returns the valueuntouched:
0prints as0.0.Co-Authored-By: Claude Opus 5 noreply@anthropic.com