#!/bin/bash # # Add XMP metadata to a series of JPEG images. # # 1) # addxmp # addxmp # # Reads from a file or stdin and adds (mostly Dublin Core) metadata # from that file to the image files metioned in it. # # 2) # addxmp -c # addxmp -c ... # # Creates a template on stdout for metadata for the images in the # current directory or for the images given on the command line. # # 3) # addxmp -r # addxmp -r ... # # Reads metadata from the images in the current directory and outputs # to stdout a pre-filled template. # # Ad 1) Reads (from a file or from stdin) a list of file names (JPEG # file) and descriptions to be added to them. Each line is either a # field to add or the name of a file. The first letter of each line is # the field name and determines what the line contains. E.g.: # # L en # C Antibes, Alpes-Maritimes, France # F 12345.jpg # T Olive tree # D An olive tree in a square # # F 12346.jpg # D The trunk of an olive tree # # Each line with an "F" gives a file name. Each file gets the same # metadata as the previous file, except for fields that are overridden # by lines directly after the file name. In the example above, the # first file, 12345.jpg, gets language="en", coverage="Antibes, # Alpes-Maritimes, France", title="Olive tree" and description="An # olive tree in a square". The second file, 12346.jpg, gets the same # language, coverage and title, but a different description; # description="The trunk of an olive tree". # # To reset a field to nothing, leave it empty: # # F 123.jpg # T Olive tree # D An olive tree # F 456.jpg # D # # The second file, 456.jpg, will get the same title as the first, but # will not get a description. # # If a field is added to a file, it replaces the field that that file # may already have. If a language is given, only the field in that # language is replaced, otherwise the field is removed in all # languages and replaced by one without a language. # # If a field is not added, such as the D (description) field for file # 456.jpg above, any existing values for that field in the file are # left unchanged. In other words, there is no way to remove an # existing field, only to replace it. # # There must be one space between the field name and the value. (If # the value is empty, the space may be omitted.) # # Values may be written over several lines by starting each line after # the first with a "+" and a space, e.g.: # # D This description # + takes two lines # # The lines will be concatenated with a space between them. # # The letters are: # # L language. The value should be a locale tag such as "fr" (for # French) or "en-us" (for American English). This is the language # of the metadata, not the language of the image. # # C coverage. The location where the photo was taken. # # T title. # # D description. # # P publisher. # # S series (Dublin Core: relation). The title for a series of # related photos, such as the name of the event where they were # taken, or the reason why they were taken. # # R rights. A copyright statement, e.g., "Copyright © 2013 Bert Bos" # # A author (Dublin Core: creator). # # c contributor. # # I identifier. See also N. # # N name pattern. A regular expression that is applied to the file # name to yield the identifier. Only used if I (identifier) is # empty. The regular expression must have a parenthesized part and # the first such part is the identifier. The pattern is anchored # at the start of the file name. E.g., is the file name is # "img_1234.jpg" and the pattern is "img_([0-9]+)", the # parenthesized subpattern will match "1234" and that will thus be # the identifier for that file. # # d date. Recommended format is YYYY-MM-DD. # # z timezone. If d is not given, the date and time are read from the # photo's EXIF, in which case this timezone is added. Recommended # format is numeric, e.g., +02:00, or Z (for UTC) # # K keywords (Dublin Core: subject). A list of keywords separated by # commas. # # X longitude. This overrides any GPS data in the photo's EXIF. # # Y latitude. This overrides any GPS data in the photo's EXIF. # # Z altitude. This overrides any GPS data in the photo's EXIF. # # s source. The source from which this image is derived. # # # comment. Lines starting with "#" are ignored. # # Empty lines are ignored. # # TO DO: continuation lines that start with a space or tab. # # TO DO: a way to delete an existing value from a file (because an # empty value just leaves it unchanged). # # File names can occur multiple times. This is useful, e.g., to add # metadata in multiple languages: # # F 123.jpg # L en # D An olive tree # F 123.jpg # L fr # D Un olivier # # In addition to the fields given in the input, each file is also # scanned for relevant EXIF data, such as camera make and GPS # coordinates. That data is also added in the XMP. Fields given # explicitly override fields found in the EXIF. # # Author: Bert Bos # Created: 14 March 2013 # Dublin Core properties # readonly DC="http://purl.org/dc/elements/1.1/" readonly TITLE="${DC}title" readonly CREATOR="${DC}creator" readonly SUBJECT="${DC}subject" readonly DESCRIPTION="${DC}description" readonly PUBLISHER="${DC}publisher" readonly CONTRIBUTOR="${DC}contributor" readonly DATE="${DC}date" readonly TYPE="${DC}type" readonly FORMAT="${DC}format" readonly IDENTIFIER="${DC}identifier" readonly RELATION="${DC}relation" readonly COVERAGE="${DC}coverage" readonly RIGHTS="${DC}rights" readonly SOURCE="${DC}source" # PhotoRDF Technical properties # readonly TECH="http://www.nbwuij.icu/2000/PhotoRDF/technical-1-0#" readonly CAMERA="${TECH}camera" readonly FILM="${TECH}film" readonly LENS="${TECH}lens" readonly DEVEL_DATE="${TECH}devel-date" # Selected EXIF/XMP properties # readonly EXIF="http://ns.adobe.com/exif/1.0/" readonly GPSVersionID="${EXIF}GPSVersionID" # Value should be 2.0.0.0 readonly GPSLatitude="${EXIF}GPSLatitude" # DDD,MM,SSk or DDD,MM.mmk readonly GPSLongitude="${EXIF}GPSLongitude" # DDD,MM,SSk or DDD,MM.mmk readonly GPSAltitudeRef="${EXIF}GPSAltitudeRef" # 0 above sea level, 1 below readonly GPSAltitude="${EXIF}GPSAltitude" # In meters readonly GPSBearingRef="${EXIF}GPSDestBearingRef" # T(rue) or M(agnetic) North readonly GPSBearing="${EXIF}GPSDestBearing" # Compas direction [0.0,360.0) # Selected properties in the TIFF namespace # readonly TIFF="http://ns.adobe.com/tiff/1.0/" readonly MAKE="${TIFF}Make" readonly MODEL="${TIFF}Model" declare -i maxprocesses=1 # Max # of write-to-file processes in parallel readonly clr_eol=$(tput el) # Clear until end of line # sed option to get extended regexps if sed -r p /dev/null; then ext="-r"; else ext="-E"; fi # die -- print error message and exit function die { echo >&2 echo "$@" >&2 wait exit 1 } # lock -- wait for exclusive access to file $1 function lock { until ln -s $$ "$1.lock" 2>/dev/null; do sleep 0.02; done; } # unlock -- release exclusive access to file $1 function unlock { rm "$1.lock"; } # semaphore-p -- decrement semaphore $1 by $2 (default 1) function semaphore-p { local -i n v=${2:-1} until lock "$1"; n=$(< "$1"); ((n >= v)); do unlock "$1"; sleep 0.1; done echo -n $((n - v)) >"$1" unlock "$1" } # semaphore-v -- increment semaphore $1 by $2 (default 1) function semaphore-v { lock "$1" local -i n=$(< "$1") v=${2:-1} echo -n $((n + v)) >"$1" unlock "$1" } # semaphore-new -- return a semaphore set to $1 (default 1) in $2 (default /tmp) function semaphore-new { local d if ! d=$(mktemp -d ${2:-/tmp}/s-XXXXX) 2>/dev/null; then return 1; fi if ! echo -n ${1:-1} >"$d/semaphore"; then rm -rf "$d"; return 1; fi echo "$d/semaphore" } # semaphore-delete -- delete a semaphore function semaphore-delete { rm -f "$1" "$1.lock"; } # usage -- print usage message and exit function usage { echo "Usage: $1 [file]" echo " or: $1 -c [image [image...]]" echo " or: $1 -r [image [image...]]" } # have -- check that program $1 exists function have { type -t "$1" >/dev/null; } ######################################################################## ## ## Routines related to print-template() ## ######################################################################## # print-template -- output a template function print-template { echo "# Template generated by $0 -c" echo "L nl" echo "C " echo "P http://www.phonk.net/" echo "S " echo "A Bert Bos" echo "R Copyright ©" $(date +%Y) "Bert Bos" echo "N ([0-9a-z]+)-" echo "# c " echo "# X longitude DDD,MM,SSk or DDD,MM.mmk or [-]DDD.dddd" echo "# Y latitude DDD,MM,SSk or DDD,MM.mmk or [-]DDD.dddd" echo "# Z altitude (meters above sea level)" echo "# d YYYY-MM-DD or YYYY-MM-DD HH:MM:SS+ZZ:ZZ" echo "# s source (original) image" echo "# z timezone" echo if [ $# == 0 ]; then # No arguments: use images from directory for f in *.jpg; do echo -e "F $f\nT\nD\nK\n" done else # With arguments: use those arguments for f; do echo -e "F $f\nT\nD\nK\n" done fi } ######################################################################## ## ## Routines related to read-template() ## ######################################################################## # scan-xmp -- extract XMP data from file $1 function scan-xmp { # tr is needed because of a bug in GNU sed 4.1: "." doesn't match # null bytes; and because it makes sed faster tr '\000' '\n' <"$1" | \ sed \ -e '/]*W5M0MpCehiHzreSzNTczkc9d/,/.*//" } if have xmp-scan; then function read-xmp-jpeg { xmp-scan "$1"; } elif have rdjpgxmp; then function read-xmp-jpeg { rdjpgxmp "$1" || echo -n; } elif have exiv2; then function read-xmp-jpeg { exiv2 -pX "$1"; } elif have exiftool; then function read-xmp-jpeg { exiftool -XMP -b "$1"; } else function read-xmp-jpeg { scan-xmp "$1"; } fi if have xmp-scan; then function read-xmp-png { xmp-scan "$1"; } elif have exiv2; then function read-xmp-png { exiv2 -pX "$1"; } elif have exiftool; then function read-xmp-png { exiftool -XMP -b "$1"; } else function read-xmp-png { scan-xmp "$1"; } fi if have xmp-scan; then function read-xmp-webp { xmp-scan "$1"; } elif have webpmux; then function read-xmp-webp { webpmux -get xmp -o - -- "$1" 2>/dev/null||echo -n; } elif have exiv2; then function read-xmp-webp { exiv2 -pX "$1"; } elif have exiftool; then function read-xmp-webp { exiftool -XMP -b "$1"; } else function read-xmp-webp { scan-xmp "$1"; } fi if have xmp-scan; then function read-xmp-any { xmp-scan "$1"; } else function read-xmp-any { scan-xmp "$1"; } fi # read-xmp -- extract any XMP from image file $1 function read-xmp { case `file --mime-type -b -L "$1"` in image/jpeg) read-xmp-jpeg "$1";; image/png) read-xmp-png "$1";; image/webp) read-xmp-webp "$1";; *) read-xmp-any "$1";; esac } # read-metadata -- read metadata from $1 and print it, $2 is file with predicates function read-metadata { # $3 is a temporary file to keep each previous block of values in. # TODO: Remove newlines from values. # TODO: Speed this up by using more Bash variables and built-ins # instead of forking join, grep, sed, head, etc.? local TMP1=$(mktemp $TMPDIR/XXXXXX) || exit 1 local TMP2=$(mktemp $TMPDIR/XXXXXX) || exit 1 local TMP3=$(mktemp $TMPDIR/XXXXXX) || exit 1 local langs # Get the existing metadata from $1. Result is a three-column TSV # file: (predicate name, language, value), sorted on the predicate # name. read-xmp "$1" | xmptool -c | xmptool -v '*' | \ sed -e '/\] /s/\[/ [/' \ -e '/\] /!s/ / /' \ -e 's/ \[/ /' \ -e 's/\] / /' \ -e 's/ x-default / / ' | \ sort >$TMP2 # Replace the RDF names with their corresponding letters from $2. # Output is a four-column TSV file: (letter, language dependence, # language, value), sorted on letter. join -t ' ' -1 3 -2 1 -o 1.1,1.2,2.2,2.3 \ <(sort -t ' ' -k3 $2) $TMP2 | sort >$TMP3 # Get one of the languages used for values for which the language # matters. Default to the language of the previous block, or x-default. lang=$(grep " y " $TMP3 | grep -v " " | \ head -n 1 | cut -f 3 || sed -n -e '/^L/{s/^L *//;p;q;}' $3) lang=${lang:-x-default} # Replace the languages of values for which the language does not # matter by the language just found. sed -e "s/ n [^ ]* / n $lang /" $TMP3 >$TMP1 mv $TMP1 $TMP3 # Also set the language of each value that does not have a language # and that consists of a URL to the language just found. sed \ -e "s| https://| $lang https://|" \ -e "s| http://| $lang http://|" $TMP3 >$TMP1 mv $TMP1 $TMP3 # Set the language of values that don't have a language yet to x-default. sed -e 's/ / x-default /' $TMP3 >$TMP1 mv $TMP1 $TMP3 # Find all languages used in the file's XMP. langs=`cut -f3 $TMP3 | sort -u` # Output all values in each language, with empty values if there is # none. If there is no XMP at all in the file, use the default # language found above, so that the loop runs at least once and # outputs empty fields. for lang in ${langs:-$lang}; do echo echo "F $1" # Generate the full list of fields, with empty values if there is # no value for that field in TMP3. ( echo "L $lang" { grep " $lang " $TMP3 || true; } | \ join -t ' ' -a 1 -o 1.1,2.4 $2 - | \ sed -e 's/ / /' ) | \ sort >$TMP2 # Filter out the fields that are the same as in the previously # printed block. comm -13 $3 $TMP2 # Remember the values of this block for the next invocation of # this function. cat $TMP2 >$3 done rm -f $TMP1 $TMP2 $TMP3 } # read-template -- create metadata template from existing metadata function read-template { local f g local -i i local TMP1=$(mktemp $TMPDIR/XXXXXX) || exit 1 local TMP2=$(mktemp $TMPDIR/XXXXXX) || exit 1 # Write a TSV file to join with in read-metadata(), sorted on the # first letter. cat >$TMP1 <<-EOF A y http://purl.org/dc/elements/1.1/creator C y http://purl.org/dc/elements/1.1/coverage D y http://purl.org/dc/elements/1.1/description I n http://purl.org/dc/elements/1.1/identifier K y http://purl.org/dc/elements/1.1/subject P y http://purl.org/dc/elements/1.1/publisher R y http://purl.org/dc/elements/1.1/rights S y http://purl.org/dc/elements/1.1/relation T y http://purl.org/dc/elements/1.1/title X n http://ns.adobe.com/exif/1.0/GPSLongitude Y n http://ns.adobe.com/exif/1.0/GPSLatitude Z n http://ns.adobe.com/exif/1.0/GPSAltitude c y http://purl.org/dc/elements/1.1/contributor d n http://purl.org/dc/elements/1.1/date s n http://purl.org/dc/elements/1.1/source" EOF # TMP2 is a temporary file to keep each previous block of values in, # for use by read-metadata. echo "# Template generated by $0 -r" if [ $# == 0 ]; then # No arguments: read all images in the directory for f in *.jpg; do read-metadata "$f" $TMP1 $TMP2 done else # With arguments: read the arguments for f; do read-metadata "$f" $TMP1 $TMP2 done fi rm -f $TMP1 $TMP2 } ######################################################################## ## ## Routines related to apply() ## ######################################################################## if have wrjpgxmp; then function write-xmp-jpeg { wrjpgxmp "$1"; } elif have exiv2; then function write-xmp-jpeg { local TMP=$(mktemp $TMPDIR/write-XXXX) || exit 1 cp "$1" $TMP exiv2 -i XX- -o - $TMP cat $TMP rm $TMP } elif have exiftool; then # Exiftool only copies tags it knows :-( function write-xmp-jpeg { exiftool -tagsFromFile - -all:all -o - "$1"; } else function write-xmp-jpeg { die "$0: no tool found to write XMP into JPEG"; } fi if have exiv2; then function write-xmp-png { local TMP=$(mktemp $TMPDIR/write-XXXX) || exit 1 cp "$1" $TMP exiv2 -i XX- -o - $TMP cat $TMP rm $TMP } elif have exiftool; then # Exiftool only copies tags it knows :-( function write-xmp-png { exiftool -tagsFromFile - -all:all -o - "$1"; } else function write-xmp-png { die "$0: no tool found to write XMP into PNG"; } fi if have webpmux; then function write-xmp-webp { webpmux -set xmp - -o - -- "$1"; } elif have exiv2; then function write-xmp-webp { local TMP=$(mktemp $TMPDIR/write-XXXX) || exit 1 cp "$1" $TMP exiv2 -i XX- -o - $TMP cat $TMP rm $TMP } elif have exiftool; then # Exiftool only copies tags it knows :-( function write-xmp-webp { exiftool -tagsFromFile - -all:all -o - "$1"; } else function write-xmp-webp { die "$0: no tool found to write XMP into WebP"; } fi # write-xmp -- read XMP from stdin and image from $1, output merged to stdout function write-xmp { xmptool -c | case `file --mime-type -b -L "$1"` in image/jpeg) write-xmp-jpeg "$1";; image/png) write-xmp-png "$1";; image/webp) write-xmp-webp "$1";; *) die "$0: Can only write metadata to JPEG, PNG or WebP images";; esac || exit 1 } # set-field -- set field $2 to value $3 with language $4 in database file $1 function set-field { # The database is just a list of field names and values, one per line echo -e "$2\t$3\t$4" >>$1 } # add-fields-to-xmp -- add fields from file $1 to XMP file $2, output to stdout function add-fields-to-xmp { local TMP=$(mktemp $TMPDIR/set-XXXX) || exit 1 local xmpfile=$2 local field value lang r h while IFS=$'\t' read field value lang; do # Guess that a value that starts with http:/https: is a resource case "$value" in http:*|https:*) r=-r;; *) r=;; esac # Delete the field in the given language and add a new value, if any if [[ -z "$value" ]]; then xmptool -d -l "$lang" -- "$field" $xmpfile else xmptool -d -l "$lang" -- "$field" $xmpfile | xmptool -w -l "$lang" $r -- "$field" "$value" fi >$TMP # Swap the roles of TMP and xmpfile h=$xmpfile; xmpfile=$TMP; TMP=$h done <$1 cat $xmpfile } # set-lat-or-long -- set latitude or longitude after checking the syntax function set-lat-or-long { local xmp=$1 field=$2 value=$3 local d m if [ "$value" == "" ]; then set-field $xmp "$field" "" x-default elif [[ "$field" == "$GPSLatitude" ]]; then if [[ "$value" =~ ^[0-9]+,[0-9]+,?[0-9]*\.?[0-9]*[NS]$ ]]; then # ^[0-9]+,[0-9]+(,[0-9]+)?(\.[0-9]+)?[NS]$ set-field $xmp "$field" "$value" x-default elif [[ "$value" =~ ^[0-9]+\.?[0-9]*$ ]]; then # ^[0-9]+(\.[0-9]+)?$ m=$(dc <<<"$value 1%4k60*p"); d=${2%.*} set-field $xmp "$field" "${d},${m}N" x-default elif [[ "$value" =~ ^-[0-9]+\.?[0-9]*$ ]]; then # ^-[0-9]+(\.[0-9]+)?$ m=$(dc <<<"${2#-} 1%4k60*p"); d=${2#-}; d=${d%.*} set-field $xmp "$field" "${d},${m}S" x-default elif [[ "$value" =~ ^[0-9]+,[0-9]*\.[0-9]+[NS]$ ]]; then set-field $xmp "$field" "$value" x-default else die "$0: Error: lat/long must be like 6,58,6W or 43,58.1N or -7.956" fi elif [[ "$field" == "$GPSLongitude" ]]; then if [[ "$value" =~ ^[0-9]+,[0-9]+,?[0-9]*\.?[0-9]*[EW]$ ]]; then # ^[0-9]+,[0-9]+(,[0-9]+)?(\.[0-9]+)?[EW]$ set-field $xmp "$field" "$value" x-default elif [[ "$value" =~ ^[0-9]+\.?[0-9]*$ ]]; then # ^[0-9]+(\.[0-9]+)?$ m=$(dc <<<"$value 1%4k60*p"); d=${2%.*} set-field $xmp "$field" "${d},${m}E" x-default elif [[ "$value" =~ ^-[0-9]+\.?[0-9]*$ ]]; then # ^-[0-9]+(\.[0-9]+)?$ m=$(dc <<<"${2#-} 1%4k60*p"); d=${2#-}; d=${d%.*} set-field $xmp "$field" "${d},${m}W" x-default elif [[ "$value" =~ ^[0-9]+,[0-9]*\.[0-9]+[EW]$ ]]; then set-field $xmp "$field" "$value" x-default else die "$0: Error: lat/long must be like 6,58,6W or 43,58.1N or -7.956" fi else die "$0: Cannot happen!" fi set-field $xmp "$GPSVersionID" "2.0.0.0" x-default } # set-altitude -- set altitude to $2, in meters (19.5m) or rational (38/2) function set-altitude { local xmp=$1 value=$2 local ref case "$value" in -*/*) ref=1; value=${value#-};; # Negative rational value +*/*) ref=0; value=${value#+};; # Positive rational value */*) ref=0;; # Positive rational value -*) ref=1; value=${value#-}; value=`dc <<<"${value%m} 100*1/p"`/100;; +*) ref=0; value=${value#+}; value=`dc <<<"${value%m} 100*1/p"`/100;; *) ref=0; value=`dc <<<"${value%m} 100*1/p"`/100;; # Positive meters esac set-field $xmp "$GPSAltitudeRef" "$ref" x-default set-field $xmp "$GPSAltitude" "$value" x-default set-field $xmp "$GPSVersionID" "2.0.0.0" x-default } # set-bearingref -- set bearing reference direction after checking syntax function set-bearingref { local xmp=$1 value=$2 case "$value" in "") set-field $xmp "$GPSBearingRef" "" x-default;; T|t) set-field $xmp "$GPSBearingRef" T x-default;; M|m) set-field $xmp "$GPSBearingRef" M x-default;; *) die "$0: Error: --bearingref must be \"T\" or \"M\"";; esac } # set-bearing -- set bearing after checking syntax function set-bearing { local xmp=$1 value=$2 local n=${value%.*} local h=${value#$n} local d=1 if [ "$value" == "" ]; then set-field $xmp "$GPSBearing" "" x-default else case "$n" in [0-9]|[0-9][0-9]|[0-9][0-9][0-9]) ;; *) die "$0: Error Bearing value must be a number";; esac h=${h#.} n=${n}${h} while [ ! -z "$h" ]; do case "$h" in [0-9]*) d=${d}0; h=${h#?};; *) die "$0: Error Bearing value must be a number";; esac done set-field $xmp "$GPSBearing" $n/$d x-default fi } if have jhead; then function read-exif-jpeg { jhead "$1"; } elif have exiftags; then function read-exif-jpeg { # Convert to the format output by jhead # Note: exiftags 1.01 prints altitudes below sea level incorrectly. exiftags "$1" | sed -e '/^Latitude:/{s/^/GPS /;s/'$'\xB0''/d/;s/'\''/m/;s/$/s/;}' \ -e '/^Longitude:/{s/^/GPS /;s/'$'\xB0''/d/;s/'\''/m/;s/$/s/;}' \ -e '/^Altitude:/s/^/GPS /' } elif have exiftool; then function read-exif-jpeg { # Convert to the format output by jhead exiftool "$1" | sed -e 's/^Make/Camera Make/' \ -e '/^GPS .*S$/{s/ S$//; s/:/: S/;}' \ -e '/^GPS .*N$/{s/ N$//; s/:/: N/;}' \ -e '/^GPS .*W$/{s/ W$//; s/:/: W/;}' \ -e '/^GPS .*E$/{s/ E$//; s/:/: E/;}' \ -e "/^GPS /{s/ deg/d/; s/'/m/; s/\"/s/;}" \ -e '/^GPS Altitude.*Above Sea Level/s/ Above Sea Level//' \ -e '/^GPS Altitude.*Below Sea Level/{s/: /: -/;s/ Below Sea Level//;}' } elif have exif; then function read-exif-jpeg { # Convert to the format output by jhead exif -m "$1" | sed -e 's/'$'\t''/: /' \ -e '/^North or South Latitude: N/,/^Latitude:/s/Latitude: */Latitude: N /' \ -e '/^North or South Latitude: S/,/^Latitude:/s/Latitude: */Latitude: S /' \ -e '/^East or West Longitude: E/,/^Longitude: /s/Longitude: */Longitude: E /' \ -e '/^East or West Longitude: W/,/^Longitude: /s/Longitude: */Longitude: W /' \ -e '/^Altitude Reference: Sea level reference/,/^Altitude:/s/Altitude: */Altitude: -/' \ -e '/^Latitude:/{s/,/d/; s/,/m/; s/$/s/;}' \ -e '/^Longitude:/{s/,/d/; s/,/m/; s/$/s/;}' \ -e 's/^Altitude/GPS Altitude/' \ -e 's/^Longitude/GPS Longitude/' \ -e 's/^Latitude/GPS Latitude/' \ -e 's/^Manufacturer:/Camera make:/' \ -e 's/^Model:/Camera model:/' \ -e 's|^Date and Time|Date/Time|' } # TODO: elif have exiv2... else function read-exif-jpeg { die "$0: No tools found to read EXIF from JPEG"; } fi if have exiftool; then function read-exif-webp { exiftool "$1" | sed -e 's/^Make/Camera Make/' \ -e '/^GPS .*S$/{s/ S$//; s/:/: S/;}' \ -e '/^GPS .*N$/{s/ N$//; s/:/: N/;}' \ -e '/^GPS .*W$/{s/ W$//; s/:/: W/;}' \ -e '/^GPS .*E$/{s/ E$//; s/:/: E/;}' \ -e "/^GPS /{s/ deg/d/; s/'/m/; s/\"/s/;}" \ -e '/^GPS Altitude.*Above Sea Level/s/ Above Sea Level//' \ -e '/^GPS Altitude.*Below Sea Level/{s/: /: -/;s/ Below Sea Level//;}' } # TODO: elif have exiv2... else function read-exif-jpeg { die "$0: No tools found to read EXIF from WebP"; } fi # read-exif -- extract some relevant info from the EXIF in image $1 function read-exif { case `file --mime-type -b -L "$1"` in image/jpeg) read-exif-jpeg "$1";; image/webp) read-exif-webp "$1";; *) echo -n ;; esac } # copy-from-exif -- get data from EXIF data in $2, add it to XMP file $1 function copy-from-exif { local timezone=$3 local inf=`read-exif "$2"` local make=`sed -n -e '/^Camera [Mm]ake/{s/[^:]*: *//p;q;}' <<<"$inf"` local camera=`sed -n -e '/^Camera [Mm]odel/{s/[^:]*: *//p;q;}' <<<"$inf"` local date=`sed -n -e '/^Date\/Time/{s/[^:]*: *//p;q;}' <<<"$inf"` # local latref=`sed -n -e '/^ *GPSLatitudeRef *=/{s/[^=]*=//p;q;}' <<<"$inf"` local latitude=`sed -n -e '/^GPS Latitude/{s/[^:]*: *//p;q;}' <<<"$inf"` # local longref=`sed -n -e '/^ *GPSLongitudeRef *=/{s/[^=]*=//p;q;}' <<<"$inf"` local longitude=`sed -n -e '/^GPS Longitude/{s/[^:]*: *//p;q;}' <<<"$inf"` # local altref=`sed -n -e '/^ *GPSAltitudeRef *=/{s/[^=]*=//p;q;}' <<<"$inf"` local altitude=`sed -n -e '/^GPS Altitude *:/{s/[^:]*: *//p;q;}' <<<"$inf"` local bearing=`sed -n -e '/^ *GPSDestBearing *=/{s/[^=]*=//p;q;}' <<<"$inf"` local bearingref=`sed -n -e '/^ *GPSDestBearingRef *=/{s/[^=]*=//p;q;}' <<<"$inf"` if [ ! -z "$camera" ]; then camera=${camera##$make } # Remove duplicate maker ("Canon") set-field $1 "$CAMERA" "${make:+$make }$camera" x-default set-field $1 "$MAKE" "$make" x-default set-field $1 "$MODEL" "$camera" x-default fi if [ ! -z "$date" ]; then case "x$timezone" in (x+*|x-*|xZ|x);; (*) timezone=" $timezone";; esac date=${date/:/-}; date=${date/:/-} # 2019:07:20 -> 2019-07-20 set-field $1 "$DATE" "$date$timezone" x-default fi if [ ! -z "$altitude" ]; then set-altitude $1 "$altitude" fi if [ ! -z "$latitude" ]; then # E.g., "N 7d 51m 8.52s" local c h d m s c=${latitude:0:1}; h=${latitude:1}; h=${h# }; h=${h# }; h=${h# } d=${h%d*}; h=${h#*d}; m=${h%m*}; h=${h#*m}; s=${h%s*} m=`dc <<<"6k $m $s 60/+ p"` set-field $1 "$GPSLatitude" "$d,$m$c" x-default fi if [ ! -z "$longitude" ]; then local c h d m s c=${longitude:0:1}; h=${longitude:1}; h=${h# }; h=${h# }; h=${h# } d=${h%d*}; h=${h#*d}; m=${h%m*}; h=${h#*m}; s=${h%s*} m=`dc <<<"6k $m $s 60/+ p"` set-field $1 "$GPSLongitude" "$d,$m$c" x-default fi if [ ! -z "$bearing" ]; then local n=${bearing%%/*} d=#{bearing##*/} set-field $1 "$GPSBearing" `dc <<<"6k $n $d / p"` x-default fi if [ ! -z "$bearingref" ]; then set-field $1 "$GPSBearing" ${bearingref//\"/} x-default fi } # from-name -- use part of $3 (given by pattern $2) as identifier function from-name { local xmp=$1 pattern=$2 file=$3 local ident ident=`sed $ext -e "s/^$pattern.*/\\1/" <<<"${file##*/}"` set-field $xmp "$IDENTIFIER" "$ident" x-default } # write-to-file -- add all known metadata to the file $1 function write-to-file { local file=$1 input=$2 local lang=$3 coverage=$4 title=$5 local desc=$6 publisher=$7 relation=$8 creator=$9 local rights=${10} ident=${11} name=${12} longitude=${13} local latitude=${14} altitude=${15} date=${16} contrib=${17} local subject=${18} source=${19} timezone=${20} semaphore-p $NPROCS # Get a free process slot echo -e "\rWriting to $file $clr_eol\c" ( lock "$file" # Get exlusive access to $file trap 'rm $db; unlock "$file"; semaphore-v $NPROCS' EXIT local db=$(mktemp $TMPDIR/db-XXXX) || exit 1 local TMP=$(mktemp $TMPDIR/xmp-XXXX) || exit 1 local xmpfile=$(mktemp $TMPDIR/xmp-XXXX) || exit 1 # Get the existing XMP from the file into $xmp # read-xmp "$file" >$xmpfile # Add to the DB all the data that was passed in. # lang=${lang:-x-default} copy-from-exif $db "$file" "$timezone" [[ -n "$title" ]] && set-field $db "$TITLE" "$title" "$lang" [[ -n "$creator" ]] && set-field $db "$CREATOR" "$creator" "$lang" [[ -n "$subject" ]] && set-field $db "$SUBJECT" "$subject" "$lang" [[ -n "$desc" ]] && set-field $db "$DESCRIPTION" "$desc" "$lang" [[ -n "$publisher" ]] && set-field $db "$PUBLISHER" "$publisher" "$lang" [[ -n "$contrib" ]] && set-field $db "$CONTRIBUTOR" "$contrib" "$lang" [[ -n "$date" ]] && set-field $db "$DATE" "$date" x-default [[ -n "$ident" ]] && set-field $db "$IDENTIFIER" "$ident" x-default [[ -n "$relation" ]] && set-field $db "$RELATION" "$relation" "$lang" [[ -n "$coverage" ]] && set-field $db "$COVERAGE" "$coverage" "$lang" [[ -n "$rights" ]] && set-field $db "$RIGHTS" "$rights" "$lang" [[ -n "$source" ]] && set-field $db "$SOURCE" "$source" "$lang" [[ -n "$latitude" ]] && set-lat-or-long $db "$GPSLatitude" "$latitude" [[ -n "$longitude" ]] && set-lat-or-long $db "$GPSLongitude" "$longitude" [[ -n "$altitude" ]] && set-altitude $db "$altitude" # [[ -n "$bearingref" ]] && set-bearingref $db "$bearingref" # [[ -n "$bearing" ]] && set-bearing $db "$bearing" [[ -n "$name" ]] && from-name $db "$name" "$file" # Add the standard type and format. # set-field $db "$FORMAT" `file --mime-type -b -L "$1"` x-default set-field $db "$TYPE" "image" x-default # Overwrite the XMP with the collected fields and write the XMP # back to the image. add-fields-to-xmp $db $xmpfile | write-xmp "$file" >$TMP && mv $TMP "$file" # xmptool -c | write-xmp "$file" >$TMP && # mv $TMP "$file" ) & } # apply -- read the descriptions from file and apply them function apply { local input=${1:-} local key line prevkey= file= local language= coverage= title= local description= publisher= relation= creator= local rights= identifier= name= longitude= local latitude= altitude= date= contributor= subject= local source= timezone= local -i lineno=0 trap 'semaphore-delete $NPROCS' RETURN local NPROCS=$(semaphore-new $maxprocesses $TMPDIR) || return 1 # If there is an argument, that is the file to read from, otherwise stdin if [ -n "$input" ]; then exec <"$input"; fi # Loop over all input lines while read key line; do ((++lineno)) if [ "$key" == "+" ]; then # Continuation line key=$prevkey line=$prevline\ $line else prevkey=$key fi prevline=$line case "$key" in "") ;; # Skip empty line "#"*) ;; # Skip comment L) language=$line;; C) coverage=$line;; T) title=$line;; D) description=$line;; P) publisher=$line;; S) relation=$line;; A) creator=$line;; R) rights=$line;; I) identifier=$line;; N) name=$line;; X) longitude=$line;; Y) latitude=$line;; Z) altitude=$line;; d) date=$line;; c) contributor=$line;; K) subject=$line;; s) source=$line;; z) timezone=$line;; F) if [ -n "$file" ]; then write-to-file "$file" "$input" \ "$language" "$coverage" "$title" \ "$description" "$publisher" "$relation" "$creator" \ "$rights" "$identifier" "$name" "$longitude" \ "$latitude" "$altitude" "$date" "$contributor" \ "$subject" "$source" "$timezone" fi file=$line;; *) die "${input:-stdin}:$lineno: Illegal field name \"$key\"";; esac done if [ -n "$file" ]; then write-to-file "$file" "$input" \ "$language" "$coverage" "$title" \ "$description" "$publisher" "$relation" "$creator" \ "$rights" "$identifier" "$name" "$longitude" \ "$latitude" "$altitude" "$date" "$contributor" \ "$subject" "$source" "$timezone" fi wait echo } # Main body # Find and reduce the effect of bugs: # - Treat unset variables as an error (-u). # - Exit immediately on an error (-e) # - A pipeline fails if any command in it fails (-o pipefail) # - Do not automatically export variables to the environment (+a) set -u -e -o pipefail +a # Avoid problems with unknown or erroneous character encodings. export LC_ALL=C # Check prerequisites # for f in jhead rdjpgxmp xmptool wrjpgxmp; do for f in xmptool ; do if [[ -z $(type -t $f) ]]; then die "Could not find $f"; fi done # Make a directory for temporary files trap 'rm -rf $TMPDIR' 0 TMPDIR=`mktemp -d /tmp/addxmp-XXXXXX` || exit 1 action= while getopts ":hcrj:" flag; do case $flag in c) if [ "$action" ]; then usage ${0##*/} >&2; exit 1 else action=print-template fi;; r) if [ "$action" ]; then usage ${0##*/} >&2; exit 1 else action=read-template fi;; j) maxprocesses=$OPTARG;; h) usage ${0##*/}; exit;; ?) usage ${0##*/} >&2; exit 1;; esac done shift $((OPTIND - 1)) case "$action" in print-template) print-template "$@";; read-template) read-template "$@";; *) apply "$@";; esac ÷߿ <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <֩>| <ı> <ı> <ı> <ı> <ı> <ı>