src/Converter.php
<?php declare(strict_types=1);
/*
* Copyright (c) Cristiano Cinotti 2024.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
namespace Susina\XmlToArray;
use SimpleXMLElement;
use Susina\XmlToArray\Exception\ConverterException;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Class to convert an xml string to array
*/
final class Converter
{
private array $options;
/**
* Static constructor.
*/
public static function create(array $options = []): self
{
return new self($options);
}
public function __construct(array $options = [])
{
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$this->options = $resolver->resolve($options);
}
/**
* Create a PHP array from an XML string.
*
* @param string $xmlToParse The XML to parse.
*
* @return array
*
* @throws ConverterException If errors while parsing XML.
*/
public function convert(string $xmlToParse): array
{
$xmlToParse = $this->normalizeXml($xmlToParse);
$flags = JSON_THROW_ON_ERROR | ($this->options['typesAsString'] === false ? JSON_NUMERIC_CHECK : 0);
$content = json_encode($this->getSimpleXml($xmlToParse), $flags);
/** @var array $array */
$array = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
$array = $this->options['mergeAttributes'] === true ? $this->mergeAttributes($array) : $array;
$array = $this->options['typesAsString'] === false ? $this->convertBool($array) : $array;
$array = $this->options['typesAsString'] === false ? $this->convertEmptyArrayToNull($array) : $this->convertEmptyArrayToNull($array, true);
return $array;
}
/**
* Convert an XML string to array and save it to file.
*
* @param string $xmlToParse The XML to parse
* @param string $filename The file name where to save the parsed array
*
* @throw \RuntimeException If the file is not writeable or the directory doesn't exist
*/
public function convertAndSave(string $xmlToParse, string $filename): void
{
$dirName = dirname($filename);
if (!file_exists($dirName)) {
throw new \RuntimeException("The directory `$dirName` does not exist: you should create it before writing a file.");
}
if (!is_writable($dirName)) {
throw new \RuntimeException("It's impossible to write into `$dirName` directory: do you have the correct permissions?");
}
$array = $this->convert($xmlToParse);
$content = "<?php declare(strict_types=1);
/*
* This file is auto-generated by susina/xml-to-array library.
*/
return " . var_export($array, true) . ";
";
file_put_contents($filename, $content);
}
private function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'mergeAttributes' => true,
'typesAsString' => false,
'preserveFirstTag' => false
]);
$resolver->setAllowedTypes('mergeAttributes', 'bool');
$resolver->setAllowedTypes('typesAsString', 'bool');
$resolver->setAllowedTypes('preserveFirstTag', 'bool');
}
/**
* Parse an XML string and return the relative SimpleXmlElement object.
*/
private function getSimpleXml(string $xmlToParse): SimpleXMLElement
{
$currentInternalErrors = libxml_use_internal_errors(true);
$xml = simplexml_load_string($xmlToParse);
if ($xml instanceof SimpleXMLElement) {
dom_import_simplexml($xml)->ownerDocument->xinclude();
}
$errors = libxml_get_errors();
libxml_clear_errors();
libxml_use_internal_errors($currentInternalErrors);
if ($xml === false) {
throw new ConverterException($errors);
}
return $xml;
}
/**
* Merge '@attributes' array into parent.
*
* @psalm-suppress MixedAssignment
*/
private function mergeAttributes(array $array): array
{
$out = [];
/** @var mixed $value */
foreach ($array as $key => $value) {
if ($key === '@attributes') {
/** @var array $value */
$out = array_merge($out, $value);
continue;
}
$out[$key] = is_array($value) ? $this->mergeAttributes($value) : $value;
}
return $out;
}
/**
* Convert all truely and falsy strings ('True', 'False' etc.)
* into boolean values.
*
* @psalm-suppress MixedAssignment
*/
private function convertBool(array $array): array
{
array_walk_recursive($array, function (mixed &$value): void {
$value = match(true) {
is_string($value) && strtolower($value) === 'true' => true,
is_string($value) && strtolower($value) === 'false' => false,
default => $value
};
});
return $array;
}
private function convertEmptyArrayToNull(array $array, bool $toString = false): array
{
return array_map(function (mixed $value) use ($toString) {
return match (true) {
$value === [] => $toString ? 'null' : null,
is_array($value) => $this->convertEmptyArrayToNull($value),
default => $value
};
}, $array);
}
private function normalizeXml(string $xml): string
{
$xml = preg_replace_callback_array([
'/<\?([\\s\\S]*?)\?>/' => fn (): string => '', //Remove header
'/<!--([\\s\\S]*?)-->/' => fn (): string => '', //Remove comments
'/<!\[CDATA\[([\\s\\S]*?)\]\]>/' => function (array $matches): string {
/** @var string $matches[1] */
return str_replace(['<', '>'], ['<', '>'], $matches[1]);
} //Convert CDATA into escaped strings
], $xml);
$xml = $xml ?? '';
//Add a fake tag to preserve the first one
$xml = $this->options['preserveFirstTag'] === true ? "<fake-tag>$xml</fake-tag>" : $xml;
return $xml;
}
}