Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 162
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfImage
0.00% covered (danger)
0.00%
0 / 162
0.00% covered (danger)
0.00%
0 / 6
2256
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
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
 getPageSize
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
56
 retrieveMetaData
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
56
 convertDumpToArray
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
90
 postProcessDump
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
506
1<?php
2/**
3 * Copyright © 2007 Xarax <jodeldi@gmx.de>
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 */
20
21namespace MediaWiki\Extension\PdfHandler;
22
23use MediaWiki\Config\Config;
24use MediaWiki\Logger\LoggerFactory;
25use MediaWiki\MainConfigNames;
26use MediaWiki\Media\BitmapMetadataHandler;
27use MediaWiki\MediaWikiServices;
28use UtfNormal\Validator;
29use Wikimedia\XMPReader\Reader as XMPReader;
30
31/**
32 * Inspired by djvuimage from Brooke Vibber
33 * Modified and written by xarax
34 */
35class PdfImage {
36
37    /**
38     * @var string
39     */
40    private $mFilename;
41
42    private Config $config;
43
44    public const ITEMS_FOR_PAGE_SIZE = [ 'Pages', 'pages', 'Page size', 'Page rot' ];
45
46    /**
47     * @param string $filename
48     * @param Config $config
49     */
50    public function __construct( $filename, Config $config ) {
51        $this->mFilename = $filename;
52        $this->config = $config;
53    }
54
55    /**
56     * @return bool
57     */
58    public function isValid() {
59        return true;
60    }
61
62    /**
63     * @param array $data
64     * @param int $page
65     * @return array{width: int, height: int}|false
66     */
67    public static function getPageSize( $data, $page ) {
68        if ( isset( $data['pages'][$page]['Page size'] ) ) {
69            $pageSize = $data['pages'][$page]['Page size'];
70        } elseif ( isset( $data['Page size'] ) ) {
71            $pageSize = $data['Page size'];
72        } else {
73            $pageSize = false;
74        }
75
76        if ( $pageSize ) {
77            $dpi = MediaWikiServices::getInstance()->getMainConfig()->get( 'PdfHandlerDpi' );
78            if ( isset( $data['pages'][$page]['Page rot'] ) ) {
79                $pageRotation = $data['pages'][$page]['Page rot'];
80            } elseif ( isset( $data['Page rot'] ) ) {
81                $pageRotation = $data['Page rot'];
82            } else {
83                $pageRotation = 0;
84            }
85            $size = explode( 'x', $pageSize, 2 );
86
87            $width  = intval( (int)trim( $size[0] ) / 72 * $dpi );
88            $height = explode( ' ', trim( $size[1] ), 2 );
89            $height = intval( (int)trim( $height[0] ) / 72 * $dpi );
90            if ( ( $pageRotation / 90 ) & 1 ) {
91                // Swap width and height for landscape pages
92                $temp = $width;
93                $width = $height;
94                $height = $temp;
95            }
96
97            return [
98                'width' => $width,
99                'height' => $height
100            ];
101        }
102
103        return false;
104    }
105
106    /**
107     * @return array
108     */
109    public function retrieveMetaData(): array {
110        $pdfInfo = $this->config->get( 'PdfInfo' );
111        $pdftoText = $this->config->get( 'PdftoText' );
112        $shellboxShell = $this->config->get( MainConfigNames::ShellboxShell );
113
114        $command = MediaWikiServices::getInstance()->getShellCommandFactory()
115            ->createBoxed( 'pdfhandler' )
116            ->disableNetwork()
117            ->firejailDefaultSeccomp()
118            ->routeName( 'pdfhandler-metadata' );
119
120        $result = $command
121            ->params( $shellboxShell, 'scripts/retrieveMetaData.sh' )
122            ->inputFileFromFile(
123                'scripts/retrieveMetaData.sh',
124                __DIR__ . '/../scripts/retrieveMetaData.sh' )
125            ->inputFileFromFile( 'file.pdf', $this->mFilename )
126            ->outputFileToString( 'meta' )
127            ->outputFileToString( 'pages' )
128            ->outputFileToString( 'text' )
129            ->outputFileToString( 'text_exit_code' )
130            ->environment( [
131                'PDFHANDLER_INFO' => $pdfInfo,
132                'PDFHANDLER_TOTEXT' => $pdftoText,
133            ] )
134            ->execute();
135
136        // Record in statsd
137        MediaWikiServices::getInstance()->getStatsFactory()
138            ->getCounter( 'pdfhandler_shell_retrievemetadata_total' )
139            ->increment();
140
141        // Metadata retrieval is allowed to fail, but we'd like to know why
142        if ( $result->getExitCode() != 0 ) {
143            wfDebug( __METHOD__ . ': retrieveMetaData.sh' .
144            "\n\nExitcode: " . $result->getExitCode() . "\n\n"
145            . $result->getStderr() );
146        }
147
148        $resultMeta = $result->getFileContents( 'meta' );
149        $resultPages = $result->getFileContents( 'pages' );
150        if ( $resultMeta !== null || $resultPages !== null ) {
151            $data = $this->convertDumpToArray(
152                $resultMeta ?? '',
153                $resultPages ?? ''
154            );
155        } else {
156            $data = [];
157        }
158
159        // Read text layer
160        $retval = $result->wasReceived( 'text_exit_code' )
161            ? (int)trim( $result->getFileContents( 'text_exit_code' ) )
162            : 1;
163        $txt = $result->getFileContents( 'text' );
164        if ( $retval === 0 && $txt != '' ) {
165            $txt = str_replace( "\r\n", "\n", $txt );
166            $pages = explode( "\f", $txt );
167            // Get rid of invalid UTF-8, strip control characters
168            // Note we need to do this per page, as \f page feed would be stripped.
169            $pages = array_filter(
170                array_map( static fn ( string $p ) => Validator::cleanUp( $p ), $pages ),
171                static fn ( string $t ) => $t !== ''
172            );
173            $data['text'] = $pages;
174        }
175
176        return $data;
177    }
178
179    /**
180     * @param string $metaDump
181     * @param string $infoDump
182     * @return array
183     */
184    protected function convertDumpToArray( $metaDump, $infoDump ): array {
185        if ( strval( $infoDump ) === '' ) {
186            return [];
187        }
188
189        $lines = explode( "\n", $infoDump );
190        $data = [];
191
192        // Metadata is always the last item, and spans multiple lines.
193        $inMetadata = false;
194
195        // Basically this loop will go through each line, splitting key value
196        // pairs on the colon, until it gets to a "Metadata:\n" at which point
197        // it will gather all remaining lines into the xmp key.
198        foreach ( $lines as $line ) {
199            if ( $inMetadata ) {
200                // Handle XMP differently due to difference in line break
201                $data['xmp'] .= "\n$line";
202                continue;
203            }
204            $bits = explode( ':', $line, 2 );
205            if ( count( $bits ) > 1 ) {
206                $key = trim( $bits[0] );
207                if ( $key === 'Metadata' ) {
208                    $inMetadata = true;
209                    $data['xmp'] = '';
210                    continue;
211                }
212                $value = trim( $bits[1] );
213                $matches = [];
214                // "Page xx rot" will be in poppler 0.20's pdfinfo output
215                // See https://bugs.freedesktop.org/show_bug.cgi?id=41867
216                if ( preg_match( '/^Page +(\d+) (size|rot)$/', $key, $matches ) ) {
217                    $data['pages'][$matches[1]][$matches[2] == 'size' ? 'Page size' : 'Page rot'] = $value;
218                } else {
219                    $data[$key] = $value;
220                }
221            }
222        }
223        $metaDump = trim( $metaDump );
224        if ( $metaDump !== '' ) {
225            $data['xmp'] = $metaDump;
226        }
227
228        return $this->postProcessDump( $data );
229    }
230
231    /**
232     * Postprocess the metadata (convert xmp into useful form, etc)
233     *
234     * This is used to generate the metadata table at the bottom
235     * of the image description page.
236     *
237     * @param array $data metadata
238     * @return array post-processed metadata
239     */
240    protected function postProcessDump( array $data ) {
241        $meta = new BitmapMetadataHandler();
242        $items = [];
243        foreach ( $data as $key => $val ) {
244            switch ( $key ) {
245                case 'Title':
246                    $items['ObjectName'] = $val;
247                    break;
248                case 'Subject':
249                    $items['ImageDescription'] = $val;
250                    break;
251                case 'Keywords':
252                    // Sometimes we have empty keywords. This seems
253                    // to be a product of how pdfinfo deals with keywords
254                    // with spaces in them. Filter such empty keywords
255                    $keyList = array_filter( explode( ' ', $val ) );
256                    if ( count( $keyList ) > 0 ) {
257                        $items['Keywords'] = $keyList;
258                    }
259                    break;
260                case 'Author':
261                    $items['Artist'] = $val;
262                    break;
263                case 'Creator':
264                    // Program used to create file.
265                    // Different from program used to convert to pdf.
266                    $items['Software'] = $val;
267                    break;
268                case 'Producer':
269                    // Conversion program
270                    $items['pdf-Producer'] = $val;
271                    break;
272                case 'ModTime':
273                    $timestamp = wfTimestamp( TS_EXIF, $val );
274                    if ( $timestamp ) {
275                        // 'if' is just paranoia
276                        $items['DateTime'] = $timestamp;
277                    }
278                    break;
279                case 'CreationTime':
280                    $timestamp = wfTimestamp( TS_EXIF, $val );
281                    if ( $timestamp ) {
282                        $items['DateTimeDigitized'] = $timestamp;
283                    }
284                    break;
285                // These last two (version and encryption) I was unsure
286                // if we should include in the table, since they aren't
287                // all that useful to editors. I leaned on the side
288                // of including. However not including if file
289                // is optimized/linearized since that is really useless
290                // to an editor.
291                case 'PDF version':
292                    $items['pdf-Version'] = $val;
293                    break;
294                case 'Encrypted':
295                    $items['pdf-Encrypted'] = $val;
296                    break;
297                // Note 'pages' and 'Pages' are different keys (!)
298                case 'pages':
299                    // A pdf document can have multiple sized pages in it.
300                    // (However 95% of the time, all pages are the same size)
301                    // get a list of all the unique page sizes in document.
302                    // This doesn't do anything with rotation as of yet,
303                    // mostly because I am unsure of what a good way to
304                    // present that information to the user would be.
305                    $pageSizes = [];
306                    foreach ( $val as $page ) {
307                        if ( isset( $page['Page size'] ) ) {
308                            $pageSizes[$page['Page size']] = true;
309                        }
310                    }
311
312                    $pageSizeArray = array_keys( $pageSizes );
313                    if ( count( $pageSizeArray ) > 0 ) {
314                        $items['pdf-PageSize'] = $pageSizeArray;
315                    }
316                    break;
317            }
318
319        }
320        $meta->addMetadata( $items, 'native' );
321
322        if ( isset( $data['xmp'] ) && XMPReader::isSupported() ) {
323            // @todo: This only handles generic xmp properties. Would be improved
324            // by handling pdf xmp properties (pdf and pdfx) via a hook.
325            $xmp = new XMPReader( LoggerFactory::getInstance( 'XMP' ) );
326            $xmp->parse( $data['xmp'] );
327            $xmpRes = $xmp->getResults();
328            foreach ( $xmpRes as $type => $xmpSection ) {
329                $meta->addMetadata( $xmpSection, $type );
330            }
331        }
332        unset( $data['xmp'] );
333        $data['mergedMetadata'] = $meta->getMetadataArray();
334        return $data;
335    }
336}