#!/bin/bash # # Add XMP metadata to a series of JPEG images. # # 1) # addxmp # addxmp -m mask # addxmp # addxmp -m mask # # Reads from a file or stdin and adds (mostly Dublin Core) metadata # from that file to the image files mentioned in it. # # If option -m is present, the metadata is only added to files that # match the mask. The mask is a file name or file name pattern with # wildcards *, ? and [...], as defined by the Bash shell. # # 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 or YYYY-MM-DDThh:mm:ssZ. # # E date. When the digital version was created, if different from # field d. Can be used to record the date an analog photo was # scanned, e.g. Recommended format is YYYY-MM-DD or # YYYY-MM-DDThh:mm:ssZ. # # 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. # # TODO: continuation lines that start with a space or tab. # # TODO: a way to delete an existing value without a language (i.e., # not even x-default) from a file. # # TODO: Allow commas in subjects ("K" lines). Commas are currently # used to separate keywords. # # 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.w3.org/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 XMP="http://ns.adobe.com/xap/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) readonly CREATEDATE="${XMP}CreateDate" # DateTimeDigitized in EXIF # Selected EXIF 2.21 properties # readonly EXIF221="http://cipa.jp/exif/1.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 [-m mask] [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 (when the photo was taken)" echo "# E YYYY-MM-DD or YYYY-MM-DD HH:MM:SS+ZZ:ZZ (when it was digitized)" 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" || true; } # Ignore error if no XMP 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" || true; } 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" || true; } 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" || true; } 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 first # Get the existing metadata from $1. Result is a three-column TSV # file: (predicate name, language, value). read-xmp "$1" | xmptool -c | xmptool -v '*' | \ sed -e '/\] /s/\[/ [/' \ -e '/\] /!s/ / /' \ -e 's/ \[/ /' \ -e 's/\] / /' \ -e 's/ x-default / / ' >$TMP1 # Add the EXIF data to the end. It will be used if there is no # corresponding data in the XMP. copy-from-exif writes three columns # to $TMP3: predicate, the value prefixed with an "x", and the # language (here: x-default). copy-from-exif $TMP3 "$1" Z x-default sed -e 's/ x-default$//' \ -e 's/ / /' \ -e 's/ x/ /' $TMP3 >>$TMP1 # Sort the metadata file, keeping just the first if there are # multiple entries with the same property and language. sort -t$'\t' -k1,2 -u $TMP1 >$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. first=true for lang in ${langs:-$lang}; do echo echo "F $1" # If this is the first of the languages, generate the full list of # fields, with empty values if there is no value for that field in # TMP3. Otherwise just generate the fields that have values. if $first; then ( echo "L $lang" { grep " $lang " $TMP3 || true; } | \ join -t ' ' -a 1 -o 1.1,2.4 $2 - | \ sed -e 's/ / /' -e 's/ $//' ) | \ sort >$TMP2 first=false else echo "L $lang" { grep " $lang " $TMP3 || true; } | \ cut -d$'\t' -f1,4 | \ sed -e 's/ / /' -e 's/ $//' fi # 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 E n http://ns.adobe.com/xap/1.0/CreateDate 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- $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- $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- $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 if have exiv2; then function write-xmp-tiff { local TMP=$(mktemp $TMPDIR/write-XXXX) || exit 1 cp "$1" $TMP exiv2 -i XX- $TMP cat $TMP rm $TMP } elif have exiftool; then # Exiftool only copies tags it knows :-( function write-xmp-tiff { exiftool -tagsFromFile - -all:all -o - "$1"; } else function write-xmp-tiff { die "$0: no tool found to write XMP into TIFF/DNG"; } fi if have exiv2; then function write-xmp-crw { local TMP=$(mktemp $TMPDIR/write-XXXX) || exit 1 cp "$1" $TMP exiv2 -i XX- $TMP cat $TMP rm $TMP } elif have exiftool; then # Exiftool only copies tags it knows :-( function write-xmp-crw { exiftool -tagsFromFile - -all:all -o - "$1"; } else function write-xmp-crw { die "$0: no tool found to write XMP into CRW"; } 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";; image/tiff) write-xmp-tiff "$1";; # Could be DNG as well image/x-canon-crw) write-xmp-crw "$1";; *) die "$0: Can only write metadata to JPEG, PNG, WebP, DNG or CRW 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\tx$3\t$4" >>$1 } # set-bag -- set field $2 to the comma-sep values $3 with lang $4 in database $1 function set-bag { # The database is just a list of field names and values, one per # line. Insert an "x" before the value, to make sure the column # isn't empty, because "read" in add-fields-to-xmp cannot read empty # fields. if [[ -z "$3" ]]; then echo -e "$2\tx$3\t$4" else local head tail=$3 while [[ -n "$tail" ]]; do head=${tail%%,*} tail=${tail#$head} tail=${tail#,} while [[ "$tail" != "${tail# }" ]]; do tail=${tail# }; done echo -e "$2\tx$head\t$4" done fi >>$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 prevfield= field value lang r h while IFS=$'\t' read field value lang; do value=${value#x} # Guess that a value that starts with http:/https: is a resource case "$value" in http:*|https:*) r=-r;; *) r=;; esac if [[ "$prevfield" == "$field" ]]; then # This field is the same as the previous line, which means it # represents an additional value in a Bag of values. Add it to # the field. xmptool -w -l "$lang" $r -- "$field" "$value" "$xmpfile" elif [[ -z "$value" ]]; then # An empty value. Delete the field in the given language. xmptool -d -l "$lang" -- "$field" $xmpfile else # A non-empty value. Replace the field in the given language. 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 prevfield=$field 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=;; # Empty value -*/*) 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 exiftool; then function read-exif-jpeg { # Convert to the format output by jhead exiftool -EXIF:\* -IPTC:\* -GPS\* "$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 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 /' \ -e 's/^Image Created:/Date\/Time:/' } 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|' } elif have exiv2; then function read-exif-jpeg { exiv2 -P EIlt "$1" | sed -e 's/ */: /' \ -e 's/^Exif.Photo.DateTimeOriginal:/Date\/Time:/' \ -e 's/^Exif.Image.Make:/Camera Make:/' \ -e 's/^Exif.Image.Model:/Camera Model:/' \ -e 's/^Exif.GPSInfo.GPSLatitude:/GPS Latitude:/' \ -e 's/^Exif.GPSInfo.GPSLongitude:/GPS Longitude:/' \ -e 's/^Exif.GPSInfo.GPSAltitude:/GPS Altitude:/' \ -e 's/^Exif.GPSInfo.GPSDestBearingRef:/GPSDestBearingRef =/' \ -e 's/^Exif.GPSInfo.GPSDestBearing:/GPSDestBearing =/' } 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//;}' } elif have exiv2; then function read-exif-webp { exiv2 -P EIlt "$1" | sed -e 's/ */: /' \ -e 's/^Exif.Photo.DateTimeOriginal:/Date\/Time:/' \ -e 's/^Exif.Image.Make:/Camera Make:/' \ -e 's/^Exif.Image.Model:/Camera Model:/' \ -e 's/^Exif.GPSInfo.GPSLatitude:/GPS Latitude:/' \ -e 's/^Exif.GPSInfo.GPSLongitude:/GPS Longitude:/' \ -e 's/^Exif.GPSInfo.GPSAltitude:/GPS Altitude:/' \ -e 's/^Exif.GPSInfo.GPSDestBearingRef:/GPSDestBearingRef =/' \ -e 's/^Exif.GPSInfo.GPSDestBearing:/GPSDestBearing =/' } else function read-exif-webp { die "$0: No tools found to read EXIF from WebP"; } fi if have exiftool; then function read-exif-tiff { 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 exiv2; then function read-exif-tiff { exiv2 -P EIlt "$1" | sed -e 's/ */: /' \ -e 's/^Exif.Photo.DateTimeOriginal:/Date\/Time:/' \ -e 's/^Exif.Image.Make:/Camera Make:/' \ -e 's/^Exif.Image.Model:/Camera Model:/' \ -e 's/^Exif.GPSInfo.GPSLatitude:/GPS Latitude:/' \ -e 's/^Exif.GPSInfo.GPSLongitude:/GPS Longitude:/' \ -e 's/^Exif.GPSInfo.GPSAltitude:/GPS Altitude:/' \ -e 's/^Exif.GPSInfo.GPSDestBearingRef:/GPSDestBearingRef =/' \ -e 's/^Exif.GPSInfo.GPSDestBearing:/GPSDestBearing =/' } else function read-exif-tiff { die "$0: No tools found to read EXIF from TIFF"; } fi if have exiftool; then function read-exif-crw { 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 exiv2; then function read-exif-crw { exiv2 -P EIlt "$1" | sed -e 's/ */: /' \ -e 's/^Exif.Photo.DateTimeOriginal:/Date\/Time:/' \ -e 's/^Exif.Image.Make:/Camera Make:/' \ -e 's/^Exif.Image.Model:/Camera Model:/' \ -e 's/^Exif.GPSInfo.GPSLatitude:/GPS Latitude:/' \ -e 's/^Exif.GPSInfo.GPSLongitude:/GPS Longitude:/' \ -e 's/^Exif.GPSInfo.GPSAltitude:/GPS Altitude:/' \ -e 's/^Exif.GPSInfo.GPSDestBearingRef:/GPSDestBearingRef =/' \ -e 's/^Exif.GPSInfo.GPSDestBearing:/GPSDestBearing =/' } else function read-exif-crw { die "$0: No tools found to read EXIF from CRW"; } 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";; image/tiff) read-exif-tiff "$1";; # Could be DNG as well image/x-canon-crw) read-exif-crw "$1";; *) echo -n ;; esac } # copy-from-exif -- get data from EXIF data in $2, add it to database file $1 function copy-from-exif { local timezone=$3 local defaultlang=$4 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"` # exiftool gives more precise info: local created=`sed -n -e '/^Create Date/{s/[^:]*: *//p;q;}' <<<"$inf"` local title=`sed -n -e '/^Headline/{s/[^:]*: *//p;q;}' <<<"$inf"` local description=`sed -n -e '/^Caption-Abstract/{s/[^:]*: *//p;q;}' <<<"$inf"` local country=`sed -n -e '/^Country/{s/[^:]*: *//p;q;}' <<<"$inf"` local province=`sed -n -e '/^Provence/{s/[^:]*: *//p;q;}' <<<"$inf"` local city=`sed -n -e '/^City/{s/[^:]*: *//p;q;}' <<<"$inf"` local lang=`sed -n -e '/^LanguageIdentifier/{s/[^:]*: *//p;q;}' <<<"$inf"` local coverage="$city${province:+, }$province${country:+, }$country" coverage=${coverage#, } lang=${lang:-$defaultlang} if [ -n "$title" ]; then set-field $1 "$TITLE" "$title" "$lang" fi if [ -n "$description" ]; then set-field $1 "$DESCRIPTION" "$description" "$lang" fi if [ -n "$coverage" ]; then set-field $1 "$COVERAGE" "$coverage" "$lang" fi 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 case "x$timezone" in (x+*|x-*|xZ|x);; (*) timezone=" $timezone";; esac if [ ! -z "$date" ]; then date=${date/:/-}; date=${date/:/-}; date=${date/ /T} # To ISO format if ! [[ "$date" =~ ([-+][0-9:]+|Z)$ ]]; then date=$date$timezone; fi set-field $1 "$DATE" "$date" "" # x-default fi if [ ! -z "$created" ]; then created=${created/:/-}; created=${created/:/-}; created=${created/ /T} case "$created" in *+*|*-*|*Z);; *) created="$created$timezone";; esac set-field $1 "$CREATEDATE" "$created" "" # 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*} [[ "$d" =~ ^[0-9]+$ && "$m" =~ ^\ *[0-9]+$ && "$s" =~ ^\ *[0-9.]+$ ]] || die "$2: Unrecognized values for latitude: $latitude" 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*} [[ "$d" =~ ^[0-9]+$ && "$m" =~ ^\ *[0-9]+$ && "$s" =~ ^\ *[0-9.]+$ ]] || die "$2: Unrecognized value for longitude: $longitude" 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} createdate=${21} 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 $TMP $xmpfile; 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} lang=${lang:-x-default} copy-from-exif $db "$file" "${timezone#x}" "$lang" [[ -n "$title" ]] && set-field $db "$TITLE" "${title#x}" "$lang" [[ -n "$creator" ]] && set-field $db "$CREATOR" "${creator#x}" "$lang" [[ -n "$subject" ]] && set-bag $db "$SUBJECT" "${subject#x}" "$lang" [[ -n "$desc" ]] && set-field $db "$DESCRIPTION" "${desc#x}" "$lang" [[ -n "$publisher" ]] && set-field $db "$PUBLISHER" "${publisher#x}" "$lang" [[ -n "$contrib" ]] && set-field $db "$CONTRIBUTOR" "${contrib#x}" "$lang" [[ -n "$date" ]] && set-field $db "$DATE" "${date#x}" "" # x-default [[ -n "$ident" ]] && set-field $db "$IDENTIFIER" "${ident#x}" "" # x-default [[ -n "$relation" ]] && set-field $db "$RELATION" "${relation#x}" "$lang" [[ -n "$coverage" ]] && set-field $db "$COVERAGE" "${coverage#x}" "$lang" [[ -n "$rights" ]] && set-field $db "$RIGHTS" "${rights#x}" "$lang" [[ -n "$source" ]] && set-field $db "$SOURCE" "${source#x}" "$lang" [[ -n "$createdate" ]] && set-field $db "$CREATEDATE" "${createdate#x}" "" # x-default [[ -n "$latitude" ]] && set-lat-or-long $db "$GPSLatitude" "${latitude#x}" [[ -n "$longitude" ]] && set-lat-or-long $db "$GPSLongitude" "${longitude#x}" [[ -n "$altitude" ]] && set-altitude $db "${altitude#x}" # [[ -n "$bearingref" ]] && set-bearingref $db "$bearingref" # [[ -n "$bearing" ]] && set-bearing $db "$bearing" [[ -n "$name" ]] && [[ -z "${ident#x}" ]] && from-name $db "${name#x}" "$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. cp "$file" $TMP && add-fields-to-xmp $db $xmpfile | write-xmp "$TMP" >"$file" || { cat $TMP >"$file"; echo "An error occurred" >&2; exit 1; } ) & } # 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= createdate= 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=x$line;; # Add an "x" so it is guaranteed not empty C) coverage=x$line;; T) title=x$line;; D) description=x$line;; P) publisher=x$line;; S) relation=x$line;; A) creator=x$line;; R) rights=x$line;; I) identifier=x$line;; N) name=x$line;; X) longitude=x$line;; Y) latitude=x$line;; Z) altitude=x$line;; d) date=x$line;; c) contributor=x$line;; K) subject=x$line;; s) source=x$line;; z) timezone=x$line;; E) createdate=x$line;; F) if [ -n "$file" ] && [ -e "$file" ] && [[ "$file" == $mask ]]; then write-to-file "$file" "$input" \ "$language" "$coverage" "$title" \ "$description" "$publisher" "$relation" "$creator" \ "$rights" "$identifier" "$name" "$longitude" \ "$latitude" "$altitude" "$date" "$contributor" \ "$subject" "$source" "$timezone" "$createdate" fi file=$line;; *) die "${input:-stdin}:$lineno: Illegal field name \"$key\"";; esac done if [ -n "$file" ] && [ -e "$file" ] && [[ "$file" == $mask ]]; then write-to-file "$file" "$input" \ "$language" "$coverage" "$title" \ "$description" "$publisher" "$relation" "$creator" \ "$rights" "$identifier" "$name" "$longitude" \ "$latitude" "$altitude" "$date" "$contributor" \ "$subject" "$source" "$timezone" "$createdate" 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= mask= while getopts ":hcrj:m:" 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;; m) mask=$OPTARG;; h) usage ${0##*/}; exit;; ?) usage ${0##*/} >&2; exit 1;; esac done if [[ -n "$mask" ]] && [[ -n "$action" ]]; then usage ${0##*/}; exit; fi if [[ -z "$mask" ]]; then mask='*'; fi shift $((OPTIND - 1)) case "$action" in print-template) print-template "$@";; read-template) read-template "$@";; *) apply "$@";; esac
Лучший частный хостинг