]> scripts.mit.edu Git - autoinstalls/mediawiki.git/blob - includes/libs/xmp/XMP.php
MediaWiki 1.30.2
[autoinstalls/mediawiki.git] / includes / libs / xmp / XMP.php
1 <?php
2 /**
3  * Reader for XMP data containing properties relevant to images.
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  * @file
21  * @ingroup Media
22  */
23
24 use Psr\Log\LoggerAwareInterface;
25 use Psr\Log\LoggerInterface;
26 use Psr\Log\NullLogger;
27 use Wikimedia\ScopedCallback;
28
29 /**
30  * Class for reading xmp data containing properties relevant to
31  * images, and spitting out an array that FormatMetadata accepts.
32  *
33  * Note, this is not meant to recognize every possible thing you can
34  * encode in XMP. It should recognize all the properties we want.
35  * For example it doesn't have support for structures with multiple
36  * nesting levels, as none of the properties we're supporting use that
37  * feature. If it comes across properties it doesn't recognize, it should
38  * ignore them.
39  *
40  * The public methods one would call in this class are
41  * - parse( $content )
42  *    Reads in xmp content.
43  *    Can potentially be called multiple times with partial data each time.
44  * - parseExtended( $content )
45  *    Reads XMPExtended blocks (jpeg files only).
46  * - getResults
47  *    Outputs a results array.
48  *
49  * Note XMP kind of looks like rdf. They are not the same thing - XMP is
50  * encoded as a specific subset of rdf. This class can read XMP. It cannot
51  * read rdf.
52  */
53 class XMPReader implements LoggerAwareInterface {
54         /** @var array XMP item configuration array */
55         protected $items;
56
57         /** @var array Array to hold the current element (and previous element, and so on) */
58         private $curItem = [];
59
60         /** @var bool|string The structure name when processing nested structures. */
61         private $ancestorStruct = false;
62
63         /** @var bool|string Temporary holder for character data that appears in xmp doc. */
64         private $charContent = false;
65
66         /** @var array Stores the state the xmpreader is in (see MODE_FOO constants) */
67         private $mode = [];
68
69         /** @var array Array to hold results */
70         private $results = [];
71
72         /** @var bool If we're doing a seq or bag. */
73         private $processingArray = false;
74
75         /** @var bool|string Used for lang alts only */
76         private $itemLang = false;
77
78         /** @var resource A resource handle for the XML parser */
79         private $xmlParser;
80
81         /** @var bool|string Character set like 'UTF-8' */
82         private $charset = false;
83
84         /** @var int */
85         private $extendedXMPOffset = 0;
86
87         /** @var int Flag determining if the XMP is safe to parse **/
88         private $parsable = 0;
89
90         /** @var string Buffer of XML to parse **/
91         private $xmlParsableBuffer = '';
92
93         /**
94          * These are various mode constants.
95          * they are used to figure out what to do
96          * with an element when its encountered.
97          *
98          * For example, MODE_IGNORE is used when processing
99          * a property we're not interested in. So if a new
100          * element pops up when we're in that mode, we ignore it.
101          */
102         const MODE_INITIAL = 0;
103         const MODE_IGNORE = 1;
104         const MODE_LI = 2;
105         const MODE_LI_LANG = 3;
106         const MODE_QDESC = 4;
107
108         // The following MODE constants are also used in the
109         // $items array to denote what type of property the item is.
110         const MODE_SIMPLE = 10;
111         const MODE_STRUCT = 11; // structure (associative array)
112         const MODE_SEQ = 12; // ordered list
113         const MODE_BAG = 13; // unordered list
114         const MODE_LANG = 14;
115         const MODE_ALT = 15; // non-language alt. Currently not implemented, and not needed atm.
116         const MODE_BAGSTRUCT = 16; // A BAG of Structs.
117
118         const NS_RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
119         const NS_XML = 'http://www.w3.org/XML/1998/namespace';
120
121         // States used while determining if XML is safe to parse
122         const PARSABLE_UNKNOWN = 0;
123         const PARSABLE_OK = 1;
124         const PARSABLE_BUFFERING = 2;
125         const PARSABLE_NO = 3;
126
127         /**
128          * @var LoggerInterface
129          */
130         private $logger;
131
132         /**
133          * Primary job is to initialize the XMLParser
134          * @param LoggerInterface|null $logger
135          */
136         function __construct( LoggerInterface $logger = null ) {
137                 if ( !function_exists( 'xml_parser_create_ns' ) ) {
138                         // this should already be checked by this point
139                         throw new RuntimeException( 'XMP support requires XML Parser' );
140                 }
141                 if ( $logger ) {
142                         $this->setLogger( $logger );
143                 } else {
144                         $this->setLogger( new NullLogger() );
145                 }
146
147                 $this->items = XMPInfo::getItems();
148
149                 $this->resetXMLParser();
150         }
151
152         public function setLogger( LoggerInterface $logger ) {
153                 $this->logger = $logger;
154         }
155
156         /**
157          * free the XML parser.
158          *
159          * @note It is unclear to me if we really need to do this ourselves
160          *  or if php garbage collection will automatically free the xmlParser
161          *  when it is no longer needed.
162          */
163         private function destroyXMLParser() {
164                 if ( $this->xmlParser ) {
165                         xml_parser_free( $this->xmlParser );
166                         $this->xmlParser = null;
167                 }
168         }
169
170         /**
171          * Main use is if a single item has multiple xmp documents describing it.
172          * For example in jpeg's with extendedXMP
173          */
174         private function resetXMLParser() {
175                 $this->destroyXMLParser();
176
177                 $this->xmlParser = xml_parser_create_ns( 'UTF-8', ' ' );
178                 xml_parser_set_option( $this->xmlParser, XML_OPTION_CASE_FOLDING, 0 );
179                 xml_parser_set_option( $this->xmlParser, XML_OPTION_SKIP_WHITE, 1 );
180
181                 xml_set_element_handler( $this->xmlParser,
182                         [ $this, 'startElement' ],
183                         [ $this, 'endElement' ] );
184
185                 xml_set_character_data_handler( $this->xmlParser, [ $this, 'char' ] );
186
187                 $this->parsable = self::PARSABLE_UNKNOWN;
188                 $this->xmlParsableBuffer = '';
189         }
190
191         /**
192          * Check if this instance supports using this class
193          * @return bool
194          */
195         public static function isSupported() {
196                 return function_exists( 'xml_parser_create_ns' ) && class_exists( 'XMLReader' );
197         }
198
199         /** Get the result array. Do some post-processing before returning
200          * the array, and transform any metadata that is special-cased.
201          *
202          * @return array Array of results as an array of arrays suitable for
203          *    FormatMetadata::getFormattedData().
204          */
205         public function getResults() {
206                 // xmp-special is for metadata that affects how stuff
207                 // is extracted. For example xmpNote:HasExtendedXMP.
208
209                 // It is also used to handle photoshop:AuthorsPosition
210                 // which is weird and really part of another property,
211                 // see 2:85 in IPTC. See also pg 21 of IPTC4XMP standard.
212                 // The location fields also use it.
213
214                 $data = $this->results;
215
216                 if ( isset( $data['xmp-special']['AuthorsPosition'] )
217                         && is_string( $data['xmp-special']['AuthorsPosition'] )
218                         && isset( $data['xmp-general']['Artist'][0] )
219                 ) {
220                         // Note, if there is more than one creator,
221                         // this only applies to first. This also will
222                         // only apply to the dc:Creator prop, not the
223                         // exif:Artist prop.
224
225                         $data['xmp-general']['Artist'][0] =
226                                 $data['xmp-special']['AuthorsPosition'] . ', '
227                                 . $data['xmp-general']['Artist'][0];
228                 }
229
230                 // Go through the LocationShown and LocationCreated
231                 // changing it to the non-hierarchal form used by
232                 // the other location fields.
233
234                 if ( isset( $data['xmp-special']['LocationShown'][0] )
235                         && is_array( $data['xmp-special']['LocationShown'][0] )
236                 ) {
237                         // the is_array is just paranoia. It should always
238                         // be an array.
239                         foreach ( $data['xmp-special']['LocationShown'] as $loc ) {
240                                 if ( !is_array( $loc ) ) {
241                                         // To avoid copying over the _type meta-fields.
242                                         continue;
243                                 }
244                                 foreach ( $loc as $field => $val ) {
245                                         $data['xmp-general'][$field . 'Dest'][] = $val;
246                                 }
247                         }
248                 }
249                 if ( isset( $data['xmp-special']['LocationCreated'][0] )
250                         && is_array( $data['xmp-special']['LocationCreated'][0] )
251                 ) {
252                         // the is_array is just paranoia. It should always
253                         // be an array.
254                         foreach ( $data['xmp-special']['LocationCreated'] as $loc ) {
255                                 if ( !is_array( $loc ) ) {
256                                         // To avoid copying over the _type meta-fields.
257                                         continue;
258                                 }
259                                 foreach ( $loc as $field => $val ) {
260                                         $data['xmp-general'][$field . 'Created'][] = $val;
261                                 }
262                         }
263                 }
264
265                 // We don't want to return the special values, since they're
266                 // special and not info to be stored about the file.
267                 unset( $data['xmp-special'] );
268
269                 // Convert GPSAltitude to negative if below sea level.
270                 if ( isset( $data['xmp-exif']['GPSAltitudeRef'] )
271                         && isset( $data['xmp-exif']['GPSAltitude'] )
272                 ) {
273                         // Must convert to a real before multiplying by -1
274                         // XMPValidate guarantees there will always be a '/' in this value.
275                         list( $nom, $denom ) = explode( '/', $data['xmp-exif']['GPSAltitude'] );
276                         $data['xmp-exif']['GPSAltitude'] = $nom / $denom;
277
278                         if ( $data['xmp-exif']['GPSAltitudeRef'] == '1' ) {
279                                 $data['xmp-exif']['GPSAltitude'] *= -1;
280                         }
281                         unset( $data['xmp-exif']['GPSAltitudeRef'] );
282                 }
283
284                 return $data;
285         }
286
287         /**
288          * Main function to call to parse XMP. Use getResults to
289          * get results.
290          *
291          * Also catches any errors during processing, writes them to
292          * debug log, blanks result array and returns false.
293          *
294          * @param string $content XMP data
295          * @param bool $allOfIt If this is all the data (true) or if its split up (false). Default true
296          * @throws RuntimeException
297          * @return bool Success.
298          */
299         public function parse( $content, $allOfIt = true ) {
300                 if ( !$this->xmlParser ) {
301                         $this->resetXMLParser();
302                 }
303                 try {
304
305                         // detect encoding by looking for BOM which is supposed to be in processing instruction.
306                         // see page 12 of http://www.adobe.com/devnet/xmp/pdfs/XMPSpecificationPart3.pdf
307                         if ( !$this->charset ) {
308                                 $bom = [];
309                                 if ( preg_match( '/\xEF\xBB\xBF|\xFE\xFF|\x00\x00\xFE\xFF|\xFF\xFE\x00\x00|\xFF\xFE/',
310                                         $content, $bom )
311                                 ) {
312                                         switch ( $bom[0] ) {
313                                                 case "\xFE\xFF":
314                                                         $this->charset = 'UTF-16BE';
315                                                         break;
316                                                 case "\xFF\xFE":
317                                                         $this->charset = 'UTF-16LE';
318                                                         break;
319                                                 case "\x00\x00\xFE\xFF":
320                                                         $this->charset = 'UTF-32BE';
321                                                         break;
322                                                 case "\xFF\xFE\x00\x00":
323                                                         $this->charset = 'UTF-32LE';
324                                                         break;
325                                                 case "\xEF\xBB\xBF":
326                                                         $this->charset = 'UTF-8';
327                                                         break;
328                                                 default:
329                                                         // this should be impossible to get to
330                                                         throw new RuntimeException( "Invalid BOM" );
331                                         }
332                                 } else {
333                                         // standard specifically says, if no bom assume utf-8
334                                         $this->charset = 'UTF-8';
335                                 }
336                         }
337                         if ( $this->charset !== 'UTF-8' ) {
338                                 // don't convert if already utf-8
339                                 MediaWiki\suppressWarnings();
340                                 $content = iconv( $this->charset, 'UTF-8//IGNORE', $content );
341                                 MediaWiki\restoreWarnings();
342                         }
343
344                         // Ensure the XMP block does not have an xml doctype declaration, which
345                         // could declare entities unsafe to parse with xml_parse (T85848/T71210).
346                         if ( $this->parsable !== self::PARSABLE_OK ) {
347                                 if ( $this->parsable === self::PARSABLE_NO ) {
348                                         throw new RuntimeException( 'Unsafe doctype declaration in XML.' );
349                                 }
350
351                                 $content = $this->xmlParsableBuffer . $content;
352                                 if ( !$this->checkParseSafety( $content ) ) {
353                                         if ( !$allOfIt && $this->parsable !== self::PARSABLE_NO ) {
354                                                 // parse wasn't Unsuccessful yet, so return true
355                                                 // in this case.
356                                                 return true;
357                                         }
358                                         $msg = ( $this->parsable === self::PARSABLE_NO ) ?
359                                                 'Unsafe doctype declaration in XML.' :
360                                                 'No root element found in XML.';
361                                         throw new RuntimeException( $msg );
362                                 }
363                         }
364
365                         $ok = xml_parse( $this->xmlParser, $content, $allOfIt );
366                         if ( !$ok ) {
367                                 $code = xml_get_error_code( $this->xmlParser );
368                                 $error = xml_error_string( $code );
369                                 $line = xml_get_current_line_number( $this->xmlParser );
370                                 $col = xml_get_current_column_number( $this->xmlParser );
371                                 $offset = xml_get_current_byte_index( $this->xmlParser );
372
373                                 $this->logger->warning(
374                                         '{method} : Error reading XMP content: {error} ' .
375                                         '(line: {line} column: {column} byte offset: {offset})',
376                                         [
377                                                 'method' => __METHOD__,
378                                                 'error_code' => $code,
379                                                 'error' => $error,
380                                                 'line' => $line,
381                                                 'column' => $col,
382                                                 'offset' => $offset,
383                                                 'content' => $content,
384                                 ] );
385                                 $this->results = []; // blank if error.
386                                 $this->destroyXMLParser();
387                                 return false;
388                         }
389                 } catch ( Exception $e ) {
390                         $this->logger->warning(
391                                 '{method} Exception caught while parsing: ' . $e->getMessage(),
392                                 [
393                                         'method' => __METHOD__,
394                                         'exception' => $e,
395                                         'content' => $content,
396                                 ]
397                         );
398                         $this->results = [];
399                         return false;
400                 }
401                 if ( $allOfIt ) {
402                         $this->destroyXMLParser();
403                 }
404
405                 return true;
406         }
407
408         /** Entry point for XMPExtended blocks in jpeg files
409          *
410          * @todo In serious need of testing
411          * @see http://www.adobe.ge/devnet/xmp/pdfs/XMPSpecificationPart3.pdf XMP spec part 3 page 20
412          * @param string $content XMPExtended block minus the namespace signature
413          * @return bool If it succeeded.
414          */
415         public function parseExtended( $content ) {
416                 // @todo FIXME: This is untested. Hard to find example files
417                 // or programs that make such files..
418                 $guid = substr( $content, 0, 32 );
419                 if ( !isset( $this->results['xmp-special']['HasExtendedXMP'] )
420                         || $this->results['xmp-special']['HasExtendedXMP'] !== $guid
421                 ) {
422                         $this->logger->info( __METHOD__ .
423                                 " Ignoring XMPExtended block due to wrong guid (guid= '$guid')" );
424
425                         return false;
426                 }
427                 $len = unpack( 'Nlength/Noffset', substr( $content, 32, 8 ) );
428
429                 if ( !$len ||
430                         $len['length'] < 4 ||
431                         $len['offset'] < 0 ||
432                         $len['offset'] > $len['length']
433                 ) {
434                         $this->logger->info(
435                                 __METHOD__ . 'Error reading extended XMP block, invalid length or offset.'
436                         );
437
438                         return false;
439                 }
440
441                 // we're not very robust here. we should accept it in the wrong order.
442                 // To quote the XMP standard:
443                 // "A JPEG writer should write the ExtendedXMP marker segments in order,
444                 // immediately following the StandardXMP. However, the JPEG standard
445                 // does not require preservation of marker segment order. A robust JPEG
446                 // reader should tolerate the marker segments in any order."
447                 // On the other hand, the probability that an image will have more than
448                 // 128k of metadata is rather low... so the probability that it will have
449                 // > 128k, and be in the wrong order is very low...
450
451                 if ( $len['offset'] !== $this->extendedXMPOffset ) {
452                         $this->logger->info( __METHOD__ . 'Ignoring XMPExtended block due to wrong order. (Offset was '
453                                 . $len['offset'] . ' but expected ' . $this->extendedXMPOffset . ')' );
454
455                         return false;
456                 }
457
458                 if ( $len['offset'] === 0 ) {
459                         // if we're starting the extended block, we've probably already
460                         // done the XMPStandard block, so reset.
461                         $this->resetXMLParser();
462                 }
463
464                 $this->extendedXMPOffset += $len['length'];
465
466                 $actualContent = substr( $content, 40 );
467
468                 if ( $this->extendedXMPOffset === strlen( $actualContent ) ) {
469                         $atEnd = true;
470                 } else {
471                         $atEnd = false;
472                 }
473
474                 $this->logger->debug( __METHOD__ . 'Parsing a XMPExtended block' );
475
476                 return $this->parse( $actualContent, $atEnd );
477         }
478
479         /**
480          * Character data handler
481          * Called whenever character data is found in the xmp document.
482          *
483          * does nothing if we're in MODE_IGNORE or if the data is whitespace
484          * throws an error if we're not in MODE_SIMPLE (as we're not allowed to have character
485          * data in the other modes).
486          *
487          * As an example, this happens when we encounter XMP like:
488          * <exif:DigitalZoomRatio>0/10</exif:DigitalZoomRatio>
489          * and are processing the 0/10 bit.
490          *
491          * @param resource $parser XMLParser reference to the xml parser
492          * @param string $data Character data
493          * @throws RuntimeException On invalid data
494          */
495         function char( $parser, $data ) {
496                 $data = trim( $data );
497                 if ( trim( $data ) === "" ) {
498                         return;
499                 }
500
501                 if ( !isset( $this->mode[0] ) ) {
502                         throw new RuntimeException( 'Unexpected character data before first rdf:Description element' );
503                 }
504
505                 if ( $this->mode[0] === self::MODE_IGNORE ) {
506                         return;
507                 }
508
509                 if ( $this->mode[0] !== self::MODE_SIMPLE
510                         && $this->mode[0] !== self::MODE_QDESC
511                 ) {
512                         throw new RuntimeException( 'character data where not expected. (mode ' . $this->mode[0] . ')' );
513                 }
514
515                 // to check, how does this handle w.s.
516                 if ( $this->charContent === false ) {
517                         $this->charContent = $data;
518                 } else {
519                         $this->charContent .= $data;
520                 }
521         }
522
523         /**
524          * Check if a block of XML is safe to pass to xml_parse, i.e. doesn't
525          * contain a doctype declaration which could contain a dos attack if we
526          * parse it and expand internal entities (T85848).
527          *
528          * @param string $content xml string to check for parse safety
529          * @return bool true if the xml is safe to parse, false otherwise
530          */
531         private function checkParseSafety( $content ) {
532                 $reader = new XMLReader();
533                 $result = null;
534
535                 // For XMLReader to parse incomplete/invalid XML, it has to be open()'ed
536                 // instead of using XML().
537                 $reader->open(
538                         'data://text/plain,' . urlencode( $content ),
539                         null,
540                         LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET
541                 );
542
543                 $oldDisable = libxml_disable_entity_loader( true );
544                 /** @noinspection PhpUnusedLocalVariableInspection */
545                 $reset = new ScopedCallback(
546                         'libxml_disable_entity_loader',
547                         [ $oldDisable ]
548                 );
549                 $reader->setParserProperty( XMLReader::SUBST_ENTITIES, false );
550
551                 // Even with LIBXML_NOWARNING set, XMLReader::read gives a warning
552                 // when parsing truncated XML, which causes unit tests to fail.
553                 MediaWiki\suppressWarnings();
554                 while ( $reader->read() ) {
555                         if ( $reader->nodeType === XMLReader::ELEMENT ) {
556                                 // Reached the first element without hitting a doctype declaration
557                                 $this->parsable = self::PARSABLE_OK;
558                                 $result = true;
559                                 break;
560                         }
561                         if ( $reader->nodeType === XMLReader::DOC_TYPE ) {
562                                 $this->parsable = self::PARSABLE_NO;
563                                 $result = false;
564                                 break;
565                         }
566                 }
567                 MediaWiki\restoreWarnings();
568
569                 if ( !is_null( $result ) ) {
570                         return $result;
571                 }
572
573                 // Reached the end of the parsable xml without finding an element
574                 // or doctype. Buffer and try again.
575                 $this->parsable = self::PARSABLE_BUFFERING;
576                 $this->xmlParsableBuffer = $content;
577                 return false;
578         }
579
580         /** When we hit a closing element in MODE_IGNORE
581          * Check to see if this is the element we started to ignore,
582          * in which case we get out of MODE_IGNORE
583          *
584          * @param string $elm Namespace of element followed by a space and then tag name of element.
585          */
586         private function endElementModeIgnore( $elm ) {
587                 if ( $this->curItem[0] === $elm ) {
588                         array_shift( $this->curItem );
589                         array_shift( $this->mode );
590                 }
591         }
592
593         /**
594          * Hit a closing element when in MODE_SIMPLE.
595          * This generally means that we finished processing a
596          * property value, and now have to save the result to the
597          * results array
598          *
599          * For example, when processing:
600          * <exif:DigitalZoomRatio>0/10</exif:DigitalZoomRatio>
601          * this deals with when we hit </exif:DigitalZoomRatio>.
602          *
603          * Or it could be if we hit the end element of a property
604          * of a compound data structure (like a member of an array).
605          *
606          * @param string $elm Namespace, space, and tag name.
607          */
608         private function endElementModeSimple( $elm ) {
609                 if ( $this->charContent !== false ) {
610                         if ( $this->processingArray ) {
611                                 // if we're processing an array, use the original element
612                                 // name instead of rdf:li.
613                                 list( $ns, $tag ) = explode( ' ', $this->curItem[0], 2 );
614                         } else {
615                                 list( $ns, $tag ) = explode( ' ', $elm, 2 );
616                         }
617                         $this->saveValue( $ns, $tag, $this->charContent );
618
619                         $this->charContent = false; // reset
620                 }
621                 array_shift( $this->curItem );
622                 array_shift( $this->mode );
623         }
624
625         /**
626          * Hit a closing element in MODE_STRUCT, MODE_SEQ, MODE_BAG
627          * generally means we've finished processing a nested structure.
628          * resets some internal variables to indicate that.
629          *
630          * Note this means we hit the closing element not the "</rdf:Seq>".
631          *
632          * @par For example, when processing:
633          * @code{,xml}
634          * <exif:ISOSpeedRatings> <rdf:Seq> <rdf:li>64</rdf:li>
635          *   </rdf:Seq> </exif:ISOSpeedRatings>
636          * @endcode
637          *
638          * This method is called when we hit the "</exif:ISOSpeedRatings>" tag.
639          *
640          * @param string $elm Namespace . space . tag name.
641          * @throws RuntimeException
642          */
643         private function endElementNested( $elm ) {
644                 /* cur item must be the same as $elm, unless if in MODE_STRUCT
645                  * in which case it could also be rdf:Description */
646                 if ( $this->curItem[0] !== $elm
647                         && !( $elm === self::NS_RDF . ' Description'
648                                 && $this->mode[0] === self::MODE_STRUCT )
649                 ) {
650                         throw new RuntimeException( "nesting mismatch. got a </$elm> but expected a </" .
651                                 $this->curItem[0] . '>' );
652                 }
653
654                 // Validate structures.
655                 list( $ns, $tag ) = explode( ' ', $elm, 2 );
656                 if ( isset( $this->items[$ns][$tag]['validate'] ) ) {
657                         $info =& $this->items[$ns][$tag];
658                         $finalName = isset( $info['map_name'] )
659                                 ? $info['map_name'] : $tag;
660
661                         if ( is_array( $info['validate'] ) ) {
662                                 $validate = $info['validate'];
663                         } else {
664                                 $validator = new XMPValidate( $this->logger );
665                                 $validate = [ $validator, $info['validate'] ];
666                         }
667
668                         if ( !isset( $this->results['xmp-' . $info['map_group']][$finalName] ) ) {
669                                 // This can happen if all the members of the struct failed validation.
670                                 $this->logger->debug( __METHOD__ . " <$ns:$tag> has no valid members." );
671                         } elseif ( is_callable( $validate ) ) {
672                                 $val =& $this->results['xmp-' . $info['map_group']][$finalName];
673                                 call_user_func_array( $validate, [ $info, &$val, false ] );
674                                 if ( is_null( $val ) ) {
675                                         // the idea being the validation function will unset the variable if
676                                         // its invalid.
677                                         $this->logger->info( __METHOD__ . " <$ns:$tag> failed validation." );
678                                         unset( $this->results['xmp-' . $info['map_group']][$finalName] );
679                                 }
680                         } else {
681                                 $this->logger->warning( __METHOD__ . " Validation function for $finalName ("
682                                         . $validate[0] . '::' . $validate[1] . '()) is not callable.' );
683                         }
684                 }
685
686                 array_shift( $this->curItem );
687                 array_shift( $this->mode );
688                 $this->ancestorStruct = false;
689                 $this->processingArray = false;
690                 $this->itemLang = false;
691         }
692
693         /**
694          * Hit a closing element in MODE_LI (either rdf:Seq, or rdf:Bag )
695          * Add information about what type of element this is.
696          *
697          * Note we still have to hit the outer "</property>"
698          *
699          * @par For example, when processing:
700          * @code{,xml}
701          * <exif:ISOSpeedRatings> <rdf:Seq> <rdf:li>64</rdf:li>
702          *   </rdf:Seq> </exif:ISOSpeedRatings>
703          * @endcode
704          *
705          * This method is called when we hit the "</rdf:Seq>".
706          * (For comparison, we call endElementModeSimple when we
707          * hit the "</rdf:li>")
708          *
709          * @param string $elm Namespace . ' ' . element name
710          * @throws RuntimeException
711          */
712         private function endElementModeLi( $elm ) {
713                 list( $ns, $tag ) = explode( ' ', $this->curItem[0], 2 );
714                 $info = $this->items[$ns][$tag];
715                 $finalName = isset( $info['map_name'] )
716                         ? $info['map_name'] : $tag;
717
718                 array_shift( $this->mode );
719
720                 if ( !isset( $this->results['xmp-' . $info['map_group']][$finalName] ) ) {
721                         $this->logger->debug( __METHOD__ . " Empty compund element $finalName." );
722
723                         return;
724                 }
725
726                 if ( $elm === self::NS_RDF . ' Seq' ) {
727                         $this->results['xmp-' . $info['map_group']][$finalName]['_type'] = 'ol';
728                 } elseif ( $elm === self::NS_RDF . ' Bag' ) {
729                         $this->results['xmp-' . $info['map_group']][$finalName]['_type'] = 'ul';
730                 } elseif ( $elm === self::NS_RDF . ' Alt' ) {
731                         // extra if needed as you could theoretically have a non-language alt.
732                         if ( $info['mode'] === self::MODE_LANG ) {
733                                 $this->results['xmp-' . $info['map_group']][$finalName]['_type'] = 'lang';
734                         }
735                 } else {
736                         throw new RuntimeException(
737                                 __METHOD__ . " expected </rdf:seq> or </rdf:bag> but instead got $elm."
738                         );
739                 }
740         }
741
742         /**
743          * End element while in MODE_QDESC
744          * mostly when ending an element when we have a simple value
745          * that has qualifiers.
746          *
747          * Qualifiers aren't all that common, and we don't do anything
748          * with them.
749          *
750          * @param string $elm Namespace and element
751          */
752         private function endElementModeQDesc( $elm ) {
753                 if ( $elm === self::NS_RDF . ' value' ) {
754                         list( $ns, $tag ) = explode( ' ', $this->curItem[0], 2 );
755                         $this->saveValue( $ns, $tag, $this->charContent );
756
757                         return;
758                 } else {
759                         array_shift( $this->mode );
760                         array_shift( $this->curItem );
761                 }
762         }
763
764         /**
765          * Handler for hitting a closing element.
766          *
767          * generally just calls a helper function depending on what
768          * mode we're in.
769          *
770          * Ignores the outer wrapping elements that are optional in
771          * xmp and have no meaning.
772          *
773          * @param resource $parser
774          * @param string $elm Namespace . ' ' . element name
775          * @throws RuntimeException
776          */
777         function endElement( $parser, $elm ) {
778                 if ( $elm === ( self::NS_RDF . ' RDF' )
779                         || $elm === 'adobe:ns:meta/ xmpmeta'
780                         || $elm === 'adobe:ns:meta/ xapmeta'
781                 ) {
782                         // ignore these.
783                         return;
784                 }
785
786                 if ( $elm === self::NS_RDF . ' type' ) {
787                         // these aren't really supported properly yet.
788                         // However, it appears they almost never used.
789                         $this->logger->info( __METHOD__ . ' encountered <rdf:type>' );
790                 }
791
792                 if ( strpos( $elm, ' ' ) === false ) {
793                         // This probably shouldn't happen.
794                         // However, there is a bug in an adobe product
795                         // that forgets the namespace on some things.
796                         // (Luckily they are unimportant things).
797                         $this->logger->info( __METHOD__ . " Encountered </$elm> which has no namespace. Skipping." );
798
799                         return;
800                 }
801
802                 if ( count( $this->mode[0] ) === 0 ) {
803                         // This should never ever happen and means
804                         // there is a pretty major bug in this class.
805                         throw new RuntimeException( 'Encountered end element with no mode' );
806                 }
807
808                 if ( count( $this->curItem ) == 0 && $this->mode[0] !== self::MODE_INITIAL ) {
809                         // just to be paranoid. Should always have a curItem, except for initially
810                         // (aka during MODE_INITAL).
811                         throw new RuntimeException( "Hit end element </$elm> but no curItem" );
812                 }
813
814                 switch ( $this->mode[0] ) {
815                         case self::MODE_IGNORE:
816                                 $this->endElementModeIgnore( $elm );
817                                 break;
818                         case self::MODE_SIMPLE:
819                                 $this->endElementModeSimple( $elm );
820                                 break;
821                         case self::MODE_STRUCT:
822                         case self::MODE_SEQ:
823                         case self::MODE_BAG:
824                         case self::MODE_LANG:
825                         case self::MODE_BAGSTRUCT:
826                                 $this->endElementNested( $elm );
827                                 break;
828                         case self::MODE_INITIAL:
829                                 if ( $elm === self::NS_RDF . ' Description' ) {
830                                         array_shift( $this->mode );
831                                 } else {
832                                         throw new RuntimeException( 'Element ended unexpectedly while in MODE_INITIAL' );
833                                 }
834                                 break;
835                         case self::MODE_LI:
836                         case self::MODE_LI_LANG:
837                                 $this->endElementModeLi( $elm );
838                                 break;
839                         case self::MODE_QDESC:
840                                 $this->endElementModeQDesc( $elm );
841                                 break;
842                         default:
843                                 $this->logger->warning( __METHOD__ . " no mode (elm = $elm)" );
844                                 break;
845                 }
846         }
847
848         /**
849          * Hit an opening element while in MODE_IGNORE
850          *
851          * XMP is extensible, so ignore any tag we don't understand.
852          *
853          * Mostly ignores, unless we encounter the element that we are ignoring.
854          * in which case we add it to the item stack, so we can ignore things
855          * that are nested, correctly.
856          *
857          * @param string $elm Namespace . ' ' . tag name
858          */
859         private function startElementModeIgnore( $elm ) {
860                 if ( $elm === $this->curItem[0] ) {
861                         array_unshift( $this->curItem, $elm );
862                         array_unshift( $this->mode, self::MODE_IGNORE );
863                 }
864         }
865
866         /**
867          *  Start element in MODE_BAG (unordered array)
868          * this should always be <rdf:Bag>
869          *
870          * @param string $elm Namespace . ' ' . tag
871          * @throws RuntimeException If we have an element that's not <rdf:Bag>
872          */
873         private function startElementModeBag( $elm ) {
874                 if ( $elm === self::NS_RDF . ' Bag' ) {
875                         array_unshift( $this->mode, self::MODE_LI );
876                 } else {
877                         throw new RuntimeException( "Expected <rdf:Bag> but got $elm." );
878                 }
879         }
880
881         /**
882          * Start element in MODE_SEQ (ordered array)
883          * this should always be <rdf:Seq>
884          *
885          * @param string $elm Namespace . ' ' . tag
886          * @throws RuntimeException If we have an element that's not <rdf:Seq>
887          */
888         private function startElementModeSeq( $elm ) {
889                 if ( $elm === self::NS_RDF . ' Seq' ) {
890                         array_unshift( $this->mode, self::MODE_LI );
891                 } elseif ( $elm === self::NS_RDF . ' Bag' ) {
892                         # T29105
893                         $this->logger->info( __METHOD__ . ' Expected an rdf:Seq, but got an rdf:Bag. Pretending'
894                                 . ' it is a Seq, since some buggy software is known to screw this up.' );
895                         array_unshift( $this->mode, self::MODE_LI );
896                 } else {
897                         throw new RuntimeException( "Expected <rdf:Seq> but got $elm." );
898                 }
899         }
900
901         /**
902          * Start element in MODE_LANG (language alternative)
903          * this should always be <rdf:Alt>
904          *
905          * This tag tends to be used for metadata like describe this
906          * picture, which can be translated into multiple languages.
907          *
908          * XMP supports non-linguistic alternative selections,
909          * which are really only used for thumbnails, which
910          * we don't care about.
911          *
912          * @param string $elm Namespace . ' ' . tag
913          * @throws RuntimeException If we have an element that's not <rdf:Alt>
914          */
915         private function startElementModeLang( $elm ) {
916                 if ( $elm === self::NS_RDF . ' Alt' ) {
917                         array_unshift( $this->mode, self::MODE_LI_LANG );
918                 } else {
919                         throw new RuntimeException( "Expected <rdf:Seq> but got $elm." );
920                 }
921         }
922
923         /**
924          * Handle an opening element when in MODE_SIMPLE
925          *
926          * This should not happen often. This is for if a simple element
927          * already opened has a child element. Could happen for a
928          * qualified element.
929          *
930          * For example:
931          * <exif:DigitalZoomRatio><rdf:Description><rdf:value>0/10</rdf:value>
932          *   <foo:someQualifier>Bar</foo:someQualifier> </rdf:Description>
933          *   </exif:DigitalZoomRatio>
934          *
935          * This method is called when processing the <rdf:Description> element
936          *
937          * @param string $elm Namespace and tag names separated by space.
938          * @param array $attribs Attributes of the element.
939          * @throws RuntimeException
940          */
941         private function startElementModeSimple( $elm, $attribs ) {
942                 if ( $elm === self::NS_RDF . ' Description' ) {
943                         // If this value has qualifiers
944                         array_unshift( $this->mode, self::MODE_QDESC );
945                         array_unshift( $this->curItem, $this->curItem[0] );
946
947                         if ( isset( $attribs[self::NS_RDF . ' value'] ) ) {
948                                 list( $ns, $tag ) = explode( ' ', $this->curItem[0], 2 );
949                                 $this->saveValue( $ns, $tag, $attribs[self::NS_RDF . ' value'] );
950                         }
951                 } elseif ( $elm === self::NS_RDF . ' value' ) {
952                         // This should not be here.
953                         throw new RuntimeException( __METHOD__ . ' Encountered <rdf:value> where it was unexpected.' );
954                 } else {
955                         // something else we don't recognize, like a qualifier maybe.
956                         $this->logger->info( __METHOD__ .
957                                 " Encountered element <$elm> where only expecting character data as value of " .
958                                 $this->curItem[0] );
959                         array_unshift( $this->mode, self::MODE_IGNORE );
960                         array_unshift( $this->curItem, $elm );
961                 }
962         }
963
964         /**
965          * Start an element when in MODE_QDESC.
966          * This generally happens when a simple element has an inner
967          * rdf:Description to hold qualifier elements.
968          *
969          * For example in:
970          * <exif:DigitalZoomRatio><rdf:Description><rdf:value>0/10</rdf:value>
971          *   <foo:someQualifier>Bar</foo:someQualifier> </rdf:Description>
972          *   </exif:DigitalZoomRatio>
973          * Called when processing the <rdf:value> or <foo:someQualifier>.
974          *
975          * @param string $elm Namespace and tag name separated by a space.
976          */
977         private function startElementModeQDesc( $elm ) {
978                 if ( $elm === self::NS_RDF . ' value' ) {
979                         return; // do nothing
980                 } else {
981                         // otherwise its a qualifier, which we ignore
982                         array_unshift( $this->mode, self::MODE_IGNORE );
983                         array_unshift( $this->curItem, $elm );
984                 }
985         }
986
987         /**
988          * Starting an element when in MODE_INITIAL
989          * This usually happens when we hit an element inside
990          * the outer rdf:Description
991          *
992          * This is generally where most properties start.
993          *
994          * @param string $ns Namespace
995          * @param string $tag Tag name (without namespace prefix)
996          * @param array $attribs Array of attributes
997          * @throws RuntimeException
998          */
999         private function startElementModeInitial( $ns, $tag, $attribs ) {
1000                 if ( $ns !== self::NS_RDF ) {
1001                         if ( isset( $this->items[$ns][$tag] ) ) {
1002                                 if ( isset( $this->items[$ns][$tag]['structPart'] ) ) {
1003                                         // If this element is supposed to appear only as
1004                                         // a child of a structure, but appears here (not as
1005                                         // a child of a struct), then something weird is
1006                                         // happening, so ignore this element and its children.
1007
1008                                         $this->logger->warning( "Encountered <$ns:$tag> outside"
1009                                                 . " of its expected parent. Ignoring." );
1010
1011                                         array_unshift( $this->mode, self::MODE_IGNORE );
1012                                         array_unshift( $this->curItem, $ns . ' ' . $tag );
1013
1014                                         return;
1015                                 }
1016                                 $mode = $this->items[$ns][$tag]['mode'];
1017                                 array_unshift( $this->mode, $mode );
1018                                 array_unshift( $this->curItem, $ns . ' ' . $tag );
1019                                 if ( $mode === self::MODE_STRUCT ) {
1020                                         $this->ancestorStruct = isset( $this->items[$ns][$tag]['map_name'] )
1021                                                 ? $this->items[$ns][$tag]['map_name'] : $tag;
1022                                 }
1023                                 if ( $this->charContent !== false ) {
1024                                         // Something weird.
1025                                         // Should not happen in valid XMP.
1026                                         throw new RuntimeException( 'tag nested in non-whitespace characters.' );
1027                                 }
1028                         } else {
1029                                 // This element is not on our list of allowed elements so ignore.
1030                                 $this->logger->debug( __METHOD__ . " Ignoring unrecognized element <$ns:$tag>." );
1031                                 array_unshift( $this->mode, self::MODE_IGNORE );
1032                                 array_unshift( $this->curItem, $ns . ' ' . $tag );
1033
1034                                 return;
1035                         }
1036                 }
1037                 // process attributes
1038                 $this->doAttribs( $attribs );
1039         }
1040
1041         /**
1042          * Hit an opening element when in a Struct (MODE_STRUCT)
1043          * This is generally for fields of a compound property.
1044          *
1045          * Example of a struct (abbreviated; flash has more properties):
1046          *
1047          * <exif:Flash> <rdf:Description> <exif:Fired>True</exif:Fired>
1048          *  <exif:Mode>1</exif:Mode></rdf:Description></exif:Flash>
1049          *
1050          * or:
1051          *
1052          * <exif:Flash rdf:parseType='Resource'> <exif:Fired>True</exif:Fired>
1053          *  <exif:Mode>1</exif:Mode></exif:Flash>
1054          *
1055          * @param string $ns Namespace
1056          * @param string $tag Tag name (no ns)
1057          * @param array $attribs Array of attribs w/ values.
1058          * @throws RuntimeException
1059          */
1060         private function startElementModeStruct( $ns, $tag, $attribs ) {
1061                 if ( $ns !== self::NS_RDF ) {
1062                         if ( isset( $this->items[$ns][$tag] ) ) {
1063                                 if ( isset( $this->items[$ns][$this->ancestorStruct]['children'] )
1064                                         && !isset( $this->items[$ns][$this->ancestorStruct]['children'][$tag] )
1065                                 ) {
1066                                         // This assumes that we don't have inter-namespace nesting
1067                                         // which we don't in all the properties we're interested in.
1068                                         throw new RuntimeException( " <$tag> appeared nested in <" . $this->ancestorStruct
1069                                                 . "> where it is not allowed." );
1070                                 }
1071                                 array_unshift( $this->mode, $this->items[$ns][$tag]['mode'] );
1072                                 array_unshift( $this->curItem, $ns . ' ' . $tag );
1073                                 if ( $this->charContent !== false ) {
1074                                         // Something weird.
1075                                         // Should not happen in valid XMP.
1076                                         throw new RuntimeException( "tag <$tag> nested in non-whitespace characters (" .
1077                                                 $this->charContent . ")." );
1078                                 }
1079                         } else {
1080                                 array_unshift( $this->mode, self::MODE_IGNORE );
1081                                 array_unshift( $this->curItem, $ns . ' ' . $tag );
1082
1083                                 return;
1084                         }
1085                 }
1086
1087                 if ( $ns === self::NS_RDF && $tag === 'Description' ) {
1088                         $this->doAttribs( $attribs );
1089                         array_unshift( $this->mode, self::MODE_STRUCT );
1090                         array_unshift( $this->curItem, $this->curItem[0] );
1091                 }
1092         }
1093
1094         /**
1095          * opening element in MODE_LI
1096          * process elements of arrays.
1097          *
1098          * Example:
1099          * <exif:ISOSpeedRatings> <rdf:Seq> <rdf:li>64</rdf:li>
1100          *   </rdf:Seq> </exif:ISOSpeedRatings>
1101          * This method is called when we hit the <rdf:li> element.
1102          *
1103          * @param string $elm Namespace . ' ' . tagname
1104          * @param array $attribs Attributes. (needed for BAGSTRUCTS)
1105          * @throws RuntimeException If gets a tag other than <rdf:li>
1106          */
1107         private function startElementModeLi( $elm, $attribs ) {
1108                 if ( ( $elm ) !== self::NS_RDF . ' li' ) {
1109                         throw new RuntimeException( "<rdf:li> expected but got $elm." );
1110                 }
1111
1112                 if ( !isset( $this->mode[1] ) ) {
1113                         // This should never ever ever happen. Checking for it
1114                         // to be paranoid.
1115                         throw new RuntimeException( 'In mode Li, but no 2xPrevious mode!' );
1116                 }
1117
1118                 if ( $this->mode[1] === self::MODE_BAGSTRUCT ) {
1119                         // This list item contains a compound (STRUCT) value.
1120                         array_unshift( $this->mode, self::MODE_STRUCT );
1121                         array_unshift( $this->curItem, $elm );
1122                         $this->processingArray = true;
1123
1124                         if ( !isset( $this->curItem[1] ) ) {
1125                                 // be paranoid.
1126                                 throw new RuntimeException( 'Can not find parent of BAGSTRUCT.' );
1127                         }
1128                         list( $curNS, $curTag ) = explode( ' ', $this->curItem[1] );
1129                         $this->ancestorStruct = isset( $this->items[$curNS][$curTag]['map_name'] )
1130                                 ? $this->items[$curNS][$curTag]['map_name'] : $curTag;
1131
1132                         $this->doAttribs( $attribs );
1133                 } else {
1134                         // Normal BAG or SEQ containing simple values.
1135                         array_unshift( $this->mode, self::MODE_SIMPLE );
1136                         // need to add curItem[0] on again since one is for the specific item
1137                         // and one is for the entire group.
1138                         array_unshift( $this->curItem, $this->curItem[0] );
1139                         $this->processingArray = true;
1140                 }
1141         }
1142
1143         /**
1144          * Opening element in MODE_LI_LANG.
1145          * process elements of language alternatives
1146          *
1147          * Example:
1148          * <dc:title> <rdf:Alt> <rdf:li xml:lang="x-default">My house
1149          *  </rdf:li> </rdf:Alt> </dc:title>
1150          *
1151          * This method is called when we hit the <rdf:li> element.
1152          *
1153          * @param string $elm Namespace . ' ' . tag
1154          * @param array $attribs Array of elements (most importantly xml:lang)
1155          * @throws RuntimeException If gets a tag other than <rdf:li> or if no xml:lang
1156          */
1157         private function startElementModeLiLang( $elm, $attribs ) {
1158                 if ( $elm !== self::NS_RDF . ' li' ) {
1159                         throw new RuntimeException( __METHOD__ . " <rdf:li> expected but got $elm." );
1160                 }
1161                 if ( !isset( $attribs[self::NS_XML . ' lang'] )
1162                         || !preg_match( '/^[-A-Za-z0-9]{2,}$/D', $attribs[self::NS_XML . ' lang'] )
1163                 ) {
1164                         throw new RuntimeException( __METHOD__
1165                                 . " <rdf:li> did not contain, or has invalid xml:lang attribute in lang alternative" );
1166                 }
1167
1168                 // Lang is case-insensitive.
1169                 $this->itemLang = strtolower( $attribs[self::NS_XML . ' lang'] );
1170
1171                 // need to add curItem[0] on again since one is for the specific item
1172                 // and one is for the entire group.
1173                 array_unshift( $this->curItem, $this->curItem[0] );
1174                 array_unshift( $this->mode, self::MODE_SIMPLE );
1175                 $this->processingArray = true;
1176         }
1177
1178         /**
1179          * Hits an opening element.
1180          * Generally just calls a helper based on what MODE we're in.
1181          * Also does some initial set up for the wrapper element
1182          *
1183          * @param resource $parser
1184          * @param string $elm Namespace "<space>" element
1185          * @param array $attribs Attribute name => value
1186          * @throws RuntimeException
1187          */
1188         function startElement( $parser, $elm, $attribs ) {
1189                 if ( $elm === self::NS_RDF . ' RDF'
1190                         || $elm === 'adobe:ns:meta/ xmpmeta'
1191                         || $elm === 'adobe:ns:meta/ xapmeta'
1192                 ) {
1193                         /* ignore. */
1194                         return;
1195                 } elseif ( $elm === self::NS_RDF . ' Description' ) {
1196                         if ( count( $this->mode ) === 0 ) {
1197                                 // outer rdf:desc
1198                                 array_unshift( $this->mode, self::MODE_INITIAL );
1199                         }
1200                 } elseif ( $elm === self::NS_RDF . ' type' ) {
1201                         // This doesn't support rdf:type properly.
1202                         // In practise I have yet to see a file that
1203                         // uses this element, however it is mentioned
1204                         // on page 25 of part 1 of the xmp standard.
1205                         // Also it seems as if exiv2 and exiftool do not support
1206                         // this either (That or I misunderstand the standard)
1207                         $this->logger->info( __METHOD__ . ' Encountered <rdf:type> which isn\'t currently supported' );
1208                 }
1209
1210                 if ( strpos( $elm, ' ' ) === false ) {
1211                         // This probably shouldn't happen.
1212                         $this->logger->info( __METHOD__ . " Encountered <$elm> which has no namespace. Skipping." );
1213
1214                         return;
1215                 }
1216
1217                 list( $ns, $tag ) = explode( ' ', $elm, 2 );
1218
1219                 if ( count( $this->mode ) === 0 ) {
1220                         // This should not happen.
1221                         throw new RuntimeException( 'Error extracting XMP, '
1222                                 . "encountered <$elm> with no mode" );
1223                 }
1224
1225                 switch ( $this->mode[0] ) {
1226                         case self::MODE_IGNORE:
1227                                 $this->startElementModeIgnore( $elm );
1228                                 break;
1229                         case self::MODE_SIMPLE:
1230                                 $this->startElementModeSimple( $elm, $attribs );
1231                                 break;
1232                         case self::MODE_INITIAL:
1233                                 $this->startElementModeInitial( $ns, $tag, $attribs );
1234                                 break;
1235                         case self::MODE_STRUCT:
1236                                 $this->startElementModeStruct( $ns, $tag, $attribs );
1237                                 break;
1238                         case self::MODE_BAG:
1239                         case self::MODE_BAGSTRUCT:
1240                                 $this->startElementModeBag( $elm );
1241                                 break;
1242                         case self::MODE_SEQ:
1243                                 $this->startElementModeSeq( $elm );
1244                                 break;
1245                         case self::MODE_LANG:
1246                                 $this->startElementModeLang( $elm );
1247                                 break;
1248                         case self::MODE_LI_LANG:
1249                                 $this->startElementModeLiLang( $elm, $attribs );
1250                                 break;
1251                         case self::MODE_LI:
1252                                 $this->startElementModeLi( $elm, $attribs );
1253                                 break;
1254                         case self::MODE_QDESC:
1255                                 $this->startElementModeQDesc( $elm );
1256                                 break;
1257                         default:
1258                                 throw new RuntimeException( 'StartElement in unknown mode: ' . $this->mode[0] );
1259                 }
1260         }
1261
1262         // @codingStandardsIgnoreStart Generic.Files.LineLength
1263         /**
1264          * Process attributes.
1265          * Simple values can be stored as either a tag or attribute
1266          *
1267          * Often the initial "<rdf:Description>" tag just has all the simple
1268          * properties as attributes.
1269          *
1270          * @par Example:
1271          * @code
1272          * <rdf:Description rdf:about="" xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:DigitalZoomRatio="0/10">
1273          * @endcode
1274          *
1275          * @param array $attribs Array attribute=>value
1276          * @throws RuntimeException
1277          */
1278         // @codingStandardsIgnoreEnd
1279         private function doAttribs( $attribs ) {
1280                 // first check for rdf:parseType attribute, as that can change
1281                 // how the attributes are interperted.
1282
1283                 if ( isset( $attribs[self::NS_RDF . ' parseType'] )
1284                         && $attribs[self::NS_RDF . ' parseType'] === 'Resource'
1285                         && $this->mode[0] === self::MODE_SIMPLE
1286                 ) {
1287                         // this is equivalent to having an inner rdf:Description
1288                         $this->mode[0] = self::MODE_QDESC;
1289                 }
1290                 foreach ( $attribs as $name => $val ) {
1291                         if ( strpos( $name, ' ' ) === false ) {
1292                                 // This shouldn't happen, but so far some old software forgets namespace
1293                                 // on rdf:about.
1294                                 $this->logger->info( __METHOD__ . ' Encountered non-namespaced attribute: '
1295                                         . " $name=\"$val\". Skipping. " );
1296                                 continue;
1297                         }
1298                         list( $ns, $tag ) = explode( ' ', $name, 2 );
1299                         if ( $ns === self::NS_RDF ) {
1300                                 if ( $tag === 'value' || $tag === 'resource' ) {
1301                                         // resource is for url.
1302                                         // value attribute is a weird way of just putting the contents.
1303                                         $this->char( $this->xmlParser, $val );
1304                                 }
1305                         } elseif ( isset( $this->items[$ns][$tag] ) ) {
1306                                 if ( $this->mode[0] === self::MODE_SIMPLE ) {
1307                                         throw new RuntimeException( __METHOD__
1308                                                 . " $ns:$tag found as attribute where not allowed" );
1309                                 }
1310                                 $this->saveValue( $ns, $tag, $val );
1311                         } else {
1312                                 $this->logger->debug( __METHOD__ . " Ignoring unrecognized element <$ns:$tag>." );
1313                         }
1314                 }
1315         }
1316
1317         /**
1318          * Given an extracted value, save it to results array
1319          *
1320          * note also uses $this->ancestorStruct and
1321          * $this->processingArray to determine what name to
1322          * save the value under. (in addition to $tag).
1323          *
1324          * @param string $ns Namespace of tag this is for
1325          * @param string $tag Tag name
1326          * @param string $val Value to save
1327          */
1328         private function saveValue( $ns, $tag, $val ) {
1329                 $info =& $this->items[$ns][$tag];
1330                 $finalName = isset( $info['map_name'] )
1331                         ? $info['map_name'] : $tag;
1332                 if ( isset( $info['validate'] ) ) {
1333                         if ( is_array( $info['validate'] ) ) {
1334                                 $validate = $info['validate'];
1335                         } else {
1336                                 $validator = new XMPValidate( $this->logger );
1337                                 $validate = [ $validator, $info['validate'] ];
1338                         }
1339
1340                         if ( is_callable( $validate ) ) {
1341                                 call_user_func_array( $validate, [ $info, &$val, true ] );
1342                                 // the reasoning behind using &$val instead of using the return value
1343                                 // is to be consistent between here and validating structures.
1344                                 if ( is_null( $val ) ) {
1345                                         $this->logger->info( __METHOD__ . " <$ns:$tag> failed validation." );
1346
1347                                         return;
1348                                 }
1349                         } else {
1350                                 $this->logger->warning( __METHOD__ . " Validation function for $finalName ("
1351                                         . $validate[0] . '::' . $validate[1] . '()) is not callable.' );
1352                         }
1353                 }
1354
1355                 if ( $this->ancestorStruct && $this->processingArray ) {
1356                         // Aka both an array and a struct. ( self::MODE_BAGSTRUCT )
1357                         $this->results['xmp-' . $info['map_group']][$this->ancestorStruct][][$finalName] = $val;
1358                 } elseif ( $this->ancestorStruct ) {
1359                         $this->results['xmp-' . $info['map_group']][$this->ancestorStruct][$finalName] = $val;
1360                 } elseif ( $this->processingArray ) {
1361                         if ( $this->itemLang === false ) {
1362                                 // normal array
1363                                 $this->results['xmp-' . $info['map_group']][$finalName][] = $val;
1364                         } else {
1365                                 // lang array.
1366                                 $this->results['xmp-' . $info['map_group']][$finalName][$this->itemLang] = $val;
1367                         }
1368                 } else {
1369                         $this->results['xmp-' . $info['map_group']][$finalName] = $val;
1370                 }
1371         }
1372 }