Test Failed
Push — main ( 961615...d5171d )
by Andreas
12:44 queued 13s
created

PdfcpuWrapperBookmarksHelper::applyBookmarks()   B

Complexity

Conditions 7
Paths 16

Size

Total Lines 45
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 29
c 1
b 0
f 0
dl 0
loc 45
rs 8.5226
cc 7
nc 16
nop 3
1
<?php
2
/**
3
 * bookmarks helper for pdfcpu wrapper
4
 *
5
 * @copyright 2014-2024 Institute of Legal Medicine, Medical University of Innsbruck
6
 * @author Andreas Erhard <[email protected]>
7
 * @license LGPL-3.0-only
8
 * @link http://www.gerichtsmedizin.at/
9
 *
10
 * @package pdftk
11
 */
12
13
namespace Gmi\Toolkit\Pdftk;
14
15
use Gmi\Toolkit\Pdftk\Exception\FileNotFoundException;
16
use Gmi\Toolkit\Pdftk\Exception\PdfException;
17
use Gmi\Toolkit\Pdftk\Util\Escaper;
18
use Gmi\Toolkit\Pdftk\Util\FileChecker;
19
use Gmi\Toolkit\Pdftk\Util\ProcessFactory;
20
21
use Exception;
22
23
/**
24
 * Bookmarks helper for pdfcpu wrapper.
25
 *
26
 * This class encapsulates the complexity of pdfcpu bookmark handling to reduce complexity of the PdfcpuWrapper.
27
 *
28
 * @internal
29
 */
30
class PdfcpuWrapperBookmarksHelper
31
{
32
    use BinaryPathAwareTrait;
33
34
    /**
35
     * @var ProcessFactory
36
     */
37
    private $processFactory;
38
39
    /**
40
     * @var Escaper
41
     */
42
    private $escaper;
43
44
    /**
45
     * @var FileChecker
46
     */
47
    private $fileChecker;
48
49
    /**
50
     * Constructor.
51
     *
52
     * @throws FileNotFoundException
53
     */
54
    public function __construct(string $pdftkBinaryPath, ProcessFactory $processFactory)
55
    {
56
        $this->binaryPath = $pdftkBinaryPath;
57
        $this->processFactory = $processFactory;
58
        $this->escaper = new Escaper();
59
        $this->fileChecker = new FileChecker();
60
    }
61
62
    /**
63
     * @see WrapperInterface::applyBookmarks()
64
     */
65
    public function applyBookmarks(Bookmarks $bookmarks, string $infile, string $outfile = null): void
66
    {
67
        $temporaryOutFile = false;
68
69
        $this->fileChecker->checkPdfFileExists($infile);
70
        $bookmarksJson = $this->exportBookmarksToJson($bookmarks);
71
        $tempfile = tempnam(sys_get_temp_dir(), 'bookmarks') . '.json';
72
        file_put_contents($tempfile, $bookmarksJson);
73
74
        if ($outfile === null || $infile === $outfile) {
75
            $temporaryOutFile = true;
76
            $outfile = tempnam(sys_get_temp_dir(), 'pdf') . '.pdf';
77
        }
78
79
        $cmd = sprintf(
80
            '%s bookmarks import -replace %s %s %s',
81
            $this->getBinary(),
82
            $this->escaper->shellArg($infile),
83
            $this->escaper->shellArg($tempfile),
84
            $this->escaper->shellArg($outfile)
85
        );
86
87
        $process = $this->processFactory->createProcess($cmd);
88
89
        try {
90
            $process->mustRun();
91
        } catch (Exception $e) {
92
            $exception = new PdfException(
93
                sprintf('Failed to write PDF bookmarks to "%s"! Error: %s', $outfile, $e->getMessage()),
94
                0,
95
                $e,
96
                $process->getErrorOutput(),
97
                $process->getOutput()
98
            );
99
        }
100
101
        unlink($tempfile);
102
103
        if ($temporaryOutFile && !isset($exception)) {
104
            unlink($infile);
105
            rename($outfile, $infile);
106
        }
107
108
        if (isset($exception)) {
109
            throw $exception;
110
        }
111
    }
112
113
    /**
114
     * @see WrapperInterface::importBookmarks()
115
     */
116
    public function importBookmarks(Bookmarks $bookmarks, string $infile): void
117
    {
118
        $tempBookmarksFile = tempnam(sys_get_temp_dir(), 'bookmarks') . '.json';
119
120
        $this->fileChecker->checkPdfFileExists($infile);
121
122
        $cmd = sprintf(
123
            '%s bookmarks export %s %s',
124
            $this->getBinary(),
125
            $this->escaper->shellArg($infile),
126
            $this->escaper->shellArg($tempBookmarksFile)
127
        );
128
129
        $process = $this->processFactory->createProcess($cmd);
130
131
        try {
132
            $process->mustRun();
133
        } catch (Exception $e) {
134
            $exception = new PdfException(
135
                sprintf('Failed to read bookmarks data from "%s"! Error: %s', $infile, $e->getMessage()),
136
                0,
137
                $e,
138
                $process->getErrorOutput(),
139
                $process->getOutput()
140
            );
141
        }
142
143
        if (isset($exception) && false === strpos($process->getErrorOutput(), 'no outlines available')) {
144
            @unlink($tempBookmarksFile);
145
            throw $exception;
146
        }
147
148
        $this->importBookmarksFromJson($bookmarks, @file_get_contents($tempBookmarksFile) ?: '');
149
150
        @unlink($tempBookmarksFile);
151
    }
152
153
    /**
154
     * Imports bookmarks from a pdfcpu bookmark JSON file.
155
     */
156
    private function importBookmarksFromJson(Bookmarks $bookmarks, string $json): void
157
    {
158
        $raw = json_decode($json, true);
159
        $bookmarksArray = $raw['bookmarks'] ?? [];
160
161
        $this->parseBookmarksTree($bookmarks, $bookmarksArray);
162
    }
163
164
    /**
165
     * Recursively traverse the bookmarks array and add the bookmarks appropriately.
166
     */
167
    private function parseBookmarksTree(Bookmarks $bookmarks, array $arr, int $level = 1): void
168
    {
169
        foreach ($arr as $current) {
170
            $bookmark = new Bookmark();
171
172
            $bookmark
173
                ->setTitle($current['title'])
174
                ->setPageNumber($current['page'])
175
                ->setLevel($level)
176
            ;
177
178
            $bookmarks->add($bookmark);
179
180
            if (isset($current['kids'])) {
181
                $this->parseBookmarksTree($bookmarks, $current['kids'], $level + 1);
182
            }
183
        }
184
    }
185
186
    /**
187
     * Exports bookmarks to a pdfcpu bookmark JSON file.
188
     */
189
    private function exportBookmarksToJson(Bookmarks $bookmarks): string
190
    {
191
        $bookmarksRecursiveArray = $this->buildBookmarksTree($this->buildBookmarksArrayForTree($bookmarks));
192
193
        return json_encode(['bookmarks' => $bookmarksRecursiveArray], JSON_PRETTY_PRINT);
194
    }
195
196
    /**
197
     * Recursively build the JSON tree based on the normalized bookmarks array.
198
     */
199
    private function buildBookmarksTree(array $bookmarksArray, int $parentId = null): array
200
    {
201
        $result = [];
202
203
        foreach ($bookmarksArray as $bookmarkItem) {
204
            if ($bookmarkItem['__parent'] === $parentId) {
205
                $children = $this->buildBookmarksTree($bookmarksArray, $bookmarkItem['__id']);
206
                if ($children) {
207
                    $bookmarkItem['kids'] = $children;
208
                }
209
210
                foreach ($bookmarkItem as $key => $value) {
211
                    if (strpos($key, "__") === 0) {
212
                        unset($bookmarkItem[$key]);
213
                    }
214
                }
215
216
                $result[] = $bookmarkItem;
217
            }
218
        }
219
220
        return $result;
221
    }
222
223
    /**
224
     * Builds an array with additional entries prefixed with "__" for level, id and parent id.
225
     */
226
    private function buildBookmarksArrayForTree(Bookmarks $bookmarks): array
227
    {
228
        $bookmarksArray = [];
229
230
        $b = $bookmarks->all();
231
        $bookmarksCount = count($b);
232
233
        $indexParent = null;
234
235
        for ($i = 0; $i < $bookmarksCount; $i++) {
236
            $bookmark = $b[$i];
237
            $prevBookmark = $b[$i - 1] ?? null;
238
239
            // bookmark has a higher level (is deeper down) than the previous one
240
            if ($prevBookmark && $prevBookmark->getLevel() < $bookmark->getLevel()) {
241
                $indexParent = $i - 1;
242
            // bookmark has a lower level (is higher up) than the previous one
243
            } elseif ($prevBookmark && $prevBookmark->getLevel() > $bookmark->getLevel()) {
244
                $indexParent = $this->getLastParentId($bookmarksArray, $bookmark->getLevel()) ?? null;
245
            }
246
247
            $bookmarksArray[] = [
248
                'title' => $bookmark->getTitle(),
249
                'page' => $bookmark->getPageNumber(),
250
                '__level' => $bookmark->getLevel(),
251
                '__id' => $i,
252
                '__parent' => $indexParent,
253
            ];
254
        }
255
256
        return $bookmarksArray;
257
    }
258
259
    /**
260
     * Returns the id of the last bookmark with a lower level than the provided current level.
261
     */
262
    private function getLastParentId(array $bookmarksArray, int $currentLevel): ?int
263
    {
264
        for ($j = count($bookmarksArray) - 1; $j >= 0; $j--) {
265
            if ($bookmarksArray[$j]['__level'] < $currentLevel) {
266
                return $bookmarksArray[$j]['__id'];
267
            }
268
        }
269
270
        return null;
271
    }
272
}
273