314208edce57d9d415110db09f5d8fbfa6ff5f58
[lhc/web/wiklou.git] / includes / media / PNGMetadataExtractor.php
1 <?php
2 /**
3 * PNG frame counter and metadata extractor.
4 * Slightly derived from GIFMetadataExtractor.php
5 * Deliberately not using MWExceptions to avoid external dependencies, encouraging
6 * redistribution.
7 *
8 * @file
9 * @ingroup Media
10 */
11
12 /**
13 * PNG frame counter.
14 *
15 * @ingroup Media
16 */
17 class PNGMetadataExtractor {
18 static $png_sig;
19 static $CRC_size;
20 static $text_chunks;
21
22 const VERSION = 1;
23 const MAX_CHUNK_SIZE = 3145728; // 3 megabytes
24
25 static function getMetadata( $filename ) {
26 self::$png_sig = pack( "C8", 137, 80, 78, 71, 13, 10, 26, 10 );
27 self::$CRC_size = 4;
28 /* based on list at http://owl.phy.queensu.ca/~phil/exiftool/TagNames/PNG.html#TextualData
29 * and http://www.w3.org/TR/PNG/#11keywords
30 */
31 self::$text_chunks = array(
32 'xml:com.adobe.xmp' => 'xmp',
33 # Artist is unofficial. Author is the recommended
34 # keyword in the PNG spec. However some people output
35 # Artist so support both.
36 'artist' => 'Artist',
37 'model' => 'Model',
38 'make' => 'Make',
39 'author' => 'Artist',
40 'comment' => 'PNGFileComment',
41 'description' => 'ImageDescription',
42 'title' => 'ObjectName',
43 'copyright' => 'Copyright',
44 # Source as in original device used to make image
45 # not as in who gave you the image
46 'source' => 'Model',
47 'software' => 'Software',
48 'disclaimer' => 'Disclaimer',
49 'warning' => 'ContentWarning',
50 'url' => 'Identifier', # Not sure if this is best mapping. Maybe WebStatement.
51 'label' => 'Label',
52 'creation time' => 'DateTimeDigitized',
53 /* Other potentially useful things - Document */
54 );
55
56 $frameCount = 0;
57 $loopCount = 1;
58 $text = array();
59 $bitDepth = 0;
60 $colorType = 'unknown';
61
62 if ( !$filename ) {
63 throw new Exception( __METHOD__ . ": No file name specified" );
64 } elseif ( !file_exists( $filename ) || is_dir( $filename ) ) {
65 throw new Exception( __METHOD__ . ": File $filename does not exist" );
66 }
67
68 $fh = fopen( $filename, 'r' );
69
70 if ( !$fh ) {
71 throw new Exception( __METHOD__ . ": Unable to open file $filename" );
72 }
73
74 // Check for the PNG header
75 $buf = fread( $fh, 8 );
76 if ( $buf != self::$png_sig ) {
77 throw new Exception( __METHOD__ . ": Not a valid PNG file; header: $buf" );
78 }
79
80 // Read chunks
81 while ( !feof( $fh ) ) {
82 $buf = fread( $fh, 4 );
83 if ( !$buf ) {
84 throw new Exception( __METHOD__ . ": Read error" );
85 }
86 $chunk_size = unpack( "N", $buf );
87 $chunk_size = $chunk_size[1];
88
89 $chunk_type = fread( $fh, 4 );
90 if ( !$chunk_type ) {
91 throw new Exception( __METHOD__ . ": Read error" );
92 }
93
94 if ( $chunk_type == "IHDR" ) {
95 $buf = self::read( $fh, $chunk_size );
96 if ( !$buf ) {
97 throw new Exception( __METHOD__ . ": Read error" );
98 }
99 $bitDepth = ord( substr( $buf, 8, 1 ) );
100 // Detect the color type in British English as per the spec
101 // http://www.w3.org/TR/PNG/#11IHDR
102 switch ( ord( substr( $buf, 9, 1 ) ) ) {
103 case 0:
104 $colorType = 'greyscale';
105 break;
106 case 2:
107 $colorType = 'truecolour';
108 break;
109 case 3:
110 $colorType = 'index-coloured';
111 break;
112 case 4:
113 $colorType = 'greyscale-alpha';
114 break;
115 case 6:
116 $colorType = 'truecolour-alpha';
117 break;
118 default:
119 $colorType = 'unknown';
120 break;
121 }
122 } elseif ( $chunk_type == "acTL" ) {
123 $buf = fread( $fh, $chunk_size );
124 if( !$buf ) {
125 throw new Exception( __METHOD__ . ": Read error" );
126 }
127
128 $actl = unpack( "Nframes/Nplays", $buf );
129 $frameCount = $actl['frames'];
130 $loopCount = $actl['plays'];
131 } elseif ( $chunk_type == "fcTL" ) {
132 $buf = self::read( $fh, $chunk_size );
133 if ( !$buf ) {
134 throw new Exception( __METHOD__ . ": Read error" );
135 }
136 $buf = substr( $buf, 20 );
137
138 $fctldur = unpack( "ndelay_num/ndelay_den", $buf );
139 if ( $fctldur['delay_den'] == 0 ) {
140 $fctldur['delay_den'] = 100;
141 }
142 if ( $fctldur['delay_num'] ) {
143 $duration += $fctldur['delay_num'] / $fctldur['delay_den'];
144 }
145 } elseif ( $chunk_type == "iTXt" ) {
146 // Extracts iTXt chunks, uncompressing if necessary.
147 $buf = self::read( $fh, $chunk_size );
148 $items = array();
149 if ( preg_match(
150 '/^([^\x00]{1,79})\x00(\x00|\x01)\x00([^\x00]*)(.)[^\x00]*\x00(.*)$/Ds',
151 $buf, $items )
152 ) {
153 /* $items[1] = text chunk name, $items[2] = compressed flag,
154 * $items[3] = lang code (or ""), $items[4]= compression type.
155 * $items[5] = content
156 */
157
158 // Theoretically should be case-sensitive, but in practise...
159 $items[1] = strtolower( $items[1] );
160 if ( !isset( self::$text_chunks[$items[1]] ) ) {
161 // Only extract textual chunks on our list.
162 fseek( $fh, self::$CRC_size, SEEK_CUR );
163 continue;
164 }
165
166 $items[3] = strtolower( $items[3] );
167 if ( $items[3] == '' ) {
168 // if no lang specified use x-default like in xmp.
169 $items[3] = 'x-default';
170 }
171
172 // if compressed
173 if ( $items[2] == "\x01" ) {
174 if ( function_exists( 'gzuncompress' ) && $items[4] === "\x00" ) {
175 wfSuppressWarnings();
176 $items[5] = gzuncompress( $items[5] );
177 wfRestoreWarnings();
178
179 if ( $items[5] === false ) {
180 // decompression failed
181 wfDebug( __METHOD__ . ' Error decompressing iTxt chunk - ' . $items[1] );
182 fseek( $fh, self::$CRC_size, SEEK_CUR );
183 continue;
184 }
185
186 } else {
187 wfDebug( __METHOD__ . ' Skipping compressed png iTXt chunk due to lack of zlib,'
188 . ' or potentially invalid compression method' );
189 fseek( $fh, self::$CRC_size, SEEK_CUR );
190 continue;
191 }
192 }
193 $finalKeyword = self::$text_chunks[ $items[1] ];
194 $text[ $finalKeyword ][ $items[3] ] = $items[5];
195 $text[ $finalKeyword ]['_type'] = 'lang';
196
197 } else {
198 // Error reading iTXt chunk
199 throw new Exception( __METHOD__ . ": Read error on iTXt chunk" );
200 }
201
202 } elseif ( $chunk_type == 'tEXt' ) {
203 $buf = self::read( $fh, $chunk_size );
204
205 list( $keyword, $content ) = explode( "\x00", $buf, 2 );
206 if ( $keyword === '' || $content === '' ) {
207 throw new Exception( __METHOD__ . ": Read error on tEXt chunk" );
208 }
209
210 // Theoretically should be case-sensitive, but in practise...
211 $keyword = strtolower( $keyword );
212 if ( !isset( self::$text_chunks[ $keyword ] ) ) {
213 // Don't recognize chunk, so skip.
214 fseek( $fh, self::$CRC_size, SEEK_CUR );
215 continue;
216 }
217 wfSuppressWarnings();
218 $content = iconv( 'ISO-8859-1', 'UTF-8', $content );
219 wfRestoreWarnings();
220
221 if ( $content === false ) {
222 throw new Exception( __METHOD__ . ": Read error (error with iconv)" );
223 }
224
225 $finalKeyword = self::$text_chunks[ $keyword ];
226 $text[ $finalKeyword ][ 'x-default' ] = $content;
227 $text[ $finalKeyword ]['_type'] = 'lang';
228
229 } elseif ( $chunk_type == 'zTXt' ) {
230 if ( function_exists( 'gzuncompress' ) ) {
231 $buf = self::read( $fh, $chunk_size );
232
233 list( $keyword, $postKeyword ) = explode( "\x00", $buf, 2 );
234 if ( $keyword === '' || $postKeyword === '' ) {
235 throw new Exception( __METHOD__ . ": Read error on zTXt chunk" );
236 }
237 // Theoretically should be case-sensitive, but in practise...
238 $keyword = strtolower( $keyword );
239
240 if ( !isset( self::$text_chunks[ $keyword ] ) ) {
241 // Don't recognize chunk, so skip.
242 fseek( $fh, self::$CRC_size, SEEK_CUR );
243 continue;
244 }
245 $compression = substr( $postKeyword, 0, 1 );
246 $content = substr( $postKeyword, 1 );
247 if ( $compression !== "\x00" ) {
248 wfDebug( __METHOD__ . " Unrecognized compression method in zTXt ($keyword). Skipping." );
249 fseek( $fh, self::$CRC_size, SEEK_CUR );
250 continue;
251 }
252
253 wfSuppressWarnings();
254 $content = gzuncompress( $content );
255 wfRestoreWarnings();
256
257 if ( $content === false ) {
258 // decompression failed
259 wfDebug( __METHOD__ . ' Error decompressing zTXt chunk - ' . $keyword );
260 fseek( $fh, self::$CRC_size, SEEK_CUR );
261 continue;
262 }
263
264 wfSuppressWarnings();
265 $content = iconv( 'ISO-8859-1', 'UTF-8', $content );
266 wfRestoreWarnings();
267
268 if ( $content === false ) {
269 throw new Exception( __METHOD__ . ": Read error (error with iconv)" );
270 }
271
272 $finalKeyword = self::$text_chunks[ $keyword ];
273 $text[ $finalKeyword ][ 'x-default' ] = $content;
274 $text[ $finalKeyword ]['_type'] = 'lang';
275
276 } else {
277 wfDebug( __METHOD__ . " Cannot decompress zTXt chunk due to lack of zlib. Skipping." );
278 fseek( $fh, $chunk_size, SEEK_CUR );
279 }
280 } elseif ( $chunk_type == 'tIME' ) {
281 // last mod timestamp.
282 if ( $chunk_size !== 7 ) {
283 throw new Exception( __METHOD__ . ": tIME wrong size" );
284 }
285 $buf = self::read( $fh, $chunk_size );
286 if ( !$buf ) {
287 throw new Exception( __METHOD__ . ": Read error" );
288 }
289
290 // Note: spec says this should be UTC.
291 $t = unpack( "ny/Cm/Cd/Ch/Cmin/Cs", $buf );
292 $strTime = sprintf( "%04d%02d%02d%02d%02d%02d",
293 $t['y'], $t['m'], $t['d'], $t['h'],
294 $t['min'], $t['s'] );
295
296 $exifTime = wfTimestamp( TS_EXIF, $strTime );
297
298 if ( $exifTime ) {
299 $text['DateTime'] = $exifTime;
300 }
301
302 } elseif ( $chunk_type == 'pHYs' ) {
303 // how big pixels are (dots per meter).
304 if ( $chunk_size !== 9 ) {
305 throw new Exception( __METHOD__ . ": pHYs wrong size" );
306 }
307
308 $buf = self::read( $fh, $chunk_size );
309 if ( !$buf ) {
310 throw new Exception( __METHOD__ . ": Read error" );
311 }
312
313 $dim = unpack( "Nwidth/Nheight/Cunit", $buf );
314 if ( $dim['unit'] == 1 ) {
315 // unit is meters
316 // (as opposed to 0 = undefined )
317 $text['XResolution'] = $dim['width']
318 . '/100';
319 $text['YResolution'] = $dim['height']
320 . '/100';
321 $text['ResolutionUnit'] = 3;
322 // 3 = dots per cm (from Exif).
323 }
324
325 } elseif ( $chunk_type == "IEND" ) {
326 break;
327 } else {
328 fseek( $fh, $chunk_size, SEEK_CUR );
329 }
330 fseek( $fh, self::$CRC_size, SEEK_CUR );
331 }
332 fclose( $fh );
333
334 if ( $loopCount > 1 ) {
335 $duration *= $loopCount;
336 }
337
338 if ( isset( $text['DateTimeDigitized'] ) ) {
339 // Convert date format from rfc2822 to exif.
340 foreach ( $text['DateTimeDigitized'] as $name => &$value ) {
341 if ( $name === '_type' ) {
342 continue;
343 }
344
345 // fixme: currently timezones are ignored.
346 // possibly should be wfTimestamp's
347 // responsibility. (at least for numeric TZ)
348 $formatted = wfTimestamp( TS_EXIF, $value );
349 if ( $formatted ) {
350 // Only change if we could convert the
351 // date.
352 // The png standard says it should be
353 // in rfc2822 format, but not required.
354 // In general for the exif stuff we
355 // prettify the date if we can, but we
356 // display as-is if we cannot or if
357 // it is invalid.
358 // So do the same here.
359
360 $value = $formatted;
361 }
362 }
363 }
364 return array(
365 'frameCount' => $frameCount,
366 'loopCount' => $loopCount,
367 'duration' => $duration,
368 'text' => $text,
369 'duration' => $duration,
370 'bitDepth' => $bitDepth,
371 'colorType' => $colorType,
372 );
373
374 }
375 /**
376 * Read a chunk, checking to make sure its not too big.
377 *
378 * @param $fh resource The file handle
379 * @param $size Integer size in bytes.
380 * @throws Exception if too big.
381 * @return String The chunk.
382 */
383 static private function read( $fh, $size ) {
384 if ( $size > self::MAX_CHUNK_SIZE ) {
385 throw new Exception( __METHOD__ . ': Chunk size of ' . $size .
386 ' too big. Max size is: ' . self::MAX_CHUNK_SIZE );
387 }
388 return fread( $fh, $size );
389 }
390 }