1<?php 2/** 3 * DokuWiki Plugin multimage (Syntax Component) 4 */ 5 6if (!defined('DOKU_INC')) die(); 7 8class syntax_plugin_multimage extends DokuWiki_Syntax_Plugin 9{ 10 public function getType(): string 11 { 12 // We capture a delimited block of raw text 13 return 'container'; 14 } 15 16 public function getPType(): string 17 { 18 // We emit block-level HTML (<div>) 19 return 'block'; 20 } 21 22 public function getSort(): int 23 { 24 return 200; 25 } 26 27 public function connectTo($mode): void 28 { 29 $this->Lexer->addEntryPattern( 30 '<images>(?=.*?</images>)', 31 $mode, 32 'plugin_multimage' 33 ); 34 } 35 36 public function postConnect(): void 37 { 38 $this->Lexer->addExitPattern('</images>', 'plugin_multimage'); 39 } 40 41 public function handle($match, $state, $pos, Doku_Handler $handler) 42 { 43 switch ($state) { 44 case DOKU_LEXER_ENTER: 45 return ['enter']; 46 case DOKU_LEXER_UNMATCHED: 47 return ['content', $match]; 48 case DOKU_LEXER_EXIT: 49 return ['exit']; 50 } 51 return []; 52 } 53 54 public function render($mode, Doku_Renderer $renderer, $data): bool 55 { 56 if ($mode !== 'xhtml') { 57 return false; 58 } 59 60 [$type, $content] = array_pad($data, 2, null); 61 62 if ($type !== 'content') { 63 return true; 64 } 65 66 $yaml = trim($content); 67 if ($yaml === '') { 68 return true; 69 } 70 71 if (!function_exists('yaml_parse')) { 72 $renderer->doc .= '<!-- multimage: PHP YAML extension missing -->'; 73 return true; 74 } 75 76 $parsed = @yaml_parse($yaml); 77 if (!is_array($parsed)) { 78 $renderer->doc .= '<!-- multimage: invalid YAML -->'; 79 return true; 80 } 81 82 /* 83 * Normalization: 84 * - mapping with 'image' => single base image 85 * - sequence => multiple base images 86 */ 87 if (array_key_exists('image', $parsed)) { 88 $baseImages = [$parsed]; 89 } elseif (array_values($parsed) === $parsed) { 90 $baseImages = $parsed; 91 } else { 92 $renderer->doc .= '<!-- multimage: expected image or list of images -->'; 93 return true; 94 } 95 96 $renderer->doc .= '<div class="images">'; 97 98 foreach ($baseImages as $baseImage) { 99 if (!is_array($baseImage)) { 100 continue; 101 } 102 103 $this->renderBaseImage($renderer, $baseImage); 104 105 106 if (!empty($baseImage['ontop']) && is_array($baseImage['ontop'])) { 107 foreach ($baseImage['ontop'] as $ontopImage) { 108 if (is_array($ontopImage)) { 109 $this->renderOntopImage($renderer, $ontopImage); 110 } 111 } 112 } 113 } 114 115 $renderer->doc .= '</div>'; 116 117 return true; 118 } 119 120 /* ---------- rendering helpers ---------- */ 121 122 protected function renderBaseImage(Doku_Renderer $renderer, array $entry): void 123 { 124 $style = $this->filterStyles($entry['style'] ?? null); 125 126 $renderer->doc .= '<div class="images_ontop">'; 127 128 if (empty($entry['image'])) { 129 $renderer->doc .= '<div class="images_image"'; 130 if ($style !== '') { 131 $renderer->doc .= ' style="' . hsc($style) . '"'; 132 } 133 $renderer->doc .= '> </div>'; 134 } else { 135 [$src, $alt] = $this->parseImage($entry['image']); 136 $renderer->doc .= '<img class="images_image" src="' . hsc($src) . '"'; 137 if ($style !== '') { 138 $renderer->doc .= ' style="' . hsc($style) . '"'; 139 } 140 $renderer->doc .= ' alt="' . hsc($alt) . '"/>'; 141 } 142 $renderer->doc .= '</div>'; 143 } 144 145 protected function renderOntopImage(Doku_Renderer $renderer, array $entry): void 146 { 147 if (empty($entry['image'])) { 148 return; 149 } 150 151 [$src, $alt] = $this->parseImage($entry['image']); 152 $style = $this->filterStyles($entry['style'] ?? null); 153 154 $renderer->doc .= '<div class="images_ontop">'; 155 $renderer->doc .= '<img class="images_ontop_image" src="' . hsc($src) . '"'; 156 if ($style !== '') { 157 $renderer->doc .= ' style="' . hsc($style) . '"'; 158 } 159 $renderer->doc .= ' alt="' . hsc($alt) . '"/>'; 160 $renderer->doc .= '</div>'; 161 } 162 163 /* ---------- parsing & validation ---------- */ 164 165 protected function parseImage(string $spec): array 166 { 167 // Format: wiki:img.svg?400|alt text 168 [$left, $alt] = array_pad(explode('|', $spec, 2), 2, ''); 169 [$id, $params] = array_pad(explode('?', $left, 2), 2, ''); 170 171 $opts = []; 172 if ($params !== '') { 173 parse_str($params, $opts); 174 } 175 176 $src = ml($id, $opts, true, '&'); 177 178 return [$src, $alt]; 179 } 180 181protected function filterStyles($styles): string 182{ 183 if (!is_array($styles)) { 184 return ''; 185 } 186 187 $allowed = $this->getAllowedStyles(); 188 $out = []; 189 190 foreach ($styles as $key => $value) { 191 if (!is_string($key) || !is_scalar($value)) { 192 continue; 193 } 194 195 $key = trim($key); 196 $value = trim((string)$value); 197 198 if (!in_array($key, $allowed, true)) { 199 continue; 200 } 201 202 // Rewrite url("...") or url('...') 203 $value = preg_replace_callback( 204 '/url\(\s*([\'"])([^\'"]+)\1\s*\)/i', 205 function ($m) { 206 $target = trim($m[2]); 207 208 // Only rewrite DokuWiki media IDs 209 if (preg_match('/^[a-zA-Z0-9_:-]+(\.[a-zA-Z0-9]+)?$/', $target)) { 210 $url = ml($target, [], true, '&'); 211 return 'url("' . $url . '")'; 212 } 213 214 // Leave untouched if not a media ID 215 return $m[0]; 216 }, 217 $value 218 ); 219 220 $out[] = $key . ': ' . $value; 221 } 222 223 return $out ? implode('; ', $out) . ';': ''; 224} 225 226 protected function getAllowedStyles(): array 227 { 228 static $styles = null; 229 230 if ($styles !== null) { 231 return $styles; 232 } 233 234 $raw = (string)$this->getConf('allowed_styles'); 235 236 $styles = array_filter( 237 array_map('trim', explode(',', $raw)), 238 static fn($s) => $s !== '' 239 ); 240 241 return $styles; 242 } 243} 244 245