xref: /plugin/totext/Extractor/ImageExtractor.php (revision 6b6a7c2c4f3e5c6b7f0466abf36e5ee130b06f7a)
1<?php
2
3namespace dokuwiki\plugin\totext\Extractor;
4
5use dokuwiki\plugin\totext\Exception\ExtractionException;
6use JpegMeta;
7
8/**
9 * Extracts textual metadata (IPTC/EXIF) from images.
10 *
11 * This reads embedded metadata only — it is NOT optical character recognition.
12 * JPEG files are read through DokuWiki's core JpegMeta reader; TIFF files (and
13 * JPEG as a fallback) are read through the exif extension. If a file carries no
14 * textual metadata, an empty string is returned (it was readable, just empty).
15 */
16class ImageExtractor implements ExtractorInterface
17{
18    /**
19     * Ordered map of output label => candidate JpegMeta field names.
20     *
21     * Mirrors the field/lookup chains used by DokuWiki's media manager
22     * (see conf/mediameta.php); the first non-empty candidate wins.
23     *
24     * @var array<string, string[]>
25     */
26    protected const JPEG_FIELDS = [
27        'Title' => ['Iptc.Headline'],
28        'Caption' => ['Iptc.Caption', 'Exif.UserComment', 'Exif.TIFFImageDescription', 'Exif.TIFFUserComment'],
29        'Author' => ['Iptc.Byline', 'Exif.TIFFArtist', 'Exif.Artist', 'Iptc.Credit'],
30        'Copyright' => ['Iptc.CopyrightNotice', 'Exif.TIFFCopyright', 'Exif.Copyright'],
31        'Keywords' => ['Iptc.Keywords', 'Exif.Category'],
32        'Date' => ['Exif.DateTimeOriginal', 'Exif.DateTimeDigitized', 'Exif.DateTime'],
33        'Camera' => ['Simple.Camera'],
34    ];
35
36    /**
37     * Ordered map of output label => candidate EXIF tag names (for TIFF).
38     *
39     * PHP's exif extension decodes the Windows "XP" tags itself and exposes them
40     * in a WINXP section under the short names Title/Comment/Author/Keywords/
41     * Subject (already UTF-8). Those short names are listed first; the raw XP*
42     * names are kept as a fallback for exif builds that surface the undecoded
43     * tag instead (normaliseExifValue() then handles the UTF-16LE decode).
44     *
45     * @var array<string, string[]>
46     */
47    protected const EXIF_FIELDS = [
48        'Title' => ['Title', 'XPTitle'],
49        'Caption' => ['ImageDescription', 'UserComment', 'Comment', 'Subject', 'XPComment', 'XPSubject'],
50        'Author' => ['Artist', 'Author', 'XPAuthor'],
51        'Copyright' => ['Copyright'],
52        'Keywords' => ['Keywords', 'XPKeywords'],
53        'Date' => ['DateTimeOriginal', 'DateTime'],
54        'Camera' => ['Model'],
55    ];
56
57    /** @inheritDoc */
58    public function extract(string $path): string
59    {
60        if (!is_file($path)) {
61            throw new ExtractionException("File not found: $path");
62        }
63
64        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
65        if ($ext === 'jpg' || $ext === 'jpeg') {
66            $fields = $this->extractJpeg($path);
67        } else {
68            $fields = $this->extractExif($path);
69        }
70
71        $lines = [];
72        foreach ($fields as $label => $value) {
73            if ($value !== '') {
74                $lines[] = "$label: $value";
75            }
76        }
77        return implode("\n", $lines);
78    }
79
80    /**
81     * Read textual metadata from a JPEG via core JpegMeta.
82     *
83     * @param string $path absolute path to the JPEG
84     * @return array<string, string> label => value (empty values kept out by caller)
85     */
86    protected function extractJpeg(string $path): array
87    {
88        $meta = new JpegMeta($path);
89        $out = [];
90        foreach (self::JPEG_FIELDS as $label => $candidates) {
91            $value = $meta->getField($candidates);
92            $out[$label] = is_string($value) ? trim($value) : '';
93        }
94        return $out;
95    }
96
97    /**
98     * Read textual metadata from a TIFF (or JPEG fallback) via the exif extension.
99     *
100     * @param string $path absolute path to the image
101     * @return array<string, string> label => value
102     * @throws ExtractionException if the exif extension is unavailable
103     */
104    protected function extractExif(string $path): array
105    {
106        if (!function_exists('exif_read_data')) {
107            throw new ExtractionException('TIFF metadata support requires the PHP exif extension');
108        }
109
110        $data = @exif_read_data($path, '', true);
111        if ($data === false) {
112            return [];
113        }
114        // flatten the section => tag => value structure into a single tag map
115        $flat = [];
116        foreach ($data as $section) {
117            if (is_array($section)) {
118                foreach ($section as $tag => $value) {
119                    $flat[$tag] = $value;
120                }
121            }
122        }
123
124        $out = [];
125        foreach (self::EXIF_FIELDS as $label => $candidates) {
126            $value = '';
127            foreach ($candidates as $tag) {
128                if (isset($flat[$tag]) && $flat[$tag] !== '') {
129                    $value = $this->normaliseExifValue($tag, $flat[$tag]);
130                    if ($value !== '') {
131                        break;
132                    }
133                }
134            }
135            $out[$label] = $value;
136        }
137        return $out;
138    }
139
140    /**
141     * Normalise a raw EXIF value to a trimmed UTF-8 string.
142     *
143     * The Windows "XP" tags are stored as UTF-16LE byte strings and need
144     * decoding; all other textual tags are returned as-is.
145     *
146     * @param string $tag the EXIF tag name
147     * @param mixed $value the raw value
148     * @return string
149     */
150    protected function normaliseExifValue(string $tag, $value): string
151    {
152        if (is_array($value)) {
153            $value = implode(', ', array_filter(array_map('strval', $value), fn($v) => $v !== ''));
154        }
155        $value = (string) $value;
156
157        if (str_starts_with($tag, 'XP') && function_exists('mb_convert_encoding')) {
158            // strip trailing NUL bytes, then decode from UTF-16LE
159            $decoded = mb_convert_encoding(rtrim($value, "\0"), 'UTF-8', 'UTF-16LE');
160            if (is_string($decoded)) {
161                $value = $decoded;
162            }
163        }
164
165        return trim($value);
166    }
167}
168