Issues (5)

lib/Caxy/HtmlDiff/HtmlDiff.php (4 issues)

1
<?php
2
3
namespace Caxy\HtmlDiff;
4
5
use Caxy\HtmlDiff\Table\TableDiff;
6
7
/**
8
 * Class HtmlDiff.
9
 */
10
class HtmlDiff extends AbstractDiff
11
{
12
    /**
13
     * @var array
14
     */
15
    protected $wordIndices;
16
17
    /**
18
     * @var array
19
     */
20
    protected $newIsolatedDiffTags;
21
22
    /**
23
     * @var array
24
     */
25
    protected $oldIsolatedDiffTags;
26
27
    /**
28
     * @param string              $oldText
29
     * @param string              $newText
30
     * @param HtmlDiffConfig|null $config
31
     *
32
     * @return self
33
     */
34 16
    public static function create($oldText, $newText, ?HtmlDiffConfig $config = null)
35
    {
36 16
        $diff = new self($oldText, $newText);
37
38 16
        if (null !== $config) {
39 16
            $diff->setConfig($config);
40
        }
41
42 16
        return $diff;
43
    }
44
45
    /**
46
     * @param $bool
47
     *
48
     * @return $this
49
     *
50
     * @deprecated since 0.1.0
51
     */
52
    public function setUseTableDiffing($bool)
53
    {
54
        $this->config->setUseTableDiffing($bool);
55
56
        return $this;
57
    }
58
59
    /**
60
     * @param bool $boolean
61
     *
62
     * @return HtmlDiff
63
     *
64
     * @deprecated since 0.1.0
65
     */
66
    public function setInsertSpaceInReplace($boolean)
67
    {
68
        $this->config->setInsertSpaceInReplace($boolean);
69
70
        return $this;
71
    }
72
73
    /**
74
     * @return bool
75
     *
76
     * @deprecated since 0.1.0
77
     */
78
    public function getInsertSpaceInReplace()
79
    {
80
        return $this->config->isInsertSpaceInReplace();
81
    }
82
83
    /**
84
     * @return string
85
     */
86 21
    public function build()
87
    {
88 21
        $this->prepare();
89
90 21
        if ($this->hasDiffCache() && $this->getDiffCache()->contains($this->oldText, $this->newText)) {
91
            $this->content = $this->getDiffCache()->fetch($this->oldText, $this->newText);
92
93
            return $this->content;
94
        }
95
96
        // Pre-processing Optimizations
97
98
        // 1. Equality
99 21
        if ($this->oldText == $this->newText) {
100 11
            return $this->newText;
101
        }
102
103 21
        $this->splitInputsToWords();
104 21
        $this->replaceIsolatedDiffTags();
105 21
        $this->indexNewWords();
106
107 21
        $operations = $this->operations();
108
109 21
        foreach ($operations as $item) {
110 21
            $this->performOperation($item);
111
        }
112
113 21
        if ($this->hasDiffCache()) {
114
            $this->getDiffCache()->save($this->oldText, $this->newText, $this->content);
115
        }
116
117 21
        return $this->content;
118
    }
119
120 21
    protected function indexNewWords() : void
121
    {
122 21
        $this->wordIndices = [];
123
124 21
        foreach ($this->newWords as $i => $word) {
125 21
            if ($this->isTag($word) === true) {
126 11
                $word = $this->stripTagAttributes($word);
127
            }
128
129 21
            if (isset($this->wordIndices[$word]) === false) {
130 21
                $this->wordIndices[$word] = [];
131
            }
132
133 21
            $this->wordIndices[$word][] = $i;
134
        }
135
    }
136
137 21
    protected function replaceIsolatedDiffTags()
138
    {
139 21
        $this->oldIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->oldWords);
140 21
        $this->newIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->newWords);
141
    }
142
143
    /**
144
     * @param array $words
145
     *
146
     * @return array
147
     */
148 21
    protected function createIsolatedDiffTagPlaceholders(&$words)
149
    {
150 21
        $openIsolatedDiffTags = 0;
151 21
        $isolatedDiffTagIndices = array();
152 21
        $isolatedDiffTagStart = 0;
153 21
        $currentIsolatedDiffTag = null;
154 21
        foreach ($words as $index => $word) {
155 21
            $openIsolatedDiffTag = $this->isOpeningIsolatedDiffTag($word, $currentIsolatedDiffTag);
156 21
            if ($openIsolatedDiffTag) {
157 17
                if ($this->isSelfClosingTag($word) || $this->stringUtil->stripos($word, '<img') !== false) {
158
                    if ($openIsolatedDiffTags === 0) {
159
                        $isolatedDiffTagIndices[] = array(
160
                            'start' => $index,
161
                            'length' => 1,
162
                            'tagType' => $openIsolatedDiffTag,
163
                        );
164
                        $currentIsolatedDiffTag = null;
165
                    }
166
                } else {
167 17
                    if ($openIsolatedDiffTags === 0) {
168 17
                        $isolatedDiffTagStart = $index;
169
                    }
170 17
                    ++$openIsolatedDiffTags;
171 17
                    $currentIsolatedDiffTag = $openIsolatedDiffTag;
172
                }
173 21
            } elseif ($openIsolatedDiffTags > 0 && $this->isClosingIsolatedDiffTag($word, $currentIsolatedDiffTag)) {
174 17
                --$openIsolatedDiffTags;
175 17
                if ($openIsolatedDiffTags == 0) {
176 17
                    $isolatedDiffTagIndices[] = array('start' => $isolatedDiffTagStart, 'length' => $index - $isolatedDiffTagStart + 1, 'tagType' => $currentIsolatedDiffTag);
177 17
                    $currentIsolatedDiffTag = null;
178
                }
179
            }
180
        }
181 21
        $isolatedDiffTagScript = array();
182 21
        $offset = 0;
183 21
        foreach ($isolatedDiffTagIndices as $isolatedDiffTagIndex) {
184 17
            $start = $isolatedDiffTagIndex['start'] - $offset;
185 17
            $placeholderString = $this->config->getIsolatedDiffTagPlaceholder($isolatedDiffTagIndex['tagType']);
186 17
            $isolatedDiffTagScript[$start] = array_splice($words, $start, $isolatedDiffTagIndex['length'], $placeholderString);
187 17
            $offset += $isolatedDiffTagIndex['length'] - 1;
188
        }
189
190 21
        return $isolatedDiffTagScript;
191
    }
192
193
    /**
194
     * @param string      $item
195
     * @param null|string $currentIsolatedDiffTag
196
     *
197
     * @return false|string
198
     */
199 21
    protected function isOpeningIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
200
    {
201 21
        $tagsToMatch = $currentIsolatedDiffTag !== null
202 17
            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
203 21
            : $this->config->getIsolatedDiffTags();
204 21
        $pattern = '#<%s(\s+[^>]*)?>#iUu';
205 21
        foreach ($tagsToMatch as $key => $value) {
206 21
            if (preg_match(sprintf($pattern, $key), $item)) {
207 17
                return $key;
208
            }
209
        }
210
211 21
        return false;
212
    }
213
214 17
    protected function isSelfClosingTag($text)
215
    {
216 17
        return (bool) preg_match('/<[^>]+\/\s*>/u', $text);
217
    }
218
219
    /**
220
     * @param string      $item
221
     * @param null|string $currentIsolatedDiffTag
222
     *
223
     * @return false|string
224
     */
225 17
    protected function isClosingIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
226
    {
227 17
        $tagsToMatch = $currentIsolatedDiffTag !== null
228 17
            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
229 17
            : $this->config->getIsolatedDiffTags();
230 17
        $pattern = '#</%s(\s+[^>]*)?>#iUu';
231 17
        foreach ($tagsToMatch as $key => $value) {
232 17
            if (preg_match(sprintf($pattern, $key), $item)) {
233 17
                return $key;
234
            }
235
        }
236
237 17
        return false;
238
    }
239
240
    /**
241
     * @param Operation $operation
242
     */
243 21
    protected function performOperation($operation)
244
    {
245 21
        switch ($operation->action) {
246 21
            case 'equal' :
0 ignored issues
show
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
247 21
                $this->processEqualOperation($operation);
248 21
                break;
249 18
            case 'delete' :
0 ignored issues
show
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
250 5
                $this->processDeleteOperation($operation, 'diffdel');
251 5
                break;
252 17
            case 'insert' :
0 ignored issues
show
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
253 11
                $this->processInsertOperation($operation, 'diffins');
254 11
                break;
255 12
            case 'replace':
256 12
                $this->processReplaceOperation($operation);
257 12
                break;
258
            default:
259
                break;
260
        }
261
    }
262
263
    /**
264
     * @param Operation $operation
265
     */
266 12
    protected function processReplaceOperation($operation)
267
    {
268 12
        $this->processDeleteOperation($operation, 'diffmod');
269 12
        $this->processInsertOperation($operation, 'diffmod');
270
    }
271
272
    /**
273
     * @param Operation $operation
274
     * @param string    $cssClass
275
     */
276 17
    protected function processInsertOperation($operation, $cssClass)
277
    {
278 17
        $text = array();
279 17
        foreach ($this->newWords as $pos => $s) {
280 17
            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
281 17
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
282 4
                    foreach ($this->newIsolatedDiffTags[$pos] as $word) {
283 4
                        $text[] = $word;
284
                    }
285
                } else {
286 17
                    $text[] = $s;
287
                }
288
            }
289
        }
290
291 17
        $this->insertTag('ins', $cssClass, $text);
292
    }
293
294
    /**
295
     * @param Operation $operation
296
     * @param string    $cssClass
297
     */
298 15
    protected function processDeleteOperation($operation, $cssClass)
299
    {
300 15
        $text = array();
301 15
        foreach ($this->oldWords as $pos => $s) {
302 15
            if ($pos >= $operation->startInOld && $pos < $operation->endInOld) {
303 15
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->oldIsolatedDiffTags[$pos])) {
304 6
                    foreach ($this->oldIsolatedDiffTags[$pos] as $word) {
305 6
                        $text[] = $word;
306
                    }
307
                } else {
308 15
                    $text[] = $s;
309
                }
310
            }
311
        }
312 15
        $this->insertTag('del', $cssClass, $text);
313
    }
314
315
    /**
316
     * @param Operation $operation
317
     * @param int       $pos
318
     * @param string    $placeholder
319
     * @param bool      $stripWrappingTags
320
     *
321
     * @return string
322
     */
323 16
    protected function diffIsolatedPlaceholder($operation, $pos, $placeholder, $stripWrappingTags = true)
324
    {
325 16
        $oldText = implode('', $this->findIsolatedDiffTagsInOld($operation, $pos));
326 16
        $newText = implode('', $this->newIsolatedDiffTags[$pos]);
327
328 16
        if ($this->isListPlaceholder($placeholder)) {
329 8
            return $this->diffList($oldText, $newText);
330 12
        } elseif ($this->config->isUseTableDiffing() && $this->isTablePlaceholder($placeholder)) {
331 1
            return $this->diffTables($oldText, $newText);
332 11
        } elseif ($this->isLinkPlaceholder($placeholder)) {
333 1
            return $this->diffElementsByAttribute($oldText, $newText, 'href', 'a');
334 10
        } elseif ($this->isImagePlaceholder($placeholder)) {
335
            return $this->diffElementsByAttribute($oldText, $newText, 'src', 'img');
336 10
        } elseif ($this->isPicturePlaceholder($placeholder)) {
337
           return $this->diffPicture($oldText, $newText);
338
        }
339
340 10
        return $this->diffElements($oldText, $newText, $stripWrappingTags);
341
    }
342
343
    /**
344
     * @param string $oldText
345
     * @param string $newText
346
     * @param bool   $stripWrappingTags
347
     *
348
     * @return string
349
     */
350 11
    protected function diffElements($oldText, $newText, $stripWrappingTags = true)
351
    {
352 11
        $wrapStart = '';
353 11
        $wrapEnd = '';
354
355 11
        if ($stripWrappingTags) {
356 11
            $pattern = '/(^<[^>]+>)|(<\/[^>]+>$)/iu';
357 11
            $matches = array();
358
359 11
            if (preg_match_all($pattern, $newText, $matches)) {
360 11
                $wrapStart = isset($matches[0][0]) ? $matches[0][0] : '';
361 11
                $wrapEnd = isset($matches[0][1]) ? $matches[0][1] : '';
362
            }
363 11
            $oldText = preg_replace($pattern, '', $oldText);
364 11
            $newText = preg_replace($pattern, '', $newText);
365
        }
366
367 11
        $diff = self::create($oldText, $newText, $this->config);
368
369 11
        return $wrapStart.$diff->build().$wrapEnd;
370
    }
371
372
    /**
373
     * @param string $oldText
374
     * @param string $newText
375
     *
376
     * @return string
377
     */
378 8
    protected function diffList($oldText, $newText)
379
    {
380 8
        $diff = ListDiffLines::create($oldText, $newText, $this->config);
381
382 8
        return $diff->build();
383
    }
384
385
    /**
386
     * @param string $oldText
387
     * @param string $newText
388
     *
389
     * @return string
390
     */
391 1
    protected function diffTables($oldText, $newText)
392
    {
393 1
        $diff = TableDiff::create($oldText, $newText, $this->config);
394
395 1
        return $diff->build();
396
    }
397
398
    /**
399
     * @param string $oldText
400
     * @param string $newText
401
     *
402
     * @return string
403
     */
404
    protected function diffPicture($oldText, $newText) {
405
        if ($oldText !== $newText) {
406
            return sprintf(
407
                '%s%s',
408
                $this->wrapText($oldText, 'del', 'diffmod'),
409
                $this->wrapText($newText, 'ins', 'diffmod')
410
            );
411
        }
412
        return $this->diffElements($oldText, $newText);
413
  }
0 ignored issues
show
Closing brace indented incorrectly; expected 4 spaces, found 2
Loading history...
414
415 1
    protected function diffElementsByAttribute($oldText, $newText, $attribute, $element)
416
    {
417 1
        $oldAttribute = $this->getAttributeFromTag($oldText, $attribute);
418 1
        $newAttribute = $this->getAttributeFromTag($newText, $attribute);
419
420 1
        if ($oldAttribute !== $newAttribute) {
421 1
            $diffClass = sprintf('diffmod diff%s diff%s', $element, $attribute);
422
423 1
            return sprintf(
424 1
                '%s%s',
425 1
                $this->wrapText($oldText, 'del', $diffClass),
426 1
                $this->wrapText($newText, 'ins', $diffClass)
427 1
            );
428
        }
429
430 1
        return $this->diffElements($oldText, $newText);
431
    }
432
433
    /**
434
     * @param Operation $operation
435
     */
436 21
    protected function processEqualOperation($operation)
437
    {
438 21
        $result = array();
439 21
        foreach ($this->newWords as $pos => $s) {
440 21
            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
441 21
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
442 16
                    $result[] = $this->diffIsolatedPlaceholder($operation, $pos, $s);
443
                } else {
444 17
                    $result[] = $s;
445
                }
446
            }
447
        }
448 21
        $this->content .= implode('', $result);
449
    }
450
451
    /**
452
     * @param string $text
453
     * @param string $attribute
454
     *
455
     * @return null|string
456
     */
457 1
    protected function getAttributeFromTag($text, $attribute)
458
    {
459 1
        $matches = array();
460 1
        if (preg_match(sprintf('/<[^>]*\b%s\s*=\s*([\'"])(.*)\1[^>]*>/iu', $attribute), $text, $matches)) {
461 1
            return htmlspecialchars_decode($matches[2]);
462
        }
463
464
        return;
465
    }
466
467
    /**
468
     * @param string $text
469
     *
470
     * @return bool
471
     */
472 16
    protected function isListPlaceholder($text)
473
    {
474 16
        return $this->isPlaceholderType($text, ['ol', 'dl', 'ul']);
475
    }
476
477
    /**
478
     * @param string $text
479
     *
480
     * @return bool
481
     */
482 11
    public function isLinkPlaceholder($text)
483
    {
484 11
        return $this->isPlaceholderType($text, 'a');
485
    }
486
487
    /**
488
     * @param string $text
489
     *
490
     * @return bool
491
     */
492 10
    public function isImagePlaceholder($text)
493
    {
494 10
        return $this->isPlaceholderType($text, 'img');
495
    }
496
497 10
    public function isPicturePlaceholder($text)
498
    {
499 10
        return $this->isPlaceholderType($text, 'picture');
500
    }
501
502
    /**
503
     * @param string       $text
504
     * @param array|string $types
505
     *
506
     * @return bool
507
     */
508 16
    protected function isPlaceholderType($text, $types)
509
    {
510 16
        if (is_array($types) === false) {
511 12
            $types = [$types];
512
        }
513
514 16
        $criteria = [];
515
516 16
        foreach ($types as $type) {
517 16
            if ($this->config->isIsolatedDiffTag($type) === true) {
518 16
                $criteria[] = $this->config->getIsolatedDiffTagPlaceholder($type);
519
            } else {
520
                $criteria[] = $type;
521
            }
522
        }
523
524 16
        return in_array($text, $criteria, true);
525
    }
526
527
    /**
528
     * @param string $text
529
     *
530
     * @return bool
531
     */
532 12
    protected function isTablePlaceholder($text)
533
    {
534 12
        return $this->isPlaceholderType($text, 'table');
535
    }
536
537
    /**
538
     * @param Operation $operation
539
     * @param int       $posInNew
540
     *
541
     * @return array
542
     */
543 16
    protected function findIsolatedDiffTagsInOld($operation, $posInNew)
544
    {
545 16
        $offset = $posInNew - $operation->startInNew;
546
547 16
        return $this->oldIsolatedDiffTags[$operation->startInOld + $offset];
548
    }
549
550
    /**
551
     * @param string $tag
552
     * @param string $cssClass
553
     * @param array  $words
554
     */
555 18
    protected function insertTag($tag, $cssClass, &$words)
556
    {
557 18
        while (count($words) > 0) {
558 18
            $nonTags = $this->extractConsecutiveWords($words, 'noTag');
559
560 18
            if (count($nonTags) > 0) {
561 18
                $this->content .= $this->wrapText(implode('', $nonTags), $tag, $cssClass);
562
            }
563
564 18
            if (count($words) === 0) {
565 17
                break;
566
            }
567
568 9
            $workTag = $this->extractConsecutiveWords($words, 'tag');
569
570
            if (
571 9
                isset($workTag[0]) === true &&
572 9
                $this->isOpeningTag($workTag[0]) === true &&
573 9
                $this->isClosingTag($workTag[0]) === false
574
            ) {
575 9
                if ($this->stringUtil->strpos($workTag[0], 'class=')) {
576 2
                    $workTag[0] = str_replace('class="', 'class="diffmod ', $workTag[0]);
577
                } else {
578 9
                    $isSelfClosing = $this->stringUtil->strpos($workTag[0], '/>') !== false;
579
580 9
                    if ($isSelfClosing === true) {
581 5
                        $workTag[0] = str_replace('/>', ' class="diffmod" />', $workTag[0]);
582
                    } else {
583 8
                        $workTag[0] = str_replace('>', ' class="diffmod">', $workTag[0]);
584
                    }
585
                }
586
            }
587
588 9
            $appendContent = implode('', $workTag);
589
590 9
            if (isset($workTag[0]) === true && $this->stringUtil->stripos($workTag[0], '<img') !== false) {
591
                $appendContent = $this->wrapText($appendContent, $tag, $cssClass);
592
            }
593
594 9
            $this->content .= $appendContent;
595
        }
596
    }
597
598
    /**
599
     * @param string $word
600
     * @param string $condition
601
     *
602
     * @return bool
603
     */
604 18
    protected function checkCondition($word, $condition)
605
    {
606 18
        return $condition == 'tag' ? $this->isTag($word) : !$this->isTag($word);
607
    }
608
609 19
    protected function wrapText(string $text, string $tagName, string $cssClass) : string
610
    {
611 19
        if (!$this->config->isSpaceMatching() && trim($text) === '') {
612 7
            return '';
613
        }
614
615 19
        return sprintf('<%1$s class="%2$s">%3$s</%1$s>', $tagName, $cssClass, $text);
616
    }
617
618
    /**
619
     * @param array  $words
620
     * @param string $condition
621
     *
622
     * @return array
623
     */
624 18
    protected function extractConsecutiveWords(&$words, $condition)
625
    {
626 18
        $indexOfFirstTag = null;
627 18
        $words = array_values($words);
628 18
        foreach ($words as $i => $word) {
629 18
            if (!$this->checkCondition($word, $condition)) {
630 9
                $indexOfFirstTag = $i;
631 9
                break;
632
            }
633
        }
634 18
        if ($indexOfFirstTag !== null) {
635 9
            $items = array();
636 9
            foreach ($words as $pos => $s) {
637 9
                if ($pos >= 0 && $pos < $indexOfFirstTag) {
638 9
                    $items[] = $s;
639
                }
640
            }
641 9
            if ($indexOfFirstTag > 0) {
642 9
                array_splice($words, 0, $indexOfFirstTag);
643
            }
644
645 9
            return $items;
646
        } else {
647 18
            $items = array();
648 18
            foreach ($words as $pos => $s) {
649 18
                if ($pos >= 0 && $pos <= count($words)) {
650 18
                    $items[] = $s;
651
                }
652
            }
653 18
            array_splice($words, 0, count($words));
654
655 18
            return $items;
656
        }
657
    }
658
659
    /**
660
     * @param string $item
661
     *
662
     * @return bool
663
     */
664 21
    protected function isTag($item)
665
    {
666 21
        return $this->isOpeningTag($item) || $this->isClosingTag($item);
667
    }
668
669 21
    protected function isOpeningTag($item) : bool
670
    {
671 21
        return preg_match('#<[^>]+>\\s*#iUu', $item) === 1;
672
    }
673
674 21
    protected function isClosingTag($item) : bool
675
    {
676 21
        return preg_match('#</[^>]+>\\s*#iUu', $item) === 1;
677
    }
678
679
    /**
680
     * @return Operation[]
681
     */
682 21
    protected function operations()
683
    {
684 21
        $positionInOld = 0;
685 21
        $positionInNew = 0;
686 21
        $operations = array();
687
688 21
        $matches   = $this->matchingBlocks();
689 21
        $matches[] = new MatchingBlock(count($this->oldWords), count($this->newWords), 0);
690
691 21
        foreach ($matches as $match) {
692 21
            $matchStartsAtCurrentPositionInOld = ($positionInOld === $match->startInOld);
693 21
            $matchStartsAtCurrentPositionInNew = ($positionInNew === $match->startInNew);
694
695 21
            if ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === false) {
696 12
                $action = 'replace';
697 21
            } elseif ($matchStartsAtCurrentPositionInOld === true && $matchStartsAtCurrentPositionInNew === false) {
698 11
                $action = 'insert';
699 21
            } elseif ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === true) {
700 5
                $action = 'delete';
701
            } else { // This occurs if the first few words are the same in both versions
702 21
                $action = 'none';
703
            }
704
705 21
            if ($action !== 'none') {
706 18
                $operations[] = new Operation($action, $positionInOld, $match->startInOld, $positionInNew, $match->startInNew);
707
            }
708
709 21
            if (count($match) !== 0) {
710 21
                $operations[] = new Operation('equal', $match->startInOld, $match->endInOld(), $match->startInNew, $match->endInNew());
711
            }
712
713 21
            $positionInOld = $match->endInOld();
714 21
            $positionInNew = $match->endInNew();
715
        }
716
717 21
        return $operations;
718
    }
719
720
    /**
721
     * @return MatchingBlock[]
722
     */
723 21
    protected function matchingBlocks()
724
    {
725 21
        $matchingBlocks = array();
726 21
        $this->findMatchingBlocks(0, count($this->oldWords), 0, count($this->newWords), $matchingBlocks);
727
728 21
        return $matchingBlocks;
729
    }
730
731
    /**
732
     * @param MatchingBlock[] $matchingBlocks
733
     */
734 21
    protected function findMatchingBlocks(int $startInOld, int $endInOld, int $startInNew, int $endInNew, array &$matchingBlocks) : void
735
    {
736 21
        $match = $this->findMatch($startInOld, $endInOld, $startInNew, $endInNew);
737
738 21
        if ($match === null) {
739 12
            return;
740
        }
741
742 21
        if ($startInOld < $match->startInOld && $startInNew < $match->startInNew) {
743 10
            $this->findMatchingBlocks($startInOld, $match->startInOld, $startInNew, $match->startInNew, $matchingBlocks);
744
        }
745
746 21
        $matchingBlocks[] = $match;
747
748 21
        if ($match->endInOld() < $endInOld && $match->endInNew() < $endInNew) {
749 13
            $this->findMatchingBlocks($match->endInOld(), $endInOld, $match->endInNew(), $endInNew, $matchingBlocks);
750
        }
751
    }
752
753
    /**
754
     * @param string $word
755
     *
756
     * @return string
757
     */
758 11
    protected function stripTagAttributes($word)
759
    {
760 11
        $space = $this->stringUtil->strpos($word, ' ', 1);
761
762 11
        if ($space > 0) {
763 7
            return '<' . $this->stringUtil->substr($word, 1, $space) . '>';
764
        }
765
766 8
        return trim($word, '<>');
767
    }
768
769 21
    protected function findMatch(int $startInOld, int $endInOld, int $startInNew, int $endInNew) : ?MatchingBlock
770
    {
771 21
        $groupDiffs     = $this->isGroupDiffs();
772 21
        $bestMatchInOld = $startInOld;
773 21
        $bestMatchInNew = $startInNew;
774 21
        $bestMatchSize = 0;
775 21
        $matchLengthAt = [];
776
777 21
        for ($indexInOld = $startInOld; $indexInOld < $endInOld; ++$indexInOld) {
778 21
            $newMatchLengthAt = [];
779
780 21
            $index = $this->oldWords[ $indexInOld ];
781
782 21
            if ($this->isTag($index) === true) {
783 8
                $index = $this->stripTagAttributes($index);
784
            }
785
786 21
            if (isset($this->wordIndices[$index]) === false) {
787 15
                $matchLengthAt = $newMatchLengthAt;
788
789 15
                continue;
790
            }
791
792 21
            foreach ($this->wordIndices[$index] as $indexInNew) {
793 21
                if ($indexInNew < $startInNew) {
794 9
                    continue;
795
                }
796
797 21
                if ($indexInNew >= $endInNew) {
798 9
                    break;
799
                }
800
801 21
                $newMatchLength =
802 21
                    (isset($matchLengthAt[$indexInNew - 1]) === true ? ($matchLengthAt[$indexInNew - 1] + 1) : 1);
803
804 21
                $newMatchLengthAt[$indexInNew] = $newMatchLength;
805
806 21
                if ($newMatchLength > $bestMatchSize ||
807
                    (
808 21
                        $groupDiffs === true &&
809 21
                        $bestMatchSize > 0 &&
810 21
                        $this->oldTextIsOnlyWhitespace($bestMatchInOld, $bestMatchSize) === true
811
                    )
812
                ) {
813 21
                    $bestMatchInOld = $indexInOld - $newMatchLength + 1;
814 21
                    $bestMatchInNew = $indexInNew - $newMatchLength + 1;
815 21
                    $bestMatchSize  = $newMatchLength;
816
                }
817
            }
818
819 21
            $matchLengthAt = $newMatchLengthAt;
820
        }
821
822
        // Skip match if none found or match consists only of whitespace
823 21
        if ($bestMatchSize !== 0 &&
824
            (
825 21
                $groupDiffs === false ||
826 21
                $this->oldTextIsOnlyWhitespace($bestMatchInOld, $bestMatchSize) === false
827
            )
828
        ) {
829 21
            return new MatchingBlock($bestMatchInOld, $bestMatchInNew, $bestMatchSize);
830
        }
831
832 12
        return null;
833
    }
834
835 21
    protected function oldTextIsOnlyWhitespace(int $startingAtWord, int $wordCount) : bool
836
    {
837 21
        $isWhitespace = true;
838
839
        // oldTextIsWhitespace get called consecutively by findMatch, with the same parameters.
840
        // by caching the previous result, we speed up the algorithm by more then 50%
841 21
        static $lastStartingWordOffset = null;
842 21
        static $lastWordCount          = null;
843 21
        static $cache                  = null;
844
845 21
        if ($this->resetCache === true) {
846 21
            $cache = null;
847
848 21
            $this->resetCache = false;
849
        }
850
851
        if (
852 21
            $cache !== null &&
853 21
            $lastWordCount === $wordCount &&
854 21
            $lastStartingWordOffset === $startingAtWord
855
        ) { // Hit
856 15
            return $cache;
857
        } // Miss
858
859 21
        for ($index = $startingAtWord; $index < ($startingAtWord + $wordCount); $index++) {
860
            // Assigning the oldWord to a variable is slightly faster then searching by reference twice
861
            // in the if statement
862 21
            $oldWord = $this->oldWords[$index];
863
864 21
            if ($oldWord !== '' && trim($oldWord) !== '') {
865 21
                $isWhitespace = false;
866
867 21
                break;
868
            }
869
        }
870
871 21
        $lastWordCount          = $wordCount;
872 21
        $lastStartingWordOffset = $startingAtWord;
873
874 21
        $cache = $isWhitespace;
875
876 21
        return $cache;
877
    }
878
}
879