diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4814428 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Composer files +composer.lock + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 124ec52..ede8213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,13 @@ # CHANGELOG.md -Current version: `v1.1.0` +Current version: `v1.1.1` Notable changes to **Metadata** - A PHP class for reading and writing *Photo Metadata* from JPEG files in a transparent way: +## v1.1.1 - 2022-07-29 +Corrected bug decoding XMP data. Added display of image data (i.e., EXIF data) to the test example. + ## v1.1.0 - 2022-07-15 Support for multi-lingual captions, that is, the CAPTION field, added. Not that using other languages than the default `x-default`, although consistent with the IPTC standard, will not work with typical photo applications, like Photoshop diff --git a/README.md b/README.md index 9a02d63..5e4307e 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ All class files are commended in a phpDocumenter (https://www.phpdoc.org/) compl The metadata class can be installed using composer ```shell -composer require diderich/meta +composer require diderich/metadata ``` # Testing @@ -205,7 +205,7 @@ of the JPEG file. Issus without accompanying JPEG data will be closed by the aut The following limitations currently exist and are acknowledged as such: * IPTC/APP13: Only Latin 1 and UTF-8 encoded data can be read. Other data formats will throw an exception. * XMP/APP1: The classes Xmp and/or XmpDocument may nor recognize all poorly/incorrectly formatted XMP/APP1 data. -* XMP/APP1: If an data element TAG is updated in the namespace NS, then it will also be updated in the namespaces +* XMP/APP1: If a data element TAG is updated in the namespace NS, then it will also be updated in the namespaces Iptc4xmpCore, dc, aux, xmp, photoshop, and photomechanic, if a data entry exists in those name spaces. Other name spaces, for example, used by other applications, are not updated/synchronized. This may result in inconsistent data. * EXIF/APP1: Although all data read is returned, only the data considered relevant is decoded. For example, thumbnails diff --git a/src/Metadata.php b/src/Metadata.php index 9fe43b4..daf73bf 100644 --- a/src/Metadata.php +++ b/src/Metadata.php @@ -21,7 +21,7 @@ class Metadata { - const VERSION = '1.1.0'; + const VERSION = '1.1.1'; /** Fielt types */ const TYPE_INVALID = 0; @@ -1118,7 +1118,7 @@ protected function importExif(): void $this->setRW(self::IMG_COLOR_SPACE_FMT, _('Wide Gamut RGB')); break; case 0xfffe: $this->setRW(self::IMG_COLOR_SPACE_FMT, _('ICC Profile')); break; - case 0xfffe: + case 0xffff: $this->setRW(self::IMG_COLOR_SPACE_FMT, _('Uncalibrated')); break; } diff --git a/src/Metadata/Xmp.php b/src/Metadata/Xmp.php index 89f776b..04cdd73 100644 --- a/src/Metadata/Xmp.php +++ b/src/Metadata/Xmp.php @@ -62,7 +62,9 @@ class Xmp { const CAMERA_SERIAL = "aux:SerialNumber"; /** Text: Camera serial number */ const LENS_MODEL = "aux:Lens"; /** Text: Lens description */ const LENS_SERIAL = "aux:LensSerialNumber"; /** Text: Lens serial number */ - const COLOR_SPACE = "photoshop:ICCProfile"; /** Text: Color space */ + const COLOR_SPACE = "photoshop:ICCProfile"; /** Text: Color Profile */ + + /** Text: Color space */ // History specification const EDIT_HISTORY = 'xmpMM:History'; /** Seq: ResourceEvebt */ diff --git a/src/Metadata/XmpDocument.php b/src/Metadata/XmpDocument.php index 3c484e1..06fab5d 100644 --- a/src/Metadata/XmpDocument.php +++ b/src/Metadata/XmpDocument.php @@ -140,7 +140,7 @@ public function isXmpBag(string $name): bool * @return string|false First node value found, or false, if not found * @throw \Holiday\Metadata\Exception */ - public function getXmpText(string $name, string|false $lang = self::LANG_DEFAULT): string|false + public function getXmpText(string $name, string|false $lang = false): string|false { if(strpos($name, ':') === false) throw new Exception(_('Node name without prefix found'), Exception::INVALID_FIELD_ID, $name); @@ -168,12 +168,12 @@ public function getXmpText(string $name, string|false $lang = self::LANG_DEFAULT /** * Get rdf:Alt value * - * @param string $name Node name, including prefix - * @param string $lang Language of entry - * @return array|false First node value found, or false, if not found + * @param string $name Node name, including prefix + * @param string|false $lang Language of entry + * @return array|false First node value found, or false, if not found * @throw \Holiday\Metadata\Exception */ - public function getXmpLangAlt(string $name, string $lang = self::LANG_DEFAULT): array|false + public function getXmpLangAlt(string $name, string|false $lang = false): array|false { if(strpos($name, ':') === false) throw new Exception(_('Node name without prefix found'), Exception::INVALID_FIELD_ID, $name); @@ -319,7 +319,7 @@ public function setXmpSeq(string $name, string|int|false $data): void * @param string|false $lang Language of entry * @throw \Holiday\Metadata\Exception */ - public function setXmpAlt(string $name, string|int|false $data, string|false $lang = self::LANG_DEFAULT): void + public function setXmpAlt(string $name, string|int|false $data, string|false $lang = false): void { if(self::existXmpAttribute($this->dom, Xmp::DESCRIPTION, $name)) throw new Exception(_('Cannot set')." 'rdf:Alt' "._('node value if an attribute with the same name exists'), @@ -478,7 +478,7 @@ public function updateHistory(string $software): void * @return string|array|false Node value or array, if $lang === LANG_ALL * @throw \Holiday\Metadata\Exception */ - protected function getXmpTextNS(string $ns, string $name, string|false $lang = self::LANG_DEFAULT): string|array|false + protected function getXmpTextNS(string $ns, string $name, string|false $lang = false): string|array|false { // Search text as attribute of any rdf:Description node $descs = self::getXmpAllNodeByName($this->dom, Xmp::DESCRIPTION, $ns); @@ -504,8 +504,16 @@ protected function getXmpTextNS(string $ns, string $name, string|false $lang = s $subchildren = self::getXmpAllNodeByName($child_alt, 'rdf:li'); if($subchildren === false) return false; foreach($subchildren as $subchild) { - if($lang === false) return (string)$subchild->nodeValue; - if($lang === self::LANG_ALL) { + if($lang === false) { + if($subchild->hasAttribute('xml:lang')) { + if($subchild->getAttribute('xml:lang') === self::LANG_DEFAULT) + return (string)$subchild->nodeValue; + } + else { + return (string)$subchild->nodeValue; + } + } + elseif($lang === self::LANG_ALL) { if($subchild->hasAttribute('xml:lang')) { $lang_result[$subchild->getAttribute('xml:lang')] = (string)$subchild->nodeValue; } @@ -516,7 +524,13 @@ protected function getXmpTextNS(string $ns, string $name, string|false $lang = s } } else { - if($subchild->getAttribute('xml:lang') === $lang) return (string)$subchild->nodeValue; + if($subchild->hasAttribute('xml:lang')) { + if($subchild->getAttribute('xml:lang') === $lang) + return (string)$subchild->nodeValue; + } + else { + return (string)$subchild->nodeValue; + } } } } @@ -527,13 +541,13 @@ protected function getXmpTextNS(string $ns, string $name, string|false $lang = s * Get Attribute / Node / rdf:Seq / rdf:Alt value in specific name space * * @access protected - * @param string $ns Name space - * @param string $name Node name, without prefix - * @param string $lang Language to retrieve, or all - * @return array|false Array of nodes, indexed by language + * @param string $ns Name space + * @param string $name Node name, without prefix + * @param string|false $lang Language to retrieve, or all + * @return array|false Array of nodes, indexed by language * @throw \Holiday\Metadata\Exception */ - protected function getXmpLangAltNS(string $ns, string $name, string $lang): array|false + protected function getXmpLangAltNS(string $ns, string $name, string|false $lang): array|false { // Search text as attribute of any rdf:Description node $descs = self::getXmpAllNodeByName($this->dom, Xmp::DESCRIPTION, $ns); @@ -706,7 +720,7 @@ protected function setXmpTextNS(string $ns, string $name, string|int|false $data * @throw \Holiday\Metadata\Exception */ protected function setXmpLiNS(string $tag, string $ns, string $name, array|string|int|false $data, - string|false $lang = self::LANG_DEFAULT, bool $update_only = false): void + string|false $lang = false, bool $update_only = false): void { // Find node $name = "$ns:$name"; @@ -956,7 +970,10 @@ protected function validateXmpDocument(array $ns_ary): void } // Re-load to ensure namespaces are recognized + // -- Disable warning messages from loadXML only + $old_error_reporting = error_reporting(error_reporting() & ~E_WARNING); $status = $this->dom->loadXML($this->dom->saveXML()); + error_reporting($old_error_reporting); if($status === false) throw new Exception(_('Internal error during XML re-validation'), Exception::INTERNAL_ERROR); } diff --git a/test/example.php b/test/example.php index 3865ba0..dbd995e 100644 --- a/test/example.php +++ b/test/example.php @@ -25,16 +25,22 @@ function my__autoload(string $name): void if(file_exists("../src/$name.php")) require_once("../src/$name.php"); } spl_autoload_register('my__autoload'); - +use \Holiday\Metadata; /*** EXAMPLE ***/ $testfiles_ary = array('img.example.jpg', 'img.mlexample.jpg'); +$exif_data_ary = array(Metadata::IMG_CAMERA_MAKE => 'CAMERA MAKE', Metadata::IMG_CAMERA_MODEL => 'CAMERA MODEL', + Metadata::IMG_CAMERA_SERIAL => 'CAMERA SERIAL', Metadata::IMG_LENS_MODEL => 'LENS MODEL', + Metadata::IMG_COLOR_SPACE_FMT => 'COLOR SPACE', Metadata::IMG_ISO => 'ISO SETTING', + Metadata::IMG_APERTURE_FMT => 'APERTURE', Metadata::IMG_EXPOSURE_FMT => 'EXPOSURE', + Metadata::IMG_FOCAL_LENGTH_FMT => 'FOCAL LENGTH', Metadata::IMG_FLASH_FMT => 'FLASH USED', + Metadata::IMG_SIZE_FMT => 'IMAGE SIZE', Metadata::IMG_RESOLUTION_FMT => 'RESOLUTION', + Metadata::IMG_SOFTWARE => 'SOFTWARE'); /** * Use of class Metadata */ -$metadata = new \Holiday\Metadata(); - +$metadata = new Metadata(); foreach($testfiles_ary as $filename) { echo "PROCESSING EXAMPLE IMAGE USING \\Holiday\\Metadata CLASS: $filename".PHP_EOL; echo "---".PHP_EOL; @@ -44,15 +50,15 @@ function my__autoload(string $name): void // Read some of the metadata (assuming metadata is available) - $caption = $metadata->get(\Holiday\Metadata::CAPTION, lang: \Holiday\Metadata::LANG_DEFAULT); - $caption_ary = $metadata->get(\Holiday\Metadata::CAPTION, lang: \Holiday\Metadata::LANG_ALL); - $date_created = $metadata->get(\Holiday\Metadata::CREATED_DATETIME); - $credit = $metadata->get(\Holiday\Metadata::CREDIT); - $city = $metadata->get(\Holiday\Metadata::CITY); - $country = $metadata->get(\Holiday\Metadata::COUNTRY); - $keywords = $metadata->get(\Holiday\Metadata::KEYWORDS); - $people = $metadata->get(\Holiday\Metadata::PEOPLE); - $event = $metadata->get(\Holiday\Metadata::EVENT); + $caption = $metadata->get(Metadata::CAPTION, lang: Metadata::LANG_DEFAULT); + $caption_ary = $metadata->get(Metadata::CAPTION, lang: Metadata::LANG_ALL); + $date_created = $metadata->get(Metadata::CREATED_DATETIME); + $credit = $metadata->get(Metadata::CREDIT); + $city = $metadata->get(Metadata::CITY); + $country = $metadata->get(Metadata::COUNTRY); + $keywords = $metadata->get(Metadata::KEYWORDS); + $people = $metadata->get(Metadata::PEOPLE); + $event = $metadata->get(Metadata::EVENT); if(!empty($caption_ary)) { echo "CAPTION:".PHP_EOL; foreach($caption_ary as $lang => $text) { @@ -68,11 +74,18 @@ function my__autoload(string $name): void if($people !== false) echo "PEOPLE : ".implode(', ', $people).PHP_EOL; echo PHP_EOL; + echo "IMAGE DATA".PHP_EOL; + foreach($exif_data_ary as $field_id => $field_name) { + $data = $metadata->get($field_id); + if($data !== false) echo '- '.substr($field_name.' ', 0, 15).': '.$data.PHP_EOL; + } + echo PHP_EOL; + // Read metadata in a transparent way and extend tagged keywords to their respective fields $metadata->read($filename, extend: true); echo "EXTENDING KEYWORD TAGS:".PHP_EOL; - $keywords = $metadata->get(\Holiday\Metadata::KEYWORDS); - $people = $metadata->get(\Holiday\Metadata::PEOPLE); + $keywords = $metadata->get(Metadata::KEYWORDS); + $people = $metadata->get(Metadata::PEOPLE); if($keywords !== false) echo "KEYWORDS : ".implode(', ', $keywords).PHP_EOL; if($people !== false) echo "PEOPLE : ".implode(', ', $people).PHP_EOL; echo PHP_EOL; @@ -81,16 +94,16 @@ function my__autoload(string $name): void if($caption !== false && $date_created !== false && $city !== false && $country !== false && $credit !== false) { $caption = strtoupper($city).', '.strtoupper($country).' - '.strtoupper(date('F d', $date_created)).': '. $caption.' (Photo by '.$credit.')'; - $metadata->set(\Holiday\Metadata::CAPTION, $caption, lang: \Holiday\Metadata::LANG_DEFAULT); + $metadata->set(Metadata::CAPTION, $caption, lang: Metadata::LANG_DEFAULT); } else { echo "NOT ALL INFORMATION AVAILABLE TO UPDATE CAPTION".PHP_EOL; } if($event !== false) { - $metadata->set(\Holiday\Metadata::EVENT, strtoupper($event)); + $metadata->set(Metadata::EVENT, strtoupper($event)); } else { - $metadata->set(\Holiday\Metadata::EVENT, 'Event was empty'); + $metadata->set(Metadata::EVENT, 'Event was empty'); } // Write metadata back to the image file @@ -98,7 +111,7 @@ function my__autoload(string $name): void // Read-back the data and display modified caption $metadata->read("new.$filename"); - $caption_ary = $metadata->get(\Holiday\Metadata::CAPTION, \Holiday\Metadata::LANG_ALL); + $caption_ary = $metadata->get(Metadata::CAPTION, Metadata::LANG_ALL); if(!empty($caption_ary)) { echo "NEW CAPTION:".PHP_EOL; foreach($caption_ary as $lang => $text) { @@ -106,7 +119,7 @@ function my__autoload(string $name): void echo " $lang: $text".PHP_EOL; } } - echo "NEW EVENT : ".$metadata->get(\Holiday\Metadata::EVENT).PHP_EOL.PHP_EOL; + echo "NEW EVENT : ".$metadata->get(Metadata::EVENT).PHP_EOL.PHP_EOL; // Paste original data to new file $metadata->read("$filename"); @@ -114,7 +127,7 @@ function my__autoload(string $name): void // Read-back the data and display original caption $metadata->read("new.$filename"); - $caption_ary = $metadata->get(\Holiday\Metadata::CAPTION, \Holiday\Metadata::LANG_ALL); + $caption_ary = $metadata->get(Metadata::CAPTION, Metadata::LANG_ALL); if(!empty($caption_ary)) { echo "PASTED CAPTION:".PHP_EOL; foreach($caption_ary as $lang => $text) { @@ -122,18 +135,18 @@ function my__autoload(string $name): void echo " $lang: $text".PHP_EOL; } } - echo "PASTED EVENT: ".$metadata->get(\Holiday\Metadata::EVENT).PHP_EOL.PHP_EOL; + echo "PASTED EVENT: ".$metadata->get(Metadata::EVENT).PHP_EOL.PHP_EOL; } /** - * Use of exception handling class \Holiday\Metadata\Exception + * Use of exception handling class Metadata\Exception */ echo PHP_EOL; try { $metadata->read('invalid.file.name.jpg'); echo "FILE WAS SUCCESSFULLY READ ALTHOUGH IT SHOULD NOT EXIST".PHP_EOL; } -catch(\Holiday\Metadata\Exception $exception) { +catch(Metadata\Exception $exception) { echo "EXCEPTION CATCHED".PHP_EOL; echo "---".PHP_EOL; echo "CODE: ".$exception->getCode().PHP_EOL; diff --git a/test/example.txt b/test/example.txt index a0b8c9f..22d8d60 100644 --- a/test/example.txt +++ b/test/example.txt @@ -9,6 +9,21 @@ EVENT : Demo Event KEYWORDS : Demo Keyword A, Demo Keyword B, Demo Keyword C, Peo: Additional Person PEOPLE : Demo Person A, Demo Person B, Demo Person C +IMAGE DATA +- CAMERA MAKE : Canon +- CAMERA MODEL : Canon EOS-1D X Mark III +- CAMERA SERIAL : 043032000222 +- LENS MODEL : EF24-70mm f/2.8L II USM +- COLOR SPACE : Adobe RGB (1998) +- ISO SETTING : 200 +- APERTURE : f/9.0 +- EXPOSURE : 1/640 second(s) +- FOCAL LENGTH : 38 mm +- FLASH USED : No flash +- IMAGE SIZE : 5211 x 3474 px - 66.18 x 44.12 cm (7 MB) +- RESOLUTION : 200 dpi +- SOFTWARE : Adobe Photoshop 23.3 (Windows) + EXTENDING KEYWORD TAGS: KEYWORDS : Demo Keyword A, Demo Keyword B, Demo Keyword C PEOPLE : Demo Person A, Demo Person B, Demo Person C, Additional Person @@ -31,6 +46,21 @@ PLACE : Klöntal, Schweiz CREATED : 12.07.2022 KEYWORDS : Spaziergang, Monument, Tal, Berg, Gessner, Stein +IMAGE DATA +- CAMERA MAKE : Canon +- CAMERA MODEL : Canon EOS-1D X Mark III +- CAMERA SERIAL : 043032000222 +- LENS MODEL : EF28-300mm f/3.5-5.6L IS USM +- COLOR SPACE : Adobe RGB (1998) +- ISO SETTING : 200 +- APERTURE : f/3.5 +- EXPOSURE : 1/80 second(s) +- FOCAL LENGTH : 28 mm +- FLASH USED : No flash +- IMAGE SIZE : 5408 x 3606 px - 45.79 x 30.53 cm (6 MB) +- RESOLUTION : 300 dpi +- SOFTWARE : Adobe Photoshop 23.4 (Windows) + EXTENDING KEYWORD TAGS: KEYWORDS : Spaziergang, Monument, Tal, Berg, Gessner, Stein