Search by

morntag / image-metadata

joshymerki

Dependency-free PHP library for reading and writing XMP and IPTC metadata in JPEG and PNG images

Package info

github.com/morntag/image-metadata

pkg:composer/morntag/image-metadata

Statistics

Installs: 19

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.2 2026-09-09 13:10 UTC

This package is auto-updated.

Last update: 2026-09-09 13:10:23 UTC


README

Latest Stable Version PHP Version

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.jpg walks 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 PNG iTXt chunks)

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 presentdata 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