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