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