<?php
/**
 * Database and template file access for MLA needs
 *
 * @package Media Library Assistant
 * @since 0.1
 */

/**
 * Class MLA (Media Library Assistant) Data provides database and template file access for MLA needs
 *
 * The _template functions are inspired by the book "WordPress 3 Plugin Development Essentials."
 * Templates separate HTML markup from PHP code for easier maintenance and localization.
 *
 * @package Media Library Assistant
 * @since 0.1
 */
class MLAData {
	/**
	 * Initialization function, similar to __construct()
	 *
	 * @since 0.1
	 */
	public static function initialize() {
		// Moved to MLAQuery but retained here for example plugins.
		self::$search_parameters =& MLAQuery::$search_parameters;
		self::$query_parameters =& MLAQuery::$query_parameters;

		add_action( 'save_post', 'MLAData::mla_save_post_action', 10, 1);
		add_action( 'edit_attachment', 'MLAData::mla_save_post_action', 10, 1);
		add_action( 'add_attachment', 'MLAData::mla_save_post_action', 10, 1);
	}

	/**
	 * Find a complete template, balancing opening and closing delimiters
	 *
	 * @since 1.50
	 *
	 * @param	string	A string possibly starting with '[+template:'
	 *
	 * @return	string	'' or template string starting with '[+template:' and ending with the matching '+]'
	 */
	private static function _find_template_substring( $tpl ) {
		if ( '[+template:' == substr( $tpl, 0, 11 ) ) {
			$nest = 11;
			$level = 1;
			do {
				$template_end = strpos( $tpl, '+]', $nest );
				if ( false === $template_end ) {
					/* translators: 1: ERROR tag 2: template excerpt */
					MLACore::mla_debug_add( __LINE__ . sprintf( _x( '%1$s: _find_template_substring no template end delimiter, tail = "%2$s".', 'error_log', 'media-library-assistant' ), __( 'ERROR', 'media-library-assistant' ), substr( $tpl, $nest ) ), MLACore::MLA_DEBUG_CATEGORY_ANY );
					return '';
				}

				$nest = strpos( $tpl, '[+', $nest );
				if ( false === $nest ) {
					$nest = $template_end + 2;
					$level--;
				} elseif ( $nest < $template_end ) {
					$nest += 2;
					$level++;
				} else {
					$nest = $template_end + 2;
					$level--;
				}
			} while ( $level );

			$template_length = $template_end + 2;
			$template_content = substr( $tpl, 0, $template_length );
			return $template_content;
		} // found template

		return '';
	}

	/**
	 * Expand a template, replacing placeholders with their values
	 *
	 * Will return an array of values if one or more of the placeholders returns an array.
	 *
	 * @since 1.50
	 *
	 * @param	string	A formatting string containing [+placeholders+]
	 * @param	array	An associative array containing keys and values e.g. array('key' => 'value')
	 * @param	string	Option value
	 *
	 * @return	mixed	string or array, depending on placeholder values. Placeholders corresponding
	 * to the keys of the markup_values will be replaced with their values.
	 */
	public static function mla_parse_array_template( $tpl, $markup_values, $option = 'export' ) {
		$result = array();	
		$offset = 0;
		while ( false !== $start = strpos( $tpl, '[+', $offset ) ) {
			if ( $offset < $start ) {
				$result[] = substr( $tpl, $offset, ( $start - $offset ) );
			}

			if ( $template_content = self::_find_template_substring( substr( $tpl, $start ) ) ) {
				$template_length = strlen( $template_content );
				$template_content = substr( $template_content, 11, $template_length - (11 + 2) );
				$template_content = self::_expand_field_level_template( $template_content, $markup_values, true );

				foreach ( $template_content as $value )
					$result[] = $value;

				$offset = $start + $template_length;
			} else { // found template
				if ( false === $end = strpos( $tpl, '+]', $offset ) ) {
					/* translators: 1: ERROR tag 2: template excerpt */
					MLACore::mla_debug_add( __LINE__ . sprintf( _x( '%1$s: mla_parse_array_template no template end delimiter, tail = "%2$s".', 'error_log', 'media-library-assistant' ), __( 'ERROR', 'media-library-assistant' ), substr( $tpl, $offset ) ), MLACore::MLA_DEBUG_CATEGORY_ANY );
					return $tpl;
				} // no end delimiter

				$key = substr( $tpl, $start + 2, $end - $start - 2 );
				if ( isset( $markup_values[ $key ] ) ) {
					$result[] = $markup_values[ $key ];
				} else { // found key and scalar value
					$result[] = substr( $tpl, $start, ( $end + 2 ) - $start );
				}

				$offset = $end + 2;
			} // simple substitution
		} // while substitution parameter present

		if ( $offset < strlen( $tpl ) ) {
			$result[] = substr( $tpl, $offset );
		}

		// Build a final result, eliminating empty elements and expanding array elements
		$final = array();
		foreach ( $result as $element ) {
			if ( is_scalar( $element ) ) {
				$element = trim( $element );
				if ( ! empty( $element ) ) {
					$final[] = $element;	
				}
			} elseif ( is_array( $element ) ) {
				foreach ($element as $key => $value ) {
					if ( is_scalar( $value ) ) {
						$value = trim( $value );
					} elseif ( is_array( $value ) && 'array' === $option ) {
						// no change
					} elseif ( ! empty( $value ) ) {
						$value = var_export( $value, true );
					}

					// Preserve any keys with string values
					if ( ! empty( $value ) ) {
						if ( is_integer( $key ) ) {
							$final[] = $value;
						} else {
							$final[ $key ] = $value;					
						}
					}
				}
			} elseif ( ! empty( $element ) ) {
				$final[] = var_export( $element, true );
			}
		}

		if ( 1 == count( $final ) ) {
			// Don't flatten a string value key
			if ( isset( $final[0] ) ) {
				$final = $final[0];
			}
		}

		return $final;
	}

	/**
	 * Expand a template, replacing placeholders with their values
	 *
	 * A simple parsing function for basic templating.
	 *
	 * @since 0.1
	 *
	 * @param	string	A formatting string containing [+placeholders+]
	 * @param	array	An associative array containing keys and values e.g. array('key' => 'value')
	 *
	 * @return	strng	Placeholders corresponding to the keys of the markup_values will be replaced with their values.
	 */
	public static function mla_parse_template( $tpl, $markup_values ) {
		// If templates are present we must step through $tpl and expand them
		if ( isset( $markup_values['[+template_count+]'] ) ) {
			$offset = 0;
			while ( false !== $start = strpos( $tpl, '[+', $offset ) ) {
				if ( $template_content = self::_find_template_substring( substr( $tpl, $start ) ) ) {
					$template_length = strlen( $template_content );
					$template_content = substr( $template_content, 11, $template_length - (11 + 2) );
					$template_content = self::_expand_field_level_template( $template_content, $markup_values );
					$tpl = substr_replace( $tpl, $template_content, $start, $template_length );
					$offset = $start;
				} else { // found template
					if ( false === $end = strpos( $tpl, '+]', $offset ) ) {
					/* translators: 1: ERROR tag 2: template excerpt */
					MLACore::mla_debug_add( __LINE__ . ' ' . sprintf( _x( '%1$s: mla_parse_template no end delimiter, tail = "%2$s".', 'error_log', 'media-library-assistant' ), __( 'ERROR', 'media-library-assistant' ), substr( $tpl, $offset ) ), MLACore::MLA_DEBUG_CATEGORY_ANY );
						return $tpl;
					} // no end delimiter

					$key = substr( $tpl, $start + 2, $end - $start - 2 );
					if ( isset( $markup_values[ $key ] ) && is_scalar( $markup_values[ $key ] ) ) {
						$tpl = substr_replace( $tpl, $markup_values[ $key ], $start, strlen( $key ) + 4 );
						$offset = $start;
					} else { // found key and scalar value
						$offset += strlen( $key ) + 4;
					}
				} // simple substitution
			} // while substitution parameter present
		} else { // template(s) present
			// No templates means a simple string substitution will suffice
			foreach ( $markup_values as $key => $value ) {
				if ( is_scalar( $value ) ) {
					$tpl = str_replace( '[+' . $key . '+]', $value, $tpl );
				}
			}
		}

		return $tpl;
	}

	/**
	 * Find a complete substitution parameter/template, balancing nested delimiters
	 *
	 * @since 2.84
	 *
	 * @param	string	$tpl A string possibly starting with '[+' at $offset
	 * @param	integer	$offset Position within $tpl to start parsing
	 *
	 * @return	integer	length of substitution parameter
	 */
	private static function _find_parameter( $tpl, $offset ) {
		// Parameter must start with opening delimiter 
		if ( $offset === strpos( $tpl, '[+', $offset ) ) {
			$start = $offset;
			$offset += 2;
			$level = 1;
			do {
				$next_open = strpos( $tpl, '[+', $offset ); 
				$next_close = strpos( $tpl, '+]', $offset ); 

				// See if a nested opening delimiter is next
				if ( ( false !== $next_open ) && ( $next_open < $next_close ) ) {
					$offset = $next_open + 2;
					$level++;
					continue;
				}

				// See if a closing delimiter is next
				if ( false !== $next_close )  {
					$offset = $next_close + 2;
					$level--;
					continue;
				}

				// Delimiters are not balanced - abort
				/* translators: 1: ERROR tag 2: template string */
				MLACore::mla_debug_add( __LINE__ . ' ' . sprintf( _x( '%1$s: _find_parameter no end delimiter, tail = "%2$s".', 'error_log', 'media-library-assistant' ), __( 'ERROR', 'media-library-assistant' ), substr( $tpl, $offset ) ), MLACore::MLA_DEBUG_CATEGORY_ANY );
				return strlen( $tpl ) - $offset;
			} while ( $level );

			return $offset - $start;
		} // found opening delimiter

		// No opening delimiter - abort
		return strlen( $tpl ) - $offset;
	}

	/**
	 * Find a complete delimited element, balancing opening and closing delimiters
	 *
	 * @since 1.50
	 *
	 * @param	string	$tpl A string possibly starting with the $open_delimiter
	 * @param	string	$open_delimiter Optional opening delimiter, default '('
	 * @param	string	$close_delimiter Optional closing delimiter, default ')'
	 *
	 * @return	string	'' or template substring including the opening and closing delimiters
	 */
	private static function _find_delimited_substring( $tpl, $open_delimiter = '(', $close_delimiter = ')' ) {
		// Template must start with opening delimiter 
		if ( $open_delimiter == $tpl[0] ) {
			$offset = 1;
			$level = 1;
			do {
				$next_parameter = strpos( $tpl, '[+', $offset ); 

				$next_open = strpos( $tpl, $open_delimiter, $offset ); 
				// Skip over escaped opening delimiters
				if ( $next_open ) {
					while ( $next_open && '\\' === $tpl[ $next_open - 1 ] ) {
						$next_open = strpos( $tpl, $open_delimiter, $next_open + 1 );
					}
				}

				$next_close = strpos( $tpl, $close_delimiter, $offset ); 
				// Skip over escaped closing delimiters
				if ( $next_close ) {
					while ( $next_close && '\\' === $tpl[ $next_close - 1 ] ) {
						$next_close = strpos( $tpl, $close_delimiter, $next_close + 1 );
					}
				}

				// See if a parameter is next
				if ( ( false !== $next_parameter ) && ( $next_parameter < $next_open ) && ( $next_parameter < $next_close ) ) {
					$offset = $next_parameter + self::_find_parameter( $tpl, $next_parameter );
					continue;
				}

				// See if a nested opening delimiter is next
				if ( ( false !== $next_open ) && ( $next_open < $next_close ) ) {
					$offset = $next_open + 1;
					$level++;
					continue;
				}

				// See if a closing delimiter is next
				if ( false !== $next_close )  {
					$offset = $next_close + 1;
					$level--;
					continue;
				}

				// Delimiters are not balanced - abort
				/* translators: 1: ERROR tag 2: template string */
				MLACore::mla_debug_add( __LINE__ . ' ' . sprintf( _x( '%1$s: _find_delimited_substring no end delimiter, tail = "%2$s".', 'error_log', 'media-library-assistant' ), __( 'ERROR', 'media-library-assistant' ), substr( $tpl, $offset ) ), MLACore::MLA_DEBUG_CATEGORY_ANY );
				return '';
			} while ( $level );

			return substr( $tpl, 0, $offset );
		} // found opening delimiter

		// No opening delimiter - abort
		return '';
	}

	/**
	 * Convert field-level "template:" string into its component parts
	 *
	 * @since 1.50
	 *
	 * @param	string	Template content with string, test and choice elements
	 *
	 * @return	array	( node => array( type => "string | test | choice | template", length => bytes, value => string | node(s) ) )
	 */
	private static function _parse_field_level_template( $tpl ) {
		$index = 0;
		$max_length = strlen( $tpl );
		$test_level = 0;
		$output = '';
		$output_values = array();
		$choice_values = array();
		while ( $index < $max_length ) {
			$byte = $tpl[ $index++ ];
			if ( '\\' == $byte ) {
				if ( $index == $max_length ) {
					$output .= $byte;
					continue;
				} // template ends with a backslash

				switch ( $tpl[ $index ] ) {
					case 'n':
						$output .= chr( 0x0A );
						break;
					case 'r':
						$output .= chr( 0x0D );
						break;
					case 't':
						$output .= chr( 0x09 );
						break;
					case 'b':
						$output .= chr( 0x08 );
						break;
					case 'f':
						$output .= chr( 0x0C );
						break;
					default: // could be a 1- to 3-digit octal value
						if ( $max_length < ( $digit_limit = $index + 3 ) ) {
							$digit_limit = $max_length;
						}

						$digit_index = $index;
						while ( $digit_index < $digit_limit )
							if ( ! ctype_digit( $tpl[ $digit_index ] ) ) {
								break;
							} else {
								$digit_index++;
							}

						if ( $digit_count = $digit_index - $index ) {
							$output .= chr( octdec( substr( $tpl, $index, $digit_count ) ) );
							$index += $digit_count - 1;
						} else { // accept the character following the backslash
							$output .= $tpl[ $index ];
						}
				} // switch

				$index++;
			} // REVERSE SOLIDUS (backslash)
			elseif ( '(' == $byte ) {
				if ( ! empty( $output ) ) {
					$output_values[] = array( 'type' => 'string', 'value' => $output, 'length' => strlen( $output ) );
					$output = '';				
				}

				$test_content = self::_find_delimited_substring( substr( $tpl, $index - 1 ) );
				if ( 2 < $test_length = strlen( $test_content ) ) {
					$values = self::_parse_field_level_template( substr( $test_content, 1, strlen( $test_content ) - 2 ) );
					$output_values[] = array( 'type' => 'test', 'value' => $values, 'length' => strlen( $test_content ) );
					$index += strlen( $test_content ) - 1;
				} // found a value
				elseif ( 2 == $test_length ) {
					$index++; // empty test string
				} else {
					$test_content = __( 'ERROR', 'media-library-assistant' ) . ': ' . __( 'Test; no closing parenthesis ', 'media-library-assistant' );
					$output_values[] = array( 'type' => 'string', 'value' => $test_content, 'length' => strlen( $test_content ) );
				} // bad test string
			} // (test) element
			elseif ( '|' == $byte ) {
				// Turn each alternative within a choice element into a conditional
				if ( ! empty( $output ) ) {
					$output_values[] = array( 'type' => 'string', 'value' => $output, 'length' => strlen( $output ) );
					$output = '';				
				}

				$length = 0;
				foreach ( $output_values as $value ) 
					if ( isset( $value['length'] ) ) {
						$length += $value['length'];
					}

				$choice_values[] = array( 'type' => 'test', 'value' => $output_values, 'length' => $length );
				$output_values = array();
			} // choice element
			elseif ( '[' == $byte && '+template:' == substr( $tpl, $index, 10 ) ) {
				if ( ! empty( $output ) ) {
					$output_values[] = array( 'type' => 'string', 'value' => $output, 'length' => strlen( $output ) );
					$output = '';				
				}

				$template_content = self::_find_template_substring( substr( $tpl, $index - 1 ) );
				$values = self::_parse_field_level_template( substr( $template_content, 11, strlen( $template_content ) - (11 + 2) ) );
				if ( 'template' == $values['type'] ) {
					$output_values = array_merge( $output_values, $values['value'] );
				} else {
					$output_values[] = $values;
				}

				$index += strlen( $template_content ) - 1;
			} // nested template
			elseif ( '[' == $byte ) {
				$match_count = preg_match( '/\[\+.+?\+\]/', $tpl, $matches, 0, $index - 1 );
				if ( $match_count ) {
					// found substitution parameter
					$output .= $matches[0];
					$index += strlen( $matches[0] ) - 1;
				} else {
					$output .= $byte;
				}
			} // maybe substitution parameter
			else {
				$output .= $byte;
			}
		} // $index < $max_length

		if ( ! empty( $output ) ) {
			$output_values[] = array( 'type' => 'string', 'value' => $output, 'length' => strlen( $output ) );
		}

		if ( ! empty( $choice_values ) ) {
			if ( ! empty( $output_values ) ) {
				$length = 0;
				foreach ( $output_values as $value ) 
					if ( isset( $value['length'] ) ) {
						$length += $value['length'];
					}

				$choice_values[] = array( 'type' => 'test', 'value' => $output_values, 'length' => $length );
			}

			return array( 'type' => 'choice', 'value' => $choice_values, 'length' => $max_length );
		}

		if ( 1 == count( $output_values ) ) {
			return $output_values[0];
		}

		return array ( 'type' => 'template', 'value' => $output_values, 'length' => $max_length );
	}

	/**
	 * Analyze a field-level "template:" element, expanding Field-level Markup Substitution Parameters
	 *
	 * Will return an array of values if one or more of the placeholders returns an array.
	 *
	 * @since 1.50
	 *
	 * @param	array	A field-level template element node
	 * @param	array	An array of markup substitution values
	 *
	 * @return	mixed	string or array, depending on placeholder values. Placeholders corresponding to the keys of the markup_values will be replaced with their values.
	 */
	private static function _evaluate_template_array_node( $node, $markup_values = array() ) {
		$result = array();

		// Check for an array of sub-nodes
		if ( ! isset( $node['type'] ) ) {
			foreach ( $node as $value ) {
				$node_result = self::_evaluate_template_array_node( $value, $markup_values );
				foreach ( $node_result as $value )
					$result[] = $value;
			}
		} else { // array of sub-nodes
			switch ( $node['type'] ) {
				case 'string':
					$result[] = self::mla_parse_array_template( $node['value'], $markup_values, 'array' );
					break;
				case 'test':
					$node_value = $node['value'];

					if ( isset( $node_value['type'] ) ) {
						$node_result = self::_evaluate_template_array_node( $node_value, $markup_values );
						foreach ( $node_result as $value )
							$result[] = $value;
					} else { // single node
						foreach ( $node_value as $value ) {
							$node_result = self::_evaluate_template_array_node( $value, $markup_values );
							foreach ( $node_result as $value )
								$result[] = $value;
						}
					} // array of nodes

					foreach ($result as $element )
						if ( is_scalar( $element ) && false !== strpos( $element, '[+' ) ) {
							$result = array();
							break;
						} elseif ( is_array( $element ) ) {
							foreach ( $element as $value ) 
								if ( is_scalar( $value ) && false !== strpos( $value, '[+' ) ) {
									$result = array();
									break;
								}
						} // is_array

					break;
				case 'choice':
					foreach ( $node['value'] as $value ) {
						$node_result = self::_evaluate_template_array_node( $value, $markup_values );
						if ( ! empty( $node_result ) ) {
							foreach ( $node_result as $value )
								$result[] = $value;
							break;
						}
					}

					break;
				case 'template':
					foreach ( $node['value'] as $value ) {
						$node_result = self::_evaluate_template_array_node( $value, $markup_values );
						foreach ( $node_result as $value )
							$result[] = $value;
					}

					break;
				default:
					/* translators: 1: ERROR tag 2: node type, e.g., template */
					MLACore::mla_debug_add( __LINE__ . sprintf( _x( '%1$s: _evaluate_template_array_node unknown type "%2$s".', 'error_log', 'media-library-assistant' ), __( 'ERROR', 'media-library-assistant' ), $node ), MLACore::MLA_DEBUG_CATEGORY_ANY );
			} // node type
		} // isset node type

		return $result;				
	}

	/**
	 * Analyze a field-level "template:" element, expanding Field-level Markup Substitution Parameters
	 *
	 * @since 1.50
	 *
	 * @param	array	A field-level template element node
	 * @param	array	An array of markup substitution values
	 *
	 * @return	string	String with expanded values, if any
	 */
	private static function _evaluate_template_node( $node, $markup_values = array() ) {
		$results = '';
		/*
		 * Check for an array of sub-nodes
		 */
		if ( ! isset( $node['type'] ) ) {
			foreach ( $node as $value )
				$results .= self::_evaluate_template_node( $value, $markup_values );

			return $results;
		} // array of sub-nodes

		switch ( $node['type'] ) {
			case 'string':
				return self::mla_parse_template( $node['value'], $markup_values );
			case 'test':
				$node_value = $node['value'];

				if ( isset( $node_value['type'] ) ) {
					$results = self::_evaluate_template_node( $node_value, $markup_values );
				} else { // single node
					foreach ( $node_value as $value )
						$results .= self::_evaluate_template_node( $value, $markup_values );
				} // array of nodes

				if ( false === strpos( $results, '[+' ) ) {
					return $results;
				}

				break;
			case 'choice':
				foreach ( $node['value'] as $value ) {
					$results = self::_evaluate_template_node( $value, $markup_values );
					if ( ! empty( $results ) ) {
						return $results;
					}
				}

				break;
			case 'template':
				foreach ( $node['value'] as $value )
					$results .= self::_evaluate_template_node( $value, $markup_values );

				return $results;
			default:
				/* translators: 1: ERROR tag 2: node type, e.g., template */
				MLACore::mla_debug_add( __LINE__ . sprintf( _x( '%1$s: _evaluate_template_node unknown type "%2$s".', 'error_log', 'media-library-assistant' ), __( 'ERROR', 'media-library-assistant' ), $node ), MLACore::MLA_DEBUG_CATEGORY_ANY );
		} // node type

		return '';				
	}

	/**
	 * Analyze a field-level "template:" element, expanding Field-level Markup Substitution Parameters
	 *
	 * @since 1.50
	 *
	 * @param	string	A formatting string containing [+placeholders+]
	 * @param	array	An array of markup substitution values
	 * @param	boolean	True to return array value(s), false to return a string
	 *
	 * @return	mixed	Element with expanded string/array values, if any
	 */
	private static function _expand_field_level_template( $tpl, $markup_values = array(), $return_arrays = false ) {
		/*
	 	 * Step 1: parse the template and build the tree of its elements
		 * root node => array( type => "string | test | choice | template", value => string | node(s) )
		 */
		$root_element = self::_parse_field_level_template( $tpl );
		unset( $markup_values['[+template_count+]'] );

		/*
		 * Step 2: Remove all the empty elements from the $markup_values,
		 * so the evaluation of conditional and choice elements is simplified.
		 */
		foreach ( $markup_values as $key => $value ) {
			if ( is_scalar( $value ) ) {
				$value = trim( $value );
			}

			if ( empty( $value ) ) {
				unset( $markup_values[ $key ] );
			}
		}

		// Step 3: walk the element tree and process each node
		if ( $return_arrays ) {
			$results = self::_evaluate_template_array_node( $root_element, $markup_values );
		} else {
			$results = self::_evaluate_template_node( $root_element, $markup_values );
		}

		return $results;
	}

	/**
	 * Process an markup field array value according to the supplied data-format option
	 *
	 * @since 1.50
	 *
	 * @param	array	an array of scalar values
	 * @param	string	data option; 'text'|'single'|'export'|'array'|'multi'
	 * @param	boolean	Optional: for option 'multi', retain existing values
	 *
	 * @return	array	( parameter => value ) for all field-level parameters and anything in $markup_values
	 */
	private static function _process_field_level_array( $record, $option = 'text', $keep_existing = false ) {
		switch ( $option ) {
			case 'single':
				$text = sanitize_text_field( current( $record ) );
				break;
			case 'export':
				$text = sanitize_text_field( var_export( $record, true ) );
				break;
			case 'unpack':
				if ( is_array( $record ) ) {
					$clean_data = array();
					foreach ( $record as $key => $value ) {
						if ( is_array( $value ) ) {
							$clean_data[ $key ] = '(ARRAY)';
						} elseif ( is_string( $value ) ) {
							$clean_data[ $key ] = self::_bin_to_utf8( substr( $value, 0, 256 ) );
						} else {
							$clean_data[ $key ] = $value;
						}
					}

					$text = sanitize_text_field( var_export( $clean_data, true) );
				} else {
					$text = sanitize_text_field( var_export( $record, true ) );
				}
				break;
			case 'multi':
				$record[0x80000000] = 'multi';
				$record[0x80000001] = $keep_existing;
				// fallthru
			case 'array':
				$text = $record;
				break;
			default: // or 'text'
				$text = '';
				foreach ( $record as $term ) {
					$term_name = sanitize_text_field( $term );
					$text .= strlen( $text ) ? ', ' . $term_name : $term_name;
				}
		} // $option

		return $text;
	}

	/**
	 * Process an argument list within a field-level parameter format specification
	 *
	 * @since 2.02
	 *
	 * @param	string	arguments, e.g., ('d/m/Y H:i:s' , "arg, \" two" ) without parens
	 *
	 * @return	array	individual arguments, e.g. array( 0 => 'd/m/Y H:i:s', 1 => 'arg, " two' )
	 */
	private static function _parse_arguments( $argument_string ) {
		$argument_string = trim( $argument_string, " \n\t\r\0\x0B," );
		$arguments = array();

		while ( strlen( $argument_string ) ) {
			$argument = '';
			$index = 0;

			// Check for array or enclosing quotes
			$delimiter = $argument_string[0];

			// Check for array
			if ( '{' == $delimiter ) {
				$array = self::_find_delimited_substring( substr( $argument_string, $index ), '{', '}' );
				if ( 2 < strlen( $array ) ) {
					$content = substr( $array, 1, strlen( $array ) - 2 );
					$argument = self::_parse_arguments( $content );
					$index += strlen( $array );
				} else {
					// Bad array format, skip the rest
					$argument = '';
					$index = strlen( $argument_string );
				}
			} else {
				// Check for enclosing quotes
				if ( '\'' == $delimiter || '"' == $delimiter ) {
					$index++;
				} else {
					$delimiter = '';
				}

				while ( $index < strlen( $argument_string ) ) {
					$byte = $argument_string[ $index++ ];
					if ( '\\' == $byte ) {
						switch ( $argument_string[ $index ] ) {
							case 'n':
								$argument .= chr( 0x0A );
								break;
							case 'r':
								$argument .= chr( 0x0D );
								break;
							case 't':
								$argument .= chr( 0x09 );
								break;
							case 'b':
								$argument .= chr( 0x08 );
								break;
							case 'f':
								$argument .= chr( 0x0C );
								break;
							default: // could be a 1- to 3-digit octal value
								$digit_limit = $index + 3;
								$digit_index = $index;
								while ( $digit_index < $digit_limit ) {
									if ( ! ctype_digit( $argument_string[ $digit_index ] ) ) {
										break;
									} else {
										$digit_index++;
									}
								}

								if ( $digit_count = $digit_index - $index ) {
									$argument .= chr( octdec( substr( $argument_string, $index, $digit_count ) ) );
									$index += $digit_count - 1;
								} else { // accept the character following the backslash
									$argument .= $argument_string[ $index ];
								}
						} // switch

						$index++;
					} else { // backslash
						if ( $delimiter === $byte || ( empty( $delimiter ) && ',' === $byte ) ) {
							break;
						}

						$argument .= $byte;
					} // just another 8-bit value, but check for closing delimiter
				} // index < strlen
			} // non-array

			$arguments[] = $argument;
			$argument_string = trim( substr( $argument_string, $index ), " \n\t\r\0\x0B," );
		} // strlen( $argument_string )

		return $arguments;
	}

	/**
	 * Regular expression pattern/subpattern matches
	 *
	 * This array contains values matched in the "match" and "extract" format/option functions,
	 * making them available for the "matches:" data source prefix.
	 *
	 * @since 2.71
	 *
	 * @var	array
	 */
	public static $regex_matches = array();

	/**
	 * Clear out the matches: prefix values
	 *
	 * @since 2.71
	 *
	 * @param	int		current attachment ID
	 */
	public static function mla_reset_regex_matches( $post_id ) {
		static $current_id = 0;

		// Do this once per post.
		if ( $current_id != $post_id ) {
			$current_id = $post_id;
			MLAData::$regex_matches = array();
		}
	}

	/**
	 * Intercept regex pattern matching errors
	 * 
	 * @since 2.54
	 *
	 * @param	int		the level of the error raised
	 * @param	string	the error message
	 * @param	string	the filename that the error was raised in
	 * @param	int		the line number the error was raised at
	 *
	 * @return	boolean	true, to bypass PHP error handler
	 */
	public static function preg_error_handler( $type, $string, $file, $line ) {
		MLACore::mla_debug_add( __LINE__ . " MLAData::preg_error_handler( $type, $string, $file, $line )", MLACore::MLA_DEBUG_CATEGORY_ANY );
		// Don't execute PHP internal error handler
		return true;
	}

	/**
	 * Parse a field-level substitution parameter (not a template) into its component parts
	 *
	 * @since 2.98
	 *
	 * @param	string	Field-level parameter, with or without enclosing '[+' and '+]'
	 * @param	string	Default format/option value
	 *
	 * @return	array	Parameter components: prefix, value, qualifier, option, format, args
	 */
	public static function mla_parse_substitution_parameter( $parameter, $default_option = 'text' ) {

			// Strip optional enclosing delimiters
			if ( 0 === strpos( $parameter, '[+' ) ) {
				$parameter = substr( $parameter, 2, (strlen( $parameter ) - 4 ) );
			}
			
			$result = array( 'prefix' => '', 'value' => '', 'qualifier' => '', 'option' => $default_option, 'format' => 'native', 'args' => '' );

			// Split the prefix, name and qualifier from the format/option code
			$match_count = preg_match( '/([^,]+),(.+)/', $parameter, $matches );
			if ( 1 == $match_count ) {
				$name = $matches[1];
				$tail = $matches[2];
			} else {
				$name = $parameter;
				$tail = '';
			}

			$match_count = preg_match( '/([^:]+):(.+)/', $name, $matches );
			if ( 1 == $match_count ) {
				$result['prefix'] = $matches[1];
				$result['value'] = $matches[2];
			} else {
				$result['value'] = $name;
			}

			if ( !empty( $tail ) ) {
				$match_count = preg_match( '/((text|single|export|unpack|array|multi|commas|raw|attr|url|kbmb|timestamp|date|fraction|substr|str_replace|match|extract|replace))(\((.*)\)$)*/', $tail, $matches );
				if ( 1 == $match_count ) {
					if ( ! empty( $matches[4] ) ) {
						$args = self::_parse_arguments( $matches[4] );

						if ( 1 == count( $args ) ) {
							$args = $args[0];
						}
					} else {
						$args = '';
					}

					if ( in_array( $matches[2], array( 'commas', 'raw', 'attr', 'url', 'kbmb', 'timestamp', 'date', 'fraction', 'substr', 'str_replace', 'match', 'extract', 'replace' ) ) ) {
						$result['option'] = 'text';
						$result['format'] = $matches[2];
						$result['args'] = $args;
					} else {
						$result['option'] = $matches[2];
					}
				} else {
					$match_count = preg_match( '/([^(]+)(\(([^)]+)\))*/', $tail, $matches );
					if ( 1 == $match_count ) {
						$result['format'] = $matches[1];

						if ( ! empty( $matches[3] ) ) {
							$args = self::_parse_arguments( $matches[3] );

							if ( 1 == count( $args ) ) {
								$args = $args[0];
							}
							$result['args'] = $args;
						}
					}
				}
			}

		// Look for field/value qualifier
		$match_count = preg_match( '/^(.+)\((.+)\)/', $result['value'], $matches );
		if ( $match_count ) {
			$result['value'] = $matches[1];
			$result['qualifier'] = $matches[2];
		}

		return $result;
	}

	/**
	 * Apply field-level format options to field-level content
	 *
	 * @since 2.10
	 *
	 * @param	string	field-level content
	 * @param	array	format code and aguments
	 *
	 * @return	string	formatted field-level content
	 */
	public static function mla_apply_field_level_format( $value, $args ) {
		if ( empty( $value ) ) {
			return $value;
		}

		switch ( $args['format'] ) {
			case 'native':
			case 'raw':
				break;
			case 'attr':
				$value = esc_attr( $value );
				break;
			case 'url':
				$value = urlencode( $value );
				break;
			case 'commas':
				if ( is_numeric( $value ) ) {
					$value = number_format( (float)$value );
				}
				break;
			case 'kbmb':
				$number = '';
				if ( is_numeric( $value ) ) {
					$number = (string) $value;
				} elseif ( is_string( $value ) ) {
					$index = 0;
					while ( $index < strlen( $value ) ) {
						if ( ctype_digit( $value[ $index ] ) || ( '.' == $value[ $index ] ) ) {
							$number .= $value[ $index ];
						}
						$index++;
					}
				}

				$threshold = 10240;
				$kb_suffix = ' KB';
				$mb_suffix = ' MB';
				$precision = 3;

				if ( is_array( $args['args'] ) ) {
					$precision = isset( $args['args'][3] ) ? absint( $args['args'][3] ) : $precision;
					$mb_suffix = isset( $args['args'][2] ) ? $args['args'][2] : $mb_suffix;
					$kb_suffix = isset( $args['args'][1] ) ? $args['args'][1] : $kb_suffix;
					$args['args'] = $args['args'][0];
				}

				if ( is_numeric( $args['args'] ) ) {
					$threshold = absint( $args['args'] );
				}

				$number = (float) $number;
				if ( 1048576 < $number ) {
					$value = number_format( ( $number/1048576 ), $precision ) . $mb_suffix;
				} elseif ( $threshold < $number ) {
					if ( empty( $kb_suffix ) ) {
						$value = number_format( ( $number/1048576 ), $precision ) . $mb_suffix;
					} else {
						$value = number_format( ( $number/1024 ), $precision ) . $kb_suffix;
					}
				} else {
					$value = number_format( $number );
				}
				break;
			case 'timestamp':
				if ( is_numeric( $value ) ) {
					$modifier = '';
					$format = empty( $args['args'] ) ? 'd/m/Y H:i:s' : $args['args'];

					if ( is_array( $format ) ) {
						$modifier = strtolower( trim( $format[1] ) );
						$format = $format[0];
					}

					switch ( $modifier ) {
						case 'i18n':
							$value = date_i18n( $format, (integer) $value );
							break;
						case 'age':
							$value = sprintf( $format, human_time_diff( (integer) $value ) );
							break;
						default:
							// date "Returns a string formatted according to the given format string using the given integer"
							$value = date( $format, (integer) $value );
					}
				}

				break;
			case 'date':
				/*
				 * strtotime will "Parse about any English textual datetime description into a Unix timestamp"
				 * If it succeeds we can format the timestamp for display
				 */
				$timestamp = strtotime( $value );
				if( false !== $timestamp ) {
					$modifier = '';
					$format = empty( $args['args'] ) ? 'd/m/Y H:i:s' : $args['args'];

					if ( is_array( $format ) ) {
						$modifier = strtolower( trim( $format[1] ) );
						$format = $format[0];
					}

					switch ( $modifier ) {
						case 'i18n':
							$value = date_i18n( $format, $timestamp );
							break;
						case 'age':
							$value = sprintf( $format, human_time_diff( $timestamp ) );
							break;
						default:
							$value = date( $format, $timestamp );
					}
				}

				break;
			case 'fraction':
				$show_fractions = true;
				if ( ! empty( $args['args'] ) ) {
					if ( is_array( $args['args'] ) ) {
						if ( is_numeric( $args['args'][0] ) ) {
							$format = '%1$+.' . absint( $args['args'][0] ) . 'f';
						} else {
							$format = $args['args'][0];
						}

						$show_fractions = ( 'false' !== strtolower( trim( $args['args'][1] ) ) );
					} else {
						if ( is_numeric( $args['args'] ) ) {
							$format = '%1$+.' . absint( $args['args'] ) . 'f';
						} else {
							$format = $args['args'];
						}
					}
				} else {
					$format = '%1$+.2f';
				}

				$fragments = array_map( 'intval', explode( '/', $value ) );
				if ( 1 == count( $fragments ) ) {
					$value = trim( $value );
					if ( ! empty( $value ) ) {
						$value = $value;
					}
				} else {
					if ( $fragments[0] ) {
						if ( 1 == $fragments[1] ) {
							$value = sprintf( '%1$+d', $fragments[0] );
						} elseif ( 0 != $fragments[1] ) {
							$value = $fragments[0] / $fragments[1];
							if ( $show_fractions && ( -1 <= $value ) && ( 1 >= $value ) ) {
								$value = sprintf( '%1$+d/%2$d', $fragments[0], $fragments[1] );
							} else {
								$value = sprintf( $format, $value );
							}
						} // fractional value
					} // non-zero numerator
				} // valid denominator
				break;
			case 'substr':
				$start = 0;
				$length = strlen( $value );

				if ( ! empty( $args['args'] ) ) {
					if ( is_array( $args['args'] ) ) {
						$start = (int) $args['args'][0];

						if ( 1 < count( $args['args'] ) ) {
							$length = (int) $args['args'][1];
						}
					} else {
						$start = (int) $args['args'];
					}
				}

				if ( false === $value = substr( $value, $start, $length ) ) {
					$value = '';
				}
				break;
			case 'str_replace':
				if ( is_array( $args['args'] ) && ( 2 === count( $args['args'] ) ) ) {
					$value = str_replace( $args['args'][0], $args['args'][1], $value );
				}
				break;
			case 'match':
				$pattern = NULL;

				if ( ! empty( $args['args'] ) ) {
					if ( is_array( $args['args'] ) ) {
						$pattern = trim( $args['args'][0] );
					} else {
						$pattern = trim( $args['args'] );
					}
				}

				if ( empty( $pattern ) ) {
					// No pattern - return empty value
					$value = '';
					break;
				}

				set_error_handler( 'MLAData::preg_error_handler' );
				try {
					$count = preg_match( $pattern, $value, $matches );
				} catch ( Throwable $e ) { // PHP 7
				} catch ( Exception $e ) { // PHP 5
				}
				restore_error_handler();

				if ( $count ) {
					// array_merge won't work because it handles numeric keys differently
					foreach ( $matches as $matches_key => $matches_value ) {
							MLAData::$regex_matches[ $matches_key ] = $matches_value;
					}

					$value = MLAData::$regex_matches[0];
				} else {
					// No pattern or no match
					$value = '';
				}

				break;
			case 'extract':
				$pattern = NULL;
				$return_value = NULL;

				if ( ! empty( $args['args'] ) ) {
					if ( is_array( $args['args'] ) ) {
						$pattern = trim( $args['args'][0] );

						if ( 1 < count( $args['args'] ) ) {
							$return_value = $args['args'][1];

							if ( is_numeric( $return_value ) ) {
								$return_value = (int) $return_value;
							}
						}
					} else {
						$pattern = trim( $args['args'] );
					}
				}

				if ( empty( $pattern ) ) {
					// No pattern - return empty value
					$value = '';
					break;
				}

				set_error_handler( 'MLAData::preg_error_handler' );
				try {
					$count = preg_match( $pattern, $value, $matches );
				} catch ( Throwable $e ) { // PHP 7
				} catch ( Exception $e ) { // PHP 5
				}
				restore_error_handler();

				if ( $count ) {
					// array_merge won't work because it handles numeric keys differently
					foreach ( $matches as $matches_key => $matches_value ) {
							MLAData::$regex_matches[ $matches_key ] = $matches_value;
					}
				} else {
					// No pattern or no match
					$value = '';
					break;
				}

				if ( !is_null( $return_value ) && isset( MLAData::$regex_matches[ $return_value ] ) ) {
					$value = MLAData::$regex_matches[ $return_value ];
				} else {
					$value = '';
				}

				break;
			case 'replace':
				$pattern = NULL;
				$replacement = NULL;
				$return_value = false;

				if ( ! empty( $args['args'] ) ) {
					if ( is_array( $args['args'] ) ) {
						if ( is_array( $args['args'][0] ) ) {
							$pattern = array_map( 'trim', $args['args'][0] );
						} else {
							$pattern = trim( $args['args'][0] );
						}

						if ( 1 < count( $args['args'] ) ) {
							// Don;t trim replacements - they may want the spaces
							$replacement = $args['args'][1];
						}

						if ( 2 < count( $args['args'] ) ) {
							$return_value = 'true' === strtolower( trim( $args['args'][2] ) );
						}
					} else {
						$pattern = trim( $args['args'] );
					}
				}

				if ( empty( $pattern ) || is_null( $replacement ) ) {
					// No pattern or no replacement - return unaltered value
					break;
				}

				// Save original value in case replacement fails
				$old_value = $value;

				// If $return_value is true we return only the matched portion with the modifications applied
				if ( $return_value ) {
					set_error_handler( 'MLAData::preg_error_handler' );
					try {
						$count = preg_match( $pattern, $value, $matches );
					} catch ( Throwable $e ) { // PHP 7
					} catch ( Exception $e ) { // PHP 5
					}
					restore_error_handler();

					if ( $count ) {
						// Keep only the matched portion of the original value
						$value = $matches[0];
					} else {
						// No match - return unaltered value
						break;
					}
				}

				set_error_handler( 'MLAData::preg_error_handler' );
				try {
					$value = preg_replace( $pattern, $replacement, $value );
				} catch ( Throwable $e ) { // PHP 7
				} catch ( Exception $e ) { // PHP 5
				}
				restore_error_handler();

				// Check or error, i.e., bad $replacement pattern
				if ( is_null( $value ) ) {
					$value = $old_value;
				}

				break;
			default:
				$value = apply_filters( 'mla_apply_custom_format', $value, $args );
		}

		return $value;
	}

	/**
	 * Expand an entry for the custom field prefixes
	 *
	 * @since 3.14
	 *
	 * @param	array	$value Field definition with
	 * 					['prefix'] => string, ['value'] => string, ['option'] => string 'text'|'single'|'export'|'array'|'multi'
	 * @param	integer	Attachment or post ID
	 *
	 * @return	mixed	Text representation of term(s) value or false on failure
	 */
	private static function _expand_terms( $value, $post_id ) {
		$taxonomy = $value['value'];
		$field = !empty( $value['qualifier'] ) ? $value['qualifier'] : 'name';

		// Look for compound taxonomy.slug notation
		$matches = explode( '.', $taxonomy );
		if ( 2 === count( $matches ) ) {
			$slug = str_replace( '{+', '[+', str_replace( '+}', '+]', stripslashes( $matches[1] ) ) );
			$replacement_values = MLAData::mla_expand_field_level_parameters( $slug, $query, $markup_values );
			$slug = MLAData::mla_parse_template( $slug, $replacement_values );
			$term = get_term_by( 'slug', $slug, $matches[0] );
			if ( false === $term ) {
				return false;
			}

			$terms = array( $term );
		} else {
			if ( 0 < $post_id ) {
				$terms = get_object_term_cache( $post_id, $taxonomy );
				if ( false === $terms ) {
					$terms = wp_get_object_terms( $post_id, $taxonomy );
					wp_cache_add( $post_id, $terms, $taxonomy . '_relationships' );
				}
			} else {
				return false;
			}
		}

		$text = '';
		if ( is_wp_error( $terms ) ) {
			$text = implode( ',', $terms->get_error_messages() );
		} elseif ( ! empty( $terms ) ) {
			if ( 'single' === $value['option'] || 1 == count( $terms ) ) {
				reset( $terms );
				$term = current( $terms );
				$fields = get_object_vars( $term );
				$text = isset( $fields[ $field ] ) ? $fields[ $field ] : $fields['name'];
				$text = sanitize_term_field( $field, $text, $term->term_id, $taxonomy, 'display' );
			} elseif ( ( 'export' === $value['option'] ) || ( 'unpack' == $value['option'] ) ) {
				$text = sanitize_text_field( var_export( $terms, true ) );
			} else {
				foreach ( $terms as $term ) {
					$fields = get_object_vars( $term );
					$field_value = isset( $fields[ $field ] ) ? $fields[ $field ] : $fields['name'];
					$field_value = sanitize_term_field( $field, $field_value, $term->term_id, $taxonomy, 'display' );
					$text .= strlen( $text ) ? ', ' . $field_value : $field_value;
				}
			}
		}

		return $text;
	}

	/**
	 * Expand an entry for the custom field prefixes
	 *
	 * @since 3.14
	 *
	 * @param	array	$value Field definition with
	 * 					['prefix'] => string, ['value'] => string, ['option'] => string 'text'|'single'|'export'|'array'|'multi'
	 * @param	integer	Attachment or post ID
	 *
	 * @return	string	Text representation of custom field value
	 */
	public static function _expand_custom_field( $value, $post_id ) {
		if ( 0 < $post_id ) {
			$record = get_metadata( 'post', $post_id, $value['value'], 'single' == $value['option'] );
			if ( empty( $record ) && 'ALL_CUSTOM' == $value['value'] ) {
				$meta_values = MLAQuery::mla_fetch_attachment_metadata( $post_id );
				$clean_data = array();
				foreach( $meta_values as $meta_key => $meta_value ) {
					if ( 0 !== strpos( $meta_key, 'mla_item_' ) ) {
						continue;
					}

					$meta_key = substr( $meta_key, 9 );
					if ( is_array( $meta_value ) ) {
						$clean_data[ $meta_key ] = '(ARRAY)';
					} elseif ( is_string( $meta_value ) ) {
						$clean_data[ $meta_key ] = self::_bin_to_utf8( substr( $meta_value, 0, 256 ) );
					} else {
						$clean_data[ $meta_key ] = $meta_value;
					}
				} // foreach value

				/*
				 * Convert the array to text, strip the outer "array( ... ,)" literal,
				 * the interior linefeed/space/space separators and backslashes.
				 */
				$record = var_export( $clean_data, true);
				$record = substr( $record, 7, strlen( $record ) - 10 );
				$record = str_replace( chr(0x0A).'  ', ' ', $record );
				$record = str_replace( '\\', '', $record );
			} // ALL_CUSTOM
		} else {
			return '';
		}

		$text = '';
		if ( is_wp_error( $record ) ) {
			$text = implode( ',', $record->get_error_messages() );
		} elseif ( ! empty( $record ) ) {
			if ( is_scalar( $record ) ) {
				$text = ( 'raw' === $value['format'] ) ? (string) $record : sanitize_text_field( (string) $record );
			} elseif ( is_array( $record ) ) {
				if ( 'export' === $value['option'] ) {
					$text = ( 'raw' === $value['format'] ) ? var_export( $record, true ) : sanitize_text_field( var_export( $record, true ) );
				} else {
					$text = '';
					foreach ( $record as $term ) {
						$term_name = ( 'raw' === $value['format'] ) ? $term : sanitize_text_field( $term );
						$text .= strlen( $text ) ? ', ' . $term_name : $term_name;
					}
				}
			} // is_array
		} // ! empty

		return $text;
	}

	/**
	 * Analyze a template, expanding Field-level Markup Substitution Parameters
	 *
	 * Field-level parameters must have one of the following prefix values:
	 * template, meta, query, request, terms, custom, iptc, exif, xmp, id3, pdf, matches.
	 * All but request and query require an attachment ID.
	 *
	 * @since 1.50
	 *
	 * @param	string	A formatting string containing [+placeholders+]
	 * @param	array	Optional: an array of values from the query, if any, e.g. shortcode parameters
	 * @param	array	Optional: an array of values to add to the returned array
	 * @param	integer	Optional: attachment ID for attachment-specific placeholders; use -1 to flush the cache
	 * @param	boolean	Optional: for option 'multi', retain existing values
	 * @param	string	Optional: default option value
	 * @param	array	Optional: attachment_metadata, required during item uploads
	 *
	 * @return	array	( parameter => value ) for all field-level parameters and anything in $markup_values
	 */
	public static function mla_expand_field_level_parameters( $tpl, $query = NULL, $markup_values = array(), $post_id = 0, $keep_existing = false, $default_option = 'text', $upload_metadata = NULL ) {
		static $cached_post_id = 0, $item_metadata = NULL, $attachment_metadata = NULL, $id3_metadata = NULL;

		if ( $cached_post_id !== $post_id ) {
			
			$item_metadata = NULL;
			$attachment_metadata = NULL;
			$id3_metadata = NULL;
			MLAData::mla_reset_regex_matches( $post_id );
			$cached_post_id = $post_id;
			
			if ( -1 === $post_id ) {
				$cached_post_id = 0;
				return array();
			}
		}

		$template_count = 0;
		$placeholders = self::mla_get_template_placeholders( $tpl, $default_option );
		foreach ($placeholders as $key => $value ) {
			// Braces in the key must become brackets for template parsing
			$markup_key = $key;
			$key = str_replace( '{', '[', str_replace( '}', ']', $key ) );
			if ( isset( $markup_values[ $markup_key ] ) ) {
				continue;
			}

			switch ( $value['prefix'] ) {
				case 'template':
					$markup_values = self::mla_expand_field_level_parameters( $value['value'], $query , $markup_values, $post_id, $keep_existing, $default_option, $upload_metadata );
					$template_count++;
					break;
				case 'meta':
					if ( is_null( $item_metadata ) ) {
						if ( is_array( $upload_metadata ) ) {
							$item_metadata = $upload_metadata;
						} elseif ( 0 < $post_id ) {
							$item_metadata = get_metadata( 'post', $post_id, '_wp_attachment_metadata', true );
						} else {
							break;
						}
					}

					$markup_values[ $markup_key ] = self::mla_find_array_element( $value['value'], $item_metadata, $value['option'] );
					break;
				case 'query':
					if ( isset( $query ) && isset( $query[ $value['value'] ] ) ) {
						$markup_values[ $markup_key ] = $query[ $value['value'] ];
					} else {
						$markup_values[ $markup_key ] = '';
					}

					break;
				case 'request':
					if ( isset( $_REQUEST[ $value['value'] ] ) ) {
						if ( is_array( $_REQUEST[ $value['value'] ] ) ) {
							$record = wp_unslash( $_REQUEST[ $value['value'] ] ); // phpcs:ignore
						} else {
							$record = wp_kses( wp_unslash( $_REQUEST[ $value['value'] ] ), 'post' );
						}
					} else {
						// Look for compound names, e.g., tax_input.attachment_category
						$key_array = explode( '.', $value['value'] );
						if ( 1 < count( $key_array ) && isset( $_REQUEST[ $key_array[0] ] ) ) {
							$array_value = array( $key_array[0] => wp_unslash( $_REQUEST[ $key_array[0] ] ) ); // phpcs:ignore
							$record = MLAData::mla_find_array_element( $value['value'], $array_value, $value['option'], false, ',' );
						} else {
							$record = '';
						}
					}

					if ( 'raw' == $value['format'] ) {
						$text = $record;
					} elseif ( is_scalar( $record ) ) {
						$text = sanitize_text_field( (string) $record );
					} elseif ( is_array( $record ) ) {
						if ( 'array' == $value['option'] ) {
							$text = $record;
						} elseif ( 'export' == $value['option'] ) {
							$text = sanitize_text_field( var_export( $record, true ) );
						} else {
							$text = '';
							foreach ( $record as $term ) {
								$term_name = sanitize_text_field( $term );
								$text .= strlen( $text ) ? ',' . $term_name : $term_name;
							}
						}
					} // is_array

					$markup_values[ $markup_key ] = $text;
					break;
				case 'terms':
					if ( 0 < $post_id ) {
						$text = self::_expand_terms( $value, $post_id );
						
						if ( false !== $text ) {
							$markup_values[ $markup_key ] = $text;
						}
					}

					break;
				case 'page_terms':
					$page_id = isset( $markup_values[ 'page_ID' ] ) ? (integer) $markup_values[ 'page_ID' ] : 0;
					
					if ( 0 < $page_id ) {
						$text = self::_expand_terms( $value, $page_id );
						
						if ( false !== $text ) {
							$markup_values[ $markup_key ] = $text;
						}
					}

					break;
				case 'parent_terms':
					$parent_id = isset( $markup_values[ 'parent' ] ) ? (integer) $markup_values[ 'parent' ] : 0;
					
					if ( 0 < $parent_id ) {
						$text = self::_expand_terms( $value, $parent_id );
						
						if ( false !== $text ) {
							$markup_values[ $markup_key ] = $text;
						}
					}

					break;
				case 'custom':
					if ( 0 < $post_id ) {
						$markup_values[ $markup_key ] = self::_expand_custom_field( $value, $post_id );
					}

					break;
				case 'page_custom':
					$page_id = isset( $markup_values[ 'page_ID' ] ) ? (integer) $markup_values[ 'page_ID' ] : 0;
					
					if ( 0 < $page_id ) {
						$markup_values[ $markup_key ] = self::_expand_custom_field( $value, $page_id );
					}

					break;
				case 'parent_custom':
					$parent_id = isset( $markup_values[ 'parent' ] ) ? (integer) $markup_values[ 'parent' ] : 0;
					
					if ( 0 < $parent_id ) {
						$markup_values[ $markup_key ] = self::_expand_custom_field( $value, $parent_id );
					}

					break;
				case 'iptc':
					if ( is_null( $attachment_metadata ) ) {
						if ( 0 < $post_id ) {
							$attachment_metadata = self::mla_fetch_attachment_image_metadata( $post_id );
						} else {
							break;
						}
					}

					$markup_values[ $markup_key ] = self::mla_iptc_metadata_value( $value['value'], $attachment_metadata, $value['option'], $keep_existing );
					break;
				case 'exif':
					if ( is_null( $attachment_metadata ) ) {
						if ( 0 < $post_id ) {
							$attachment_metadata = self::mla_fetch_attachment_image_metadata( $post_id );
						} else {
							break;
						}
					}

					$record = self::mla_exif_metadata_value( $value['value'], $attachment_metadata, $value['option'], $keep_existing );
					if ( is_array( $record ) ) {
						$markup_values[ $markup_key ] = self::_process_field_level_array( $record, $value['option'], $keep_existing );
					} else {
						$markup_values[ $markup_key ] = $record;
					}

					break;
				case 'xmp':
					if ( is_null( $attachment_metadata ) ) {
						if ( 0 < $post_id ) {
							$attachment_metadata = self::mla_fetch_attachment_image_metadata( $post_id );
						} else {
							break;
						}
					}

					$markup_values[ $markup_key ] = self::mla_xmp_metadata_value( $value['value'], $attachment_metadata['mla_xmp_metadata'], $value['option'], $keep_existing );
					break;
				case 'id3':
					if ( is_null( $id3_metadata ) ) {
						if ( 0 < $post_id ) {
							$id3_metadata = self::mla_fetch_attachment_id3_metadata( $post_id );
						} else {
							break;
						}
					}

					$markup_values[ $markup_key ] = self::mla_id3_metadata_value( $value['value'], $id3_metadata, $value['option'], $keep_existing );
					break;
				case 'pdf':
					if ( is_null( $attachment_metadata ) ) {
						if ( 0 < $post_id ) {
							$attachment_metadata = self::mla_fetch_attachment_image_metadata( $post_id );
						} else {
							break;
						}
					}

					$record = self::mla_pdf_metadata_value( $value['value'], $attachment_metadata );
					if ( is_array( $record ) ) {
						$markup_values[ $markup_key ] = self::_process_field_level_array( $record, $value['option'], $keep_existing );
					} else {
						$markup_values[ $markup_key ] = $record;
					}

					break;
				case 'png':
					if ( is_null( $attachment_metadata ) ) {
						if ( 0 < $post_id ) {
							$attachment_metadata = self::mla_fetch_attachment_image_metadata( $post_id );
						} else {
							break;
						}
					}

					$markup_values[ $markup_key ] = self::mla_png_metadata_value( $value['value'], $attachment_metadata['mla_png_metadata'], $value['option'], $keep_existing );
					break;
				case 'mso':
					if ( is_null( $attachment_metadata ) ) {
						if ( 0 < $post_id ) {
							$attachment_metadata = self::mla_fetch_attachment_image_metadata( $post_id );
						} else {
							break;
						}
					}

					$markup_values[ $markup_key ] = self::mla_mso_metadata_value( $value['value'], $attachment_metadata['mla_mso_metadata'], $value['option'], $keep_existing );
					break;
				case 'matches':
					$markup_values[ $markup_key ] = isset( MLAData::$regex_matches[ $value['value'] ] ) ? MLAData::$regex_matches[ $value['value'] ] : '';
					break;
				case '':
					$candidate = str_replace( '{', '[', str_replace( '}', ']', $value['value'] ) );

					if ( MLAShortcodes::mla_is_data_source( $candidate ) ) {
						$data_value = array(
							'data_source' => $candidate,
							'qualifier' => $value['qualifier'],
							'keep_existing' => false,
							'format' => 'raw',
							'option' => $value['option'] ); // single, export, text for array values, e.g., alt_text

						$markup_values[ $markup_key ] = MLAShortcodes::mla_get_data_source( $post_id, 'single_attachment_mapping', $data_value );
					} elseif ( isset( $markup_values[ $value['value'] ] ) ) {
						// A standard element can have a format modifier, e.g., commas, attr
						$markup_values[ $markup_key ] = $markup_values[ $value['value'] ];
					} else {
						$custom_value = apply_filters( 'mla_expand_custom_data_source', NULL, $key, $candidate, $value, $query, $markup_values, $post_id, $keep_existing, $default_option );
						if ( !is_null( $custom_value ) ) {
							$markup_values[ $markup_key ] = $custom_value;
						}
					}

					break;
				default:
					$custom_value = apply_filters( 'mla_expand_custom_prefix', NULL, $key, $value, $query, $markup_values, $post_id, $keep_existing, $default_option );
					if ( !is_null( $custom_value ) ) {
						$markup_values[ $markup_key ] = $custom_value;
					}
			} // switch

			if ( isset( $markup_values[ $markup_key ] ) ) {
				$markup_values[ $markup_key ] = self::mla_apply_field_level_format( $markup_values[ $markup_key ], $value );
			} // isset( $markup_values[ $markup_key ] )
		} // foreach placeholder

		if ( $template_count ) {
			$markup_values['[+template_count+]'] = $template_count;
		}

		return $markup_values;
	}

	/**
	 * Analyze a template, returning an array of the placeholders it contains
	 *
	 * @since 0.90
	 *
	 * @param	string	A formatting string containing [+placeholders+]
	 * @param	string	Optional: default option value
	 *
	 * @return	array	Placeholder information: each entry is an array with
	 * 					['prefix'] => string, ['value'] => string, ['option'] => string 'text'|'single'|'export'|'array'|'multi'
	 */
	public static function mla_get_template_placeholders( $tpl, $default_option = 'text' ) {
		$results = array();
		/*
		 * Look for and process templates, removing them from the input so substitution parameters within
		 * the template are not expanded. They will be expanded when the template itself is expanded.
		 */
		while ( false !== ( $template_offset = strpos( $tpl, '[+template:' ) ) ) {
			$nest = $template_offset + 11;
			$level = 1;
			do {
				$template_end = strpos( $tpl, '+]', $nest );
				if ( false === $template_end ) {
					/* translators: 1: ERROR tag 2: template excerpt */
					MLACore::mla_debug_add( __LINE__ . ' ' . sprintf( _x( '%1$s: mla_get_template_placeholders no template-end delimiter dump = "%2$s".', 'error_log', 'media-library-assistant' ), __( 'ERROR', 'media-library-assistant' ), self::mla_hex_dump( substr( $tpl, $template_offset, 128 ), 128, 16 ) ), MLACore::MLA_DEBUG_CATEGORY_ANY );
					return array();
				}

				$nest = strpos( $tpl, '[+', $nest );
				if ( false === $nest ) {
					$nest = $template_end + 2;
					$level--;
				} elseif ( $nest < $template_end ) {
					$nest += 2;
					$level++;
				} else {
					$nest = $template_end + 2;
					$level--;
				}

			} while ( $level );

			$template_length = $template_end + 2 - $template_offset;
			$template_content = substr( $tpl, $template_offset + 11, $template_length - (11 + 2) );
			$placeholders = self::mla_get_template_placeholders( $template_content );
			$result = array( 'template:' . $template_content => array( 'prefix' => 'template', 'value' => $template_content, 'option' => $default_option, 'format' => 'native' ) );
			$results = array_merge( $results, $result, $placeholders );
			$tpl = substr_replace( $tpl, '', $template_offset, $template_length );
		} // found a template

		$match_count = preg_match_all( '/\[\+.+?\+\]/', $tpl, $matches );
		if ( ( $match_count == false ) || ( $match_count == 0 ) ) {
			return $results;
		}

		foreach ( $matches[0] as $match ) {
			$key = substr( $match, 2, (strlen( $match ) - 4 ) );
			$results[ $key ] = self::mla_parse_substitution_parameter( $key, $default_option );
		}

		return $results;
	}

	/**
	 * WP_Query filter "parameters"
	 *
	 * Moved to MLAQuery but retained here for example plugins.
	 *
	 * @since 0.30
	 *
	 * @var	array
	 */
	public static $query_parameters = array();

	/**
	 * WP_Query 'posts_search' filter "parameters"
	 *
	 * Moved to MLAQuery but retained here for example plugins.
	 *
	 * @since 2.00
	 *
	 * @var	array
	 */
	public static $search_parameters = array();

	/**
	 * Get the total number of attachment posts
	 *
	 * Compatibility shim for MLAQuery::mla_count_list_table_items
	 *
	 * @since 0.30
	 *
	 * @param	array	Query variables, e.g., from $_REQUEST
	 * @param	int		(optional) number of rows to skip over to reach desired page
	 * @param	int		(optional) number of rows on each page
	 *
	 * @return	integer	Number of attachment posts
	 */
	public static function mla_count_list_table_items( $request, $offset = NULL, $count = NULL ) {
		return MLAQuery::mla_count_list_table_items( $request, $offset, $count );
	}

	/**
	 * Retrieve attachment objects for list table display
	 *
	 * Compatibility shim for MLAQuery::mla_query_list_table_items
	 *
	 * @since 0.1
	 *
	 * @param	array	query parameters from web page, usually found in $_REQUEST
	 * @param	int		number of rows to skip over to reach desired page
	 * @param	int		number of rows on each page
	 *
	 * @return	array	attachment objects (posts) including parent data, meta data and references
	 */
	public static function mla_query_list_table_items( $request, $offset, $count ) {
		return MLAQuery::mla_query_list_table_items( $request, $offset, $count );
	}

	/** 
	 * Retrieve an Attachment array given a $post_id
	 *
	 * The (associative) array will contain every field that can be found in
	 * the posts and postmeta tables, and all references to the attachment.
	 * 
	 * @since 0.1
	 * 
	 * @param	integer	The ID of the attachment post
	 * @param	boolean	True to add references, false to skip references
	 *
	 * @return	NULL|array NULL on failure else associative array
	 */
	public static function mla_get_attachment_by_id( $post_id, $add_references = true ) {
		global $post;
		static $save_id = -1, $post_data;

		if ( $post_id == $save_id ) {
			return $post_data;
		} elseif ( $post_id == -1 ) {
			$save_id = -1;
			return NULL;
		}

		$item = get_post( $post_id );
		if ( empty( $item ) ) {
			/* translators: 1: ERROR tag 2: post ID */
			MLACore::mla_debug_add( __LINE__ . sprintf( _x( '%1$s: mla_get_attachment_by_id(%2$d) not found.', 'error_log', 'media-library-assistant' ), __( 'ERROR', 'media-library-assistant' ), $post_id ), MLACore::MLA_DEBUG_CATEGORY_ANY );
			return NULL;
		}

		if ( $item->post_type != 'attachment' ) {
			/* translators: 1: ERROR tag 2: post ID 3: post_type */
			MLACore::mla_debug_add( __LINE__ . sprintf( _x( '%1$s: mla_get_attachment_by_id(%2$d) wrong post_type "%3$s".', 'error_log', 'media-library-assistant' ), __( 'ERROR', 'media-library-assistant' ), $post_id, $item->post_type ), MLACore::MLA_DEBUG_CATEGORY_ANY );
			return NULL;
		}

		$post_data = (array) $item;
		// Save current post; it may be, for example, a parent post/page
		$save_post = $post;
		$post = $item;
		setup_postdata( $item );

		// Add parent data
		$post_data = array_merge( $post_data, MLAQuery::mla_fetch_attachment_parent_data( $post_data['post_parent'] ) );

		// Add meta data
		$post_data = array_merge( $post_data, MLAQuery::mla_fetch_attachment_metadata( $post_id ) );

		// Add references, if requested, or "empty" references array
		$post_data['mla_references'] = MLAQuery::mla_fetch_attachment_references( $post_id, $post_data['post_parent'], $add_references );

		// Restore current post; it may be, for example, a parent post/page
		$post = $save_post;
		setup_postdata( $post );

		$save_id = $post_id;
		return $post_data;
	}

	/**
	 * Adds or replaces the value of a key in a possibly nested array structure
	 *
	 * @since 1.51
	 *
	 * @param string key value, e.g. array1.array2.element
	 * @param mixed replacement value, string or array, by reference
	 * @param array PHP nested arrays, by reference
	 *
	 * @return boolean	true if $needle element set, false if not
	 */
	private static function _set_array_element( $needle, &$value, &$haystack ) {
		$key_array = explode( '.', $needle );
		$key = array_shift( $key_array );

		if ( empty( $key_array ) ) {
			$haystack[ $key ] = $value;
			return true;
		} // lowest level

		/*
		 * If an intermediate key is not an array, leave it alone and fail.
		 * If an intermediate key does not exist, create an empty array for it.
		 */
		if ( isset( $haystack[ $key ] ) ) {
			if ( ! is_array( $haystack[ $key ] ) ) {
				return false;
			}
		} else {
			$haystack[ $key ] = array();
		}

		return self::_set_array_element( implode( '.', $key_array ), $value, $haystack[ $key ] );
	}

	/**
	 * Deletes the value of a key in a possibly nested array structure
	 *
	 * @since 1.51
	 *
	 * @param string key value, e.g. array1.array2.element
	 * @param array PHP nested arrays, by reference
	 *
	 * @return boolean	true if $needle element found, false if not
	 */
	private static function _unset_array_element( $needle, &$haystack ) {
		$key_array = explode( '.', $needle );
		$key = array_shift( $key_array );

		if ( empty( $key_array ) ) {
			if ( isset( $haystack[ $key ] ) ) {
				unset( $haystack[ $key ] );
				return true;
			}

			return false;
		} // lowest level

		if ( isset( $haystack[ $key ] ) ) {
			return self::_unset_array_element( implode( '.', $key_array ), $haystack[ $key ] );
		}

		return false;
	}

	/**
	 * Finds the value of a key in a possibly nested array structure
	 *
	 * Used primarily to extract fields from the _wp_attachment_metadata custom field.
	 * Also used with the audio/video ID3 metadata exposed in WordPress 3.6 and later.
	 *
	 * @since 1.30
	 *
	 * @param string key value, e.g. array1.array2.element
	 * @param array PHP nested arrays
	 * @param string data option; 'text'|'single'|'export'|'array'|'multi'
	 * @param boolean keep existing values - for 'multi' option
	 * @param string inter-element glue for text implode
	 *
	 * @return mixed string or array value matching key(.key ...) or ''
	 */
	public static function mla_find_array_element( $needle, $haystack, $option, $keep_existing = false, $glue = ', ' ) {
		$key_array = explode( '.', $needle );
		if ( is_array( $key_array ) ) {
			foreach ( $key_array as $key ) {
				/*
				 * The '*' key means:
				 * 1) needle.* => accept any value, or 
				 * 2) needle.*.tail => search each sub-array using "tail"
				 *    and build an array of results.
				 */
				if ( '*' == $key ) {
					if ( false !== ( $tail = strpos( $needle, '*.' ) ) ) { 
						$tail = substr( $needle, $tail + 2 );
						if ( ! empty( $tail ) ) {
							if ( is_array( $haystack ) ) {
								$results = array();
								foreach ( $haystack as $substack ) {
									$results[] = self::mla_find_array_element( $tail, $substack, $option, $keep_existing );
								}

								if ( 1 == count( $results ) ) {
									$haystack = $results[0];
								} else {
									$haystack = $results;
								}
							} else {
								$haystack = '';
							}
						} // found tail
					} // found .*.

					break;
				} else {
					if ( is_array( $haystack ) ) {
						if ( isset( $haystack[ $key ] ) ) {
							$haystack = $haystack[ $key ];
						} else {
							$haystack = '';
						}
					} else {
						$haystack = '';
					}
				} // * != key
			} // foreach $key
		} else {
			$haystack = '';
		}

		if ( is_array( $haystack ) ) {
			switch ( $option ) {
				case 'single':
					$haystack = current( $haystack );
					break;
				case 'export':
					$haystack = var_export( $haystack, true );
					break;
				case 'unpack':
					if ( is_array( $haystack ) ) {
						$clean_data = array();
						foreach ( $haystack as $key => $value ) {
							if ( is_array( $value ) ) {
								$clean_data[ $key ] = '(ARRAY)';
							} elseif ( is_string( $value ) ) {
								$clean_data[ $key ] = self::_bin_to_utf8( substr( $value, 0, 256 ) );
							} else {
								$clean_data[ $key ] = $value;
							}
						}

						$haystack = var_export( $clean_data, true);
					} else {
						$haystack = var_export( $record, true );
					}
					break;
				case 'multi':
					$haystack[0x80000000] = $option;
					$haystack[0x80000001] = $keep_existing;
					// fallthru
				case 'array':
					return $haystack;
					break;
				default:
					$haystack = self::_bin_to_utf8( @implode( $glue, $haystack ) );
			} // $option
		} // is_array

		return self::_bin_to_utf8( (string) $haystack );
	} // mla_find_array_element

	/**
	 * Invalidates $mla_galleries and $galleries arrays and cached values after post, page or attachment updates
	 *
	 * @since 1.00
	 *
	 * @param	integer ID of post/page/attachment; not used at this time
	 *
	 * @return	void
	 */
	public static function mla_save_post_action( $post_id ) {
		MLAQuery::mla_flush_mla_galleries( MLACoreOptions::MLA_GALLERY_IN_TUNING );
		MLAQuery::mla_flush_mla_galleries( MLACoreOptions::MLA_MLA_GALLERY_IN_TUNING );
	}

	/**
	 * Parse a PDF date string
	 * 
	 * @since 1.50
	 *
	 * @param	string	PDF date string of the form D:YYYYMMDDHHmmSSOHH'mm
	 *
	 * @return	string	formatted date string YYYY-MM-DD HH:mm:SS
	 */
	public static function mla_parse_pdf_date( $source_string ) {
		if ( 'D:' == substr( $source_string, 0, 2) && ctype_digit( substr( $source_string, 2, 12 ) ) ) {
			return sprintf( '%1$s-%2$s-%3$s %4$s:%5$s:%6$s',
				substr( $source_string, 2, 4),
				substr( $source_string, 6, 2),
				substr( $source_string, 8, 2),
				substr( $source_string, 10, 2),
				substr( $source_string, 12, 2),
				substr( $source_string, 14, 2) );
		}

		return $source_string;
	}

	/**
	 * Parse a ISO 8601 Timestamp
	 * 
	 * @since 1.50
	 *
	 * @param	string	ISO string of the form YYYY-MM-DDTHH:MM:SS-HH:MM (inc time zone)
	 *
	 * @return	string	formatted date string YYYY-MM-DD HH:mm:SS
	 */
	private static function _parse_iso8601_date( $source_string ) {
		if ( 1 == preg_match( '/^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d(Z|[+-]\\d\\d:\\d\\d)/', $source_string ) ) {
			return sprintf( '%1$s-%2$s-%3$s %4$s:%5$s:%6$s',
				substr( $source_string, 0, 4),
				substr( $source_string, 5, 2),
				substr( $source_string, 8, 2),
				substr( $source_string, 11, 2),
				substr( $source_string, 14, 2),
				substr( $source_string, 17, 2) );
		}

		return $source_string;
	}

	/**
	 * Parse an XMP array value, stripping namespace prefixes and Seq/Alt/Bag arrays
	 * 
	 * @since 2.10
	 *
	 * @param	array	XMP multi-valued element
	 *
	 * @return	mixed	Simplified array or string value
	 */
	private static function _parse_xmp_array( $values ) {
		if ( is_scalar( $values ) ) {
			return $values;
		}

		if ( isset( $values['rdf:Alt'] ) ) {
			return self::_parse_xmp_array( $values['rdf:Alt'] );
		}

		if ( isset( $values['rdf:Bag'] ) ) {
			return self::_parse_xmp_array( $values['rdf:Bag'] );
		}

		if ( isset( $values['rdf:Seq'] ) ) {
			return self::_parse_xmp_array( $values['rdf:Seq'] );
		}

		$results = array();
		foreach( $values as $key => $value ) {
			if ( false !== ($colon = strpos( $key, ':' ) ) ) {
				$new_key = substr( $key, $colon + 1 );
			} else {
				$new_key = $key;
			}

			if ( is_array( $value ) ) {
				$results[ $new_key ] = self::_parse_xmp_array( $value );
			} else {
				$results[ $new_key ] = self::_parse_iso8601_date( $value );
			}
		}

		return $results;
	}

	/**
	 * Search the namespace array for a non-empty value
	 * 
	 * @since 2.10
	 *
	 * @param	array	namespace array
	 * @param	string	namespace
	 * @param	string	key
	 *
	 * @return	string	trimmed value of the key within the namespace
	 */
	private static function _nonempty_value( &$namespace_array, $namespace, $key ) {
		$result = '';

		if ( isset( $namespace_array[ $namespace ] ) && isset( $namespace_array[ $namespace ][ $key ] ) ) {
			if ( is_array( $namespace_array[ $namespace ][ $key ] ) ) {
				$result = @implode( ',', $namespace_array[ $namespace ][ $key ] );
			} else {
				$result = (string) $namespace_array[ $namespace ][ $key ];
			}
		}

		$trim_value = trim( $result, " \n\t\r\0\x0B," );
		if ( empty( $trim_value ) ) {
			$result = '';
		}

		return $result;
	}

	/**
	 * Extract XML meta data from a string; for XMP and MS Office files
	 * 
	 * @since 2.82
	 *
	 * @param	string	XML structure
	 *
	 * @return	mixed	array of metadata values or NULL on failure
	 */
	public static function mla_parse_xml_string( &$xml_string ) {
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_string = " . var_export( $xml_string, true ), 0 );
//error_log( __LINE__ . "  MLAData::mla_parse_xml_string xml_string = \r\n" . MLAData::mla_hex_dump( $xml_string ), 0 );

		// damage repair for GodsHillPC 
		$xml_string = str_replace( "\000", '0', $xml_string );
		$xml_values = array();
		$xml_parser = xml_parser_create('UTF-8');
		if ( xml_parser_set_option( $xml_parser, XML_OPTION_SKIP_WHITE, 0 ) && xml_parser_set_option( $xml_parser, XML_OPTION_CASE_FOLDING, 0 ) ) {
			if ( 0 == xml_parse_into_struct( $xml_parser, $xml_string, $xml_values ) ) {
				MLACore::mla_debug_add( __LINE__ . __( 'ERROR', 'media-library-assistant' ) . ': ' . _x( 'mla_parse_xml_string xml_parse_into_struct failed.', 'error_log', 'media-library-assistant' ), MLACore::MLA_DEBUG_CATEGORY_ANY );
				$xml_values = array();
			}
		} else {
			MLACore::mla_debug_add( __LINE__ . __( 'ERROR', 'media-library-assistant' ) . ': ' . _x( 'mla_parse_xml_string set option failed.', 'error_log', 'media-library-assistant' ), MLACore::MLA_DEBUG_CATEGORY_ANY );
		}

		xml_parser_free($xml_parser);

		if ( empty( $xml_values ) ) {
			return NULL;
		}
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values = " . var_export( $xml_values, true ), 0 );

		$levels = array();
		$current_level = 0;
		$results = array();
		$xmlns = array();
		foreach ( $xml_values as $index => $value ) {
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$index} ) value = " . var_export( $value, true ), 0 );
			$language = 'x-default';
			$node_attributes = array();
			if ( isset( $value['attributes'] ) ) {
				foreach ( $value['attributes'] as $att_tag => $att_value ) {
					$att_value = self::_bin_to_utf8( $att_value );

					if ( 'xmlns:' == substr( $att_tag, 0, 6 ) ) {
						$xmlns[ substr( $att_tag, 6 ) ] = $att_value;
					} elseif ( 'x:xmptk' == $att_tag ) {
						$results['xmptk'] = $att_value;
					} elseif ( 'xml:lang' == $att_tag ) {
						$language = $att_value;
					} else {
						$key = explode( ':', $att_tag );
						switch ( count( $key ) ) {
							case 1:
								$att_ns = 'unknown';
								$att_name = $key[0];
								break;
							case 2:
								$att_ns = $key[0];
								$att_name = $key[1];
								break;
							default:
								$att_ns = array_shift( $key );
								$att_name = implode( ':', $key );
								break;
						} // count

						if ( ! in_array( $att_tag, array( 'rdf:about', 'rdf:parseType' ) ) ) {
							$node_attributes[ $att_tag ] = self::_bin_to_utf8( $att_value );
						}
					}
				}
			} // attributes

			switch ( $value['type'] ) {
				case 'open':
					$levels[ ++$current_level ] = array( 'key' => $value['tag'], 'values' => $node_attributes );
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$current_level}, {$index} ) case 'open': top_level = " . var_export( $levels[ $current_level ], true ), 0 );
					break;
				case 'close':
					if ( 0 < --$current_level ) {
						$top_level = array_pop( $levels );
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$current_level}, {$index} ) case 'close': top_level = " . var_export( $top_level, true ), 0 );
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$current_level}, {$index} ) case 'close': levels( {$current_level} ) before = " . var_export( $levels, true ), 0 );
						if ( 'rdf:li' == $top_level['key'] ) {
							$levels[ $current_level ]['values'][] = $top_level['values'];
						} else {
							if ( isset( $levels[ $current_level ]['values'][ $top_level['key'] ] ) ) {
								$levels[ $current_level ]['values'][ $top_level['key'] ] = array_merge( (array) $levels[ $current_level ]['values'][ $top_level['key'] ], $top_level['values'] );
							} else {
								$levels[ $current_level ]['values'][ $top_level['key'] ] = $top_level['values'];
							}
						}
					}
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$current_level}, {$index} ) case 'close': levels( {$current_level} ) after = " . var_export( $levels, true ), 0 );
					break;
				case 'complete':
					if ( 'x-default' != $language ) {
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$current_level}, {$index}, {$language} ) value = " . var_export( $value, true ), 0 );
						//break;
					}

//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$index} ) case 'complete': node_attributes = " . var_export( $node_attributes, true ), 0 );
					if ( empty( $node_attributes ) ) {
						if ( isset( $value['value'] ) ) {
							$complete_value = self::_bin_to_utf8( $value['value'] );
						} else {
							$complete_value = '';
						}
					} else {
						// Check for MS Office handling of dcterms
						if ( 0 === strpos( $value['tag'], 'dcterms:' ) && isset( $value['value'] ) ) {
							$complete_value = self::_bin_to_utf8( $value['value'] );
						} else {
							$complete_value = $node_attributes;
						}
					}

//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$index} ) case 'complete': value = " . var_export( $value, true ), 0 );
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$index} ) case 'complete': complete_value = " . var_export( $complete_value, true ), 0 );
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$index} ) case 'complete': (array) complete_value = " . var_export( (array) $complete_value, true ), 0 );
					if ( 'rdf:li' == $value['tag'] ) {
						if ( 'x-default' === $language ) {
							$levels[ $current_level ]['values'][] = $complete_value;
						} else {
							$levels[ $current_level ]['values'][$language][] = $complete_value;
						}
					} else {
						if ( 'x-default' === $language ) {
							if ( isset( $levels[ $current_level ]['values'][ $value['tag'] ] ) ) {
								$new_value = (array) $levels[ $current_level ]['values'][ $value['tag'] ];
								$levels[ $current_level ]['values'][ $value['tag'] ] = array_merge( $new_value, (array) $complete_value );
							} else {
								$levels[ $current_level ]['values'][ $value['tag'] ] = $complete_value;
							}
						} else {
							if ( isset( $levels[ $current_level ]['values'][ $value['tag'] ][$language] ) ) {
								$new_value = (array) $levels[ $current_level ]['values'][ $value['tag'] ][$language];
								$levels[ $current_level ]['values'][ $value['tag'] ][$language] = array_merge( $new_value, (array) $complete_value );
							} else {
								$levels[ $current_level ]['values'][ $value['tag'] ][$language] = $complete_value;
							}
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$index}, {$current_level} ) case 'complete': language tag values = " . var_export( $levels[ $current_level ]['values'], true ), 0 );
						}
					}

//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$index}, {$current_level} ) case 'complete': values = " . var_export( $levels[ $current_level ]['values'], true ), 0 );
					break;
				default:
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$index}, {$current_level} ) ignoring type = " . var_export( $value['type'], true ), 0 );
			} // switch on type
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xml_values( {$index}, {$current_level} ) levels = " . var_export( $levels, true ), 0 );
		} // foreach value
//error_log( __LINE__ . " MLAData::mla_parse_xml_string levels = " . var_export( $levels, true ), 0 );
//error_log( __LINE__ . " MLAData::mla_parse_xml_string xmlns = " . var_export( $xmlns, true ), 0 );

		// Parse "namespace:name" names into arrays of simple names
		$namespace_arrays = array();
		$metadata_source = '';
		if ( isset( $levels[1] ) && isset( $levels[1]['values'] ) ) {
			if ( isset( $levels[1]['values']['rdf:RDF'] ) && !empty( $levels[1]['values']['rdf:RDF']['rdf:Description'] ) ) {
				if ( is_array( $levels[1]['values']['rdf:RDF']['rdf:Description'] ) ) {
					/*
					 * XMP parsing
					 * NOTE: The string "XAP" or "xap" appears in some namespaces, keywords,
					 * and related names in stored XMP data. It reflects an early internal
					 * code name for XMP; the names have been preserved for compatibility purposes.
					 */
					$metadata_source = 'xmp';
					foreach ( $levels[1]['values']['rdf:RDF']['rdf:Description'] as $key => $value ) {
						if ( is_string( $value ) ) {
							$value = self::_parse_iso8601_date( self::mla_parse_pdf_date( $value ) );
						} elseif ( is_array( $value ) ) {
							$value = self::_parse_xmp_array( $value );
						}
	
						if ( false !== ($colon = strpos( $key, ':' ) ) ) {
							$array_name = substr( $key, 0, $colon );
							$array_index = substr( $key, $colon + 1 );
							$namespace_arrays[ $array_name ][ $array_index ] = $value;
	
							if ( ! isset( $results[ $array_index ] ) && in_array( $array_name, array( 'xmp', 'xmpMM', 'xmpRights', 'xap', 'xapMM', 'dc', 'pdf', 'pdfx', 'mwg-rs' ) ) ) {
								if ( is_array( $value ) && 1 == count( $value ) && isset( $value[0] ) ) {
									$results[ $array_index ] = $value[0];
								} else {
									$results[ $array_index ] = $value;
								}
							}
						} // found namespace
					} // foreach Description
				} // is_array
			} else {
				/*
				 * Microsoft Office parsing
				 * Core properties are similar to XMP; Extended properties in "app.xml" do not have
				 * a namespace prepended to them.
				 */
				$metadata_source = 'mso';
				$is_app_xml = isset( $levels[1]['key'] ) && ( 'Properties' === $levels[1]['key'] );
				foreach ( $levels[1]['values'] as $key => $value ) {
					if ( is_string( $value ) ) {
						$value = self::_parse_iso8601_date( self::mla_parse_pdf_date( $value ) );
					} elseif ( is_array( $value ) ) {
						$value = self::_parse_xmp_array( $value );
					}

					if ( false !== ($colon = strpos( $key, ':' ) ) ) {
						$array_name = substr( $key, 0, $colon );
						$array_index = substr( $key, $colon + 1 );
						$namespace_arrays[ $array_name ][ $array_index ] = $value;

						if ( ! isset( $results[ $array_index ] ) && in_array( $array_name, array( 'cp', 'dc', 'dcterms' ) ) ) {
							if ( is_array( $value ) && 1 == count( $value ) && isset( $value[0] ) ) {
								$results[ $array_index ] = $value[0];
							} else {
								$results[ $array_index ] = $value;
							}
						}
					// found namespace
					} elseif ( $is_app_xml ) {
						$namespace_arrays[ 'app' ][ $key ] = $value;

						if ( ! isset( $results[ $key ] ) ) {
							if ( is_array( $value ) && 1 == count( $value ) && isset( $value[0] ) ) {
								$results[ $key ] = $value[0];
							} else {
								$results[ $key ] = $value;
							}
						}
					} // $is_app_xml
				} // foreach Description				
			}
		} // isset( $levels[1]['values'] 
//error_log( __LINE__ . " MLAData::mla_parse_xml_string results = " . var_export( $results, true ), 0 );

		/*
		 * Try to populate all the PDF-standard keys (except Trapped)
		 * Title - The document's title
		 * Author - The name of the person who created the document
		 * Subject - The subject of the document
		 * Keywords - Keywords associated with the document
		 * Creator - the name of the conforming product that created the original document
		 * Producer - the name of the conforming product that converted it to PDF
		 * CreationDate - The date and time the document was created
		 * ModDate - The date and time the document was most recently modified
		 */
		if ( ! isset( $results['Title'] ) ) {
			$replacement = self::_nonempty_value( $namespace_arrays, 'dc', 'title' );
			if ( ! empty( $replacement ) ) {
				$results['Title'] = $replacement;
			}
		}

		if ( ! isset( $results['Author'] ) ) {
			$replacement = self::_nonempty_value( $namespace_arrays, 'dc', 'creator' );
			if ( ! empty( $replacement ) ) {
				$results['Author'] = $replacement;
			}
		}

		if ( ! isset( $results['Subject'] ) ) {
			if ( 'xmp' == $metadata_source ) {
				$replacement = self::_nonempty_value( $namespace_arrays, 'dc', 'description' );
			} else {
				$replacement = self::_nonempty_value( $namespace_arrays, 'dc', 'subject' );
			}

			if ( ! empty( $replacement ) ) {
				$results['Subject'] = $replacement;
			}
		}

		/*
		 * Keywords are special, since they are often assigned to taxonomy terms.
		 * Build or preserve an array if there are multiple values; string for single values.
		 * "pdf:Keywords" uses a ';' delimiter, "dc:subject" uses an array.
		 */
		$keywords = array();
		if ( isset( $results['Keywords'] ) ) {
			if ( false !== strpos( $results['Keywords'], ';' ) ) {
				$terms = array_map( 'trim', explode( ';', $results['Keywords'] ) );
				foreach ( $terms as $term )
					if ( ! empty( $term ) ) {
						$keywords[ $term ] = $term;
					}
			} elseif ( false !== strpos( $results['Keywords'], ',' ) ) {
				$terms = array_map( 'trim', explode( ',', $results['Keywords'] ) );
				foreach ( $terms as $term )
					if ( ! empty( $term ) ) {
						$keywords[ $term ] = $term;
					}
			} else {
				$term = trim( $results['Keywords'] );
				if ( ! empty( $term ) ) {
					$keywords[ $term ] = $term;
				}
			}
		} // Keywords

		// MS Office has both keywords and categories
		if ( ( 'mso' == $metadata_source ) && isset( $namespace_arrays['cp'] ) && isset( $namespace_arrays['cp']['keywords'] ) ) {
			$cp_keywords = $namespace_arrays['cp']['keywords'];
			if ( false !== strpos( $cp_keywords, ';' ) ) {
				$terms = array_map( 'trim', explode( ';', $cp_keywords ) );
				foreach ( $terms as $term )
					if ( ! empty( $term ) ) {
						$keywords[ $term ] = $term;
					}
			} elseif ( false !== strpos( $cp_keywords, ',' ) ) {
				$terms = array_map( 'trim', explode( ',', $cp_keywords ) );
				foreach ( $terms as $term )
					if ( ! empty( $term ) ) {
						$keywords[ $term ] = $term;
					}
			} else {
				$term = trim( $cp_keywords );
				if ( ! empty( $term ) ) {
					$keywords[ $term ] = $term;
				}
			}
		} // MSO cp:keywords

		if ( ( 'mso' == $metadata_source ) && isset( $namespace_arrays['cp'] ) && isset( $namespace_arrays['cp']['category'] ) ) {
			$cp_keywords = $namespace_arrays['cp']['category'];
			if ( false !== strpos( $cp_keywords, ';' ) ) {
				$terms = array_map( 'trim', explode( ';', $cp_keywords ) );
				foreach ( $terms as $term )
					if ( ! empty( $term ) ) {
						$keywords[ $term ] = $term;
					}
			} elseif ( false !== strpos( $cp_keywords, ',' ) ) {
				$terms = array_map( 'trim', explode( ',', $cp_keywords ) );
				foreach ( $terms as $term )
					if ( ! empty( $term ) ) {
						$keywords[ $term ] = $term;
					}
			} else {
				$term = trim( $cp_keywords );
				if ( ! empty( $term ) ) {
					$keywords[ $term ] = $term;
				}
			}
		} // MSO cp:category

		// XMP puts keywords in the"dc:subject" field, unlike MSO
		if ( ( 'xmp' == $metadata_source ) && isset( $namespace_arrays['dc'] ) && isset( $namespace_arrays['dc']['subject'] ) ) {
			if ( is_array( $namespace_arrays['dc']['subject'] ) ) {
				foreach ( $namespace_arrays['dc']['subject'] as $term ) {
					$term = trim( $term, " \n\t\r\0\x0B," );
					if ( ! empty( $term ) ) {
						$keywords[ $term ] = $term;
					}
				}
			} elseif ( is_string( $namespace_arrays['dc']['subject'] ) ) {
				$term = trim ( $namespace_arrays['dc']['subject'], " \n\t\r\0\x0B," );
				if ( ! empty( $term ) ) {
					$keywords[ $term ] = $term;
				}
			}
		} // XMP dc:subject

		if ( ! empty( $keywords ) ) {
			if ( 1 == count( $keywords ) ) {
				$results['Keywords'] = array_shift( $keywords );
			} else {
				$results['Keywords'] = array();
				foreach ( $keywords as $term ) {
					$results['Keywords'][] = $term;
				}
			}
		}

//		if ( ! isset( $results['Producer'] ) ) {
//		}

		if ( ! isset( $results['Creator'] ) ) {
			$replacement = self::_nonempty_value( $namespace_arrays, 'xmp', 'CreatorTool' );
			if ( ! empty( $replacement ) ) {
				$results['Creator'] = $replacement;
			} else {
				$replacement = self::_nonempty_value( $namespace_arrays, 'app', 'Application' );
				if ( ! empty( $replacement ) ) {
					$results['Creator'] = $replacement;
				} else {
					$replacement = self::_nonempty_value( $namespace_arrays, 'xap', 'CreatorTool' );
					if ( ! empty( $replacement ) ) {
						$results['Creator'] = $replacement;
					} elseif ( ! empty( $results['Producer'] ) ) {
						$results['Creator'] = $results['Producer'];
					}
				}
			}
		}

		if ( ! isset( $results['CreationDate'] ) ) {
			$replacement = self::_nonempty_value( $namespace_arrays, 'xmp', 'CreateDate' );
			if ( ! empty( $replacement ) ) {
				$results['CreationDate'] = $replacement;
			} else {
				$replacement = self::_nonempty_value( $namespace_arrays, 'xap', 'CreateDate' );
				if ( ! empty( $replacement ) ) {
					$results['CreationDate'] = $replacement;
				} else {
					$replacement = self::_nonempty_value( $namespace_arrays, 'dcterms', 'created' );
					if ( ! empty( $replacement ) ) {
						$results['CreationDate'] = $replacement;
					}
				}
			}
		}

		if ( ! isset( $results['ModDate'] ) ) {
			$replacement = self::_nonempty_value( $namespace_arrays, 'xmp', 'ModifyDate' );
			if ( ! empty( $replacement ) ) {
				$results['ModDate'] = $replacement;
			} else {
				$replacement = self::_nonempty_value( $namespace_arrays, 'xap', 'ModifyDate' );
				if ( ! empty( $replacement ) ) {
					$results['ModDate'] = $replacement;
				} else {
					$replacement = self::_nonempty_value( $namespace_arrays, 'dcterms', 'modified' );
					if ( ! empty( $replacement ) ) {
						$results['ModDate'] = $replacement;
					}
				}
			}
		}

		if ( ! empty( $xmlns ) ) {
			$results['xmlns'] = $xmlns;
		}

		$results = array_merge( $results, $namespace_arrays );
		return $results;
	}

	/**
	 * Extract XMP meta data from a file
	 * 
	 * @since 2.10
	 *
	 * @param	string	full path and file name
	 * @param	integer	offset within the file of the search start point
	 *
	 * @return	mixed	array of metadata values or NULL on failure
	 */
	public static function mla_parse_xmp_metadata( $file_name, $file_offset ) {
//error_log( __LINE__ . " MLAData::mla_parse_xmp_metadata( {$file_name}, {$file_offset} ) ", 0 );
		$chunksize = 16384;			
		$xmp_chunk = file_get_contents( $file_name, true, NULL, $file_offset, $chunksize );
//error_log( __LINE__ . " MLAData::mla_parse_xmp_metadata( {$file_offset} ) chunk = \r\n" . MLAData::mla_hex_dump( $xmp_chunk ), 0 );

		// If necessary and possible, advance the $xmp_chunk through the file until it contains the start tag
		if ( false === ( $start_tag = strpos( $xmp_chunk, '<x:xmpmeta' ) ) && ( $chunksize == strlen( $xmp_chunk ) ) ) {
			$new_offset = $file_offset + ( $chunksize - 16 );
//error_log( __LINE__ . " MLAData::mla_parse_xmp_metadata( {$new_offset} ) ", 0 );
			$xmp_chunk = file_get_contents( $file_name, true, NULL, $new_offset, $chunksize );
//error_log( __LINE__ . " MLAData::mla_parse_xmp_metadata( {$new_offset} ) chunk = \r\n" . MLAData::mla_hex_dump( $xmp_chunk ), 0 );
			while ( false === ( $start_tag = strpos( $xmp_chunk, '<x:xmpmeta' ) ) && ( $chunksize == strlen( $xmp_chunk ) ) ) {
				$new_offset = $new_offset + ( $chunksize - 16 );
//error_log( __LINE__ . " MLAData::mla_parse_xmp_metadata( {$new_offset} ) ", 0 );
				$xmp_chunk = file_get_contents( $file_name, true, NULL, $new_offset, $chunksize );
//error_log( __LINE__ . " MLAData::mla_parse_xmp_metadata( {$new_offset} ) chunk = \r\n" . MLAData::mla_hex_dump( $xmp_chunk ), 0 );
			} // while not found
		} else { // if not found
			$new_offset = $file_offset;
		}

//error_log( __LINE__ . " MLAData::mla_parse_xmp_metadata( {$start_tag} ) ", 0 );
		if ( false === $start_tag ) {
			return NULL;
		}

		// If necessary and possible, expand the $xmp_chunk until it contains the start tag
		if ( false === ( $end_tag = strpos( $xmp_chunk, '</x:xmpmeta>', $start_tag ) ) && ( $chunksize == strlen( $xmp_chunk ) ) ) {
			$new_offset = $new_offset + $start_tag;
			$start_tag = 0;
			$new_chunksize = $chunksize + $chunksize;
			$xmp_chunk = file_get_contents( $file_name, true, NULL, $new_offset, $new_chunksize );
			while ( false === ( $end_tag = strpos( $xmp_chunk, '</x:xmpmeta>' ) ) && ( $new_chunksize == strlen( $xmp_chunk ) ) ) {
				$new_chunksize = $new_chunksize + $chunksize;
				$xmp_chunk = file_get_contents( $file_name, true, NULL, $new_offset, $new_chunksize );
			} // while not found
		} // if not found

		if ( false === $end_tag ) {
			return NULL;
		}

		$xmp_string = "<?xml version='1.0'?>\n" . substr($xmp_chunk, $start_tag, ( $end_tag + 12 ) - $start_tag );
//error_log( __LINE__ . " MLAData::mla_parse_xmp_metadata xmp_string = " . var_export( $xmp_string, true ), 0 );
//error_log( __LINE__ . "  MLAData::mla_parse_xmp_metadata xmp_string = \r\n" . MLAData::mla_hex_dump( $xmp_string ), 0 );

		$results = MLAData::mla_parse_xml_string( $xmp_string );
//error_log( __LINE__ . " MLAData::mla_parse_xmp_metadata results = " . var_export( $results, true ), 0 );
		return $results;
	}

	/**
	 * Convert a big-endian string to an integer
	 * 
	 * @since 3.07
	 *
	 * @param	string	Source string
	 * @param	integer	Number of bytes to convert
	 *
	 * @return	integer Converted value
	 */
	public static function mla_convert_to_integer( $source, $length ) {
		$result = 0;
		
		for ( $index = 0; $index < $length; $index++ ) {
			$result = ( $result << 8 ) + ord( $source[ $index ] );
		}

		return $result;
	}

	/**
	 * Parse a PNG chunk header; length and type
	 * 
	 * @since 3.07
	 *
	 * @param	string	Chunk of file data
	 * @param	integer	Optional; default zero. Current offset within the chunk
	 *
	 * @return	mixed	array ( 'length' => length of chunk, 'type' => chunk type) or NULL on failure
	 */
	public static function mla_parse_png_chunk_header( $file_chunk, $chunk_offset = 0 ) {
		$chunk_header = substr( $file_chunk, $chunk_offset );
//error_log( __LINE__ . "  MLAData::mla_parse_png_chunk_header chunk_header = \r\n" . MLAData::mla_hex_dump( $chunk_header, 128 ), 0 );

		if ( 8 > strlen( $chunk_header ) ) {
			return NULL;
		}

		$results = array( 'length' => 0, 'type' => '' );

		// Length is four byte "big endian" binary value
		$results['length'] = self::mla_convert_to_integer( $chunk_header, 4 );

		// Type is four byte ISO 646 alphabetic (A-Z and a-z) string
		$results['type'] = substr( $chunk_header, 4, 4 );
		
//error_log( __LINE__ . "  MLAData::mla_parse_png_chunk_header results = " . var_export( $results, true ), 0 );
		return $results;
	}

	/**
	 * Extract meta data from a PNG file
	 * 
	 * @since 3.07
	 *
	 * @param	string	full path and file name
	 *
	 * @return	mixed	array of metadata values or NULL on failure
	 */
	public static function mla_parse_png_metadata( $file_name ) {
		$file_offset = 0;
		$chunksize = 16384;			
		$metadata = array();
		$chunk_list = array();
		
//error_log( __LINE__ . " MLAData::mla_parse_png_metadata( {$file_name}, {$file_offset} ) ", 0 );
		
		$file_chunk = file_get_contents( $file_name, true, NULL, $file_offset, $chunksize );
//error_log( __LINE__ . " MLAData::mla_parse_png_metadata( {$file_offset} ) chunk = \r\n" . MLAData::mla_hex_dump( $file_chunk, 1024 ), 0 );

		// Validate the PNG signature
		$png_signature = chr(0x89) . chr(0x50) . chr(0x4E) . chr(0x47) . chr(0x0D) . chr(0x0A) . chr(0x1A) . chr(0x0A);
		if ( 0 !== strpos( $file_chunk, $png_signature ) ) {
			return NULL;
		}
		
		// Find and parse the iHDR chunk
		$chunk_header = self::mla_parse_png_chunk_header( $file_chunk, 8 );
		if ( !empty( $chunk_header['type'] ) && ( 'IHDR' === $chunk_header['type'] ) && ( 13 === $chunk_header['length'] ) ) {
			$chunk_header['offset'] = 8;
			$chunk_list[] = $chunk_header;
			$ihdr = substr( $file_chunk, 8+8, 13 );
			
			$metadata['IHDR']['Width'] = self::mla_convert_to_integer( substr( $ihdr, 0, 4 ), 4 );
			$metadata['IHDR']['Height'] = self::mla_convert_to_integer( substr( $ihdr, 4, 4 ), 4 );
			$metadata['IHDR']['BitDepth'] = ord( $ihdr[8] );
			$metadata['IHDR']['ColorTypeRaw'] = ord( $ihdr[9] );
			$metadata['IHDR']['CompressionRaw'] = ord( $ihdr[10] );
			$metadata['IHDR']['FilterRaw'] = ord( $ihdr[11] );
			$metadata['IHDR']['InterlaceRaw'] = ord( $ihdr[12] );

			switch ( $metadata['IHDR']['ColorTypeRaw'] ) {
				case 0:
					$metadata['IHDR']['ColorType'] = 'Greyscale';
					break;
				case 2:
					$metadata['IHDR']['ColorType'] = 'RGB';
					break;
				case 3:
					$metadata['IHDR']['ColorType'] = 'Indexed';
					break;
				case 4:
					$metadata['IHDR']['ColorType'] = 'Greyscale+alpha';
					break;
				case 6:
					$metadata['IHDR']['ColorType'] = 'RGB+alpha';
					break;
				default:
					$metadata['IHDR']['ColorType'] = 'Unknown';
			}
			
			switch ( $metadata['IHDR']['CompressionRaw'] ) {
				case 0:
					$metadata['IHDR']['Compression'] = 'Deflate';
					break;
				default:
					$metadata['IHDR']['Compression'] = 'Unknown';
			}
			
			switch ( $metadata['IHDR']['FilterRaw'] ) {
				case 0:
					$metadata['IHDR']['Filter'] = 'Adaptive';
					break;
				default:
					$metadata['IHDR']['Filter'] = 'Unknown';
			}
			
			switch ( $metadata['IHDR']['InterlaceRaw'] ) {
				case 0:
					$metadata['IHDR']['Interlace'] = 'Noninterlaced';
					break;
				case 1:
					$metadata['IHDR']['Interlace'] = 'Adam7 Interlace';
					break;
				default:
					$metadata['IHDR']['Interlace'] = 'Unknown';
			}
		} else {
			return NULL; // Invalid IDHR chunk
		}

		$file_offset = 8+8+13+4; // signature + header + data + CRC
		$file_chunk = file_get_contents( $file_name, true, NULL, $file_offset, $chunksize );
//error_log( __LINE__ . " MLAData::mla_parse_png_metadata( {$file_offset} ) chunk = \r\n" . MLAData::mla_hex_dump( $file_chunk, 1024 ), 0 );

		while ( !empty( $file_chunk ) ) {
			$chunk_header = self::mla_parse_png_chunk_header( $file_chunk, 0 );
			if ( !empty( $chunk_header['type'] ) ) {
				$chunk_header['offset'] = $file_offset;
				$chunk_list[] = $chunk_header;

				// Each tEXt chunk contains a keyword and a text string, separated by a zero byte (null character).
				if ( 'tEXt' === $chunk_header['type'] ) {
					$text = substr( $file_chunk, 8, $chunk_header['length'] );
					$separator = strpos( $text, chr(0) );
					$keyword = substr( $text, 0, $separator++ );
					$text = substr( $text, $separator );
					$metadata[ $keyword ] = $text;
				}
			}
			
			$file_offset += 8 + $chunk_header['length'] + 4;
			$file_chunk = file_get_contents( $file_name, true, NULL, $file_offset, $chunksize );
//error_log( __LINE__ . " MLAData::mla_parse_png_metadata( {$file_offset} ) chunk = \r\n" . MLAData::mla_hex_dump( $file_chunk, 1024 ), 0 );
		}

		if ( !empty( $chunk_list ) ) {
			//$metadata['chunk_list'] = $chunk_list;
		}
		
//error_log( __LINE__ . " MLAData::mla_parse_png_metadata() metadata = " . var_export( $metadata, true ), 0 );
		return $metadata;
	}

	/**
	 * Parse an array of ASCII integer values to a string
	 * 
	 * @since 3.21
	 *
	 * @param	array	integer representation of ASCII characters
	 *
	 * @return	string	ASCII string
	 */
	private static function _parse_ASCII_array( &$source_array ) {
		$output = '';
		foreach ( $source_array as $index => $value ) {
			$value = absint( $value );
			if ( ( 0 < $value ) && ( 128 > $value ) ) {
				$output .= chr( $value );
			}
		}
		
		return $output;
	}

	/**
	 * Parse a Windows Unicode (16-bit Little Endian) string to UTF-8
	 * From https://stackoverflow.com/questions/1805802/php-convert-unicode-codepoint-to-utf-8
	 * 
	 * @since 3.10
	 *
	 * @param	string	string of probable 16-bit characters
	 *
	 * @return	string	UTF-8 encoded string
	 */
	private static function _parse_UTF16LE( &$source_string ) {
		$output = '';
		$null_bytes = 0;
		
		for ($index = 0; $index < strlen( $source_string ); ) {
			$value = ord( $source_string[ $index++ ] );

			// non-Unicode strings may not be an een number of characters.
			if ( isset( $source_string[ $index ] ) ) {
				$null_bytes += ( 0 === ord( $source_string[ $index ] ) ) ? 1 : 0;
				$value += ( ord( $source_string[ $index++ ] ) << 8 );
			}
			
 			if ( $value === 0x00 ) {
				continue;
 			} elseif ( $value < 0x80 ) {
				$output .= chr( $value );
			} elseif ( $value < 0x800 ) {
				$output .= chr(($value>>6)+192).chr(($value&63)+128);
			} elseif ( $value < 0x10000 ) {
				$output .= chr(($value>>12)+224).chr((($value>>6)&63)+128).chr(($value&63)+128);
			} elseif ( $value < 0x200000 ) {
				$output .= chr(($value>>18)+240).chr((($value>>12)&63)+128).chr((($value>>6)&63)+128).chr(($value&63)+128);
			} else {
				$output .= '.';
			}
		}

		// Assume that Unicode strings will have one or more plain text characters, i.e., null upper bytes.
		if ( 0 < $null_bytes ) {
			return $output;
		} else {
			return $source_string;
		}
	}

	/**
	 * UTF-8 replacements for invalid SQL characters
	 *
	 * @since 1.41
	 *
	 * @var	array
	 */
	public static $utf8_chars = array(
		"\xC2\x80", "\xC2\x81", "\xC2\x82", "\xC2\x83", "\xC2\x84", "\xC2\x85", "\xC2\x86", "\xC2\x87", 
		"\xC2\x88", "\xC2\x89", "\xC2\x8A", "\xC2\x8B", "\xC2\x8C", "\xC2\x8D", "\xC2\x8E", "\xC2\x8F", 
		"\xC2\x90", "\xC2\x91", "\xC2\x92", "\xC2\x93", "\xC2\x94", "\xC2\x95", "\xC2\x96", "\xC2\x97", 
		"\xC2\x98", "\xC2\x99", "\xC2\x9A", "\xC2\x9B", "\xC2\x9C", "\xC2\x9D", "\xC2\x9E", "\xC2\x9F", 
		"\xC2\xA0", "\xC2\xA1", "\xC2\xA2", "\xC2\xA3", "\xC2\xA4", "\xC2\xA5", "\xC2\xA6", "\xC2\xA7", 
		"\xC2\xA8", "\xC2\xA9", "\xC2\xAA", "\xC2\xAB", "\xC2\xAC", "\xC2\xAD", "\xC2\xAE", "\xC2\xAF", 
		"\xC2\xB0", "\xC2\xB1", "\xC2\xB2", "\xC2\xB3", "\xC2\xB4", "\xC2\xB5", "\xC2\xB6", "\xC2\xB7", 
		"\xC2\xB8", "\xC2\xB9", "\xC2\xBA", "\xC2\xBB", "\xC2\xBC", "\xC2\xBD", "\xC2\xBE", "\xC2\xBF", 
		"\xC3\x80", "\xC3\x81", "\xC3\x82", "\xC3\x83", "\xC3\x84", "\xC3\x85", "\xC3\x86", "\xC3\x87", 
		"\xC3\x88", "\xC3\x89", "\xC3\x8A", "\xC3\x8B", "\xC3\x8C", "\xC3\x8D", "\xC3\x8E", "\xC3\x8F", 
		"\xC3\x90", "\xC3\x91", "\xC3\x92", "\xC3\x93", "\xC3\x94", "\xC3\x95", "\xC3\x96", "\xC3\x97", 
		"\xC3\x98", "\xC3\x99", "\xC3\x9A", "\xC3\x9B", "\xC3\x9C", "\xC3\x9D", "\xC3\x9E", "\xC3\x9F", 
		"\xC3\xA0", "\xC3\xA1", "\xC3\xA2", "\xC3\xA3", "\xC3\xA4", "\xC3\xA5", "\xC3\xA6", "\xC3\xA7", 
		"\xC3\xA8", "\xC3\xA9", "\xC3\xAA", "\xC3\xAB", "\xC3\xAC", "\xC3\xAD", "\xC3\xAE", "\xC3\xAF", 
		"\xC3\xB0", "\xC3\xB1", "\xC3\xB2", "\xC3\xB3", "\xC3\xB4", "\xC3\xB5", "\xC3\xB6", "\xC3\xB7", 
		"\xC3\xB8", "\xC3\xB9", "\xC3\xBA", "\xC3\xBB", "\xC3\xBC", "\xC3\xBD", "\xC3\xBE", "\xC3\xBF"
	);

	/**
	 * Replace SQL incorrect characters (0x80 - 0xFF) with their UTF-8 equivalents
	 * 
	 * @since 1.41
	 *
	 * @param	string	unencoded string
	 *
	 * @return	string	UTF-8 encoded string
	 */
	private static function _bin_to_utf8( $string ) {
		if ( seems_utf8( $string ) ) {
			return $string;
		}

		/* utf8_encode() is deprecated in PHP 8.2
		if (function_exists('utf8_encode')) {
			return utf8_encode( $string );
		} // */

		if (function_exists('mb_convert_encoding')) {
			return mb_convert_encoding( $string, 'UTF-8', 'ISO-8859-1' );
		}

		$output = '';
		for ($index = 0; $index < strlen( $string ); $index++ ) {
			$value = ord( $string[ $index ] );
			if ( $value < 0x80 ) {
				$output .= chr( $value );
			} else {
				$output .= self::$utf8_chars[ $value - 0x80 ];
			}
		}

		return $output;
	}

	/**
	 * IPTC Dataset identifiers and names
	 *
	 * This array contains the identifiers and names of Datasets defined in
	 * the "IPTC-NAA Information Interchange Model Version No. 4.1".
	 *
	 * @since 0.90
	 *
	 * @var	array
	 */
	public static $mla_iptc_records = array(
		// Envelope Record
		"1#000" => "Model Version",
		"1#005" => "Destination",
		"1#020" => "File Format",
		"1#022" => "File Format Version",
		"1#030" => "Service Identifier",
		"1#040" => "Envelope Number",
		"1#050" => "Product ID",
		"1#060" => "Envelope Priority",
		"1#070" => "Date Sent",
		"1#080" => "Time Sent",
		"1#090" => "Coded Character Set",
		"1#100" => "UNO",
		"1#120" => "ARM Identifier",
		"1#122" => "ARM Version",

		// Application Record
		"2#000" => "Record Version",
		"2#003" => "Object Type Reference",
		"2#004" => "Object Attribute Reference",
		"2#005" => "Object Name",
		"2#007" => "Edit Status",
		"2#008" => "Editorial Update",
		"2#010" => "Urgency",
		"2#012" => "Subject Reference",
		"2#015" => "Category",
		"2#020" => "Supplemental Category",
		"2#022" => "Fixture Identifier",
		"2#025" => "Keywords",
		"2#026" => "Content Location Code",
		"2#027" => "Content Location Name",
		"2#030" => "Release Date",
		"2#035" => "Release Time",
		"2#037" => "Expiration Date",
		"2#038" => "Expiration Time",
		"2#040" => "Special Instructions",
		"2#042" => "Action Advised",
		"2#045" => "Reference Service",
		"2#047" => "Reference Date",
		"2#050" => "Reference Number",
		"2#055" => "Date Created",
		"2#060" => "Time Created",
		"2#062" => "Digital Creation Date",
		"2#063" => "Digital Creation Time",
		"2#065" => "Originating Program",
		"2#070" => "Program Version",
		"2#075" => "Object Cycle",
		"2#080" => "By-line",
		"2#085" => "By-line Title",
		"2#090" => "City",
		"2#092" => "Sub-location",
		"2#095" => "Province or State",
		"2#100" => "Country or Primary Location Code",
		"2#101" => "Country or Primary Location Name",
		"2#103" => "Original Transmission Reference",
		"2#105" => "Headline",
		"2#110" => "Credit",
		"2#115" => "Source",
		"2#116" => "Copyright Notice",
		"2#118" => "Contact",
		"2#120" => "Caption or Abstract",
		"2#122" => "Caption Writer or Editor",
		"2#125" => "Rasterized Caption",
		"2#130" => "Image Type",
		"2#131" => "Image Orientation",
		"2#135" => "Language Identifier",
		"2#150" => "Audio Type",
		"2#151" => "Audio Sampling Rate",
		"2#152" => "Audio Sampling Resolution",
		"2#153" => "Audio Duration",
		"2#154" => "Audio Outcue",
		"2#200" => "ObjectData Preview File Format",
		"2#201" => "ObjectData Preview File Format Version",
		"2#202" => "ObjectData Preview Data",

		// Pre ObjectData Descriptor Record
		"7#010" => "Size Mode",
		"7#020" => "Max Subfile Size",
		"7#090" => "ObjectData Size Announced",
		"7#095" => "Maximum ObjectData Size",

		// ObjectData Record
		"8#010" => "Subfile",

		// Post ObjectData Descriptor Record
		"9#010" => "Confirmed ObjectData Size"
	);

	/**
	 * IPTC Dataset friendly name/slug and identifiers
	 *
	 * This array contains the sanitized names and identifiers of Datasets defined in
	 * the "IPTC-NAA Information Interchange Model Version No. 4.1".
	 *
	 * @since 0.90
	 *
	 * @var	array
	 */
	public static $mla_iptc_keys = array(
		// Envelope Record
		'model-version' => '1#000',
		'destination' => '1#005',
		'file-format' => '1#020',
		'file-format-version' => '1#022',
		'service-identifier' => '1#030',
		'envelope-number' => '1#040',
		'product-id' => '1#050',
		'envelope-priority' => '1#060',
		'date-sent' => '1#070',
		'time-sent' => '1#080',
		'coded-character-set' => '1#090',
		'uno' => '1#100',
		'arm-identifier' => '1#120',
		'arm-version' => '1#122',

		// Application Record
		'record-version' => '2#000',
		'object-type-reference' => '2#003',
		'object-attribute-reference' => '2#004',
		'object-name' => '2#005',
		'edit-status' => '2#007',
		'editorial-update' => '2#008',
		'urgency' => '2#010',
		'subject-reference' => '2#012',
		'category' => '2#015',
		'supplemental-category' => '2#020',
		'fixture-identifier' => '2#022',
		'keywords' => '2#025',
		'content-location-code' => '2#026',
		'content-location-name' => '2#027',
		'release-date' => '2#030',
		'release-time' => '2#035',
		'expiration-date' => '2#037',
		'expiration-time' => '2#038',
		'special-instructions' => '2#040',
		'action-advised' => '2#042',
		'reference-service' => '2#045',
		'reference-date' => '2#047',
		'reference-number' => '2#050',
		'date-created' => '2#055',
		'time-created' => '2#060',
		'digital-creation-date' => '2#062',
		'digital-creation-time' => '2#063',
		'originating-program' => '2#065',
		'program-version' => '2#070',
		'object-cycle' => '2#075',
		'by-line' => '2#080',
		'by-line-title' => '2#085',
		'city' => '2#090',
		'sub-location' => '2#092',
		'province-or-state' => '2#095',
		'country-or-primary-location-code' => '2#100',
		'country-or-primary-location-name' => '2#101',
		'original-transmission-reference' => '2#103',
		'headline' => '2#105',
		'credit' => '2#110',
		'source' => '2#115',
		'copyright-notice' => '2#116',
		'contact' => '2#118',
		'caption-or-abstract' => '2#120',
		'caption-writer-or-editor' => '2#122',
		'rasterized-caption' => '2#125',
		'image-type' => '2#130',
		'image-orientation' => '2#131',
		'language-identifier' => '2#135',
		'audio-type' => '2#150',
		'audio-sampling-rate' => '2#151',
		'audio-sampling-resolution' => '2#152',
		'audio-duration' => '2#153',
		'audio-outcue' => '2#154',
		'objectdata-preview-file-format' => '2#200',
		'objectdata-preview-file-format-version' => '2#201',
		'objectdata-preview-data' => '2#202',

		// Pre ObjectData Descriptor Record
		'size-mode' => '7#010',
		'max-subfile-size' => '7#020',
		'objectdata-size-announced' => '7#090',
		'maximum-objectdata-size' => '7#095',

		// ObjectData Record
		'subfile' => '8#010',

		// Post ObjectData Descriptor Record
		'confirmed-objectdata-size' => '9#010'
);

	/**
	 * IPTC Dataset descriptions
	 *
	 * This array contains the descriptions of Datasets defined in
	 * the "IPTC-NAA Information Interchange Model Version No. 4.1".
	 *
	 * @since 0.90
	 *
	 * @var	array
	 */
	private static $mla_iptc_descriptions = array(
		// Envelope Record
		"1#000" => "2 octet binary IIM version number",
		"1#005" => "Max 1024 characters of Destination (ISO routing information); repeatable",
		"1#020" => "2 octet binary file format number, see IPTC-NAA V4 Appendix A",
		"1#022" => "2 octet binary file format version number",
		"1#030" => "Max 10 characters of Service Identifier and product",
		"1#040" => "8 Character Envelope Number",
		"1#050" => "Max 32 characters subset of provider's overall service; repeatable",
		"1#060" => "1 numeric character of envelope handling priority (not urgency)",
		"1#070" => "8 numeric characters of Date Sent by service - CCYYMMDD",
		"1#080" => "11 characters of Time Sent by service - HHMMSS±HHMM",
		"1#090" => "Max 32 characters of control functions, etc.",
		"1#100" => "14 to 80 characters of eternal, globally unique identification for objects",
		"1#120" => "2 octet binary Abstract Relationship Model Identifier",
		"1#122" => "2 octet binary Abstract Relationship Model Version",

		// Application Record
		"2#000" => "2 octet binary Information Interchange Model, Part II version number",
		"2#003" => "3 to 67 Characters of Object Type Reference number and optional text",
		"2#004" => "3 to 67 Characters of Object Attribute Reference number and optional text; repeatable",
		"2#005" => "Max 64 characters of the object name or shorthand reference",
		"2#007" => "Max 64 characters of the status of the objectdata",
		"2#008" => "2 numeric characters of the type of update this object provides",
		"2#010" => "1 numeric character of the editorial urgency of content",
		"2#012" => "13 to 236 characters of a structured definition of the subject matter; repeatable",
		"2#015" => "Max 3 characters of the subject of the objectdata, DEPRECATED",
		"2#020" => "Max 32 characters (each) of further refinement of subject, DEPRECATED; repeatable",
		"2#022" => "Max 32 characters identifying recurring, predictable content",
		"2#025" => "Max 64 characters (each) of tags; repeatable",
		"2#026" => "3 characters of ISO3166 country code or IPTC-assigned code; repeatable",
		"2#027" => "Max 64 characters of publishable country/geographical location name; repeatable",
		"2#030" => "8 numeric characters of Release Date - CCYYMMDD",
		"2#035" => "11 characters of Release Time (earliest use) - HHMMSS±HHMM",
		"2#037" => "8 numeric characters of Expiration Date (latest use) - CCYYMDD",
		"2#038" => "11 characters of Expiration Time (latest use) - HHMMSS±HHMM",
		"2#040" => "Max 256 Characters of editorial instructions, e.g., embargoes and warnings",
		"2#042" => "2 numeric characters of type of action this object provides to a previous object",
		"2#045" => "Max 10 characters of the Service ID (1#030) of a prior envelope; repeatable",
		"2#047" => "8 numeric characters of prior envelope Reference Date (1#070) - CCYYMMDD; repeatable",
		"2#050" => "8 characters of prior envelope Reference Number (1#040); repeatable",
		"2#055" => "8 numeric characters of intellectual content Date Created - CCYYMMDD",
		"2#060" => "11 characters of intellectual content Time Created - HHMMSS±HHMM",
		"2#062" => "8 numeric characters of digital representation creation date - CCYYMMDD",
		"2#063" => "11 characters of digital representation creation time - HHMMSS±HHMM",
		"2#065" => "Max 32 characters of the program used to create the objectdata",
		"2#070" => "Program Version - Max 10 characters of the version of the program used to create the objectdata",
		"2#075" => "1 character where a=morning, p=evening, b=both",
		"2#080" => "Max 32 Characters of the name of the objectdata creator, e.g., the writer, photographer; repeatable",
		"2#085" => "Max 32 characters of the title of the objectdata creator; repeatable",
		"2#090" => "Max 32 Characters of the city of objectdata origin",
		"2#092" => "Max 32 Characters of the location within the city of objectdata origin",
		"2#095" => "Max 32 Characters of the objectdata origin Province or State",
		"2#100" => "3 characters of ISO3166 or IPTC-assigned code for Country of objectdata origin",
		"2#101" => "Max 64 characters of publishable country/geographical location name of objectdata origin",
		"2#103" => "Max 32 characters of a code representing the location of original transmission",
		"2#105" => "Max 256 Characters of a publishable entry providing a synopsis of the contents of the objectdata",
		"2#110" => "Max 32 Characters that identifies the provider of the objectdata (Vs the owner/creator)",
		"2#115" => "Max 32 Characters that identifies the original owner of the intellectual content",
		"2#116" => "Max 128 Characters that contains any necessary copyright notice",
		"2#118" => "Max 128 characters that identifies the person or organisation which can provide further background information; repeatable",
		"2#120" => "Max 2000 Characters of a textual description of the objectdata",
		"2#122" => "Max 32 Characters that the identifies the person involved in the writing, editing or correcting the objectdata or caption/abstract; repeatable",
		"2#125" => "7360 binary octets of the rasterized caption - 1 bit per pixel, 460x128-pixel image",
		"2#130" => "2 characters of color composition type and information",
		"2#131" => "1 alphabetic character indicating the image area layout - P=portrait, L=landscape, S=square",
		"2#135" => "2 or 3 aphabetic characters containing the major national language of the object, according to the ISO 639:1988 codes",
		"2#150" => "2 characters identifying monaural/stereo and exact type of audio content",
		"2#151" => "6 numeric characters representing the audio sampling rate in hertz (Hz)",
		"2#152" => "2 numeric characters representing the number of bits in each audio sample",
		"2#153" => "6 numeric characters of the Audio Duration - HHMMSS",
		"2#154" => "Max 64 characters of the content of the end of an audio objectdata",
		"2#200" => "2 octet binary file format of the ObjectData Preview",
		"2#201" => "2 octet binary particular version of the ObjectData Preview File Format",
		"2#202" => "Max 256000 binary octets containing the ObjectData Preview data",

		// Pre ObjectData Descriptor Record
		"7#010" => "1 numeric character - 0=objectdata size not known, 1=objectdata size known at beginning of transfer",
		"7#020" => "4 octet binary maximum subfile dataset(s) size",
		"7#090" => "4 octet binary objectdata size if known at beginning of transfer",
		"7#095" => "4 octet binary largest possible objectdata size",

		// ObjectData Record
		"8#010" => "Subfile DataSet containing the objectdata itself; repeatable",

		// Post ObjectData Descriptor Record
		"9#010" => "4 octet binary total objectdata size"
	);

	/**
	 * IPTC file format identifiers and descriptions
	 *
	 * This array contains the file format identifiers and descriptions defined in
	 * the "IPTC-NAA Information Interchange Model Version No. 4.1" for dataset 1#020.
	 *
	 * @since 0.90
	 *
	 * @var	array
	 */
	private static $mla_iptc_formats = array(
		 0 => "No ObjectData",
		 1 => "IPTC-NAA Digital Newsphoto Parameter Record",
		 2 => "IPTC7901 Recommended Message Format",
		 3 => "Tagged Image File Format (Adobe/Aldus Image data)",
		 4 => "Illustrator (Adobe Graphics data)",
		 5 => "AppleSingle (Apple Computer Inc)",
		 6 => "NAA 89-3 (ANPA 1312)",
		 7 => "MacBinary II",
		 8 => "IPTC Unstructured Character Oriented File Format (UCOFF)",
		 9 => "United Press International ANPA 1312 variant",
		10 => "United Press International Down-Load Message",
		11 => "JPEG File Interchange (JFIF)",
		12 => "Photo-CD Image-Pac (Eastman Kodak)",
		13 => "Microsoft Bit Mapped Graphics File [*.BMP]",
		14 => "Digital Audio File [*.WAV] (Microsoft & Creative Labs)",
		15 => "Audio plus Moving Video [*.AVI] (Microsoft)",
		16 => "PC DOS/Windows Executable Files [*.COM][*.EXE]",
		17 => "Compressed Binary File [*.ZIP] (PKWare Inc)",
		18 => "Audio Interchange File Format AIFF (Apple Computer Inc)",
		19 => "RIFF Wave (Microsoft Corporation)",
		20 => "Freehand (Macromedia/Aldus)",
		21 => "Hypertext Markup Language - HTML (The Internet Society)",
		22 => "MPEG 2 Audio Layer 2 (Musicom), ISO/IEC",
		23 => "MPEG 2 Audio Layer 3, ISO/IEC",
		24 => "Portable Document File (*.PDF) Adobe",
		25 => "News Industry Text Format (NITF)",
		26 => "Tape Archive (*.TAR)",
		27 => "Tidningarnas Telegrambyrå NITF version (TTNITF DTD)",
		28 => "Ritzaus Bureau NITF version (RBNITF DTD)",
		29 => "Corel Draw [*.CDR]"
	);

	/**
	 * IPTC image type identifiers and descriptions
	 *
	 * This array contains the image type identifiers and descriptions defined in
	 * the "IPTC-NAA Information Interchange Model Version No. 4.1" for dataset 2#130, octet 2.
	 *
	 * @since 0.90
	 *
	 * @var	array
	 */
	private static $mla_iptc_image_types = array(
		"M" => "Monochrome",
		"Y" => "Yellow Component",
		"M" => "Magenta Component",
		"C" => "Cyan Component",
		"K" => "Black Component",
		"R" => "Red Component",
		"G" => "Green Component",
		"B" => "Blue Component",
		"T" => "Text Only",
		"F" => "Full colour composite, frame sequential",
		"L" => "Full colour composite, line sequential",
		"P" => "Full colour composite, pixel sequential",
		"S" => "Full colour composite, special interleaving"
	);

	/**
	 * Parse one IPTC metadata field
	 * 
	 * @since 1.41
	 *
	 * @param	string	field name - IPTC Identifier or friendly name/slug
	 * @param	array	metadata array containing iptc, exif, xmp and pdf metadata arrays
	 * @param	string	data option; 'text'|'single'|'export'|'array'|'multi'
	 * @param	boolean	Optional: for option 'multi', retain existing values
	 *
	 * @return	mixed	string/array representation of metadata value or an empty string
	 */
	public static function mla_iptc_metadata_value( $iptc_key, $item_metadata, $option = 'text', $keep_existing = false ) {
		// convert friendly name/slug to identifier
		if ( array_key_exists( $iptc_key, self::$mla_iptc_keys ) ) {
			$iptc_key = self::$mla_iptc_keys[ $iptc_key ];
		}

		if ( 'ALL_IPTC' == $iptc_key ) {
			$clean_data = array();
			if ( isset( $item_metadata['mla_iptc_metadata'] ) && is_array( $item_metadata['mla_iptc_metadata'] ) ) {
				foreach ( $item_metadata['mla_iptc_metadata'] as $key => $value ) {
					if ( is_array( $value ) ) {
						foreach ($value as $text_key => $text )
							$value[ $text_key ] = self::_bin_to_utf8( $text );

						$clean_data[ $key ] = 'ARRAY(' . implode( ',', $value ) . ')';
					} elseif ( is_string( $value ) ) {
						$clean_data[ $key ] = self::_bin_to_utf8( substr( $value, 0, 256 ) );
					} else {
						$clean_data[ $key ] = self::_bin_to_utf8( $value );
					}
				}
			}

			return var_export( $clean_data, true);
		}

		return self::mla_find_array_element( $iptc_key, $item_metadata['mla_iptc_metadata'], $option, $keep_existing );
	}

	/**
	 * Parse one EXIF metadata field
	 * 
	 * Also handles the special pseudo-values 'ALL_EXIF' and 'ALL_IPTC'.
	 *
	 * @since 1.13
	 *
	 * @param	string	field name
	 * @param	array	metadata array containing iptc, exif, xmp and pdf metadata arrays
	 * @param	string	data option; 'text'|'single'|'export'|'array'|'multi'
	 * @param	boolean	Optional: for option 'multi', retain existing values
	 *
	 * @return	mixed	string/array representation of metadata value or an empty string
	 */
	public static function mla_exif_metadata_value( $exif_key, $item_metadata, $option = 'text', $keep_existing = false ) {
		if ( 'ALL_EXIF' == $exif_key ) {
			$clean_data = array();
			if ( isset( $item_metadata['mla_exif_metadata'] ) && is_array( $item_metadata['mla_exif_metadata'] ) ) {
				foreach ( $item_metadata['mla_exif_metadata'] as $key => $value ) {
					if ( is_array( $value ) ) {
						$clean_data[ $key ] = '(ARRAY)';
					} elseif ( is_string( $value ) ) {
						$clean_data[ $key ] = self::_bin_to_utf8( substr( $value, 0, 256 ) );
					} else {
						$clean_data[ $key ] = $value;
					}
				}
			}

			return var_export( $clean_data, true);
		} elseif ( 'ALL_IPTC' == $exif_key ) {
			$clean_data = array();
			if ( isset( $item_metadata['mla_iptc_metadata'] ) && is_array( $item_metadata['mla_iptc_metadata'] ) ) {
				foreach ( $item_metadata['mla_iptc_metadata'] as $key => $value ) {
					if ( is_array( $value ) ) {
						foreach ($value as $text_key => $text )
							$value[ $text_key ] = self::_bin_to_utf8( $text );

						$clean_data[ $key ] = 'ARRAY(' . implode( ',', $value ) . ')';
					} elseif ( is_string( $value ) ) {
						$clean_data[ $key ] = self::_bin_to_utf8( substr( $value, 0, 256 ) );
					} else {
						$clean_data[ $key ] = self::_bin_to_utf8( $value );
					}
				}
			}

			return var_export( $clean_data, true);
		}

		return self::mla_find_array_element( $exif_key, $item_metadata['mla_exif_metadata'], $option, $keep_existing );
	}

	/**
	 * Parse one PNG image metadata field
	 * 
	 * Also handles the special pseudo-value 'ALL_PNG'.
	 *
	 * @since 3.08
	 *
	 * @param	string	field name
	 * @param	array	PNG metadata array
	 * @param	string	data option; 'text'|'single'|'export'|'array'|'multi'
	 * @param	boolean	Optional: for option 'multi', retain existing values
	 *
	 * @return	mixed	string/array representation of metadata value or an empty string
	 */
	public static function mla_png_metadata_value( $png_key, $png_metadata, $option = 'text', $keep_existing = false ) {
		if ( 'ALL_PNG' == $png_key ) {
			$clean_data = array();
			if ( is_array( $png_metadata ) ) {
				foreach ( $png_metadata as $key => $value ) {
					if ( is_array( $value ) ) {
						$clean_data[ $key ] = '(ARRAY)';
					} elseif ( is_string( $value ) ) {
						$clean_data[ $key ] = self::_bin_to_utf8( substr( $value, 0, 256 ) );
					} else {
						$clean_data[ $key ] = $value;
					}
				}
			}

			return var_export( $clean_data, true);
		}

		return self::mla_find_array_element($png_key, $png_metadata, $option, $keep_existing );
	}

	/**
	 * Parse one MS Office metadata field
	 * 
	 * Also handles the special pseudo-value 'ALL_MSO'.
	 *
	 * @since 2.82
	 *
	 * @param	string	field name
	 * @param	array	MS Office metadata array
	 * @param	string	data option; 'text'|'single'|'export'|'array'|'multi'
	 * @param	boolean	Optional: for option 'multi', retain existing values
	 *
	 * @return	mixed	string/array representation of metadata value or an empty string
	 */
	public static function mla_mso_metadata_value( $mso_key, $mso_metadata, $option = 'text', $keep_existing = false ) {
		if ( 'ALL_MSO' == $mso_key ) {
			$clean_data = array();
			if ( is_array( $mso_metadata ) ) {
				foreach ( $mso_metadata as $key => $value ) {
					if ( is_array( $value ) ) {
						$clean_data[ $key ] = '(ARRAY)';
					} elseif ( is_string( $value ) ) {
						$clean_data[ $key ] = self::_bin_to_utf8( substr( $value, 0, 256 ) );
					} else {
						$clean_data[ $key ] = $value;
					}
				}
			}

			return var_export( $clean_data, true);
		}

		return self::mla_find_array_element($mso_key, $mso_metadata, $option, $keep_existing );
	}

	/**
	 * Parse one XMP metadata field
	 * 
	 * Also handles the special pseudo-value 'ALL_XMP'.
	 *
	 * @since 2.10
	 *
	 * @param	string	field name
	 * @param	array	XMP metadata array
	 * @param	string	data option; 'text'|'single'|'export'|'array'|'multi'
	 * @param	boolean	Optional: for option 'multi', retain existing values
	 *
	 * @return	mixed	string/array representation of metadata value or an empty string
	 */
	public static function mla_xmp_metadata_value( $xmp_key, $xmp_metadata, $option = 'text', $keep_existing = false ) {
		if ( 'ALL_XMP' == $xmp_key ) {
			$clean_data = array();
			if ( is_array( $xmp_metadata ) ) {
				foreach ( $xmp_metadata as $key => $value ) {
					if ( is_array( $value ) ) {
						$clean_data[ $key ] = '(ARRAY)';
					} elseif ( is_string( $value ) ) {
						$clean_data[ $key ] = self::_bin_to_utf8( substr( $value, 0, 256 ) );
					} else {
						$clean_data[ $key ] = $value;
					}
				}
			}

			return var_export( $clean_data, true);
		}

		return self::mla_find_array_element($xmp_key, $xmp_metadata, $option, $keep_existing );
	}

	/**
	 * Parse one ID3 (audio/visual) metadata field
	 * 
	 * Also handles the special pseudo-value 'ALL_ID3'.
	 *
	 * @since 2.13
	 *
	 * @param	string	field name
	 * @param	array	ID3 metadata array
	 * @param	string	data option; 'text'|'single'|'export'|'array'|'multi'
	 * @param	boolean	Optional: for option 'multi', retain existing values
	 *
	 * @return	mixed	string/array representation of metadata value or an empty string
	 */
	public static function mla_id3_metadata_value( $id3_key, $id3_metadata, $option, $keep_existing ) {
		if ( 'ALL_ID3' == $id3_key ) {
			$clean_data = array();
			if ( is_array( $id3_metadata ) ) {
				foreach ( $id3_metadata as $key => $value ) {
					if ( is_array( $value ) ) {
						$clean_data[ $key ] = '(ARRAY)';
					} elseif ( is_string( $value ) ) {
						$clean_data[ $key ] = self::_bin_to_utf8( substr( $value, 0, 256 ) );
					} else {
						$clean_data[ $key ] = $value;
					}
				}
			}

			return var_export( $clean_data, true);
		}

		return self::mla_find_array_element($id3_key, $id3_metadata, $option, $keep_existing );
	}

	/**
	 * Parse one PDF metadata field
	 * 
	 * Also handles the special pseudo-value 'ALL_PDF'.
	 *
	 * @since 1.50
	 *
	 * @param	string	field name
	 * @param	string	metadata array containing iptc, exif, xmp and pdf metadata arrays
	 *
	 * @return	mixed	string/array representation of metadata value or an empty string
	 */
	public static function mla_pdf_metadata_value( $pdf_key, $item_metadata ) {
		$text = '';
		if ( array_key_exists( $pdf_key, $item_metadata['mla_pdf_metadata'] ) ) {
			$text = $item_metadata['mla_pdf_metadata'][ $pdf_key ];
			if ( is_array( $text ) ) {
				foreach ($text as $key => $value ) {
					if ( is_array( $value ) ) {
						$text[ $key ] = self::_bin_to_utf8( var_export( $value, true ) );
					} else {
						$text[ $key ] = self::_bin_to_utf8( $value );
					}
				}
			} elseif ( is_string( $text ) ) {
				$text = self::_bin_to_utf8( $text );
			}
		} elseif ( 'ALL_PDF' == $pdf_key ) {
			$clean_data = array();
			if ( isset( $item_metadata['mla_pdf_metadata'] ) && is_array( $item_metadata['mla_pdf_metadata'] ) ) {
				foreach ( $item_metadata['mla_pdf_metadata'] as $key => $value ) {
					if ( is_array( $value ) ) {
						$clean_data[ $key ] = '(ARRAY)';
					} elseif ( is_string( $value ) ) {
						$clean_data[ $key ] = self::_bin_to_utf8( substr( $value, 0, 256 ) );
					} else {
						$clean_data[ $key ] = $value;
					}
				}
			}

			$text = var_export( $clean_data, true);
		} // ALL_PDF

		return $text;
	}

	/**
	 * Convert an EXIF GPS rational value to a PHP float value
	 * 
	 * @since 1.50
	 *
	 * @param	array	array( 0 => numerator, 1 => denominator )
	 *
	 * @return	float	numerator/denominator
	 */
	private static function _rational_to_decimal( $rational ) {
		$parts = explode('/', $rational);
		return $parts[0] / ( $parts[1] ? $parts[1] : 1);
	}

	/**
	 * Convert an EXIF rational value to a formatted string
	 * 
	 * @since 2.02
	 *
	 * @param	string	numerator/denominator
	 * @param	string	format for integer values
	 * @param	string	format for fractional values from -1 to +1
	 * @param	string	format for integer.fraction values 
	 *
	 * @return	mixed	formatted value or boolean false if no value available
	 */
	private static function _rational_to_string( $rational, $integer_format, $fraction_format, $mixed_format ) {
		$fragments = array_map( 'intval', explode( '/', $rational ) );
		if ( 1 == count( $fragments ) ) {
			$value = trim( $rational );
			if ( ! empty( $value ) ) {
				return $value;
			}
		} else {
			if ( $fragments[0] ) {
				if ( 1 == $fragments[1] ) {
					return sprintf( $integer_format, $fragments[0] );
				} elseif ( 0 != $fragments[1] ) {
					$value = $fragments[0] / $fragments[1];
						if ( ( -1 <= $value ) && ( 1 >= $value ) ) {
							return sprintf( $fraction_format, $fragments[0], $fragments[1] );
						} else {
							if ( $value == (int) $value ) {
								return sprintf( $integer_format, $value );
							}else {
								return sprintf( $mixed_format, $value );
							}
						} // mixed value
				} // fractional or mixed value
			} // non-zero numerator
		} // valid denominator

		return false;
	}

	/**
	 * Passes IPTC/EXIF/WP parse errors between mla_metadata_error_handler
	 * and mla_fetch_attachment_image_metadata
	 *
	 * @since 1.81
	 *
	 * @var	array
	 */
	private static $mla_metadata_errors = array();

	/**
	 * Intercept IPTC, EXIF and ID3 parse errors
	 * 
	 * @since 1.81
	 *
	 * @param	int		the level of the error raised
	 * @param	string	the error message
	 * @param	string	the filename that the error was raised in
	 * @param	int		the line number the error was raised at
	 *
	 * @return	boolean	true, to bypass PHP error handler
	 */
	public static function mla_metadata_error_handler( $type, $string, $file, $line ) {
//error_log( 'DEBUG: mla_metadata_error_handler $type = ' . var_export( $type, true ), 0 );
//error_log( 'DEBUG: mla_metadata_error_handler $string = ' . var_export( $string, true ), 0 );
//error_log( 'DEBUG: mla_metadata_error_handler $file = ' . var_export( $file, true ), 0 );
//error_log( 'DEBUG: mla_metadata_error_handler $line = ' . var_export( $line, true ), 0 );

		switch ( $type ) {
			case E_ERROR:
				$level = 'E_ERROR';
				break;
			case E_WARNING:
				$level = 'E_WARNING';
				break;
			case E_NOTICE:
				$level = 'E_NOTICE';
				break;
			default:
				$level = 'OTHER';
		}

		$path_info = pathinfo( $file );
		$file_name = $path_info['basename'];
		MLAData::$mla_metadata_errors[] = "{$level} ({$type}) {$string}";

		/* Don't execute PHP internal error handler */
		return true;
	}

	/**
	 * Convert .webp EXIF chunk data to a proper EXIF array
	 * 
	 * @since 3.07
	 *
	 * @param	string	raw EXIF chunk data.
	 * @param	integer	optinoal. Image width.
	 * @param	integer	optinoal. Image height.
	 *
	 * @return	array	Meta data variables, including 'audio' and 'video'
	 */
	private static function _convert_webp_exif_metadata( $raw_exif, $width = 1, $height = 1 ) {

		if ( ! class_exists( 'Imagick' ) ) {
			return array();
		}
		
		// Container for metadata and any errors
		$results = array();
		
		// Create an Imagick object with plain white canvas
		$img = new Imagick();
		$img->newImage( $width, $height, new ImagickPixel('white') );
		$img->setImageFormat('jpeg');

		// Get the image "file contents"
		$image_blob = $img->getImageBlob();
		$image_length = strlen( $image_blob );
//error_log( "MLM image_blob ( {$image_length} ) = " . var_export( MLAData::mla_hex_dump( $image_blob, 2048, 32, 0 ), true ), 0 );

		set_error_handler( 'MLAData::mla_metadata_error_handler' );
		try {
			$file_path = plugin_dir_path( __FILE__ ) . 'exif.jpg';
	
			if ( file_exists( $file_path ) ) {
				unlink( $file_path ); // delete if exists
			}
			
			$file_handle = fopen( $file_path, 'wb' );
	
			$SOI_section = chr( 0xFF ) . chr( 0xD8 );
			if ( false === fwrite( $file_handle, $SOI_section ) ) {
				$error_info = error_get_last();
				MLAData::$mla_metadata_errors[] = sprintf( '%1$d ERROR: fwrite SOI $error_info = "%2$s".', __LINE__, var_export( $error_info, true ) );
			}
	
			// Some files have the six-byte prefix, some do not
			$exif_prefix = 'Exif' . chr( 0x00 ) . chr( 0x00 );
			if ( 0 !== strpos( $raw_exif, $exif_prefix ) ) {
				$raw_exif = $exif_prefix . $raw_exif;
			}
			
			$exif_length = 2 + strlen( $raw_exif );
			$APP1_section  = chr( 0xFF ) . chr( 0xE1 ) . chr( intdiv( $exif_length, 256 ) ) . chr( $exif_length % 256 ) . $raw_exif;
			if ( false === fwrite( $file_handle, $APP1_section ) ) {
				$error_info = error_get_last();
				MLAData::$mla_metadata_errors[] = sprintf( '%1$d ERROR: fwrite APP1 $error_info = "%2$s".', __LINE__, var_export( $error_info, true ) );
			}
	
			// Splice the EXIF data into the image blob
			if ( false === fwrite( $file_handle, substr( $image_blob, 2 ) ) ) {
				$error_info = error_get_last();
				MLAData::$mla_metadata_errors[] = sprintf( '%1$d ERROR: fwrite tail $error_info = "%2$s".', __LINE__, var_export( $error_info, true ) );
			}
	
//$tmpfile_content = file_get_contents( $file_path );
//error_log( __LINE__ . " _convert_webp_exif_metadata() tmpfile_content = " . var_export( MLAData::mla_hex_dump( $tmpfile_content, 2048, 32, 0 ), true ), 0 );

			fclose( $file_handle );
	
			$tmpfile_metadata = self::mla_fetch_attachment_image_metadata( 0, $file_path );
//error_log( __LINE__ . " _convert_webp_exif_metadata() tmpfile_metadata = " . var_export( $tmpfile_metadata, true ), 0 );
			
			// Put errors back in the shared variable for processing below
			if ( !empty( $tmpfile_metadata['mla_exif_errors'] ) ) {
				MLAData::$mla_metadata_errors = $tmpfile_metadata['mla_exif_errors'];
			}
			
			unlink( $file_path ); // delete if exists
		} catch ( Throwable $e ) { // PHP 7
			$exception = $e;
			$tmpfile_metadata = NULL;
		} catch ( Exception $e ) { // PHP 5
			$exception = $e;
			$tmpfile_metadata = NULL;
		}
		restore_error_handler();
	
		if ( ! empty( $exception ) ) {
			MLAData::$mla_metadata_errors[] = sprintf( '%1$d ERROR: (%2$s) %3$s', __LINE__, $exception->getCode(), $exception->getMessage() );
		}
	
		// Combine exceptions with PHP notice/warning/error messages
		if ( ! empty( MLAData::$mla_metadata_errors ) ) {
			$results['mla_webp_exif_errors'] = MLAData::$mla_metadata_errors;
			MLACore::mla_debug_add( __LINE__ . ' ' . __( 'ERROR', 'media-library-assistant' ) . ': ' . '$results[mla_webp_exif_errors] = ' . var_export( $results['mla_webp_exif_errors'], true ), MLACore::MLA_DEBUG_CATEGORY_ANY );
			MLAData::$mla_metadata_errors = array();
		}

		if ( isset( $tmpfile_metadata['mla_exif_metadata'] ) ) {
			$results['mla_webp_exif_metadata'] = $tmpfile_metadata['mla_exif_metadata'];
		} else {
			$results['mla_webp_exif_metadata'] = array();
		}

//error_log( __LINE__ . " _convert_webp_exif_metadata() results = " . var_export( $results, true ), 0 );
		return $results;
	}

	/**
	 * Fetch and filter ID3 metadata for an audio or video attachment
	 * 
	 * Adapted from /wp-admin/includes/media.php functions wp_add_id3_tag_data,
	 * wp_read_video_metadata and wp_read_audio_metadata
	 *
	 * @since 2.13
	 *
	 * @param	int		post ID of attachment
	 * @param	string	optional; if $post_id is zero, path to the image file.
	 *
	 * @return	array	Meta data variables, including 'audio' and 'video'
	 */
	public static function mla_fetch_attachment_id3_metadata( $post_id, $path = '' ) {
		static $id3 = NULL;

		if ( 0 != $post_id ) {
			$path = get_attached_file( $post_id );
		}

//$id3_chunk = file_get_contents( $path, true, NULL, 57500, 256 );
//error_log( __LINE__ . " mla_fetch_attachment_id3_metadata( {$post_id} ) id3_chunk = " . var_export( MLAData::mla_hex_dump( $id3_chunk ), true ), 0 );

		if ( ! empty( $path ) ) {
			if ( ! class_exists( 'getID3' ) ) {
				require( ABSPATH . WPINC . '/ID3/getid3.php' );
			}

			if ( NULL == $id3 ) {
				$id3 = new getID3();
			}

			$data = $id3->analyze( $path );
		} else {
			$data = array();
		}
		MLACore::mla_debug_add( __LINE__ . " MLAData::mla_fetch_attachment_id3_metadata( {$post_id}, {$path} ) ID3 data = " . var_export( $data, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );

		if ( isset( $data['riff']['WEBP']['EXIF'][0]['data'] ) ) {
//error_log( __LINE__ . " mla_fetch_attachment_id3_metadata( {$post_id} ) EXIF[0][data] = " . var_export( MLAData::mla_hex_dump( $data['riff']['WEBP']['EXIF'][0]['data'] ), true ), 0 );

//error_log( __LINE__ . " mla_fetch_attachment_id3_metadata( {$post_id} ) VP8_array = " . var_export( $data['riff']['WEBP']['VP8 '], true ), 0 );
			if ( isset( $data['riff']['WEBP']['VP8 '][0]['width'] ) ) {
				$width = $data['riff']['WEBP']['VP8 '][0]['width'];
				$height = $data['riff']['WEBP']['VP8 '][0]['height'];
			} else {
				$width = 1;
				$height = 1;
			}

			$exif_array = self::_convert_webp_exif_metadata( $data['riff']['WEBP']['EXIF'][0]['data'], $width, $height );
//error_log( __LINE__ . " mla_fetch_attachment_id3_metadata( {$post_id} ) exif_array = " . var_export( $exif_array, true ), 0 );

			// Replace the exif.jpg values with the original webp file values
			$exif_array['mla_webp_exif_metadata']['FileName'] = $data['filename'];
			$exif_array['mla_webp_exif_metadata']['FileDateTime'] = (integer) filectime( $path ); // Date uploaded
			$exif_array['mla_webp_exif_metadata']['FileSize'] = $data['filesize'];
			$exif_array['mla_webp_exif_metadata']['MimeType'] = $data['mime_type'];
			$exif_array['mla_webp_exif_metadata']['COMPUTED']['IsColor'] = 1;

			// Add this data to the ID3 data
			$data['mla_webp_exif_metadata'] = $exif_array['mla_webp_exif_metadata'];
			
			if ( isset( $exif_array['mla_webp_exif_errors'] ) ) {
				$data['mla_webp_exif_errors'] = $exif_array['mla_webp_exif_errors'];
			}
		}

		if ( ! empty( $data['filesize'] ) )
			$data['filesize'] = (int) $data['filesize'];
		if ( ! empty( $data['playtime_seconds'] ) )
			$data['length'] = (int) round( $data['playtime_seconds'] );

		// from wp_read_video_metadata
		if ( ! empty( $data['video'] ) ) {
			if ( ! empty( $data['video']['bitrate'] ) )
				$data['bitrate'] = (int) $data['video']['bitrate'];
			if ( ! empty( $data['video']['resolution_x'] ) )
				$data['width'] = (int) $data['video']['resolution_x'];
			if ( ! empty( $data['video']['resolution_y'] ) )
				$data['height'] = (int) $data['video']['resolution_y'];
		}

		// from wp_read_audio_metadata
		if ( ! empty( $data['audio'] ) ) {
			unset( $data['audio']['streams'] );
		}

		// from wp_add_id3_tag_data
		foreach ( array( 'id3v2', 'id3v1' ) as $version ) {
			if ( ! empty( $data[ $version ]['comments'] ) ) {
				foreach ( $data[ $version ]['comments'] as $key => $list ) {
					if ( 'length' !== $key && ! empty( $list ) ) {
						$data[ $key ] = reset( $list );
						// Fix bug in byte stream analysis.
						if ( 'terms_of_use' === $key && 0 === strpos( $metadata[ $key ], 'yright notice.' ) )
							$metadata[ $key ] = 'Cop' . $metadata[$key];
					}
				}
				break;
			}
		}

		unset( $data['id3v2']['comments'] );
		unset( $data['id3v1']['comments'] );

		if ( ! empty( $data['id3v2']['APIC'] ) ) {
			$image = reset( $data['id3v2']['APIC']);
			if ( ! empty( $image['data'] ) ) {
				$data['image'] = array(
					'data' => $image['data'],
					'mime' => $image['image_mime'],
					'width' => $image['image_width'],
					'height' => $image['image_height']
				);
			}

			unset( $data['id3v2']['APIC'] );
		} elseif ( ! empty( $data['comments']['picture'] ) ) {
			$image = reset( $data['comments']['picture'] );
			if ( ! empty( $image['data'] ) ) {
				$data['image'] = array(
					'data' => $image['data'],
					'mime' => $image['image_mime']
				);
			}

			unset( $data['comments']['picture'] );
		}

		$data['post_id'] = $post_id;

		MLACore::mla_debug_add( __LINE__ . " MLAData::mla_fetch_attachment_id3_metadata( {$post_id}, {$path} ) final data = " . var_export( $data, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );
		return $data;
	}

	/**
	 * Format and return IPTC and EXIF, XMP or PDF metadata,
	 * in substitution parameter syntax with prefixes.
	 * 
	 * @since 2.98
	 *
	 * @param	array	$value Array of metadata elements
	 * @param	string	$prefix Element ancestor(s) 
	 * @param	string	$separator Optional. Delimiter between prefix and element name
	 *
	 * @return	string	Metadata text representation in "var_export" format
	 */
	private static function _compose_metadata_array( $value, $prefix, $separator = '.' ) {
		$text = '';
		foreach ( $value as $key => $element ) {
			if ( is_array( $element ) && ( 1 === count( $element ) ) && isset( $element[0] ) ) {
				$element = $element[0];
			}

			if ( is_scalar( $element ) ) {
				$text .= $prefix . $separator . $key . ' => ' . (string) $element . "\r\n";
			} elseif ( is_array( $element ) ) {
				$text .= self::_compose_metadata_array( $element, $prefix . $separator . $key );
			}
		}
		
		return $text;
	}

	/**
	 * Format and return IPTC and EXIF, XMP or PDF metadata,
	 * in substitution parameter syntax with prefixes.
	 * 
	 * @since 2.98
	 *
	 * @param	int		post ID of attachment
	 * @param	string	optional; if $post_id is zero, path to the image file.
	 *
	 * @return	string	Text representation of the IPTC, EXIF, XMP, PDF and/or MSO metadata
	 */
	public static function mla_compose_attachment_metadata( $post_id, $path = '' ) {
		$metadata = self::mla_fetch_attachment_image_metadata( $post_id, $path );

		$text = '';
		foreach ( $metadata as $key => $value ) {
			$match_count = preg_match( '/mla_(.+)_metadata/', $key, $matches );
			if ( $match_count ) {
				$text .= self::_compose_metadata_array( $value, $matches[1], ':' );
			} elseif ( is_scalar( $value ) ) {
				$text .= $key . ' => ' . (string) $value . "\r\n";
			} elseif ( is_array( $value ) ) {
				$text .= self::_compose_metadata_array( $value, $key );
			}
		}

		$id3_metadata = self::mla_fetch_attachment_id3_metadata( $post_id );
		if ( !empty( $id3_metadata ) && !isset( $id3_metadata['error'] ) ) {
			$text .= self::_compose_metadata_array( $id3_metadata, 'id3', ':' );
		}

		return $text; // var_export( $metadata, true );
	}

	/**
	 * Fetch and filter IPTC, EXIF, XMP, PDF and/or MSO metadata for an attachment
	 * 
	 * @since 0.90
	 *
	 * @param	int		post ID of attachment
	 * @param	string	optional; if $post_id is zero, path to the image file.
	 *
	 * @return	array	Meta data variables, IPTC, EXIF, XMP, PDF and/or MSO
	 */
	public static function mla_fetch_attachment_image_metadata( $post_id, $path = '' ) {
		$results = array(
			'post_id' => $post_id,
			'mla_iptc_metadata' => array(),
			'mla_exif_metadata' => array(),
			'mla_xmp_metadata' => array(),
			'mla_pdf_metadata' => array(),
			'mla_mso_metadata' => array(),
			);

		if ( 0 != $post_id ) {
			$path = false;

			// WP 5.3+ produces "scaled" images without metadata; we need the original.
			if ( function_exists( 'wp_get_original_image_path' ) ) {
				$path = wp_get_original_image_path( $post_id );
			}

			if ( false === $path ) {
				$path = get_attached_file( $post_id );
			}
		}

		if ( ! empty( $path ) ) {
			if ( !file_exists( $path ) ) {
				MLACore::mla_debug_add( __LINE__ . ' ' . __( 'ERROR', 'media-library-assistant' ) . ': ' . "mla_fetch_attachment_image_metadata( {$post_id}, {$path} ) not found", MLACore::MLA_DEBUG_CATEGORY_ANY );
				return $results;
			}

			if ( 'pdf' == strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ) ) {
				if ( !class_exists( 'MLAPDF' ) ) {
					require_once( MLA_PLUGIN_PATH . 'includes/class-mla-data-pdf.php' );
				}

				$pdf_metadata = MLAPDF::mla_extract_pdf_metadata( $path );
				$results['mla_xmp_metadata'] = $pdf_metadata['xmp'];
				$results['mla_pdf_metadata'] = $pdf_metadata['pdf'];
				MLACore::mla_debug_add( __LINE__ . " MLAData::mla_fetch_attachment_image_metadata( {$post_id}, {$path} ) results = " . var_export( $results, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );
				return $results;
			}

			$filetype = wp_check_filetype( $path );
			if ( false !== strpos( $filetype['type'], 'vnd.openxmlformats-officedocument' ) ) {
				if ( !class_exists( 'MLAOffice' ) ) {
					require_once( MLA_PLUGIN_PATH . 'includes/class-mla-data-office.php' );
				}

				$mso_metadata = MLAOffice::mla_extract_office_metadata( $path );
				$results['mla_mso_metadata'] = $mso_metadata['mso'];
				MLACore::mla_debug_add( __LINE__ . " MLAData::mla_fetch_attachment_image_metadata( {$post_id}, {$path} ) results = " . var_export( $results, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );
				return $results;
			}

			$size = getimagesize( $path, $info );
			MLACore::mla_debug_add( __LINE__ . ' MLAData::mla_fetch_attachment_image_metadata getimagesize returns ' . var_export( $size, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );
			MLACore::mla_debug_add( __LINE__ . ' MLAData::mla_fetch_attachment_image_metadata getimagesize info keys =  ' . var_export( array_keys( $info ), true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );

			if ( is_callable( 'iptcparse' ) ) {
				if ( ! empty( $info['APP13'] ) ) {
					set_error_handler( 'MLAData::mla_metadata_error_handler' );
					try {
						$exception = NULL;
						$iptc_values = iptcparse( $info['APP13'] );
					} catch ( Throwable $e ) { // PHP 7
						$exception = $e;
						$iptc_values = NULL;
					} catch ( Exception $e ) { // PHP 5
						$exception = $e;
						$iptc_values = NULL;
					}
					restore_error_handler();

					// MLACore::mla_debug_add( __LINE__ . ' MLAData::mla_fetch_attachment_image_metadata iptc_values = ' . var_export( $iptc_values, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );

					if ( ! empty( $exception ) ) {
						MLAData::$mla_metadata_errors[] = sprintf( '(%1$s) %2$s', $exception->getCode(), $exception->getMessage() );
					}

					// Combine exceptions with PHP notice/warning/error messages
					if ( ! empty( MLAData::$mla_metadata_errors ) ) {
						$results['mla_iptc_errors'] = MLAData::$mla_metadata_errors;
						MLACore::mla_debug_add( __LINE__ . ' ' . __( 'ERROR', 'media-library-assistant' ) . ': ' . '$results[mla_iptc_errors] = ' . var_export( $results['mla_iptc_errors'], true ), MLACore::MLA_DEBUG_CATEGORY_ANY );
						MLAData::$mla_metadata_errors = array();
					}

					if ( ! is_array( $iptc_values ) ) {
						$iptc_values = array();
					}

					foreach ( $iptc_values as $key => $value ) {
						if ( in_array( $key, array( '1#000', '1#020', '1#022', '1#120', '1#122', '2#000', '2#200', '2#201' ) ) ) {
							$value = unpack( 'nbinary', $value[0] );
							$results['mla_iptc_metadata'][ $key ] = (string) $value['binary'];
						} elseif ( 1 == count( $value ) ) {
							$results['mla_iptc_metadata'][ $key ] = $value[0];
						} else {
							$results['mla_iptc_metadata'][ $key ] = $value;
						}
					} // foreach $value
				} // ! empty
			} // iptcparse

			if ( is_callable( 'exif_read_data' ) && !empty( $size[2] ) && in_array( $size[2], array( IMAGETYPE_JPEG, IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM ) ) ) {
				set_error_handler( 'MLAData::mla_metadata_error_handler' );
				try {
					$exception = NULL;
					$exif_data = exif_read_data( $path, NULL, true );
				} catch ( Throwable $e ) { // PHP 7
					$exception = $e;
					$exif_data = NULL;
				} catch ( Exception $e ) { // PHP 5
					$exception = $e;
					$exif_data = NULL;
				}
				restore_error_handler();

				// MLACore::mla_debug_add( __LINE__ . ' MLAData::mla_fetch_attachment_image_metadata (PHP ' . phpversion() . ') exif_data = ' . var_export( $exif_data, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );

				if ( ! empty( $exception ) ) {
					MLAData::$mla_metadata_errors[] = sprintf( '(%1$s) %2$s', $exception->getCode(), $exception->getMessage() );
				}

				// Combine exceptions with PHP notice/warning/error messages
				if ( ! empty( MLAData::$mla_metadata_errors ) ) {
					$results['mla_exif_errors'] = MLAData::$mla_metadata_errors;
					MLACore::mla_debug_add( __LINE__ . ' ' . __( 'ERROR', 'media-library-assistant' ) . ': ' . '$results[mla_exif_errors] = ' . var_export( $results['mla_exif_errors'], true ), MLACore::MLA_DEBUG_CATEGORY_ANY );
					MLAData::$mla_metadata_errors = array();
				}

				if ( ! is_array( $exif_data ) ) {
					$exif_data = array();
				}

				// Promote most array elements to top-level simple values, emulating $arrays = false
				foreach ( $exif_data as $section_name => $section_data ) {
					/*
				 	 * The sections COMPUTED, THUMBNAIL, and COMMENT always become arrays as
				 	 * they may contain values whose names conflict with other sections.
					 * The WINXP section usually contains garbage that overwrites IFD0 values.
				 	 */
					if ( in_array( $section_name, array ( 'COMPUTED', 'THUMBNAIL', 'COMMENT', 'WINXP' ) ) ) {
						$results['mla_exif_metadata'][ $section_name ] = $section_data;
						continue;
					}

					foreach ( $section_data as $element_name => $element_value ) {
						// JPEG standard defines 0xEA1C as "padding"
						if ( 'UndefinedTag:0xEA1C' === $element_name ) {
							continue;
						}

						// Decode some non-string values
						if ( 'IFD0' === $section_name ) {
							/*
							 * PixelUnit (1 byte) Units for the PixelPerUnitX and PixelPerUnitY densities
							 * 0: no units, PixelPerUnitX and PixelPerUnitY specify the pixel aspect ratio
							 * 1: PixelPerUnitX and PixelPerUnitY are dots per inch
							 * 2: PixelPerUnitX and PixelPerUnitY are dots per cm
							 */
							if ( 'PixelUnit' === $element_name ) {
								$element_value = (string) ord( $element_value );
							}

							if ( in_array( $element_name, array( 'Title', 'Keywords', 'Subject', 'Comments', 'Author', 'DateTimeOriginal' ) ) ) {
								if ( is_string( $element_value ) ) {
									// Problem with Unicode values edited through Windows right-click properties. 
									$element_value =  self::_parse_UTF16LE( $element_value );
								} elseif ( is_array( $element_value ) ) {
									// Problem with image submitted by Thomas Buchhorn 20240912. 
									$element_value =  self::_parse_ASCII_array( $element_value );
								}
							}
						}

						$results['mla_exif_metadata'][ $element_name ] = $element_value;
					} // foreach $section_data
				} // foreach $exif_data

				// $exif_data is used for enhanced values below
				$exif_data = $results['mla_exif_metadata'];
			} // exif_read_data

			// Extract IHDR and tEXt metadata from PNG files
			if ( !empty( $size['mime'] ) && 'image/png' === $size['mime'] ) {
				$results['mla_png_metadata'] = self::mla_parse_png_metadata( $path );

				if ( NULL === $results['mla_png_metadata'] ) {
					$results['mla_png_metadata'] = array();
				}
			}
			
			// Extract EXIF metadata from WebP files
			if ( !empty( $size['mime'] ) && 'image/webp' === $size['mime'] ) {
				$id3_metadata = self::mla_fetch_attachment_id3_metadata( $post_id );

				if ( !empty( $id3_metadata['mla_webp_exif_metadata'] ) ) {
					$results['mla_exif_metadata'] = $id3_metadata['mla_webp_exif_metadata'];
					$exif_data = $results['mla_exif_metadata'];
					
					if ( !empty( $id3_metadata['mla_webp_exif_errors'] ) ) {
					$results['mla_exif_errors'] = $id3_metadata['mla_webp_exif_errors'];
					}
				}
			} // image/webp

			$results['mla_xmp_metadata'] = self::mla_parse_xmp_metadata( $path, 0 );
			if ( NULL === $results['mla_xmp_metadata'] ) {
				$results['mla_xmp_metadata'] = array();
			}

			// damage repair for Robert O'Conner (Rufus McDufus)
			if ( isset( $exif_data['DateTimeOriginal'] ) && ( 8 > strlen( $exif_data['DateTimeOriginal'] ) ) ) {
				if ( isset( $results['mla_xmp_metadata']['CreateDate'] )&& ( is_numeric( strtotime( $results['mla_xmp_metadata']['CreateDate'] ) ) ) ) {
					$exif_data['BadDateTimeOriginal'] = $exif_data['DateTimeOriginal'];
					$results['mla_exif_metadata']['BadDateTimeOriginal'] = $exif_data['DateTimeOriginal'];

					$exif_data['DateTimeOriginal'] = $results['mla_xmp_metadata']['CreateDate'];
					$results['mla_exif_metadata']['DateTimeOriginal'] = $results['mla_xmp_metadata']['CreateDate'];
				}
			}

			// experimental damage repair for Elsie Gilmore (earthnutvt)
			if ( isset( $exif_data['Keywords'] ) && is_string( $exif_data['Keywords'] ) && ( '????' == substr( $exif_data['Keywords'], 0, 4 ) ) ) {
				if ( isset( $results['mla_xmp_metadata']['Keywords'] ) ) {
					$exif_data['Keywords'] = $results['mla_xmp_metadata']['Keywords'];
					$results['mla_exif_metadata']['Keywords'] = $results['mla_xmp_metadata']['Keywords'];
				} else {
					unset( $exif_data['Keywords'] );
					unset( $results['mla_exif_metadata']['Keywords'] );
				}
			}
		}

		// Filter the metadata before MLA expands CAMERA and GPS values
		$results = apply_filters( 'mla_fetch_attachment_image_metadata_raw', $results, $post_id, $path );

		/*
		 * Expand EXIF Camera-related values:
		 *
		 * ExposureBiasValue
		 * ExposureTime
		 * Flash
		 * FNumber 
		 * FocalLength
		 * ShutterSpeed from ExposureTime
		 */
		$new_data = array();
		if ( isset( $exif_data['FNumber'] ) ) {
			if ( false !== ( $value = self::_rational_to_string( $exif_data['FNumber'], '%1$d', '%1$d/%2$d', '%1$.1f' ) ) ) {
				$new_data['FNumber'] = $value;
			}
		} // FNumber

		if ( isset( $exif_data['ExposureBiasValue'] ) ) {
			$fragments = array_map( 'intval', explode( '/', $exif_data['ExposureBiasValue'] ) );
			if ( ! is_null( $fragments[1] ) ) {
				$numerator = $fragments[0];
				$denominator = $fragments[1];

				// Clean up some common format issues, e.g. 4/6, 2/4
				while ( ( ( 0 !== $numerator ) || ( 0 !== $denominator ) ) && ( 0 === ( $numerator & 0x1 ) ) && ( 0 === ( $denominator & 0x1 ) ) ) {
					$numerator = ( $numerator >> 1 );
					$denominator = ( $denominator >> 1 );
				}

				// Remove excess precision
				if ( ( $denominator > $numerator) && ( 1000 < $numerator ) && ( 1000 < $denominator ) ) {
					$exif_data['ExposureBiasValue'] = sprintf( '%1$+.3f', ( $numerator/$denominator ) );
				} else {
					$fragments[0] = $numerator;
					$fragments[1] = $denominator;
					$exif_data['ExposureBiasValue'] = $numerator . '/' . $denominator;
				}
			}

			if ( false !== ( $value = self::_rational_to_string( $exif_data['ExposureBiasValue'], '%1$+d', '%1$+d/%2$d', '%1$+.2f' ) ) ) {
				$new_data['ExposureBiasValue'] = $value;
			}
		} // ExposureBiasValue

		if ( isset( $exif_data['ExposureMode'] ) ) {
			switch ( absint( $exif_data['ExposureMode'] ) ) {
				case 0:
					$new_data['ExposureMode'] = __( 'Auto', 'media-library-assistant' );
					break;
				case 1:
					$new_data['ExposureMode'] = __( 'Manual', 'media-library-assistant' );
					break;
				case 2:
					$new_data['ExposureMode'] = __( 'Bracket', 'media-library-assistant' );
					break;
				default:
					$new_data['ExposureMode'] = __( 'Other', 'media-library-assistant' ) . ': ' . $exif_data['ExposureMode'];
			}
		} // ExposureMode

		if ( isset( $exif_data['Flash'] ) ) {
			$value = ( absint( $exif_data['Flash'] ) );
			if ( $value & 0x1 ) {
				$new_data['Flash'] = __( 'Yes', 'media-library-assistant' );
			} else {
				$new_data['Flash'] = __( 'No', 'media-library-assistant' );
			}
		} // Flash

		if ( isset( $exif_data['FocalLength'] ) ) {
			if ( false !== ( $value = self::_rational_to_string( $exif_data['FocalLength'], '%1$d', '%1$d/%2$d', '%1$.2f' ) ) ) {
				$new_data['FocalLength'] = $value;
			}
		} // FocalLength

		if ( isset( $exif_data['ExposureTime'] ) ) {
			if ( false !== ( $value = self::_rational_to_string( $exif_data['ExposureTime'], '%1$d', '%1$d/%2$d', '%1$.2f' ) ) ) {
				$new_data['ExposureTime'] = $value;
			}
		} // ExposureTime

		/*
		 * ShutterSpeed in "1/" format, from ExposureTime
		 * Special logic for "fractional shutter speed" values 1.3, 1.5, 1.6, 2.5
		 */
		if ( isset( $exif_data['ExposureTime'] ) ) {
			$fragments = array_map( 'intval', explode( '/', $exif_data['ExposureTime'] ) );
			if ( ! is_null( $fragments[1] && $fragments[0] ) ) {
				if ( 1 == $fragments[1] ) {
					$new_data['ShutterSpeed'] = $new_data['ExposureTime'] = sprintf( '%1$d', $fragments[0] );
				} elseif ( 0 != $fragments[1] ) {
					$value = $fragments[0] / $fragments[1];
					if ( ( 0 < $value ) && ( 1 > $value ) ) {
						// Convert to "1/" value for shutter speed
						if ( 1 == $fragments[0] ) {
							$new_data['ShutterSpeed'] = $new_data['ExposureTime'];
						} else {
							$test = (float) number_format( 1.0 / $value, 1, '.', '');
							if ( in_array( $test, array( 1.3, 1.5, 1.6, 2.5 ) ) ) {
								$new_data['ShutterSpeed'] = '1/' . number_format( 1.0 / $value, 1, '.', '' );
							} else {
								$new_data['ShutterSpeed'] = '1/' . number_format( 1.0 / $value, 0, '.', '' );
							}
						}
					} else {
						$new_data['ShutterSpeed'] = $new_data['ExposureTime'] = sprintf( '%1$.2f', $value );
					}
				} // fractional value
			} // valid denominator and non-zero numerator
		} // ShutterSpeed

		if ( isset( $exif_data['UndefinedTag:0xA420'] ) ) {
			$new_data['ImageUniqueID'] = $exif_data['UndefinedTag:0xA420'];
		}

		if ( isset( $exif_data['UndefinedTag:0xA430'] ) ) {
			$new_data['CameraOwnerName'] = $exif_data['UndefinedTag:0xA430'];
		}

		if ( isset( $exif_data['UndefinedTag:0xA431'] ) ) {
			$new_data['BodySerialNumber'] = $exif_data['UndefinedTag:0xA431'];
		}

		if ( isset( $exif_data['UndefinedTag:0xA432'] ) && is_array( $exif_data['UndefinedTag:0xA432'] ) ) {
			$array = $new_data['LensSpecification'] = $exif_data['UndefinedTag:0xA432'];

			if ( isset ( $array[0] ) ) {
				if ( false !== ( $value = self::_rational_to_string( $array[0], '%1$d', '%1$d/%2$d', '%1$.2f' ) ) ) {
					$new_data['LensMinFocalLength'] = $value;
				}
			}

			if ( isset ( $array[1] ) ) {
				if ( false !== ( $value = self::_rational_to_string( $array[1], '%1$d', '%1$d/%2$d', '%1$.2f' ) ) ) {
					$new_data['LensMaxFocalLength'] = $value;
				}
			}

			if ( isset ( $array[2] ) ) {
				if ( false !== ( $value = self::_rational_to_string( $array[2], '%1$d', '%1$d/%2$d', '%1$.1f' ) ) ) {
					$new_data['LensMinFocalLengthFN'] = $value;
				}
			}

			if ( isset ( $array[3] ) ) {
				if ( false !== ( $value = self::_rational_to_string( $array[3], '%1$d', '%1$d/%2$d', '%1$.1f' ) ) ) {
					$new_data['LensMaxFocalLengthFN'] = $value;
				}
			}

		}

		if ( isset( $exif_data['UndefinedTag:0xA433'] ) ) {
			$new_data['LensMake'] = $exif_data['UndefinedTag:0xA433'];
		}

		if ( isset( $exif_data['UndefinedTag:0xA434'] ) ) {
			$new_data['LensModel'] = $exif_data['UndefinedTag:0xA434'];
		}

		if ( isset( $exif_data['UndefinedTag:0xA435'] ) ) {
			$new_data['LensSerialNumber'] = $exif_data['UndefinedTag:0xA435'];
		}

		if ( ! empty( $new_data ) ) {
			$results['mla_exif_metadata']['CAMERA'] = $new_data;
		}

		// Expand EXIF GPS values
		$new_data = array();
		if ( isset( $exif_data['GPSVersion'] ) ) {
			$new_data['Version'] = sprintf( '%1$d.%2$d.%3$d.%4$d', ord( $exif_data['GPSVersion'][0] ), ord( $exif_data['GPSVersion'][1] ), ord( $exif_data['GPSVersion'][2] ), ord( $exif_data['GPSVersion'][3] ) );
		}

		if ( isset( $exif_data['GPSLatitudeRef'] ) ) {
			$new_data['LatitudeRef'] = $exif_data['GPSLatitudeRef'];
			$new_data['LatitudeRefS'] = ( 'N' == $exif_data['GPSLatitudeRef'] ) ? '' : '-';
			$ref = $new_data['LatitudeRef'];
			$refs = $new_data['LatitudeRefS'];
		} else {
			$ref = '';
			$refs = '';
		}

		if ( isset( $exif_data['GPSLatitude'] ) ) {
			$rational = $exif_data['GPSLatitude'];
			$new_data['LatitudeD'] = $degrees = self::_rational_to_decimal( $rational[0] );
			$new_data['LatitudeM'] = $minutes = self::_rational_to_decimal( $rational[1] );
			$new_data['LatitudeS'] = sprintf( '%1$01.4f', $seconds = self::_rational_to_decimal( $rational[2] ) );
			$decimal_minutes = $minutes + ( $seconds / 60 );
			$decimal_degrees = ( $decimal_minutes / 60 );

			$new_data['Latitude'] = sprintf( '%1$dd %2$d\' %3$01.4f" %4$s', $degrees, $minutes, $seconds, $ref );
			$new_data['LatitudeDM'] = sprintf( '%1$d %2$01.4f', $degrees, $decimal_minutes );
			$new_data['LatitudeDD'] = sprintf( '%1$01f', $degrees + $decimal_degrees );
			$new_data['LatitudeMinDec'] = substr( $new_data['LatitudeDM'], strpos( $new_data['LatitudeDM'], ' ' ) + 1 );
			$new_data['LatitudeDegDec'] = substr( $new_data['LatitudeDD'], strpos( $new_data['LatitudeDD'], '.' ) );
			$new_data['LatitudeSDM'] = $refs . $new_data['LatitudeDM'];
			$new_data['LatitudeSDD'] = $refs . $new_data['LatitudeDD'];
			$new_data['LatitudeDM'] = $new_data['LatitudeDM'] . $ref;
			$new_data['LatitudeDD'] = $new_data['LatitudeDD'] . $ref;
		}

		if ( isset( $exif_data['GPSLongitudeRef'] ) ) {
			$new_data['LongitudeRef'] = $exif_data['GPSLongitudeRef'];
			$new_data['LongitudeRefS'] = ( 'E' == $exif_data['GPSLongitudeRef'] ) ? '' : '-';
			$ref = $new_data['LongitudeRef'];
			$refs = $new_data['LongitudeRefS'];
		} else {
			$ref = '';
			$refs = '';
		}

		if ( isset( $exif_data['GPSLongitude'] ) ) {
			$rational = $exif_data['GPSLongitude'];
			$new_data['LongitudeD'] = $degrees = self::_rational_to_decimal( $rational[0] );
			$new_data['LongitudeM'] = $minutes = self::_rational_to_decimal( $rational[1] );
			$new_data['LongitudeS'] = sprintf( '%1$01.4f', $seconds = self::_rational_to_decimal( $rational[2] ) );
			$decimal_minutes = $minutes + ( $seconds / 60 );
			$decimal_degrees = ( $decimal_minutes / 60 );

			$new_data['Longitude'] = sprintf( '%1$dd %2$d\' %3$01.4f" %4$s', $degrees, $minutes, $seconds, $ref );
			$new_data['LongitudeDM'] = sprintf( '%1$d %2$01.4f', $degrees, $decimal_minutes );
			$new_data['LongitudeDD'] = sprintf( '%1$01f', $degrees + $decimal_degrees );
			$new_data['LongitudeMinDec'] = substr( $new_data['LongitudeDM'], strpos( $new_data['LongitudeDM'], ' ' ) + 1 );
			$new_data['LongitudeDegDec'] = substr( $new_data['LongitudeDD'], strpos( $new_data['LongitudeDD'], '.' ) );
			$new_data['LongitudeSDM'] = $refs . $new_data['LongitudeDM'];
			$new_data['LongitudeSDD'] = $refs . $new_data['LongitudeDD'];
			$new_data['LongitudeDM'] = $new_data['LongitudeDM'] . $ref;
			$new_data['LongitudeDD'] = $new_data['LongitudeDD'] . $ref;
		}

		if ( isset( $exif_data['GPSAltitudeRef'] ) ) {
			$new_data['AltitudeRef'] = sprintf( '%1$d', ord( $exif_data['GPSAltitudeRef'][0] ) );
			$new_data['AltitudeRefS'] = ( '0' == $new_data['AltitudeRef'] ) ? '' : '-';
			$refs = $new_data['AltitudeRefS'];
		} else {
			$refs = '';
		}

		if ( isset( $exif_data['GPSAltitude'] ) ) {
			$new_data['Altitude'] = sprintf( '%1$s%2$01.4f', $refs, $meters = self::_rational_to_decimal( $exif_data['GPSAltitude'] ) );
			$new_data['AltitudeFeet'] = sprintf( '%1$s%2$01.2f', $refs, $meters * 3.280839895013 );
		}

		if ( isset( $exif_data['GPSTimeStamp'] ) ) {
			$rational = $exif_data['GPSTimeStamp'];
			$new_data['TimeStampH'] = sprintf( '%1$02d', $hours = self::_rational_to_decimal( $rational[0] ) );
			$new_data['TimeStampM'] = sprintf( '%1$02d', $minutes = self::_rational_to_decimal( $rational[1] ) );
			$new_data['TimeStampS'] = sprintf( '%1$02d', $seconds = self::_rational_to_decimal( $rational[2] ) );
			$new_data['TimeStamp'] = sprintf( '%1$02d:%2$02d:%3$02d', $hours, $minutes, $seconds );
		}

		if ( isset( $exif_data['GPSDateStamp'] ) ) {
			$parts = explode( ':', $exif_data['GPSDateStamp'] );		
			$new_data['DateStampY'] = $parts[0];
			$new_data['DateStampM'] = $parts[1];
			$new_data['DateStampD'] = $parts[2];
			$new_data['DateStamp'] = $exif_data['GPSDateStamp'];
		}

		if ( isset( $exif_data['GPSMapDatum'] ) ) {
			$new_data['MapDatum'] = $exif_data['GPSMapDatum'];
		}

		if ( ! empty( $new_data ) ) {
			$results['mla_exif_metadata']['GPS'] = $new_data;
		}

		// Filter the metadata after MLA expands CAMERA and GPS values
		$results = apply_filters( 'mla_fetch_attachment_image_metadata_final', $results, $post_id, $path );

		MLACore::mla_debug_add( __LINE__ . ' MLAData::mla_fetch_attachment_image_metadata( PHP ' . phpversion() . ", {$post_id}, {$path} ) results = " . var_export( $results, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );

		return $results;
	}

	/**
	 * Update "meta:" data for a single attachment
	 * 
	 * @since 1.51
	 * 
	 * @param	array	The current wp_attachment_metadata value
	 * @param	array	Field name => value pairs
	 *
	 * @return	string	success/failure message(s); empty string if no changes.
	 */
	public static function mla_update_wp_attachment_metadata( &$current_values, $new_meta ) {
		$message = '';

		foreach( $new_meta as $key => $value ) {
			/*
			 * The "Multi" option has no meaning for attachment_metadata;
			 * convert to a simple array or string
			 */ 
			if ( isset( $value[0x80000000] ) ) {
				unset( $value[0x80000000] );
				unset( $value[0x80000001] );
				unset( $value[0x80000002] );
				unset( $value[0x80000003] );

				if ( 1 == count( $value ) ) {
					foreach ( $value as $single_key => $single_value ) {
						if ( is_integer( $single_key ) ) {
							$value = $single_value;
						}
					}
				} // one-element array
			} // Multi-key value

			$value = sanitize_text_field( $value );
			$old_value = self::mla_find_array_element( $key, $current_values, 'array' );
			if ( ! empty( $old_value ) ) {
				if ( empty( $value ) ) {
					if ( self::_unset_array_element( $key, $current_values ) ) {
						/* translators: 1: meta_key */
						$message .= sprintf( __( 'Deleting %1$s', 'media-library-assistant' ) . '<br>', $key );
					} else {
						/* translators: 1: ERROR tag 2: meta_key */
						$message .= sprintf( __( '%1$s: meta:%2$s not found', 'media-library-assistant' ) . '<br>', __( 'ERROR', 'media-library-assistant' ), $key );
					}

					continue;
				}
			} else { // old_value present
				if ( ! empty( $value ) ) {
					if ( self::_set_array_element( $key, $value, $current_values ) ) {
						/* translators: 1: meta_key 2: meta_value */
						$message .= sprintf( __( 'Adding %1$s = %2$s', 'media-library-assistant' ) . '<br>', $key,
							( is_array( $value ) ) ? var_export( $value, true ) : $value );
					} else {
						/* translators: 1: ERROR tag 2: meta_key */
						$message .= sprintf( __( '%1$s: Adding meta:%2$s; not found', 'media-library-assistant' ) . '<br>', __( 'ERROR', 'media-library-assistant' ), $key );
					}

					continue;
				} elseif ( NULL == $value ) {
					if ( self::_unset_array_element( $key, $current_values ) ) {
						/* translators: 1: meta_key */
						$message .= sprintf( __( 'Deleting Null meta:%1$s', 'media-library-assistant' ) . '<br>', $key );
					}

					continue;
				}
			} // old_value empty

			if ( $old_value !== $value ) {
				if ( self::_set_array_element( $key, $value, $current_values ) ) {
					/* translators: 1: element name 2: old_value 3: new_value */
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', 'meta:' . $key,
						( is_array( $old_value ) ) ? var_export( $old_value, true ) : $old_value,
						( is_array( $value ) ) ? var_export( $value, true ) : $value );
				} else {
					/* translators: 1: ERROR tag 2: meta_key */
					$message .= sprintf( __( '%1$s: Changing meta:%2$s; not found', 'media-library-assistant' ) . '<br>', __( 'ERROR', 'media-library-assistant' ), $key );
				}
			}
		} // foreach new_meta

		return $message;
	}

	/**
	 * Update custom field and "meta:" data for a single attachment
	 * 
	 * @since 1.40
	 * 
	 * @param	int		The ID of the attachment to be updated
	 * @param	array	Field name => value pairs
	 *
	 * @return	string	success/failure message(s)
	 */
	public static function mla_update_item_postmeta( $post_id, $new_meta ) {
		$post_data = MLAQuery::mla_fetch_attachment_metadata( $post_id );
		
		// Check for updates from "mla_mapping_updates" filters
		if ( !empty( $new_meta[0x80000000] ) ) {
			$message = $new_meta[0x80000000];
			unset( $new_meta[0x80000000] );
		} else {
			$message = '';
		}

		$attachment_meta_values = array();
		foreach ( $new_meta as $meta_key => $meta_value ) {
			if ( 'meta:' == substr( $meta_key, 0, 5 ) ) {
				$meta_key = substr( $meta_key, 5 );
				$attachment_meta_values[ $meta_key ] = $meta_value;
				continue;
			}

			if ( $multi_key = isset( $meta_value[0x80000000] ) ) {
				unset( $meta_value[0x80000000] );
			}

			if ( $keep_existing = isset( $meta_value[0x80000001] ) ) {
				$keep_existing = (boolean) $meta_value[0x80000001];
				unset( $meta_value[0x80000001] );
			}

			if ( $no_null = isset( $meta_value[0x80000002] ) ) {
				$no_null = (boolean) $meta_value[0x80000002];
				unset( $meta_value[0x80000002] );
			}

			if ( $replace_all = isset( $meta_value[0x80000003] ) ) {
				$replace_all = (boolean) $meta_value[0x80000003];
				unset( $meta_value[0x80000003] );
			}

			// mla_fetch_attachment_metadata doesn't return "hidden" fields
			if ( '_' === $meta_key[0] ) {
				$old_meta_value = get_post_meta( $post_id, $meta_key );

				if ( !empty( $old_meta_value ) ) {
					if ( is_array( $old_meta_value ) ) {
						if ( count( $old_meta_value ) == 1 ) {
							$old_meta_value = maybe_unserialize( current( $old_meta_value ) );
						} else {
							foreach ( $old_meta_value as $single_key => $single_value ) {
								$old_meta_value[ $single_key ] = maybe_unserialize( $single_value );
							}
						}
					}

					$post_data[ 'mla_item_' . $meta_key ] = $old_meta_value;
				}
			}

			if ( isset( $post_data[ 'mla_item_' . $meta_key ] ) ) {
				$old_meta_value = $post_data[ 'mla_item_' . $meta_key ];

				if ( $multi_key && $no_null ) {
					if ( is_string( $old_meta_value ) ) {
						$old_meta_value = trim( $old_meta_value );
					}

					$delete = empty( $old_meta_value );
				} else {
					$delete = NULL === $meta_value;
				}

				if ( $delete) {
					if ( delete_post_meta( $post_id, $meta_key ) ) {
						/* translators: 1: meta_key */
						$message .= sprintf( __( 'Deleting %1$s', 'media-library-assistant' ) . '<br>', $meta_key );
					}

					continue;
				}
			} else {
				if ( NULL !== $meta_value ) {
					if ( $multi_key ) {
						foreach ( $meta_value as $new_value ) {
							if ( add_post_meta( $post_id, $meta_key, $new_value ) ) {
								/* translators: 1: meta_key 2: new_value */
								$message .= sprintf( __( 'Adding %1$s = %2$s', 'media-library-assistant' ) . '<br>', $meta_key, '[' . $new_value . ']' );
							}
						}
					} else {
						if ( add_post_meta( $post_id, $meta_key, $meta_value ) ) {
							if ( is_array( $meta_value ) ) {
								$new_text = var_export( $meta_value, true );
							} else {
								$new_text = $meta_value;
							}

							/* translators: 1: meta_key 2: meta_value */
							$message .= sprintf( __( 'Adding %1$s = %2$s', 'media-library-assistant' ) . '<br>', $meta_key, $new_text );
						}
					}
				}

				continue; // no change or message if old and new are both NULL
			} // no old value

			$old_text = ( is_array( $old_meta_value ) ) ? var_export( $old_meta_value, true ) : $old_meta_value;

			/*
			 * Multi-key change from existing values to new values
			 */
			if ( $multi_key ) {
				/*
				 * Test for "no changes"
				 */
				if ( $meta_value == (array) $old_meta_value ) {
					continue;
				}

				if ( ! $keep_existing ) {
					if ( delete_post_meta( $post_id, $meta_key ) ) {
						/* translators: 1: meta_key */
						$message .= sprintf( __( 'Deleting old %1$s values', 'media-library-assistant' ) . '<br>', $meta_key );
					}

					$old_meta_value = array();
				} elseif ( $old_text == $old_meta_value ) { // single value
					$old_meta_value = array( $old_meta_value );
				}

				$updated = 0;
				foreach ( $meta_value as $new_value ) {
					if ( ! in_array( $new_value, $old_meta_value ) ) {
						add_post_meta( $post_id, $meta_key, $new_value );
						$old_meta_value[] = $new_value; // prevent duplicates
						$updated++;
					}
				}

				if ( $updated ) {
					$meta_value = get_post_meta( $post_id, $meta_key );
					if ( is_array( $meta_value ) ) {
						if ( 1 == count( $meta_value ) ) {
							$new_text = $meta_value[0];
						} else {
							$new_text = var_export( $meta_value, true );
						}
					} else {
						$new_text = $meta_value;
					}

					/* translators: 1: meta_key 2: old_value 3: new_value 4: update count*/
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"; %4$d updates', 'media-library-assistant' ) . '<br>', 'meta:' . $meta_key, $old_text, $new_text, $updated );
				}
			} elseif ( $old_meta_value !== $meta_value ) {
				if ( is_array( $old_meta_value ) ) {
					delete_post_meta( $post_id, $meta_key );
				}

				if ( is_array( $meta_value ) ) {
					$new_text = var_export( $meta_value, true );
				} else {
					$new_text = $meta_value;
				}

				if ( update_post_meta( $post_id, $meta_key, $meta_value ) ) {
					/* translators: 1: element name 2: old_value 3: new_value */
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', 'meta:' . $meta_key, $old_text, $new_text );
				}
			}
		} // foreach $new_meta

		/*
		 * Process the "meta:" updates, if any
		 */
		if ( ! empty( $attachment_meta_values ) ) {
			if ( isset( $post_data['mla_wp_attachment_metadata'] ) ) {
				$current_values = $post_data['mla_wp_attachment_metadata'];
			} else {
				$current_values = array();
			}

			$results = self::mla_update_wp_attachment_metadata( $current_values, $attachment_meta_values );
			if ( ! empty( $results ) ) {
				if ( update_post_meta( $post_id, '_wp_attachment_metadata', $current_values ) ) {
					$message .= $results;
				}
			}
		}

		return $message;
	}

    /**
     * Fires after an object's terms have been set.
     *
     * @since 2.84
     *
     * @param int    $object_id  Object ID.
     * @param array  $terms      An array of object terms.
     * @param array  $tt_ids     An array of term taxonomy IDs.
     * @param string $taxonomy   Taxonomy slug.
     * @param bool   $append     Whether to append new terms to the old terms.
     * @param array  $old_tt_ids Old array of term taxonomy IDs.
     */
	public static function mla_set_object_terms_action( $object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ) {
		$append_replace = $append ? 'append' : 'replace';
		MLACore::mla_debug_add( __LINE__ . " MLAData::mla_set_object_terms_action( $object_id, $taxonomy, $append_replace ) terms = " . var_export( $terms, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );
		MLACore::mla_debug_add( __LINE__ . " MLAData::mla_set_object_terms_action( $object_id, $taxonomy, $append_replace ) tt_ids = " . var_export( $tt_ids, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );
		MLACore::mla_debug_add( __LINE__ . " MLAData::mla_set_object_terms_action( $object_id, $taxonomy, $append_replace ) old_tt_ids = " . var_export( $old_tt_ids, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );
	}

	/**
	 * Update a single item; change the "post" data, taxonomy terms 
	 * and meta data for a single attachment
	 * 
	 * @since 0.1
	 * 
	 * @param	int		The ID of the attachment to be updated
	 * @param	array	Field name => value pairs
	 * @param	array	Optional taxonomy term values, default null
	 * @param	array	Optional taxonomy actions (add, remove, replace), default null
	 *
	 * @return	array	success/failure message and NULL content
	 */
	public static function mla_update_single_item( $post_id, $new_data, $tax_input = NULL, $tax_actions = NULL ) {
		$post_data = self::mla_get_attachment_by_id( $post_id, false );
		if ( !isset( $post_data ) ) {
			return array(
				'message' => __( 'ERROR', 'media-library-assistant' ) . ': ' . __( 'Could not retrieve Attachment.', 'media-library-assistant' ),
				'body' => '' 
			);
		}

		$updates = apply_filters( 'mla_update_single_item', compact( array( 'new_data', 'tax_input', 'tax_actions' ) ), $post_id, $post_data );
		$new_data = isset( $updates['new_data'] ) ? $updates['new_data'] : array();
		$tax_input = isset( $updates['tax_input'] ) ? $updates['tax_input'] : NULL;
		$tax_actions = isset( $updates['tax_actions'] ) ? $updates['tax_actions'] : NULL;

		$message = '';
		$updates = array( 'ID' => $post_id );
		$new_data = stripslashes_deep( $new_data );
		$new_meta = NULL;

		$log_details = ( MLACore::$mla_debug_level & 1 ) && ( MLACore::$mla_debug_level & MLACore::MLA_DEBUG_CATEGORY_METADATA );

		foreach ( $new_data as $key => $value ) {
			switch ( $key ) {
				case 'post_title':
					if ( $value == $post_data[ $key ] ) {
						break;
					}

					/* translators: 1: element name 2: old_value 3: new_value */
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'Title', 'media-library-assistant' ), esc_attr( $post_data[ $key ] ), esc_attr( $value ) );
					$updates[ $key ] = $value;
					break;
				case 'post_name':
					if ( $value == $post_data[ $key ] ) {
						break;
					}

					$value = sanitize_title( $value );

					// Make sure new slug is unique
					$args = array(
						'name' => $value,
						'post_type' => 'attachment',
						'post_status' => 'inherit',
						'showposts' => 1 
					);
					$my_posts = get_posts( $args );

					if ( $my_posts ) {
						/* translators: 1: ERROR tag 2: old_value */
						$message .= sprintf( __( '%1$s: Could not change Name/Slug "%2$s"; name already exists', 'media-library-assistant' ) . '<br>', __( 'ERROR', 'media-library-assistant' ), $value );
					} else {
						/* translators: 1: element name 2: old_value 3: new_value */
						$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'Name/Slug', 'media-library-assistant' ), esc_attr( $post_data[ $key ] ), esc_attr( $value ) );
						$updates[ $key ] = $value;
					}
					break;
				/*
				 * bulk_image_alt requires a separate key because some attachment types
				 * should not get a value. MLA v2.84 allows all application/ MIME types.
				 */
				case 'bulk_image_alt':
					if ( ( 'image/' !== substr( $post_data[ 'post_mime_type' ], 0, 6 ) )
					    && ( 'application/' !== substr( $post_data[ 'post_mime_type' ], 0, 12 ) ) ) {
						break;
					}
					// fallthru
				case 'image_alt':
					$key = 'mla_wp_attachment_image_alt';
					if ( !isset( $post_data[ $key ] ) ) {
						$post_data[ $key ] = NULL;
					}

					if ( $value == $post_data[ $key ] ) {
						break;
					}

					if ( empty( $value ) ) {
						if ( delete_post_meta( $post_id, '_wp_attachment_image_alt' ) ) {
							/* translators: 1: old_value */
							$message .= sprintf( __( 'Deleting ALT Text, was "%1$s"', 'media-library-assistant' ) . '<br>', esc_attr( $post_data[ $key ] ) );
						} else {
							/* translators: 1: ERROR tag 2: old_value */
							$message .= sprintf( __( '%1$s: Could not delete ALT Text, remains "%2$s"', 'media-library-assistant' ) . '<br>', __( 'ERROR', 'media-library-assistant' ), esc_attr( $post_data[ $key ] ) );
						}
					} else {
						/*
						 * ALT Text isn't supposed to have multiple values, but it happens.
						 * Delete multiple values and start over.
						 */
						if ( is_array( $post_data[ $key ] ) ) {
							delete_post_meta( $post_id, '_wp_attachment_image_alt' );
						}

						if ( update_post_meta( $post_id, '_wp_attachment_image_alt', $value ) ) {
							/* translators: 1: element name 2: old_value 3: new_value */
							$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'ALT Text', 'media-library-assistant' ), esc_attr( $post_data[ $key ] ), esc_attr( $value ) );
						} else {
							/* translators: 1: ERROR tag 2: old_value 3: new_value */
							$message .= sprintf( __( '%1$s: Could not change ALT Text from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'ERROR', 'media-library-assistant' ), esc_attr( $post_data[ $key ] ), esc_attr( $value ) );
						}
					}
					break;
				case 'post_excerpt':
					if ( $value == $post_data[ $key ] ) {
						break;
					}

					/* translators: 1: element name 2: old_value 3: new_value */
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'Caption', 'media-library-assistant' ), esc_attr( $post_data[ $key ] ), esc_attr( $value ) );
					$updates[ $key ] = $value;
					break;
				case 'post_content':
					if ( $value == $post_data[ $key ] ) {
						break;
					}

					/* translators: 1: element name 2: old_value 3: new_value */
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'Description', 'media-library-assistant' ), esc_textarea( $post_data[ $key ] ), esc_textarea( $value ) );
					$updates[ $key ] = $value;
					break;
				case 'post_parent':
					if ( $value == $post_data[ $key ] ) {
						break;
					}

					$value = absint( $value );

					/* translators: 1: element name 2: old_value 3: new_value */
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'Parent', 'media-library-assistant' ), $post_data[ $key ], $value );
					$updates[ $key ] = $value;
					break;
				case 'menu_order':
					if ( $value == $post_data[ $key ] ) {
						break;
					}

					$value = absint( $value );

					/* translators: 1: element name 2: old_value 3: new_value */
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'Menu Order', 'media-library-assistant' ), $post_data[ $key ], $value );
					$updates[ $key ] = $value;
					break;
				case 'post_author':
					if ( $value == $post_data[ $key ] ) {
						break;
					}

					$value = absint( $value );

					$from_user = get_userdata( $post_data[ $key ] );
					$to_user = get_userdata( $value );
					/* translators: 1: element name 2: old_value 3: new_value */
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'Author', 'media-library-assistant' ), $from_user->display_name, $to_user->display_name );
					$updates[ $key ] = $value;
					break;
				case 'comment_status':
					if ( $value == $post_data[ $key ] ) {
						break;
					}

					/* translators: 1: element name 2: old_value 3: new_value */
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'Comments', 'media-library-assistant' ), esc_attr( $post_data[ $key ] ), esc_attr( $value ) );
					$updates[ $key ] = $value;
					break;
				case 'ping_status':
					if ( $value == $post_data[ $key ] ) {
						break;
					}

					/* translators: 1: element name 2: old_value 3: new_value */
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'Pings', 'media-library-assistant' ), esc_attr( $post_data[ $key ] ), esc_attr( $value ) );
					$updates[ $key ] = $value;
					break;
				case 'post_date':
					if ( $value == $post_data[ $key ] ) {
						break;
					}

					/* translators: 1: element name 2: old_value 3: new_value */
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'Uploaded on', 'media-library-assistant' ), esc_attr( $post_data[ $key ] ), esc_attr( $value ) );
					$updates[ $key ] = $value;
					break;
				case 'post_date_gmt':
					if ( $value == $post_data[ $key ] ) {
						break;
					}

					/* translators: 1: element name 2: old_value 3: new_value */
					$message .= sprintf( __( 'Changing %1$s from "%2$s" to "%3$s"', 'media-library-assistant' ) . '<br>', __( 'Uploaded on', 'media-library-assistant' ) . ' GMT', esc_attr( $post_data[ $key ] ), esc_attr( $value ) );
					$updates[ $key ] = $value;
					break;
				case 'taxonomy_updates':
					$tax_input = $value['inputs'];
					$tax_actions = $value['actions'];
					break;
				case 'custom_updates':
					$new_meta = $value;
					break;
				default:
					// Ignore anything else
			} // switch $key
		} // foreach $new_data

		if ( ! empty( $tax_input ) ) {
			foreach ( $tax_input as $taxonomy => $tags ) {
				if ( ! empty( $tax_actions ) ) {
					$tax_action = $tax_actions[ $taxonomy ];
				} else {
					$tax_action = 'replace';
				}

				$taxonomy_obj = get_taxonomy( $taxonomy );

				// Check if logged-in user can assign terms
				$current_user = wp_get_current_user();
				if ( $current_user->ID ) {
					if ( ! current_user_can( $taxonomy_obj->cap->assign_terms ) ) {
						/* translators: 1: taxonomy */
						$message .= sprintf( __( 'You cannot assign "%1$s" terms', 'media-library-assistant' ) . '<br>', $taxonomy );
						continue;
					}
				}

				// array of int = hierarchical, comma-delimited string = flat.
				if ( is_array( $tags ) ) {
					$tags = array_filter( $tags );
				} else {
					/*
					 * Convert flat taxonomy input to term IDs, to avoid ambiguity.
					 * Adapted from edit_post() in /wp-admin/includes/post.php
					 */
					$comma = _x( ',', 'tag delimiter' );
					if ( ',' !== $comma ) {
						$tags = str_replace( $comma, ',', $tags );
					}
					$tags = explode( ',', trim( $tags, " \n\t\r\0\x0B," ) );

					$clean_terms = array();
					foreach ( $tags as $tag ) {
						// Empty terms are invalid input.
						if ( empty( $tag ) ) {
							continue;
						}

						$_term = MLAQuery::mla_wp_get_terms( $taxonomy, array(
							'name' => $tag,
							'fields' => 'ids',
							'hide_empty' => false,
						) );

						if ( ! empty( $_term ) ) {
							$clean_terms[] = (int) $_term[0];
						} else {
							// No existing term was found, so pass the string. A new term will be created.
							$clean_terms[] = $tag;
						}
					}

					$tags = $clean_terms;
				}

				if ( $log_details ) {
					MLACore::mla_debug_add( __LINE__ . " MLAData::mla_update_single_item( $post_id, $taxonomy ) \$tags = " . var_export( $tags, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );

					add_action( 'set_object_terms', 'MLAData::mla_set_object_terms_action', 10, 6 );
				}

				switch ( $tax_action ) {
					case 'add':
						if ( ! empty( $tags ) ) {
							$action_name = __( 'Adding', 'media-library-assistant' );
							$result = wp_set_post_terms( $post_id, $tags, $taxonomy, true );
						}
						break;
					case 'remove':
						$action_name = __( 'Removing', 'media-library-assistant' );
						$tags = self::_remove_terms( $post_id, $tags, $taxonomy_obj );
						$result = wp_set_post_terms( $post_id, $tags, $taxonomy );

						if ( empty( $tags ) ) {
							$result = true;
						}
						break;
					case 'replace':
						$action_name = __( 'Replacing', 'media-library-assistant' );
						$result = wp_set_post_terms( $post_id, $tags, $taxonomy );

						if ( empty( $tags ) ) {
							$result = true;
						}
						break;
					default:
						$action_name = __( 'Ignoring', 'media-library-assistant' );
						$result = NULL;
						// ignore anything else
				}

				if ( $log_details ) {
					remove_action( 'set_object_terms', 'MLAData::mla_set_object_terms_action' );
				}

				/*
				 * Definitive results check would use:
				 * do_action( 'set_object_terms', $object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids );
				 * in /wp_includes/taxonomy.php function wp_set_object_terms()
				 */
				if ( ! empty( $result ) ) {
					delete_transient( MLA_OPTION_PREFIX . 't_term_counts_' . $taxonomy );
					/* translators: 1: action_name, 2: taxonomy */
					$message .= sprintf( __( '%1$s "%2$s" terms', 'media-library-assistant' ) . '<br>', $action_name, $taxonomy );
				}
			} // foreach $tax_input
		} // ! empty $tax_input

		if ( is_array( $new_meta ) ) {
			$message .= self::mla_update_item_postmeta( $post_id, $new_meta );
		}

		if ( empty( $message ) ) {
			return array(
				/* translators: 1: post ID */
				'message' => sprintf( __( 'Item %1$d, no changes detected.', 'media-library-assistant' ), $post_id ),
				'body' => '' 
			);
		} else {
			// invalidate the cached item
			self::mla_get_attachment_by_id( -1 );
			MLAQuery::mla_fetch_attachment_parent_data( -1 );
			MLAQuery::mla_fetch_attachment_metadata( -1 );
			MLAQuery::mla_fetch_attachment_references( -1, 0 );

			// See if anything else has changed
			if ( 1 <  count( $updates ) ) {
				$result = wp_update_post( $updates );
			} else {
				$result = $post_id;
			}

			/*
			 * Allow Jordy Meow's Media File Renamer plugin to do its work
			 * https://wordpress.org/support/topic/media-file-rename-media-library-assistant/
			 */
			if ( class_exists( 'Meow_MFRH_Core', false ) && isset( $updates['post_title'] ) ) {
				global $mfrh_core;

				if ( is_object( $mfrh_core ) ) {
					$mfrh_core->rename( $post_id );
				}
			}

			do_action( 'mla_updated_single_item', $post_id, $result );

			if ( $result ) {
				/* translators: 1: post ID */
				$final_message = sprintf( __( 'Item %1$d updated.', 'media-library-assistant' ), $post_id );
				/*
				 * Uncomment this for debugging.
				 */
				// $final_message .= '<br>' . $message;
				//error_log( __LINE__ . ' MLAData::mla_update_single_item message = ' . var_export( $message, true ), 0 );

				if ( $log_details ) {
					MLACore::mla_debug_add( __LINE__ . " MLAData::mla_update_single_item( $post_id ) message = " . var_export( $final_message . '<br>' . $message, true ), MLACore::MLA_DEBUG_CATEGORY_METADATA );
				}

				return array(
					'message' => $final_message,
					'body' => '' 
				);
			} else {
				return array(
					/* translators: 1: ERROR tag 2: post ID */
					'message' => sprintf( __( '%1$s: Item %2$d update failed.', 'media-library-assistant' ), __( 'ERROR', 'media-library-assistant' ), $post_id ),
					'body' => '' 
				);
			}
		}
	}

	/**
	 * Remove terms from an attachment's assignments
	 * 
	 * @since 0.40
	 * 
	 * @param	integer	The ID of the attachment to be updated
	 * @param	array	The term ids (integer array) or names (string array) to remove
	 * @param	object	The taxonomy object
	 *
	 * @return	array	Term ids/names of the surviving terms
	 */
	private static function _remove_terms( $post_id, $terms, $taxonomy_obj ) {
		$taxonomy = $taxonomy_obj->name;
		$hierarchical = $taxonomy_obj->hierarchical;

		/*
		 * Get the current terms for the terms_after check
		 */
		$current_terms = get_object_term_cache( $post_id, $taxonomy );
		if ( false === $current_terms ) {
			$current_terms = wp_get_object_terms( $post_id, $taxonomy );
			wp_cache_add( $post_id, $current_terms, $taxonomy . '_relationships' );
		}

		$terms_before = array();
		foreach( $current_terms as $term ) {
			$terms_before[ $term->term_id ] = $term->name;
		}

		$terms_after = array();
		if ( $hierarchical  || MLACore::mla_taxonomy_support( $taxonomy, 'flat-checklist' )) {
			$terms = array_map( 'intval', $terms );
			$terms = array_unique( $terms );

			foreach( $terms_before as $index => $term ) {
				if ( ! in_array( $index, $terms ) ) {
					$terms_after[] = $index;
				}
			}
		} else {
			// WordPress encodes special characters, e.g., "&" as HTML entities in term names
			array_map( '_wp_specialchars', $terms );
			foreach( $terms_before as $index => $term ) {
				if ( ! in_array( $term, $terms ) ) {
					$terms_after[] = $term;
				}
			}
		}

		return $terms_after;
	}

	/**
	 * Format printable version of binary data
	 * 
	 * @since 0.90
	 * 
	 * @param	string	Binary data
	 * @param	integer	Bytes to format, default = 0 (all bytes)
	 * @param	intger	Bytes to format on each line
	 * @param	integer	offset of initial byte, or -1 to suppress printing offset information
	 *
	 * @return	string	Printable representation of $data
	 */
	public static function mla_hex_dump( $data, $limit = 0, $bytes_per_row = 16, $offset = -1 ) {
		if ( 0 == $limit ) {
			$limit = strlen( $data );
		}

		$position = 0;
		$output = "\r\n";
		$print_offset = ( 0 <= $offset );

		if ( $print_offset ) {
			$print_length = $bytes_per_row + 5;
		} else {
			$print_length = $bytes_per_row;
		}

		while ( $position < $limit ) {
			$row_length = strlen( substr( $data, $position ) );

			if ( 0 == $row_length ) {
				break;
			}

			if ( $row_length > ( $limit - $position ) ) {
				$row_length = $limit - $position;
			}

			if ( $row_length > $bytes_per_row ) {
				$row_length = $bytes_per_row;
			}

			$row_data = substr( $data, $position, $row_length );

			if ( $print_offset ) {
				$print_string = sprintf( '%04X ', $position + $offset );
			} else {
				$print_string = '';
			}

			$hex_string = '';
			for ( $index = 0; $index < $row_length; $index++ ) {
				$char = ord( substr( $row_data, $index, 1 ) );
				if ( ( 31 < $char ) && ( 127 > $char ) ) {
					$print_string .= chr($char);
				} else {
					$print_string .= '.';
				}

				$hex_string .= ' ' . bin2hex( chr($char) );
			} // for

			$output .= str_pad( $print_string, $print_length, ' ', STR_PAD_RIGHT ) . $hex_string . "\r\n";
			$position += $row_length;
		} // while

		return $output;
	}
} // class MLAData
?>