From ca902a2f9f746e6dd6969f110f4d08d3be4ab3c0 Mon Sep 17 00:00:00 2001 From: Claude Diderich Date: Mon, 11 Jul 2022 14:46:09 +0200 Subject: [PATCH] Release 1.0.1 --- CHANGELOG.md | 9 +- README.md | 33 +- locale/metadata.pot | 228 ++++++++------ src/Metadata.php | 569 +++++++++++++++++++---------------- src/Metadata/Exif.php | 2 + src/Metadata/Jpeg.php | 47 +-- src/Metadata/XmpDocument.php | 1 - 7 files changed, 498 insertions(+), 391 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e34718b..89698ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,15 @@ # CHANGELOG.md -Current version: `v1.0.0` +Current version: `v1.0.1` All notable changes to the **HOLIDAY - Metadata** PHP classes for reading and writing metadata from/to JPG image files. +## 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 + 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 + ## v1.0.0 - 2022-07-10 Initial release diff --git a/README.md b/README.md index 6e02d8e..494e77c 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,9 @@ The package has been developed in the context of developing the proprietary HOLI 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`. -Most of the decoding and encoding of the JPG image file into different header segments as well as the decoding and -encoding of the IPTC data is based on code from **The PHP JPEG Metadata Toolkit** -(https://www.ozhiker.com/electronics/pjmt/), which is made available through the GNU Public License Version 1.12 and is -hereby duly acknowledged. +The decoding and encoding of the JPG 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 @@ -73,10 +72,10 @@ data. In the current implementation, user modifiable data stored in the EXIF/APP user-editable data and b) the complexity of the EXIF/APP1 data format. ## Exception handling -All functions return `false`in case of non-fatal errors. Fatal errors trigger the exception `\Holiday\Metadata\Exception` -to be thrown (which extends the generic `Exception` class). The numerical code associated with an error -(`$exception->getCode()`) allows identifying the type of the error. Constants are defined in the exception class -`\Holiday\Metadata\Exception`. +All functions return `false`in case of non-fatal errors. Fatal errors trigger the exception +`\Holiday\Metadata\Exception` to be thrown (which extends the generic `Exception` class). The numerical code associated +with an error (`$exception->getCode()`) allows identifying the type of the error. Constants are defined in the exception +class `\Holiday\Metadata\Exception`. The function `\Holiday\Metadata\Exception::getData()`returns additional data relevant to the thrown exception in human readable form. @@ -137,12 +136,12 @@ All class files are commended in a phpDocumenter (https://www.phpdoc.org/) compl |-- src/ Directory containing all class files required to use the library |-- Metadata.php Class implementing transparent read/write access to JPG 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 + |-- 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 - |-- XmpDocument.php Class encoding and decoding Xmp data in a DOMDocument class format - |-- Xmp.php Class reading and writing XMP/APP1 specific 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 @@ -181,6 +180,14 @@ The following limitations currently exist and are acknowledged as such: The author is not aiming a removing these limitations in the future. +# Idea for future development +Although not currently planned, I 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. + + # Support Free community support is available on `github.com` using the Issues and Discussion sections. The author may participate in providing free support, but does not guarantee to do so. Guaranteed paid support is available from diff --git a/locale/metadata.pot b/locale/metadata.pot index 641cbb3..3a42da9 100644 --- a/locale/metadata.pot +++ b/locale/metadata.pot @@ -7,137 +7,157 @@ msgid "HOLIDAY - Metadata / PHP classes for reading and writing metadata from JP msgstr "" "Project-Id-Version: 1.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-07-04 12:00+0200\n" -"PO-Revision-Date: 2022-07-04 12:00+0200\n" +"POT-Creation-Date: 2022-07-10 12:00+0200\n" +"PO-Revision-Date: 2022-07-10 12:00+0200\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" -"Language: \n" +"Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: src/Metadata.php:148 src/Metadata/Jpeg.php:56 +#: src/Metadata.php:160 src/Metadata/Jpeg.php:60 msgid "File not found" msgstr "" -#: src/Metadata.php:181 src/Metadata.php:201 src/Metadata/Jpeg.php:307 +#: src/Metadata.php:193 src/Metadata.php:215 src/Metadata/Jpeg.php:316 msgid "No image and metadata previously read" msgstr "" -#: src/Metadata.php:205 -msgid "File to copy metadata to not found" +#: src/Metadata.php:195 src/Metadata/Jpeg.php:148 +msgid "Cannot write file because data was read in read-only mode" msgstr "" -#: src/Metadata.php:231 src/Metadata.php:262 src/Metadata.php:327 -#: src/Metadata.php:354 +#: src/Metadata.php:219 +msgid "File to paste metadata to not found" +msgstr "" + +#: src/Metadata.php:245 src/Metadata.php:276 src/Metadata.php:341 +#: src/Metadata.php:368 msgid "Invalid field identifier specified" msgstr "" -#: src/Metadata.php:267 +#: src/Metadata.php:281 msgid "Invalid type of field value identifier specified" msgstr "" -#: src/Metadata.php:271 +#: src/Metadata.php:285 msgid "Field is not writable" msgstr "" -#: src/Metadata.php:331 +#: src/Metadata.php:345 msgid "Only individual values of arrays can be dropped" msgstr "" -#: src/Metadata.php:939 +#: src/Metadata.php:928 +msgid "Invalid fraction specified" +msgstr "" + +#: src/Metadata.php:962 msgid "sRGB" msgstr "" -#: src/Metadata.php:942 -msgid "Adove RGB" +#: src/Metadata.php:964 +msgid "Adobe RGB" msgstr "" -#: src/Metadata.php:945 +#: src/Metadata.php:966 msgid "Wide Gamut RGB" msgstr "" -#: src/Metadata.php:948 +#: src/Metadata.php:968 msgid "ICC Profile" msgstr "" -#: src/Metadata.php:951 +#: src/Metadata.php:970 msgid "Uncalibrated" msgstr "" -#: src/Metadata.php:997 +#: src/Metadata.php:978 msgid "second(s)" msgstr "" -#: src/Metadata.php:1002 -msgid "Not defined" +#: src/Metadata.php:984 +msgid "Auto" msgstr "" -#: src/Metadata.php:1005 src/Metadata.php:1040 +#: src/Metadata.php:986 src/Metadata.php:997 msgid "Manual" msgstr "" -#: src/Metadata.php:1008 -msgid "Program AE" +#: src/Metadata.php:988 +msgid "Auto bracket" msgstr "" -#: src/Metadata.php:1011 -msgid "Aperture-priority AE" +#: src/Metadata.php:995 +msgid "Not defined" msgstr "" -#: src/Metadata.php:1014 -msgid "Shutter speed priority AE" +#: src/Metadata.php:999 +msgid "Program" msgstr "" -#: src/Metadata.php:1017 -msgid "Creative (Slow speed)" +#: src/Metadata.php:1001 +msgid "Aperture-priority" msgstr "" -#: src/Metadata.php:1020 -msgid "Action (High speed)" +#: src/Metadata.php:1003 +msgid "Shutter speed priority" +msgstr "" + +#: src/Metadata.php:1005 +msgid "Creative (slow speed)" msgstr "" -#: src/Metadata.php:1023 +#: src/Metadata.php:1007 +msgid "Action (high speed)" +msgstr "" + +#: src/Metadata.php:1009 msgid "Portrait" msgstr "" -#: src/Metadata.php:1026 +#: src/Metadata.php:1011 msgid "Landscape" msgstr "" -#: src/Metadata.php:1029 +#: src/Metadata.php:1013 msgid "Bulb" msgstr "" -#: src/Metadata.php:1037 -msgid "Auto" +#: src/Metadata.php:1020 +msgid "Flash" msgstr "" -#: src/Metadata.php:1043 -msgid "Auto bracket" +#: src/Metadata.php:1020 +msgid "No flash" +msgstr "" + +#: src/Metadata.php:1026 +msgid "mm" msgstr "" -#: src/Metadata.php:1051 +#: src/Metadata.php:1057 msgid "Average" msgstr "" -#: src/Metadata.php:1054 +#: src/Metadata.php:1059 msgid "Center-weighted average" msgstr "" -#: src/Metadata.php:1057 +#: src/Metadata.php:1061 msgid "Spot" msgstr "" -#: src/Metadata.php:1060 +#: src/Metadata.php:1063 msgid "Multi-spot" msgstr "" -#: src/Metadata.php:1063 +#: src/Metadata.php:1065 msgid "Multi-segment" msgstr "" -#: src/Metadata.php:1066 +#: src/Metadata.php:1067 msgid "Partial" msgstr "" @@ -145,39 +165,47 @@ msgstr "" msgid "Other" msgstr "" -#: src/Metadata.php:1072 +#: src/Metadata.php:1071 msgid "Unknown" msgstr "" -#: src/Metadata/Exif.php:147 +#: src/Metadata.php:1129 +msgid "px" +msgstr "" + +#: src/Metadata.php:1132 +msgid "cm" +msgstr "" + +#: src/Metadata/Exif.php:151 msgid "Specified EXIF tag name is read-only" msgstr "" -#: src/Metadata/Exif.php:195 src/Metadata/Exif.php:244 +#: src/Metadata/Exif.php:202 +msgid "Invalid byte alignment read" +msgstr "" + +#: src/Metadata/Exif.php:217 msgid "Invalid TIFF header size" msgstr "" -#: src/Metadata/Exif.php:203 src/Metadata/Exif.php:252 +#: src/Metadata/Exif.php:225 msgid "TIFF header ID not found" msgstr "" -#: src/Metadata/Exif.php:208 src/Metadata/Exif.php:257 +#: src/Metadata/Exif.php:230 msgid "Error finding position of first IFD" msgstr "" -#: src/Metadata/Exif.php:229 -msgid "Invalid byte alignment read" -msgstr "" - -#: src/Metadata/Exif.php:426 +#: src/Metadata/Exif.php:339 msgid "Cannot calculate data size of invalid data type" msgstr "" -#: src/Metadata/Exif.php:498 src/Metadata/Exif.php:530 +#: src/Metadata/Exif.php:411 src/Metadata/Exif.php:443 msgid "Invalid IFD data type found" msgstr "" -#: src/Metadata/Exif.php:528 +#: src/Metadata/Exif.php:441 msgid "bytes of binary data" msgstr "" @@ -189,101 +217,121 @@ msgstr "" msgid "IPTC data seems to be corrupt while decoding data tag" msgstr "" -#: src/Metadata/Jpeg.php:62 +#: src/Metadata/Jpeg.php:66 msgid "Invalid file type" msgstr "" -#: src/Metadata/Jpeg.php:69 +#: src/Metadata/Jpeg.php:73 msgid "File is corrupt" msgstr "" -#: src/Metadata/Jpeg.php:102 +#: src/Metadata/Jpeg.php:107 msgid "Image data seems to be corrupt" msgstr "" -#: src/Metadata/Jpeg.php:111 +#: src/Metadata/Jpeg.php:117 msgid "File seems to be corrupt" msgstr "" -#: src/Metadata/Jpeg.php:139 src/Metadata/Jpeg.php:209 -#: src/Metadata/Jpeg.php:227 src/Metadata/Jpeg.php:294 -#: src/Metadata/Jpeg.php:362 +#: src/Metadata/Jpeg.php:146 src/Metadata/Jpeg.php:218 +#: src/Metadata/Jpeg.php:236 src/Metadata/Jpeg.php:303 +#: src/Metadata/Jpeg.php:371 msgid "No image and metadata read" msgstr "" -#: src/Metadata/Jpeg.php:144 +#: src/Metadata/Jpeg.php:153 msgid "Header segment is too large to fit into JPG segment" msgstr "" -#: src/Metadata/Jpeg.php:150 +#: src/Metadata/Jpeg.php:159 msgid "Could not open file for writing" msgstr "" -#: src/Metadata/XmpDocument.php:83 src/Metadata/XmpDocument.php:335 -#: src/Metadata/XmpDocument.php:352 src/Metadata/XmpDocument.php:362 -#: src/Metadata/XmpDocument.php:388 src/Metadata/XmpDocument.php:419 -#: src/Metadata/XmpDocument.php:524 +#: src/Metadata/XmpDocument.php:95 src/Metadata/XmpDocument.php:384 +#: src/Metadata/XmpDocument.php:401 src/Metadata/XmpDocument.php:411 +#: src/Metadata/XmpDocument.php:437 src/Metadata/XmpDocument.php:468 +#: src/Metadata/XmpDocument.php:573 msgid "Cannot find" msgstr "" -#: src/Metadata/XmpDocument.php:83 src/Metadata/XmpDocument.php:335 -#: src/Metadata/XmpDocument.php:352 src/Metadata/XmpDocument.php:362 -#: src/Metadata/XmpDocument.php:388 src/Metadata/XmpDocument.php:419 -#: src/Metadata/XmpDocument.php:524 +#: src/Metadata/XmpDocument.php:95 src/Metadata/XmpDocument.php:384 +#: src/Metadata/XmpDocument.php:401 src/Metadata/XmpDocument.php:411 +#: src/Metadata/XmpDocument.php:437 src/Metadata/XmpDocument.php:468 +#: src/Metadata/XmpDocument.php:573 msgid "node" msgstr "" -#: src/Metadata/XmpDocument.php:131 src/Metadata/XmpDocument.php:159 -#: src/Metadata/XmpDocument.php:192 +#: src/Metadata/XmpDocument.php:143 src/Metadata/XmpDocument.php:171 +#: src/Metadata/XmpDocument.php:237 msgid "Node name without prefix found" msgstr "" -#: src/Metadata/XmpDocument.php:217 src/Metadata/XmpDocument.php:243 -#: src/Metadata/XmpDocument.php:269 +#: src/Metadata/XmpDocument.php:218 src/Metadata/XmpDocument.php:340 +msgid "Internal error finding" +msgstr "" + +#: src/Metadata/XmpDocument.php:218 +msgid "element" +msgstr "" + +#: src/Metadata/XmpDocument.php:262 src/Metadata/XmpDocument.php:288 +#: src/Metadata/XmpDocument.php:314 msgid "Cannot set" msgstr "" -#: src/Metadata/XmpDocument.php:217 src/Metadata/XmpDocument.php:243 -#: src/Metadata/XmpDocument.php:269 +#: src/Metadata/XmpDocument.php:262 src/Metadata/XmpDocument.php:288 +#: src/Metadata/XmpDocument.php:314 msgid "node value if an attribute with the same name exists" msgstr "" -#: src/Metadata/XmpDocument.php:352 src/Metadata/XmpDocument.php:362 +#: src/Metadata/XmpDocument.php:340 +msgid "including" +msgstr "" + +#: src/Metadata/XmpDocument.php:341 +msgid "as namespaces" +msgstr "" + +#: src/Metadata/XmpDocument.php:401 src/Metadata/XmpDocument.php:411 msgid "child of" msgstr "" -#: src/Metadata/XmpDocument.php:431 +#: src/Metadata/XmpDocument.php:480 msgid "Error" msgstr "" -#: src/Metadata/XmpDocument.php:431 +#: src/Metadata/XmpDocument.php:480 msgid "deleting" msgstr "" -#: src/Metadata/XmpDocument.php:431 +#: src/Metadata/XmpDocument.php:480 msgid "updating" msgstr "" -#: src/Metadata/XmpDocument.php:431 +#: src/Metadata/XmpDocument.php:480 msgid "attribute" msgstr "" -#: src/Metadata/XmpDocument.php:468 +#: src/Metadata/XmpDocument.php:517 msgid "Error creating new attribute" msgstr "" -#: src/Metadata/Xmp.php:99 +#: src/Metadata/XmpDocument.php:707 +msgid "Internal error during XML re-validation" +msgstr "" + +#: src/Metadata/Xmp.php:100 msgid "XMP metadata start tag not found" msgstr "" -#: src/Metadata/Xmp.php:101 +#: src/Metadata/Xmp.php:102 msgid "XMP metadata end tag not found" msgstr "" -#: src/Metadata/Xmp.php:110 +#: src/Metadata/Xmp.php:109 msgid "Error decoding XMP metdata as XML" msgstr "" -#: src/Metadata/Xmp.php:127 +#: src/Metadata/Xmp.php:126 msgid "Error encoding XMP metdata as XML" msgstr "" diff --git a/src/Metadata.php b/src/Metadata.php index eaac51b..75a90dd 100644 --- a/src/Metadata.php +++ b/src/Metadata.php @@ -21,89 +21,97 @@ class Metadata { - const VERSION = '1.0.0'; + const VERSION = '1.0.1'; /** Fielt types */ const TYPE_INVALID = 0; const TYPE_STR = 1; const TYPE_INT = 2; - const TYPE_ARY = 3; + const TYPE_FLOAT = 3; + const TYPE_ARY = 4; /** File specific fields: read only */ - const FILE_NAME = 001; /** String: Filename */ - const FILE_EXT = 002; /** String: File extension */ - const FILE_SIZE = 003; /** Int: File size */ - const FILE_DATE = 004; /** Int: Last modification date */ + const FILE_NAME = 001; /** String: Filename */ + const FILE_EXT = 002; /** String: File extension */ + const FILE_SIZE = 003; /** Int: File size */ + const FILE_DATE = 004; /** Int: Last modification date */ /** IPTC/XMP fields: read/write */ - const FIELD_ID_WRITE_FIRST = 100; /** First field identifier that can be modified */ - const FIELD_ID_WRITE_LAST = 132; /** Last field identifier that can be modified */ + const FIELD_ID_WRITE_FIRST = 100; /** First field identifier that can be modified */ + const FIELD_ID_WRITE_LAST = 132; /** Last field identifier that can be modified */ - const AUTHOR = 101; /** String: Creator (name of photographer) */ - const PHOTOGRAPHER = 101; /** - String: Creator (name of photographer) */ - const AUTHOR_TITLE = 102; /** String: Creator's job title */ - const PHOTOGRAPHER_TITLE = 102; /** - String: Creator's job title */ - const CAPTION = 103; /** String: Description/Caption */ - const CAPTION_WRITER = 104; /** String: Description writer */ - const CATEGORY = 105; /** String: Category - Max 3 characters */ - const CITY = 106; /** String: City */ - const COPYRIGHT = 107; /** String: Copyright notice */ - const COUNTRY = 108; /** String: Country name */ - const COUNTRY_CODE = 109; /** String: ISO country code*/ - const CREDIT = 110; /** String: Credit Line */ - const EDIT_STATUS = 111; /** String: Edit Status - Max 64 characters */ - const EVENT = 112; /** String: Event identifier */ - const GENRE = 113; /** Array: Genre */ - const HEADLINE = 114; /** String: Headline */ - const INSTRUCTIONS = 115; /** String: Instructions */ - const KEYWORDS = 116; /** Array: Keywords */ - const LOCATION = 117; /** String: Location */ - const OBJECT = 118; /** String: Object name (Title)*/ - const ORG_CODE = 119; /** Array: Code of Organization in image */ - const NAT = 119; /** - Array: Nationalities */ - const ORG_NAME = 120; /** Array: Name of Organization in image */ - const ORG = 120; /** - Array: Organizations/Teams in image */ - const PERSON = 121; /** Array: Person shown in image */ - const PEOPLE = 121; /** - Array: Person shown in image */ - const PERSONALITY = 121; /** - Array: Person shown in image (Getty terminology) */ - const PRIORITY = 122; /** Int: Urgency - 1 numeric character */ - const RATING = 123; /** Int: Numeric image rating, -1 (rejected), 0..5 */ - const SCENES = 124; /** Array: Scene codes*/ - const SOURCE = 125; /** String: Source */ - const STATE = 126; /** String: Providence/State */ - const SUBJECT_CODE = 127; /** Array: Subject code */ - const SUPP_CATEGORY_A = 128; /** String: Supplemental Category 1 */ - const SUPP_CATEGORY_B = 129; /** String: Supplemental Category 2 */ - const SUPP_CATEGORY_C = 130; /** String: Supplemental Category 3 */ - const TRANSFER_REF = 131; /** String: Original Transmission Reference - Max 32 characters */ - const USAGE_TERMS = 132; /** String: Rights Usage Terms */ + const AUTHOR = 101; /** String: Creator (name of photographer) */ + const PHOTOGRAPHER = 101; /** - String: Creator (name of photographer) */ + const AUTHOR_TITLE = 102; /** String: Creator's job title */ + const PHOTOGRAPHER_TITLE = 102; /** - String: Creator's job title */ + const CAPTION = 103; /** String: Description/Caption */ + const CAPTION_WRITER = 104; /** String: Description writer */ + const CATEGORY = 105; /** String: Category - Max 3 characters */ + const CITY = 106; /** String: City */ + const COPYRIGHT = 107; /** String: Copyright notice */ + const COUNTRY = 108; /** String: Country name */ + const COUNTRY_CODE = 109; /** String: ISO country code*/ + const CREDIT = 110; /** String: Credit Line */ + const EDIT_STATUS = 111; /** String: Edit Status - Max 64 characters */ + const EVENT = 112; /** String: Event identifier */ + const GENRE = 113; /** Array: Genre */ + const HEADLINE = 114; /** String: Headline */ + const INSTRUCTIONS = 115; /** String: Instructions */ + const KEYWORDS = 116; /** Array: Keywords */ + const LOCATION = 117; /** String: Location */ + const OBJECT = 118; /** String: Object name (Title)*/ + const ORG_CODE = 119; /** Array: Code of Organization in image */ + const NAT = 119; /** - Array: Nationalities */ + const ORG_NAME = 120; /** Array: Name of Organization in image */ + const ORG = 120; /** - Array: Organizations/Teams in image */ + const PERSON = 121; /** Array: Person shown in image */ + const PEOPLE = 121; /** - Array: Person shown in image */ + const PERSONALITY = 121; /** - Array: Person shown in image (Getty terminology) */ + const PRIORITY = 122; /** Int: Urgency - 1 numeric character */ + const RATING = 123; /** Int: Numeric image rating, -1 (rejected), 0..5 */ + const SCENES = 124; /** Array: Scene codes*/ + const SOURCE = 125; /** String: Source */ + const STATE = 126; /** String: Providence/State */ + const SUBJECT_CODE = 127; /** Array: Subject code */ + const SUPP_CATEGORY_A = 128; /** String: Supplemental Category 1 */ + const SUPP_CATEGORY_B = 129; /** String: Supplemental Category 2 */ + const SUPP_CATEGORY_C = 130; /** String: Supplemental Category 3 */ + const TRANSFER_REF = 131; /** String: Original Transmission Reference - Max 32 characters */ + const USAGE_TERMS = 132; /** String: Rights Usage Terms */ /** IPTC/XMP fiels: read only */ - const CREATED_DATETIME = 201; /** Int: Timestamp when photo was created */ + const CREATED_DATETIME = 201; /** Int: Timestamp when photo was created */ /** Image data fields: read only **/ - const IMG_APERTURE = 301; /** String: Aperture (f/X) */ - const IMG_CAMERA_MAKE = 302; /** String: Camera brand */ - const IMG_CAMERA_MODEL = 303; /** String: Camera model */ - const IMG_CAMERA_SERIAL = 304; /** Stringg: Camera serial number */ - const IMG_COLOR_SPACE = 305; /** String: Color space */ - const IMG_EXPOSURE = 306; /** String: Exposure */ - const IMG_EXPOSURE_MODE = 307; /** String/Int: Exposure mode */ - const IMG_EXPOSURE_PROGRAM = 308; /** String/Int: Exposure setting */ - const IMG_FLASH = 309; /** Int: Flash used */ - const IMG_FOCAL_LENGTH = 310; /** Int: Focal length */ - const IMG_HEIGHT = 311; /** Int: Image height */ - const IMG_ISO = 312; /** Int: ISO */ - const IMG_LENS_MAKE = 313; /** String: Lens brand */ - const IMG_LENS_MODEL = 314; /** String: Lens name */ - const IMG_LENS_SERIAL = 315; /** String: Lens serial number */ - const IMG_ORIENTATION = 316; /** Int: Orientation */ - const IMG_RESOLUTION = 317; /** Int: Image resolution */ - const IMG_SIZE_FORMATTED = 318; /** String: Formatted image size ( W x H px - X x Y cm (x MB) */ - const IMG_SOFTWARE = 319; /** String: Software used */ - const IMG_TYPE = 320; /** Int: Image type (see imagetypes() for constants) */ - const IMG_WIDTH = 321; /** Int: Image width */ - const IMG_METERING_MODE = 322; /** String/Int: Merering model */ + const IMG_APERTURE = 301; /** Float: Aperture */ + const IMG_APERTURE_FMT = 302; /** String: Aperture (f/X) */ + const IMG_CAMERA_MAKE = 303; /** String: Camera brand */ + const IMG_CAMERA_MODEL = 304; /** String: Camera model */ + const IMG_CAMERA_SERIAL = 305; /** String: Camera serial number */ + const IMG_COLOR_SPACE_FMT = 306; /** String: Color space */ + const IMG_EXPOSURE = 307; /** Array: Exposure */ + const IMG_EXPOSURE_FMT = 308; /** String: Exposure (1/X second(s))*/ + const IMG_EXPOSURE_MODE_FMT = 309; /** String: Exposure mode */ + const IMG_EXPOSURE_PGM_FMT = 310; /** String: Exposure setting */ + const IMG_FLASH = 311; /** Int: Flash used */ + const IMG_FLASH_FMT = 312; /** String: Flash used (Flash | No flash)*/ + const IMG_FOCAL_LENGTH = 313; /** Int: Focal length */ + const IMG_FOCAL_LENGTH_FMT = 314; /** String: Focal length (X mm) */ + const IMG_HEIGHT = 315; /** Int: Image height */ + const IMG_ISO = 316; /** Int: ISO */ + const IMG_LENS_MAKE = 317; /** String: Lens brand */ + const IMG_LENS_MODEL = 318; /** String: Lens name */ + const IMG_LENS_SERIAL = 319; /** String: Lens serial number */ + const IMG_METERING_MODE_FMT = 320; /** String: Merering model */ + const IMG_ORIENTATION = 321; /** Int: Orientation */ + const IMG_RESOLUTION = 322; /** Int: Image resolution, in resolution unit */ + const IMG_RESOLUTION_FMT = 323; /** String: Image resolution, in resolution unit */ + const IMG_RESOLUTION_UNIT = 324; /** Int: Resolution unit (1, 3=cm / 2=inch) */ + const IMG_SIZE_FMT = 325; /** String: Formatted image size ( W x H px - X x Y cm (x MB) */ + const IMG_SOFTWARE = 326; /** String: Software used */ + const IMG_TYPE = 327; /** Int: Image type (see imagetypes() for constants) */ + const IMG_TYPE_FMT = 328; /** String: Image type (jpeg) */ + const IMG_WIDTH = 329; /** Int: Image width */ /** Orientation encoding: IMG_ORIENTATION */ const IMG_ORI_VERTICAL = 1; @@ -113,6 +121,7 @@ class Metadata { /** Private variables */ protected bool $data_read; /** Has data been loaded/read */ + protected bool $read_only; /** Data has benn read in read-only mode, disabling writing back */ protected array $data; /** Source agnostic data */ protected Metadata\Jpeg $jpeg; /** Jpeg object */ @@ -120,8 +129,8 @@ class Metadata { * Consturctor */ public function __construct() - { - $this->data_read = false; + { + $this->data_read = false; $this->read_only = true; $this->data = array(); $this->jpeg = new Metadata\Jpeg(); } @@ -136,15 +145,17 @@ public function __desctruct() /** * Read IPTC, XMP, and EXIF metadata from file and make then available in a transparent way * - * @param string $filename Filename to read metadata from - * @param bool $extend Extend IPTC keyword data into XMP specific fields, e.g. Event:, Scene:, Genre:, + * @param string $filename Filename to read metadata from + * @param bool $extend Extend IPTC keyword data into XMP specific fields, e.g. Event:, Scene:, Genre:, + * @param bool $read_only Read JPG data in read-only model * @throw Exception */ - public function read(string $filename, bool $extend = false): void + public function read(string $filename, bool $extend = false, bool $read_only = false): void { // Re-initialize data $this->data_read = false; $this->data = array(); + $this->read_only = $read_only; // Get file specific fields if(!file_exists($filename)) throw new Exception(_('File not found'), Exception::FILE_NOT_FOUND, $filename); @@ -157,7 +168,7 @@ public function read(string $filename, bool $extend = false): void $this->setRW(self::FILE_DATE, filemtime($filename)); // Read JPG data - $this->jpeg->read($filename); + $this->jpeg->read($filename, read_only: $this->read_only); // Import data (in decreasing order of relevance) $this->importIptc(); @@ -181,6 +192,8 @@ public function write(string $filename): void { if(!$this->data_read) throw new Exception(_('No image and metadata previously read'), Exception::DATA_NOT_FOUND); + if($this->read_only) + throw new Exception(_('Cannot write file because data was read in read-only mode'), Exception::DATA_NOT_FOUND); // Export data $this->exportIptc(); @@ -224,10 +237,10 @@ 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 - * @return string|int|array|false Field value - * @throw Metadate\Exception + * @return string|int|float|array|false Field value + * @throw Exception */ - public function get(int $field_id): string|int|array|false + public function get(int $field_id): string|int|float|array|false { if(self::fieldType($field_id) === self::TYPE_INVALID) throw new Exception(_('Invalid field identifier specified'), Exception::INVALID_FIELD_ID, @@ -240,11 +253,11 @@ public function get(int $field_id): string|int|array|false /** * Save data associated with a given field identifier * - * @param int $field_id Field identifier - * @param string|int|array|false $field_value Field value + * @param int $field_id Field identifier + * @param string|int|float|array|false $field_value Field value * @throw Exception */ - public function set(int $field_id, string|int|array|false $field_value): void + public function set(int $field_id, string|int|float|array|false $field_value): void { $this->setRW($field_id, $field_value, ignore_write: false); } @@ -253,12 +266,12 @@ public function set(int $field_id, string|int|array|false $field_value): void * Save data associated with a given field identifier * * @access private - * @param int $field_id Field identifier - * @param string|int|array|false $field_value Field value - * @param bool $ignore_write Ignore write check + * @param int $field_id Field identifier + * @param string|int|float|array|false $field_value Field value + * @param bool $ignore_write Ignore write check * @throw Exception */ - private function setRW(int $field_id, string|int|array|false $field_value, bool $ignore_write = true): void + private function setRW(int $field_id, string|int|float|array|false $field_value, bool $ignore_write = true): void { if(self::fieldType($field_id) === self::TYPE_INVALID) throw new Exception(_('Invalid field identifier specified'), Exception::INVALID_FIELD_ID, @@ -377,6 +390,7 @@ public static function fieldType(int $field_id): int case self::PERSON: case self::SCENES: case self::SUBJECT_CODE: + case self::IMG_EXPOSURE: return self::TYPE_ARY; case self::FILE_DATE: @@ -390,9 +404,13 @@ public static function fieldType(int $field_id): int case self::IMG_ISO: case self::IMG_ORIENTATION; case self::IMG_RESOLUTION: + case self::IMG_RESOLUTION_UNIT: case self::IMG_TYPE: case self::IMG_WIDTH: return self::TYPE_INT; + + case self::IMG_APERTURE: + return self::TYPE_FLOAT; case self::FILE_NAME: case self::FILE_EXT: @@ -419,20 +437,24 @@ public static function fieldType(int $field_id): int case self::SUPP_CATEGORY_C: case self::TRANSFER_REF: case self::USAGE_TERMS: - case self::IMG_APERTURE: + case self::IMG_APERTURE_FMT: case self::IMG_CAMERA_MAKE: case self::IMG_CAMERA_MODEL: case self::IMG_CAMERA_SERIAL; - case self::IMG_COLOR_SPACE: - case self::IMG_EXPOSURE: - case self::IMG_EXPOSURE_MODE: - case self::IMG_EXPOSURE_PROGRAM: + case self::IMG_COLOR_SPACE_FMT: + case self::IMG_EXPOSURE_FMT: + case self::IMG_EXPOSURE_MODE_FMT: + case self::IMG_EXPOSURE_PGM_FMT: + case self::IMG_FLASH_FMT: + case self::IMG_FOCAL_LENGTH_FMT: case self::IMG_LENS_MAKE: case self::IMG_LENS_MODEL: case self::IMG_LENS_SERIAL: - case self::IMG_SIZE_FORMATTED: + case self::IMG_METERING_MODE_FMT: + case self::IMG_RESOLUTION_FMT: + case self::IMG_SIZE_FMT: case self::IMG_SOFTWARE: - case self::IMG_METERING_MODE: + case self::IMG_TYPE_FMT: return self::TYPE_STR; default: return self::TYPE_INVALID; @@ -442,17 +464,19 @@ public static function fieldType(int $field_id): int /** * Return if the type asssociated with a given field value is valid * - * @param int $field_id Field identifier - * @param string|int|array|false $field_value Field value + * @param int $field_id Field identifier + * @param string|int|float|array|false $field_value Field value * @return bool Isd the type of the value appropriate for the field */ - public static function isValidFieldType(int $field_id, string|int|array|false $field_value): bool + public static function isValidFieldType(int $field_id, string|int|float|array|false $field_value): bool { switch(self::fieldType($field_id)) { case self::TYPE_STR: return gettype($field_value) === 'string'; case self::TYPE_INT: return gettype($field_value) === 'integer'; + case self::TYPE_FLOAT: + return gettype($field_value) === 'double' || gettype($field_value) === 'integer'; case self::TYPE_ARY: return gettype($field_value) === 'array'; default: @@ -710,10 +734,8 @@ protected function importXmp(): void $this->set(self::CREDIT, $xmp_data->getXmpText(Xmp::CREDIT)); if(!$this->isSet(self::GENRE) && $xmp_data->isXmpText(Xmp::GENRE)) $this->set(self::GENRE, $this->stringToArray($xmp_data->getXmpText(Xmp::GENRE))); - if(!$this->isSet(self::HEADLINE) && $xmp_data->isXmpText(Xmp::HEADLINE)) - $this->set(self::HEADLINE, $xmp_data->getXmpText(Xmp::PS_HEADLINE)); if(!$this->isSet(self::HEADLINE) && $xmp_data->isXmpText(Xmp::PS_HEADLINE)) - $this->set(self::HEADLINE, $xmp_data->getXmpText(Xmp::HEADLINE)); + $this->set(self::HEADLINE, $xmp_data->getXmpText(Xmp::PS_HEADLINE)); if(!$this->isSet(self::INSTRUCTIONS) && $xmp_data->isXmpText(Xmp::INSTRUCTIONS)) $this->set(self::INSTRUCTIONS, $xmp_data->getXmpText(Xmp::INSTRUCTIONS)); if(!$this->isSet(self::KEYWORDS) && $xmp_data->isXmpBag(Xmp::KEYWORDS)) @@ -794,8 +816,8 @@ protected function importXmp(): void $this->setRW(self::IMG_LENS_MODEL, $xmp_data->getXmpText(Xmp::LENS_MODEL), true); if(!$this->isSet(self::IMG_LENS_SERIAL) && $xmp_data->isXmpText(Xmp::LENS_SERIAL)) $this->setRW(self::IMG_LENS_SERIAL, $xmp_data->getXmpText(Xmp::LENS_SERIAL), true); - if(!$this->isSet(self::IMG_COLOR_SPACE) && $xmp_data->isXmpText(Xmp::COLOR_SPACE)) - $this->setRW(self::IMG_COLOR_SPACE, $xmp_data->getXmpText(Xmp::COLOR_SPACE), true); + if(!$this->isSet(self::IMG_COLOR_SPACE_FMT) && $xmp_data->isXmpText(Xmp::COLOR_SPACE)) + $this->setRW(self::IMG_COLOR_SPACE_FMT, $xmp_data->getXmpText(Xmp::COLOR_SPACE), true); } @@ -827,7 +849,6 @@ protected function exportXmp(): void if($xmp_data->isXmpText(Xmp::PM_EDIT_STATUS)) $xmp_data->setXmpText(Xmp::PM_EDIT_STATUS, $this->get(self::EDIT_STATUS)); $xmp_data->setXmpText(Xmp::GENRE, self::arrayToString($this->get(self::GENRE))); - $xmp_data->setXmpText(Xmp::HEADLINE, $this->get(self::HEADLINE)); if($xmp_data->isXmpText(Xmp::PS_HEADLINE)) $xmp_data->setXmpText(Xmp::PS_HEADLINE, $this->get(self::HEADLINE)); $xmp_data->setXmpText(Xmp::INSTRUCTIONS, $this->get(self::INSTRUCTIONS)); $xmp_data->setXmpBag(Xmp::KEYWORDS, $this->get(self::KEYWORDS)); @@ -871,32 +892,46 @@ protected function exportXmp(): void * * @access protected * @param string $data String formatted "x/y" - * @return int|false Rounded value dividing x by y + * @return float|false Rounded value dividing x by y */ - public static function calcFrac(string $data): int|false + protected static function calcFrac(string $data): float|false { if(strpos($data, '/') === 0) return false; list($nom, $denom) = explode('/', $data, 2); - return (int)round((int)$nom / (int)$denom); + return (float)((int)$nom / (int)$denom); } /** - * Re-format exposure into 1/x fraction + * Normalize fraction to a 1/x fraction * * @access protected - * @param string $exposure Exposure as a string x/y - * @return string Exposure as a string 1/y + * @param string $frac Fraction as a string x/y + * @return string Fraction as a string 1/y (where y is rounded) */ - public static function calcExposure(string $exposure): string + protected static function nrmFrac(string $frac): string { - if(strpos($exposure, '/') === false) return $exposure; - list($num, $denom) = explode('/', $exposure); + if(strpos($frac, '/') === false) return $frac; + list($num, $denom) = explode('/', $frac); $num = (int)trim($num); $denom = (int)trim($denom); $new_denom = $denom / $num; - if($new_denom != floor($new_denom)) return $exposure; - return '1/'.$new_denom; + return '1/'.round($new_denom); } - + + /** + * Decompose fraction into array + * + * @access protected + * @param string $frac Fraction as a string x/y + * @return array Array with two elements of fraction + */ + protected static function fracToArray(string $frac): array + { + if(strpos($frac, '/') === false) + throw new Exception(_('Invalid fraction specified'), Exception::DATA_FORMAT_ERROR, $frac); + list($num, $denom) = explode('/', $frac); + return array($num, $denom); + } + /** * Read EXIF metadata from file * Function may be extended to read additional fields and/or recoginiez additional tags @@ -908,199 +943,201 @@ protected function importExif(): void { $exif_data = $this->jpeg->getExifData(); if($exif_data === false) return; - $this->setRW(self::IMG_TYPE, IMG_JPG); - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF,Exif::TAG_EXIF_EXIF_IMAGE_WIDTH)]) && - !$this->isSet(self::IMG_WIDTH)) { - $width = (int)$exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXIF_IMAGE_WIDTH)]; - $this->setRW(self::IMG_WIDTH, $width); - } - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXIF_IMAGE_HEIGHT)]) && - !$this->isSet(self::IMG_HEIGHT)) { - $height = (int)$exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXIF_IMAGE_HEIGHT)]; - $this->setRW(self::IMG_HEIGHT, $height); - } - if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_RESOLUTION_UNIT)])) { - switch((int)$exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_RESOLUTION_UNIT)]) { - case 1: - case 3: - $resolution_cm = 1.0; - break; - case 2: - $resolution_cm = 2.54; - break; - } - } - else { - $resolution_cm = 2.54; - } - // Note: We use XResolution as the image resolution, ignoring YResolution - if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_XRESOLUTION)]) && - !$this->isSet(self::IMG_RESOLUTION)) { - $resolution = self::calcFrac($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_XRESOLUTION)]); - $this->setRW(self::IMG_RESOLUTION, $resolution); - } - if(isset($width) && isset($height)) { - $size = "$width x $height px"; - if($width > $height) $this->setRW(self::IMG_ORIENTATION, self::IMG_ORI_HORIZONTAL); - if($width < $height) $this->setRW(self::IMG_ORIENTATION, self::IMG_ORI_VERTICAL); - if($width === $height) $this->setRW(self::IMG_ORIENTATION, self::IMG_ORI_SQUARE); - if(isset($resolution) && isset($resolution_cm)) { - $size .= ' - '.number_format($resolution_cm * $width / $resolution, 2).' x '. - number_format($resolution_cm * $height / $resolution, 2).' cm ('. - number_format($this->get(self::FILE_SIZE) / 1024 / 1024, 0).' MB)'; - } - $this->setRW(self::IMG_SIZE_FORMATTED, $size); + + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_FNUMBER)])) { + $aperture = self::calcFrac($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_FNUMBER)]); + if(!$this->isSet(self::IMG_APERTURE)) $this->setRW(self::IMG_APERTURE, $aperture); + if(!$this->isSet(self::IMG_APERTURE_FMT)) $this->setRW(self::IMG_APERTURE_FMT, 'f/'.number_format($aperture, 1)); } + if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_CAMERA_MAKE)]) && !$this->isSet(self::IMG_CAMERA_MAKE)) + $this->setRW(self::IMG_CAMERA_MAKE, $exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_CAMERA_MAKE)]); + if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_CAMERA_MODEL)]) && + !$this->isSet(self::IMG_CAMERA_MODEL)) + $this->setRW(self::IMG_CAMERA_MODEL, $exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_CAMERA_MODEL)]); + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_CAMERA_SERIAL_NUMBER)]) && + !$this->isSet(self::IMG_CAMERA_SERIAL)) + $this->setRW(self::IMG_CAMERA_SERIAL, $exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_CAMERA_SERIAL_NUMBER)]); if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_COLOR_SPACE)]) && - !$this->isSet(self::IMG_COLOR_SPACE)) { + !$this->isSet(self::IMG_COLOR_SPACE_FMT)) { switch((int)$exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_COLOR_SPACE)]) { case 0x1: - $this->setRW(self::IMG_COLOR_SPACE, _('sRGB')); - break; + $this->setRW(self::IMG_COLOR_SPACE_FMT, _('sRGB')); break; case 0x2: - $this->setRW(self::IMG_COLOR_SPACE, _('Adove RGB')); - break; + $this->setRW(self::IMG_COLOR_SPACE_FMT, _('Adobe RGB')); break; case 0xfffd: - $this->setRW(self::IMG_COLOR_SPACE, _('Wide Gamut RGB')); - break; + $this->setRW(self::IMG_COLOR_SPACE_FMT, _('Wide Gamut RGB')); break; case 0xfffe: - $this->setRW(self::IMG_COLOR_SPACE, _('ICC Profile')); - break; + $this->setRW(self::IMG_COLOR_SPACE_FMT, _('ICC Profile')); break; case 0xfffe: - $this->setRW(self::IMG_COLOR_SPACE, _('Uncalibrated')); - break; + $this->setRW(self::IMG_COLOR_SPACE_FMT, _('Uncalibrated')); break; } } - if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_CAMERA_MAKE)]) && - !$this->isSet(self::IMG_CAMERA_MAKE)) - $this->setRW(self::IMG_CAMERA_MAKE, $exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_CAMERA_MAKE)]); - if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_CAMERA_MODEL)]) && - !$this->isSet(self::IMG_CAMERA_MODEL)) - $this->setRW(self::IMG_CAMERA_MODEL, $exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_CAMERA_MODEL)]); - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_CAMERA_SERIAL_NUMBER)]) && - !$this->isSet(self::IMG_CAMERA_SERIAL)) - $this->setRW(self::IMG_CAMERA_SERIAL, - $exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_CAMERA_SERIAL_NUMBER)]); - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_LENS_MAKE)]) && - !$this->isSet(self::IMG_LENS_MAKE)) - $this->setRW(self::IMG_LENS_MAKE, $exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_LENS_MAKE)]); - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_LENS_MODEL)]) && - !$this->isSet(self::IMG_LENS_MODEL)) - $this->setRW(self::IMG_LENS_MODEL, $exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_LENS_MODEL)]); - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_LENS_SERIAL_NUMBER)]) && - !$this->isSet(self::IMG_LENS_SERIAL)) - $this->setRW(self::IMG_LENS_SERIAL, - $exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_LENS_SERIAL_NUMBER)]); - if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_SOFTWARE)]) && - !$this->isSet(self::IMG_SOFTWARE)) - $this->setRW(self::IMG_SOFTWARE, $exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_SOFTWARE)]); - if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_PROCESSING_SOFTWARE)]) && - !$this->isSet(self::IMG_SOFTWARE)) - $this->setRW(self::IMG_SOFTWARE, $exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_PROCESSING_SOFTWARE)]); - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_FNUMBER)]) && - !$this->isSet(self::IMG_APERTURE)) { - $aperture = 'f/'. - number_format(self::calcFrac($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_FNUMBER)]), 1); - $this->setRW(self::IMG_APERTURE, $aperture); + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXPOSURE_TIME)])) { + $exposure = $exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXPOSURE_TIME)]; + if(!$this->isSet(self::IMG_EXPOSURE)) $this->setRW(self::IMG_EXPOSURE, self::fracToArray($exposure)); + if(!$this->isSet(self::IMG_EXPOSURE_FMT)) + $this->setRW(self::IMG_EXPOSURE_FMT, self::nrmFrac($exposure).' '._('second(s)')); } - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_APERTURE_VALUE)]) && - !$this->isSet(self::IMG_APERTURE)) { - $aperture = 'f/'. - number_format(self::calcFrac($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_APERTURE_VALUE)]), 1); - $this->setRW(self::IMG_APERTURE, $aperture); + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXPOSURE_MODE)]) && + !$this->isSet(self::IMG_EXPOSURE_MODE_FMT)) { + switch($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXPOSURE_MODE)]) { + case 0: + $this->setRW(self::IMG_EXPOSURE_MODE_FMT, _('Auto')); break; + case 1: + $this->setRW(self::IMG_EXPOSURE_MODE_FMT, _('Manual')); break; + case 2: + $this->setRW(self::IMG_EXPOSURE_MODE_FMT, _('Auto bracket')); break; + } } - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXPOSURE_TIME)]) && - !$this->isSet(self::IMG_EXPOSURE)) - $this->setRW(self::IMG_EXPOSURE, - self::calcExposure($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXPOSURE_TIME)]). - ' '._('second(s)')); if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXPOSURE_PROGRAM)]) && - !$this->isSet(self::IMG_EXPOSURE_PROGRAM)) { + !$this->isSet(self::IMG_EXPOSURE_PGM_FMT)) { switch($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXPOSURE_PROGRAM)]) { case 0: - $this->setRW(self::IMG_EXPOSURE_PROGRAM, _('Not defined')); - break; + $this->setRW(self::IMG_EXPOSURE_PGM_FMT, _('Not defined')); break; case 1: - $this->setRW(self::IMG_EXPOSURE_PROGRAM, _('Manual')); - break; + $this->setRW(self::IMG_EXPOSURE_PGM_FMT, _('Manual')); break; case 2: - $this->setRW(self::IMG_EXPOSURE_PROGRAM, _('Program AE')); - break; + $this->setRW(self::IMG_EXPOSURE_PGM_FMT, _('Program')); break; case 3: - $this->setRW(self::IMG_EXPOSURE_PROGRAM, _('Aperture-priority AE')); - break; + $this->setRW(self::IMG_EXPOSURE_PGM_FMT, _('Aperture-priority')); break; case 4: - $this->setRW(self::IMG_EXPOSURE_PROGRAM, _('Shutter speed priority AE')); - break; + $this->setRW(self::IMG_EXPOSURE_PGM_FMT, _('Shutter speed priority')); break; case 5: - $this->setRW(self::IMG_EXPOSURE_PROGRAM, _('Creative (Slow speed)')); - break; + $this->setRW(self::IMG_EXPOSURE_PGM_FMT, _('Creative (slow speed)')); break; case 6: - $this->setRW(self::IMG_EXPOSURE_PROGRAM, _('Action (High speed)')); - break; + $this->setRW(self::IMG_EXPOSURE_PGM_FMT, _('Action (high speed)')); break; case 7: - $this->setRW(self::IMG_EXPOSURE_PROGRAM, _('Portrait')); - break; + $this->setRW(self::IMG_EXPOSURE_PGM_FMT, _('Portrait')); break; case 8: - $this->setRW(self::IMG_EXPOSURE_PROGRAM, _('Landscape')); - break; + $this->setRW(self::IMG_EXPOSURE_PGM_FMT, _('Landscape')); break; case 9: - $this->setRW(self::IMG_EXPOSURE_PROGRAM, _('Bulb')); - break; + $this->setRW(self::IMG_EXPOSURE_PGM_FMT, _('Bulb')); break; } } - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXPOSURE_MODE)]) && - !$this->isSet(self::IMG_EXPOSURE_MODE)) { - switch($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXPOSURE_MODE)]) { - case 0: - $this->setRW(self::IMG_EXPOSURE_MODE, _('Auto')); - break; - case 1: - $this->setRW(self::IMG_EXPOSURE_MODE, _('Manual')); - break; - case 2: - $this->setRW(self::IMG_EXPOSURE_MODE, _('Auto bracket')); - break; + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_FLASH)])) { + $flash =$exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_FLASH)]; + if(!$this->isSet(self::IMG_FLASH)) $this->setRW(self::IMG_FLASH, (int)$flash); + if(!$this->isSet(self::IMG_FLASH_FMT)) + $this->setRW(self::IMG_FLASH_FMT, (int)$flash === 1 ? _('Flash') : _('No flash')); + } + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_FOCAL_LENGTH)])) { + $focal_length = (int)self::calcFrac($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_FOCAL_LENGTH)]); + if(!$this->isSet(self::IMG_FOCAL_LENGTH)) $this->setRW(self::IMG_FOCAL_LENGTH, $focal_length); + if(!$this->isSet(self::IMG_FOCAL_LENGTH_FMT)) + $this->setRW(self::IMG_FOCAL_LENGTH_FMT, $focal_length.' '._('mm')); + } + if(!$this->isSet(self::IMG_HEIGHT)) { + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXIF_IMAGE_HEIGHT)])) { + $height = (int)$exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXIF_IMAGE_HEIGHT)]; + if($height > 0 && $height < 32768) $this->setRW(self::IMG_HEIGHT, $height); else unset($height); } } + else { + $height = $this->get(self::IMG_HEIGHT); + } + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_ISO_SPEED)]) && !$this->isSet(self::IMG_ISO)) + $this->setRW(self::IMG_ISO, (int)$exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_ISO_SPEED)]); + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_PHOTO_SENSITIVITY)]) && !$this->isSet(self::IMG_ISO)) { + // This is not according to specifications, but typically works + $photo_sensitivity = (int)$exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_PHOTO_SENSITIVITY)]; + if($photo_sensitivity < 65536) $this->setRW(self::IMG_ISO, $photo_sensitivity); + } + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_LENS_MAKE)]) && + !$this->isSet(self::IMG_LENS_MAKE)) + $this->setRW(self::IMG_LENS_MAKE, $exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_LENS_MAKE)]); + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_LENS_MODEL)]) && + !$this->isSet(self::IMG_LENS_MODEL)) + $this->setRW(self::IMG_LENS_MODEL, $exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_LENS_MODEL)]); + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_LENS_SERIAL_NUMBER)]) && + !$this->isSet(self::IMG_LENS_SERIAL)) + $this->setRW(self::IMG_LENS_SERIAL, $exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_LENS_SERIAL_NUMBER)]); if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_METERING_MODE)]) && - !$this->isSet(self::IMG_METERING_MODE)) { + !$this->isSet(self::IMG_METERING_MODE_FMT)) { switch($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_METERING_MODE)]) { case 1: - $this->setRW(self::IMG_METERING_MODE, _('Average')); - break; + $this->setRW(self::IMG_METERING_MODE_FMT, _('Average')); break; case 2: - $this->setRW(self::IMG_METERING_MODE, _('Center-weighted average')); - break; + $this->setRW(self::IMG_METERING_MODE_FMT, _('Center-weighted average')); break; case 3: - $this->setRW(self::IMG_METERING_MODE, _('Spot')); - break; + $this->setRW(self::IMG_METERING_MODE_FMT, _('Spot')); break; case 4: - $this->setRW(self::IMG_METERING_MODE, _('Multi-spot')); - break; + $this->setRW(self::IMG_METERING_MODE_FMT, _('Multi-spot')); break; case 5: - $this->setRW(self::IMG_METERING_MODE, _('Multi-segment')); - break; + $this->setRW(self::IMG_METERING_MODE_FMT, _('Multi-segment')); break; case 6: - $this->setRW(self::IMG_METERING_MODE, _('Partial')); - break; + $this->setRW(self::IMG_METERING_MODE_FMT, _('Partial')); break; case 255: - $this->setRW(self::IMG_METERING_MODE, _('Other')); - break; + $this->setRW(self::IMG_METERING_MODE_FMT, _('Other')); break; default: - $this->setRW(self::IMG_METERING_MODE, _('Unknown')); - break; + $this->setRW(self::IMG_METERING_MODE_FMT, _('Unknown')); break; } } - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_FOCAL_LENGTH)]) && - !$this->isSet(self::IMG_FOCAL_LENGTH)) - $this->setRW(self::IMG_FOCAL_LENGTH, - self::calcFrac($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_FOCAL_LENGTH)])); - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_FLASH)]) && !$this->isSet(self::IMG_FLASH)) - $this->setRW(self::IMG_FLASH, (int)$exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_FLASH)]); - if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_ISO_SPEED)]) && !$this->isSet(self::IMG_ISO)) - $this->setRW(self::IMG_ISO, (int)$exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_ISO_SPEED)]); + if(!$this->isSet(self::IMG_RESOLUTION_UNIT)) { + if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_RESOLUTION_UNIT)])) { + $resolution_unit = (int)$exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_RESOLUTION_UNIT)]; + $this->setRW(self::IMG_RESOLUTION_UNIT, $resolution_unit); + } + else { + $resolution_unit = 0; + } + } + else { + $resolution_unit = $this->get(self::IMG_RESOLUTION_UNIT); + } + switch($resolution_unit) { + case 1: + case 3: + $resolution_per_cm = 1.0; break; + default: + $resolution_per_cm = 2.54; break; + } + // Note: We give preference to XResolution ofer YResolution + if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_XRESOLUTION)]) && + !$this->isSet(self::IMG_RESOLUTION)) { + $resolution = (int)self::calcFrac($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_XRESOLUTION)]); + $this->setRW(self::IMG_RESOLUTION, $resolution); + $this->setRW(self::IMG_RESOLUTION_FMT, $resolution.' '.($resolution_per_cm == 1 ? _('dpcm') : _('dpi'))); + } + if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_YRESOLUTION)]) && + !$this->isSet(self::IMG_RESOLUTION)) { + $resolution = (int)self::calcFrac($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_YRESOLUTION)]); + $this->setRW(self::IMG_RESOLUTION, $resolution); + } + if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_SOFTWARE)]) && !$this->isSet(self::IMG_SOFTWARE)) + $this->setRW(self::IMG_SOFTWARE, $exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_SOFTWARE)]); + if(isset($exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_PROCESSING_SOFTWARE)]) && + !$this->isSet(self::IMG_SOFTWARE)) + $this->setRW(self::IMG_SOFTWARE, $exif_data[Exif::tag(Exif::IFD_IFD0, Exif::TAG_IFD0_PROCESSING_SOFTWARE)]); + $this->setRW(self::IMG_TYPE, IMG_JPG); + $this->setRW(self::IMG_TYPE_FMT, 'jpeg'); + if(!$this->isSet(self::IMG_WIDTH)) { + if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXIF_IMAGE_WIDTH)])) { + $width = (int)$exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_EXIF_IMAGE_WIDTH)]; + if($width > 0 && $width < 32768) $this->setRW(self::IMG_WIDTH, $height); else unset($width); + } + } + else { + $width = $this->get(self::IMG_WIDTH); + } + if(isset($width) && isset($height)) { + if($width > $height) $this->setRW(self::IMG_ORIENTATION, self::IMG_ORI_HORIZONTAL); + elseif($width < $height) $this->setRW(self::IMG_ORIENTATION, self::IMG_ORI_VERTICAL); + else $this->setRW(self::IMG_ORIENTATION, self::IMG_ORI_SQUARE); + } + else { + $this->setRW(self::IMG_ORIENTATION, self::IMG_ORI_UNKNOWN); + } + if(isset($width) && isset($height)) { + $size_fmt = "$width x $height "._('px'); + if(isset($resolution)) { + $size_fmt .= ' - '.number_format($resolution_per_cm * $width / $resolution, 2).' x '. + number_format($resolution_per_cm * $height / $resolution, 2).' '._('cm'); + } + if($this->isSet(self::FILE_SIZE)) + $size_fmt .= ' ('.number_format($this->get(self::FILE_SIZE)/1024/1024, 0).' MB)'; + $this->setRW(self::IMG_SIZE_FMT, $size_fmt); + } if(isset($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_DATE_TIME_ORIGINAL)]) && !$this->isSet(self::CREATED_DATETIME)) @@ -1110,8 +1147,6 @@ protected function importExif(): void !$this->isSet(self::CREATED_DATETIME)) $this->setRW(self::CREATED_DATETIME, strtotime($exif_data[Exif::tag(Exif::IFD_EXIF, Exif::TAG_EXIF_CREATE_DATE)])); - - } /** diff --git a/src/Metadata/Exif.php b/src/Metadata/Exif.php index 5b0b36a..02c1a17 100644 --- a/src/Metadata/Exif.php +++ b/src/Metadata/Exif.php @@ -59,6 +59,8 @@ class Exif { const TAG_EXIF_FNUMBER = 0x829d; const TAG_EXIF_EXPOSURE_PROGRAM = 0x8822; const TAG_EXIF_ISO_SPEED = 0x8833; + const TAG_EXIF_PHOTO_SENSITIVITY = 0x8827; + const TAX_EXIF_SENSITIVITY_TYPE = 0x8830; const TAG_EXIF_DATE_TIME_ORIGINAL = 0x9003; const TAG_EXIF_CREATE_DATE = 0x9004; const TAG_EXIF_APERTURE_VALUE = 0x9202; diff --git a/src/Metadata/Jpeg.php b/src/Metadata/Jpeg.php index 09913ab..d30be05 100644 --- a/src/Metadata/Jpeg.php +++ b/src/Metadata/Jpeg.php @@ -22,6 +22,7 @@ class Jpeg { private XmpDocument|false $xmp_data; // Read XMP data private array|false $exif_data; // Read EXIF data private bool $data_read; // Flag if data has been read + private bool $read_only; // Data read is read only (writing is disabled) /** * Constructor @@ -30,7 +31,7 @@ public function __construct() { $this->filename = false; $this->header = array(); $this->img = ''; $this->iptc_data = false; $this->xmp_data = false; $this->exif_data = false; - $this->data_read = false; + $this->data_read = false; $this->read_only = true; } /** @@ -41,15 +42,18 @@ public function __destruct() } /** - * Read all data (image and metadata) from a JPG file + * Read all data (image and metadata) from a JPG file: IF read-only is set, the image data is not read and the data + * cannot be written back. * - * @param string $filename JPG filename + * @param string $filename JPG filename + * @param bool $readonly Allow only reading data * @throw Metadata\Exception */ - public function read(string $filename): void + public function read(string $filename, bool $read_only = false): void { // Initrialize all variables self::__construct(); + $this->read_only = $read_only; // Open image file for reading $handle = fopen($filename, 'rb'); @@ -87,22 +91,24 @@ public function read(string $filename): void // Check if the segment was the last one if($data[1] === "\xDA") { $hit_img_data = true; - - // Read image data + $this->img = ''; - do { - $this->img .= $this->dataRead($handle, 1048576); + if(!$this->read_only) { + // Read image data + do { + $this->img .= $this->dataRead($handle, 1048576); + } + while(!feof($handle)); + + // Stripp of EOI and anything thereafter + $eoi_pos = strpos($this->img, "\xFF\xD9"); + if($eoi_pos === false) { + fclose($handle); + throw new Exception(_('Image data seems to be corrupt'), Exception::FILE_CORRUPT, $filename); + } + + $this->img = substr($this->img, 0, $eoi_pos); } - while(!feof($handle)); - - // Stripp of EOI and anything thereafter - $eoi_pos = strpos($this->img, "\xFF\xD9"); - if($eoi_pos === false) { - fclose($handle); - throw new Exception(_('Image data seems to be corrupt'), Exception::FILE_CORRUPT, $filename); - } - - $this->img = substr($this->img, 0, $eoi_pos); } else { $data = $this->dataRead($handle, 2); @@ -136,7 +142,10 @@ public function read(string $filename): void */ public function write(string $filename): void { - if(!$this->data_read) throw new Exception(_('No image and metadata read'), Exception::DATA_NOT_FOUND); + if(!$this->data_read) + throw new Exception(_('No image and metadata read'), Exception::DATA_NOT_FOUND); + if($this->read_only) + throw new Exception(_('Cannot write file because data was read in read-only mode'), Exception::DATA_NOT_FOUND); // Check that headers are not too large foreach($this->header as $segment) { diff --git a/src/Metadata/XmpDocument.php b/src/Metadata/XmpDocument.php index 3147995..9ae4c29 100644 --- a/src/Metadata/XmpDocument.php +++ b/src/Metadata/XmpDocument.php @@ -705,6 +705,5 @@ protected function validateXmpDocument(array $ns_ary): void $status = $this->dom->loadXML($this->dom->saveXML()); if($status === false) throw new Exception(_('Internal error during XML re-validation'), Exception::INTERNAL_ERROR); - echo str_replace("><", ">\n<", $this->dom->saveXML()); } }