[ * 'value1', * 'value2' * ], * 'numeric-n-index' => [ * 'value1', * 'value2' * ], * 'nested' => [ * 'node1' => 'value1', * 'node2' => [ * 'node' => 'value' * ] * ] * ]; * $additionalOptions = [ * 'useIndexTagForNum' => 'numbered-index' * 'alt_options' => [ * '/numeric-n-index' => [ * 'useNindex' => true * ], * '/nested' => [ * 'useIndexTagForAssoc' => 'nested-outer', * 'clearStackPath' => true, * 'alt_options' => [ * '/nested-outer' => [ * 'useIndexTagForAssoc' => 'nested-inner' * ] * ] * ] * ] * ]; * ``` * => * ```xml * * * value1 * value2 * * * value1 * value2 * * * value1 * * value * * * * ``` * Available options are: * - grandParentTagMap[grandParentTagName/parentTagName] [string] * Convert array key X to XML node name "{grandParentTagMap}" with node attribute "index=X" * - if grand-parent is "{grandParentTagName}" and parent node is "{parentTagName}". * - parentTagMap[parentTagName:_IS_NUM] [string] * Convert array key X to XML node name "{parentTagMap}" with node attribute "index=X" * - if parent node is "{parentTagName}" and current node is number-indexed. * - parentTagMap[parentTagName:nodeName] [string] * Convert array key X to XML node name "{parentTagMap}" with node attribute "index=X" * - if parent node is "{parentTagName}" and current node is "{nodeName}". * - parentTagMap[parentTagName] [string] * Convert array key X to XML node name "{parentTagMap}" with node attribute "index=X" * - if parent node is "{parentTagName}". * - useNindex [bool] * Convert number-indexed array key X to XML node name "nX". * - useIndexTagForNum [string] * Convert number-indexed array key X to XML node name "{useIndexTagForNum}" with node * attribute "index=X". * - useIndexTagForAssoc [string] * Convert associative array key X to XML node name "{useIndexTagForAssoc}" with node * attribute "index=X". * - disableTypeAttrib [bool|int] * Disable node attribute "type" for all value types * (true = disable for all except arrays, 2 = disable for all). * - alt_options[/.../nodeName] [array] * Set new options for specific array path. * - clearStackPath [bool] * Resetting internal counter when descending the array hierarchy: Allows using relative * array path in nested "alt_options" instead of absolute path. * * @internal still experimental */ readonly class Typo3XmlSerializer { /** * This method serves as a wrapper for encode() and is used to replace * GeneralUtility::array2xml(), which returns an exception as a string instead of throwing it. * In perspective, all uses of this method should be replaced by encode() and the exceptions * should be handled locally. * * @param array $input PHP array * @param Typo3XmlParserOptions|null $options Encoding configuration - see encode() for details * @param array $additionalOptions Encoding options - see encode() for details * @return string XML or exception */ public function encodeWithReturningExceptionAsString( array $input, ?Typo3XmlParserOptions $options = null, array $additionalOptions = [] ): string { try { return $this->encode($input, $options, $additionalOptions); } catch (\Throwable $e) { return $e->getMessage(); } } /** * @param array $input PHP array * @param Typo3XmlParserOptions|null $options Apply specific encoding configuration - XML format, namespace prefix and root node name * @param array $additionalOptions Apply specific encoding options - for the full array or specific array paths. * @return string XML string */ public function encode( array $input, ?Typo3XmlParserOptions $options = null, array $additionalOptions = [] ): string { $options = $options ?? new Typo3XmlParserOptions(); return $this->parseArray( $input, $options, $additionalOptions ); } protected function parseArray( array $input, Typo3XmlParserOptions $options, array $additionalOptions, int $level = 0, array $stackData = [] ): string { $xml = ''; $rootNodeName = $options->getRootNodeName(); if (empty($rootNodeName)) { $indentation = str_repeat($options->getIndentationStep(), $level); } else { $indentation = str_repeat($options->getIndentationStep(), $level + 1); } foreach ($input as $key => $value) { // Construct the node name + attributes $nodeName = $key = (string)$key; $nodeAttributes = ''; if (isset( $stackData['grandParentTagName'], $stackData['parentTagName'], $additionalOptions['grandParentTagMap'][$stackData['grandParentTagName'] . '/' . $stackData['parentTagName']] )) { // ... based on grand-parent + parent node name $nodeName = (string)$additionalOptions['grandParentTagMap'][$stackData['grandParentTagName'] . '/' . $stackData['parentTagName']]; $nodeAttributes = ' index="' . htmlspecialchars($key) . '"'; } elseif (isset( $stackData['parentTagName'], $additionalOptions['parentTagMap'][$stackData['parentTagName'] . ':_IS_NUM'] ) && MathUtility::canBeInterpretedAsInteger($nodeName) ) { // ... based on parent node name + if current node name is numeric $nodeName = (string)$additionalOptions['parentTagMap'][$stackData['parentTagName'] . ':_IS_NUM']; $nodeAttributes = ' index="' . htmlspecialchars($key) . '"'; } elseif (isset( $stackData['parentTagName'], $additionalOptions['parentTagMap'][$stackData['parentTagName'] . ':' . $nodeName] )) { // ... based on parent node name + current node name $nodeName = (string)$additionalOptions['parentTagMap'][$stackData['parentTagName'] . ':' . $nodeName]; $nodeAttributes = ' index="' . htmlspecialchars($key) . '"'; } elseif (isset( $stackData['parentTagName'], $additionalOptions['parentTagMap'][$stackData['parentTagName']] )) { // ... based on parent node name $nodeName = (string)$additionalOptions['parentTagMap'][$stackData['parentTagName']]; $nodeAttributes = ' index="' . htmlspecialchars($key) . '"'; } elseif (MathUtility::canBeInterpretedAsInteger($nodeName)) { // ... if current node name is numeric if ($additionalOptions['useNindex'] ?? false) { $nodeName = 'n' . $nodeName; } else { $nodeName = ($additionalOptions['useIndexTagForNum'] ?? false) ?: 'numIndex'; $nodeAttributes = ' index="' . $key . '"'; } } elseif (!empty($additionalOptions['useIndexTagForAssoc'])) { // ... if current node name is string $nodeName = $additionalOptions['useIndexTagForAssoc']; $nodeAttributes = ' index="' . htmlspecialchars($key) . '"'; } $nodeName = $this->cleanUpNodeName($nodeName); // Construct the node value if (is_array($value)) { // ... if has sub elements if (isset($additionalOptions['alt_options']) && ($additionalOptions['alt_options'][($stackData['path'] ?? '') . '/' . $nodeName] ?? false) ) { $subOptions = $additionalOptions['alt_options'][($stackData['path'] ?? '') . '/' . $nodeName]; $clearStackPath = (bool)($subOptions['clearStackPath'] ?? false); } else { $subOptions = $additionalOptions; $clearStackPath = false; } if (empty($value)) { $nodeValue = ''; } else { $nodeValue = $options->getNewlineChar(); $nodeValue .= $this->parseArray( $value, $options, $subOptions, $level + 1, [ 'parentTagName' => $nodeName, 'grandParentTagName' => $stackData['parentTagName'] ?? '', 'path' => $clearStackPath ? '' : ($stackData['path'] ?? '') . '/' . $nodeName, ] ); $nodeValue .= $indentation; } // Dropping the "type=array" attribute makes the XML prettier, but means that empty // arrays are not restored with XmlDecoder::decode(). if (($additionalOptions['disableTypeAttrib'] ?? false) !== 2) { $nodeAttributes .= ' type="array"'; } } else { // ... if is simple value if ($this->isBinaryValue($value)) { $nodeValue = $options->getNewlineChar() . chunk_split(base64_encode($value)); $nodeAttributes .= ' base64="1"'; } else { $type = gettype($value); if ($type === 'string') { $nodeValue = htmlspecialchars($value); } else { $nodeValue = $value; if (($additionalOptions['disableTypeAttrib'] ?? false) === false) { $nodeAttributes .= ' type="' . $type . '"'; } } } } // Construct the node if ($nodeName !== '') { $xml .= $indentation; $xml .= '<' . $options->getNamespacePrefix() . $nodeName . $nodeAttributes . '>'; $xml .= $nodeValue; $xml .= 'getNamespacePrefix() . $nodeName . '>'; $xml .= $options->getNewlineChar(); } } // Wrap with the root node if it is on the outermost level. if ($level === 0 && !empty($rootNodeName)) { $xml = '<' . $rootNodeName . '>' . $options->getNewlineChar() . $xml . ''; } return $xml; } /** * The node name is cleaned so that it contains only alphanumeric characters (plus - and _) and * is no longer than 100 characters. * * @param string $nodeName * @return string Cleaned node name */ protected function cleanUpNodeName(string $nodeName): string { return substr((string)preg_replace('/[^[:alnum:]_-]/', '', $nodeName), 0, 100); } /** * Is $value the content of a binary file, for example an image? If so, this value must be * stored in a binary-safe manner so that it can be decoded correctly later. * * @param mixed $value * @return bool */ protected function isBinaryValue(mixed $value): bool { if (!is_string($value)) { return false; } $binaryChars = "\0" . chr(1) . chr(2) . chr(3) . chr(4) . chr(5) . chr(6) . chr(7) . chr(8) . chr(11) . chr(12) . chr(14) . chr(15) . chr(16) . chr(17) . chr(18) . chr(19) . chr(20) . chr(21) . chr(22) . chr(23) . chr(24) . chr(25) . chr(26) . chr(27) . chr(28) . chr(29) . chr(30) . chr(31); $length = strlen($value); return $length && strcspn($value, $binaryChars) !== $length; } }