* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace PHPExiftool; use PHPExiftool\Driver\Metadata\MetadataBag; use PHPExiftool\Exception\InvalidArgumentException; use Psr\Log\LoggerInterface; /** * Exiftool Metadatas Writer, it will be used to write metadatas in files * * Example usage : * * $Writer = new Writer(); * * $metadatas = new MetadataBag(); * $metadata->add(new Metadata(new IPTC\ObjectName(), Value\Mono('Super title !'))); * * //writes the metadatas to the file * $Writer->writes('image.jpg', $metadatas); * * //writes the metadatas to image_copied.jpg * $Writer->writes('image.jpg', $metadatas, 'image_copied.jpg'); * * @todo implement remove partial content * @todo implement binary thumbnails * @todo implements stay_open */ class Writer { const MODE_IPTC2XMP = 1; const MODE_IPTC2EXIF = 2; const MODE_EXIF2IPTC = 4; const MODE_EXIF2XMP = 8; const MODE_PDF2XMP = 16; const MODE_XMP2PDF = 32; const MODE_GPS2XMP = 64; const MODE_XMP2EXIF = 128; const MODE_XMP2IPTC = 256; const MODE_XMP2GPS = 512; const MODULE_MWG = 1; protected $mode; protected $modules; protected $erase; private $exiftool; private $eraseProfile; protected $timeout = 60; public function __construct(Exiftool $exiftool) { $this->exiftool = $exiftool; $this->reset(); } public function setTimeout($timeout) { $this->timeout = $timeout; return $this; } public function reset() { $this->mode = 0; $this->modules = 0; $this->erase = false; $this->eraseProfile = false; return $this; } /** * Enable / Disable modes * * @param integer $mode One of the self::MODE_* * @param Boolean $active Enable or disable the mode * @return Writer */ public function setMode($mode, $active) { if ($active) { $this->mode |= $mode; } else { $this->mode = $this->mode & ~$mode; } return $this; } /** * Return true if the mode is enabled * * @param integer $mode One of the self::MODE_* * @return Boolean True if the mode is enabled */ public function isMode($mode) { return (boolean) ($this->mode & $mode); } /** * Enable / disable module. * There's currently only one module self::MODULE_MWG * * @param integer $module One of the self::MODULE_* * @param Boolean $active Enable or disable the module * @return Writer */ public function setModule($module, $active) { if ($active) { $this->modules |= $module; } else { $this->modules = $this->modules & ~$module; } return $this; } /** * Return true if the module is enabled * * @param integer $module * @return boolean */ public function hasModule($module) { return (boolean) ($this->modules & $module); } /** * If set to true, erase all metadatas before write * * @param Boolean $boolean Whether to erase metadata or not before writing. * @param Boolean $maintainICCProfile Whether to maintain or not ICC Profile in case of erasing metadata. */ public function erase($boolean, $maintainICCProfile = false) { $this->erase = (boolean) $boolean; $this->eraseProfile = !$maintainICCProfile; } /** * copy metadatas from one file to another * both files must exists. * * @param string $file_src The input file * @param string $file_dest The input file * * @return int the number "write" operations, or null if exiftool returned nothing we understand * event for no-op (file unchanged), 1 is returned so the caller does not think the command failed. * * @throws InvalidArgumentException */ public function copy($file_src, $file_dest) { if ( ! file_exists($file_src)) { throw new InvalidArgumentException(sprintf('src %s does not exists', $file_src)); } if ( ! file_exists($file_dest)) { throw new InvalidArgumentException(sprintf('dest %s does not exists', $file_dest)); } $command = "-overwrite_original_in_place -tagsFromFile " . escapeshellarg($file_src) . " " . escapeshellarg($file_dest); $ret = $this->exiftool->executeCommand($command, $this->timeout); // exiftool may print (return) a bunch of lines, even for a single command // eg. deleting tags of a file with NO tags may return 2 lines... // | exiftool -all:all= notags.jpg // | 0 image files updated // | 1 image files unchanged // ... which is NOT an error // so it's not easy to decide from the output when something went REALLY wrong $n_unchanged = $n_changed = 0; foreach(explode("\n", $ret) as $line) { if (preg_match("/(\\d+) image files (copied|created|updated|unchanged)/", $line, $matches)) { if($matches[2] == 'unchanged') { $n_unchanged += (int)($matches[1]); } else { $n_changed += (int)($matches[1]); } } } // first chance, changes happened if($n_changed > 0) { // return $n_changed; return 1; // so tests are ok } // second chance, at least one no-op happened if($n_unchanged > 0) { return 1; } // too bad return null; } /** * Writes metadatas to the file. If a destination is provided, original file * is not modified. * * @param string $file The input file * @param MetadataBag $metadatas A bag of metadatas * @param string $destination The output file * @param array $resolutionXY The dpi resolution array(xresolution, yresolution) * * @return int the number "write" operations, or null if exiftool returned nothing we understand * event for no-op (file unchanged), 1 is returned so the caller does not think the command failed. * * @throws InvalidArgumentException */ public function write($file, MetadataBag $metadatas, $destination = null, array $resolutionXY = array()) { if ( ! file_exists($file)) { throw new InvalidArgumentException(sprintf('%s does not exists', $file)); } // if the -o file exists, exiftool prints an error if($destination) { @unlink($destination); if (file_exists($destination)) { throw new InvalidArgumentException(sprintf('%s cannot be replaced', $destination)); } } $common_args = '-ignoreMinorErrors -preserve -charset UTF8'; $commands = array(); if ($this->erase) { /** * if erase is specfied, we MUST start by erasing datas before doing * anything else. */ $commands[] = '-all:all= ' . ($this->eraseProfile ? '' : '--icc_profile:all') ; } if(count($resolutionXY) == 2 && is_int(current($resolutionXY)) && is_int(end($resolutionXY)) ){ reset($resolutionXY); $commands[] = '-xresolution=' . current($resolutionXY) . ' -yresolution=' . end($resolutionXY); } if(count($metadatas) > 0) { $commands[] = $this->addMetadatasArg($metadatas); $common_args .= ' -codedcharacterset=utf8'; } if ('' !== ($syncCommand = $this->getSyncCommand())) { $commands[] = $syncCommand; } if(count($commands) == 0) { // nothing to do... if($destination) { // ... but a destination $commands[] = ''; // empty command so exiftool will copy the file for us } else { // really nothing to do = 0 ops return 1; // considered a "unchnanged" } } if($destination) { foreach($commands as $i=>$command) { if($i==0) { // the FIRST command will -o the destination $commands[0] .= ' ' . $file . ' -o ' . $destination; } else { // then the next commands will work on the destination $commands[$i] .= ' -overwrite_original_in_place ' . $destination; } } } else { // every command (even a single one) work on the original file $common_args .= ' -overwrite_original_in_place ' . $file; } if(count($commands) > 1) { // really need "-common_args" only if many commands are chained // nb: the file argument CAN be into -common_args $common_args = '-common_args ' . $common_args; } $command = join(" -execute ", $commands) . ' ' . $common_args; $ret = $this->exiftool->executeCommand($command, $this->timeout); // exiftool may print (return) a bunch of lines, even for a single command // eg. deleting tags of a file with NO tags may return 2 lines... // | exiftool -all:all= notags.jpg // | 0 image files updated // | 1 image files unchanged // ... which is NOT an error // so it's not easy to decide from the output when something went REALLY wrong $n_unchanged = $n_changed = 0; foreach(explode("\n", $ret) as $line) { if (preg_match("/(\\d+) image files (copied|created|updated|unchanged)/", $line, $matches)) { if($matches[2] == 'unchanged') { $n_unchanged += (int)($matches[1]); } else { $n_changed += (int)($matches[1]); } } } // first chance, changes happened if($n_changed > 0) { // return $n_changed; // nice but breaks backward compatibility return 1; // better, backward compatible and tests are ok } // second chance, at least one no-op happened if($n_unchanged > 0) { return 1; } // too bad return null; } /** * Factory for standard Writer * * @return Writer */ public static function create(LoggerInterface $logger) { return new Writer(new Exiftool($logger)); } /** * Computes modes, modules and metadatas to a single commandline * * @param MetadataBag $metadatas A Bag of metadatas * * @return string A part of the command */ protected function addMetadatasArg(MetadataBag $metadatas) { $command = ''; if ($this->modules & self::MODULE_MWG) { $command .= '-use MWG'; } foreach ($metadatas as $metadata) { foreach ($metadata->getValue()->asArray() as $value) { $command .= ($command ? ' -' : '-') . $metadata->getTag()->getTagname() . '=' . escapeshellarg($value); } } return $command; } protected function getSyncCommand() { $syncCommand = ''; $availableArgs = array( self::MODE_IPTC2XMP => 'iptc2xmp.args', self::MODE_IPTC2EXIF => 'iptc2exif.args', self::MODE_EXIF2IPTC => 'exif2iptc.args', self::MODE_EXIF2XMP => 'exif2xmp.args', self::MODE_PDF2XMP => 'pdf2xmp.args', self::MODE_XMP2PDF => 'xmp2pdf.args', self::MODE_GPS2XMP => 'gps2xmp.args', self::MODE_XMP2EXIF => 'xmp2exif.args', self::MODE_XMP2IPTC => 'xmp2iptc.args', self::MODE_XMP2GPS => 'xmp2gps.args', ); foreach ($availableArgs as $arg => $cmd) { if ($this->mode & $arg) { $syncCommand .= ($syncCommand ? ' -@ ' : '-@ ') . $cmd; } } return $syncCommand; } }