morntag / image-metadata
Dependency-free PHP library for reading and writing XMP and IPTC metadata in JPEG and PNG images
Requires
- php: ^8.2
- ext-dom: *
- ext-libxml: *
- ext-mbstring: *
- ext-zlib: *
Requires (Dev)
- phpstan/phpstan: ^2
- phpunit/phpunit: ^11
- squizlabs/php_codesniffer: ^3.10
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Dependency-free PHP library for reading and writing XMP and IPTC metadata
in JPEG and PNG images. No exiftool, no shell-outs, no external
services — the container formats are parsed directly with native PHP.
Built to be embedded in a CMS. It performs no I/O the caller did not ask for, chooses no log destination, and localises nothing.
Installation
composer require morntag/image-metadata
Nothing else. The package has no Composer dependencies; the three ext-
requirements are extensions PHP ships with.
Status: 0.x. The API below is what the first consumer builds against and no change to it is planned, but the minor version may still move before 1.0. Pin
^0.1.17 fields - the list is under Fields below.
php example.php path/to/image.jpgwalks through the whole API on a copy of your image and checks itself as it goes - read it to learn the library, run it to prove it works on your server. It lives in the repository, not in the Composer dist; clone the repo to run it.
Requirements
- PHP 8.2+
ext-dom,ext-libxml(XMP is parsed as a DOM)ext-zlib(compressed PNGiTXtchunks)
iptcparse() is part of PHP's standard extension set and needs no
declaration.
What it handles
JPEG — XMP in APP1, IPTC in APP13. Every Photoshop Image Resource
Block the file already carried is preserved byte-for-byte: the embedded
thumbnail, print resolution, slices, print settings. The IPTC digest
(0x0425) is recomputed so it continues to describe the data actually
present.
PNG — XMP in iTXt chunks, compressed or not. All non-XMP chunks are
preserved. PNG has no IPTC.
How writes are made safe
A rewrite is staged in a temp file beside the target, then rename()d
over it. rename() is atomic at the filesystem level, so a reader always
sees a whole file - the old one or the new one, never a mixture - and the
original is never removed first. Keeping the temp in the target's own
directory guarantees the rename is a same-filesystem operation, which is
what makes it atomic; the original's permissions are copied across, since
the rename swaps in a different inode that would otherwise arrive with the
temp's mode.
One path is deliberately not atomic. When new JPEG metadata fits
exactly in the space the old metadata occupied, it is written in place -
a few KB instead of copying the whole file, which is what keeps bulk
processing viable. That write edits the original directly, so an
interruption can leave a half-updated segment and a concurrent reader can
observe one. The bytes involved are always inside a metadata segment and
never the image data, so the worst outcome is a corrupt metadata block in
an intact photograph, reported as write.partial.
Format limits you should know about
A JPEG segment length is an unsigned 16-bit field that counts itself, so no single segment can carry more than 65533 bytes. This is the format, not a library choice, and it cannot be raised.
Real Lightroom exports do exceed it. On the reference images, Camera Raw
develop settings (crs:) account for 87 % of the XMP packet. When a
packet will not fit, the library sheds namespaces it does not manage — see
Strip waves below — and if it still will not fit, the write is refused
and the file is left untouched. It is never truncated.
ExtendedXMP is not supported. XMP too large for one segment is
normally split across http://ns.adobe.com/xmp/extension/ segments. This
library cannot read those, and deletes any it finds along with the
xmpNote:HasExtendedXMP pointer that references them. A file arriving
with real extended data loses it. Every occurrence is reported.
EXIF is not implemented. Artist, ImageDescription and Copyright
do not round-trip. This is a deliberate decision, not an oversight: the
fields it would duplicate are already carried by XMP and IPTC.
Fields
The seventeen fields, and the only names the library speaks:
Title · Description · Headline · Keywords · AltText · Creator ·
CreatorAddress · CreatorCity · CreatorPostalCode · CreatorCountry ·
CreatorWorkEmail · CreatorWorkURL · DateCreated · CreateDate ·
City · Country · Copyright
One name per real-world field. read() returns them all, flat, every key
always present - scalars null and lists [] when absent. write()
accepts any subset; a field that is absent or null is left as it was.
src/FieldMap.php is the source of truth; the table below is generated
from it.
Names are PascalCase. The set is deliberately the seventeen the consuming plugin needs; Bridge's full IPTC Core panel would be one map entry each, when and if they are wanted.
| Field | Type | XMP | IPTC | Notes |
|---|---|---|---|---|
Title |
string | dc:title |
2#005 (max 64) |
|
Description |
string | dc:description |
2#120 (max 2000) |
|
Headline |
string | photoshop:Headline |
2#105 (max 256) |
|
Keywords |
list | dc:subject |
2#025 (max 64) |
|
AltText |
string | Iptc4xmpCore:AltTextAccessibility |
— | |
Creator |
list | dc:creator |
2#080 (max 32) |
|
CreatorAddress |
string | Iptc4xmpCore:CiAdrExtadr |
— | |
CreatorCity |
string | Iptc4xmpCore:CiAdrCity |
— | |
CreatorPostalCode |
string | Iptc4xmpCore:CiAdrPcode |
— | |
CreatorCountry |
string | Iptc4xmpCore:CiAdrCtry |
— | |
CreatorWorkEmail |
string | Iptc4xmpCore:CiEmailWork |
— | |
CreatorWorkURL |
string | Iptc4xmpCore:CiUrlWork |
— | |
DateCreated |
string | photoshop:DateCreated |
2#055 (max 8) |
ISO 8601 in XMP, CCYYMMDD in IPTC; converted both ways |
CreateDate |
string | xmp:CreateDate |
— | |
City |
string | photoshop:City |
2#090 (max 32) |
|
Country |
string | photoshop:Country |
2#101 (max 64) |
|
Copyright |
string | dc:rights |
2#116 (max 128) |
How the two sides are kept in step
On a JPEG, writing a field that has an IPTC column lands on both XMP
and IPTC in the same call. That is the whole point: before this layer a
caller had to write Description and Caption-Abstract separately, and
nothing checked they agreed - while the IPTC digest (0x0425) this library
recomputes told Bridge they did. On a PNG everything goes to XMP; PNG has
no IPTC.
On read, XMP is primary and IPTC fills gaps. Where XMP has no value
and IPTC does, the IPTC value is returned and field.fallback is raised
naming the field. Where both have a value and they differ, XMP wins and
field.diverged is raised with both values in context - a real
divergence means something edited the file outside this workflow, and it
should not be normalised away in silence.
Fields with no IPTC column - the creator contact block, AltText,
CreateDate - can never be filled from the fallback. A PNG has no
fallback at all.
Things IIM does that XMP does not
Byte caps. Every IPTC dataset has a maximum length; XMP has none. An
over-long value goes to XMP in full and to IPTC truncated on a UTF-8
character boundary, with one iptc.truncated warning per field naming how
many values were clipped. That matches what Photoshop does with an
over-long field.
Dates. 2#055 is CCYYMMDD and holds no time. DateCreated is
converted on the way in and out; a value that cannot be understood leaves
the IPTC side untouched and raises iptc.date_invalid.
Line endings. Photoshop writes \n into XMP and \r into IPTC for
the same caption. Canonical values use \n; the conversion is applied on
write and undone on read, so what came from which side is not visible.
Charset. An IPTC block built from nothing now carries 1#090
(ESC % G, declaring UTF-8) and 2#000 (record version), as Photoshop
writes them. Without the charset marker most readers assume Latin-1 and
every umlaut in a German caption comes back mangled.
Configuration
The library takes one optional argument: a sink to receive issues as they are raised. With no sink it is completely silent and reports everything through the returned envelope.
$manager = new MetadataManager(); // silent $manager = new MetadataManager( $mySink ); // also pushed to your logger
The sink is a one-method interface, deliberately not PSR-3, so the package carries no dependencies:
interface IssueSink { public function handle( array $issue ): void; }
Not configurable yet
Three behaviours are currently hardcoded. Each is a deliberate decision rather than an oversight, and each is a candidate for host configuration in a future version. They are recorded here so a consumer knows what it is accepting.
Strip waves
When an XMP packet exceeds the segment limit, namespaces are discarded in waves, one at a time, so no more is sacrificed than the overflow actually requires.
| Wave | Namespaces | Rationale |
|---|---|---|
| 1 | crs: |
Camera Raw develop settings. 271 properties and 87 % of the packet on the reference images — an editing recipe reproducible from the catalogue or the source raw. |
| 2 | xmpMM:, stEvt:, stRef: |
Edit history and provenance. Only reached if wave 1 was not enough. |
Never stripped: dc:, photoshop:, Iptc4xmpCore:, xmp: (the
library's own fields), plus aux: (camera and lens hardware — a factual
record of the exposure that no Photoshop file reproduces), lr:
(hierarchical keywords the flat dc:subject list cannot express) and
xmpRights:.
Future: the wave list becomes host-supplied. A workflow that values
crs: above provenance should be able to reorder them, or declare a
namespace unsacrificeable.
ExtendedXMP policy
Currently always drop: orphaned extension segments are deleted and the pointer removed.
Future: a choice between drop and refuse, so a host that would
rather fail loudly than lose extended data can say so. Reading ExtendedXMP
is a separate and much larger piece of work.
Maximum segment payload
Fixed at 65533 bytes.
This one deserves a caveat: it is a hard ceiling from the JPEG specification and can never be raised. If it ever becomes configurable, that means being able to lower it — to leave headroom for a reader known to be less tolerant than the format allows. Not the same kind of setting as the two above.
Error reporting
One channel. The library throws nothing of its own. Every condition it expects — a missing file, an unreadable one, a refused write, a stripped namespace — comes back in the return value.
That is deliberate. An uncaught exception inside a CMS hook is a fatal error; in WordPress it can kill an upload and get the calling plugin paused in recovery mode. A returned array cannot do that.
Genuinely unexpected failures — a bug in this library, out of memory — still propagate as ordinary PHP throwables. There is no catch-all swallowing them.
Plain arrays throughout; nothing in the payload is an object, so
json_encode() works natively and the result is safe to put in WordPress
post meta without the __PHP_Incomplete_Class risk that serialised
objects carry.
read() and write() return the same three keys, and all three are
always present — data and issues are empty arrays rather than absent
or null, so $result['data']['Title'] ?? null is always safe.
[
'success' => true, // bool
'data' => [ … ], // flat canonical fields; on write, the merged result
'issues' => [ … ], // list of issue arrays, empty when clean
]
Each issue:
[
'severity' => 'warning',
'code' => 'xmp.namespaces.stripped',
'message' => 'Stripped crs: to fit the 65533 byte JPEG segment limit.',
'context' => [ 'nodes_removed' => 271, 'bytes_saved' => 61044 ],
]
The code is the part that matters for a CMS: it lets the host render its own message, in its own language, through its own translation layer. The library ships no user-facing text and contains no translation calls.
$result = $manager->write( $path, $data ); if ( ! $result['success'] ) { foreach ( $result['issues'] as $issue ) { // $issue['code'] => 'xmp.oversize.refused' // $issue['context'] => ['bytes' => 70123, 'limit' => 65533] } }
data on write holds the merged result — the fields you supplied plus
everything already in the file that was left untouched — so you do not
need a second read to learn the resulting state. It reflects what was
serialised and written, not a re-read of the bytes on disk.
Severity values
| Value | Meaning |
|---|---|
error |
The operation did not happen. success is false. |
warning |
It happened, but something was lost or could not be trusted. |
info |
It happened; something changed that is worth knowing about. |
Every issue also carries context['path'], so a caller processing a batch
can always tell which file an issue belongs to.
Issue codes
Codes and severity values are bare strings, and they are public API — they stay stable across versions, because hosts switch on them. This table is the contract.
| Code | Severity | Meaning |
|---|---|---|
file.not_found |
error |
The path does not exist. |
file.not_readable |
error |
The file exists but could not be opened. |
format.unsupported |
error |
Not a JPEG or PNG — the signature matched neither. |
write.failed |
error |
The write failed before anything changed. The original is untouched. |
write.target_not_writable |
error |
The directory holding the file cannot be written to, so the file cannot be replaced. Nothing was attempted. |
write.partial |
error |
An in-place write failed midway. The file may hold a half-updated metadata segment; the image data itself is untouched. |
temp.cleanup_failed |
warning |
A temporary file was left behind and could not be removed. The image is unaffected. |
xmp.oversize.refused |
error |
XMP still over 65533 bytes after every strip wave. Nothing written. |
iptc.oversize.refused |
error |
IPTC payload over 65533 bytes. Nothing written. |
xmp.namespaces.stripped |
warning |
Namespaces discarded to make the packet fit. context names them. |
xmp.extended.dropped |
warning |
Orphaned ExtendedXMP segments deleted and the pointer removed. |
xmp.malformed |
warning |
XMP was present but unusable; the empty packet was substituted. context.reason is parse or doctype (XMP forbids a DOCTYPE, and one is where hostile entities would be declared). |
png.malformed |
warning on read, error on write |
The chunk chain is broken - truncated before IEND, a chunk length past the end of the file, or an XMP iTXt whose body does not parse. Read returns what came before the break; write refuses. context.reason and offset say where. |
jpeg.malformed |
warning on read, error on write |
The marker chain is broken - truncated before SOS, a non-FF byte where a marker should be, or a length below 2. Read returns what came before the break; write refuses and touches nothing. context.reason and offset say where. |
iptc.unparsable |
warning |
An APP13 block was present but iptcparse() could not read it. |
jpeg.segment.inserted |
info |
An APP1 or APP13 segment was added to a file that had none. |
field.unknown |
warning |
A key passed to write() is not a field this library knows. It was ignored. |
field.fallback |
info |
The field had no XMP value; the IPTC value was returned instead. |
field.diverged |
info |
XMP and IPTC hold different values; XMP was returned. Both are in context. |
iptc.truncated |
warning |
One or more values exceeded the IIM byte cap. XMP holds them in full; IPTC holds shortened copies. |
iptc.date_invalid |
warning |
DateCreated could not be converted to CCYYMMDD; the IPTC side was left unchanged. |
Development
composer install composer test # PHPUnit, 61 tests composer lint # PSR-12 on src/, tests/ and example.php composer analyse # PHPStan at the level in phpstan.neon
phpunit.xml turns any PHP warning, notice or deprecation raised inside
the library into a test failure. That is deliberate: several of the
parser-hardening fixes were warnings that had been landing inside HTTP
responses, and the suite must never let one back in silently.
Fixtures
tests/Fixtures/ holds four small images, ~70 KB in total, each carrying
the real metadata of a full-size source - Photoshop's resource blocks
and digest, Lightroom's develop settings, the IPTC charset marker - with
only the pixel data replaced. The library never reads pixels, so a 7 MB
photograph and its 45 KB fixture test exactly the same bytes.
| Fixture | Derived how | Exercises |
|---|---|---|
photoshop.jpg |
source JPEG, scan cut after the first restart marker | every JPEG path; strip waves via its crs: block |
bare.jpg |
the same with XMP and Photoshop segments removed | segment insertion into a file that has none |
photoshop.png |
source PNG around a 1x1 greyscale image | plain iTXt |
compressed.png |
the same with the iTXt zlib-compressed |
the decompression path |
They are rebuilt from two source images with
composer fixtures -- path/to/source.jpg path/to/source.png. The sources
are not in the repository. One test pins a keyword count to the size of
photoshop.jpg's XMP and says so in its comment; rebuilding from a
different source means re-measuring that number.
CI
.github/workflows/ci.yml runs the suite, the linter and example.php on
both fixture formats, on PHP 8.2, 8.3 and 8.4.
License
MIT