From 7e13bc8145e3191e58cdcfc178105c16064b3738 Mon Sep 17 00:00:00 2001 From: Matthew McEachen Date: Thu, 28 Mar 2024 13:27:18 -0700 Subject: [PATCH] v25.1.0. Fixes #178 - deleteAllTags now takes a retain array - add tests for deleteAllTags with and without tag retention - add js docs for Tag interfaces - shove the orphan Tag fields into correct sub-interfaces - export `isExifToolTag`, `isGeolocationTag`, ... --- CHANGELOG.md | 8 ++ src/ErrorsAndWarnings.ts | 1 + src/ExifTool.ts | 16 ++- src/ExifToolTags.ts | 19 ++++ src/ExifToolVendoredTags.ts | 37 +++++++ src/FileTags.ts | 38 +++++++ src/GeolocationTags.ts | 24 +++++ src/Object.ts | 13 +++ src/Tags.ts | 198 ++++++++++++++++++++++-------------- src/WriteTask.spec.ts | 126 ++++++++++++++++++++++- src/_chai.spec.ts | 31 +++--- src/update/mktags.ts | 56 +++++++--- test/oly.jpg | Bin 13857 -> 13767 bytes 13 files changed, 460 insertions(+), 107 deletions(-) create mode 100644 src/ExifToolTags.ts create mode 100644 src/ExifToolVendoredTags.ts create mode 100644 src/FileTags.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5955b5a3..6abc6647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ vendored versions of ExifTool match the version that they vendor. ## Version history +### v25.1.0 + +- ✨ Added `retain` field to [`ExifTool.deleteAllTags`](https://photostructure.github.io/exiftool-vendored.js/classes/ExifTool.html#deleteAllTags) to address [#178](https://github.com/photostructure/exiftool-vendored.js/issues/178) + +- πŸ“¦ Added jsdocs for many `Tag` interface types + +- πŸ“¦ Expose `GeolocationTags` and `isGeolocationTag()` + ### v25.0.0 - 🌱/✨ ExifTool upgraded to [v12.80](https://exiftool.org/history.html#v12.80), which **adds support for reverse-geo lookups** and [several other geolocation features](https://exiftool.org/geolocation.html diff --git a/src/ErrorsAndWarnings.ts b/src/ErrorsAndWarnings.ts index 28514dd9..cc7fc7c8 100644 --- a/src/ErrorsAndWarnings.ts +++ b/src/ErrorsAndWarnings.ts @@ -8,6 +8,7 @@ export interface ErrorsAndWarnings { * process. */ errors?: string[] + /** * This is a list of all non-critical errors raised by ExifTool during the * read process. diff --git a/src/ExifTool.ts b/src/ExifTool.ts index 96106146..fe1956a6 100644 --- a/src/ExifTool.ts +++ b/src/ExifTool.ts @@ -12,7 +12,7 @@ import { DeleteAllTagsArgs } from "./DeleteAllTagsArgs" import { ErrorsAndWarnings } from "./ErrorsAndWarnings" import { ExifToolOptions, handleDeprecatedOptions } from "./ExifToolOptions" import { ExifToolTask } from "./ExifToolTask" -import { GeolocationTags } from "./GeolocationTags" +import { ExifToolVendoredTags } from "./ExifToolVendoredTags" import { ICCProfileTags } from "./ICCProfileTags" import { isWin32 } from "./IsWin32" import { lazy } from "./Lazy" @@ -48,6 +48,7 @@ import { ExifToolTags, FileTags, FlashPixTags, + GeolocationTags, IPTCTags, JFIFTags, MPFTags, @@ -83,6 +84,7 @@ export { ExifDate } from "./ExifDate" export { ExifDateTime } from "./ExifDateTime" export { ExifTime } from "./ExifTime" export { ExifToolTask } from "./ExifToolTask" +export { isGeolocationTag } from "./GeolocationTags" export { parseJSON } from "./JSON" export { DefaultReadTaskOptions } from "./ReadTask" export { @@ -110,6 +112,7 @@ export type { ErrorsAndWarnings, ExifToolOptions, ExifToolTags, + ExifToolVendoredTags, ExpandedDateTags, FileTags, FlashPixTags, @@ -333,8 +336,15 @@ export class ExifTool { * stat information and image dimensions, are intrinsic to the file and will * continue to exist if you re-`read` the file. */ - deleteAllTags(file: string): Promise { - return this.write(file, {}, DeleteAllTagsArgs) + deleteAllTags( + file: string, + opts?: { retain?: (keyof Tags | string)[] } + ): Promise { + const args = [...DeleteAllTagsArgs] + for (const ea of opts?.retain ?? []) { + args.push(`-${ea}<${ea}`) + } + return this.write(file, {}, args) } /** diff --git a/src/ExifToolTags.ts b/src/ExifToolTags.ts new file mode 100644 index 00000000..a3942965 --- /dev/null +++ b/src/ExifToolTags.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { keysOf } from "./Object" +import { ExifToolTags } from "./Tags" + +export const ExifToolTagNames = keysOf({ + ExifToolVersion: true, + SourceFile: true, + Error: true, + Warning: true, +}) + +/** + * Is the given tag name intrinsic to the content of a given file? In other + * words, is it not an artifact of a metadata field? + */ +export function isExifToolTag(name: string): name is keyof ExifToolTags { + return ExifToolTagNames.includes(name as any) +} diff --git a/src/ExifToolVendoredTags.ts b/src/ExifToolVendoredTags.ts new file mode 100644 index 00000000..dd0df875 --- /dev/null +++ b/src/ExifToolVendoredTags.ts @@ -0,0 +1,37 @@ +import { ErrorsAndWarnings } from "./ErrorsAndWarnings" +import { keysOf } from "./Object" + +/** + * This tags are added to {@link Tags} from this library. + */ +export interface ExifToolVendoredTags extends ErrorsAndWarnings { + /** + * Either an offset, like `UTC-7`, or an actual IANA timezone, like + * `America/Los_Angeles`. + * + * This will be missing if we can't intuit a timezone from the metadata. + */ + tz?: string + + /** + * Description of where and how `tz` was extracted + */ + tzSource?: string +} + +export const ExifToolVendoredTagNames = keysOf({ + tz: true, + tzSource: true, + errors: true, + warnings: true, +}) + +/** + * Is the given tag name intrinsic to the content of a given file? In other + * words, is it not an artifact of a metadata field? + */ +export function isExifToolVendoredTag( + name: string +): name is keyof ExifToolVendoredTags { + return ExifToolVendoredTagNames.includes(name as any) +} diff --git a/src/FileTags.ts b/src/FileTags.ts new file mode 100644 index 00000000..373c59f3 --- /dev/null +++ b/src/FileTags.ts @@ -0,0 +1,38 @@ +import { keysOf } from "./Object" +import { FileTags } from "./Tags" + +const FileTagNames = keysOf({ + BMPVersion: true, + BitsPerSample: true, + ColorComponents: true, + CurrentIPTCDigest: true, + Directory: true, + EncodingProcess: true, + ExifByteOrder: true, + FileAccessDate: true, + FileInodeChangeDate: true, + FileModifyDate: true, + FileName: true, + FilePermissions: true, + FileSize: true, + FileType: true, + FileTypeExtension: true, + ImageDataMD5: true, + ImageHeight: true, + ImageWidth: true, + MIMEType: true, + NumColors: true, + NumImportantColors: true, + PixelsPerMeterX: true, + PixelsPerMeterY: true, + Planes: true, + YCbCrSubSampling: true, +}) + +/** + * Is the given tag name intrinsic to the content of a given file? In other + * words, is it not an artifact of a metadata field? + */ +export function isFileTag(name: string): name is keyof FileTags { + return FileTagNames.includes(name as any) +} diff --git a/src/GeolocationTags.ts b/src/GeolocationTags.ts index 4d73ce0d..cda0e1ee 100644 --- a/src/GeolocationTags.ts +++ b/src/GeolocationTags.ts @@ -1,3 +1,27 @@ +import { keysOf } from "./Object" + +export const GeolocationTagNames = keysOf({ + GeolocationBearing: true, + GeolocationCity: true, + GeolocationCountry: true, + GeolocationCountryCode: true, + GeolocationDistance: true, + GeolocationFeatureCode: true, + GeolocationPopulation: true, + GeolocationPosition: true, + GeolocationRegion: true, + GeolocationSubregion: true, + GeolocationTimeZone: true, +}) + +/** + * Is the given tag name intrinsic to the content of a given file? In other + * words, is it not an artifact of a metadata field? + */ +export function isGeolocationTag(name: string): name is keyof GeolocationTags { + return GeolocationTagNames.includes(name as any) +} + /** * These tags are only available if {@link ExifToolOptions.geolocation} is true and the file * has valid GPS location data. diff --git a/src/Object.ts b/src/Object.ts index 47452ab7..8aef2a3b 100644 --- a/src/Object.ts +++ b/src/Object.ts @@ -45,3 +45,16 @@ export function omit, S extends string>( } return result } + +/** + * Provides a type-safe exhaustive array of keys for a given interface. + * + * Unfortunately, `satisfies (keyof T)[]` doesn't ensure all keys are present, + * and doesn't guard against duplicates. This function does. + * + * @param t - The interface to extract keys from. This is a Record of keys to + * `true`, which ensures the returned key array is unique. + */ +export function keysOf(t: Required>): (keyof T)[] { + return Object.keys(t) as any +} diff --git a/src/Tags.ts b/src/Tags.ts index 790a2f36..572be307 100644 --- a/src/Tags.ts +++ b/src/Tags.ts @@ -1,10 +1,9 @@ import { ApplicationRecordTags } from "./ApplicationRecordTags" import { BinaryField } from "./BinaryField" -import { ErrorsAndWarnings } from "./ErrorsAndWarnings" import { ExifDate } from "./ExifDate" import { ExifDateTime } from "./ExifDateTime" import { ExifTime } from "./ExifTime" -import { GeolocationTags } from "./GeolocationTags" +import { ExifToolVendoredTags } from "./ExifToolVendoredTags" import { ICCProfileTags } from "./ICCProfileTags" import { ImageDataHashTag } from "./ImageDataHashTag" import { MWGCollectionsTags, MWGKeywordTags } from "./MWGTags" @@ -14,15 +13,24 @@ import { Version } from "./Version" /* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * These tags are added by `exiftool`. + */ export interface ExifToolTags { /** β˜†β˜†β˜†β˜† βœ” Example: "File is empty" */ Error?: string /** β˜…β˜…β˜…β˜… βœ” Example: 12.8 */ ExifToolVersion?: number + /** β˜†β˜†β˜†β˜† Example: "path/to/file.jpg" */ + SourceFile?: string /** β˜†β˜†β˜†β˜† βœ” Example: "Unrecognized IPTC record 0 (ignored)" */ Warning?: string } +/** + * These tags are not metadata fields, but are intrinsic to the content of a + * given file. ExifTool can't write to many of these tags. + */ export interface FileTags { /** β˜†β˜†β˜†β˜† βœ” Example: "Windows V3" */ BMPVersion?: string @@ -38,7 +46,7 @@ export interface FileTags { EncodingProcess?: string /** β˜…β˜…β˜…β˜… βœ” Example: "Little-endian (Intel, II)" */ ExifByteOrder?: string - /** β˜…β˜…β˜…β˜… βœ” Example: "2024:03:26 15:28:17-07:00" */ + /** β˜…β˜…β˜…β˜… βœ” Example: "2024:03:28 11:39:10-07:00" */ FileAccessDate?: ExifDateTime | string /** β˜…β˜…β˜…β˜… βœ” Example: "2024:03:20 21:17:00-07:00" */ FileInodeChangeDate?: ExifDateTime | string @@ -76,6 +84,11 @@ export interface FileTags { YCbCrSubSampling?: string } +/** + * These are tags are derived from the values of one or more other tags. + * Only a few are writable directly. + * @see https://exiftool.org/TagNames/Composite.html + */ export interface CompositeTags { /** β˜†β˜†β˜†β˜† βœ” Example: "Unknown (49 5)" */ AdvancedSceneMode?: string @@ -127,7 +140,7 @@ export interface CompositeTags { OriginalDecisionData?: BinaryField | string /** β˜†β˜†β˜†β˜† Example: "9.9 um" */ PeakSpectralSensitivity?: string - /** β˜…β˜…β˜…β˜† βœ” Example: "(Binary data 315546 bytes, use -b option to extract)" */ + /** β˜…β˜…β˜…β˜† βœ” Example: "(Binary data 37244 bytes, use -b option to extract)" */ PreviewImage?: BinaryField /** β˜†β˜†β˜†β˜† βœ” Example: "On" */ RedEyeReduction?: string @@ -176,7 +189,7 @@ export interface APP1Tags { CreatorSoftware?: string /** β˜†β˜†β˜†β˜† Example: "2013:03:12 16:31:26" */ DateTimeGenerated?: ExifDateTime | string - /** β˜†β˜†β˜†β˜† Example: "(Binary data 275008 bytes, use -b option to extract)" */ + /** β˜†β˜†β˜†β˜† Example: "(Binary data 1998654 bytes, use -b option to extract)" */ EmbeddedImage?: BinaryField | string /** β˜†β˜†β˜†β˜† Example: 960 */ EmbeddedImageHeight?: number @@ -184,6 +197,8 @@ export interface APP1Tags { EmbeddedImageType?: string /** β˜†β˜†β˜†β˜† Example: 640 */ EmbeddedImageWidth?: number + /** β˜†β˜†β˜†β˜† Example: 1 */ + Emissivity?: number /** β˜†β˜†β˜†β˜† Example: "46.1 deg" */ FieldOfView?: string /** β˜†β˜†β˜†β˜† Example: "NOF" */ @@ -266,6 +281,8 @@ export interface APP1Tags { Real2IR?: number /** β˜†β˜†β˜†β˜† Example: "26.7 C" */ ReflectedApparentTemperature?: string + /** β˜†β˜†β˜†β˜† Example: "80.0 %" */ + RelativeHumidity?: string /** β˜†β˜†β˜†β˜† Example: "41 110 240" */ UnderflowColor?: string } @@ -429,10 +446,6 @@ export interface APP14Tags { export interface APP4Tags { /** β˜†β˜†β˜†β˜† βœ” Example: "40 C" */ AmbientTemperature?: string - /** β˜†β˜†β˜†β˜† Example: 1 */ - Emissivity?: number - /** β˜†β˜†β˜†β˜† Example: "80.0 %" */ - RelativeHumidity?: string } export interface APP5Tags { @@ -440,31 +453,9 @@ export interface APP5Tags { Compass?: string } -export interface APP6Tags { - /** β˜†β˜†β˜†β˜† βœ” Example: 800 */ - AutoISOMax?: number - /** β˜†β˜†β˜†β˜† βœ” Example: 3200 */ - AutoISOMin?: number - /** β˜†β˜†β˜†β˜† βœ” Example: "Up" */ - AutoRotation?: string - /** β˜†β˜†β˜†β˜† βœ” Example: "Photo Global Settings" */ - DeviceName?: string - /** β˜†β˜†β˜†β˜† βœ” Example: "On (Manual)" */ - HDRSetting?: string - /** β˜†β˜†β˜†β˜† βœ” Example: "AUTO" */ - MaximumShutterAngle?: string - /** β˜†β˜†β˜†β˜† βœ” Example: "859830e2f50cb3397a6216f09553fce800000000000000000000000000000000" */ - MediaUniqueID?: string - /** β˜†β˜†β˜†β˜† βœ” Example: "7.6.4" */ - MetadataVersion?: string - /** β˜†β˜†β˜†β˜† βœ” Example: "12MP_W" */ - PhotoResolution?: string - /** β˜†β˜†β˜†β˜† βœ” Example: "On" */ - ProTune?: string - /** β˜†β˜†β˜†β˜† βœ” Example: "4_1SEC" */ - Rate?: string -} - +/** + * @see https://exiftool.org/TagNames/EXIF.html + */ export interface EXIFTags { /** β˜†β˜†β˜†β˜† βœ” Example: 988517 */ Acceleration?: number @@ -648,7 +639,7 @@ export interface EXIFTags { InteropIndex?: string /** β˜…β˜…β˜…β˜… βœ” Example: "undef undef undef" */ InteropVersion?: string - /** β˜†β˜†β˜†β˜† βœ” Example: "(Binary data 772608 bytes, use -b option to extract)" */ + /** β˜†β˜†β˜†β˜† βœ” Example: "(Binary data 687616 bytes, use -b option to extract)" */ JpgFromRaw?: BinaryField /** β˜†β˜†β˜†β˜† βœ” Example: 845574 */ JpgFromRawLength?: number @@ -804,7 +795,7 @@ export interface EXIFTags { SubjectDistanceRange?: string /** β˜†β˜†β˜†β˜† βœ” Example: 1 */ SubjectLocation?: number - /** β˜…β˜…β˜…β˜… βœ” Example: "(Binary data 10202 bytes, use -b option to extract)" */ + /** β˜…β˜…β˜…β˜… βœ” Example: "(Binary data 39781 bytes, use -b option to extract)" */ ThumbnailImage?: BinaryField /** β˜…β˜…β˜…β˜… βœ” Example: 9998 */ ThumbnailLength?: number @@ -816,7 +807,7 @@ export interface EXIFTags { TileByteCounts?: BinaryField | string /** β˜†β˜†β˜†β˜† βœ” Example: 512 */ TileLength?: number - /** β˜†β˜†β˜†β˜† βœ” Example: "(Binary data 508 bytes, use -b option to extract)" */ + /** β˜†β˜†β˜†β˜† βœ” Example: "(Binary data 507 bytes, use -b option to extract)" */ TileOffsets?: BinaryField | string /** β˜†β˜†β˜†β˜† βœ” Example: 512 */ TileWidth?: number @@ -858,6 +849,34 @@ export interface EXIFTags { YResolution?: number } +export interface APP6Tags { + /** β˜†β˜†β˜†β˜† βœ” Example: 800 */ + AutoISOMax?: number + /** β˜†β˜†β˜†β˜† βœ” Example: 3200 */ + AutoISOMin?: number + /** β˜†β˜†β˜†β˜† βœ” Example: "Up" */ + AutoRotation?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "Photo Global Settings" */ + DeviceName?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "On (Manual)" */ + HDRSetting?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "AUTO" */ + MaximumShutterAngle?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "859830e2f50cb3397a6216f09553fce800000000000000000000000000000000" */ + MediaUniqueID?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "7.6.4" */ + MetadataVersion?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "12MP_W" */ + PhotoResolution?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "On" */ + ProTune?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "4_1SEC" */ + Rate?: string +} + +/** + * @see https://exiftool.org/TagNames/FlashPix.html + */ export interface FlashPixTags { /** β˜†β˜†β˜†β˜† Example: "(Binary data 20796 bytes, use -b option to extract)" */ AudioStream?: BinaryField | string @@ -883,6 +902,35 @@ export interface FlashPixTags { UsedExtensionNumbers?: number } +/** + * These tags are only available if {@link ExifToolOptions.geolocation} is true + * and the file has valid GPS location data. + */ +export interface GeolocationTags { + /** β˜†β˜†β˜†β˜† βœ” Example: 99 */ + GeolocationBearing?: number + /** β˜†β˜†β˜†β˜† βœ” Example: "ZΓΌrich" */ + GeolocationCity?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "United States" */ + GeolocationCountry?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "US" */ + GeolocationCountryCode?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "9.60 km" */ + GeolocationDistance?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "PPLL" */ + GeolocationFeatureCode?: string + /** β˜†β˜†β˜†β˜† βœ” Example: 95000 */ + GeolocationPopulation?: number + /** β˜†β˜†β˜†β˜† βœ” Example: "7.3397, 134.4733" */ + GeolocationPosition?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "Île-de-France" */ + GeolocationRegion?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "Yokohama Shi" */ + GeolocationSubregion?: string + /** β˜†β˜†β˜†β˜† βœ” Example: "Pacific/Saipan" */ + GeolocationTimeZone?: string +} + export interface JSONTags { /** β˜†β˜†β˜†β˜† Example: 0 */ AIScene?: number @@ -898,31 +946,9 @@ export interface JSONTags { ZoomMultiple?: number } -export interface MPFTags { - /** β˜…β˜…β˜†β˜† βœ” Example: 9697 */ - DependentImage1EntryNumber?: number - /** β˜…β˜…β˜†β˜† βœ” Example: 960 */ - DependentImage2EntryNumber?: number - /** β˜†β˜†β˜†β˜† βœ” Example: "(Binary data 66 bytes, use -b option to extract)" */ - ImageUIDList?: BinaryField | string - /** β˜…β˜…β˜†β˜† βœ” Example: "0100" */ - MPFVersion?: string - /** β˜…β˜…β˜†β˜† βœ” Example: "Representative image, Dependent parent image" */ - MPImageFlags?: string - /** β˜…β˜…β˜†β˜† βœ” Example: "Unknown (4)" */ - MPImageFormat?: string - /** β˜…β˜…β˜†β˜† βœ” Example: 999325 */ - MPImageLength?: number - /** β˜…β˜…β˜†β˜† βœ” Example: 9999872 */ - MPImageStart?: number - /** β˜…β˜…β˜†β˜† βœ” Example: "Undefined" */ - MPImageType?: string - /** β˜…β˜…β˜†β˜† βœ” Example: 3 */ - NumberOfImages?: number - /** β˜†β˜†β˜†β˜† βœ” Example: 1 */ - TotalFrames?: number -} - +/** + * @see https://exiftool.org/TagNames/IPTC.html + */ export interface IPTCTags { /** β˜†β˜†β˜†β˜† βœ” Example: 4 */ ApplicationRecordVersion?: number @@ -990,6 +1016,31 @@ export interface IPTCTags { Urgency?: string } +export interface MPFTags { + /** β˜…β˜…β˜†β˜† βœ” Example: 9697 */ + DependentImage1EntryNumber?: number + /** β˜…β˜…β˜†β˜† βœ” Example: 960 */ + DependentImage2EntryNumber?: number + /** β˜†β˜†β˜†β˜† βœ” Example: "(Binary data 66 bytes, use -b option to extract)" */ + ImageUIDList?: BinaryField | string + /** β˜…β˜…β˜†β˜† βœ” Example: "0100" */ + MPFVersion?: string + /** β˜…β˜…β˜†β˜† βœ” Example: "Representative image, Dependent parent image" */ + MPImageFlags?: string + /** β˜…β˜…β˜†β˜† βœ” Example: "Unknown (4)" */ + MPImageFormat?: string + /** β˜…β˜…β˜†β˜† βœ” Example: 999325 */ + MPImageLength?: number + /** β˜…β˜…β˜†β˜† βœ” Example: 9999872 */ + MPImageStart?: number + /** β˜…β˜…β˜†β˜† βœ” Example: "Undefined" */ + MPImageType?: string + /** β˜…β˜…β˜†β˜† βœ” Example: 3 */ + NumberOfImages?: number + /** β˜†β˜†β˜†β˜† βœ” Example: 1 */ + TotalFrames?: number +} + export interface MetaTags { /** β˜†β˜†β˜†β˜† Example: 1 */ BorderID?: number @@ -1036,6 +1087,9 @@ export interface PanasonicRawTags { NumWBEntries?: number } +/** + * @see https://exiftool.org/TagNames/Photoshop.html + */ export interface PhotoshopTags { /** β˜†β˜†β˜†β˜† βœ” Example: true */ CopyrightFlag?: boolean @@ -2167,7 +2221,7 @@ export interface MakerNotesTags { DSPFirmwareVersion?: string /** β˜†β˜†β˜†β˜† βœ” Example: "Yes" */ DarkFocusEnvironment?: string - /** β˜…β˜…β˜†β˜† βœ” Example: "(Binary data 114 bytes, use -b option to extract)" */ + /** β˜…β˜…β˜†β˜† βœ” Example: "(Binary data 280 bytes, use -b option to extract)" */ DataDump?: BinaryField | string /** β˜†β˜†β˜†β˜† βœ” Example: 8289 */ DataScaling?: number @@ -4497,6 +4551,9 @@ export interface MakerNotesTags { ZoomedPreviewStart?: number } +/** + * @see https://exiftool.org/TagNames/XMP.html + */ export interface XMPTags { /** β˜†β˜†β˜†β˜† βœ” Example: "uuid:faf5bdd5-ba3d-11da-ad31-d33d75182f1b" */ About?: string @@ -5062,8 +5119,8 @@ export interface XMPTags { * devices (like iPhones) An example value, JSON stringified, follows the * popularity ratings. * - * Autogenerated by "yarn mktags" by ExifTool 12.80 on Tue Mar 26 2024. - * 2723 unique tags were found in 10096 photo and video files. + * Autogenerated by "yarn mktags" by ExifTool 12.80 on Thu Mar 28 2024. + * 2735 unique tags were found in 10096 photo and video files. */ export interface Tags extends APP12Tags, @@ -5075,8 +5132,8 @@ export interface Tags ApplicationRecordTags, CompositeTags, EXIFTags, - ErrorsAndWarnings, ExifToolTags, + ExifToolVendoredTags, FileTags, FlashPixTags, GeolocationTags, @@ -5096,11 +5153,4 @@ export interface Tags QuickTimeTags, RAFTags, RIFFTags, - XMPTags { - /** Full, resolved native path to this file */ - SourceFile?: string - /** Either an offset, like `UTC-7`, or an actual timezone, like `America/Los_Angeles` */ - tz?: string - /** Description of where and how `tz` was extracted */ - tzSource?: string -} + XMPTags {} diff --git a/src/WriteTask.spec.ts b/src/WriteTask.spec.ts index d2479422..e6e1a859 100644 --- a/src/WriteTask.spec.ts +++ b/src/WriteTask.spec.ts @@ -2,13 +2,19 @@ import { existsSync } from "node:fs" import { ExifDate } from "./ExifDate" import { ExifDateTime } from "./ExifDateTime" import { ExifTool } from "./ExifTool" +import { isExifToolTag } from "./ExifToolTags" +import { + ExifToolVendoredTags, + isExifToolVendoredTag, +} from "./ExifToolVendoredTags" import { isFileEmpty } from "./File" +import { isFileTag } from "./FileTags" import { omit } from "./Object" import { ResourceEvent } from "./ResourceEvent" import { isSidecarExt } from "./Sidecars" import { stripSuffix } from "./String" import { Struct } from "./Struct" -import { Tags } from "./Tags" +import { ExifToolTags, FileTags, Tags } from "./Tags" import { Version } from "./Version" import { WriteTags } from "./WriteTags" import { @@ -705,4 +711,122 @@ describe("WriteTask", function () { }) }) } + + /** + * @see https://github.com/photostructure/exiftool-vendored.js/issues/178 + */ + describe("deleteAllTags()", () => { + const exiftool = new ExifTool() + after(() => exiftool.end()) + + const exp = { + UserComment: "This is a user comment added by exiftool.", + Artist: "Arturo DeImage", + Copyright: "Β© Chuckles McSnortypants, Inc.", + Credit: "photo by Jenny Snapsalot", + } + + const expectedDefinedTags = [ + "Make", + "Model", + "Software", + "ExposureTime", + "FNumber", + "ISO", + "CreateDate", + "DateTimeOriginal", + "LightSource", + "Flash", + "FocalLength", + "SerialNumber", + "DateTimeUTC", + ] + + function assertMissingGeneralTags(t: Tags) { + for (const ea of expectedDefinedTags) { + expect(t).to.not.haveOwnProperty(ea) + } + } + + function assertDefinedGeneralTags(t: Tags) { + for (const ea of expectedDefinedTags) { + expect(t).to.haveOwnProperty(ea) + } + } + + function isIntrinsticTag( + k: string + ): k is keyof (FileTags | ExifToolTags | ExifToolVendoredTags) { + return ( + isFileTag(k) || + isExifToolTag(k) || + isExifToolVendoredTag(k) || + ["ImageSize", "Megapixels"].includes(k) + ) + } + + function expectedChangedTag(k: string) { + return [ + "CurrentIPTCDigest", + "ExifByteOrder", + "FileModifyDate", + "FileSize", + "tz", + "tzSource", + ].includes(k) + } + + it("deletes all tags by default", async () => { + const img = await testImg({ srcBasename: "oly.jpg" }) + const before = await exiftool.read(img) + expect(before).to.containSubset(exp) + assertDefinedGeneralTags(before) + await exiftool.deleteAllTags(img) + const after = await exiftool.read(img) + assertMissingGeneralTags(after) + expect(after).to.not.containSubset(exp) + for (const k in exp) { + expect(after).to.not.haveOwnProperty(k) + } + // And make sure everything else is gone: + for (const k in before) { + if (expectedChangedTag(k)) continue + if (isIntrinsticTag(k)) { + expect(after[k]).to.eql(before[k], "intrinsic tag " + k) + } else { + expect(after).to.not.haveOwnProperty(k) + } + } + }) + + for (const key in exp) { + it(`deletes all tags except ${key}`, async () => { + const img = await testImg({ srcBasename: "oly.jpg" }) + const before = await exiftool.read(img) + expect(before).to.containSubset(exp) + assertDefinedGeneralTags(before) + await exiftool.deleteAllTags(img, { retain: [key] }) + const after = await exiftool.read(img) + assertMissingGeneralTags(after) + expect(after).to.haveOwnProperty(key) + for (const k in Object.keys(exp)) { + if (k !== key) { + expect(after).to.not.haveOwnProperty(k) + } + } + }) + } + it("supports deleting everything-except (issue #178)", async () => { + const img = await testImg({ srcBasename: "oly.jpg" }) + const before = await exiftool.read(img) + expect(before).to.containSubset(exp) + assertDefinedGeneralTags(before) + await exiftool.deleteAllTags(img, { retain: Object.keys(exp) }) + const after = await exiftool.read(img) + assertMissingGeneralTags(after) + expect(after).to.containSubset(exp) + // const missing = Object.keys(before).filter((k) => !(k in after)) + // console.log({ missing }) + }) + }) }) diff --git a/src/_chai.spec.ts b/src/_chai.spec.ts index 05d1ae3e..6109b2a5 100644 --- a/src/_chai.spec.ts +++ b/src/_chai.spec.ts @@ -1,11 +1,11 @@ import { Deferred, Log, setLogger } from "batch-cluster" import { expect } from "chai" -import crypto, { randomBytes } from "node:crypto" +import { createHash, randomBytes } from "node:crypto" import { createReadStream } from "node:fs" import { copyFile, mkdir } from "node:fs/promises" -import path from "node:path" -import process from "node:process" -import tmp from "tmp" +import { join } from "node:path" +import { env } from "node:process" +import { dirSync } from "tmp" import { compact } from "./Array" import { DateOrTime, toExifString } from "./DateTime" import { isWin32 } from "./IsWin32" @@ -35,7 +35,7 @@ setLogger( warn: console.warn, error: console.error, }, - (process.env.LOG as any) ?? "error" + (env.LOG as any) ?? "error" ) ) ) @@ -43,16 +43,16 @@ setLogger( export { expect } from "chai" -export const testDir = path.join(__dirname, "..", "test") +export const testDir = join(__dirname, "..", "test") export function randomChars(chars = 8) { return randomBytes(chars / 2).toString("hex") } -export const tmpdir = lazy(() => tmp.dirSync().name) +export const tmpdir = lazy(() => dirSync().name) export function tmpname(prefix = ""): string { - return path.join(tmpdir(), prefix + randomChars()) + return join(tmpdir(), prefix + randomChars()) } export function renderTagsWithISO(t: Tags) { @@ -72,30 +72,31 @@ export function renderTagsWithRawValues(t: Tags) { */ export async function testImg({ srcBasename = "img.jpg", - parentDir = "test", + parentDir, destBasename, }: { srcBasename?: Maybe parentDir?: string destBasename?: string } = {}): Promise { - const dir = path.join(tmpname(), parentDir) + const parent = tmpname() + const dir = parentDir == null ? parent : join(parent, parentDir) await mkdirp(dir) - const dest = path.join(dir, destBasename ?? srcBasename) - await copyFile(path.join(testDir, srcBasename), dest) + const dest = join(dir, destBasename ?? srcBasename) + await copyFile(join(testDir, srcBasename), dest) return dest } export async function testFile(name: string): Promise { const dir = tmpname() await mkdirp(dir) - return path.join(dir, name) + return join(dir, name) } export function sha1(path: string): Promise { const d = new Deferred() const readStream = createReadStream(path, { autoClose: true }) - const sha = crypto.createHash("sha1") + const sha = createHash("sha1") readStream.on("data", (ea) => sha.update(ea)) readStream.on("error", (err) => d.reject(err)) readStream.on("end", () => d.resolve(sha.digest().toString("hex"))) @@ -103,7 +104,7 @@ export function sha1(path: string): Promise { } export function sha1buffer(input: string | Buffer): string { - return crypto.createHash("sha1").update(input).digest().toString("hex") + return createHash("sha1").update(input).digest().toString("hex") } function dateishToExifString(d: Maybe): Maybe { diff --git a/src/update/mktags.ts b/src/update/mktags.ts index b5213cf1..702bc73c 100644 --- a/src/update/mktags.ts +++ b/src/update/mktags.ts @@ -10,6 +10,7 @@ import path from "node:path" import process from "node:process" import { compact, filterInPlace, uniq } from "../Array" import { ExifTool } from "../ExifTool" +import { ExifToolVendoredTagNames } from "../ExifToolVendoredTags" import { Maybe, map } from "../Maybe" import { isNumber } from "../Number" import { nullish } from "../ReadTask" @@ -128,6 +129,7 @@ const RequiredTags: Record = { SonyExposureTime: { t: "string", grp: "MakerNotes" }, SonyFNumber: { t: "number", grp: "MakerNotes" }, SonyISO: { t: "number", grp: "MakerNotes" }, + SourceFile: { t: "string", grp: "ExifTool", value: "path/to/file.jpg" }, SubSecCreateDate: { t: "ExifDateTime | string", grp: "Composite" }, SubSecDateTimeOriginal: { t: "ExifDateTime | string", grp: "Composite" }, SubSecMediaCreateDate: { t: "ExifDateTime | string", grp: "Composite" }, @@ -149,6 +151,28 @@ const RequiredTags: Record = { // ☠☠ NO REALLY THIS IS BAD CODE PLEASE STOP SCROLLING ☠☠ +const js_docs = { + CompositeTags: [ + "These are tags are derived from the values of one or more other tags.", + "Only a few are writable directly.", + "@see https://exiftool.org/TagNames/Composite.html", + ], + EXIFTags: ["@see https://exiftool.org/TagNames/EXIF.html"], + ExifToolTags: ["These tags are added by `exiftool`."], + FileTags: [ + "These tags are not metadata fields, but are intrinsic to the content of a", + "given file. ExifTool can't write to many of these tags.", + ], + FlashPixTags: ["@see https://exiftool.org/TagNames/FlashPix.html"], + GeolocationTags: [ + "These tags are only available if {@link ExifToolOptions.geolocation} is true", + "and the file has valid GPS location data.", + ], + IPTCTags: ["@see https://exiftool.org/TagNames/IPTC.html"], + PhotoshopTags: ["@see https://exiftool.org/TagNames/Photoshop.html"], + XMPTags: ["@see https://exiftool.org/TagNames/XMP.html"], +} + // If we don't do tag pruning, TypeScript fails with // error TS2590: Expression produces a union type that is too complex to represent. @@ -173,9 +197,9 @@ const ExcludedTagRe = new RegExp( "DayltConv", "DefConv", "DefCor", + ...[...ExifToolVendoredTagNames].map((ea) => "^" + ea + "$"), "Face\\d", "FCS\\d", - "Geolocation", // we add these with the external GeolocationTags interface "HJR", "IM[a-z]", "IncandConv", @@ -228,6 +252,7 @@ const exiftool = new ExifTool({ // if we use straight defaults, we're load-testing those defaults. streamFlushMillis: 2, minDelayBetweenSpawnMillis: 0, + geolocation: true, // maxTasksPerProcess: 100, // < uncomment to verify proc wearing works }) @@ -609,6 +634,10 @@ class TagMap { ) { return } + // Let's move the geolocation tags to their own Geolocation group: + if (tagName.startsWith("ExifTool:Geolocation")) { + tagName = tagName.replace("ExifTool:", "Geolocation:") + } const tag = this.tag(tagName) if (important) { tag.important = true @@ -738,11 +767,10 @@ Promise.all(files.map((file) => readAndAddToTagMap(file))) [ 'import { ApplicationRecordTags } from "./ApplicationRecordTags"', 'import { BinaryField } from "./BinaryField"', - 'import { ErrorsAndWarnings } from "./ErrorsAndWarnings"', 'import { ExifDate } from "./ExifDate"', 'import { ExifDateTime } from "./ExifDateTime"', 'import { ExifTime } from "./ExifTime"', - 'import { GeolocationTags } from "./GeolocationTags"', + 'import { ExifToolVendoredTags } from "./ExifToolVendoredTags"', 'import { ICCProfileTags } from "./ICCProfileTags"', 'import { ImageDataHashTag } from "./ImageDataHashTag"', 'import { MWGCollectionsTags, MWGKeywordTags } from "./MWGTags"', @@ -774,12 +802,20 @@ Promise.all(files.map((file) => readAndAddToTagMap(file))) return indexOf >= 0 ? indexOf : index + unsortedGroupNames.length }) for (const group of groupNames) { + const interfaceName = group + "Tags" // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const tagsForGroup = groupedTags.get(group)! const filteredTags = sortBy(tagsForGroup, (ea) => ea.tag) if (filteredTags.length > 0) { tagGroups.push(group) - tagWriter.write(`\nexport interface ${group}Tags {\n`) + if (interfaceName in js_docs) { + tagWriter.write(`\n/**\n`) + for (const line of (js_docs as any)[interfaceName]) { + tagWriter.write(" * " + line + "\n") + } + tagWriter.write(` */`) + } + tagWriter.write(`\nexport interface ${interfaceName} {\n`) for (const tag of filteredTags) { tagWriter.write( ` /** ${tag.popIcon(files.length)} ${tag.example()} */\n` @@ -792,8 +828,7 @@ Promise.all(files.map((file) => readAndAddToTagMap(file))) const interfaceNames = [ ...tagGroups.map((s) => s + "Tags"), "ApplicationRecordTags", - "ErrorsAndWarnings", - "GeolocationTags", + "ExifToolVendoredTags", "ImageDataHashTag", "ICCProfileTags", "MWGCollectionsTags", @@ -818,14 +853,7 @@ Promise.all(files.map((file) => readAndAddToTagMap(file))) ` * ${tagMap.byBase.size} unique tags were found in ${files.length} photo and video files.`, ` */`, "export interface Tags", - ` extends ${interfaceNames.join(",\n ")} {`, - ` /** Full, resolved native path to this file */`, - " SourceFile?: string", - " /** Either an offset, like `UTC-7`, or an actual timezone, like `America/Los_Angeles` */", - " tz?: string", - " /** Description of where and how `tz` was extracted */", - " tzSource?: string", - "}", + ` extends ${interfaceNames.join(",\n ")} {}`, "", ].join("\n") ) diff --git a/test/oly.jpg b/test/oly.jpg index ae7cb392c918dff3079132639840e3c21c2cb057..abb9f2566a15db6e13dae890741c94350a627173 100644 GIT binary patch delta 401 zcmXAl!Ak-`6vp4Kvnqik)FH~l914PBRoo&HA~T2$1j)dTZ92B1&dBZ>84+So9@Qo2 z(jllrrw(2^N8o=Dfv5g}PMtd0Q(=bp@tgO(`DPx={gQY1HT>3E9d9Es4FG8gU`F-; z7Lak@H)Wt$j}a7a$(Xl2086cj<{H5BhYy0--01Z0!U@Z%S%U5|y<(g)fcF=gG;6t1 znb|az%Wz>i^{T-gdzI=ur}hTYf*ep4fQP0sp1$sSNIbfQgtTRt#k&tsOC28&)DmVR z-KlYB0lCthxVS4wc>WB@W!DEsD!>)$8`M#Bea753vMHtAUlN!6Zdln11TnbwGY;n&GP+~u{ue`md4 A(*OVf delta 309 zcmX?}y)cLE|Be3-O;skcH8?sjFfar#GB5};F*5)u1|}e$(Zs|65}yDR$ZTf;i$7py zU{LLVu(?bb7?vJkXAl4?zA*9l)X9(78#ebaN-^=)u`)2sZDIxMXq&v5+1juVD8e#< zi9rNt-Xb8IaUwed3y{qSw2x&H7g*&spg8L!h^~E;{_5MHIvC1ELMzfCNE~OW?VA)K8ri=8WRQvlO;?H3P9;In~hn+nHl{ycXNbmPkz9} aF?kWYytx8-U~&k%!ekMX%E{+VegFU`(mw70