Passed
Push — images ( 63cf70...07e871 )
by Arnaud
03:45
created

Parsedown::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 20
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 1
eloc 8
c 3
b 0
f 0
nc 1
nop 1
dl 0
loc 20
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Cecil\Converter;
15
16
use Cecil\Assets\Asset;
17
use Cecil\Assets\Image;
18
use Cecil\Builder;
19
use Cecil\Exception\RuntimeException;
20
use Cecil\Util;
21
use Highlight\Highlighter;
22
23
class Parsedown extends \ParsedownToC
24
{
25
    /** @var Builder */
26
    protected $builder;
27
28
    /** {@inheritdoc} */
29
    protected $regexAttribute = '(?:[#.][-\w:\\\]+[ ]*|[-\w:\\\]+(?:=(?:["\'][^\n]*?["\']|[^\s]+)?)?[ ]*)';
30
31
    /** Valid a media block (image, audio or video) */
32
    protected $MarkdownMediaRegex = "~^!\[.*?\]\(.*?\)~";
33
34
    /** @var Highlighter */
35
    protected $highlighter;
36
37
    public function __construct(Builder $builder)
38
    {
39
        $this->builder = $builder;
40
41
        // "insert" line block: ++text++ -> <ins>text</ins>
42
        $this->InlineTypes['+'][] = 'Insert';
43
        $this->inlineMarkerList = implode('', array_keys($this->InlineTypes));
44
        $this->specialCharacters[] = '+';
45
46
        // Media (image, audio or video) block
47
        $this->BlockTypes['!'][] = 'Media';
48
49
        // "notes" block
50
        $this->BlockTypes[':'][] = 'Note';
51
52
        // code highlight
53
        $this->highlighter = new Highlighter();
54
55
        // Table of Content
56
        parent::__construct(['selectors' => $this->builder->getConfig()->get('body.toc')]);
57
    }
58
59
    /**
60
     * Insert inline.
61
     * e.g.: ++text++ -> <ins>text</ins>.
62
     */
63
    protected function inlineInsert($Excerpt)
64
    {
65
        if (!isset($Excerpt['text'][1])) {
66
            return;
67
        }
68
69
        if ($Excerpt['text'][1] === '+' && preg_match('/^\+\+(?=\S)(.+?)(?<=\S)\+\+/', $Excerpt['text'], $matches)) {
70
            return [
71
                'extent'  => strlen($matches[0]),
72
                'element' => [
73
                    'name'    => 'ins',
74
                    'text'    => $matches[1],
75
                    'handler' => 'line',
76
                ],
77
            ];
78
        }
79
    }
80
81
    /**
82
     * {@inheritdoc}
83
     */
84
    protected function inlineImage($Excerpt)
85
    {
86
        $image = parent::inlineImage($Excerpt);
87
        if (!isset($image)) {
88
            return null;
89
        }
90
91
        // clean source path / URL
92
        $image['element']['attributes']['src'] = $this->cleanUrl($image['element']['attributes']['src']);
93
94
        // should be lazy loaded?
95
        if ($this->builder->getConfig()->get('body.images.lazy.enabled')) {
96
            $image['element']['attributes']['loading'] = 'lazy';
97
        }
98
99
        // disable remote image handling?
100
        if (Util\Url::isUrl($image['element']['attributes']['src']) && !$this->builder->getConfig()->get('body.images.remote.enabled') ?? true) {
101
            return $image;
102
        }
103
104
        // create asset
105
        $asset = new Asset($this->builder, $image['element']['attributes']['src'], ['force_slash' => false]);
106
        $image['element']['attributes']['src'] = $asset;
107
        $width = null;
108
        $height = null;
109
110
        /**
111
         * Should be resized?
112
         */
113
        $assetResized = null;
114
        if ($this->builder->getConfig()->get('body.images.resize.enabled')
115
            && (
116
                (isset($image['element']['attributes']['width']) && (int) $image['element']['attributes']['width'] < $asset->getWidth())
117
                || (isset($image['element']['attributes']['height']) && (int) $image['element']['attributes']['height'] < $asset->getHeight())
118
            )
119
        ) {
120
            if (isset($image['element']['attributes']['width'])) {
121
                $width = (int) $image['element']['attributes']['width'];
122
            }
123
            if (isset($image['element']['attributes']['height'])) {
124
                $height = (int) $image['element']['attributes']['height'];
125
            }
126
127
            try {
128
                $assetResized = $asset->resize($width, $height);
129
                $image['element']['attributes']['src'] = $assetResized;
130
            } catch (\Exception $e) {
131
                $this->builder->getLogger()->debug($e->getMessage());
132
133
                return $image;
134
            }
135
        }
136
137
        // set width
138
        if (!isset($image['element']['attributes']['width']) && $asset['type'] == 'image') {
139
            $image['element']['attributes']['width'] = ($assetResized ?? $asset)->getWidth();
140
        }
141
        // set height
142
        if (!isset($image['element']['attributes']['height']) && $asset['type'] == 'image') {
143
            $image['element']['attributes']['height'] = ($assetResized ?? $asset)->getHeight();
144
        }
145
146
        /**
147
         * Should be responsive?
148
         */
149
        if ($this->builder->getConfig()->get('body.images.responsive.enabled') && false === false) {
150
            if ($srcset = Image::buildSrcset(
151
                $assetResized ?? $asset,
152
                $this->builder->getConfig()->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920]
153
            )) {
154
                $image['element']['attributes']['srcset'] = $srcset;
155
                $image['element']['attributes']['sizes'] = $this->builder->getConfig()->get('assets.images.responsive.sizes.default');
156
            }
157
        }
158
159
        return $image;
160
    }
161
162
    /**
163
     * Media block support:
164
     * 1. <picture>/<source> for WebP images
165
     * 2. <audio> and <video> elements
166
     * 3. <figure>/<figcaption> for element with a title.
167
     */
168
    protected function blockMedia($Excerpt)
169
    {
170
        if (1 !== preg_match($this->MarkdownMediaRegex, $Excerpt['text'])) {
171
            return;
172
        }
173
174
        $InlineImage = $this->inlineImage($Excerpt);
175
        if (!isset($InlineImage)) {
176
            return;
177
        }
178
        $block = $InlineImage;
179
180
        switch ($block['element']['attributes']['alt']) {
181
            case 'audio':
182
                $audio = [];
183
                $audio['element'] = [
184
                    'name'    => 'audio',
185
                    'handler' => 'element',
186
                ];
187
                $audio['element']['attributes'] = $block['element']['attributes'];
188
                unset($audio['element']['attributes']['loading']);
189
                $block = $audio;
190
                unset($block['element']['attributes']['alt']);
191
                break;
192
            case 'video':
193
                $video = [];
194
                $video['element'] = [
195
                    'name'       => 'video',
196
                    'handler'    => 'element',
197
                ];
198
                $video['element']['attributes'] = $block['element']['attributes'];
199
                unset($video['element']['attributes']['loading']);
200
                if (isset($block['element']['attributes']['poster'])) {
201
                    $video['element']['attributes']['poster'] = new Asset($this->builder, $block['element']['attributes']['poster'], ['force_slash' => false]);
202
                }
203
                $block = $video;
204
                unset($block['element']['attributes']['alt']);
205
        }
206
207
        /*
208
        <!-- if image has a title: a <figure> is required for <figcaption> -->
209
        <figure>
210
            <!-- if WebP: a <picture> is required for <source> -->
211
            <picture>
212
                <source type="image/webp"
213
                    srcset="..."
214
                    sizes="..."
215
                >
216
                <img src="..."
217
                    srcset="..."
218
                    sizes="..."
219
                >
220
            </picture>
221
            <!-- title -->
222
            <figcaption>...</figcaption>
223
        </figure>
224
        */
225
226
        // creates a <picture> used to add WebP <source> in addition to the image <img> element
227
        if ($this->builder->getConfig()->get('body.images.webp.enabled') ?? false
228
            && ($InlineImage['element']['attributes']['src'])['type'] == 'image'
229
            && ($InlineImage['element']['attributes']['src'])['subtype'] != 'image/webp') {
230
            try {
231
                // Image src must be an Asset instance
232
                if (is_string($InlineImage['element']['attributes']['src'])) {
233
                    throw new RuntimeException(\sprintf('Asset "%s" can\'t be converted to WebP', $InlineImage['element']['attributes']['src']));
234
                }
235
                // Image asset is an animated GIF
236
                if (Image::isAnimatedGif($InlineImage['element']['attributes']['src'])) {
237
                    throw new RuntimeException(\sprintf('Asset "%s" is an animated GIF and can\'t be converted to WebP', $InlineImage['element']['attributes']['src']));
238
                }
239
                $assetWebp = Image::convertTopWebp($InlineImage['element']['attributes']['src'], $this->builder->getConfig()->get('assets.images.quality') ?? 75);
240
                $srcset = '';
241
                if ($this->builder->getConfig()->get('body.images.responsive.enabled')) {
242
                    $srcset = Image::buildSrcset(
243
                        $assetWebp,
244
                        $this->builder->getConfig()->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920]
245
                    );
246
                }
247
                if (empty($srcset)) {
248
                    $srcset = (string) $assetWebp;
249
                }
250
                $PictureBlock = [
251
                    'element' => [
252
                        'name'    => 'picture',
253
                        'handler' => 'elements',
254
                    ],
255
                ];
256
                $source = [
257
                    'element' => [
258
                        'name'       => 'source',
259
                        'attributes' => [
260
                            'type'   => 'image/webp',
261
                            'srcset' => $srcset,
262
                            'sizes'  => $this->builder->getConfig()->get('assets.images.responsive.sizes.default'),
263
                        ],
264
                    ],
265
                ];
266
                $PictureBlock['element']['text'][] = $source['element'];
267
                $PictureBlock['element']['text'][] = $InlineImage['element'];
268
                $block = $PictureBlock;
269
            } catch (\Exception $e) {
270
                $this->builder->getLogger()->debug($e->getMessage());
271
            }
272
        }
273
274
        // if there is a title: put the <img> (or <picture>) in a <figure> element to use the <figcaption>
275
        if ($this->builder->getConfig()->get('body.images.caption.enabled') && !empty($InlineImage['element']['attributes']['title'])) {
276
            $FigureBlock = [
277
                'element' => [
278
                    'name'    => 'figure',
279
                    'handler' => 'elements',
280
                    'text'    => [
281
                        $block['element'],
282
                    ],
283
                ],
284
            ];
285
            $InlineFigcaption = [
286
                'element' => [
287
                    'name' => 'figcaption',
288
                    'text' => $InlineImage['element']['attributes']['title'],
289
                ],
290
            ];
291
            $FigureBlock['element']['text'][] = $InlineFigcaption['element'];
292
293
            return $FigureBlock;
294
        }
295
296
        return $block;
297
    }
298
299
    /**
300
     * Note block-level markup.
301
     *
302
     * :::tip
303
     * **Tip:** This is an advice.
304
     * :::
305
     *
306
     * Code inspired by https://github.com/sixlive/parsedown-alert from TJ Miller (@sixlive).
307
     */
308
    protected function blockNote($block)
309
    {
310
        if (preg_match('/:::(.*)/', $block['text'], $matches)) {
311
            $block = [
312
                'char'    => ':',
313
                'element' => [
314
                    'name'       => 'aside',
315
                    'text'       => '',
316
                    'attributes' => [
317
                        'class' => 'note',
318
                    ],
319
                ],
320
            ];
321
            if (!empty($matches[1])) {
322
                $block['element']['attributes']['class'] .= " note-{$matches[1]}";
323
            }
324
325
            return $block;
326
        }
327
    }
328
329
    protected function blockNoteContinue($line, $block)
330
    {
331
        if (isset($block['complete'])) {
332
            return;
333
        }
334
        if (preg_match('/:::/', $line['text'])) {
335
            $block['complete'] = true;
336
337
            return $block;
338
        }
339
        $block['element']['text'] .= $line['text']."\n";
340
341
        return $block;
342
    }
343
344
    protected function blockNoteComplete($block)
345
    {
346
        $block['element']['rawHtml'] = $this->text($block['element']['text']);
347
        unset($block['element']['text']);
348
349
        return $block;
350
    }
351
352
    /**
353
     * Apply Highlight to code blocks.
354
     */
355
    protected function blockFencedCodeComplete($block)
356
    {
357
        if (!$this->builder->getConfig()->get('body.highlight.enabled')) {
358
            return $block;
359
        }
360
        if (!isset($block['element']['text']['attributes'])) {
361
            return $block;
362
        }
363
364
        $code = $block['element']['text']['text'];
365
        unset($block['element']['text']['text']);
366
        $languageClass = $block['element']['text']['attributes']['class'];
367
        $language = explode('-', $languageClass);
368
        $highlighted = $this->highlighter->highlight($language[1], $code);
369
        $block['element']['text']['attributes']['class'] = vsprintf('%s hljs %s', [
370
            $languageClass,
371
            $highlighted->language,
372
        ]);
373
        $block['element']['text']['rawHtml'] = $highlighted->value;
374
        $block['element']['text']['allowRawHtmlInSafeMode'] = true;
375
376
        return $block;
377
    }
378
379
    /**
380
     * {@inheritdoc}
381
     */
382
    protected function parseAttributeData($attributeString)
383
    {
384
        $attributes = preg_split('/[ ]+/', $attributeString, -1, PREG_SPLIT_NO_EMPTY);
385
        $Data = [];
386
        $HtmlAtt = [];
387
388
        foreach ($attributes as $attribute) {
389
            switch ($attribute[0]) {
390
                case '#': // ID
391
                    $Data['id'] = substr($attribute, 1);
392
                    break;
393
                case '.': // Classes
394
                    $classes[] = substr($attribute, 1);
395
                    break;
396
                default:  // Attributes
397
                    parse_str($attribute, $parsed);
398
                    $HtmlAtt = array_merge($HtmlAtt, $parsed);
399
            }
400
        }
401
402
        if (isset($classes)) {
403
            $Data['class'] = implode(' ', $classes);
404
        }
405
        if (!empty($HtmlAtt)) {
406
            foreach ($HtmlAtt as $a => $v) {
407
                $Data[$a] = trim($v, '"');
408
            }
409
        }
410
411
        return $Data;
412
    }
413
414
    /**
415
     * Returns URL without query string.
416
     */
417
    private function cleanUrl(string $path): string
418
    {
419
        return strtok(trim($path), '?') ?: trim($path);
420
    }
421
}
422