├── .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 | [](http://www.php.net)
2 | [](https://packagist.org/packages/bitandblack/idml-json-converter)
3 | [](https://packagist.org/packages/bitandblack/idml-json-converter)
4 |
5 |
6 |
7 |
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;', $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 |
--------------------------------------------------------------------------------