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