diff --git a/gen/main.go b/gen/main.go index d8ba901..074a3bb 100644 --- a/gen/main.go +++ b/gen/main.go @@ -60,11 +60,11 @@ func main() { cmd.Stderr = &errorBuf if err := cmd.Run(); err != nil { - if strings.Contains(errorBuf.String(), "identify:") { - // We have some corrupt images. - return nil - } - return err + // identify may fail for corrupt images (prefix "identify:") or + // unsupported formats (e.g. RAW files without a darktable/libraw delegate). + // Non-fatal: readGoldenInfo already handles missing .config.json via os.IsNotExist. + log.Printf("warning: identify failed for %s: %s (skipping config.json)", path, errorBuf.String()) + return nil } imageConfigOutFilename := filepath.Join(outDir, basePath+".config.json") diff --git a/gen/testdata_exiftool/images/sample.arw.config.json b/gen/testdata_exiftool/images/sample.arw.config.json new file mode 100644 index 0000000..e720607 --- /dev/null +++ b/gen/testdata_exiftool/images/sample.arw.config.json @@ -0,0 +1 @@ +{"width": 3880, "height": 2600} \ No newline at end of file diff --git a/gen/testdata_exiftool/images/sample.arw.json b/gen/testdata_exiftool/images/sample.arw.json new file mode 100644 index 0000000..1e94d56 --- /dev/null +++ b/gen/testdata_exiftool/images/sample.arw.json @@ -0,0 +1,203 @@ +[{ + "SourceFile": "testdata/images/sample.arw", + "ExifTool": { + "ExifToolVersion": 13.50 + }, + "File": { + "FileName": "sample.arw", + "Directory": "testdata/images", + "FileSize": 9601024, + "FilePermissions": 100644, + "FileType": "ARW", + "FileTypeExtension": "ARW", + "MIMEType": "image/x-sony-arw", + "ExifByteOrder": "II" + }, + "EXIF": { + "ImageDescription": "SONY DSC", + "Make": "SONY", + "Model": "DSLR-A330", + "Software": "DSLR-A330 v1.10", + "ModifyDate": "2009:11:13 13:33:25", + "SubfileType": 0, + "ImageWidth": 3880, + "ImageHeight": 2600, + "BitsPerSample": 12, + "Compression": 32767, + "PhotometricInterpretation": 32803, + "Orientation": 1, + "SamplesPerPixel": 1, + "PlanarConfiguration": 1, + "XResolution": 0.01388888889, + "YResolution": 0.01388888889, + "ResolutionUnit": 3, + "CFARepeatPatternDim": "2 2", + "CFAPattern2": "0 1 1 2", + "SonyRawFileType": 3, + "SonyToneCurve": "8000 10400 12900 14100", + "StripOffsets": 688128, + "RowsPerStrip": 2600, + "StripByteCounts": 8883620, + "PreviewImageStart": 155683, + "PreviewImageLength": 530370, + "ExposureTime": 0.00625, + "FNumber": 4.5, + "ExposureProgram": 2, + "ISO": 100, + "ExifVersion": "0221", + "DateTimeOriginal": "2009:11:13 13:33:25", + "CreateDate": "2009:11:13 13:33:25", + "ComponentsConfiguration": "1 2 3 0", + "CompressedBitsPerPixel": 8, + "BrightnessValue": 6.75, + "ExposureCompensation": 0, + "MaxApertureValue": 4, + "MeteringMode": 2, + "LightSource": 0, + "Flash": 16, + "FocalLength": 100, + "UserComment": "", + "FlashpixVersion": "0100", + "ColorSpace": 1, + "ExifImageWidth": 3880, + "ExifImageHeight": 2600, + "InteropIndex": "R98", + "InteropVersion": "0100", + "FileSource": 3, + "SceneType": 1, + "CustomRendered": 0, + "ExposureMode": 2, + "WhiteBalance": 0, + "FocalLengthIn35mmFormat": 150, + "SceneCaptureType": 0, + "Contrast": 0, + "Saturation": 0, + "Sharpness": 0, + "ThumbnailOffset": 32770, + "ThumbnailLength": 3598 + }, + "MakerNotes": { + "AFPointSelected": 1, + "FocusModeSetting": 1, + "AFPoint": 4, + "AFStatusActiveSensor": 759, + "AFStatusTop-right": -32768, + "AFStatusBottom-right": -32768, + "AFStatusBottom": 948, + "AFStatusMiddleHorizontal": 1136, + "AFStatusCenterVertical": 759, + "AFStatusTop": 1387, + "AFStatusTop-left": 1410, + "AFStatusBottom-left": -32768, + "AFStatusLeft": -32768, + "AFStatusCenterHorizontal": -32768, + "AFStatusRight": 1136, + "DriveMode2": 7, + "Rotation": 0, + "ImageStabilizationSetting": 1, + "DynamicRangeOptimizerMode": 0, + "BracketShotNumber": 0, + "WhiteBalanceBracketing": 0, + "BracketShotNumber2": 0, + "DynamicRangeOptimizerBracket": 0, + "ExposureBracketShotNumber": 0, + "ExposureProgram": 2, + "ISOSetting": 100, + "ISO": 100, + "DynamicRangeOptimizerLevel": 1, + "ShutterCount": 78, + "FocusPosition": 128, + "TiffMeteringImage": "(Binary data 7404 bytes, use -b option to extract)", + "Quality": 6, + "FlashExposureComp": 0, + "Teleconverter": 0, + "WhiteBalanceFineTune": 0, + "ExposureTime": 0.0055242717280199, + "FNumber": 4.36203093066103, + "HighSpeedSync": 0, + "ExposureCompensationSet": 0, + "WhiteBalanceSetting": 2, + "ColorTemperatureSet": 5500, + "ColorCompensationFilterSet": 0, + "CustomWB_RGBLevels": "0 0 0", + "ColorTemperatureCustom": 5500, + "ColorCompensationFilterCustom": 0, + "AFAreaMode": 2, + "AFPointSetting": 1, + "FlashExposureCompSet": 0, + "MeteringMode": 2, + "FlashControl": 0, + "ImageStyle": 1, + "ShutterSpeedSetting": 0.324209888662752, + "ApertureSetting": 3.66801617281868, + "FlashAction": 0, + "AELock": 1, + "FlashAction2": 5, + "FocusMode": 1, + "FocusStatus": 1, + "SonyImageSize": 1, + "AspectRatio": 1, + "ExposureLevelIncrements": 33, + "DriveMode": 7, + "FlashMode": 5, + "ColorSpace": 6, + "WhiteBalance": 0, + "BatteryLevel": 85, + "Rating": 0, + "LongExposureNoiseReduction": 4294901760, + "HighISONoiseReduction": 0, + "FileFormat": "3 1 0 0", + "SonyModelID": 264, + "CreativeStyle": "Standard", + "ColorTemperature": 0, + "ColorCompensationFilter": 0, + "SceneMode": 0, + "ZoneMatching": 0, + "DynamicRangeOptimizer": 0, + "ImageStabilization": 1, + "LensType": 56, + "ColorMode": 0, + "LensSpec": "01 55 200 4 5.6 02", + "FullImageSize": "3872 2592", + "PreviewImageSize": "1616 1080", + "SR2SubIFDOffset": 36496, + "SR2SubIFDLength": 30960, + "SR2SubIFDKey": 1144201745, + "FirmwareID": 21870002, + "SensorHeight": 2608, + "SensorWidth": 3880, + "ImageHeight": 2592, + "ImageWidth": 3872, + "RawDepth": 16, + "BitDepth": 12, + "StorageMethod": 82, + "BayerPattern": 0, + "WBScale": "2 2 2 2", + "Saturation": 0, + "Contrast": 0, + "Sharpness": 0, + "WBMode": 0, + "ProgramMode": 128, + "BWFilter": 0, + "Hue": -1, + "BlackLevel": "0 0 0 0", + "WB_RGGBLevelsAuto": "1800 1024 1024 1584", + "WB_RGGBLevels": "1800 1024 1024 1584", + "ColorMatrix": "1208 -168 -16 -40 1168 -104 -8 16 1016", + "WB_RGBLevelsDaylight": "1740 1024 1660", + "WB_RGBLevelsCloudy": "1880 1024 1492", + "WB_RGBLevelsTungsten": "1032 1024 3288", + "WB_RGBLevelsFlash": "1924 1024 1476", + "WB_RGBLevels4500K": "1560 1024 1920", + "WB_RGBLevelsShade": "2056 1024 1292", + "WB_RGBLevelsFluorescent": "1604 1024 2372", + "WB_RGBLevelsFluorescentP1": "1812 1024 1976", + "WB_RGBLevelsFluorescentP2": "2208 1024 1704", + "WB_RGBLevelsFluorescentM1": "1228 1024 3112", + "WB_RGBLevels8500K": "2160 1024 1200", + "WB_RGBLevels6000K": "1856 1024 1500", + "WB_RGBLevels3200K": "1172 1024 2804", + "WB_RGBLevels2500K": "872 1024 3996", + "WhiteLevel": 15360 + } +}] diff --git a/gen/testdata_exiftool/images/sample.cr2.config.json b/gen/testdata_exiftool/images/sample.cr2.config.json new file mode 100644 index 0000000..f1fb0d9 --- /dev/null +++ b/gen/testdata_exiftool/images/sample.cr2.config.json @@ -0,0 +1 @@ +{"width": 3648, "height": 2736} \ No newline at end of file diff --git a/gen/testdata_exiftool/images/sample.cr2.json b/gen/testdata_exiftool/images/sample.cr2.json new file mode 100644 index 0000000..5fd2faf --- /dev/null +++ b/gen/testdata_exiftool/images/sample.cr2.json @@ -0,0 +1,204 @@ +[{ + "SourceFile": "testdata/images/sample.cr2", + "ExifTool": { + "ExifToolVersion": 13.50 + }, + "File": { + "FileName": "sample.cr2", + "Directory": "testdata/images", + "FileSize": 13287537, + "FilePermissions": 100644, + "FileType": "CR2", + "FileTypeExtension": "CR2", + "MIMEType": "image/x-canon-cr2", + "ExifByteOrder": "II" + }, + "EXIF": { + "ImageWidth": 1600, + "ImageHeight": 1200, + "BitsPerSample": "8 8 8", + "Compression": 6, + "ImageDescription": " ", + "Make": "Canon", + "Model": "Canon PowerShot S90", + "PreviewImageStart": 18944, + "Orientation": 1, + "PreviewImageLength": 871523, + "XResolution": 180, + "YResolution": 180, + "ResolutionUnit": 2, + "ModifyDate": "2011:08:24 14:41:04", + "ExposureTime": 0.008, + "FNumber": 5.6, + "ISO": 80, + "ExifVersion": "0221", + "DateTimeOriginal": "2011:08:24 14:41:04", + "CreateDate": "2011:08:24 14:41:04", + "ComponentsConfiguration": "1 2 3 0", + "CompressedBitsPerPixel": 3, + "ShutterSpeedValue": "0.00798357147386029", + "ApertureValue": 5.59591869015324, + "ExposureCompensation": 0.3333333333, + "MaxApertureValue": 2.79795934507662, + "MeteringMode": 5, + "Flash": 16, + "FocalLength": 8.557, + "UserComment": "", + "FlashpixVersion": "0100", + "ColorSpace": 1, + "ExifImageWidth": 3648, + "ExifImageHeight": 2736, + "InteropIndex": "R98", + "InteropVersion": "0100", + "RelatedImageWidth": 3648, + "RelatedImageHeight": 2736, + "FocalPlaneXResolution": 5479.452055, + "FocalPlaneYResolution": 5479.452055, + "FocalPlaneResolutionUnit": 2, + "SensingMethod": 2, + "FileSource": 3, + "CustomRendered": 0, + "ExposureMode": 1, + "WhiteBalance": 0, + "DigitalZoomRatio": 1, + "SceneCaptureType": 0, + "ThumbnailOffset": 11264, + "ThumbnailLength": 7291, + "PhotometricInterpretation": 2, + "SamplesPerPixel": 3, + "RowsPerStrip": 342, + "PlanarConfiguration": 1, + "StripOffsets": 1826816, + "StripByteCounts": 11460721, + "CR2CFAPattern": "0 1 1 2", + "RawImageSegmentation": "1 1848 1896", + "SRawType": 1 + }, + "MakerNotes": { + "MacroMode": 2, + "SelfTimer": 0, + "Quality": 4, + "CanonFlashMode": 0, + "ContinuousDrive": 1, + "FocusMode": 4, + "RecordMode": 7, + "CanonImageSize": -1, + "EasyMode": 1, + "DigitalZoom": 0, + "Contrast": 0, + "Saturation": 0, + "Sharpness": 0, + "CameraISO": 80, + "MeteringMode": 3, + "FocusRange": 1, + "AFPoint": 8197, + "CanonExposureMode": 3, + "LensType": 65535, + "MaxFocalLength": 22.5, + "MinFocalLength": 6, + "FocalUnits": 1000, + "MaxAperture": 2.79795934507662, + "MinAperture": 8, + "FlashBits": 0, + "FocusContinuous": 0, + "AESetting": 0, + "ImageStabilization": 2, + "ZoomSourceWidth": 3648, + "ZoomTargetWidth": 3648, + "SpotMeteringMode": 0, + "ManualFlashOutput": 0, + "SRAWQuality": 0, + "FocalType": 2, + "FocalLength": 8.557, + "FocalPlaneXSize": 7.5946, + "FocalPlaneYSize": 5.6896, + "AutoISO": 100, + "BaseISO": 78.7990422553943, + "MeasuredEV": 13.3125, + "TargetAperture": 5.59591869015324, + "TargetExposureTime": "0.00798357147386029", + "ExposureCompensation": 0.333333333333333, + "WhiteBalance": 0, + "SlowShutter": 0, + "SequenceNumber": 1, + "OpticalZoomCode": 3, + "FlashGuideNumber": 0, + "FlashExposureComp": 0, + "AutoExposureBracketing": 0, + "AEBBracketValue": 0, + "ControlMode": 1, + "FocusDistanceUpper": 6.12, + "FocusDistanceLower": 0, + "FNumber": 5.65685424949238, + "ExposureTime": "0.00870614642653041", + "BulbDuration": 0, + "CameraType": 250, + "AutoRotate": 0, + "NDFilter": 0, + "SelfTimer2": 0, + "FlashOutput": 0, + "CanonImageType": "IMG:High definition image", + "CanonFirmwareVersion": "Firmware Version 1.01", + "FileNumber": 1060023, + "OwnerName": "", + "ISO": 74.9153538438341, + "Rotation": 0, + "CameraTemperature": 34, + "CanonModelID": 41025536, + "AFAreaMode": 2, + "NumAFPoints": 9, + "ValidAFPoints": 1, + "CanonImageWidth": 1600, + "CanonImageHeight": 1200, + "AFImageWidth": 100, + "AFImageHeight": 100, + "AFAreaWidths": "18 0 0 0 0 0 0 0 0", + "AFAreaHeights": "18 0 0 0 0 0 0 0 0", + "AFAreaXPositions": "0 0 0 0 0 0 0 0 0", + "AFAreaYPositions": "0 0 0 0 0 0 0 0 0", + "AFPointsInFocus": 1, + "PrimaryAFPoint": 0, + "ThumbnailImageValidArea": "0 0 0 0", + "DateStampMode": 0, + "MyColorMode": 0, + "FirmwareRevision": 16843520, + "Categories": 0, + "IntelligentContrast": 0, + "ImageUniqueID": "74fcebdd64dead9f86bee58d14806187", + "VRDOffset": 0, + "SensorWidth": 3744, + "SensorHeight": 2784, + "SensorLeftBorder": 24, + "SensorTopBorder": 28, + "SensorRightBorder": 3671, + "SensorBottomBorder": 2763, + "BlackMaskLeftBorder": 3732, + "BlackMaskTopBorder": 28, + "BlackMaskRightBorder": 3735, + "BlackMaskBottomBorder": 2763, + "ColorDataVersion": -3, + "WB_RGGBLevelsAsShot": "1515 882 882 1840", + "ColorTempAsShot": 6120, + "WB_RGGBLevelsAuto": "1515 882 882 1840", + "ColorTempAuto": 6120, + "WB_RGGBLevelsMeasured": "1515 882 882 1840", + "ColorTempMeasured": 6120, + "WB_RGGBLevelsDaylight": "1503 881 881 1852", + "ColorTempDaylight": 6046, + "WB_RGGBLevelsShade": "1577 883 883 1775", + "ColorTempShade": 6574, + "WB_RGGBLevelsCloudy": "1577 883 883 1775", + "ColorTempCloudy": 6574, + "WB_RGGBLevelsTungsten": "973 792 792 2560", + "ColorTempTungsten": 3512, + "WB_RGGBLevelsFluorescent": "1467 903 903 2047", + "ColorTempFluorescent": 5386, + "WB_RGGBLevelsKelvin": "1503 881 881 1852", + "ColorTempKelvin": 6046, + "WB_RGGBLevelsFlash": "1832 896 895 1497", + "ColorTempFlash": 8511, + "PerChannelBlackLevel": "128 128 128 128", + "SpecularWhiteLevel": 11572, + "VignettingCorrVersion": 1 + } +}] diff --git a/gen/testdata_exiftool/images/sample.dng.config.json b/gen/testdata_exiftool/images/sample.dng.config.json new file mode 100644 index 0000000..ee91db2 --- /dev/null +++ b/gen/testdata_exiftool/images/sample.dng.config.json @@ -0,0 +1 @@ +{"width": 2592, "height": 1944} \ No newline at end of file diff --git a/gen/testdata_exiftool/images/sample.dng.json b/gen/testdata_exiftool/images/sample.dng.json new file mode 100644 index 0000000..2f6fcb4 --- /dev/null +++ b/gen/testdata_exiftool/images/sample.dng.json @@ -0,0 +1,71 @@ +[{ + "SourceFile": "testdata/images/sample.dng", + "ExifTool": { + "ExifToolVersion": 13.50 + }, + "File": { + "FileName": "sample.dng", + "Directory": "testdata/images", + "FileSize": 6611520, + "FilePermissions": 100644, + "FileType": "DNG", + "FileTypeExtension": "DNG", + "MIMEType": "image/x-adobe-dng", + "ExifByteOrder": "II" + }, + "EXIF": { + "ImageDescription": "", + "Make": "Canon", + "Model": "Canon PowerShot SD450", + "Orientation": 1, + "Software": "CHDK ver. 0.9.7-725", + "ModifyDate": "2009:03:30 19:25:18", + "SubfileType": 0, + "ImageWidth": 2672, + "ImageHeight": 1968, + "BitsPerSample": 10, + "Compression": 1, + "PhotometricInterpretation": 32803, + "StripOffsets": 38400, + "SamplesPerPixel": 1, + "RowsPerStrip": 1968, + "StripByteCounts": 6573120, + "XResolution": 180, + "YResolution": 180, + "PlanarConfiguration": 1, + "ResolutionUnit": 2, + "CFARepeatPatternDim": "2 2", + "CFAPattern2": "0 1 1 2", + "BlackLevel": 31, + "WhiteLevel": 1023, + "DefaultCropOrigin": "12 8", + "DefaultCropSize": "2592 1944", + "ActiveArea": "8 12 1968 2628", + "Copyright": "", + "ExposureTime": 0.060283, + "FNumber": 2.8, + "ExposureProgram": 2, + "ISO": 100, + "ExifVersion": "0221", + "DateTimeOriginal": "2009:03:30 19:25:18", + "ShutterSpeedValue": 0.060283904315009, + "ApertureValue": 2.84892307330769, + "ExposureCompensation": 0, + "MaxApertureValue": 2.84892307330769, + "MeteringMode": 5, + "Flash": 16, + "FocalLength": 5.8, + "FocalLengthIn35mmFormat": 35, + "TIFF-EPStandardID": "1 0 0 0", + "DNGVersion": "1 1 0 0", + "UniqueCameraModel": "Canon PowerShot SD450", + "ColorMatrix1": "0.536034 -0.173429 -0.089823 -0.290416 0.735807 0.047894 -0.071455 0.114314 0.274533", + "AnalogBalance": "1 1 1", + "AsShotNeutral": "0.8326394671 1.012145749 0.3512469266", + "BaselineExposure": -0.5, + "BaselineNoise": 1, + "BaselineSharpness": 1.333333333, + "LinearResponseLimit": 1, + "CalibrationIlluminant1": 1 + } +}] diff --git a/gen/testdata_exiftool/images/sample.nef.config.json b/gen/testdata_exiftool/images/sample.nef.config.json new file mode 100644 index 0000000..904c1f6 --- /dev/null +++ b/gen/testdata_exiftool/images/sample.nef.config.json @@ -0,0 +1 @@ +{"width": 2012, "height": 1324} \ No newline at end of file diff --git a/gen/testdata_exiftool/images/sample.nef.json b/gen/testdata_exiftool/images/sample.nef.json new file mode 100644 index 0000000..5e79e12 --- /dev/null +++ b/gen/testdata_exiftool/images/sample.nef.json @@ -0,0 +1,83 @@ +[{ + "SourceFile": "testdata/images/sample.nef", + "ExifTool": { + "ExifToolVersion": 13.50 + }, + "File": { + "FileName": "sample.nef", + "Directory": "testdata/images", + "FileSize": 4055474, + "FilePermissions": 100644, + "FileType": "NEF", + "FileTypeExtension": "NEF", + "MIMEType": "image/x-nikon-nef", + "ExifByteOrder": "MM" + }, + "EXIF": { + "ImageDescription": " ", + "Make": "NIKON CORPORATION", + "Model": "NIKON D1", + "Software": "Ver.1.05", + "ModifyDate": "2000:11:19 13:01:50", + "SubfileType": 0, + "ImageWidth": 2012, + "ImageHeight": 1324, + "BitsPerSample": 12, + "Compression": 1, + "PhotometricInterpretation": 32803, + "StripOffsets": "(Binary data 518 bytes, use -b option to extract)", + "SamplesPerPixel": 1, + "RowsPerStrip": 20, + "StripByteCounts": "(Binary data 401 bytes, use -b option to extract)", + "XResolution": 300, + "YResolution": 300, + "PlanarConfiguration": 1, + "ResolutionUnit": 2, + "CFARepeatPatternDim": "2 2", + "CFAPattern2": "2 1 1 0", + "ReferenceBlackWhite": "0 255 0 255 0 255", + "Copyright": "Copyright,NIKON CORPORATION,1999", + "ExposureTime": 0.004, + "FNumber": 7.6, + "ExposureProgram": 2, + "CreateDate": "2000:11:19 13:01:50", + "ExposureCompensation": 0, + "MaxApertureValue": 2.82842712474619, + "MeteringMode": 5, + "FocalLength": 20, + "UserComment": "", + "SubSecTime": 24, + "SubSecTimeOriginal": 24, + "SubSecTimeDigitized": 24, + "SensingMethod": 2, + "FileSource": 3, + "SceneType": 1, + "CFAPattern": "2 2 2 1 1 0", + "DateTimeOriginal": "2000:11:19 13:01:50", + "TIFF-EPStandardID": "1 0 0 0" + }, + "MakerNotes": { + "MakerNoteVersion": "0100", + "ISO": "0 200", + "ColorMode": "COLOR", + "Quality": "RAW2.7M", + "WhiteBalance": "AUTO ", + "Sharpness": " ", + "FocusMode": "AF-S ", + "FlashSetting": " ", + "FlashType": " ", + "WhiteBalanceFineTune": 0, + "WB_RBLevels": "1.9765625 1.2890625 1 1", + "ProgramShift": 0, + "ExposureDifference": 0, + "ToneComp": "NORMAL ", + "LensType": 0, + "Lens": "20 20 2.8 2.8", + "FlashMode": 0, + "AFAreaMode": 1, + "AFPoint": 0, + "AFPointsInFocus": 256, + "ShootingMode": 1, + "LensFStops": 6 + } +}] diff --git a/helpers.go b/helpers.go index 2d73e03..bfbc97b 100644 --- a/helpers.go +++ b/helpers.go @@ -224,9 +224,10 @@ func (c vc) convertDegreesToDecimal(ctx valueConverterContext, v any) any { } func (vc) convertNumbersToSpaceLimited(ctx valueConverterContext, v any) any { - nums, ok := typeAssertSlice[any](ctx, v) + nums, ok := v.([]any) if !ok { - return "" + // Single value, keep original type. + return v } var sb strings.Builder diff --git a/imagedecoder_raw.go b/imagedecoder_raw.go new file mode 100644 index 0000000..9d58174 --- /dev/null +++ b/imagedecoder_raw.go @@ -0,0 +1,278 @@ +// Copyright 2026 Toni Melisma +// SPDX-License-Identifier: MIT + +package imagemeta + +import "encoding/binary" + +const ( + rawMeaningOfLife = 42 + rawTagImageWidth = 0x0100 + rawTagImageHeight = 0x0101 + rawTagSubIFDs = 0x014a + rawTagExifIFDPointer = 0x8769 + rawTagExifImageWidth = 0xa002 + rawTagExifImageHeight = 0xa003 + rawTagDefaultCropSize = 0xc620 +) + +type imageDecoderRAW struct { + *baseStreamingDecoder +} + +func (e *imageDecoderRAW) decode() error { + byteOrderTag := e.read2() + switch byteOrderTag { + case byteOrderBigEndian: + e.byteOrder = binary.BigEndian + case byteOrderLittleEndian: + e.byteOrder = binary.LittleEndian + default: + return errInvalidFormat + } + + if id := e.read2(); id != rawMeaningOfLife { + return errInvalidFormat + } + + ifdOffset := e.read4() + + if ifdOffset < 8 { + return errInvalidFormat + } + + e.skip(int64(ifdOffset - 8)) + + if e.opts.Sources.Has(CONFIG) { + ifdPos := e.pos() + + var width, height int + var exifIFDOffset uint32 + var subIFDOffsets []uint32 + var defaultCropW, defaultCropH int + var hasDefaultCrop bool + + numTags := e.read2() + for range int(numTags) { + tagID := e.read2() + dataType := e.read2() + count := e.read4() + + switch tagID { + case rawTagImageWidth, rawTagImageHeight: + var value int + if dataType == 3 { // SHORT + value = int(e.read2()) + e.skip(2) + } else { // LONG + value = int(e.read4()) + } + if tagID == rawTagImageWidth { + width = value + } else { + height = value + } + case rawTagExifIFDPointer: + exifIFDOffset = e.read4() + case rawTagSubIFDs: + if count == 1 { + subIFDOffsets = append(subIFDOffsets, e.read4()) + } else { + // Value field is an offset to the array of offsets. + arrayOffset := e.read4() + e.preservePos(func() error { + e.seek(int64(arrayOffset)) + for range count { + subIFDOffsets = append(subIFDOffsets, e.read4()) + } + return nil + }) + } + case rawTagDefaultCropSize: + w, h, ok := e.readDefaultCropSize(dataType, count) + if ok { + hasDefaultCrop = true + defaultCropW, defaultCropH = w, h + } + default: + e.skip(4) + } + } + + // Follow ExifIFD for ExifImageWidth/ExifImageHeight. + var exifW, exifH int + if exifIFDOffset > 0 { + e.preservePos(func() error { + e.seek(int64(exifIFDOffset)) + exifW, exifH = e.readIFDDimensions(rawTagExifImageWidth, rawTagExifImageHeight) + return nil + }) + } + + // Follow SubIFDs for largest dimensions and DefaultCropSize. + var subW, subH int + for _, off := range subIFDOffsets { + e.preservePos(func() error { + e.seek(int64(off)) + w, h, cropW, cropH := e.readSubIFDInfo() + if cropW > 0 && cropH > 0 { + hasDefaultCrop = true + defaultCropW, defaultCropH = cropW, cropH + } + if w*h > subW*subH { + subW, subH = w, h + } + return nil + }) + } + + // Priority: DefaultCropSize > largest of (ExifIFD, SubIFD, IFD0). + bestW, bestH := width, height + if exifW*exifH > bestW*bestH { + bestW, bestH = exifW, exifH + } + if subW*subH > bestW*bestH { + bestW, bestH = subW, subH + } + if hasDefaultCrop && defaultCropW > 0 && defaultCropH > 0 { + bestW, bestH = defaultCropW, defaultCropH + } + + e.result.ImageConfig = ImageConfig{ + Width: bestW, + Height: bestH, + } + + // If only CONFIG was requested, we're done. + if e.opts.Sources == CONFIG { + return nil + } + + e.seek(ifdPos) + } + + dec := newMetaDecoderEXIFFromStreamReader(e.streamReader, 0, e.opts) + + if err := dec.decodeTags("IFD0"); err != nil { + return err + } + + // Follow IFD1 (thumbnail). + ifd1Offset := dec.read4() + if ifd1Offset > 0 { + dec.seek(int64(ifd1Offset)) + return dec.decodeTags("IFD1") + } + + return nil +} + +// readDefaultCropSize reads a DefaultCropSize tag value from the current IFD entry's +// value field. Handles SHORT, LONG, and RATIONAL data types. +// Returns (width, height, ok). +func (e *imageDecoderRAW) readDefaultCropSize(dataType uint16, count uint32) (int, int, bool) { + var w, h int + switch { + case dataType == 4 && count == 2: // LONG×2 + // Two LONGs don't fit in 4-byte value field; value is an offset. + cropOffset := e.read4() + e.preservePos(func() error { + e.seek(int64(cropOffset)) + w = int(e.read4()) + h = int(e.read4()) + return nil + }) + case dataType == 3 && count == 2: // SHORT×2 + w = int(e.read2()) + h = int(e.read2()) + case dataType == 5 && count == 2: // RATIONAL×2 + // Two RATIONALs (each 8 bytes) don't fit inline. + cropOffset := e.read4() + e.preservePos(func() error { + e.seek(int64(cropOffset)) + num1 := e.read4() + den1 := e.read4() + num2 := e.read4() + den2 := e.read4() + if den1 > 0 { + w = int(num1 / den1) + } + if den2 > 0 { + h = int(num2 / den2) + } + return nil + }) + default: + e.skip(4) + return 0, 0, false + } + return w, h, true +} + +// readIFDDimensions reads an IFD at the current position and scans for +// width/height tags specified by wTag and hTag. Returns (width, height). +func (e *imageDecoderRAW) readIFDDimensions(wTag, hTag uint16) (int, int) { + numTags := e.read2() + var w, h int + for range int(numTags) { + tagID := e.read2() + dataType := e.read2() + _ = e.read4() // count + if tagID == wTag || tagID == hTag { + var value int + if dataType == 3 { // SHORT + value = int(e.read2()) + e.skip(2) + } else { // LONG + value = int(e.read4()) + } + if tagID == wTag { + w = value + } else { + h = value + } + if w > 0 && h > 0 { + return w, h + } + } else { + e.skip(4) + } + } + return w, h +} + +// readSubIFDInfo reads a SubIFD at the current position and scans for +// ImageWidth, ImageHeight, and DefaultCropSize. +// Returns (width, height, defaultCropW, defaultCropH). +func (e *imageDecoderRAW) readSubIFDInfo() (int, int, int, int) { + numTags := e.read2() + var w, h, cropW, cropH int + for range int(numTags) { + tagID := e.read2() + dataType := e.read2() + count := e.read4() + switch tagID { + case rawTagImageWidth, rawTagImageHeight: + var value int + if dataType == 3 { // SHORT + value = int(e.read2()) + e.skip(2) + } else { // LONG + value = int(e.read4()) + } + if tagID == rawTagImageWidth { + w = value + } else { + h = value + } + case rawTagDefaultCropSize: + cw, ch, ok := e.readDefaultCropSize(dataType, count) + if ok { + cropW, cropH = cw, ch + } + default: + e.skip(4) + } + } + return w, h, cropW, cropH +} diff --git a/imageformat_string.go b/imageformat_string.go index 3ab5ce1..4d5f847 100644 --- a/imageformat_string.go +++ b/imageformat_string.go @@ -15,11 +15,15 @@ func _() { _ = x[WebP-4] _ = x[HEIF-5] _ = x[AVIF-6] + _ = x[DNG-7] + _ = x[CR2-8] + _ = x[NEF-9] + _ = x[ARW-10] } -const _ImageFormat_name = "ImageFormatAutoJPEGTIFFPNGWebPHEIFAVIF" +const _ImageFormat_name = "ImageFormatAutoJPEGTIFFPNGWebPHEIFAVIFDNGCR2NEFARW" -var _ImageFormat_index = [...]uint8{0, 15, 19, 23, 26, 30, 34, 38} +var _ImageFormat_index = [...]uint8{0, 15, 19, 23, 26, 30, 34, 38, 41, 44, 47, 50} func (i ImageFormat) String() string { idx := int(i) - 0 diff --git a/imagemeta.go b/imagemeta.go index 1093541..8ee6221 100644 --- a/imagemeta.go +++ b/imagemeta.go @@ -52,6 +52,14 @@ const ( HEIF // AVIF is the AVIF image format (ISO Base Media File Format with AV1 codec). AVIF + // DNG is the DNG (Digital Negative) RAW image format. + DNG + // CR2 is the Canon CR2 RAW image format. + CR2 + // NEF is the Nikon NEF RAW image format. + NEF + // ARW is the Sony ARW RAW image format. + ARW ) // ImageConfig contains basic image configuration. @@ -193,6 +201,8 @@ func Decode(opts Options) (result DecodeResult, err error) { sourceSet = EXIF | XMP | IPTC | CONFIG case HEIF, AVIF: sourceSet = EXIF | XMP | CONFIG + case DNG, CR2, NEF, ARW: + sourceSet = EXIF | XMP | IPTC | CONFIG default: return result, fmt.Errorf("unsupported image format") @@ -230,6 +240,8 @@ func Decode(opts Options) (result DecodeResult, err error) { dec = &imageDecoderPNG{baseStreamingDecoder: base} case HEIF, AVIF: dec = &imageDecoderHEIF{baseStreamingDecoder: base} + case DNG, CR2, NEF, ARW: + dec = &imageDecoderRAW{baseStreamingDecoder: base} } decode := func() chan error { diff --git a/imagemeta_fuzz_test.go b/imagemeta_fuzz_test.go index 6180ab9..b0c9f6d 100644 --- a/imagemeta_fuzz_test.go +++ b/imagemeta_fuzz_test.go @@ -89,6 +89,46 @@ func FuzzDecodeTIFF(f *testing.F) { }) } +func FuzzDecodeDNG(f *testing.F) { + filenames := []string{"sample.dng"} + for _, filename := range filenames { + f.Add(readTestDataFileAll(f, filename)) + } + f.Fuzz(func(t *testing.T, imageBytes []byte) { + fuzzDecodeBytes(t, imageBytes, imagemeta.DNG) + }) +} + +func FuzzDecodeCR2(f *testing.F) { + filenames := []string{"sample.cr2"} + for _, filename := range filenames { + f.Add(readTestDataFileAll(f, filename)) + } + f.Fuzz(func(t *testing.T, imageBytes []byte) { + fuzzDecodeBytes(t, imageBytes, imagemeta.CR2) + }) +} + +func FuzzDecodeNEF(f *testing.F) { + filenames := []string{"sample.nef"} + for _, filename := range filenames { + f.Add(readTestDataFileAll(f, filename)) + } + f.Fuzz(func(t *testing.T, imageBytes []byte) { + fuzzDecodeBytes(t, imageBytes, imagemeta.NEF) + }) +} + +func FuzzDecodeARW(f *testing.F) { + filenames := []string{"sample.arw"} + for _, filename := range filenames { + f.Add(readTestDataFileAll(f, filename)) + } + f.Fuzz(func(t *testing.T, imageBytes []byte) { + fuzzDecodeBytes(t, imageBytes, imagemeta.ARW) + }) +} + func fuzzDecodeBytes(t *testing.T, imageBytes []byte, f imagemeta.ImageFormat) error { r := bytes.NewReader(imageBytes) _, err := imagemeta.Decode(imagemeta.Options{R: r, ImageFormat: f, Sources: imagemeta.EXIF | imagemeta.IPTC | imagemeta.XMP | imagemeta.CONFIG, Timeout: 600 * time.Millisecond}) diff --git a/imagemeta_test.go b/imagemeta_test.go index 43511f6..a834240 100644 --- a/imagemeta_test.go +++ b/imagemeta_test.go @@ -112,6 +112,69 @@ func TestDecodeHEIFConfig(t *testing.T) { c.Assert(res2.ImageConfig.Height, qt.Equals, 6192) } +func TestDecodeRAW(t *testing.T) { + c := qt.New(t) + + // DNG (Canon PowerShot SD450) + _, tags, err := extractTags(t, "sample.dng", imagemeta.EXIF) + c.Assert(err, qt.IsNil) + c.Assert(tags.EXIF()["Make"].Value, qt.Equals, "Canon") + c.Assert(tags.EXIF()["Model"].Value, qt.Equals, "Canon PowerShot SD450") + + // CR2 (Canon PowerShot S90) + _, tags, err = extractTags(t, "sample.cr2", imagemeta.EXIF) + c.Assert(err, qt.IsNil) + c.Assert(tags.EXIF()["Make"].Value, qt.Equals, "Canon") + c.Assert(tags.EXIF()["Model"].Value, qt.Equals, "Canon PowerShot S90") + + // NEF (Nikon D1) + _, tags, err = extractTags(t, "sample.nef", imagemeta.EXIF) + c.Assert(err, qt.IsNil) + c.Assert(tags.EXIF()["Make"].Value, qt.Equals, "NIKON CORPORATION") + c.Assert(tags.EXIF()["Model"].Value, qt.Equals, "NIKON D1") + + // ARW (Sony DSLR-A330) + _, tags, err = extractTags(t, "sample.arw", imagemeta.EXIF) + c.Assert(err, qt.IsNil) + c.Assert(tags.EXIF()["Make"].Value, qt.Equals, "SONY") + c.Assert(tags.EXIF()["Model"].Value, qt.Equals, "DSLR-A330") +} + +func TestDecodeRAWConfig(t *testing.T) { + c := qt.New(t) + + tests := []struct { + filename string + format imagemeta.ImageFormat + width int + height int + }{ + {"sample.dng", imagemeta.DNG, 2592, 1944}, // DefaultCropSize + {"sample.cr2", imagemeta.CR2, 3648, 2736}, // ExifIFD dimensions + {"sample.nef", imagemeta.NEF, 2012, 1324}, // SubIFD dimensions + {"sample.arw", imagemeta.ARW, 3880, 2600}, // ExifIFD dimensions + } + + for _, tt := range tests { + c.Run(tt.filename, func(c *qt.C) { + f, err := os.Open(filepath.Join("testdata", "images", tt.filename)) + c.Assert(err, qt.IsNil) + defer f.Close() + + res, err := imagemeta.Decode(imagemeta.Options{ + R: f, + ImageFormat: tt.format, + Sources: imagemeta.CONFIG, + HandleTag: func(ti imagemeta.TagInfo) error { return nil }, + Warnf: panicWarnf, + }) + c.Assert(err, qt.IsNil) + c.Assert(res.ImageConfig.Width, qt.Equals, tt.width) + c.Assert(res.ImageConfig.Height, qt.Equals, tt.height) + }) + } +} + func TestDecodeWebP(t *testing.T) { c := qt.New(t) _, tags, err := extractTags(t, "bep/sunrise.webp", imagemeta.EXIF|imagemeta.IPTC|imagemeta.XMP) @@ -953,6 +1016,11 @@ func compareWithExiftoolOutput(t testing.TB, filename string, sources imagemeta. } if found { + // Skip comparison for binary data tags (exiftool shows + // "(Binary data N bytes, use -b option to extract)" for large arrays). + if s, ok := exifToolValue.(string); ok && strings.Contains(s, "Binary data") { + continue + } expect := normalizeThem(v.Tag, exifToolValue, v.Source) got := normalizeUs(v.Tag, v.Value, v.Source) if v, ok := got.(float64); ok { @@ -985,6 +1053,14 @@ func extToFormat(ext string) imagemeta.ImageFormat { return imagemeta.HEIF case ".avif": return imagemeta.AVIF + case ".dng": + return imagemeta.DNG + case ".cr2": + return imagemeta.CR2 + case ".nef": + return imagemeta.NEF + case ".arw": + return imagemeta.ARW case ".txt": return -1 default: @@ -1132,6 +1208,8 @@ func withTestDataFile(t testing.TB, fn func(path string, info os.FileInfo, err e var goldenSkip = map[string]bool{ "goexif/geodegrees_as_string.jpg": true, // The file has many EXIF errors. I think we do a better job than exiftools, but there are some differences. "invalid-encoding-usercomment.jpg": true, // The file has an EXIF error that produces a warning in imagemeta. It's tested separately. + "sample.cr2": true, // CR2 chains 4 IFDs; exiftool reports IFD3 StripByteCounts which we don't reach. + "sample.arw": true, // IFD0 0x0201/0x0202 are preview offsets; exiftool renames to PreviewImageStart/Length so values differ. } var isSpaceDelimitedFloatRe = regexp.MustCompile(`^(\d+\.\d+)( \d+\.?\d*)+$`) @@ -1327,6 +1405,47 @@ func BenchmarkDecode(b *testing.B) { _, err := imagemeta.Decode(imagemeta.Options{R: r, ImageFormat: imageFormat, HandleTag: handleTag, Warnf: panicWarnf, Sources: imagemeta.CONFIG}) return err }) + + runBenchmarkWithFile := func(b *testing.B, name string, imageFormat imagemeta.ImageFormat, filename string, f func(r io.ReadSeeker) error) { + img, err := os.Open(filepath.Join("testdata", "images", filename)) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { img.Close() }) + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + if err := f(img); err != nil { + b.Fatal(err) + } + img.Seek(0, 0) + } + }) + } + + for _, tt := range []struct { + name string + format imagemeta.ImageFormat + filename string + }{ + {"dng", imagemeta.DNG, "sample.dng"}, + {"cr2", imagemeta.CR2, "sample.cr2"}, + {"nef", imagemeta.NEF, "sample.nef"}, + {"arw", imagemeta.ARW, "sample.arw"}, + } { + imageFormat = tt.format + runBenchmarkWithFile(b, "exif/"+tt.name, tt.format, tt.filename, func(r io.ReadSeeker) error { + _, err := imagemeta.Decode(imagemeta.Options{R: r, ImageFormat: imageFormat, HandleTag: handleTag, Warnf: panicWarnf, Sources: sourceSetEXIF}) + return err + }) + runBenchmarkWithFile(b, "all/"+tt.name, tt.format, tt.filename, func(r io.ReadSeeker) error { + _, err := imagemeta.Decode(imagemeta.Options{R: r, ImageFormat: imageFormat, HandleTag: handleTag, Warnf: panicWarnf, Sources: sourceSetAll}) + return err + }) + runBenchmarkWithFile(b, "config/"+tt.name, tt.format, tt.filename, func(r io.ReadSeeker) error { + _, err := imagemeta.Decode(imagemeta.Options{R: r, ImageFormat: imageFormat, HandleTag: handleTag, Warnf: panicWarnf, Sources: imagemeta.CONFIG}) + return err + }) + } } func BenchmarkDecodeCompareWithGoexif(b *testing.B) { diff --git a/metadecoder_exif.go b/metadecoder_exif.go index abb8b06..2e01fc0 100644 --- a/metadecoder_exif.go +++ b/metadecoder_exif.go @@ -75,6 +75,7 @@ var exifTypeSize = map[exifType]uint32{ var ( exifFieldsAll = map[uint16]string{} exifIFDPointers = map[uint16]string{ + 0x014a: "SubIFD", 0x8769: "ExifIFDP", 0x8825: "GPSInfoIFD", 0xa005: "InteroperabilityIFD", @@ -109,6 +110,26 @@ var ( "LensInfo": exifConverters.convertRatsToSpaceLimited, "Padding": exifConverters.convertBinaryData, "UserComment": exifConverters.convertUserComment, + "CFAPattern2": exifConverters.convertBytesToStringSpaceDelim, + "CFARepeatPatternDim": exifConverters.convertNumbersToSpaceLimited, + "DefaultCropOrigin": exifConverters.convertNumbersToSpaceLimited, + "DefaultCropSize": exifConverters.convertNumbersToSpaceLimited, + "DefaultScale": exifConverters.convertRatsToSpaceLimited, + "ActiveArea": exifConverters.convertNumbersToSpaceLimited, + "ColorMatrix1": exifConverters.convertRatsToSpaceLimited, + "ColorMatrix2": exifConverters.convertRatsToSpaceLimited, + "AnalogBalance": exifConverters.convertRatsToSpaceLimited, + "AsShotNeutral": exifConverters.convertRatsToSpaceLimited, + "AsShotWhiteXY": exifConverters.convertRatsToSpaceLimited, + "DNGVersion": exifConverters.convertBytesToStringSpaceDelim, + "DNGBackwardVersion": exifConverters.convertBytesToStringSpaceDelim, + "TIFF-EPStandardID": exifConverters.convertBytesToStringSpaceDelim, + "CR2CFAPattern": exifConverters.convertBytesToStringSpaceDelim, + "RawImageSegmentation": exifConverters.convertNumbersToSpaceLimited, + "SonyToneCurve": exifConverters.convertNumbersToSpaceLimited, + "ThumbnailImageValidArea": exifConverters.convertNumbersToSpaceLimited, + "BlackLevelRepeatDim": exifConverters.convertNumbersToSpaceLimited, + "MaskedAreas": exifConverters.convertNumbersToSpaceLimited, "CFAPattern": func(ctx valueConverterContext, v any) any { b := v.([]byte) horizontalRepeat := ctx.s.byteOrder.Uint16(b[:2]) @@ -434,12 +455,27 @@ func (e *metaDecoderEXIF) decodeTag(namespace string) error { } if isIFDPointer { - offset, ok := val.(uint32) - if !ok { + switch v := val.(type) { + case uint32: + return e.decodeTagsAt(path.Join(namespace, ifd), int64(v)) + case []any: + // SubIFDs (0x014a) can be an array of IFD offsets. + // Number them to match exiftool: SubIFD, SubIFD1, SubIFD2, etc. + for i, item := range v { + if offset, ok := item.(uint32); ok { + ns := path.Join(namespace, ifd) + if i > 0 { + ns = path.Join(namespace, fmt.Sprintf("%s%d", ifd, i)) + } + if err := e.decodeTagsAt(ns, int64(offset)); err != nil { + return err + } + } + } + return nil + default: return newInvalidFormatErrorf("invalid IFD pointer value") } - namespace := path.Join(namespace, ifd) - return e.decodeTagsAt(namespace, int64(offset)) } if convert, found := exifValueConverterMap[tagName]; found { diff --git a/testdata/images/license.txt b/testdata/images/license.txt index 28242be..4ecc236 100644 --- a/testdata/images/license.txt +++ b/testdata/images/license.txt @@ -1 +1,2 @@ iphone.heic and sony.heif are copyright 2026 Toni Melisma, license: https://creativecommons.org/licenses/by-sa/4.0/ +sample.dng (Canon PowerShot SD450), sample.cr2 (Canon PowerShot S90), sample.nef (Nikon D1), sample.arw (Sony DSLR-A330) are from https://raw.pixls.us/, license: CC0 1.0 Universal (https://creativecommons.org/publicdomain/zero/1.0/) diff --git a/testdata/images/sample.arw b/testdata/images/sample.arw new file mode 100644 index 0000000..72c164b Binary files /dev/null and b/testdata/images/sample.arw differ diff --git a/testdata/images/sample.cr2 b/testdata/images/sample.cr2 new file mode 100644 index 0000000..3a5d358 Binary files /dev/null and b/testdata/images/sample.cr2 differ diff --git a/testdata/images/sample.dng b/testdata/images/sample.dng new file mode 100644 index 0000000..f11da34 Binary files /dev/null and b/testdata/images/sample.dng differ diff --git a/testdata/images/sample.nef b/testdata/images/sample.nef new file mode 100644 index 0000000..cf68251 Binary files /dev/null and b/testdata/images/sample.nef differ