Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 203
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
PagedTiffImage
0.00% covered (danger)
0.00%
0 / 203
0.00% covered (danger)
0.00%
0 / 9
5402
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isValid
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getImageSize
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getPageSize
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 resetMetaData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 retrieveMetaData
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
210
 parseTiffinfoOutput
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
702
 parseExiv2Output
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 parseIdentifyOutput
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
600
1<?php
2/**
3 * Copyright © Wikimedia Deutschland, 2009
4 * Authors Hallo Welt! Medienwerkstatt GmbH
5 * Authors Sebastian Ulbricht, Daniel Lynge, Marc Reymann, Markus Glaser
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * http://www.gnu.org/copyleft/gpl.html
21 */
22
23namespace MediaWiki\Extension\PagedTiffHandler;
24
25use BitmapMetadataHandler;
26use ExifBitmapHandler;
27use InvalidTiffException;
28use MediaWiki\Config\Config;
29use MediaWiki\MainConfigNames;
30use MediaWiki\Shell\CommandFactory;
31use Wikimedia\Stats\StatsFactory;
32
33/**
34 * inspired by djvuimage from Brion Vibber
35 * modified and written by xarax
36 * adapted to tiff by Hallo Welt! - Medienwerkstatt GmbH
37 */
38class PagedTiffImage {
39    private ?array $metadata = null;
40
41    public function __construct(
42        private readonly CommandFactory $commandFactory,
43        private readonly Config $config,
44        private readonly StatsFactory $statsFactory,
45        private readonly string $filename,
46    ) {
47    }
48
49    /**
50     * Called by MimeMagick functions.
51     * @return int
52     */
53    public function isValid() {
54        return count( $this->retrieveMetaData() );
55    }
56
57    /**
58     * Returns an array that corresponds to the native PHP function getimagesize().
59     * @return array|false
60     */
61    public function getImageSize() {
62        $data = $this->retrieveMetaData();
63        $size = self::getPageSize( $data, 1 );
64
65        if ( $size ) {
66            $width = $size['width'];
67            $height = $size['height'];
68            return [ $width, $height, 'Tiff',
69            "width=\"$width\" height=\"$height\"" ];
70        }
71        return false;
72    }
73
74    /**
75     * Returns an array with width and height of the tiff page.
76     * @param array $data
77     * @param int $page
78     * @return array|false
79     */
80    public static function getPageSize( $data, $page ) {
81        if ( isset( $data['page_data'][$page] ) ) {
82            return [
83                'width'  => intval( $data['page_data'][$page]['width'] ),
84                'height' => intval( $data['page_data'][$page]['height'] )
85            ];
86        }
87        return false;
88    }
89
90    public function resetMetaData() {
91        $this->metadata = null;
92    }
93
94    /**
95     * Reads metadata of the tiff file via shell command and returns an associative array.
96     * @return array Associative array. Layout:
97     * meta['page_count'] = number of pages
98     * meta['first_page'] = number of first page
99     * meta['last_page'] = number of last page
100     * meta['page_data'] = metadata per page
101     * meta['exif']  = Exif, XMP and IPTC
102     * meta['errors'] = identify-errors
103     * meta['warnings'] = identify-warnings
104     */
105    public function retrieveMetaData() {
106        if ( $this->metadata !== null ) {
107            return $this->metadata;
108        }
109
110        $useTiffinfo = $this->config->get( 'TiffUseTiffinfo' );
111        $useExiv = $this->config->get( 'TiffUseExiv' );
112        $command = $this->commandFactory
113            ->createBoxed( 'pagedtiffhandler' )
114            ->disableNetwork()
115            ->firejailDefaultSeccomp()
116            ->routeName( 'pagedtiffhandler-metadata' );
117        $command
118            ->params( $this->config->get( MainConfigNames::ShellboxShell ), 'scripts/retrieveMetaData.sh' )
119            ->inputFileFromFile(
120                'scripts/retrieveMetaData.sh',
121                __DIR__ . '/../scripts/retrieveMetaData.sh' )
122            ->inputFileFromFile( 'file.tiff', $this->filename )
123            ->environment( [
124                'TIFF_USETIFFINFO' => $useTiffinfo ? 'yes' : 'no',
125                'TIFF_TIFFINFO' => $this->config->get( 'TiffTiffinfoCommand' ),
126                'TIFF_IDENTIFY' => $this->config->get( 'ImageMagickIdentifyCommand' ),
127                'TIFF_USEEXIV' => $useExiv ? 'yes' : 'no',
128                'TIFF_EXIV2' => $this->config->get( MainConfigNames::Exiv2Command ),
129            ] );
130        if ( $useTiffinfo ) {
131            $command->outputFileToString( 'info' );
132        } else {
133            $command->outputFileToString( 'identified' );
134        }
135        if ( $useExiv ) {
136            $command
137                ->outputFileToString( 'extended' )
138                ->outputFileToString( 'exiv_exit_code' );
139        }
140
141        $result = $command->execute();
142        // Record in statsd
143        $this->statsFactory->getCounter( 'pagedtiffhandler_shell_retrievemetadata_total' )
144            ->copyToStatsdAt( 'pagedtiffhandler.shell.retrieve_meta_data' )
145            ->increment();
146
147        $overallExit = $result->getExitCode();
148        if ( $overallExit == 10 ) {
149            // tiffinfo failure
150            wfDebug( __METHOD__ . ": tiffinfo command failed: {$this->filename}" );
151            return [ 'errors' => [ "tiffinfo command failed: {$this->filename}" ] ];
152        } elseif ( $overallExit == 11 ) {
153            // identify failure
154            wfDebug( __METHOD__ . ": identify command failed: {$this->filename}" );
155            return [ 'errors' => [ "identify command failed: {$this->filename}" ] ];
156        }
157
158        if ( $useTiffinfo ) {
159            $this->metadata = $this->parseTiffinfoOutput( $result->getFileContents( 'info' ) );
160        } else {
161            $this->metadata = $this->parseIdentifyOutput( $result->getFileContents( 'identified' ) );
162        }
163
164        $this->metadata['exif'] = [];
165
166        if ( !empty( $this->metadata['errors'] ) ) {
167            wfDebug( __METHOD__ . ": found errors, skipping EXIF extraction" );
168        } elseif ( $useExiv ) {
169            $exivExit = (int)trim( $result->getFileContents( 'exiv_exit_code' ) );
170            if ( $exivExit != 0 ) {
171                // FIXME: $data is immediately overwritten?
172                $data = [ 'errors' => [ "exiv command failed: {$this->filename}" ] ];
173                wfDebug( __METHOD__ . ": exiv command failed: {$this->filename}" );
174                // don't fail - we are missing info, just report
175            }
176
177            $data = $this->parseExiv2Output( $result->getFileContents( 'extended' ) );
178
179            $this->metadata['exif'] = $data;
180        } elseif ( $this->config->get( MainConfigNames::ShowEXIF ) ) {
181            try {
182                wfDebug( __METHOD__ . ": using internal Exif( {$this->filename} )" );
183                $this->metadata['exif'] = BitmapMetadataHandler::Tiff( $this->filename );
184            } catch ( InvalidTiffException $e ) {
185                // BitmapMetadataHandler throws an exception in certain exceptional for invalid exif data
186                wfDebug( __METHOD__ . ': ' . $e->getMessage() );
187
188                $this->metadata['_error'] = ExifBitmapHandler::BROKEN_FILE;
189            }
190        }
191
192        unset( $this->metadata['exif']['Image'] );
193        unset( $this->metadata['exif']['filename'] );
194        unset( $this->metadata['exif']['Base filename'] );
195        unset( $this->metadata['exif']['XMLPacket'] );
196        unset( $this->metadata['exif']['ImageResources'] );
197
198        $this->metadata['TIFF_METADATA_VERSION'] = PagedTiffHandler::TIFF_METADATA_VERSION;
199
200        return $this->metadata;
201    }
202
203    /**
204     * helper function of retrieveMetaData().
205     * parses shell return from tiffinfo-command into an array.
206     * @param string $dump
207     * @return array
208     */
209    protected function parseTiffinfoOutput( $dump ) {
210        # HACK: width and length are given on a single line...
211        $dump = preg_replace( '/ Image Length:/', "\n  Image Length:", $dump );
212        $rows = preg_split( '/[\r\n]+\s*/', $dump );
213
214        $state = new PagedTiffInfoParserState();
215
216        $ignoreIFDs = [];
217        $ignore = false;
218
219        foreach ( $rows as $row ) {
220            $row = trim( $row );
221
222            # ignore XML rows
223            if ( preg_match( '/^<|^$/', $row ) ) {
224                continue;
225            }
226
227            $error = false;
228
229            # handle fatal errors
230            foreach ( $this->config->get( 'TiffTiffinfoRejectMessages' ) as $pattern ) {
231                if ( preg_match( $pattern, trim( $row ) ) ) {
232                    $state->addError( $row );
233                    $error = true;
234                    break;
235                }
236            }
237
238            if ( $error ) {
239                continue;
240            }
241
242            $m = [];
243
244            if ( preg_match( '/^TIFF Directory at offset 0x[a-f0-9]+ \((\d+)\)/', $row, $m ) ) {
245                # new IFD starting, flush previous page
246
247                if ( $ignore ) {
248                    $state->resetPage();
249                } else {
250                    $ok = $state->finishPage();
251
252                    if ( !$ok ) {
253                        $error = true;
254                        continue;
255                    }
256                }
257
258                # check if the next IFD is to be ignored
259                $offset = (int)$m[1];
260                $ignore = !empty( $ignoreIFDs[ $offset ] );
261            } elseif ( preg_match( '#^(TIFF.*?Directory): (.*?/.*?): (.*)#i', $row, $m ) ) {
262                # handle warnings
263
264                $bypass = false;
265                $msg = $m[3];
266
267                foreach ( $this->config->get( 'TiffTiffinfoBypassMessages' ) as $pattern ) {
268                    if ( preg_match( $pattern, trim( $row ) ) ) {
269                        $bypass = true;
270                        break;
271                    }
272                }
273
274                if ( !$bypass ) {
275                    $state->addWarning( $msg );
276                }
277            } elseif ( preg_match( '/^\s*(.*?)\s*:\s*(.*?)\s*$/', $row, $m ) ) {
278                # handle key/value pair
279
280                [ , $key, $value ] = $m;
281
282                if ( $key == 'Page Number' && preg_match( '/(\d+)-(\d+)/', $value, $m ) ) {
283                    $state->setPageProperty( 'page', (string)( (int)$m[1] + 1 ) );
284                } elseif ( $key == 'Samples/Pixel' ) {
285                    if ( $value == '4' ) {
286                        $state->setPageProperty( 'alpha', 'true' );
287                    }
288                } elseif ( $key == 'Extra samples' ) {
289                    if ( preg_match( '/.*alpha.*/', $value ) ) {
290                        $state->setPageProperty( 'alpha', 'true' );
291                    }
292                } elseif ( $key == 'Image Width' || $key == 'PixelXDimension' ) {
293                    $state->setPageProperty( 'width', (string)( (int)$value ) );
294                } elseif ( $key == 'Image Length' || $key == 'PixelYDimension' ) {
295                    $state->setPageProperty( 'height', (string)( (int)$value ) );
296                } elseif ( preg_match( '/.*IFDOffset/', $key ) ) {
297                    # ignore extra IFDs,
298                    # see <http://www.awaresystems.be/imaging/tiff/tifftags/exififd.html>
299                    # Note: we assume that we will always see the reference before the actual IFD,
300                    # so we know which IFDs to ignore
301                    // Offset is usually in hex
302                    if ( preg_match( '/^0x[0-9A-Fa-f]+$/', $value ) ) {
303                        $value = hexdec( substr( $value, 2 ) );
304                    }
305                    $offset = (int)$value;
306                    $ignoreIFDs[$offset] = true;
307                }
308            } else {
309                // strange line
310            }
311
312        }
313
314        $state->finish( !$ignore );
315
316        return $state->getMetadata();
317    }
318
319    /**
320     * helper function of retrieveMetaData().
321     * parses shell return from exiv2-command into an array.
322     * @param string $dump
323     * @return array
324     */
325    protected function parseExiv2Output( $dump ) {
326        $result = [];
327        preg_match_all( '/^(\w+)\s+(.+)$/m', $dump, $result, PREG_SET_ORDER );
328
329        $data = [];
330
331        foreach ( $result as $row ) {
332            $data[$row[1]] = $row[2];
333        }
334
335        return $data;
336    }
337
338    /**
339     * helper function of retrieveMetaData().
340     * parses shell return from identify-command into an array.
341     * @param string $dump
342     * @return array
343     */
344    protected function parseIdentifyOutput( $dump ) {
345        $state = new PagedTiffInfoParserState();
346
347        if ( strval( $dump ) == '' ) {
348            $state->addError( "no metadata" );
349            return $state->getMetadata();
350        }
351
352        $infos = null;
353        preg_match_all( '/\[BEGIN\](.+?)\[END\]/si', $dump, $infos, PREG_SET_ORDER );
354        // ImageMagick < 6.6.8-10 starts page numbering at 1; >= 6.6.8-10 starts at zero.
355        // Handle both and map to one-based page numbers (which are assumed in various other parts
356        // of the support for displaying multi-page files).
357        $pageSeen = false;
358        $pageOffset = 0;
359        foreach ( $infos as $info ) {
360            $state->resetPage();
361            $lines = explode( "\n", $info[1] );
362            foreach ( $lines as $line ) {
363                if ( trim( $line ) == '' ) {
364                    continue;
365                }
366                [ $key, $value ] = explode( '=', $line );
367                $key = trim( $key );
368                $value = trim( $value );
369                if ( $key === 'alpha' && $value === '%A' ) {
370                    continue;
371                }
372                if ( $key === 'alpha2' && !$state->hasPageProperty( 'alpha' ) ) {
373                    switch ( $value ) {
374                        case 'DirectClassRGBMatte':
375                        case 'DirectClassRGBA':
376                            $state->setPageProperty( 'alpha', 'true' );
377                            break;
378                        default:
379                            $state->setPageProperty( 'alpha', 'false' );
380                            break;
381                    }
382                    continue;
383                }
384                if ( $key === 'page' ) {
385                    if ( !$pageSeen ) {
386                        $pageSeen = true;
387                        $pageOffset = 1 - intval( $value );
388                    }
389                    if ( $pageOffset !== 0 ) {
390                        $value = intval( $value ) + $pageOffset;
391                    }
392                }
393                $state->setPageProperty( $key, (string)$value );
394            }
395            $state->finishPage();
396        }
397
398        $dump = preg_replace( '/\[BEGIN\](.+?)\[END\]/si', '', $dump );
399        if ( strlen( $dump ) ) {
400            $errors = explode( "\n", $dump );
401            foreach ( $errors as $error ) {
402                $error = trim( $error );
403                if ( $error === '' ) {
404                    continue;
405                }
406
407                $knownError = false;
408                foreach ( $this->config->get( 'TiffIdentifyRejectMessages' ) as $msg ) {
409                    if ( preg_match( $msg, trim( $error ) ) ) {
410                        $state->addError( $error );
411                        $knownError = true;
412                        break;
413                    }
414                }
415                if ( !$knownError ) {
416                    // ignore messages that match $wgTiffIdentifyBypassMessages
417                    foreach ( $this->config->get( 'TiffIdentifyBypassMessages' ) as $msg ) {
418                        if ( preg_match( $msg, trim( $error ) ) ) {
419                            $knownError = true;
420                            break;
421                        }
422                    }
423                }
424                if ( !$knownError ) {
425                    $state->addWarning( $error );
426                }
427            }
428        }
429
430        $state->finish();
431
432        return $state->getMetadata();
433    }
434}