├── .gitattributes ├── .gitignore ├── ChangeLog-1.1.md ├── ChangeLog-1.2.md ├── ChangeLog-2.0.md ├── ChangeLog-2.1.md ├── ChangeLog-2.2.md ├── LICENSE ├── README.md ├── bin └── idml-json-converter ├── composer.json ├── ecs.php ├── example └── example1 │ ├── index.php │ ├── input.idml │ ├── input.indd │ ├── output.idml │ └── output.json ├── phpstan.neon ├── phpunit.xml ├── rector.php ├── src ├── Command │ ├── IDMLConvertJSONCommand.php │ └── JSONConvertIDMLCommand.php ├── Converter │ ├── ArrayToDomNodeConverter.php │ └── DomNodeToArrayConverter.php ├── Exception.php ├── Exception │ ├── CannotReadFileException.php │ ├── FailedExtractingContentExpection.php │ ├── JsonEncodeException.php │ └── UnknownFileException.php ├── File │ ├── IDML.php │ └── JSON.php ├── MemoryLimit.php ├── Utils │ └── RootElementNameFromFileName.php └── ValueModifier │ ├── AttributeSplitter.php │ └── ValueNormalizer.php └── tests └── Converter ├── ArrayToDomNodeConverterTest.php └── DomNodeToArrayConverterTest.php /.gitattributes: -------------------------------------------------------------------------------- 1 | *.idml filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # These are some examples of commonly ignored file patterns. 2 | # You should customize this list as applicable to your project. 3 | # Learn more about .gitignore: 4 | # https://www.atlassian.com/git/tutorials/saving-changes/gitignore 5 | 6 | # Node artifact files 7 | node_modules/ 8 | dist/ 9 | 10 | # Compiled Java class files 11 | *.class 12 | 13 | # Compiled Python bytecode 14 | *.py[cod] 15 | 16 | # Log files 17 | *.log 18 | 19 | # Package files 20 | *.jar 21 | 22 | # Maven 23 | target/ 24 | dist/ 25 | 26 | # JetBrains IDE 27 | .idea/ 28 | 29 | # Unit test reports 30 | TEST*.xml 31 | 32 | # Generated by MacOS 33 | .DS_Store 34 | 35 | # Generated by Windows 36 | Thumbs.db 37 | 38 | # Applications 39 | *.app 40 | *.exe 41 | *.war 42 | 43 | # Large media files 44 | *.mp4 45 | *.tiff 46 | *.avi 47 | *.flv 48 | *.mov 49 | *.wmv 50 | 51 | 52 | /vendor/ 53 | /composer.lock 54 | /tests/output.json 55 | /tests/build/* 56 | -------------------------------------------------------------------------------- /ChangeLog-1.1.md: -------------------------------------------------------------------------------- 1 | # Changes in Bit&Black IDML-JSON-Converter v1.1 2 | 3 | ## 1.1.0 2024-01-26 4 | 5 | ### Changed 6 | 7 | - Updated dependencies. -------------------------------------------------------------------------------- /ChangeLog-1.2.md: -------------------------------------------------------------------------------- 1 | # Changes in Bit&Black IDML-JSON-Converter v1.2 2 | 3 | ## 1.2.0 2024-07-23 4 | 5 | ### Added 6 | 7 | - Add possibility to escape or unescape JSON output in the [IDML](./src/File/IDML.php) class with `setUnescapeOutput`. -------------------------------------------------------------------------------- /ChangeLog-2.0.md: -------------------------------------------------------------------------------- 1 | # Changes in Bit&Black IDML-JSON-Converter v2.0 2 | 3 | ## 2.0.0 2024-07-23 4 | 5 | ### Changed 6 | 7 | - Changed unescaping JSON output in the [IDML](./src/File/IDML.php) class to default `true`. 8 | - Updated `maennchen/zipstream-php` library to `v3`. -------------------------------------------------------------------------------- /ChangeLog-2.1.md: -------------------------------------------------------------------------------- 1 | # Changes in Bit&Black IDML-JSON-Converter v2.1 2 | 3 | ## 2.1.0 2025-02-18 4 | 5 | ### Changed 6 | 7 | - Improved support for PHP 8.4. -------------------------------------------------------------------------------- /ChangeLog-2.2.md: -------------------------------------------------------------------------------- 1 | # Changes in Bit&Black IDML-JSON-Converter v2.2 2 | 3 | ## 2.2.0 2025-02-26 4 | 5 | ### Added 6 | 7 | - Added support for the `XML/Mapping.xml` file. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 BitAndBlack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PHP from Packagist](https://img.shields.io/packagist/php-v/bitandblack/idml-json-converter)](http://www.php.net) 2 | [![Total Downloads](https://poser.pugx.org/bitandblack/idml-json-converter/downloads)](https://packagist.org/packages/bitandblack/idml-json-converter) 3 | [![License](https://poser.pugx.org/bitandblack/idml-json-converter/license)](https://packagist.org/packages/bitandblack/idml-json-converter) 4 | 5 |

6 | 7 | Bit&Black Logo 8 | 9 |

10 | 11 | # Bit&Black IDML-JSON Converter 12 | 13 | Convert Adobe InDesign Markup Language Files (IDML) into JSON and JSON into IDML. 14 | 15 | ## Motivation 16 | 17 | Using this converter allows a __simple handling of IDML__ files in PHP. 18 | 19 | - __Extracting__ information is easy, because you only need to navigate through an array, that holds the whole content of an IDML file. 20 | - __Manipulating__ information is easy, because you can change all values by your needs. This allows handling of placeholders, that have been added in Adobe InDesign. 21 | 22 | ## Example 23 | 24 | If you want to have a quick look at how the JSON looks like, navigate to the [example](./example) folder and take the [output.json](./example/example1/output.json) file. 25 | 26 | ## Installation 27 | 28 | This library is written in [PHP](https://www.php.net) and made for the use with [Composer](https://packagist.org/packages/bitandblack/idml-json-converter). Be sure to have both of them installed on your system. 29 | 30 | Add the library then to your project by running `$ composer require bitandblack/idml-json-converter`. 31 | 32 | ## Usage 33 | 34 | ### From command line 35 | 36 | This library comes with two commands that allow the conversion of IDML into JSON and JSON into IDML via CLI. 37 | 38 | The CLI is located under [`bin/idml-json-converter`](bin/idml-json-converter) or, if you installed the library as Composer dependency, under `vendor/bin/idml-json-converter`. 39 | 40 | Use the command 41 | 42 | - `idml:convert:json` to convert an IDML file into JSON. 43 | - `json:convert:idml` to convert a JSON file into IDML. 44 | 45 | Add option `-h` to get more information about the usage of a command. 46 | 47 | ### Custom 48 | 49 | Instead of using the CLI, it is also possible to convert the contents manually. 50 | 51 | #### Converting an IDML file 52 | 53 | Use the [IDML](./src/File/IDML.php) class and initialize it with the path to an IDML. Calling the `getContent()` method will return its content as an array. 54 | 55 | ```php 56 | getContent(); 62 | ``` 63 | 64 | The array contains the name of each file and its content then. For example: 65 | 66 | ```text 67 | [ 68 | 'mimetype' => 'application/vnd.adobe.indesign-idml-package', 69 | 'designmap.xml' => [ 70 | '@name' => 'Document', 71 | '@attributes' => [ 72 | 'DOMVersion' => 18.0, 73 | 'Self' => 'd', 74 | 'StoryList' => [ 75 | 0 => 'ufa', 76 | 1 => 'u126', 77 | 2 => 'u97', 78 | ], 79 | 'Name' => 'file.indd', 80 | [...] 81 | ``` 82 | 83 | You can use the `getJSON()` method to return the content converted into a JSON string. 84 | 85 | The JSON content will be prettified per default. You can disable that behaviour, for example, to save space on your file system: `$idml->setPrettifyOutput(false)`. 86 | 87 | #### Converting JSON content 88 | 89 | Use the [JSON](./src/File/JSON.php) class and initialize it with an array of your content. The array needs to have the same structure a shown above. Calling the `getIDML()` method will return its content as an string, that can be saved as IDML file (for example by using `file_put_contents()`). 90 | 91 | ```php 92 | 'application/vnd.adobe.indesign-idml-package', 98 | 'designmap.xml' => [ 99 | '@name' => 'Document', 100 | '@attributes' => [ 101 | 'DOMVersion' => 18.0, 102 | 'Self' => 'd', 103 | 'StoryList' => [ 104 | 0 => 'ufa', 105 | 1 => 'u126', 106 | 2 => 'u97', 107 | ], 108 | 'Name' => 'file.indd', 109 | [...] 110 | ]; 111 | 112 | $json = new JSON($content); 113 | $idmlContent = $json->getIDML(); 114 | 115 | file_put_contents( 116 | '/path/to/file.idml', 117 | $idmlContent 118 | ); 119 | ``` 120 | 121 | ## Other Tools 122 | 123 | Bit&Black offers some more tools to handle IDML files: 124 | 125 | - The [IDML-Creator](https://www.idml.dev/en/idml-creator-php.html) library that allows creating IDML content natively in PHP in an object-oriented way. (A demo is available [here](https://bitbucket.org/wirbelwild/idml-creator-demo).) 126 | - The [IDML-Writer](https://www.idml.dev/en/idml-writer-php.html) library that can write IDML content into a valid IDML file. 127 | - The [IDML-Validator](https://www.idml.dev/en/idml-validator-php.html) library that allows validating IDML files against the official schema from Adobe. 128 | 129 | Feel free to visit [www.idml.dev](https://www.idml.dev) for more information! 130 | 131 | ## Help 132 | 133 | If you have any questions feel free to contact us under `hello@bitandblack.com`. 134 | 135 | Further information about Bit&Black can be found under [www.bitandblack.com](https://www.bitandblack.com). -------------------------------------------------------------------------------- /bin/idml-json-converter: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | add(new IDMLConvertJSONCommand()); 40 | $application->add(new JSONConvertIDMLCommand()); 41 | $application->run(); -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bitandblack/idml-json-converter", 3 | "description": "Convert Adobe InDesign Markup Language Files (IDML) into JSON and JSON into IDML.", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "Adobe", 8 | "InDesign", 9 | "InDesign Markup Language", 10 | "IDML" 11 | ], 12 | "authors": [ 13 | { 14 | "name": "Tobias Köngeter", 15 | "email": "hello@bitandblack.com", 16 | "homepage": "https://www.bitandblack.com" 17 | } 18 | ], 19 | "homepage": "https://www.idml.dev/en/json-idml-converter-php.html", 20 | "funding": [ 21 | { 22 | "type": "buymeacoffee", 23 | "url": "https://www.buymeacoffee.com/tobiaskoengeter" 24 | } 25 | ], 26 | "require": { 27 | "php": ">=8.2", 28 | "ext-dom": "*", 29 | "ext-json": "*", 30 | "ext-zip": "*", 31 | "bitandblack/composer-helper": "^1.0", 32 | "bitandblack/helpers": "^1.8 || ^2.0", 33 | "maennchen/zipstream-php": "^3.0", 34 | "symfony/console": "^6.0 || ^7.0" 35 | }, 36 | "require-dev": { 37 | "phpstan/phpstan": "^1.0", 38 | "phpunit/phpunit": "^11.0", 39 | "rector/rector": "^1.0", 40 | "symplify/easy-coding-standard": "^12.0" 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "BitAndBlack\\IdmlJsonConverter\\": "src/" 45 | } 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { 49 | "BitAndBlack\\IdmlJsonConverter\\Tests\\": "tests/" 50 | } 51 | }, 52 | "bin": [ 53 | "bin/idml-json-converter" 54 | ], 55 | "config": { 56 | "sort-packages": true 57 | }, 58 | "scripts": { 59 | "phpstan": "php vendor/bin/phpstan analyse --configuration ./phpstan.neon --memory-limit=-1 --ansi", 60 | "phpunit": "php vendor/bin/phpunit --configuration ./phpunit.xml --colors=always", 61 | "refactor": "php vendor/bin/rector && php vendor/bin/ecs --fix" 62 | }, 63 | "scripts-descriptions": { 64 | "phpstan": "Runs PHPStan over the src folder and the tests folder.", 65 | "phpunit": "Runs PHPUnit.", 66 | "refactor": "Runs tools to refactor the code." 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | withParallel() 9 | ->withPaths([ 10 | __DIR__, 11 | ]) 12 | ->withSkip([ 13 | __DIR__ . DIRECTORY_SEPARATOR . 'vendor', 14 | ]) 15 | ->withSets([ 16 | SetList::PSR_12, 17 | SetList::ARRAY, 18 | SetList::CLEAN_CODE, 19 | ]) 20 | ->withConfiguredRule(YodaStyleFixer::class, [ 21 | 'always_move_variable' => true, 22 | ]) 23 | ; 24 | -------------------------------------------------------------------------------- /example/example1/index.php: -------------------------------------------------------------------------------- 1 | getContent(); 29 | $idmlContentAsJson = $idml->getJSON(); 30 | 31 | file_put_contents( 32 | __DIR__ . DIRECTORY_SEPARATOR . 'output.json', 33 | $idmlContentAsJson 34 | ); 35 | 36 | $json = new JSON($idmlContentAsArray); 37 | $idmlContent = $json->getIDML(); 38 | 39 | file_put_contents( 40 | __DIR__ . DIRECTORY_SEPARATOR . 'output.idml', 41 | $idmlContent 42 | ); 43 | -------------------------------------------------------------------------------- /example/example1/input.idml: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:71c5c06a5391d7e29627f6f43b8987d6b0f4143a3245d20530220f85329f1fd8 3 | size 471159 4 | -------------------------------------------------------------------------------- /example/example1/input.indd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BitAndBlack/idml-json-converter/162dca6ddba5184bff1da9662302d38867ff6423/example/example1/input.indd -------------------------------------------------------------------------------- /example/example1/output.idml: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1c2fcff07650e618c98745404c9664cc8054e4d2ed0ada8271216db2ff45823e 3 | size 467158 4 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - src 5 | - tests -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | tests/ 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withParallel() 10 | ->withPaths([ 11 | __DIR__, 12 | ]) 13 | ->withSkip([ 14 | __DIR__ . DIRECTORY_SEPARATOR . 'vendor', 15 | MyCLabsMethodCallToEnumConstRector::class, 16 | PreferPHPUnitThisCallRector::class, 17 | ]) 18 | ->withSets([ 19 | PHPUnitSetList::PHPUNIT_100, 20 | PHPUnitSetList::PHPUNIT_CODE_QUALITY, 21 | ]) 22 | ->withImportNames() 23 | ->withPhpSets() 24 | ; 25 | -------------------------------------------------------------------------------- /src/Command/IDMLConvertJSONCommand.php: -------------------------------------------------------------------------------- 1 | setName('idml:convert:json') 31 | ->setDescription('Converts an IDML file into JSON.') 32 | ->addArgument( 33 | 'idml-file', 34 | InputArgument::REQUIRED, 35 | 'Path to the IDML file.' 36 | ) 37 | ->addArgument( 38 | 'json-file', 39 | InputArgument::REQUIRED, 40 | 'Path to the JSON file.' 41 | ) 42 | ->addOption( 43 | 'compress-output', 44 | 'c', 45 | InputOption::VALUE_NONE, 46 | 'Whether to reduce the size of the output by removing unnecessary whitespaces. This option is deactivated by default, resulting in easily readable but larger JSON files.', 47 | ) 48 | ->addOption( 49 | 'memory-limit-disabled', 50 | 'm', 51 | InputOption::VALUE_NONE, 52 | 'This option deactivates the memory limit.', 53 | ) 54 | ; 55 | } 56 | 57 | public function execute(InputInterface $input, OutputInterface $output): int 58 | { 59 | $io = new SymfonyStyle($input, $output); 60 | 61 | $isMemoryLimitDisabled = $input->getOption('memory-limit-disabled'); 62 | 63 | if ($isMemoryLimitDisabled) { 64 | $success = ini_set('memory_limit', '-1'); 65 | 66 | if (!$success) { 67 | $io->error('Failed changing memory limit.'); 68 | } 69 | } 70 | 71 | $memoryLimitCurrent = new MemoryLimit(); 72 | $memoryLimitCurrentBytes = $memoryLimitCurrent->getMemoryLimitInBytes(); 73 | 74 | if (-1.0 === $memoryLimitCurrentBytes) { 75 | $isMemoryLimitDisabled = true; 76 | } 77 | 78 | $memoryLimitPreferredMegaBytes = 512; 79 | $memoryLimitPreferredBytes = $memoryLimitPreferredMegaBytes * 1024 * 1024; 80 | 81 | if (!$isMemoryLimitDisabled && $memoryLimitCurrentBytes < $memoryLimitPreferredBytes) { 82 | $io->warning( 83 | 'The memory limit is currently set to ' . $memoryLimitCurrent . ', which may lead to problems. ' 84 | . 'Our recommendation for this process would be at least ' . $memoryLimitPreferredMegaBytes . 'M. ' 85 | . 'Please consider changing this value. ' 86 | . 'If you want to disable the memory limit completely, run this command with the option "--memory-limit-disabled".' 87 | ); 88 | } 89 | 90 | $idmlFile = $input->getArgument('idml-file'); 91 | $jsonFile = $input->getArgument('json-file'); 92 | 93 | if (!is_string($idmlFile) || !is_string($jsonFile)) { 94 | $io->error('Cannot use argument.'); 95 | return Command::FAILURE; 96 | } 97 | 98 | $io->writeln('Starting conversion.'); 99 | 100 | try { 101 | $idml = new IDML($idmlFile); 102 | } catch (CannotReadFileException $exception) { 103 | $io->error('Failed.'); 104 | $io->writeln($exception->getMessage()); 105 | return Command::FAILURE; 106 | } 107 | 108 | /** @var bool $compressOutput */ 109 | $compressOutput = $input->getOption('compress-output'); 110 | 111 | if ($compressOutput) { 112 | $idml->setPrettifyOutput(false); 113 | } 114 | 115 | try { 116 | $json = $idml->getJSON(); 117 | } catch (JsonEncodeException $exception) { 118 | $io->error('Failed.'); 119 | $io->writeln($exception->getMessage()); 120 | return Command::FAILURE; 121 | } 122 | 123 | $success = false !== file_put_contents($jsonFile, $json); 124 | 125 | if (!$success) { 126 | $io->error('Failed.'); 127 | $io->writeln('Cannot write file.'); 128 | return Command::FAILURE; 129 | } 130 | 131 | $io->success('Finished conversion.'); 132 | 133 | return Command::SUCCESS; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Command/JSONConvertIDMLCommand.php: -------------------------------------------------------------------------------- 1 | setName('json:convert:idml') 31 | ->setDescription('Converts JSON into IDML.') 32 | ->addArgument( 33 | 'json-file', 34 | InputArgument::REQUIRED, 35 | 'Path to the JSON file.' 36 | ) 37 | ->addArgument( 38 | 'idml-file', 39 | InputArgument::REQUIRED, 40 | 'Path to the IDML file.' 41 | ) 42 | ; 43 | } 44 | 45 | public function execute(InputInterface $input, OutputInterface $output): int 46 | { 47 | $io = new SymfonyStyle($input, $output); 48 | 49 | $jsonFile = $input->getArgument('json-file'); 50 | $idmlFile = $input->getArgument('idml-file'); 51 | 52 | if (!is_string($idmlFile) || !is_string($jsonFile)) { 53 | $io->error('Cannot use argument.'); 54 | return Command::FAILURE; 55 | } 56 | 57 | $io->writeln('Starting conversion.'); 58 | 59 | $jsonContent = file_get_contents($jsonFile); 60 | 61 | if (!$jsonContent) { 62 | $io->error('Failed.'); 63 | $io->writeln('Cannot read file.'); 64 | return Command::FAILURE; 65 | } 66 | 67 | try { 68 | $jsonContent = json_decode($jsonContent, true, 512, JSON_THROW_ON_ERROR); 69 | } catch (JsonException) { 70 | $jsonContent = null; 71 | } 72 | 73 | if (!is_array($jsonContent)) { 74 | $io->error('Failed.'); 75 | $io->writeln('Cannot read JSON content.'); 76 | return Command::FAILURE; 77 | } 78 | 79 | try { 80 | $json = new JSON($jsonContent); 81 | } catch (FailedExtractingContentExpection|UnknownFileException|DOMException $exception) { 82 | $io->error('Failed.'); 83 | $io->writeln($exception->getMessage()); 84 | return Command::FAILURE; 85 | } 86 | 87 | $idmlContent = $json->getIDML(); 88 | 89 | $success = false !== file_put_contents($idmlFile, $idmlContent); 90 | 91 | if (!$success) { 92 | $io->error('Failed.'); 93 | $io->writeln('Cannot write file.'); 94 | return Command::FAILURE; 95 | } 96 | 97 | $io->success('Finished conversion.'); 98 | 99 | return Command::SUCCESS; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Converter/ArrayToDomNodeConverter.php: -------------------------------------------------------------------------------- 1 | >, 30 | * "@value": string|null, 31 | * "@children": array>, 32 | * } $content 33 | * @throws DOMException 34 | */ 35 | public function __construct(array $content, string $rootElementName = 'root') 36 | { 37 | $dom = new DomDocument('1.0', 'UTF-8'); 38 | $dom->preserveWhiteSpace = true; 39 | $dom->formatOutput = true; 40 | $dom->xmlStandalone = true; 41 | 42 | if ('Document' === $rootElementName) { 43 | /** 44 | * Append processing instruction as second line here 45 | * InDesign needs that line and without that the IDML document will be invalid 46 | */ 47 | $aidInstruction = $dom->createProcessingInstruction( 48 | 'aid', 49 | 'style="50" type="document" readerVersion="6.0" featureSet="257" product="18.1(51)"' 50 | ); 51 | $dom->appendChild($aidInstruction); 52 | } elseif ('x:xmpmeta' === $rootElementName) { 53 | $xpacket = $dom->createProcessingInstruction('xpacket', 'begin="" id=" "'); 54 | $dom->appendChild($xpacket); 55 | } 56 | 57 | $root = $dom->createElement($rootElementName); 58 | 59 | if ('container' === $rootElementName) { 60 | $root->setAttribute('xmlns', 'urn:oasis:names:tc:opendocument:xmlns:container'); 61 | } else { 62 | $root->setAttribute('xmlns:idPkg', 'http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging'); 63 | } 64 | 65 | $this->getXMLFromArray($content, $dom, $root); 66 | $dom->appendChild($root); 67 | 68 | if ('x:xmpmeta' === $rootElementName) { 69 | $root->setAttribute('xmlns:x', 'adobe:ns:meta/'); 70 | $xpacket = $dom->createProcessingInstruction('xpacket', 'end="r"'); 71 | $dom->appendChild($xpacket); 72 | } 73 | 74 | $this->domDocument = $dom; 75 | } 76 | 77 | /** 78 | * @param array{ 79 | * "@name": string, 80 | * "@attributes": array>, 81 | * "@value": string|null, 82 | * "@children": array>, 83 | * } $data 84 | * @throws DOMException 85 | */ 86 | private function getXMLFromArray(array $data, DOMDocument $domDocument, DOMNode $node): void 87 | { 88 | foreach ($data['@attributes'] as $attributeKey => $attributeValue) { 89 | if (is_array($attributeValue)) { 90 | $attributeValue = implode(' ', $attributeValue); 91 | } 92 | 93 | if ('DOMVersion' === $attributeKey || 'version' === $attributeKey) { 94 | $attributeValue = number_format((float) $attributeValue, 1); 95 | } 96 | 97 | if ('CustomCharacters' === $attributeKey) { 98 | $attributeValue = ''; 99 | } 100 | 101 | $node->setAttribute($attributeKey, $attributeValue); 102 | } 103 | 104 | if (null !== $value = $data['@value']) { 105 | if ('Contents' === $data['@name']) { 106 | $cdataSection = $domDocument->createCDATASection($value); 107 | $node->appendChild($cdataSection); 108 | } else { 109 | $value = StringHelper::booleanToString($value); 110 | $node->nodeValue = htmlspecialchars((string) $value); 111 | } 112 | } 113 | 114 | foreach ($data['@children'] as $child) { 115 | $subnode = $domDocument->createElement($child['@name']); 116 | $node->appendChild($subnode); 117 | $this->getXMLFromArray($child, $domDocument, $subnode); 118 | } 119 | } 120 | 121 | public function getDomDocument(): DOMDocument 122 | { 123 | return $this->domDocument; 124 | } 125 | 126 | public function getString(): string 127 | { 128 | $xml = (string) $this->getDomDocument()->saveXML(); 129 | 130 | return (string) preg_replace_callback( 131 | '/&#(\d+);/m', 132 | static fn ($matches): string => sprintf('&#x%X;', $matches[1]), 133 | $xml 134 | ); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Converter/DomNodeToArrayConverter.php: -------------------------------------------------------------------------------- 1 | domNodeArray = $this->domNodeToArray($node); 29 | } 30 | 31 | /** 32 | * @return array|string|null 33 | */ 34 | private function domNodeToArray(DOMNode $node): array|string|null 35 | { 36 | if (XML_CDATA_SECTION_NODE === $node->nodeType || XML_TEXT_NODE === $node->nodeType) { 37 | $textContent = trim($node->textContent, "\t\n\r"); 38 | 39 | return '' !== $textContent 40 | ? $textContent 41 | : null 42 | ; 43 | } 44 | 45 | $output = []; 46 | 47 | if (XML_ELEMENT_NODE === $node->nodeType) { 48 | $childNodesCount = $node->childNodes->length; 49 | 50 | for ($counter = 0; $counter < $childNodesCount; ++$counter) { 51 | $childNode = $node->childNodes->item($counter); 52 | 53 | if (!isset($output['@children'])) { 54 | $output['@children'] = []; 55 | } 56 | 57 | $childKeyNext = count($output['@children']); 58 | 59 | if (null === $childNode) { 60 | continue; 61 | } 62 | 63 | $childNodeValues = $this->domNodeToArray($childNode); 64 | 65 | if (isset($childNode->tagName)) { 66 | if (!is_array($output)) { 67 | continue; 68 | } 69 | 70 | $output['@children'][$childKeyNext] = $childNodeValues; 71 | continue; 72 | } 73 | 74 | if (is_string($childNodeValues)) { 75 | $output['@value'] = $childNodeValues; 76 | } 77 | 78 | if ([] !== $output['@children']) { 79 | $output['@value'] = null; 80 | } 81 | } 82 | 83 | if (is_array($output)) { 84 | $nodeAttributes = $node->attributes; 85 | 86 | if (null !== $nodeAttributes) { 87 | $attributes = []; 88 | 89 | foreach ($nodeAttributes as $nodeAttribute) { 90 | $attributes[$nodeAttribute->nodeName] = (string) $nodeAttribute->nodeValue; 91 | 92 | if ('CustomCharacters' === $nodeAttribute->nodeName) { 93 | $attributes[$nodeAttribute->nodeName] = htmlentities((string) $nodeAttribute->nodeValue); 94 | } 95 | } 96 | 97 | $output['@attributes'] = $attributes; 98 | } 99 | 100 | $base = [ 101 | '@name' => $node->nodeName, 102 | '@attributes' => [], 103 | '@value' => null, 104 | '@children' => [], 105 | ]; 106 | 107 | $output = array_merge($base, $output); 108 | } 109 | } 110 | 111 | return $output; 112 | } 113 | 114 | public function getDomNodeArray(): array|string|null 115 | { 116 | return $this->domNodeArray; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | private readonly array $content; 29 | 30 | private bool $prettifyOutput; 31 | 32 | private bool $unescapeOutput; 33 | 34 | /** 35 | * @throws CannotReadFileException 36 | */ 37 | public function __construct(string $file) 38 | { 39 | $this->prettifyOutput = true; 40 | $this->unescapeOutput = true; 41 | 42 | $zipArchive = new ZipArchive(); 43 | 44 | $contents = []; 45 | 46 | if (true !== $zipArchive->open($file)) { 47 | throw new CannotReadFileException($file); 48 | } 49 | 50 | $filesCount = $zipArchive->numFiles; 51 | 52 | for ($counter = 0; $counter < $filesCount; ++$counter) { 53 | $filename = $zipArchive->getNameIndex($counter); 54 | $content = $zipArchive->getFromIndex($counter); 55 | 56 | if (false === $filename || false === $content) { 57 | continue; 58 | } 59 | 60 | if (str_ends_with($filename, '.xml')) { 61 | $content = (string) preg_replace_callback( 62 | '/<\?ACE\s(.*?)\?>/s', 63 | static fn ($matches) => '<?ACE ' . htmlentities((string) $matches[1], ENT_QUOTES | ENT_XML1, 'UTF-8') . '?>', 64 | $content 65 | ); 66 | 67 | $content = (string) preg_replace_callback( 68 | '//s', 69 | static fn ($matches) => htmlentities((string) $matches[1], ENT_QUOTES | ENT_XML1, 'UTF-8'), 70 | $content 71 | ); 72 | 73 | $domDocument = new DOMDocument(); 74 | $domDocument->loadXML($content); 75 | 76 | $node = $domDocument->documentElement; 77 | 78 | if (null === $node) { 79 | continue; 80 | } 81 | 82 | $domNodeToArrayConverter = new DomNodeToArrayConverter($node); 83 | $array = $domNodeToArrayConverter->getDomNodeArray(); 84 | 85 | $contents[$filename] = $array; 86 | continue; 87 | } 88 | 89 | $contents[$filename] = $content; 90 | } 91 | 92 | $zipArchive->close(); 93 | 94 | $attributeSplitter = new AttributeSplitter($contents); 95 | $contents = $attributeSplitter->getValue(); 96 | 97 | $valueNormalizer = new ValueNormalizer($contents); 98 | $contents = $valueNormalizer->getTyped(); 99 | 100 | $this->content = $contents; 101 | } 102 | 103 | /** 104 | * @return array 105 | */ 106 | public function getContent(): array 107 | { 108 | return $this->content; 109 | } 110 | 111 | /** 112 | * @throws JsonEncodeException 113 | */ 114 | public function getJSON(): string 115 | { 116 | $jsonFlags = JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION; 117 | 118 | if ($this->isPrettifyOutput()) { 119 | $jsonFlags |= JSON_PRETTY_PRINT; 120 | } 121 | 122 | if ($this->isUnescapeOutput()) { 123 | $jsonFlags |= JSON_UNESCAPED_UNICODE; 124 | } 125 | 126 | try { 127 | $json = json_encode( 128 | $this->getContent(), 129 | $jsonFlags 130 | ); 131 | } catch (JsonException $jsonException) { 132 | throw new JsonEncodeException($jsonException); 133 | } 134 | 135 | return $json; 136 | } 137 | 138 | public function setPrettifyOutput(bool $prettifyOutput): self 139 | { 140 | $this->prettifyOutput = $prettifyOutput; 141 | return $this; 142 | } 143 | 144 | public function isPrettifyOutput(): bool 145 | { 146 | return $this->prettifyOutput; 147 | } 148 | 149 | public function setUnescapeOutput(bool $unescapeOutput): self 150 | { 151 | $this->unescapeOutput = $unescapeOutput; 152 | return $this; 153 | } 154 | 155 | public function isUnescapeOutput(): bool 156 | { 157 | return $this->unescapeOutput; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/File/JSON.php: -------------------------------------------------------------------------------- 1 | $content 29 | * @throws DOMException 30 | * @throws FailedExtractingContentExpection 31 | * @throws UnknownFileException 32 | */ 33 | public function __construct(array $content) 34 | { 35 | $outputStream = fopen('php://memory', 'wb+'); 36 | 37 | if (false === $outputStream) { 38 | throw FailedExtractingContentExpection::phpMemory(); 39 | } 40 | 41 | $zipStream = new ZipStream( 42 | outputStream: $outputStream 43 | ); 44 | 45 | foreach ($content as $fileName => $fileContent) { 46 | $valueNormalizer = new ValueNormalizer($fileContent); 47 | $fileContent = $valueNormalizer->getStringified(); 48 | 49 | if (str_ends_with($fileName, '.xml')) { 50 | $rootElementName = new RootElementNameFromFileName($fileName); 51 | $arrayToDomNodeConverter = new ArrayToDomNodeConverter($fileContent, $rootElementName); 52 | $fileContent = $arrayToDomNodeConverter->getString(); 53 | 54 | $fileContent = preg_replace_callback( 55 | '/<\?ACE\s(.*?)\?>/s', 56 | static fn ($matches) => '', 57 | $fileContent 58 | ); 59 | } 60 | 61 | $zipStream->addFile($fileName, $fileContent); 62 | } 63 | 64 | try { 65 | $zipStream->finish(); 66 | } catch (OverflowException $exception) { 67 | throw FailedExtractingContentExpection::fileTooLarge($exception); 68 | } 69 | 70 | rewind($outputStream); 71 | 72 | $response = stream_get_contents($outputStream); 73 | 74 | fclose($outputStream); 75 | 76 | if (!$response) { 77 | throw FailedExtractingContentExpection::cannotReadFromStream(); 78 | } 79 | 80 | $this->response = $response; 81 | } 82 | 83 | public function getIDML(): ?string 84 | { 85 | return $this->response; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/MemoryLimit.php: -------------------------------------------------------------------------------- 1 | memoryLimit = $memoryLimit; 29 | } 30 | 31 | public function __toString(): string 32 | { 33 | return $this->getMemoryLimit(); 34 | } 35 | 36 | public function getMemoryLimit(): string 37 | { 38 | return $this->memoryLimit; 39 | } 40 | 41 | public function getMemoryLimitInBytes(): float 42 | { 43 | $memoryLimit = $this->getMemoryLimit(); 44 | 45 | if (preg_match('/^(\d+)(.)$/', $memoryLimit, $matches)) { 46 | if ('M' === $matches[2]) { 47 | $memoryLimit = (float) $matches[1] * 1024 * 1024; 48 | } elseif ('K' === $matches[2]) { 49 | $memoryLimit = (float) $matches[1] * 1024; 50 | } 51 | } 52 | 53 | return (float) $memoryLimit; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Utils/RootElementNameFromFileName.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | private array $rootElementNames = [ 23 | 'designmap.xml' => 'Document', 24 | 'META-INF/container.xml' => 'container', 25 | 'META-INF/metadata.xml' => 'x:xmpmeta', 26 | 'Resources/Fonts.xml' => 'idPkg:Fonts', 27 | 'Resources/Graphic.xml' => 'idPkg:Graphic', 28 | 'Resources/Preferences.xml' => 'idPkg:Preferences', 29 | 'Resources/Styles.xml' => 'idPkg:Styles', 30 | 'XML/BackingStory.xml' => 'idPkg:BackingStory', 31 | 'XML/Mapping.xml' => 'idPkg:Mapping', 32 | 'XML/Tags.xml' => 'idPkg:Tags', 33 | ]; 34 | 35 | private readonly string $rootElementName; 36 | 37 | /** 38 | * @throws UnknownFileException 39 | */ 40 | public function __construct(string $fileName) 41 | { 42 | $rootElementName = $this->rootElementNames[$fileName] ?? null; 43 | 44 | if (null === $rootElementName) { 45 | if (str_starts_with($fileName, 'MasterSpreads/')) { 46 | $rootElementName = 'idPkg:MasterSpread'; 47 | } elseif (str_starts_with($fileName, 'Spreads/')) { 48 | $rootElementName = 'idPkg:Spread'; 49 | } elseif (str_starts_with($fileName, 'Stories/')) { 50 | $rootElementName = 'idPkg:Story'; 51 | } 52 | } 53 | 54 | if (null === $rootElementName) { 55 | throw new UnknownFileException($fileName); 56 | } 57 | 58 | $this->rootElementName = $rootElementName; 59 | } 60 | 61 | public function __toString(): string 62 | { 63 | return $this->getRootElementName(); 64 | } 65 | 66 | public function getRootElementName(): string 67 | { 68 | return $this->rootElementName; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ValueModifier/AttributeSplitter.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | private array $attributesSplittable = [ 22 | 'Anchor', 23 | 'ColumnsPositions', 24 | 'GeometricBounds', 25 | 'ItemTransform', 26 | 'LeftDirection', 27 | 'MasterPageTransform', 28 | 'RightDirection', 29 | 'StoryList', 30 | ]; 31 | 32 | /** 33 | * @var array 34 | */ 35 | private readonly array $content; 36 | 37 | /** 38 | * @param array $content 39 | */ 40 | public function __construct(array $content) 41 | { 42 | $this->content = ArrayHelper::recurse( 43 | $content, 44 | function (string|int|float|bool|null $value, string|int|null $key = null): mixed { 45 | if (in_array($key, $this->attributesSplittable, true)) { 46 | return explode(' ', (string) $value); 47 | } 48 | 49 | return $value; 50 | } 51 | ); 52 | } 53 | 54 | /** 55 | * @return array 56 | */ 57 | public function getValue(): array 58 | { 59 | return $this->content; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ValueModifier/ValueNormalizer.php: -------------------------------------------------------------------------------- 1 | $content 21 | */ 22 | public function __construct( 23 | private string|array $content 24 | ) { 25 | } 26 | 27 | /** 28 | * @return string|array 29 | */ 30 | public function getTyped(): string|array 31 | { 32 | return ArrayHelper::recurse( 33 | $this->content, 34 | static function (string|int|float|bool|null $value): mixed { 35 | $value = StringHelper::stringToBoolean($value); 36 | $value = StringHelper::stringToNumber($value); 37 | return $value; 38 | } 39 | ); 40 | } 41 | 42 | /** 43 | * @return string|array 44 | */ 45 | public function getStringified(): string|array 46 | { 47 | return ArrayHelper::recurse( 48 | $this->content, 49 | static function (string|int|float|bool|null $value): mixed { 50 | $value = StringHelper::booleanToString($value); 51 | 52 | /** 53 | * Current hack to keep null values. 54 | */ 55 | if ('null' === $value) { 56 | $value = null; 57 | } 58 | 59 | return $value; 60 | } 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Converter/ArrayToDomNodeConverterTest.php: -------------------------------------------------------------------------------- 1 | domDocument = new DOMDocument('1.0', 'UTF-8'); 31 | $this->domDocument->preserveWhiteSpace = false; 32 | $this->domDocument->formatOutput = true; 33 | } 34 | 35 | /** 36 | * @param array $input 37 | * @throws DOMException 38 | */ 39 | #[DataProvider('provideTestCases')] 40 | public function testConversion(array $input, string $xmlExpected): void 41 | { 42 | $this->domDocument->loadXML($xmlExpected); 43 | 44 | $arrayToDomNodeConverter = new ArrayToDomNodeConverter($input); 45 | $output = $arrayToDomNodeConverter->getString(); 46 | 47 | self::assertEquals( 48 | $this->domDocument->saveXML(), 49 | $output 50 | ); 51 | } 52 | 53 | public static function provideTestCases(): Generator 54 | { 55 | yield [ 56 | [ 57 | '@name' => 'root', 58 | '@value' => null, 59 | '@attributes' => [], 60 | '@children' => [ 61 | [ 62 | '@name' => 'child', 63 | '@value' => 'value 2', 64 | '@attributes' => [ 65 | 'attribute' => 'value 1', 66 | ], 67 | '@children' => [], 68 | ], 69 | ], 70 | ], 71 | ' 72 | 73 | value 2 74 | ', 75 | ]; 76 | 77 | yield [ 78 | [ 79 | '@name' => 'root', 80 | '@value' => null, 81 | '@attributes' => [], 82 | '@children' => [ 83 | [ 84 | '@name' => 'child1', 85 | '@value' => null, 86 | '@attributes' => [ 87 | 'attribute' => 'value 1', 88 | ], 89 | '@children' => [ 90 | [ 91 | '@name' => 'child2', 92 | '@value' => 'value 3', 93 | '@attributes' => [ 94 | 'attribute' => 'value 2', 95 | ], 96 | '@children' => [], 97 | ], 98 | [ 99 | '@name' => 'child2', 100 | '@value' => 'value 5', 101 | '@attributes' => [ 102 | 'attribute' => 'value 4', 103 | ], 104 | '@children' => [], 105 | ], 106 | ], 107 | ], 108 | ], 109 | ], 110 | ' 111 | 112 | 113 | value 3 114 | value 5 115 | 116 | ', 117 | ]; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/Converter/DomNodeToArrayConverterTest.php: -------------------------------------------------------------------------------- 1 | domDocument = new DOMDocument(); 30 | } 31 | 32 | /** 33 | * @param array $outputExpected 34 | */ 35 | #[DataProvider('provideTestCases')] 36 | public function testConversion(string $xml, array $outputExpected): void 37 | { 38 | $this->domDocument->loadXML($xml); 39 | $domNode = $this->domDocument->firstChild; 40 | 41 | $domNodeToArrayConverter = new DomNodeToArrayConverter($domNode); 42 | $actualArray = $domNodeToArrayConverter->getDomNodeArray(); 43 | 44 | self::assertEquals( 45 | $outputExpected, 46 | $actualArray 47 | ); 48 | } 49 | 50 | public static function provideTestCases(): Generator 51 | { 52 | yield [ 53 | ' 54 | value 2 55 | ', 56 | [ 57 | '@name' => 'root', 58 | '@attributes' => [], 59 | '@value' => null, 60 | '@children' => [ 61 | [ 62 | '@name' => 'child', 63 | '@value' => 'value 2', 64 | '@attributes' => [ 65 | 'attribute' => 'value 1', 66 | ], 67 | '@children' => [], 68 | ], 69 | ], 70 | ], 71 | ]; 72 | 73 | yield [ 74 | ' 75 | 76 | value 3 77 | value 5 78 | 79 | 80 | ', 81 | [ 82 | '@name' => 'root', 83 | '@attributes' => [], 84 | '@value' => null, 85 | '@children' => [ 86 | [ 87 | '@name' => 'child1', 88 | '@value' => null, 89 | '@attributes' => [ 90 | 'attribute' => 'value 1', 91 | ], 92 | '@children' => [ 93 | [ 94 | '@name' => 'child2', 95 | '@value' => 'value 3', 96 | '@attributes' => [ 97 | 'attribute' => 'value 2', 98 | ], 99 | '@children' => [], 100 | ], 101 | [ 102 | '@name' => 'child2', 103 | '@value' => 'value 5', 104 | '@attributes' => [ 105 | 'attribute' => 'value 4', 106 | ], 107 | '@children' => [], 108 | ], 109 | [ 110 | '@name' => 'child2', 111 | '@value' => null, 112 | '@attributes' => [], 113 | '@children' => [], 114 | ], 115 | ], 116 | ], 117 | ], 118 | ], 119 | ]; 120 | 121 | yield [ 122 | ' 123 | 124 | ', 125 | [ 126 | '@name' => 'root', 127 | '@value' => null, 128 | '@attributes' => [], 129 | '@children' => [ 130 | [ 131 | '@name' => 'test', 132 | '@value' => null, 133 | '@attributes' => [ 134 | 'value' => "\t", 135 | ], 136 | '@children' => [], 137 | ], 138 | ], 139 | ], 140 | ]; 141 | 142 | yield [ 143 | ' 144 | value 1b 145 | value 2b 146 | value 3b 147 | value 4b 148 | ', 149 | [ 150 | '@name' => 'root', 151 | '@attributes' => [], 152 | '@value' => null, 153 | '@children' => [ 154 | [ 155 | '@name' => 'child1', 156 | '@attributes' => [ 157 | 'attribute' => 'value 1a', 158 | ], 159 | '@value' => 'value 1b', 160 | '@children' => [], 161 | ], 162 | [ 163 | '@name' => 'child2', 164 | '@attributes' => [ 165 | 'attribute' => 'value 2a', 166 | ], 167 | '@value' => 'value 2b', 168 | '@children' => [], 169 | ], 170 | [ 171 | '@name' => 'child1', 172 | '@attributes' => [ 173 | 'attribute' => 'value 3a', 174 | ], 175 | '@value' => 'value 3b', 176 | '@children' => [], 177 | ], 178 | [ 179 | '@name' => 'child2', 180 | '@attributes' => [ 181 | 'attribute' => 'value 4a', 182 | ], 183 | '@value' => 'value 4b', 184 | '@children' => [], 185 | ], 186 | ], 187 | ], 188 | ]; 189 | } 190 | } 191 | --------------------------------------------------------------------------------