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