xref: /plugin/fontello/helper.php (revision 1776c5c5befa8de3cc97e981444f6918b129ab85)
1*1776c5c5Sdh-tools<?php
2*1776c5c5Sdh-tools
3*1776c5c5Sdh-toolsuse dokuwiki\Extension\Plugin;
4*1776c5c5Sdh-tools
5*1776c5c5Sdh-tools/**
6*1776c5c5Sdh-tools * Shared logic for the Fontello plugin.
7*1776c5c5Sdh-tools */
8*1776c5c5Sdh-toolsclass helper_plugin_fontello extends Plugin
9*1776c5c5Sdh-tools{
10*1776c5c5Sdh-tools    protected const ACTIVE_DIR = DOKU_PLUGIN . 'fontello/assets/active';
11*1776c5c5Sdh-tools    protected const ACTIVE_CSS = self::ACTIVE_DIR . '/css/fontello.css';
12*1776c5c5Sdh-tools    protected const ACTIVE_CONFIG = self::ACTIVE_DIR . '/config.json';
13*1776c5c5Sdh-tools    protected const ACTIVE_MANIFEST = self::ACTIVE_DIR . '/manifest.json';
14*1776c5c5Sdh-tools    protected const ACTIVE_ENABLED = self::ACTIVE_DIR . '/enabled.json';
15*1776c5c5Sdh-tools    protected const ACTIVE_FONT_DIR = self::ACTIVE_DIR . '/font';
16*1776c5c5Sdh-tools
17*1776c5c5Sdh-tools    /**
18*1776c5c5Sdh-tools     * Returns true when an active package is available.
19*1776c5c5Sdh-tools     *
20*1776c5c5Sdh-tools     * @return bool
21*1776c5c5Sdh-tools     */
22*1776c5c5Sdh-tools    public function hasActivePackage()
23*1776c5c5Sdh-tools    {
24*1776c5c5Sdh-tools        return file_exists(self::ACTIVE_CONFIG) && file_exists(self::ACTIVE_CSS);
25*1776c5c5Sdh-tools    }
26*1776c5c5Sdh-tools
27*1776c5c5Sdh-tools    /**
28*1776c5c5Sdh-tools     * Returns the public URL to the generated stylesheet.
29*1776c5c5Sdh-tools     *
30*1776c5c5Sdh-tools     * @return string
31*1776c5c5Sdh-tools     */
32*1776c5c5Sdh-tools    public function getCssUrl()
33*1776c5c5Sdh-tools    {
34*1776c5c5Sdh-tools        $mtime = @filemtime(self::ACTIVE_CSS) ?: time();
35*1776c5c5Sdh-tools        return DOKU_BASE . 'lib/plugins/fontello/assets/active/css/fontello.css?v=' . $mtime;
36*1776c5c5Sdh-tools    }
37*1776c5c5Sdh-tools
38*1776c5c5Sdh-tools    /**
39*1776c5c5Sdh-tools     * Load the currently active package information.
40*1776c5c5Sdh-tools     *
41*1776c5c5Sdh-tools     * @return array|null
42*1776c5c5Sdh-tools     */
43*1776c5c5Sdh-tools    public function getPackageInfo()
44*1776c5c5Sdh-tools    {
45*1776c5c5Sdh-tools        if (!$this->hasActivePackage()) return null;
46*1776c5c5Sdh-tools
47*1776c5c5Sdh-tools        $config = $this->loadJsonFile(self::ACTIVE_CONFIG);
48*1776c5c5Sdh-tools        if ($config === null) return null;
49*1776c5c5Sdh-tools
50*1776c5c5Sdh-tools        $manifest = $this->loadJsonFile(self::ACTIVE_MANIFEST) ?: [];
51*1776c5c5Sdh-tools        $prefix = (string) ($config['css_prefix_text'] ?? 'icon-');
52*1776c5c5Sdh-tools        $icons = $this->extractIcons($config);
53*1776c5c5Sdh-tools        $enabledNames = $this->loadEnabledIconNames($icons);
54*1776c5c5Sdh-tools        $enabledMap = array_fill_keys($enabledNames, true);
55*1776c5c5Sdh-tools
56*1776c5c5Sdh-tools        foreach ($icons as $index => $icon) {
57*1776c5c5Sdh-tools            $icons[$index]['enabled'] = isset($enabledMap[$icon['name']]);
58*1776c5c5Sdh-tools        }
59*1776c5c5Sdh-tools
60*1776c5c5Sdh-tools        return [
61*1776c5c5Sdh-tools            'prefix' => $prefix,
62*1776c5c5Sdh-tools            'icons' => $icons,
63*1776c5c5Sdh-tools            'icon_count' => count($icons),
64*1776c5c5Sdh-tools            'enabled_count' => count($enabledNames),
65*1776c5c5Sdh-tools            'font_files' => $manifest['font_files'] ?? [],
66*1776c5c5Sdh-tools            'imported_at' => $manifest['imported_at'] ?? null,
67*1776c5c5Sdh-tools            'zip_name' => $manifest['zip_name'] ?? '',
68*1776c5c5Sdh-tools        ];
69*1776c5c5Sdh-tools    }
70*1776c5c5Sdh-tools
71*1776c5c5Sdh-tools    /**
72*1776c5c5Sdh-tools     * Return all active icons for toolbar or picker integrations.
73*1776c5c5Sdh-tools     *
74*1776c5c5Sdh-tools     * @return array
75*1776c5c5Sdh-tools     */
76*1776c5c5Sdh-tools    public function getActiveIcons()
77*1776c5c5Sdh-tools    {
78*1776c5c5Sdh-tools        $package = $this->getPackageInfo();
79*1776c5c5Sdh-tools        if ($package === null) return [];
80*1776c5c5Sdh-tools
81*1776c5c5Sdh-tools        return array_values(array_filter($package['icons'], static function ($icon) {
82*1776c5c5Sdh-tools            return !empty($icon['enabled']);
83*1776c5c5Sdh-tools        }));
84*1776c5c5Sdh-tools    }
85*1776c5c5Sdh-tools
86*1776c5c5Sdh-tools    /**
87*1776c5c5Sdh-tools     * Check if the given icon exists in the active package.
88*1776c5c5Sdh-tools     *
89*1776c5c5Sdh-tools     * @param string $iconName
90*1776c5c5Sdh-tools     * @return bool
91*1776c5c5Sdh-tools     */
92*1776c5c5Sdh-tools    public function hasIcon($iconName)
93*1776c5c5Sdh-tools    {
94*1776c5c5Sdh-tools        return $this->getIconClass($iconName) !== null;
95*1776c5c5Sdh-tools    }
96*1776c5c5Sdh-tools
97*1776c5c5Sdh-tools    /**
98*1776c5c5Sdh-tools     * Return the CSS class for an icon name.
99*1776c5c5Sdh-tools     *
100*1776c5c5Sdh-tools     * @param string $iconName
101*1776c5c5Sdh-tools     * @return string|null
102*1776c5c5Sdh-tools     */
103*1776c5c5Sdh-tools    public function getIconClass($iconName)
104*1776c5c5Sdh-tools    {
105*1776c5c5Sdh-tools        $package = $this->getPackageInfo();
106*1776c5c5Sdh-tools        if ($package === null) return null;
107*1776c5c5Sdh-tools
108*1776c5c5Sdh-tools        foreach ($package['icons'] as $icon) {
109*1776c5c5Sdh-tools            if ($icon['name'] === $iconName) return $icon['class'];
110*1776c5c5Sdh-tools        }
111*1776c5c5Sdh-tools
112*1776c5c5Sdh-tools        return null;
113*1776c5c5Sdh-tools    }
114*1776c5c5Sdh-tools
115*1776c5c5Sdh-tools    /**
116*1776c5c5Sdh-tools     * Parse a Fontello icon token.
117*1776c5c5Sdh-tools     *
118*1776c5c5Sdh-tools     * @param string $token
119*1776c5c5Sdh-tools     * @return array|null
120*1776c5c5Sdh-tools     */
121*1776c5c5Sdh-tools    public function parseIconToken($token)
122*1776c5c5Sdh-tools    {
123*1776c5c5Sdh-tools        if (!preg_match('/^<icon:([A-Za-z0-9_-]+)((?:\|[A-Za-z0-9_-]+)*)>$/', $token, $match)) {
124*1776c5c5Sdh-tools            return null;
125*1776c5c5Sdh-tools        }
126*1776c5c5Sdh-tools
127*1776c5c5Sdh-tools        $flags = [];
128*1776c5c5Sdh-tools        if ($match[2] !== '') {
129*1776c5c5Sdh-tools            foreach (explode('|', ltrim($match[2], '|')) as $flag) {
130*1776c5c5Sdh-tools                if ($flag === '') continue;
131*1776c5c5Sdh-tools                if (!in_array($flag, ['toc', 'notoc'], true)) return null;
132*1776c5c5Sdh-tools                $flags[$flag] = true;
133*1776c5c5Sdh-tools            }
134*1776c5c5Sdh-tools        }
135*1776c5c5Sdh-tools
136*1776c5c5Sdh-tools        return [
137*1776c5c5Sdh-tools            'raw' => $token,
138*1776c5c5Sdh-tools            'name' => $match[1],
139*1776c5c5Sdh-tools            'flags' => $flags,
140*1776c5c5Sdh-tools            'toc' => isset($flags['toc']),
141*1776c5c5Sdh-tools            'notoc' => isset($flags['notoc']),
142*1776c5c5Sdh-tools        ];
143*1776c5c5Sdh-tools    }
144*1776c5c5Sdh-tools
145*1776c5c5Sdh-tools    /**
146*1776c5c5Sdh-tools     * Return the XHTML markup for a known icon.
147*1776c5c5Sdh-tools     *
148*1776c5c5Sdh-tools     * @param string $iconName
149*1776c5c5Sdh-tools     * @return string|null
150*1776c5c5Sdh-tools     */
151*1776c5c5Sdh-tools    public function renderIconXhtml($iconName)
152*1776c5c5Sdh-tools    {
153*1776c5c5Sdh-tools        $class = $this->getIconClass($iconName);
154*1776c5c5Sdh-tools        if ($class === null) return null;
155*1776c5c5Sdh-tools
156*1776c5c5Sdh-tools        return '<span class="fontello-icon ' . hsc($class) . '" aria-hidden="true"></span>';
157*1776c5c5Sdh-tools    }
158*1776c5c5Sdh-tools
159*1776c5c5Sdh-tools    /**
160*1776c5c5Sdh-tools     * Decide whether a parsed icon token should remain visible in the TOC.
161*1776c5c5Sdh-tools     *
162*1776c5c5Sdh-tools     * @param array $token
163*1776c5c5Sdh-tools     * @return bool
164*1776c5c5Sdh-tools     */
165*1776c5c5Sdh-tools    public function iconTokenShowsInToc(array $token)
166*1776c5c5Sdh-tools    {
167*1776c5c5Sdh-tools        if (!empty($token['notoc'])) return false;
168*1776c5c5Sdh-tools        if (!empty($token['toc'])) return true;
169*1776c5c5Sdh-tools
170*1776c5c5Sdh-tools        return (bool) $this->getConf('showInToc');
171*1776c5c5Sdh-tools    }
172*1776c5c5Sdh-tools
173*1776c5c5Sdh-tools    /**
174*1776c5c5Sdh-tools     * Persist which icons should be offered in toolbar or picker integrations.
175*1776c5c5Sdh-tools     *
176*1776c5c5Sdh-tools     * This does not affect inline rendering of imported icons.
177*1776c5c5Sdh-tools     *
178*1776c5c5Sdh-tools     * @param array $iconNames
179*1776c5c5Sdh-tools     * @return int
180*1776c5c5Sdh-tools     */
181*1776c5c5Sdh-tools    public function saveEnabledIconNames(array $iconNames)
182*1776c5c5Sdh-tools    {
183*1776c5c5Sdh-tools        $package = $this->getPackageInfo();
184*1776c5c5Sdh-tools        if ($package === null) {
185*1776c5c5Sdh-tools            throw new RuntimeException($this->getLang('err_no_package'));
186*1776c5c5Sdh-tools        }
187*1776c5c5Sdh-tools
188*1776c5c5Sdh-tools        $requested = array_fill_keys(array_map('strval', $iconNames), true);
189*1776c5c5Sdh-tools        $enabled = [];
190*1776c5c5Sdh-tools
191*1776c5c5Sdh-tools        foreach ($package['icons'] as $icon) {
192*1776c5c5Sdh-tools            if (isset($requested[$icon['name']])) {
193*1776c5c5Sdh-tools                $enabled[] = $icon['name'];
194*1776c5c5Sdh-tools            }
195*1776c5c5Sdh-tools        }
196*1776c5c5Sdh-tools
197*1776c5c5Sdh-tools        io_makeFileDir(self::ACTIVE_ENABLED);
198*1776c5c5Sdh-tools        file_put_contents(self::ACTIVE_ENABLED, json_encode($enabled, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
199*1776c5c5Sdh-tools
200*1776c5c5Sdh-tools        return count($enabled);
201*1776c5c5Sdh-tools    }
202*1776c5c5Sdh-tools
203*1776c5c5Sdh-tools    /**
204*1776c5c5Sdh-tools     * Import a Fontello ZIP package.
205*1776c5c5Sdh-tools     *
206*1776c5c5Sdh-tools     * @param array $upload
207*1776c5c5Sdh-tools     * @return array
208*1776c5c5Sdh-tools     */
209*1776c5c5Sdh-tools    public function importPackage(array $upload)
210*1776c5c5Sdh-tools    {
211*1776c5c5Sdh-tools        if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
212*1776c5c5Sdh-tools            throw new RuntimeException($this->uploadErrorMessage((int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE)));
213*1776c5c5Sdh-tools        }
214*1776c5c5Sdh-tools
215*1776c5c5Sdh-tools        $tmpName = (string) ($upload['tmp_name'] ?? '');
216*1776c5c5Sdh-tools        if ($tmpName === '' || !is_uploaded_file($tmpName) && !file_exists($tmpName)) {
217*1776c5c5Sdh-tools            throw new RuntimeException($this->getLang('err_upload_missing'));
218*1776c5c5Sdh-tools        }
219*1776c5c5Sdh-tools
220*1776c5c5Sdh-tools        $archive = $this->openArchive($tmpName);
221*1776c5c5Sdh-tools        $map = $archive['map'];
222*1776c5c5Sdh-tools        $configEntry = $this->findRequiredEntry($map, 'config.json', $this->getLang('err_missing_config'));
223*1776c5c5Sdh-tools        $this->findRequiredEntry($map, 'css/fontello.css', $this->getLang('err_missing_css'));
224*1776c5c5Sdh-tools        $fontEntries = $this->findFontEntries($map);
225*1776c5c5Sdh-tools        if ($fontEntries === []) {
226*1776c5c5Sdh-tools            $this->closeArchive($archive);
227*1776c5c5Sdh-tools            throw new RuntimeException($this->getLang('err_missing_fonts'));
228*1776c5c5Sdh-tools        }
229*1776c5c5Sdh-tools
230*1776c5c5Sdh-tools        $configJson = $this->readArchiveEntry($archive, $configEntry);
231*1776c5c5Sdh-tools        $config = json_decode($configJson, true);
232*1776c5c5Sdh-tools        if (!is_array($config) || !isset($config['glyphs']) || !is_array($config['glyphs'])) {
233*1776c5c5Sdh-tools            $this->closeArchive($archive);
234*1776c5c5Sdh-tools            throw new RuntimeException($this->getLang('err_invalid_config'));
235*1776c5c5Sdh-tools        }
236*1776c5c5Sdh-tools
237*1776c5c5Sdh-tools        $icons = $this->extractIcons($config);
238*1776c5c5Sdh-tools        if ($icons === []) {
239*1776c5c5Sdh-tools            $this->closeArchive($archive);
240*1776c5c5Sdh-tools            throw new RuntimeException($this->getLang('err_no_icons'));
241*1776c5c5Sdh-tools        }
242*1776c5c5Sdh-tools
243*1776c5c5Sdh-tools        $fontFiles = [];
244*1776c5c5Sdh-tools        $fontContents = [];
245*1776c5c5Sdh-tools        foreach ($fontEntries as $relative => $original) {
246*1776c5c5Sdh-tools            $basename = basename($relative);
247*1776c5c5Sdh-tools            $fontFiles[] = $basename;
248*1776c5c5Sdh-tools            $fontContents[$basename] = $this->readArchiveEntry($archive, $original);
249*1776c5c5Sdh-tools        }
250*1776c5c5Sdh-tools
251*1776c5c5Sdh-tools        $manifest = [
252*1776c5c5Sdh-tools            'zip_name' => (string) ($upload['name'] ?? ''),
253*1776c5c5Sdh-tools            'imported_at' => date('c'),
254*1776c5c5Sdh-tools            'prefix' => (string) ($config['css_prefix_text'] ?? 'icon-'),
255*1776c5c5Sdh-tools            'icon_count' => count($icons),
256*1776c5c5Sdh-tools            'font_files' => array_values($fontFiles),
257*1776c5c5Sdh-tools        ];
258*1776c5c5Sdh-tools
259*1776c5c5Sdh-tools        $css = $this->buildCss($config, $fontFiles);
260*1776c5c5Sdh-tools
261*1776c5c5Sdh-tools        $this->closeArchive($archive);
262*1776c5c5Sdh-tools        $this->resetActiveDirectory();
263*1776c5c5Sdh-tools
264*1776c5c5Sdh-tools        foreach ($fontContents as $basename => $content) {
265*1776c5c5Sdh-tools            $target = self::ACTIVE_FONT_DIR . '/' . $basename;
266*1776c5c5Sdh-tools            io_makeFileDir($target);
267*1776c5c5Sdh-tools            file_put_contents($target, $content);
268*1776c5c5Sdh-tools        }
269*1776c5c5Sdh-tools
270*1776c5c5Sdh-tools        io_makeFileDir(self::ACTIVE_CONFIG);
271*1776c5c5Sdh-tools        file_put_contents(self::ACTIVE_CONFIG, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
272*1776c5c5Sdh-tools        io_makeFileDir(self::ACTIVE_MANIFEST);
273*1776c5c5Sdh-tools        file_put_contents(self::ACTIVE_MANIFEST, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
274*1776c5c5Sdh-tools        io_makeFileDir(self::ACTIVE_ENABLED);
275*1776c5c5Sdh-tools        file_put_contents(self::ACTIVE_ENABLED, json_encode(array_column($icons, 'name'), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
276*1776c5c5Sdh-tools        io_makeFileDir(self::ACTIVE_CSS);
277*1776c5c5Sdh-tools        file_put_contents(self::ACTIVE_CSS, $css);
278*1776c5c5Sdh-tools        $this->purgeDokuWikiCaches();
279*1776c5c5Sdh-tools
280*1776c5c5Sdh-tools        return $this->getPackageInfo() ?: $manifest;
281*1776c5c5Sdh-tools    }
282*1776c5c5Sdh-tools
283*1776c5c5Sdh-tools    /**
284*1776c5c5Sdh-tools     * Extract icon metadata from the package config.
285*1776c5c5Sdh-tools     *
286*1776c5c5Sdh-tools     * @param array $config
287*1776c5c5Sdh-tools     * @return array
288*1776c5c5Sdh-tools     */
289*1776c5c5Sdh-tools    protected function extractIcons(array $config)
290*1776c5c5Sdh-tools    {
291*1776c5c5Sdh-tools        $prefix = (string) ($config['css_prefix_text'] ?? 'icon-');
292*1776c5c5Sdh-tools        $icons = [];
293*1776c5c5Sdh-tools
294*1776c5c5Sdh-tools        foreach ($config['glyphs'] ?? [] as $glyph) {
295*1776c5c5Sdh-tools            $name = trim((string) ($glyph['css'] ?? ''));
296*1776c5c5Sdh-tools            $code = $glyph['code'] ?? null;
297*1776c5c5Sdh-tools            if ($name === '' || !is_numeric($code)) continue;
298*1776c5c5Sdh-tools
299*1776c5c5Sdh-tools            $icon = [
300*1776c5c5Sdh-tools                'name' => $name,
301*1776c5c5Sdh-tools                'class' => $prefix . $name,
302*1776c5c5Sdh-tools                'code' => strtolower(dechex((int) $code)),
303*1776c5c5Sdh-tools            ];
304*1776c5c5Sdh-tools
305*1776c5c5Sdh-tools            // Fontello packages may contain duplicate css names; keep the last one.
306*1776c5c5Sdh-tools            $icons[$icon['class']] = $icon;
307*1776c5c5Sdh-tools        }
308*1776c5c5Sdh-tools
309*1776c5c5Sdh-tools        $icons = array_values($icons);
310*1776c5c5Sdh-tools
311*1776c5c5Sdh-tools        usort($icons, static function ($left, $right) {
312*1776c5c5Sdh-tools            return strcmp($left['name'], $right['name']);
313*1776c5c5Sdh-tools        });
314*1776c5c5Sdh-tools
315*1776c5c5Sdh-tools        return $icons;
316*1776c5c5Sdh-tools    }
317*1776c5c5Sdh-tools
318*1776c5c5Sdh-tools    /**
319*1776c5c5Sdh-tools     * Load enabled icon names. Missing or invalid state means all icons are enabled.
320*1776c5c5Sdh-tools     *
321*1776c5c5Sdh-tools     * @param array $icons
322*1776c5c5Sdh-tools     * @return array
323*1776c5c5Sdh-tools     */
324*1776c5c5Sdh-tools    protected function loadEnabledIconNames(array $icons)
325*1776c5c5Sdh-tools    {
326*1776c5c5Sdh-tools        $allNames = array_column($icons, 'name');
327*1776c5c5Sdh-tools        $enabled = $this->loadJsonFile(self::ACTIVE_ENABLED);
328*1776c5c5Sdh-tools        if ($enabled === null || array_values($enabled) !== $enabled) return $allNames;
329*1776c5c5Sdh-tools
330*1776c5c5Sdh-tools        $known = array_fill_keys($allNames, true);
331*1776c5c5Sdh-tools        $names = [];
332*1776c5c5Sdh-tools
333*1776c5c5Sdh-tools        foreach ($enabled as $name) {
334*1776c5c5Sdh-tools            $name = (string) $name;
335*1776c5c5Sdh-tools            if (isset($known[$name])) {
336*1776c5c5Sdh-tools                $names[$name] = true;
337*1776c5c5Sdh-tools            }
338*1776c5c5Sdh-tools        }
339*1776c5c5Sdh-tools
340*1776c5c5Sdh-tools        return array_keys($names);
341*1776c5c5Sdh-tools    }
342*1776c5c5Sdh-tools
343*1776c5c5Sdh-tools    /**
344*1776c5c5Sdh-tools     * Build a normalized entry map for the archive.
345*1776c5c5Sdh-tools     *
346*1776c5c5Sdh-tools     * @param ZipArchive $zip
347*1776c5c5Sdh-tools     * @return array
348*1776c5c5Sdh-tools     */
349*1776c5c5Sdh-tools    protected function buildArchiveMap(array $originalNames)
350*1776c5c5Sdh-tools    {
351*1776c5c5Sdh-tools        $roots = [];
352*1776c5c5Sdh-tools        $hasTopLevelFiles = false;
353*1776c5c5Sdh-tools
354*1776c5c5Sdh-tools        foreach ($originalNames as $name) {
355*1776c5c5Sdh-tools            $name = str_replace('\\', '/', $name);
356*1776c5c5Sdh-tools            if (substr($name, -1) === '/') continue;
357*1776c5c5Sdh-tools            $name = trim($name, '/');
358*1776c5c5Sdh-tools            if ($name === '') continue;
359*1776c5c5Sdh-tools            $parts = explode('/', $name, 2);
360*1776c5c5Sdh-tools            $roots[$parts[0]] = true;
361*1776c5c5Sdh-tools            if (count($parts) === 1) $hasTopLevelFiles = true;
362*1776c5c5Sdh-tools        }
363*1776c5c5Sdh-tools
364*1776c5c5Sdh-tools        $stripRoot = count($roots) === 1 && !$hasTopLevelFiles;
365*1776c5c5Sdh-tools        $map = [];
366*1776c5c5Sdh-tools
367*1776c5c5Sdh-tools        foreach ($originalNames as $name) {
368*1776c5c5Sdh-tools            $name = str_replace('\\', '/', $name);
369*1776c5c5Sdh-tools            if (substr($name, -1) === '/') continue;
370*1776c5c5Sdh-tools            $name = trim($name, '/');
371*1776c5c5Sdh-tools            if ($name === '') continue;
372*1776c5c5Sdh-tools            $relative = $name;
373*1776c5c5Sdh-tools            if ($stripRoot) {
374*1776c5c5Sdh-tools                $relative = explode('/', $name, 2)[1] ?? '';
375*1776c5c5Sdh-tools            }
376*1776c5c5Sdh-tools            if ($relative === '' || substr($relative, -1) === '/') continue;
377*1776c5c5Sdh-tools            $map[$relative] = $name;
378*1776c5c5Sdh-tools        }
379*1776c5c5Sdh-tools
380*1776c5c5Sdh-tools        return $map;
381*1776c5c5Sdh-tools    }
382*1776c5c5Sdh-tools
383*1776c5c5Sdh-tools    /**
384*1776c5c5Sdh-tools     * Find a required archive entry.
385*1776c5c5Sdh-tools     *
386*1776c5c5Sdh-tools     * @param array $map
387*1776c5c5Sdh-tools     * @param string $relativePath
388*1776c5c5Sdh-tools     * @param string $errorMessage
389*1776c5c5Sdh-tools     * @return string
390*1776c5c5Sdh-tools     */
391*1776c5c5Sdh-tools    protected function findRequiredEntry(array $map, $relativePath, $errorMessage)
392*1776c5c5Sdh-tools    {
393*1776c5c5Sdh-tools        if (!isset($map[$relativePath])) {
394*1776c5c5Sdh-tools            throw new RuntimeException($errorMessage);
395*1776c5c5Sdh-tools        }
396*1776c5c5Sdh-tools
397*1776c5c5Sdh-tools        return $map[$relativePath];
398*1776c5c5Sdh-tools    }
399*1776c5c5Sdh-tools
400*1776c5c5Sdh-tools    /**
401*1776c5c5Sdh-tools     * Return all supported font entries.
402*1776c5c5Sdh-tools     *
403*1776c5c5Sdh-tools     * @param array $map
404*1776c5c5Sdh-tools     * @return array
405*1776c5c5Sdh-tools     */
406*1776c5c5Sdh-tools    protected function findFontEntries(array $map)
407*1776c5c5Sdh-tools    {
408*1776c5c5Sdh-tools        $fonts = [];
409*1776c5c5Sdh-tools        foreach ($map as $relative => $original) {
410*1776c5c5Sdh-tools            if (!str_starts_with($relative, 'font/')) continue;
411*1776c5c5Sdh-tools            $extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
412*1776c5c5Sdh-tools            if (!in_array($extension, ['eot', 'svg', 'ttf', 'woff', 'woff2'], true)) continue;
413*1776c5c5Sdh-tools            $fonts[$relative] = $original;
414*1776c5c5Sdh-tools        }
415*1776c5c5Sdh-tools
416*1776c5c5Sdh-tools        return $fonts;
417*1776c5c5Sdh-tools    }
418*1776c5c5Sdh-tools
419*1776c5c5Sdh-tools    /**
420*1776c5c5Sdh-tools     * Read a single entry from the archive.
421*1776c5c5Sdh-tools     *
422*1776c5c5Sdh-tools     * @param ZipArchive $zip
423*1776c5c5Sdh-tools     * @param string $entryName
424*1776c5c5Sdh-tools     * @return string
425*1776c5c5Sdh-tools     */
426*1776c5c5Sdh-tools    protected function openArchive($tmpName)
427*1776c5c5Sdh-tools    {
428*1776c5c5Sdh-tools        if (class_exists('ZipArchive')) {
429*1776c5c5Sdh-tools            $zip = new ZipArchive();
430*1776c5c5Sdh-tools            if ($zip->open($tmpName) === true) {
431*1776c5c5Sdh-tools                $names = [];
432*1776c5c5Sdh-tools                for ($i = 0; $i < $zip->numFiles; $i++) {
433*1776c5c5Sdh-tools                    $names[] = $zip->getNameIndex($i);
434*1776c5c5Sdh-tools                }
435*1776c5c5Sdh-tools                return [
436*1776c5c5Sdh-tools                    'type' => 'ziparchive',
437*1776c5c5Sdh-tools                    'handle' => $zip,
438*1776c5c5Sdh-tools                    'map' => $this->buildArchiveMap($names),
439*1776c5c5Sdh-tools                ];
440*1776c5c5Sdh-tools            }
441*1776c5c5Sdh-tools        }
442*1776c5c5Sdh-tools
443*1776c5c5Sdh-tools        if ($this->canUseSystemZipTools()) {
444*1776c5c5Sdh-tools            return [
445*1776c5c5Sdh-tools                'type' => 'system',
446*1776c5c5Sdh-tools                'path' => $tmpName,
447*1776c5c5Sdh-tools                'map' => $this->buildArchiveMap($this->listArchiveEntries($tmpName)),
448*1776c5c5Sdh-tools            ];
449*1776c5c5Sdh-tools        }
450*1776c5c5Sdh-tools
451*1776c5c5Sdh-tools        throw new RuntimeException($this->getLang('err_zip_support'));
452*1776c5c5Sdh-tools    }
453*1776c5c5Sdh-tools
454*1776c5c5Sdh-tools    /**
455*1776c5c5Sdh-tools     * Close an open archive handle when needed.
456*1776c5c5Sdh-tools     *
457*1776c5c5Sdh-tools     * @param array $archive
458*1776c5c5Sdh-tools     * @return void
459*1776c5c5Sdh-tools     */
460*1776c5c5Sdh-tools    protected function closeArchive(array $archive)
461*1776c5c5Sdh-tools    {
462*1776c5c5Sdh-tools        if (($archive['type'] ?? '') === 'ziparchive' && isset($archive['handle'])) {
463*1776c5c5Sdh-tools            $archive['handle']->close();
464*1776c5c5Sdh-tools        }
465*1776c5c5Sdh-tools    }
466*1776c5c5Sdh-tools
467*1776c5c5Sdh-tools    /**
468*1776c5c5Sdh-tools     * Read a single entry from the archive.
469*1776c5c5Sdh-tools     *
470*1776c5c5Sdh-tools     * @param array $archive
471*1776c5c5Sdh-tools     * @param string $entryName
472*1776c5c5Sdh-tools     * @return string
473*1776c5c5Sdh-tools     */
474*1776c5c5Sdh-tools    protected function readArchiveEntry(array $archive, $entryName)
475*1776c5c5Sdh-tools    {
476*1776c5c5Sdh-tools        if (($archive['type'] ?? '') === 'ziparchive') {
477*1776c5c5Sdh-tools            $content = $archive['handle']->getFromName($entryName);
478*1776c5c5Sdh-tools            if ($content === false) {
479*1776c5c5Sdh-tools                throw new RuntimeException(sprintf($this->getLang('err_archive_read'), $entryName));
480*1776c5c5Sdh-tools            }
481*1776c5c5Sdh-tools            return $content;
482*1776c5c5Sdh-tools        }
483*1776c5c5Sdh-tools
484*1776c5c5Sdh-tools        $command = 'unzip -p ' . escapeshellarg($archive['path']) . ' ' . escapeshellarg($entryName);
485*1776c5c5Sdh-tools        $descriptorSpec = [
486*1776c5c5Sdh-tools            1 => ['pipe', 'w'],
487*1776c5c5Sdh-tools            2 => ['pipe', 'w'],
488*1776c5c5Sdh-tools        ];
489*1776c5c5Sdh-tools        $process = proc_open($command, $descriptorSpec, $pipes);
490*1776c5c5Sdh-tools        if (!is_resource($process)) {
491*1776c5c5Sdh-tools            throw new RuntimeException($this->getLang('err_zip_open'));
492*1776c5c5Sdh-tools        }
493*1776c5c5Sdh-tools
494*1776c5c5Sdh-tools        $content = stream_get_contents($pipes[1]);
495*1776c5c5Sdh-tools        $error = stream_get_contents($pipes[2]);
496*1776c5c5Sdh-tools        fclose($pipes[1]);
497*1776c5c5Sdh-tools        fclose($pipes[2]);
498*1776c5c5Sdh-tools        $exitCode = proc_close($process);
499*1776c5c5Sdh-tools
500*1776c5c5Sdh-tools        if ($exitCode !== 0) {
501*1776c5c5Sdh-tools            throw new RuntimeException(trim($error) !== '' ? trim($error) : sprintf($this->getLang('err_archive_read'), $entryName));
502*1776c5c5Sdh-tools        }
503*1776c5c5Sdh-tools
504*1776c5c5Sdh-tools        return $content;
505*1776c5c5Sdh-tools    }
506*1776c5c5Sdh-tools
507*1776c5c5Sdh-tools    /**
508*1776c5c5Sdh-tools     * List archive entries using zipinfo.
509*1776c5c5Sdh-tools     *
510*1776c5c5Sdh-tools     * @param string $tmpName
511*1776c5c5Sdh-tools     * @return array
512*1776c5c5Sdh-tools     */
513*1776c5c5Sdh-tools    protected function listArchiveEntries($tmpName)
514*1776c5c5Sdh-tools    {
515*1776c5c5Sdh-tools        $output = [];
516*1776c5c5Sdh-tools        $exitCode = 0;
517*1776c5c5Sdh-tools        exec('zipinfo -1 ' . escapeshellarg($tmpName), $output, $exitCode);
518*1776c5c5Sdh-tools        if ($exitCode !== 0) {
519*1776c5c5Sdh-tools            throw new RuntimeException($this->getLang('err_zip_open'));
520*1776c5c5Sdh-tools        }
521*1776c5c5Sdh-tools
522*1776c5c5Sdh-tools        return $output;
523*1776c5c5Sdh-tools    }
524*1776c5c5Sdh-tools
525*1776c5c5Sdh-tools    /**
526*1776c5c5Sdh-tools     * Check whether system ZIP tools can be used as a fallback.
527*1776c5c5Sdh-tools     *
528*1776c5c5Sdh-tools     * @return bool
529*1776c5c5Sdh-tools     */
530*1776c5c5Sdh-tools    protected function canUseSystemZipTools()
531*1776c5c5Sdh-tools    {
532*1776c5c5Sdh-tools        if (!function_exists('exec') || !function_exists('proc_open')) return false;
533*1776c5c5Sdh-tools
534*1776c5c5Sdh-tools        return $this->commandExists('unzip') && $this->commandExists('zipinfo');
535*1776c5c5Sdh-tools    }
536*1776c5c5Sdh-tools
537*1776c5c5Sdh-tools    /**
538*1776c5c5Sdh-tools     * Check whether a shell command exists.
539*1776c5c5Sdh-tools     *
540*1776c5c5Sdh-tools     * @param string $command
541*1776c5c5Sdh-tools     * @return bool
542*1776c5c5Sdh-tools     */
543*1776c5c5Sdh-tools    protected function commandExists($command)
544*1776c5c5Sdh-tools    {
545*1776c5c5Sdh-tools        $output = [];
546*1776c5c5Sdh-tools        $exitCode = 0;
547*1776c5c5Sdh-tools        exec('command -v ' . escapeshellarg($command), $output, $exitCode);
548*1776c5c5Sdh-tools        return $exitCode === 0 && !empty($output);
549*1776c5c5Sdh-tools    }
550*1776c5c5Sdh-tools
551*1776c5c5Sdh-tools    /**
552*1776c5c5Sdh-tools     * Remove the current active package and recreate the base directory.
553*1776c5c5Sdh-tools     *
554*1776c5c5Sdh-tools     * @return void
555*1776c5c5Sdh-tools     */
556*1776c5c5Sdh-tools    protected function resetActiveDirectory()
557*1776c5c5Sdh-tools    {
558*1776c5c5Sdh-tools        if (file_exists(self::ACTIVE_DIR)) {
559*1776c5c5Sdh-tools            io_rmdir(self::ACTIVE_DIR, true);
560*1776c5c5Sdh-tools        }
561*1776c5c5Sdh-tools
562*1776c5c5Sdh-tools        io_mkdir_p(self::ACTIVE_DIR);
563*1776c5c5Sdh-tools    }
564*1776c5c5Sdh-tools
565*1776c5c5Sdh-tools    /**
566*1776c5c5Sdh-tools     * Expire DokuWiki render and asset caches after package changes.
567*1776c5c5Sdh-tools     *
568*1776c5c5Sdh-tools     * DokuWiki's extension manager uses the same local.php touch pattern.
569*1776c5c5Sdh-tools     *
570*1776c5c5Sdh-tools     * @return void
571*1776c5c5Sdh-tools     */
572*1776c5c5Sdh-tools    protected function purgeDokuWikiCaches()
573*1776c5c5Sdh-tools    {
574*1776c5c5Sdh-tools        global $config_cascade;
575*1776c5c5Sdh-tools
576*1776c5c5Sdh-tools        $localConfig = reset($config_cascade['main']['local']);
577*1776c5c5Sdh-tools        if ($localConfig) {
578*1776c5c5Sdh-tools            @touch($localConfig);
579*1776c5c5Sdh-tools        }
580*1776c5c5Sdh-tools    }
581*1776c5c5Sdh-tools
582*1776c5c5Sdh-tools    /**
583*1776c5c5Sdh-tools     * Generate the public stylesheet from config data.
584*1776c5c5Sdh-tools     *
585*1776c5c5Sdh-tools     * @param array $config
586*1776c5c5Sdh-tools     * @param array $fontFiles
587*1776c5c5Sdh-tools     * @return string
588*1776c5c5Sdh-tools     */
589*1776c5c5Sdh-tools    protected function buildCss(array $config, array $fontFiles)
590*1776c5c5Sdh-tools    {
591*1776c5c5Sdh-tools        $family = 'fontello';
592*1776c5c5Sdh-tools        $icons = $this->extractIcons($config);
593*1776c5c5Sdh-tools        $sources = [];
594*1776c5c5Sdh-tools
595*1776c5c5Sdh-tools        $formatMap = [
596*1776c5c5Sdh-tools            'eot' => 'embedded-opentype',
597*1776c5c5Sdh-tools            'woff2' => 'woff2',
598*1776c5c5Sdh-tools            'woff' => 'woff',
599*1776c5c5Sdh-tools            'ttf' => 'truetype',
600*1776c5c5Sdh-tools            'svg' => 'svg',
601*1776c5c5Sdh-tools        ];
602*1776c5c5Sdh-tools        $priority = ['eot', 'woff2', 'woff', 'ttf', 'svg'];
603*1776c5c5Sdh-tools
604*1776c5c5Sdh-tools        foreach ($priority as $extension) {
605*1776c5c5Sdh-tools            foreach ($fontFiles as $fontFile) {
606*1776c5c5Sdh-tools                if (strtolower(pathinfo($fontFile, PATHINFO_EXTENSION)) !== $extension) continue;
607*1776c5c5Sdh-tools                $url = "../font/$fontFile";
608*1776c5c5Sdh-tools                if ($extension === 'svg') {
609*1776c5c5Sdh-tools                    $url .= '#' . $family;
610*1776c5c5Sdh-tools                }
611*1776c5c5Sdh-tools                $sources[] = "url('$url') format('" . $formatMap[$extension] . "')";
612*1776c5c5Sdh-tools            }
613*1776c5c5Sdh-tools        }
614*1776c5c5Sdh-tools
615*1776c5c5Sdh-tools        $css = "@font-face {\n";
616*1776c5c5Sdh-tools        $css .= "  font-family: '$family';\n";
617*1776c5c5Sdh-tools        $css .= '  src: ' . implode(",\n       ", $sources) . ";\n";
618*1776c5c5Sdh-tools        $css .= "  font-weight: normal;\n";
619*1776c5c5Sdh-tools        $css .= "  font-style: normal;\n";
620*1776c5c5Sdh-tools        $css .= "}\n\n";
621*1776c5c5Sdh-tools        $css .= ".fontello-icon {\n";
622*1776c5c5Sdh-tools        $css .= "  display: inline-block;\n";
623*1776c5c5Sdh-tools        $css .= "}\n\n";
624*1776c5c5Sdh-tools        $css .= ".fontello-icon:before {\n";
625*1776c5c5Sdh-tools        $css .= "  font-family: '$family';\n";
626*1776c5c5Sdh-tools        $css .= "  font-style: normal;\n";
627*1776c5c5Sdh-tools        $css .= "  font-weight: normal;\n";
628*1776c5c5Sdh-tools        $css .= "  speak: never;\n";
629*1776c5c5Sdh-tools        $css .= "  display: inline-block;\n";
630*1776c5c5Sdh-tools        $css .= "  text-decoration: inherit;\n";
631*1776c5c5Sdh-tools        $css .= "  width: 1em;\n";
632*1776c5c5Sdh-tools        $css .= "  margin-right: .2em;\n";
633*1776c5c5Sdh-tools        $css .= "  text-align: center;\n";
634*1776c5c5Sdh-tools        $css .= "  font-variant: normal;\n";
635*1776c5c5Sdh-tools        $css .= "  text-transform: none;\n";
636*1776c5c5Sdh-tools        $css .= "  line-height: 1em;\n";
637*1776c5c5Sdh-tools        $css .= "  margin-left: .2em;\n";
638*1776c5c5Sdh-tools        $css .= "  -webkit-font-smoothing: antialiased;\n";
639*1776c5c5Sdh-tools        $css .= "  -moz-osx-font-smoothing: grayscale;\n";
640*1776c5c5Sdh-tools        $css .= "}\n\n";
641*1776c5c5Sdh-tools
642*1776c5c5Sdh-tools        foreach ($icons as $icon) {
643*1776c5c5Sdh-tools            $css .= '.fontello-icon.' . $icon['class'] . ':before { content: "\\' . $icon['code'] . "\"; }\n";
644*1776c5c5Sdh-tools        }
645*1776c5c5Sdh-tools
646*1776c5c5Sdh-tools        return $css;
647*1776c5c5Sdh-tools    }
648*1776c5c5Sdh-tools
649*1776c5c5Sdh-tools    /**
650*1776c5c5Sdh-tools     * Load a JSON file from disk.
651*1776c5c5Sdh-tools     *
652*1776c5c5Sdh-tools     * @param string $file
653*1776c5c5Sdh-tools     * @return array|null
654*1776c5c5Sdh-tools     */
655*1776c5c5Sdh-tools    protected function loadJsonFile($file)
656*1776c5c5Sdh-tools    {
657*1776c5c5Sdh-tools        if (!file_exists($file)) return null;
658*1776c5c5Sdh-tools
659*1776c5c5Sdh-tools        $json = file_get_contents($file);
660*1776c5c5Sdh-tools        if ($json === false) return null;
661*1776c5c5Sdh-tools
662*1776c5c5Sdh-tools        $decoded = json_decode($json, true);
663*1776c5c5Sdh-tools        return is_array($decoded) ? $decoded : null;
664*1776c5c5Sdh-tools    }
665*1776c5c5Sdh-tools
666*1776c5c5Sdh-tools    /**
667*1776c5c5Sdh-tools     * Translate PHP upload error codes.
668*1776c5c5Sdh-tools     *
669*1776c5c5Sdh-tools     * @param int $error
670*1776c5c5Sdh-tools     * @return string
671*1776c5c5Sdh-tools     */
672*1776c5c5Sdh-tools    protected function uploadErrorMessage($error)
673*1776c5c5Sdh-tools    {
674*1776c5c5Sdh-tools        return match ($error) {
675*1776c5c5Sdh-tools            UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => $this->getLang('err_upload_too_large'),
676*1776c5c5Sdh-tools            UPLOAD_ERR_PARTIAL => $this->getLang('err_upload_partial'),
677*1776c5c5Sdh-tools            UPLOAD_ERR_NO_FILE => $this->getLang('err_upload_missing'),
678*1776c5c5Sdh-tools            default => $this->getLang('err_upload_generic'),
679*1776c5c5Sdh-tools        };
680*1776c5c5Sdh-tools    }
681*1776c5c5Sdh-tools}
682