diff --git a/CHANGELOG.md b/CHANGELOG.md index 6988d46..124ec52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,25 @@ # CHANGELOG.md -Current version: `v1.0.2` +Current version: `v1.1.0` -All notable changes to the **HOLIDAY - Metadata** PHP classes for reading and writing metadata from/to JPG image files. +Notable changes to **Metadata** - A PHP class for reading and writing *Photo Metadata* from JPEG files in a transparent +way: + +## 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 +or Photomechanic. See https://iptc.org/standards/photo-metadata/interoperability-tests/ for further information. + +## v1.0.3 - 2022-07-15 +Added functionality to support multi-lingual caption data in the future. Functionality is not yet fully implemented +because no other software can read/write multi-lingual data (yet). ## v1.0.2 - 2022-07-13 -Correced bug in decoding EXIF data where incorrect data type information was used. +Corrected bug in decoding EXIF data where incorrect data type information was used. ## v1.0.1 - 2022-07-10 * Improved namespace handing in `XmpDocument` class -* Added option `read_ony` in `Jpeg` and `Metadata` to read the image data file without reading/importing the whole +* Added option `read_only` in `Jpeg` and `Metadata` to read the image data file without reading/importing the whole image, reducing memory usage and improving reading speed * Added fields with extension _FMT in `Metadata` covering EXIF data in which data is pre-formatted * Updated `locale` translation file diff --git a/LICENSE b/LICENSE index e274772..6e74b19 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,7 @@ MIT License +Metadata - A PHP class for reading and writing Photo Metadata from JPEG files +in a transparent way Copyright (c) 2022 Claude Diderich Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/README.md b/README.md index 662c81c..b3cfc19 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ [![Latest Stable Version](https://img.shields.io/packagist/v/diderich/metadata)](https://packagist.org/packages/diderich/metadata) [![Minimum PHP version](https://img.shields.io/packagist/php-v/diderich/metadata)](https://packagist.org/packages/diderich/metadata) -[![License: -MIT](https://img.shields.io/badge/license-MIT-blueviolet.svg)](https://github.com/diderich/metadata/blob/master/LICENSE) +[![License: MIT](https://img.shields.io/badge/license-MIT-blueviolet.svg)](https://github.com/diderich/metadata/blob/master/LICENSE) +[![Standard](https://img.shields.io/badge/standard-IPTC%20Photo%20Metadata%20Standard%202021.1-yellow)](https://iptc.org/standards/photo-metadata/interoperability-tests/) + # Name -Metadata: A PHP classes for reading and writing metadata from JPG files in a transparent way +**Metadata** - A PHP class for reading and writing *Photo Metadata* from JPEG files in a transparent way # Version @@ -12,30 +13,31 @@ See `CHANGELOG.md` and php constant `\Holiday\Metadata::VERSION` for the latest # Description -The **Metadata** class and its sub-classes implement read and write access to IPTC data and read access to EXIF data -from JPG files, focusing on those metadata elements that are relevant for searching and managing photos within a photo -database or similar application. +The **Metadata** class and its sub-classes implement transparent read and write access to IPTC Photo Metadata based on +the *IPTC Photo Metadata Standard 2021.1* and read access to EXIF data from JPEG files, focusing on those metadata +elements that are relevant for searching and managing photos within a photo database or similar application. The class +is unique in the sense that it supports multiple concurrent languages for the caption/description information. There exist many implementations for reading and/or writing metadata from photos of various formats. Typically, these -packages and programs (exiftool being the most prominent one) focus on decoding raw data in the context of its origin, +packages and programs (`exiftool` being the most prominent one) focus on decoding raw data in the context of its origin, e.g., decoding EXIF data or decoding IPTC/APP13 data. This project takes a different approach! It takes a user-centric -approach and exposes IPTC and EXIF data in a transparent way to the user, i.e, the user does not have to worry where the -data is coming from and/or how it is encoded. +approach and exposes IPTC and EXIF photo metadata in a transparent way to the user, i.e, the user does not have to worry +where the data is coming from and/or how it is encoded. -The package has been developed in the context of developing the proprietary HOLIDAY photo database software (see -https://www.cdsp.photo/technology/holiday-database/), the end-user. As such it may lack functionality relevant for -different uses. This explains the use of the top-level namespace prefix `\Holiday`. +The package has been developed in the context of the proprietary HOLIDAY photo database software (see +https://www.cdsp.photo/technology/holiday-database/) as the end-user. As such it may lack functionality relevant for +different uses. This explains also the use of the top-level namespace prefix `\Holiday`. -The decoding and encoding of the JPG image file into different header segments as well as the decoding and encoding of +The decoding and encoding of the JPEG image file into different header segments as well as the decoding and encoding of the IPTC data is inspired in part by **The PHP JPEG Metadata Toolkit** (https://www.ozhiker.com/electronics/pjmt/), which is hereby duly acknowledged. -## Transparent access to JPG image metadata -The class `\Holiday\Metadata` allows reading and writing the most relevant (from the author's perspective) data in JPEG -files and access them in a transparent way, independent of how they are stored. Data for a field FIELD_ID can be read -from file FILENAME using the following code. If no data is found `$data` will be equal to `false`, otherwise contain the -read data, which may be a string, an integer, or an array: +## Transparent access to JPEG image metadata +The class `\Holiday\Metadata` allows reading and writing the most relevant (from the author's perspective) photo +metadata in JPEG files and access them in a transparent way, independent of where/how they are stored. Data for a field +FIELD_ID can be read from file FILENAME using the following code. If no data is found, `$data` will be equal to `false`, +otherwise it will contain the data read, which may be a string, an integer, or an array: ```php $metadata = new \Holiday\Metadata(); @@ -54,8 +56,8 @@ $metadata->write(NEW_FILENAME); The file NEW_FILENAME is automatically overwritten. -If you want to past the editable part of the metadata from one file FILENAME to another one named PASTE_FILENAME (which -must exist), you can do so use the following code: +Pasting the editable part of the metadata from one file FILENAME to another one named PASTE_FILENAME (which must exist), +can be done using the following code: ```php $metadata = new \Holiday\Metadata(); @@ -64,6 +66,20 @@ $metadata->read(FILENAME); $metadata->paste(PASTE_FILENAME); ``` +The caption/description field `\Holiday\Metadata::CAPTION` supports multiple languages, the default language being named +according to the standard) `\Holiday\Metadata::LANG_DEFAULT`. Access the field's data is done using the following code: +```php +// Retrieve the default data +$caption = $metadata->get(\Holiday\Metadata::CAPTION, lang: \Holiday\Metadata::LANG_DEFAULT); + +// Retrieve the data in a specific language, e.g., 'de-de' (German) +$caption = $metadata->get(\Holiday\Metadata::CAPTION, lang: 'de-de'); + +// Retrieve the data in all available languages (an array, indexed with the respective language identifier is returned) +$caption_ary = $metadata->get(\Holiday\Metadata::CAPTION, lang: \Holiday\Metadata::LANG_ALL); +``` + + The metadata is read in the following order, the first data read taking priority, i.e, if the CAPTION data is stored in the IPTC/APP13 segment as well as in the XMP/APP1 and EXIF/APP1 segment, the data from the IPTC/APP13 will prevail. @@ -88,8 +104,9 @@ readable form. All exception messages are translatable using the `gettext` library. A translation template is provided in the `locale` directory. + # Example -The following example shows how to read, modify, and write metadata from JPG files in a transparent way (see also +The following example shows how to read, modify, and write metadata from JPEG files in a transparent way (see also `test/example.php`). Is requires/assumes a PSR-4 compliant mechanism for loading the class files. ```php @@ -101,7 +118,8 @@ try { $metadata->read(FILENAME); // Read some of the metadata (assuming metadata is available) - $caption = $metadata->get(\Holiday\Metadata::CAPTION); + $caption = $metadata->get(\Holiday\Metadata::CAPTION, lang: \Holiday\Metadata::LANG_DEFAULT); + $caption_ary = $metadata->get(\Holiday\Metadata::CAPTION); $date_created = $metadata->get(\Holiday\Metadata::CREATED_DATETIME); $credit = $metadata->get(\Holiday\Metadata::CREDIT); $city = $metadata->get(\Holiday\Metadata::CITY); @@ -109,7 +127,11 @@ try { $people = $metadata->get(\Holiday\Metadata::PEOPLE); $keywords = $metadata->get(\Holiday\Metadata::KEYWORDS); $event = $metadata->get(\Holiday\Metadata::EVENT); - if($caption !== false) echo "CAPTION: $caption".PHP_EOL; + if(!empty($caption_ary)) { + echo "CAPTION:".PHP_EOL; + foreach($caption_ary as $lang => $text) + echo " $lang: $text".PHP:EOL; + } if($credit !== false) echo "CREDIT: $credit".PHP_EOL; if($city !== false && $country !== false) echo "PLACE: $city, $country".PHP_EOL; if($date_created !== false) echo "CREATED: ".date('d.m.Y', $date_created).PHP_EOL; @@ -121,7 +143,7 @@ try { 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); + $metadata->set(\Holiday\Metadata::CAPTION, $caption, lang: \Holiday\Metadata::LANG_DEFAULT); } if($event !== false) $metadata->set(\Holiday\Metadata::EVENT, strtoupper($event)); @@ -139,17 +161,19 @@ The directory structure shown describes the most relevant directories and files All class files are commended in a phpDocumenter (https://www.phpdoc.org/) compliant way. ``` |-- src/ Directory containing all class files required to use the library - |-- Metadata.php Class implementing transparent read/write access to JPG metadata + |-- Metadata.php Class implementing transparent read/write access to JPEG metadata |-- Metadata |-- Exception.php Exception handling class |-- Exif.php Class reading and writing EXIF/APP1 specific raw data |-- Iptc.php Class reading and writing IPTC/APP13 specific raw data - |-- Jpeg.php Class reading and writing JPG header segment data + |-- Jpeg.php Class reading and writing JPEG header segment data |-- XmpDocument.php Class encoding and decoding Xmp data in a DOMDocument class format |-- Xmp.php Class reading and writing XMP/APP1 specific data |-- test/ |-- example.php Sample program using the metadata class libraries - |-- img.example.jpg Sample image used by metadata.php + |-- example.txt Sample program output + |-- img.example.jpg Sample image including data in all supported fields + |-- img.mlexample.jpg Sample image containing caption data in two languages |-- locale/ |-- metadata.pot Untranslated text messages generated by the classes |-- README.md @@ -166,15 +190,15 @@ composer require diderich/meta ``` # Testing -The classes have been successfully tested on a number of JPG images written by a non-exhaustive list of different camera -models. As decoding data is highly dependent on the choices made when encoding the data, the class may be unable to -decode some less common JPG file encodings. This is especially the case for data stored (in a less compliant way) using -th XMP format. +The classes have been successfully tested on a number of JPEG images written by a non-exhaustive list of different +camera models. As decoding data is highly dependent on the choices made when encoding the data, the class may be unable +to decode some less common JPEG file encodings. This is especially the case for data stored (in a less compliant way) +using th XMP format. No exhaustive test concept for the classes exists and/or is planned. -If you find a JPG file that is not correctly decoded, please raise an issue in the `Issue` section AND include a copy of -the JPG file. Issus without accompanying JPG data will be closed by the author without consideration. +If you find a JPEG file that is not correctly decoded, please raise an issue in the `Issue` section AND include a copy +of the JPEG file. Issus without accompanying JPEG data will be closed by the author without consideration. # Open issues @@ -183,20 +207,18 @@ The following limitations currently exist and are acknowledged as such: * 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 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. This may result in inconsistent data. + 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 or markernotes are not decoded. Data not decoded is return as a human readable hexadecimal string. * EXIF/APP1: Due to the complexity of writing EXIF/APP1 data, the IPTC/NAA records in the EXIF IFD are not updated. They are overwritten with \x00. The author is not aiming a removing these limitations in the future. - -# Idea for future development -Although not currently planned, The would love to see the class implement multi-lingual metadata, allowing reading and -writing of the same data fields in different languages. For example, caption information could be stored in English, -French, and German in/with the same image. The multi-lingual framework exists in the XMP specification (attribute -`xml:lang="x-default"` in text, rdf:Alt, and rdf:Bag elements), but I am currently not aware of any photo handling -software that exploits this possibility. +# Important note +Files containing caption/description data in mode than one language may not be correctly read/saved by most (it not all) +photo editing data. To the author's knowledge, no of-the-shelve software currently supports multiple concurrent +languages, as described by the *XMP Specification* (referenced as *Lang Alt*). See +https://iptc.org/standards/photo-metadata/interoperability-tests/ for more details about interoperability. # Support @@ -210,11 +232,9 @@ The project is actively maintained. Not new features are currently planned. # Author -Claude Diderich (cdiderich@cdsp.photo), https://www.cdsp.photo +Claude Diderich (cdiderich@cdsp.photo), https://www.cdsp.photo. The author will respond to e-mails at his own discretion. # License MIT https://opensource.org/licenses/mit - - diff --git a/SECURITY.md b/SECURITY.md index 4ad8c60..13e7bbb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,11 +1,11 @@ # Security Policy -The following version are currently being supported with security updates. +The following version(s) are currently being supported with security updates. See `README.md` for more details. | Version | Supported | | ------- | ------------------ | -| 1.0.x | :white_check_mark: | +| 1.1.x | :white_check_mark: | # Reporting a Vulnerability diff --git a/src/Metadata.php b/src/Metadata.php index 18b8e1c..245c98f 100644 --- a/src/Metadata.php +++ b/src/Metadata.php @@ -3,7 +3,7 @@ * Metadata.php - Image file metadata handing * * @project Holiday\Metadata - * @version 1.0 + * @version 1.1 * @author Claude Diderich (cdiderich@cdsp.photo) * @copyright (c) 2022 by Claude Diderich * @license https://opensource.org/licenses/mit MIT @@ -11,11 +11,6 @@ * @see https://exiftool.org/TagNames/EXIF.html */ - /** - * NOTE: The API specification for the set(), get(), and drop() functions support language as optional parameter, but - * the functionality to handle non- default languages has not (net) been implemented - */ - namespace Holiday; use \Holiday\Metadata\Iptc; @@ -26,7 +21,7 @@ class Metadata { - const VERSION = '1.0.2'; + const VERSION = '1.1.0'; /** Fielt types */ const TYPE_INVALID = 0; @@ -124,10 +119,12 @@ class Metadata { const IMG_ORI_SQUARE = 3; const IMG_ORI_UNKNOWN = -1; - /** Supported foreign languages */ + /** Languages (non exchaustive) */ + const LANG_ALL = 'x-all'; /** Proxy constand including all languages */ const LANG_DEFAULT = 'x-default'; /** Default language: English */ - const LANG_DE = 'de'; /** Language: German */ - const LANG_FR = 'fr'; /** Language: French */ + const LANG_EN = 'en-us'; /** Language: English */ + const LANG_DE = 'de-de'; /** Language: German */ + const LANG_FR = 'fr-fr'; /** Language: French */ /** Private variables */ protected bool $data_read; /** Has data been loaded/read */ @@ -171,6 +168,7 @@ public function read(string $filename, bool $extend = false, bool $read_only = f if(!file_exists($filename)) throw new Exception(_('File not found'), Exception::FILE_NOT_FOUND, $filename); // Read and set file specific data + clearstatcache(); $pathinfo = pathinfo($filename); $this->setRW(self::FILE_NAME, $pathinfo['basename']); $this->setRW(self::FILE_EXT, $pathinfo['extension']); @@ -246,18 +244,17 @@ public function paste(string $filename): void /** * Return data associated with a given field or 'false', if not value can be found * - * @param int $field_id Field identifier + * @param int $field_id Field identifier + * @param string|false $lang Language of value set (if language is supported by value) * @return string|int|float|array|false Field value - * @param string $lang Language of value set (if language is supported by value) * @throw Exception */ - public function get(int $field_id, string $lang = self::LANG_DEFAULT): string|int|float|array|false + public function get(int $field_id, string|false $lang = false): string|int|float|array|false { if(self::fieldType($field_id) === self::TYPE_INVALID) throw new Exception(_('Invalid field identifier specified'), Exception::INVALID_FIELD_ID, $field_id); - if(!self::supportsLang($field_id, $lang)) - throw new Exception(_('Field does not support multi-lingual data'), Exception::INVALID_FIELD_DATA, $lang); - + + if($lang !== false) return self::getLang($field_id, $lang); if(isset($this->data[$field_id])) return $this->data[$field_id]; return false; } @@ -265,12 +262,12 @@ public function get(int $field_id, string $lang = self::LANG_DEFAULT): string|in /** * Save data associated with a given field identifier * - * @param int $field_id Field identifier - * @param string|int|float|array|false $field_value Field value - * @param string $lang Language of value set (if language is supported by value) + * @param int $field_id Field identifier + * @param string|int|float|array|false $field_value Field value + * @param string|false $lang Language of value set (if language is supported by value) * @throw Exception */ - public function set(int $field_id, string|int|float|array|false $field_value, string $lang = self::LANG_DEFAULT): void + public function set(int $field_id, string|int|float|array|false $field_value, string|false $lang = false): void { $this->setRW($field_id, $field_value, $lang, ignore_write: false); } @@ -279,39 +276,35 @@ public function set(int $field_id, string|int|float|array|false $field_value, st * Save data associated with a given field identifier * * @access private - * @param int $field_id Field identifier + * @param int $field_id Field identifier * @param string|int|float|array|false $field_value Field value - * @param string $lang Language of value set (if language is supported by value) - * @param bool $ignore_write Ignore write check + * @param string|false $lang Language of value set (if language is supported by value) + * @param bool $ignore_write Ignore write check * @throw Exception */ - private function setRW(int $field_id, string|int|float|array|false $field_value, string $lang = self::LANG_DEFAULT, + private function setRW(int $field_id, string|int|float|array|false $field_value, string|false $lang = false, bool $ignore_write = true): void { if(self::fieldType($field_id) === self::TYPE_INVALID) throw new Exception(_('Invalid field identifier specified'), Exception::INVALID_FIELD_ID, $field_id); - if(!self::supportsLang($field_id, $lang)) - throw new Exception(_('Field does not support multi-lingual data'), Exception::INVALID_FIELD_DATA, $lang); + if(!$ignore_write && ($field_id < self::FIELD_ID_WRITE_FIRST || $field_id > self::FIELD_ID_WRITE_LAST)) + throw new Exception(_('Field is not writable'), Exception::INVALID_FIELD_WRITE, $field_id); + + if($lang !== false) { self::setLang($field_id, $field_value, $lang); return; } if(!self::isValidFieldType($field_id, $field_value) && !(self::fieldType($field_id) === self::TYPE_ARY && !is_array($field_value)) && $field_value !== false) throw new Exception(_('Invalid type of field value identifier specified'), - Exception::INVALID_FIELD_ID, $field_id); + Exception::INVALID_FIELD_ID, $field_id); - if(!$ignore_write && ($field_id < self::FIELD_ID_WRITE_FIRST || $field_id > self::FIELD_ID_WRITE_LAST)) - throw new Exception(_('Field is not writable'), Exception::INVALID_FIELD_WRITE, $field_id); - // Setting field to false is identical to dropping field - if($field_value === false) { - $this->drop($field_id, $field_value); - return; - } + if($field_value === false) { $this->drop($field_id, $field_value, $lang); return; } // Add/update field value if(self::fieldType($field_id) === self::TYPE_ARY) { if(is_array($field_value)) { // Replace all values - $this->drop($field_id); + $this->drop($field_id, false, $lang); foreach($field_value as $field_subvalue) { $this->data[$field_id][] = $field_subvalue; } @@ -344,32 +337,34 @@ public function getData(): array|false /** * Drop all data * - * @param string $lang Language of value set (if language is supported by value) + * @param string|false $lang Language of value set (if language is supported by value) * @throw Exception */ - public function dropAll(string $lang = self::LANG_DEFAULT): void + public function dropAll(string|false $lang = false): void { - $this->data = array(); + if($lang === false) { $this->data = array(); return; } + foreach($this->data as $field_id => $field_value) { + if(self::isLang($field_id) && isset($this->data[$field_id][$lang])) unset($this->data[$field_id][$lang]); + if(!self::isLang($field_id) && isset($this->data[$field_id])) unset($this->data[$field_id]); + } } /** * Drop data associated with a given field identifier * - * @param int $field_id Field identifier - * @param string|false $fiel_value Field value - * @param string $lang Language of value set (if language is supported by value) + * @param int $field_id Field identifier + * @param string|int|false $fiel_value Field value + * @param string|false $lang Language of value set (if language is supported by value) * @throw Exception */ - public function drop(int $field_id, string|false $field_value = false, string $lang = self::LANG_DEFAULT): void + public function drop(int $field_id, string|int|false $field_value = false, string|false $lang = false): void { if(self::fieldType($field_id) === self::TYPE_INVALID) throw new Exception(_('Invalid field identifier specified'), Exception::INVALID_FIELD_ID, $field_id); - if(!self::supportsLang($field_id, $lang)) - throw new Exception(_('Field does not support multi-lingual data'), Exception::INVALID_FIELD_DATA, $lang); - if(self::fieldType($field_id) !== self::TYPE_ARY && $field_value !== false) throw new Exception(_('Only individual values of arrays can be dropped'), Exception::INVALID_FIELD_ID, $field_id); + if($lang !== false) { $this->dropLang($field_id, $field_value, $lang); return; } if($field_value !== false) { $field_pos = array_search($field_value, $this->data[$field_id], strict: true); unset($this->data[$field_id][$field_pos]); @@ -382,37 +377,154 @@ public function drop(int $field_id, string|false $field_value = false, string $l /** * Return if a given field has already been set * - * @param int $field_id Field identifier - * @param string|false $fiel_value Field value - * @param string $lang Language of value set (if language is supported by value) + * @param int $field_id Field identifier + * @param string|int|float|false $fiel_value Field value + * @param string|false $lang Language of value set (if language is supported by value) * @return bool Is field already set * @throw Exception */ - public function isSet(int $field_id, string|false $field_value = false, string $lang = self::LANG_DEFAULT): bool + public function isSet(int $field_id, string|int|float|false $field_value = false, string|false $lang = false): bool { if(self::fieldType($field_id) === self::TYPE_INVALID) throw new Exception(_('Invalid field identifier specified'), Exception::INVALID_FIELD_ID, $field_id); - if(!self::supportsLang($field_id, $lang)) - throw new Exception(_('Field does not support multi-lingual data'), Exception::INVALID_FIELD_DATA, $lang); + if($lang !== false) return $this->isSetLang($field_id, $field_value, $lang); if($field_value === false) return isset($this->data[$field_id]); - $field_pos = array_search($field_value, $this->data[$field_id], strict: true); - return !($field_pos === false); + if(isset($this->data[$field_id])) { + $field_pos = array_search($field_value, $this->data[$field_id], strict: true); + return !($field_pos === false); + } + return false; + } + + + /** + * Return if a given field has already been set in the corresponding language + * + * @param int $field_id Field identifier + * @param string|int|float|false $fiel_value Field value + * @param string $lang Language of value set (if language is supported by value) + * @return bool Is field already set + * @throw Exception + */ + private function isSetLang(int $field_id, string|int|float|false $field_value = false, + string $lang = self::LANG_DEFAULT): bool + { + if(!self::isLang($field_id)) + throw new Exception(_('Field does not support multi-lingual data'), Exception::INVALID_FIELD_DATA, $field_id); + + if($field_value === false) return isset($this->data[$field_id][$lang]); + if(isset($this->data[$field_id][$lang])) { + $field_pos = array_search($field_value, $this->data[$field_id][$lang], strict: true); + return !($field_pos === false); + } + return false; } /** - * Returns true, if the field supports the specified language + * Save data associated with a given field identifier is a specific language * - * @param string $lang Laguage - * @return bool Is language supported for specific field + * @param int $field_id Field identifier + * @param string|int|float|array|false $field_value Field value + * @param string $lang Language of value set (if language is supported by value) + * @throw Exception */ - public static function supportsLang(int $field_id, string $lang): bool + private function setLang(int $field_id, string|int|float|array|false $field_value, string $lang): void + { + if(!self::isLang($field_id)) + throw new Exception(_('Field does not support multi-lingual data'), Exception::INVALID_FIELD_DATA, $field_id); + + // Setting field to false is identical to dropping field + if($field_value === false) { self::dropLang($field_id, $field_value, $lang); return; } + + // Add/update field value + if(is_array($field_value)) { + foreach($field_value as $lang_value => $data_value) { + if($lang === self::LANG_ALL || $lang_value === $lang) { + $this->data[$field_id][$lang_value] = $data_value; + } + } + } + else { + $this->data[$field_id][$lang] = $field_value; + } + } + + /** + * Return data associated with a given field is a specific language or 'false', if not value can be found + * + * @param int $field_id Field identifier + * @param string $lang Language of value set (if language is supported by value) + * @return string|int|float|array|false Field value + * @throw Exception + */ + private function getLang(int $field_id, string $lang): string|int|float|array|false + { + if(!self::isLang($field_id)) + throw new Exception(_('Field does not support multi-lingual data'), Exception::INVALID_FIELD_DATA, $field_id); + + if(isset($this->data[$field_id])) { + if($lang === self::LANG_ALL) return $this->data[$field_id]; + if(isset($this->data[$field_id][$lang])) return $this->data[$field_id][$lang]; + } + return false; + } + + /** + * Drop data associated with a given field identifier in a specific language + * + * @param int $field_id Field identifier + * @param string|int|float|false $fiel_value Field value + * @param string $lang Language of value set (if language is supported by value) + * @throw Exception + */ + private function dropLang(int $field_id, string|int|float|false $field_value, string $lang): void + { + if(!self::isLang($field_id)) + throw new Exception(_('Field does not support multi-lingual data'), Exception::INVALID_FIELD_DATA, $field_id); + + if($lang === self::LANG_ALL) { + if($field_value !== false) { + foreach($this->data[$field_id] as $field_lang => $value) { + $field_pos = array_search($field_value, $this->data[$field_id][$field_lang], strict: true); + if($field_pos !== false) unset($this->data[$field_id][$field_lang][$field_pos]); + } + } + else { + unset($this->data[$field_id]); + } + } + else { + if($field_value !== false) { + $field_pos = array_search($field_value, $this->data[$field_id][$lang], strict: true); + unset($this->data[$field_id][$lang][$field_pos]); + } + else { + unset($this->data[$field_id][$lang]); + } + } + } + + /** + * Returns true, if the field supports multiple languages, i.e., if the XMP Standard defines it as 'Lang Alt' type + * NOTE: + * Currently only the CAPTION field is supported. Other elements that would be relevant for translation, like + * HEADLINE, CITY, or COUNTRY are not of type 'Lang Alt'. As such the XMP Standard does not explicitely support + * their translation + * + * @param int $field_id Field identifier + * @return bool Return true, if the field supports multiple languages + */ + public static function isLang(int $field_id): bool { switch($field_id) { + case self::CAPTION: // XMP name - dc:description (Lang Alt) + return true; default: - return $lang === self::LANG_DEFAULT; + return false; } } + /** * Return the type associaed with a given field identifier * @@ -615,8 +727,6 @@ private function extendFields(): void * 1: READ/WRITE IPTC DATA */ - - /** * Import IPTC metadata from JPG object * @@ -633,8 +743,8 @@ private function importIptc(): void $this->set(self::AUTHOR, $iptc_data[Iptc::AUTHOR][0]); if(isset($iptc_data[Iptc::AUTHOR_TITLE][0]) && !$this->isSet(self::AUTHOR_TITLE)) $this->set(self::AUTHOR_TITLE, $iptc_data[Iptc::AUTHOR_TITLE][0]); - if(isset($iptc_data[Iptc::CAPTION][0]) && !$this->isSet(self::CAPTION)) - $this->set(self::CAPTION, $iptc_data[Iptc::CAPTION][0]); + if(isset($iptc_data[Iptc::CAPTION][0]) && !$this->isSetLang(self::CAPTION, self::LANG_DEFAULT)) + $this->setLang(self::CAPTION, $iptc_data[Iptc::CAPTION][0], self::LANG_DEFAULT); if(isset($iptc_data[Iptc::CAPTION_WRITER][0]) && !$this->isSet(self::CAPTION_WRITER)) $this->set(self::CAPTION_WRITER, $iptc_data[Iptc::CAPTION_WRITER][0]); if(isset($iptc_data[Iptc::CATEGORY][0]) && !$this->isSet(self::CATEGORY)) @@ -696,7 +806,8 @@ private function exportIptc(): void $iptc_data = array(); if($this->isSet(self::AUTHOR)) $iptc_data[Iptc::AUTHOR][0] = $this->get(self::AUTHOR); if($this->isSet(self::AUTHOR_TITLE)) $iptc_data[Iptc::AUTHOR_TITLE][0] = $this->get(self::AUTHOR_TITLE); - if($this->isSet(self::CAPTION)) $iptc_data[Iptc::CAPTION][0] = $this->get(self::CAPTION); + if($this->isSet(self::CAPTION, self::LANG_DEFAULT)) + $iptc_data[Iptc::CAPTION][0] = $this->get(self::CAPTION, self::LANG_DEFAULT); if($this->isSet(self::CAPTION_WRITER)) $iptc_data[Iptc::CAPTION_WRITER][0] = $this->get(self::CAPTION_WRITER); if($this->isSet(self::CATEGORY)) $iptc_data[Iptc::CATEGORY][0] = $this->get(self::CATEGORY); @@ -755,8 +866,7 @@ protected function importXmp(): void $this->set(self::AUTHOR, $xmp_data->getXmpText(Xmp::AUTHOR)); if(!$this->isSet(self::AUTHOR_TITLE) && $xmp_data->isXmpText(Xmp::PS_AUTHOR_TITLE)) $this->set(self::AUTHOR_TITLE, $xmp_data->getXmpText(Xmp::PS_AUTHOR_TITLE)); - if(!$this->isSet(self::CAPTION) && $xmp_data->isXmpText(Xmp::CAPTION)) - $this->set(self::CAPTION, $xmp_data->getXmpText(Xmp::CAPTION)); + $this->setLang(self::CAPTION, $xmp_data->getXmpLangAlt(Xmp::CAPTION, self::LANG_ALL), self::LANG_ALL); if(!$this->isSet(self::CATEGORY) && $xmp_data->isXmpText(Xmp::PS_CATEGORY)) $this->set(self::CATEGORY, $xmp_data->getXmpText(Xmp::PS_CATEGORY)); if(!$this->isSet(self::CITY) && $xmp_data->isXmpText(Xmp::CITY)) @@ -872,7 +982,7 @@ protected function exportXmp(): void $xmp_data->setXmpSeq(Xmp::AUTHOR, $this->get(self::AUTHOR)); $xmp_data->setXmpText(Xmp::PS_AUTHOR_TITLE, $this->get(self::AUTHOR_TITLE)); - $xmp_data->setXmpAlt(Xmp::CAPTION, $this->get(self::CAPTION)); + $xmp_data->setXmpLangAlt(Xmp::CAPTION, $this->getLang(self::CAPTION, self::LANG_ALL)); if($xmp_data->isXmpText(Xmp::PS_CAPTION_WRITER)) $xmp_data->setXmpText(Xmp::PS_CAPTION_WRITER, $this->get(self::CAPTION_WRITER)); $xmp_data->setXmpText(Xmp::PS_CATEGORY, $this->get(self::CATEGORY)); @@ -915,6 +1025,11 @@ protected function exportXmp(): void $xmp_data->setXmpText(Xmp::RATING, (string)$this->get(self::RATING)); if($xmp_data->isXmpText(Xmp::PS_TRANSFER_REF)) $xmp_data->setXmpText(Xmp::PS_TRANSFER_REF, $this->get(self::TRANSFER_REF)); + + // Add/Update languages supported + $xmp_data->addLanguages(); + + // Update history log $xmp_data->updateHistory('Metadata '.self::VERSION); $this->jpeg->setXmpData($xmp_data); } diff --git a/src/Metadata/Exception.php b/src/Metadata/Exception.php index a13723c..7b42120 100644 --- a/src/Metadata/Exception.php +++ b/src/Metadata/Exception.php @@ -3,7 +3,7 @@ * Exception.php - Metadata error handling * * @package Holiday\Metadata - * @version 1.0 + * @version 1.1 * @author Claude Diderich (cdiderich@cdsp.photo) * @copyright (c) 2022 by Claude Diderich * @license https://opensource.org/licenses/mit MIT diff --git a/src/Metadata/Exif.php b/src/Metadata/Exif.php index 805bfdd..e0c5783 100644 --- a/src/Metadata/Exif.php +++ b/src/Metadata/Exif.php @@ -3,7 +3,7 @@ * Exif.php - Decode EXIF data from JPG segment APP1 * * @package Holiday\Metadata - * @version 1.0 + * @version 1.1 * @author Claude Diderich (cdiderich@cdsp.photo) * @copyright (c) 2022 by Claude Diderich * @license https://opensource.org/licenses/mit MIT @@ -388,15 +388,7 @@ protected static function decodeIFDData(string $data, int $type, int $byte_align case self::TYPE_URAT: return array('num' => self::decodeIFDData(substr($data, 0, 4), self::TYPE_ULONG, $byte_align), 'denom' => self::decodeIFDData(substr($data, 4, 4), self::TYPE_ULONG, $byte_align)); - if($byte_align === self::EXIF_BYTE_ALIGN_LE) - $value = unpack('Vnum/Vdenom', $data); - else - $value = unpack('Nnum/Ndenom', $data); case self::TYPE_SRAT: - if($byte_align === self::EXIF_BYTE_ALIGN_LE) - $value = unpack('Vnum/Vdenom', $data); - else - $value = unpack('Nnum/Ndenom', $data); $value = array('num' => self::decodeIFDData(substr($data, 0, 4), self::TYPE_ULONG, $byte_align), 'denom' => self::decodeIFDData(substr($data, 4, 4), self::TYPE_ULONG, $byte_align)); if($value['num'] > 2147483648) $value['num'] -= 4294967296; @@ -420,11 +412,11 @@ protected static function decodeIFDData(string $data, int $type, int $byte_align * Return IFD value as formatted text string * * @access protected - * @param string $data IFD data - * @param int $type Data type according to TIFF 6.0 specification + * @param string|int|array $data IFD data + * @param int $type Data type according to TIFF 6.0 specification * @return string Formatted IFD data */ - protected static function getIFDString(string|in|array $data, int $type): string + protected static function getIFDString(string|int|array $data, int $type): string { switch($type) { case self::TYPE_UBYTE: @@ -433,7 +425,7 @@ protected static function getIFDString(string|in|array $data, int $type): string case self::TYPE_SSHORT: case self::TYPE_ULONG: case self::TYPE_SLONG: - return $data; + return (string)$data; case self::TYPE_ASCII: return trim($data); case self::TYPE_URAT: diff --git a/src/Metadata/Iptc.php b/src/Metadata/Iptc.php index 68f78ad..11f958e 100644 --- a/src/Metadata/Iptc.php +++ b/src/Metadata/Iptc.php @@ -3,7 +3,7 @@ * Iptc.php - Encoding and decode IPTC data from segment APP13 * * @package Holiday\Metadata - * @version 1.0 + * @version 1.1 * @author Claude Diderich (cdiderich@cdsp.photo) * @copyright (c) 2022 by Claude Diderich * @license https://opensource.org/licenses/mit MIT diff --git a/src/Metadata/Jpeg.php b/src/Metadata/Jpeg.php index 8bc3b3a..b6257d3 100644 --- a/src/Metadata/Jpeg.php +++ b/src/Metadata/Jpeg.php @@ -3,7 +3,7 @@ * Jpeg.php - JPG metadata encode and decoding functions (IPTC and XMP fields) * * @package Holiday\Metadata - * @version 1.0 + * @version 1.1 * @author Claude Diderich (cdiderich@cdsp.photo) * @copyright (c) 2022 by Claude Diderich * @license https://opensource.org/licenses/mit MIT @@ -198,7 +198,8 @@ public function getFilename(): string|false private function getIptcSegment(): string { $segment = ''; - for($pos = 0; $pos < count($this->header); $pos++) { + $nb_header = count($this->header); + for($pos = 0; $pos < $nb_header; $pos++) { if($this->header[$pos]['name'] === Iptc::IPTC_TYPE && strncmp($this->header[$pos]['data'], Iptc::IPTC_HEADER, Iptc::IPTC_HEADER_LEN) === 0) { $segment .= substr($this->header[$pos]['data'], Iptc::IPTC_HEADER_LEN); @@ -283,7 +284,8 @@ public function setIptcData(array|false $iptc_data_ary): void */ private function getXmpSegment(): string { - for($pos = 0; $pos < count($this->header); $pos++) { + $nb_header = count($this->header); + for($pos = 0; $pos < $nb_header; $pos++) { if($this->header[$pos]['name'] === Xmp::XMP_TYPE && strncmp($this->header[$pos]['data'], Xmp::XMP_HEADER, Xmp::XMP_HEADER_LEN) === 0){ return substr($this->header[$pos]['data'], Xmp::XMP_HEADER_LEN); @@ -320,7 +322,8 @@ public function setXmpData(XmpDocument|false $xmp_data): void $this->xmp_data = $xmp_data; // Find existing segment and repace or delete it - for($pos = 0; $pos < count($this->header); $pos++) { + $nb_header = count($this->header); + for($pos = 0; $pos < $nb_header; $pos++) { if($this->header[$pos]['name'] === Xmp::XMP_TYPE && strncmp($this->header[$pos]['data'], Xmp::XMP_HEADER, Xmp::XMP_HEADER_LEN) === 0) { if($xmp_data === false || $xmp_block === false) { @@ -350,7 +353,8 @@ public function setXmpData(XmpDocument|false $xmp_data): void private function getExifSegments(): array { $exif_segments = array(); - for($pos = 0; $pos < count($this->header); $pos++) { + $nb_header = count($this->header); + for($pos = 0; $pos < $nb_header; $pos++) { if($this->header[$pos]['name'] === Exif::EXIF_TYPE && (strncmp($this->header[$pos]['data'], "Exif\x00\x00", 6) === 0 || strncmp($this->header[$pos]['data'], "Exif\x00\xFF", 6) === 0)) { diff --git a/src/Metadata/Xmp.php b/src/Metadata/Xmp.php index 126e259..62fd790 100644 --- a/src/Metadata/Xmp.php +++ b/src/Metadata/Xmp.php @@ -3,7 +3,7 @@ * Xmp.php - Encode and decode XMP data from JPG segment APP1 * * @package Holiday\Metadata - * @version 1.0 + * @version 1.1 * @author Claude Diderich (cdiderich@cdsp.photo) * @copyright (c) 2022 by Claude Diderich * @license https://opensource.org/licenses/mit MIT @@ -121,14 +121,11 @@ public static function encode(XmpDocument|false $xmp_dom): string|false { if($xmp_dom === false) return false; - // Decode XML document + // Encode XML document $xmp_data = $xmp_dom->getDom()->saveXML(); if($xmp_data === false) throw new Exception(_('Error encoding XMP metdata as XML'), Exception::DATA_FORMAT_ERROR); $xmp_data = html_entity_decode($xmp_data, ENT_NOQUOTES, 'UTF-8'); - if(empty($xmp_data)) return false; - $xmp_data = html_entity_decode($xmp_data, ENT_NOQUOTES, 'UTF-8'); - // Build XMP block // - Remove xml header, if it exists if(strncmp($xmp_data, self::XMP_XML_HEADER, self::XMP_XML_HEADER_LEN) === 0) diff --git a/src/Metadata/XmpDocument.php b/src/Metadata/XmpDocument.php index bd9c956..3c484e1 100644 --- a/src/Metadata/XmpDocument.php +++ b/src/Metadata/XmpDocument.php @@ -3,7 +3,7 @@ * XmpDocument.php - Functions for reading and writing XMP specific data * * @package Holiday\Metadata - * @version 1.0 + * @version 1.1 * @author Claude Diderich (cdiderich@cdsp.photo) * @copyright (c) 2022 by Claude Diderich * @license https://opensource.org/licenses/mit MIT @@ -28,6 +28,10 @@ class XmpDocument { const NS_PHOTOSHOP = 'photoshop'; const NS_PHOTOMECHANIC = 'photomechanic'; + /** Supported Languages */ + const LANG_ALL = 'x-all'; /** All languages */ + const LANG_DEFAULT = 'x-default'; /** Default language: English */ + /** Private variables */ private array $nsPriorityAry; /** Prioritized array of name spaces for core metadata */ @@ -51,8 +55,6 @@ public function __construct(\DOMDocument $dom) 'GettyImagesGIFT' => 'http://xmp.gettyimages.com/gift/1.0/', 'exifEX' => 'http://cipa.jp/exif/1.0/', 'plus' => 'http://ns.useplus.org/ldf/xmp/1.0/', - 'stEvt' => 'http://ns.adobe.com/xap/1.0/sType/ResourceEvent#', - 'stRef' => 'http://ns.adobe.com/xap/1.0/sType/ResourceRef#', 'xmpMM' => 'http://ns.adobe.com/xap/1.0/mm/', 'xmpRights' => 'http://ns.adobe.com/xap/1.0/rights/'); $this->dom = $dom; @@ -133,11 +135,12 @@ public function isXmpBag(string $name): bool /** * Get Attribute / Node / rdf:Seq / rdf:Alt value * - * @param string $name Node name, including prefix + * @param string $name Node name, including prefix + * @param string|false $lang Language of entry * @return string|false First node value found, or false, if not found * @throw \Holiday\Metadata\Exception */ - public function getXmpText(string $name): string|false + public function getXmpText(string $name, string|false $lang = self::LANG_DEFAULT): string|false { if(strpos($name, ':') === false) throw new Exception(_('Node name without prefix found'), Exception::INVALID_FIELD_ID, $name); @@ -145,13 +148,46 @@ public function getXmpText(string $name): string|false list($prefix, $name) = explode(':', $name, 2); // Seach first in specified name space (if any) - $result = $this->getXmpTextNS($prefix, $name); + $result = $this->getXmpTextNS($prefix, $name, lang: $lang); + if(is_array($result)) + throw new Exception(_('Multiple language support not yet implemented'), Exception::NOT_IMPLEMENTED); if($result !== false) return $result; // Search in other name spaces according to priority (if core element) if(in_array($prefix, $this->nsPriorityAry)) { foreach($this->nsPriorityAry as $prefix) { - $result = $this->getXmpTextNS($prefix, $name); + $result = $this->getXmpTextNS($prefix, $name, lang: $lang); + if(is_array($result)) + throw new Exception(_('Multiple language support not yet implemented'), Exception::NOT_IMPLEMENTED); + if($result !== false) return $result; + } + } + return false; + } + + /** + * 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 + * @throw \Holiday\Metadata\Exception + */ + public function getXmpLangAlt(string $name, string $lang = self::LANG_DEFAULT): array|false + { + if(strpos($name, ':') === false) + throw new Exception(_('Node name without prefix found'), Exception::INVALID_FIELD_ID, $name); + + list($prefix, $name) = explode(':', $name, 2); + + // Seach first in specified name space (if any) + $result = $this->getXmpLangAltNS($prefix, $name, lang: $lang); + if($result !== false) return $result; + + // Search in other name spaces according to priority (if core element) + if(in_array($prefix, $this->nsPriorityAry)) { + foreach($this->nsPriorityAry as $prefix) { + $result = $this->getXmpLangAltNS($prefix, $name, lang: $lang); if($result !== false) return $result; } } @@ -280,22 +316,49 @@ public function setXmpSeq(string $name, string|int|false $data): void * * @param string $name Node name, including prefix * @param string|int|false $data Node value + * @param string|false $lang Language of entry * @throw \Holiday\Metadata\Exception */ - public function setXmpAlt(string $name, string|int|false $data): void + public function setXmpAlt(string $name, string|int|false $data, string|false $lang = self::LANG_DEFAULT): 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'), Exception::INVALID_FIELD_ID, $name); list($prefix, $name) = explode(':', $name, 2); - $this->setXmpLiNS('Alt', $prefix, $name, $data, lang: true, update_only: false); + $this->setXmpLiNS('Alt', $prefix, $name, $data, lang: $lang, update_only: false); + + // Update nodes is associated namespaces + if(in_array($prefix, $this->nsPriorityAry)) { + foreach($this->nsPriorityAry as $ns_prefix) { + if($this->isXmpLi('Alt', "$ns_prefix:$name") && $ns_prefix !== $prefix) { + $this->setXmpLiNS('Alt', $ns_prefix, $name, $data, lang: $lang, update_only: true); + } + } + } + } + + /** + * Set/Update req:Alt tag entries and updating values in other namespaces + * + * @param string $name Node name, including prefix + * @param array|false $data Array of node values indexed by language + * @throw \Holiday\Metadata\Exception + */ + public function setXmpLangAlt(string $name, array|false $data): 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'), + Exception::INVALID_FIELD_ID, $name); + list($prefix, $name) = explode(':', $name, 2); + $this->setXmpLiLangNS($prefix, $name, $data); + // Update nodes is associated namespaces if(in_array($prefix, $this->nsPriorityAry)) { foreach($this->nsPriorityAry as $ns_prefix) { if($this->isXmpLi('Alt', "$ns_prefix:$name") && $ns_prefix !== $prefix) { - $this->setXmpLiNS('Alt', $ns_prefix, $name, $data, lang: true, update_only: true); + $this->setXmpLiLangNS($ns_prefix, $name, $data); } } } @@ -313,10 +376,10 @@ public function setXmpBag(string $name, array|false $data): void if(self::existXmpAttribute($this->dom, Xmp::DESCRIPTION, $name)) throw new Exception(_('Cannot set')." 'rdf:Bag' "._('node value if an attribute with the same name exists'), Exception::INVALID_FIELD_ID, $name); - + list($prefix, $name) = explode(':', $name, 2); $this->setXmpLiNS('Bag', $prefix, $name, $data, lang: false, update_only: false); - + // Update nodes is associated namespaces if(in_array($prefix, $this->nsPriorityAry)) { foreach($this->nsPriorityAry as $ns_prefix) { @@ -326,7 +389,39 @@ public function setXmpBag(string $name, array|false $data): void } } } + + /** + * Add an element listing all languages found throughout the DOM + */ + public function addLanguages(): void + { + $languages = $this->recFindLanguages($this->dom->documentElement); + if(!empty($languages)) $this->setXmpBag('dc:language', $languages); + } + /** + * Recursively traverse a DOM, finding any node that has an attribute 'xml:lang + * + * @access private + * @param \DOMElement $dom DOM Node + * @return array Array of languages found + */ + private function recFindLanguages(\DOMElement $dom): array + { + $result = array(); + if($dom->hasAttribute('xml:lang')) { + $lang = $dom->getAttribute('xml:lang'); + if($lang !== 'x-default') $result[$lang] = $lang; + } + if($dom->hasChildNodes()) { + foreach($dom->childNodes as $child) { + if($child->nodeType == XML_ELEMENT_NODE) + $result = array_merge($result, $this->recFindLanguages($child)); + } + } + return $result; + } + /** * Add a history entry indicating that the data has been updated (in xmpMM:History * @@ -342,9 +437,9 @@ public function updateHistory(string $software): void // Ensure that rdf:Description has stEvt and stRef namespace attributes set if(!$desc->hasAttribute('xmlns:stEvt')) - $desc->setAttribute('stEvt', 'http://ns.adobe.com/xap/1.0/sType/ResourceEvent#'); + $desc->setAttribute('xmlns:stEvt', 'http://ns.adobe.com/xap/1.0/sType/ResourceEvent#'); if(!$desc->hasAttribute('xmlns:stRef')) - $desc->setAttribute('stRef','http://ns.adobe.com/xap/1.0/sType/ResourceRef#'); + $desc->setAttribute('xmlns:stRef','http://ns.adobe.com/xap/1.0/sType/ResourceRef#'); // Create new history entry, if none exists $history = self::getXmpFirstNodeByName($desc, Xmp::EDIT_HISTORY); @@ -379,50 +474,108 @@ public function updateHistory(string $software): void * @access protected * @param string $ns Name space * @param string $name Node name, without prefix - * @return string|false Node value + * @param string|false $lang Language of entry + * @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 + protected function getXmpTextNS(string $ns, string $name, string|false $lang = self::LANG_DEFAULT): string|array|false { // Search text as attribute of any rdf:Description node $descs = self::getXmpAllNodeByName($this->dom, Xmp::DESCRIPTION, $ns); if($descs === false) throw new Exception(_('Cannot find')." 'rdf:Description' "._('node'), Exception::DATA_FORMAT_ERROR); - + // Search for Name as Attribute - foreach($descs as $desc) { - $result = $desc->getAttribute("$ns:$name"); - if(!empty($result)) return $result; + if($lang === false) { + foreach($descs as $desc) { + $result = $desc->getAttribute("$ns:$name"); + if(!empty($result)) return $result; + } } - + // Try find Name as Node rather than Attribute $node = self::getXmpFirstNodeByName($this->dom, "$ns:$name"); if($node === false) return false; - + //Try rdf:Alt - We only read the first list element + $lang_result = array(); $child_alt = self::getXmpFirstNodeByName($node, 'rdf:Alt'); if($child_alt !== false) { - $subchild = self::getXmpFirstNodeByName($child_alt, 'rdf:li'); - if($subchild === false) - throw new Exception(_('Cannot find')." 'rdf:li' "._('child of')." 'rdf:Alt' "._('node'), - Exception::DATA_FORMAT_ERROR, $name); - return (string)$subchild->nodeValue; + $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($subchild->hasAttribute('xml:lang')) { + $lang_result[$subchild->getAttribute('xml:lang')] = (string)$subchild->nodeValue; + } + else { + if(!isset($lang_result[self::LANG_DEFAULT])) { + $lang_result[self::LANG_DEFAULT] = (string)$subchild->nodeValue; + } + } + } + else { + if($subchild->getAttribute('xml:lang') === $lang) return (string)$subchild->nodeValue; + } + } + } + return empty($lang_result) ? false : $lang_result; + } + + /** + * 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 + * @throw \Holiday\Metadata\Exception + */ + protected function getXmpLangAltNS(string $ns, string $name, string $lang): array|false + { + // Search text as attribute of any rdf:Description node + $descs = self::getXmpAllNodeByName($this->dom, Xmp::DESCRIPTION, $ns); + if($descs === false) + throw new Exception(_('Cannot find')." 'rdf:Description' "._('node'), Exception::DATA_FORMAT_ERROR); + + // Search for Name as Attribute + foreach($descs as $desc) { + $result = $desc->getAttribute("$ns:$name"); + if(!empty($result)) + throw new Exception(_('Found attribute with the same name as')." 'rdf:Alt' "._('element'), + Exception::DATA_FORMAT_ERROR); } - // Try rdf:Seq - We only read the first list element - $child_seq = self::getXmpFirstNodeByName($node, 'rdf:Seq'); - if($child_seq !== false) { - $subchild = self::getXmpFirstNodeByName($child_seq, 'rdf:li'); - if($subchild === false) - throw new Exception(_('Cannot find')." 'rdf:li' "._('child of')." 'rdf:Seq' "._('node'), + // Try find Name as Node rather than Attribute + $node = self::getXmpFirstNodeByName($this->dom, "$ns:$name"); + if($node === false) return false; + + // Read rdf:Alt - We only read the first list element + $lang_result = array(); + $child_alt = self::getXmpFirstNodeByName($node, 'rdf:Alt'); + if($child_alt !== false) { + $subchildren = self::getXmpAllNodeByName($child_alt, 'rdf:li'); + if($subchildren === false) + throw new Exception(_('Cannot find')." 'rdf:li' "._('child of')." 'rdf:Alt' "._('node in lang'), Exception::DATA_FORMAT_ERROR, $name); - return (string)$subchild->nodeValue; + foreach($subchildren as $subchild) { + if($subchild->hasAttribute('xml:lang')) { + if($subchild->getAttribute('xml:lang') === $lang || $lang === self::LANG_ALL) { + $lang_result[$subchild->getAttribute('xml:lang')] = (string)$subchild->nodeValue; + } + } + else { + if(!isset($lang_result[self::LANG_DEFAULT])) { + $lang_result[self::LANG_DEFAULT] = (string)$subchild->nodeValue; + } + } + } } - - // Return value of node - return (string)$node->nodeValue; + return empty($lang_result) ? false : $lang_result; } - + /** * Get array of Bag node values * @@ -544,77 +697,164 @@ protected function setXmpTextNS(string $ns, string $name, string|int|false $data * Set values in a rdf:li of rd:f$tag children of node $node * * @access protected - * @param string $tag Tag identifier of child node - * @param string $name Node name, with prefix - * @param array|string|int|false $data Node value(s) - * @param bool $lang Set xml:lang language attribute to x-default - * @param bool $pdate_only Only update value, do not create new nodes + * @param string $tag Tag identifier of child node + * @param string $ns Node prefix + * @param string $name Node name, without prefix + * @param array|string|int|false $data Node value(s) + * @param string|false $lang Set xml:lang language attribute to x-default + * @param bool $pdate_only Only update value, do not create new nodes * @throw \Holiday\Metadata\Exception */ protected function setXmpLiNS(string $tag, string $ns, string $name, array|string|int|false $data, - bool $lang = false, bool $update_only = false): void + string|false $lang = self::LANG_DEFAULT, bool $update_only = false): void { + // Find node $name = "$ns:$name"; - $node = self::getXmpFirstNodeByName($this->dom, $name); - if($data === false) { - if($node !== false) $this->deleteXmpChildren($node->parentNode); - return; - } - + // If node does not exist and we update only, then exit if($node === false && $update_only) return; - // If node does not exists, create a new one + // If node does not exists and we do not only update, create a new one if($node === false) { // Check if $name is an attribute - $descs = self::getXmpAllNodeByName($this->dom, Xmp::DESCRIPTION); - $root = false; - foreach($descs as $desc) { - if($desc->hasAttribute("xmlns:$ns")) { - $root = $desc; - } - } + $root = self::getXmpFirstNodeByName($this->dom, Xmp::DESCRIPTION, $ns); if($root === false) throw new Exception(_('Cannot find')." 'rdf:Description' "._('node'), Exception::DATA_FORMAT_ERROR, $ns); $new_child = $this->dom->createElement($name); $node = $root->appendChild($new_child); } + + // Check if $node has children that are not of tye rdf:$tag + if($node->childNodes->count() !== 0) { + $remove_children = array(); + foreach($node->childNodes as $child) { + if($child->prefix === 'rdf' && $child->nodeName !== "rdf:$tag") { + throw new Exception(_('Incorrect element tag found'), Exception::DATA_FORMAT_ERROR, $child->nodeName); + } + $remove_children[] = $child; + } + if(!empty($remove_children)) { + foreach($remove_children as $remove_child) { + $node->removeChild($remove_child); + } + } + } + + // Check if $node has no children of type rdf:$tag + $rdf_tag = self::getXmpFirstNodeByName($node, "rdf:$tag"); + if($rdf_tag === false) { + $new_child = $this->dom->createElement("rdf:$tag"); + $rdf_tag = $node->appendChild($new_child); + } - // Delete all sub-nodes - $this->deleteXmpChildren($node); + // Delete all sub-nodes of $rdf_tag that are in the specified language (if any language specified) + $all_rdf_li = self::getXmpAllNodeByName($rdf_tag, 'rdf:li'); + if($all_rdf_li !== false) { + $remove_children = array(); + foreach($all_rdf_li as $rdf_li) { + if($lang === false || $rdf_li->getAttribute('xml:lang') === $lang) { + $remove_children[] = $rdf_li; + } + } + if(!empty($remove_children)) { + foreach($remove_children as $remove_child) { + $rdf_tag->removeChild($remove_child); + } + } + } - // Add new tag rdf:$tag - $new_child = $this->dom->createElement("rdf:$tag"); - $child = $node->appendChild($new_child); - if(is_array($data)) { - foreach($data as $value) { + // Add new rdf:li tags + if($data !== false) { + if(is_array($data)) { + foreach($data as $value) { + $new_child = $this->dom->createElement('rdf:li'); + if($lang !== false) $new_child->setAttribute('xml:lang', $lang); + $rdf_li = $rdf_tag->appendChild($new_child); + $rdf_li->nodeValue = $value; + } + } + else { $new_child = $this->dom->createElement('rdf:li'); - if($lang) $new_child->setAttribute('xml:lang', 'x-default'); - $grandchild = $child->appendChild($new_child); - $grandchild->nodeValue = $value; + if($lang !== false) $new_child->setAttribute('xml:lang', $lang); + $rdf_li = $rdf_tag->appendChild($new_child); + $rdf_li->nodeValue = $data; } } - else { - $new_child = $this->dom->createElement('rdf:li'); - if($lang) $new_child->setAttribute('xml:lang', 'x-default'); - $grandchild = $child->appendChild($new_child); - $grandchild->nodeValue = $data; - } } - + /** - * Delete all child nodes of a node + * Set values in a rdf:li of rd:f$tag children of node $node * * @access protected - * @param \DOMDocument|\DOMElement|\DOMNode $dom Node + * @param string $ns Node prefix + * @param string $name Node name, without prefix + * @param array|false $data Array of node values, indexed by language * @throw \Holiday\Metadata\Exception */ - protected function deleteXmpChildren(\DOMDocument|\DOMElement|\DOMNode $node): void + protected function setXmpLiLangNS(string $ns, string $name, array|false $data): void { - while($node->hasChildNodes()) { - $node->removeChild($node->firstChild); + // Find node + $name = "$ns:$name"; + $node = self::getXmpFirstNodeByName($this->dom, $name); + + // If node does not exists, create a new one + if($node === false) { + // Check if $name is an attribute + $root = self::getXmpFirstNodeByName($this->dom, Xmp::DESCRIPTION, $ns); + if($root === false) + throw new Exception(_('Cannot find')." 'rdf:Description' "._('node'), Exception::DATA_FORMAT_ERROR, $ns); + + $new_child = $this->dom->createElement($name); + $node = $root->appendChild($new_child); + } + + // Check if $node has children that are not of tye rdf:Alt + if($node->childNodes->count() !== 0) { + $remove_children = array(); + foreach($node->childNodes as $child) { + if($child->prefix === 'rdf' && $child->nodeName !== "rdf:Alt") { + throw new Exception(_('Incorrect element tag found'), Exception::DATA_FORMAT_ERROR, $child->nodeName); + } + $remove_children[] = $child; + } + if(!empty($remove_children)) { + foreach($remove_children as $remove_child) { + $node->removeChild($remove_child); + } + } + } + + // Check if $node has no children of type rdf:Alt + $rdf_tag = self::getXmpFirstNodeByName($node, "rdf:Alt"); + if($rdf_tag === false) { + $new_child = $this->dom->createElement("rdf:Alt"); + $rdf_tag = $node->appendChild($new_child); + } + + // Delete all sub-nodes of $rdf_tag + $all_rdf_li = self::getXmpAllNodeByName($rdf_tag, 'rdf:li'); + if($all_rdf_li !== false) { + $remove_children = array(); + foreach($all_rdf_li as $rdf_li) { + $remove_children[] = $rdf_li; + } + if(!empty($remove_children)) { + foreach($remove_children as $remove_child) { + $rdf_tag->removeChild($remove_child); + } + } + } + + // Add new rdf:li tags + if($data !== false) { + foreach($data as $lang => $value) { + $new_child = $this->dom->createElement('rdf:li'); + $new_child->setAttribute('xml:lang', $lang); + $rdf_li = $rdf_tag->appendChild($new_child); + $rdf_li->nodeValue = $value; + } } } diff --git a/test/example.php b/test/example.php index 7e6ac8d..3fd332f 100644 --- a/test/example.php +++ b/test/example.php @@ -3,7 +3,7 @@ * example.php - Image file metadata handing exampe file * * @project Holiday\Metadata - * @version 1.0 + * @version 1.1 * @author Claude Diderich (cdiderich@cdsp.photo) * @copyright (c) 2022 by Claude Diderich * @license https://opensource.org/licenses/GPL-3.0 GPL-3.0 @@ -28,7 +28,7 @@ function my__autoload(string $name): void /*** EXAMPLE ***/ -$testfiles_ary = array('img.example.jpg'); +$testfiles_ary = array('img.example.jpg', 'img.mlexample.jpg'); /** * Use of class Metadata @@ -36,35 +36,52 @@ function my__autoload(string $name): void $metadata = new \Holiday\Metadata(); foreach($testfiles_ary as $filename) { - echo "PROCESSING EXAMPLE IMAGE USING \\Holiday\\Metadata: $filename".PHP_EOL; + echo "PROCESSING EXAMPLE IMAGE USING \\Holiday\\Metadata CLASS: $filename".PHP_EOL; echo "---".PHP_EOL; - // Read metadata in a transparent way and extend tagged keywords to their respective fields - $metadata->read($filename, extend: true); + // Read metadata in a transparent way not extend tagged keywords + $metadata->read($filename, extend: false); + // Read some of the metadata (assuming metadata is available) - $caption = $metadata->get(\Holiday\Metadata::CAPTION); + $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); - $people = $metadata->get(\Holiday\Metadata::PEOPLE); $keywords = $metadata->get(\Holiday\Metadata::KEYWORDS); + $people = $metadata->get(\Holiday\Metadata::PEOPLE); $event = $metadata->get(\Holiday\Metadata::EVENT); - if($caption !== false) echo "CAPTION: $caption".PHP_EOL; - if($credit !== false) echo "CREDIT: $credit".PHP_EOL; - if($city !== false && $country !== false) echo "PLACE: $city, $country".PHP_EOL; - if($date_created !== false) echo "CREATED: ".date('d.m.Y', $date_created).PHP_EOL; - if($event !== false) echo "EVENT: $event".PHP_EOL; - if($keywords !== false) echo "KEYWORDS: ".implode(', ', $keywords).PHP_EOL; - if($people !== false) echo "PEOPLE: ".implode(', ', $people).PHP_EOL; + if(!empty($caption_ary)) { + echo "CAPTION:".PHP_EOL; + foreach($caption_ary as $lang => $text) { + $lang = substr($lang.' ', 0, 9); + echo " $lang: $text".PHP_EOL; + } + } + if($credit !== false) echo "CREDIT : $credit".PHP_EOL; + if($city !== false && $country !== false) echo "PLACE : $city, $country".PHP_EOL; + if($date_created !== false) echo "CREATED : ".date('d.m.Y', $date_created).PHP_EOL; + if($event !== false) echo "EVENT : $event".PHP_EOL; + if($keywords !== false) echo "KEYWORDS : ".implode(', ', $keywords).PHP_EOL; + if($people !== false) echo "PEOPLE : ".implode(', ', $people).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); + if($keywords !== false) echo "KEYWORDS : ".implode(', ', $keywords).PHP_EOL; + if($people !== false) echo "PEOPLE : ".implode(', ', $people).PHP_EOL; + echo PHP_EOL; + // Re-format caption and update information 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); + $metadata->set(\Holiday\Metadata::CAPTION, $caption, lang: \Holiday\Metadata::LANG_DEFAULT); } else { echo "NOT ALL INFORMATION AVAILABLE TO UPDATE CAPTION".PHP_EOL; @@ -73,7 +90,7 @@ function my__autoload(string $name): void $metadata->set(\Holiday\Metadata::EVENT, strtoupper($event)); } else { - $metadata->set(\Holiday\Metadata::EVENT, 'New demo event name'); + $metadata->set(\Holiday\Metadata::EVENT, 'Event was empty'); } // Write metadata back to the image file @@ -81,8 +98,15 @@ function my__autoload(string $name): void // Read-back the data and display modified caption $metadata->read("new.$filename"); - echo "NEW CAPTION: ".$metadata->get(\Holiday\Metadata::CAPTION).PHP_EOL; - echo "NEW EVENT: ".$metadata->get(\Holiday\Metadata::EVENT).PHP_EOL.PHP_EOL; + $caption_ary = $metadata->get(\Holiday\Metadata::CAPTION); + if(!empty($caption_ary)) { + echo "NEW CAPTION:".PHP_EOL; + foreach($caption_ary as $lang => $text) { + $lang = substr($lang.' ', 0, 9); + echo " $lang: $text".PHP_EOL; + } + } + echo "NEW EVENT : ".$metadata->get(\Holiday\Metadata::EVENT).PHP_EOL.PHP_EOL; // Paste original data to new file $metadata->read("$filename"); @@ -90,8 +114,15 @@ function my__autoload(string $name): void // Read-back the data and display original caption $metadata->read("new.$filename"); - echo "PASTED CAPTION: ".$metadata->get(\Holiday\Metadata::CAPTION).PHP_EOL; - echo "PASTED EVENT: ".$metadata->get(\Holiday\Metadata::EVENT).PHP_EOL.PHP_EOL; + $caption_ary = $metadata->get(\Holiday\Metadata::CAPTION); + if(!empty($caption_ary)) { + echo "PASTED CAPTION:".PHP_EOL; + foreach($caption_ary as $lang => $text) { + $lang = substr($lang.' ', 0, 9); + echo " $lang: $text".PHP_EOL; + } + } + echo "PASTED EVENT: ".$metadata->get(\Holiday\Metadata::EVENT).PHP_EOL.PHP_EOL; } /** diff --git a/test/example.txt b/test/example.txt new file mode 100644 index 0000000..a0b8c9f --- /dev/null +++ b/test/example.txt @@ -0,0 +1,52 @@ +PROCESSING EXAMPLE IMAGE USING \Holiday\Metadata CLASS: img.example.jpg +--- +CAPTION: + x-default: Demo Caption, démonstration en français, Deutsche Übersetzung +CREDIT : Demo Credit +PLACE : Demo City, Switzerland +CREATED : 10.05.2022 +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 + +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 + +NEW CAPTION: + x-default: DEMO CITY, SWITZERLAND - MAY 10: Demo Caption, démonstration en français, Deutsche Übersetzung (Photo by Demo Credit) +NEW EVENT : DEMO EVENT + +PASTED CAPTION: + x-default: Demo Caption, démonstration en français, Deutsche Übersetzung +PASTED EVENT: Demo Event + +PROCESSING EXAMPLE IMAGE USING \Holiday\Metadata CLASS: img.mlexample.jpg +--- +CAPTION: + x-default: Denkmal zu Ehren von Salonom Gessner (1730-1788), einem idyllischen Dichter und Maler aus Zürich, am Klöntalersee im Klöntal am Dienstag, 12. Juli 2022 bei Klöntal, Schweiz + en-us : Monument in honor of Salonom Gessner (1730-1788), an idyllic poet and painter from Zurich, at the Klöntalersee in the Klöntal valley on Tuesday, July 12, 2022 near Klöntal, Switzerland +CREDIT : Claude Diderich +PLACE : Klöntal, Schweiz +CREATED : 12.07.2022 +KEYWORDS : Spaziergang, Monument, Tal, Berg, Gessner, Stein + +EXTENDING KEYWORD TAGS: +KEYWORDS : Spaziergang, Monument, Tal, Berg, Gessner, Stein + +NEW CAPTION: + x-default: KLöNTAL, SCHWEIZ - JULY 12: Denkmal zu Ehren von Salonom Gessner (1730-1788), einem idyllischen Dichter und Maler aus Zürich, am Klöntalersee im Klöntal am Dienstag, 12. Juli 2022 bei Klöntal, Schweiz (Photo by Claude Diderich) + en-us : Monument in honor of Salonom Gessner (1730-1788), an idyllic poet and painter from Zurich, at the Klöntalersee in the Klöntal valley on Tuesday, July 12, 2022 near Klöntal, Switzerland +NEW EVENT : Event was empty + +PASTED CAPTION: + x-default: Denkmal zu Ehren von Salonom Gessner (1730-1788), einem idyllischen Dichter und Maler aus Zürich, am Klöntalersee im Klöntal am Dienstag, 12. Juli 2022 bei Klöntal, Schweiz + en-us : Monument in honor of Salonom Gessner (1730-1788), an idyllic poet and painter from Zurich, at the Klöntalersee in the Klöntal valley on Tuesday, July 12, 2022 near Klöntal, Switzerland +PASTED EVENT: + + +EXCEPTION CATCHED +--- +CODE: 11 +MESSAGE: File not found +DATA: invalid.file.name.jpg diff --git a/test/img.mlexample.jpg b/test/img.mlexample.jpg new file mode 100644 index 0000000..4ba01a4 Binary files /dev/null and b/test/img.mlexample.jpg differ