package de.dpa.oss.metadata.mapper.imaging.backend.exiftool; /** * * Parts of this code are taken from Riyad Kalla written wrapper * {@link http://www.thebuzzmedia.com/software/exiftool-enhanced-java-integration-for-exiftool/} * and are provided via the following license: * * Copyright 2011 The Buzz Media, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import com.google.common.base.Strings; import com.google.common.collect.ListMultimap; import com.google.common.io.ByteStreams; import com.google.gson.*; import de.dpa.oss.metadata.mapper.imaging.backend.exiftool.taginfo.TagGroupBuilder; import de.dpa.oss.metadata.mapper.imaging.backend.exiftool.taginfo.TagInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Document; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import java.io.*; import java.util.*; public class ExifToolWrapper { private static Logger logger = LoggerFactory.getLogger(ExifToolWrapper.class); /** * Environment variable referring to exiftool call */ public static final String ENVIRONMENT_VAR_EXIFTOOL_PATH = "EXIFTOOL"; /** * java system property used to lookup for the exiftool program */ public static final String JAVA_SYSTEM_PROPERTY = "exiftool.path"; private static String pathToExifTool; static { if (System.getenv().containsKey(ENVIRONMENT_VAR_EXIFTOOL_PATH)) { pathToExifTool = System.getenv().get(ENVIRONMENT_VAR_EXIFTOOL_PATH); } else { pathToExifTool = System.getProperty(JAVA_SYSTEM_PROPERTY,"exiftool"); } } private Map<MetadataEncodingScope, String> characterEncoding; public static String getPathToExifTool() { return pathToExifTool; } public static void setPathToExifTool( final String pathToExifTool ) { ExifToolWrapper.pathToExifTool = pathToExifTool; } public static final long PROCESS_CLEANUP_DELAY = Long.getLong( "exiftool.processCleanupDelay", 600000); /** * Name used to identify the (optional) cleanup {@link Thread}. * <p/> * This is only provided to make debugging and profiling easier for * implementors making use of this class such that the resources this class * creates and uses (i.e. Threads) are readily identifiable in a running VM. * <p/> * Default value is "<code>ExifTool Cleanup Thread</code>". */ protected static final String CLEANUP_THREAD_NAME = "ExifTool Cleanup Thread"; /** * @param args Should only contain commandline parameters which have to be passed to the tool */ protected static IOStream startExifToolProcess(List<String> args) throws RuntimeException, ExifToolIntegrationException { Process proc; IOStream streams; logger.debug("\tAttempting to start external ExifTool process using args: %s", args); args.add(0, getPathToExifTool()); try { proc = new ProcessBuilder(args).start(); logger.debug("\t\tSuccessful"); } catch (Throwable t) { List<String> cmdArgs = new ArrayList<>(args); cmdArgs.remove(0); throw new ExifToolIntegrationException("Call of exiftool at \"" + args.get(0) + "\" failed. Commandline args: " + cmdArgs, t); } logger.debug("\tSetting up Read/Write streams to the external ExifTool process..."); // Setup read/write streams to the new process. streams = new IOStream(new BufferedReader(new InputStreamReader( proc.getInputStream())), new OutputStreamWriter( proc.getOutputStream())); logger.debug("\t\tSuccessful, returning streams to caller."); return streams; } public void setCharacterEncoding(final Map<MetadataEncodingScope,String> characterEncoding) { this.characterEncoding = characterEncoding; } /** * Simple class used to house the read/write streams used to communicate * with an external ExifTool process as well as the logic used to safely * close the streams when no longer needed. * <p/> * This class is just a convenient way to group and manage the read/write * streams as opposed to making them dangling member variables off of * ExifTool directly. * * @author Riyad Kalla (software@thebuzzmedia.com) * @since 1.1 */ private static class IOStream { BufferedReader reader; OutputStreamWriter writer; public IOStream(BufferedReader reader, OutputStreamWriter writer) { this.reader = reader; this.writer = writer; } public void close() { try { logger.debug("\tClosing Read stream..."); reader.close(); logger.debug("\t\tSuccessful"); } catch (Exception e) { // no-op, just try to close it. } try { logger.debug("\tClosing Write stream..."); writer.close(); logger.debug("\t\tSuccessful"); } catch (Exception e) { // no-op, just try to close it. } // Null the stream references. reader = null; writer = null; logger.debug("\tRead/Write streams successfully closed."); } } private Timer cleanupTimer; private TimerTask currentCleanupTask; private IOStream streams; /** * if true leaves the exiftool process open for subsequent calls. */ private boolean stayOpen = false; private ExifToolWrapper(boolean stayOpen) { this.stayOpen = stayOpen; if (stayOpen && PROCESS_CLEANUP_DELAY > 0) { this.cleanupTimer = new Timer(CLEANUP_THREAD_NAME, true); // Start the first cleanup task counting down. resetCleanupTask(); } } /** * Used to shutdown the external ExifTool process and close the read/write * streams used to communicate with it when {@link #stayOpen} is * enabled. * <p/> * <strong>NOTE</strong>: Calling this method does not preclude this * instance of {@link ExifToolWrapper} from being re-used, it merely disposes of * the native and internal resources until the next call to * <code>getImageMeta</code> causes them to be re-instantiated. * <p/> * The cleanup thread will automatically call this after an interval of * inactivity defined by {@link #PROCESS_CLEANUP_DELAY}. * <p/> * Calling this method on an instance of this class without * {@link #stayOpen} support enabled has no effect. */ public void close() { /* * no-op if the underlying process and streams have already been closed * OR if stayOpen was never used in the first place in which case * nothing is open right now anyway. */ if (streams == null) return; try { logger.debug("\tAttempting to close ExifTool daemon process, issuing '-stay_open\\nFalse\\n' command..."); // Tell the ExifTool process to exit. streams.writer.write("-stay_open\nFalse\n"); streams.writer.flush(); logger.debug("\t\tSuccessful"); } catch (IOException e) { e.printStackTrace(); } finally { streams.close(); } streams = null; logger.debug("\tExifTool daemon process successfully terminated."); } protected String runExiftool(final File imageFile, final List<String> cmdArgs) throws ExifToolIntegrationException { return runExiftool(imageFile, cmdArgs.toArray(new String[cmdArgs.size()])); } /** * * @param givenArguments arguments given by callee * @return callee's argument merged with arguments given by builder */ private List<String> addConfiguredArguments( final String ... givenArguments ) { final List<String> toReturn = new ArrayList(Arrays.asList(givenArguments)); if( characterEncoding!= null && characterEncoding.size() > 0 ) { for (MetadataEncodingScope metadataFormat : characterEncoding.keySet()) { toReturn.add( "-charset"); toReturn.add( metadataFormat + "=" + characterEncoding.get(metadataFormat)); } } return toReturn; } /** * @param imageFile may be null. This is usefull in order to gather settings from exiftool, like e.g. "exiftool - charset" which is * used to get the list of supported charsets. */ protected synchronized String runExiftool(final File imageFile, final String... cmdArgs) throws ExifToolIntegrationException { final List<String> mergedArgs = addConfiguredArguments(cmdArgs); StringBuilder sb = new StringBuilder(); try { long exifToolCallElapsedTime; if (stayOpen) { logger.debug("\tUsing ExifTool in daemon mode (-stay_open True)..."); // Always reset the cleanup task. resetCleanupTask(); /* * If this is our first time calling getImageMeta with a stayOpen * connection, set up the persistent process and run it so it is * ready to receive commands from us. */ if (streams == null) { final List<String> args = new ArrayList<>(); logger.debug("\tStarting daemon ExifTool process and creating read/write streams (this only happens once)..."); args.add("-stay_open"); args.add("True"); args.add("-@"); args.add("-"); // Begin the persistent ExifTool process. streams = startExifToolProcess(args); } logger.debug("\tStreaming arguments to ExifTool process..."); for (String cmdArg : mergedArgs) { streams.writer.write(cmdArg + "\n"); } if (imageFile != null) { streams.writer.write(imageFile.getAbsolutePath()); streams.writer.write('\n'); } logger.debug("\tExecuting ExifTool..."); logger.debug("Using arguments: " + mergedArgs); // Begin tracking the duration ExifTool takes to respond. exifToolCallElapsedTime = System.currentTimeMillis(); // Begin tracking the duration ExifTool takes to respond. // Run ExifTool on our file with all the given arguments. streams.writer.write("-execute\n"); streams.writer.flush(); } else { logger.debug("\tUsing ExifTool in non-daemon mode (-stay_open False)..."); /* * Since we are not using a stayOpen process, we need to setup the * execution arguments completely each time. */ final List<String> args = new ArrayList<>(); args.addAll(mergedArgs); if (imageFile != null) { args.add(imageFile.getAbsolutePath()); } logger.debug("\tExecuting ExifTool..."); exifToolCallElapsedTime = System.currentTimeMillis(); // Run the ExifTool with our args. logger.debug("Using arguments: " + args); streams = startExifToolProcess(args); } logger.debug("\tReading response back from ExifTool..."); String line; while ((line = streams.reader.readLine()) != null) { /* * When using a persistent ExifTool process, it terminates its * output to us with a "{ready}" clause on a new line, we need to * look for it and break from this loop when we see it otherwise * this process will hang indefinitely blocking on the input stream * with no data to read. */ sb.append(line); if (stayOpen && line.equals("{ready}")) break; } logger.debug("\tFinished reading ExifTool response in %d ms.", (System.currentTimeMillis() - exifToolCallElapsedTime)); } catch (IOException e) { throw new ExifToolIntegrationException(e); } finally { if (!stayOpen && streams != null) { /* * If we are not using a persistent ExifTool process, then after running * the command above, the process exited in which case we need to clean * our streams up since it no longer exists. If we were using a * persistent ExifTool process, leave the streams open for future calls. */ streams.close(); streams = null; } } return sb.toString(); } /** * Helper method used to make canceling the current task and scheduling a * new one easier. * <p/> * It is annoying that we cannot just reset the timer on the task, but that * isn't the way the java.util.Timer class was designed unfortunately. */ private void resetCleanupTask() { // no-op if the timer was never created. if (cleanupTimer == null) return; logger.debug("\tResetting cleanup task..."); // Cancel the current cleanup task if necessary. if (currentCleanupTask != null) currentCleanupTask.cancel(); // Schedule a new cleanup task. cleanupTimer.schedule( (currentCleanupTask = new CleanupTimerTask(this)), PROCESS_CLEANUP_DELAY, PROCESS_CLEANUP_DELAY); logger.debug("\t\tSuccessful"); } /** * Class used to represent the {@link TimerTask} used by the internal auto * cleanup {@link Timer} to call {@link ExifToolWrapper#close()} after a specified * interval of inactivity. * * @author Riyad Kalla (software@thebuzzmedia.com) * @since 1.1 */ private class CleanupTimerTask extends TimerTask { private ExifToolWrapper owner; public CleanupTimerTask(ExifToolWrapper owner) throws IllegalArgumentException { if (owner == null) throw new IllegalArgumentException( "owner cannot be null and must refer to the ExifTool instance creating this task."); this.owner = owner; } @Override public void run() { logger.debug("\tAuto cleanup task running..."); owner.close(); } } /** * The core function used to set image metadata. It expects a multimap where each key points to a list of values. * The key has to be a fully qualified name consisting of group name and tag id. E.g.: * <pre> * IPTC:Keywords * </pre> * For each item found in the value list the method adds the following argument to the resulting command line call: * <pre> * -KEY=VALUE * </pre> * Having an entry like * <pre> * "IPTC:Keywords" --> {"politics", "government"} * </pre> * results in * <pre> * -IPTC:Keywords=politics -IPTC:Keywords=government * </pre> * <p/> * The value string may contain a JSON format which can be used to set up complex XMP entries. * * @param image image to modify * @param tags Map<String, List<String>> which specifies single/multiple values for a given tag (key) * @param options additional commandline parameters. See also {@link ExifToolWrapper.ExifToolOptionBuilder}. May be null * @throws IllegalArgumentException * @throws SecurityException * @throws IOException * @throws ExifToolIntegrationException */ public void setImageMeta(File image, ListMultimap<String, String> tags, List<String> options) throws IllegalArgumentException, SecurityException, IOException, ExifToolIntegrationException { if (image == null) throw new IllegalArgumentException( "image cannot be null and must be a valid stream of image data."); if (tags == null || tags.size() == 0) throw new IllegalArgumentException( "tags cannot be null and must contain 1 or more Tag to query the image for."); if (!image.canWrite()) throw new SecurityException( "Unable to read the given image [" + image.getAbsolutePath() + "], ensure that the image exists at the given path and that the executing Java process has permissions to read it."); logger.debug("Writing %d tags to image: %s", tags.size(), image.getAbsolutePath()); final List<String> cmdArgs; if (options != null) { cmdArgs = new ArrayList<>(options); } else { cmdArgs = new ArrayList<>(); } addArgsToSetImageMetadata(cmdArgs, tags); runExiftool(image, cmdArgs); } private void addArgsToSetImageMetadata(final List<String> args, final ListMultimap<String, String> tags) { for (Map.Entry<String, String> entry : tags.entries()) { args.add("-" + entry.getKey() + "=" + entry.getValue()); } } /** * Wrapper of the call * <pre> * exiftool -listx -S * </pre> * which gives an overview of the supported tag groups and the tags which each group provides. * * @return group names supported by exiftool. * @throws ExifToolIntegrationException */ public TagInfo getSupportedTagsOfGroups() throws ExifToolIntegrationException { String etResult = runExiftool(null, "-listx", "-S"); final TagInfo toReturn; try { toReturn = parseTagInfoFromXMLInput(etResult); } catch (ParserConfigurationException | SAXException e) { throw new ExifToolIntegrationException("DOM parser setup DOM failed", e); } catch (IOException e) { throw new ExifToolIntegrationException(e); } return toReturn; } /** * @deprecated * @param nameOfTagGroups the list of tag groups to inspect. An entry may contain the specific location too. It has to be * separated by a colon. Example entries: "IPTC", "XMP:XMP-dc" * @return map containing tags as keys and tag values */ public List readTagGroup( File image, final String ... nameOfTagGroups ) throws ExifToolIntegrationException { final List tagToValue; List<String> args = new ArrayList<>(); args.add("-j"); for (String nameOfTagGroup : nameOfTagGroups) { args.add( "-" + nameOfTagGroup); } String result = runExiftool(image, args); if(!Strings.isNullOrEmpty(result)) { tagToValue = new Gson().fromJson( result, ArrayList.class); } else { tagToValue = new ArrayList<>(); } return tagToValue; } /** * * @return json object where each field has name "GroupName:Entry" and related value. In some cases the related value can by of type * array. * Example output: * <pre> { "IPTC:City": "Stuttgart", "IPTC:CopyrightNotice": "dpa", "XMP:ToneCurveBlue": [ "0, 0", "255, 255" ] } * </pre> * Return value may be null in case no entries could be found */ public JsonObject readTagGroups( File image, final String ... nameOfTagGroups ) throws ExifToolIntegrationException { JsonObject toReturn = null; final List<String> args = new ArrayList(); args.add("-j"); for (String nameOfTagGroup : nameOfTagGroups) { args.add( "-" + nameOfTagGroup); } String result = runExiftool(image, args); if(!Strings.isNullOrEmpty(result)) { final JsonArray array = new JsonParser().parse(result).getAsJsonArray(); if(array.size()>0) { toReturn = array.get(0).getAsJsonObject(); } } return toReturn; } private TagInfo parseTagInfoFromXMLInput(final String xmlInput) throws ParserConfigurationException, SAXException, IOException { DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); dbf.setIgnoringElementContentWhitespace(true); DocumentBuilder documentBuilder = dbf.newDocumentBuilder(); Document document = documentBuilder.parse(new ByteArrayInputStream(xmlInput.getBytes())); TagInfo tagInfo = new TagInfo(); /* * read all tables */ NodeList tableNodes = document.getDocumentElement().getChildNodes(); for (int i = 0; i < tableNodes.getLength(); i++) { Node table = tableNodes.item(i); NamedNodeMap attributes = table.getAttributes(); TagGroupBuilder tagGroupBuilder = TagGroupBuilder.aTagGroup() .withName(attributes.getNamedItem("name").getTextContent()) .withInformationType(attributes.getNamedItem("g0").getTextContent()) .withSpecificLocation(attributes.getNamedItem("g1").getTextContent()); NodeList tagNodes = table.getChildNodes(); for (int j = 0; j < tagNodes.getLength(); j++) { Node tag = tagNodes.item(j); if (tag.getNodeType() != Node.ELEMENT_NODE) { continue; } NamedNodeMap tagAttributes = tag.getAttributes(); tagGroupBuilder.addTagGroupItem(tagAttributes.getNamedItem("id").getTextContent(), tagAttributes.getNamedItem("name").getTextContent(), tagAttributes.getNamedItem("type").getTextContent(), Boolean.parseBoolean(tagAttributes.getNamedItem("writable").getTextContent())); } tagInfo.add(tagGroupBuilder.build()); } return tagInfo; } public static ExifToolOptionBuilder exiftoolOptions() { return new ExifToolOptionBuilder(); } /** * @deprecated Use {@link ExifTool} */ public static class ExifToolOptionBuilder { List<String> options = new ArrayList<>(); public ExifToolOptionBuilder useEncodingCharsetForIPTC(final ExifTool.CodedCharset codedCharset) { options.add("-IPTC:codedcharacterset=" + codedCharset.getCodepageId()); return this; } public List<String> build() { return options; } } public static ExifToolBuilder anExifTool() { return new ExifToolBuilder(); } public enum MetadataEncodingScope { /** * Set encoding for all metadata formats */ ALL_FORMATS("ExifTool"), /** * Specify encoding for iptc */ IPTC("IPTC"), /** * Specify encoding for exif */ EXIF("EXIF") ; MetadataEncodingScope(final String scopeID) { this.encodingScope = scopeID; } private String encodingScope; public String getEncodingScope() { return encodingScope; } @Override public String toString() { return encodingScope; } } public static class ExifToolBuilder { private boolean stayOpen = false; private Map<MetadataEncodingScope,String> metadataTypeToEncoding = new HashMap<>(); /** * leave the exiftool process open for subsequent calls. * If used then caller have to make sure to call {@link #close()} to shutdown the process */ public ExifToolBuilder WithStayOpenMode() { stayOpen = true; return this; } public ExifToolBuilder withEncodingCharSet(final MetadataEncodingScope encodingScope, final String encodingCharSet) { metadataTypeToEncoding.put(encodingScope,encodingCharSet); return this; } public ExifToolWrapper build() { ExifToolWrapper toReturn = new ExifToolWrapper(stayOpen); if (metadataTypeToEncoding.size() > 0) { toReturn.setCharacterEncoding(metadataTypeToEncoding); } return toReturn; } } }