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