Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.71% covered (warning)
85.71%
30 / 35
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
XmpWriter
85.71% covered (warning)
85.71%
30 / 35
50.00% covered (danger)
50.00%
1 / 2
11.35
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 serialize
85.29% covered (warning)
85.29%
29 / 34
0.00% covered (danger)
0.00%
0 / 1
10.32
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Xmp;
6
7/**
8 * Serialize an {@see XmpPacket} into an XMP XML packet with `<?xpacket?>` wrapping.
9 *
10 * Handles namespace registration and RDF/Description structure.
11 * The output includes trailing padding per the XMP spec to allow
12 * in-place updates without rewriting the file.
13 */
14final class XmpWriter
15{
16    private const DEFAULT_NAMESPACES = [
17        'dc'   => 'http://purl.org/dc/elements/1.1/',
18        'xmp'  => 'http://ns.adobe.com/xap/1.0/',
19        'pdf'  => 'http://ns.adobe.com/pdf/1.3/',
20        'xmpMM' => 'http://ns.adobe.com/xap/1.0/mm/',
21        'stEvt' => 'http://ns.adobe.com/xap/1.0/sType/ResourceEvent#',
22    ];
23
24    /** @var array<string, string> */
25    private array $namespaces;
26
27    /** @param array<string, string> $additionalNamespaces prefix => URI */
28    public function __construct(array $additionalNamespaces = [])
29    {
30        $this->namespaces = array_merge(self::DEFAULT_NAMESPACES, $additionalNamespaces);
31    }
32
33    public function serialize(XmpPacket $packet): string
34    {
35        $properties = $packet->all();
36
37        // Group properties by namespace prefix
38        $grouped = [];
39        $usedPrefixes = [];
40        foreach ($properties as $key => $value) {
41            if (str_contains($key, ':')) {
42                [$prefix, $localName] = explode(':', $key, 2);
43                $grouped[$prefix][$localName] = $value;
44                $usedPrefixes[$prefix] = true;
45            } else {
46                $grouped['_unqualified'][$key] = $value;
47            }
48        }
49
50        // Build namespace declarations for used prefixes
51        $nsDecls = '';
52        foreach ($this->namespaces as $prefix => $uri) {
53            if (isset($usedPrefixes[$prefix])) {
54                $nsDecls .= "\n      xmlns:{$prefix}=\"" . htmlspecialchars($uri, ENT_XML1) . '"';
55            }
56        }
57
58        // Build property elements
59        $propsXml = '';
60        foreach ($grouped as $prefix => $props) {
61            if ($prefix === '_unqualified') {
62                continue;
63            }
64            foreach ($props as $localName => $value) {
65                $escaped = htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8');
66                $propsXml .= "      <{$prefix}:{$localName}>{$escaped}</{$prefix}:{$localName}>\n";
67            }
68        }
69        // Unqualified properties
70        if (isset($grouped['_unqualified'])) {
71            foreach ($grouped['_unqualified'] as $key => $value) {
72                $escaped = htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8');
73                $propsXml .= "      <{$key}>{$escaped}</{$key}>\n";
74            }
75        }
76
77        $bom = "\xEF\xBB\xBF"; // UTF-8 BOM (the  character)
78
79        return '<?xpacket begin="' . $bom . '" id="W5M0MpCehiHzreSzNTczkc9d"?>' . "\n"
80            . '<x:xmpmeta xmlns:x="adobe:ns:meta/">' . "\n"
81            . '  <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' . "\n"
82            . '    <rdf:Description rdf:about=""' . $nsDecls . ">\n"
83            . $propsXml
84            . "    </rdf:Description>\n"
85            . "  </rdf:RDF>\n"
86            . "</x:xmpmeta>\n"
87            . '<?xpacket end="w"?>';
88    }
89}