fix(lnk): emit ExifTool's LNK tag names and groups - #403
Merged
Conversation
swackhamer
enabled auto-merge (squash)
August 2, 2026 03:40
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>
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>
…ode (#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>
`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>
…amera 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>
`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>
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>
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>
`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>
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>
…ting 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>
`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>
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>
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>
swackhamer
force-pushed
the
fix/lnk-exiftool-tag-names
branch
from
August 2, 2026 07:43
b8983ac to
a471737
Compare
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.
The gap
parse_lnk_metadatais reachable —src/core/format_dispatch.rs:173dispatchesFileFormat::LNKto it — but it reported invented, ungrouped tag names:CreationTime,Name,IconLocation,VolumeSerialNumber,MACAddress,DroidFileID,DroidVolumeID,KnownFolderID,LinkFlagsDescription,HasPropertyStore. ExifTool names none of those. So every one of the 43 tagsExifTool extracts from a Windows shortcut scored as MISSING, and the parser also
had no ItemID walk and no extra-data-block walk at all.
Measured before / after
Scored per file, keyed
Group1:Name, value-exact against ExifTool 13.55,excluding the File/System/ExifTool groups.
/tmp/oxidex-exiftool-cache/combined-samples/LNK.lnk:exiftool/t/images/LNK.lnkalso goes 0 → 43.What changed
src/parsers/specialized/lnk.rsis rewritten againstImage::ExifTool::LNK(LNK.pm 1.18); 58 lines cite the Perl line they transcribe. It now walks the
structures the old parser skipped:
%LNK::Main—FlagsandFileAttributesBITMASK, the three FILETIMEdates,
TargetFileSize,IconIndex,RunWindow,HotKeyProcessItemID(LNK.pm:1449) — the ItemID list, its0xbeefextensionsplit, and the 0x20/0x30/0x40 ID-range folding, feeding
%LNK::TargetInfoand
%LNK::Beef0004(TargetFileModifyDate,TargetFileAttributes,TargetFileDOSName,TargetFileCreateDate,TargetFileAccessDate,OperatingSystem,TargetFileName) — entirely newProcessLinkInfo(LNK.pm:1589) —DriveType,DriveSerialNumber,VolumeLabel,LocalBasePath,CommonPathSuffix,CommonPathSuffixUnicode,NetName,DeviceName,NetProviderTypeDescription, notName;IconFileName, notIconLocation)%LNK::TrackerData(MachineID) and%LNK::ConsoleData(17 tags:FillAttributes,ScreenBufferSize,FontFamily,FontName,CursorSize,QuickEdit, …) — entirely newEvery PrintConv is read out of LNK.pm rather than approximated — the
%.2xforms, the
"$a x $b"joins, the%08Xserial split, theMask => 0xf0fontnibble, ExifTool's
Unknown (0x..)miss text, andDecodeBits'(none)/[n]rendering.Verified against real ExifTool beyond the corpus sample
Synthetic shortcuts were built to exercise paths the one sample never reaches,
each compared tag-for-tag with
exiftool -j -G1:HotKeyvalues → 43/43 each. This pinned a Perl precedence quirk:chr $ch =~ /^[A-Z0-9]$/parses aschr($ch =~ ...), which is always true,so the F-key / Num Lock arms below it are dead and
0x70printsp, notF1.LinkInfo(header ≥ 0x24,VolumeLabelOffsetUnicode,CommonPathSuffixUnicode) → 44/44.NetProviderTypevalues plus 5 unnamed ones → 73/73, including theUnknown (0x280000)fallback.Bugs a 150-file differential fuzz run caught
Comparing oxidex and ExifTool over truncated and byte-flipped shortcuts took
agreement from 69% to 93% and found three real defects, each fixed and tested:
$raf->Readfortruth, not for the full count, so a partial read still yields a value and the
walk continues; oxidex returned early and dropped every later tag.
Decode(..., 'UTF16')stops at the first null. A padded field reportsonly the text before the padding —
A A NUL B BisAA, notAA\0BB.+. chrono's%Yemits+30324;ExifTool's
sprintf("%4d:...")does not.A zero-length
undef[$size-14]read is also skipped now, matchingProcessBinaryData: a 14-byte TargetInfo entry reports no
TargetFileDOSName,while a 15-byte one reports
"".Still open, deliberately
These are omitted rather than approximated, so no wrong value ships under a
real ExifTool name:
TargetInfo/Beef0004—RootFolder,VolumeGUID,ControlPanel*,MTPType2,PropertyStore,URI,UsersFilesFolder,NetworkLocation, andBeef0003/0014/0025/0026. Noneappear in the corpus and each needs its own GUID table or string heuristic.
ConsoleFEDataCodePage— needs%Image::ExifTool::Microsoft::codePage,which is not transcribed in this repo.
EnvVarData—EnvironmentTarget/EnvironmentTargetUnicode, no sampleto verify against.
Residual fuzz-only divergences (10 of 150 corrupted files) come from ExifTool's
JSON writer, not its LNK module: the
exiftoolscript'sEscapeJSONdeletesNUL bytes and
XMP::FixUTF8rewrites each undecodable byte as?where oxidexemits U+FFFD. That is global output-layer behaviour affecting every format, so it
is out of scope here.
Tests
tests/forensic/lnk_tests.rsasserted the old invented names, so 15 of itstests were failing against the corrected parser and have been rewritten — they
now assert ExifTool's names and exact PrintConv strings, plus a new test that
fails if any tag is ungrouped or matches one of the 15 invented names. Fixtures
that were structurally malformed (a LocalBasePath declared past the end of its
own LinkInfo block) were corrected.
cargo test --workspacepasses,cargo clippy --all-features -- -D warningsandcargo fmt --all --checkare clean, and 555 fuzzed inputs produce no panics.🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 5 noreply@anthropic.com