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 file_put_contents(self::ACTIVE_ENABLED, json_encode(array_column($icons, 'name'), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 276 io_makeFileDir(self::ACTIVE_CSS); 277 file_put_contents(self::ACTIVE_CSS, $css); 278 $this->purgeDokuWikiCaches(); 279 280 return $this->getPackageInfo() ?: $manifest; 281 } 282 283 /** 284 * Extract icon metadata from the package config. 285 * 286 * @param array $config 287 * @return array 288 */ 289 protected function extractIcons(array $config) 290 { 291 $prefix = (string) ($config['css_prefix_text'] ?? 'icon-'); 292 $icons = []; 293 294 foreach ($config['glyphs'] ?? [] as $glyph) { 295 $name = trim((string) ($glyph['css'] ?? '')); 296 $code = $glyph['code'] ?? null; 297 if ($name === '' || !is_numeric($code)) continue; 298 299 $icon = [ 300 'name' => $name, 301 'class' => $prefix . $name, 302 'code' => strtolower(dechex((int) $code)), 303 ]; 304 305 // Fontello packages may contain duplicate css names; keep the last one. 306 $icons[$icon['class']] = $icon; 307 } 308 309 $icons = array_values($icons); 310 311 usort($icons, static function ($left, $right) { 312 return strcmp($left['name'], $right['name']); 313 }); 314 315 return $icons; 316 } 317 318 /** 319 * Load enabled icon names. Missing or invalid state means all icons are enabled. 320 * 321 * @param array $icons 322 * @return array 323 */ 324 protected function loadEnabledIconNames(array $icons) 325 { 326 $allNames = array_column($icons, 'name'); 327 $enabled = $this->loadJsonFile(self::ACTIVE_ENABLED); 328 if ($enabled === null || array_values($enabled) !== $enabled) return $allNames; 329 330 $known = array_fill_keys($allNames, true); 331 $names = []; 332 333 foreach ($enabled as $name) { 334 $name = (string) $name; 335 if (isset($known[$name])) { 336 $names[$name] = true; 337 } 338 } 339 340 return array_keys($names); 341 } 342 343 /** 344 * Build a normalized entry map for the archive. 345 * 346 * @param ZipArchive $zip 347 * @return array 348 */ 349 protected function buildArchiveMap(array $originalNames) 350 { 351 $roots = []; 352 $hasTopLevelFiles = false; 353 354 foreach ($originalNames as $name) { 355 $name = str_replace('\\', '/', $name); 356 if (substr($name, -1) === '/') continue; 357 $name = trim($name, '/'); 358 if ($name === '') continue; 359 $parts = explode('/', $name, 2); 360 $roots[$parts[0]] = true; 361 if (count($parts) === 1) $hasTopLevelFiles = true; 362 } 363 364 $stripRoot = count($roots) === 1 && !$hasTopLevelFiles; 365 $map = []; 366 367 foreach ($originalNames as $name) { 368 $name = str_replace('\\', '/', $name); 369 if (substr($name, -1) === '/') continue; 370 $name = trim($name, '/'); 371 if ($name === '') continue; 372 $relative = $name; 373 if ($stripRoot) { 374 $relative = explode('/', $name, 2)[1] ?? ''; 375 } 376 if ($relative === '' || substr($relative, -1) === '/') continue; 377 $map[$relative] = $name; 378 } 379 380 return $map; 381 } 382 383 /** 384 * Find a required archive entry. 385 * 386 * @param array $map 387 * @param string $relativePath 388 * @param string $errorMessage 389 * @return string 390 */ 391 protected function findRequiredEntry(array $map, $relativePath, $errorMessage) 392 { 393 if (!isset($map[$relativePath])) { 394 throw new RuntimeException($errorMessage); 395 } 396 397 return $map[$relativePath]; 398 } 399 400 /** 401 * Return all supported font entries. 402 * 403 * @param array $map 404 * @return array 405 */ 406 protected function findFontEntries(array $map) 407 { 408 $fonts = []; 409 foreach ($map as $relative => $original) { 410 if (!str_starts_with($relative, 'font/')) continue; 411 $extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION)); 412 if (!in_array($extension, ['eot', 'svg', 'ttf', 'woff', 'woff2'], true)) continue; 413 $fonts[$relative] = $original; 414 } 415 416 return $fonts; 417 } 418 419 /** 420 * Read a single entry from the archive. 421 * 422 * @param ZipArchive $zip 423 * @param string $entryName 424 * @return string 425 */ 426 protected function openArchive($tmpName) 427 { 428 if (class_exists('ZipArchive')) { 429 $zip = new ZipArchive(); 430 if ($zip->open($tmpName) === true) { 431 $names = []; 432 for ($i = 0; $i < $zip->numFiles; $i++) { 433 $names[] = $zip->getNameIndex($i); 434 } 435 return [ 436 'type' => 'ziparchive', 437 'handle' => $zip, 438 'map' => $this->buildArchiveMap($names), 439 ]; 440 } 441 } 442 443 if ($this->canUseSystemZipTools()) { 444 return [ 445 'type' => 'system', 446 'path' => $tmpName, 447 'map' => $this->buildArchiveMap($this->listArchiveEntries($tmpName)), 448 ]; 449 } 450 451 throw new RuntimeException($this->getLang('err_zip_support')); 452 } 453 454 /** 455 * Close an open archive handle when needed. 456 * 457 * @param array $archive 458 * @return void 459 */ 460 protected function closeArchive(array $archive) 461 { 462 if (($archive['type'] ?? '') === 'ziparchive' && isset($archive['handle'])) { 463 $archive['handle']->close(); 464 } 465 } 466 467 /** 468 * Read a single entry from the archive. 469 * 470 * @param array $archive 471 * @param string $entryName 472 * @return string 473 */ 474 protected function readArchiveEntry(array $archive, $entryName) 475 { 476 if (($archive['type'] ?? '') === 'ziparchive') { 477 $content = $archive['handle']->getFromName($entryName); 478 if ($content === false) { 479 throw new RuntimeException(sprintf($this->getLang('err_archive_read'), $entryName)); 480 } 481 return $content; 482 } 483 484 $command = 'unzip -p ' . escapeshellarg($archive['path']) . ' ' . escapeshellarg($entryName); 485 $descriptorSpec = [ 486 1 => ['pipe', 'w'], 487 2 => ['pipe', 'w'], 488 ]; 489 $process = proc_open($command, $descriptorSpec, $pipes); 490 if (!is_resource($process)) { 491 throw new RuntimeException($this->getLang('err_zip_open')); 492 } 493 494 $content = stream_get_contents($pipes[1]); 495 $error = stream_get_contents($pipes[2]); 496 fclose($pipes[1]); 497 fclose($pipes[2]); 498 $exitCode = proc_close($process); 499 500 if ($exitCode !== 0) { 501 throw new RuntimeException(trim($error) !== '' ? trim($error) : sprintf($this->getLang('err_archive_read'), $entryName)); 502 } 503 504 return $content; 505 } 506 507 /** 508 * List archive entries using zipinfo. 509 * 510 * @param string $tmpName 511 * @return array 512 */ 513 protected function listArchiveEntries($tmpName) 514 { 515 $output = []; 516 $exitCode = 0; 517 exec('zipinfo -1 ' . escapeshellarg($tmpName), $output, $exitCode); 518 if ($exitCode !== 0) { 519 throw new RuntimeException($this->getLang('err_zip_open')); 520 } 521 522 return $output; 523 } 524 525 /** 526 * Check whether system ZIP tools can be used as a fallback. 527 * 528 * @return bool 529 */ 530 protected function canUseSystemZipTools() 531 { 532 if (!function_exists('exec') || !function_exists('proc_open')) return false; 533 534 return $this->commandExists('unzip') && $this->commandExists('zipinfo'); 535 } 536 537 /** 538 * Check whether a shell command exists. 539 * 540 * @param string $command 541 * @return bool 542 */ 543 protected function commandExists($command) 544 { 545 $output = []; 546 $exitCode = 0; 547 exec('command -v ' . escapeshellarg($command), $output, $exitCode); 548 return $exitCode === 0 && !empty($output); 549 } 550 551 /** 552 * Remove the current active package and recreate the base directory. 553 * 554 * @return void 555 */ 556 protected function resetActiveDirectory() 557 { 558 if (file_exists(self::ACTIVE_DIR)) { 559 io_rmdir(self::ACTIVE_DIR, true); 560 } 561 562 io_mkdir_p(self::ACTIVE_DIR); 563 } 564 565 /** 566 * Expire DokuWiki render and asset caches after package changes. 567 * 568 * DokuWiki's extension manager uses the same local.php touch pattern. 569 * 570 * @return void 571 */ 572 protected function purgeDokuWikiCaches() 573 { 574 global $config_cascade; 575 576 $localConfig = reset($config_cascade['main']['local']); 577 if ($localConfig) { 578 @touch($localConfig); 579 } 580 } 581 582 /** 583 * Generate the public stylesheet from config data. 584 * 585 * @param array $config 586 * @param array $fontFiles 587 * @return string 588 */ 589 protected function buildCss(array $config, array $fontFiles) 590 { 591 $family = 'fontello'; 592 $icons = $this->extractIcons($config); 593 $sources = []; 594 595 $formatMap = [ 596 'eot' => 'embedded-opentype', 597 'woff2' => 'woff2', 598 'woff' => 'woff', 599 'ttf' => 'truetype', 600 'svg' => 'svg', 601 ]; 602 $priority = ['eot', 'woff2', 'woff', 'ttf', 'svg']; 603 604 foreach ($priority as $extension) { 605 foreach ($fontFiles as $fontFile) { 606 if (strtolower(pathinfo($fontFile, PATHINFO_EXTENSION)) !== $extension) continue; 607 $url = "../font/$fontFile"; 608 if ($extension === 'svg') { 609 $url .= '#' . $family; 610 } 611 $sources[] = "url('$url') format('" . $formatMap[$extension] . "')"; 612 } 613 } 614 615 $css = "@font-face {\n"; 616 $css .= " font-family: '$family';\n"; 617 $css .= ' src: ' . implode(",\n ", $sources) . ";\n"; 618 $css .= " font-weight: normal;\n"; 619 $css .= " font-style: normal;\n"; 620 $css .= "}\n\n"; 621 $css .= ".fontello-icon {\n"; 622 $css .= " display: inline-block;\n"; 623 $css .= "}\n\n"; 624 $css .= ".fontello-icon:before {\n"; 625 $css .= " font-family: '$family';\n"; 626 $css .= " font-style: normal;\n"; 627 $css .= " font-weight: normal;\n"; 628 $css .= " speak: never;\n"; 629 $css .= " display: inline-block;\n"; 630 $css .= " text-decoration: inherit;\n"; 631 $css .= " width: 1em;\n"; 632 $css .= " margin-right: .2em;\n"; 633 $css .= " text-align: center;\n"; 634 $css .= " font-variant: normal;\n"; 635 $css .= " text-transform: none;\n"; 636 $css .= " line-height: 1em;\n"; 637 $css .= " margin-left: .2em;\n"; 638 $css .= " -webkit-font-smoothing: antialiased;\n"; 639 $css .= " -moz-osx-font-smoothing: grayscale;\n"; 640 $css .= "}\n\n"; 641 642 foreach ($icons as $icon) { 643 $css .= '.fontello-icon.' . $icon['class'] . ':before { content: "\\' . $icon['code'] . "\"; }\n"; 644 } 645 646 return $css; 647 } 648 649 /** 650 * Load a JSON file from disk. 651 * 652 * @param string $file 653 * @return array|null 654 */ 655 protected function loadJsonFile($file) 656 { 657 if (!file_exists($file)) return null; 658 659 $json = file_get_contents($file); 660 if ($json === false) return null; 661 662 $decoded = json_decode($json, true); 663 return is_array($decoded) ? $decoded : null; 664 } 665 666 /** 667 * Translate PHP upload error codes. 668 * 669 * @param int $error 670 * @return string 671 */ 672 protected function uploadErrorMessage($error) 673 { 674 return match ($error) { 675 UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => $this->getLang('err_upload_too_large'), 676 UPLOAD_ERR_PARTIAL => $this->getLang('err_upload_partial'), 677 UPLOAD_ERR_NO_FILE => $this->getLang('err_upload_missing'), 678 default => $this->getLang('err_upload_generic'), 679 }; 680 } 681} 682