xref: /plugin/multimage/syntax.php (revision 3d0502afe0387853d1dec69b59db8664ad1deac4)
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 .= '>&nbsp;</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