Issues (4)

lib/Caxy/HtmlDiff/HtmlDiff.php (3 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 15
    public static function create($oldText, $newText, HtmlDiffConfig $config = null)
35
    {
36 15
        $diff = new self($oldText, $newText);
37
38 15
        if (null !== $config) {
39 15
            $diff->setConfig($config);
40
        }
41
42 15
        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 18
    public function build()
87
    {
88 18
        $this->prepare();
89
90 18
        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 18
        if ($this->oldText == $this->newText) {
100 11
            return $this->newText;
101
        }
102
103 18
        $this->splitInputsToWords();
104 18
        $this->replaceIsolatedDiffTags();
105 18
        $this->indexNewWords();
106
107 18
        $operations = $this->operations();
108
109 18
        foreach ($operations as $item) {
110 18
            $this->performOperation($item);
111
        }
112
113 18
        if ($this->hasDiffCache()) {
114
            $this->getDiffCache()->save($this->oldText, $this->newText, $this->content);
115
        }
116
117 18
        return $this->content;
118
    }
119
120 18
    protected function indexNewWords() : void
121
    {
122 18
        $this->wordIndices = [];
123
124 18
        foreach ($this->newWords as $i => $word) {
125 18
            if ($this->isTag($word) === true) {
126 10
                $word = $this->stripTagAttributes($word);
127
            }
128
129 18
            if (isset($this->wordIndices[$word]) === false) {
130 18
                $this->wordIndices[$word] = [];
131
            }
132
133 18
            $this->wordIndices[$word][] = $i;
134
        }
135 18
    }
136
137 18
    protected function replaceIsolatedDiffTags()
138
    {
139 18
        $this->oldIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->oldWords);
140 18
        $this->newIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->newWords);
141 18
    }
142
143
    /**
144
     * @param array $words
145
     *
146
     * @return array
147
     */
148 18
    protected function createIsolatedDiffTagPlaceholders(&$words)
149
    {
150 18
        $openIsolatedDiffTags = 0;
151 18
        $isolatedDiffTagIndices = array();
152 18
        $isolatedDiffTagStart = 0;
153 18
        $currentIsolatedDiffTag = null;
154 18
        foreach ($words as $index => $word) {
155 18
            $openIsolatedDiffTag = $this->isOpeningIsolatedDiffTag($word, $currentIsolatedDiffTag);
156 18
            if ($openIsolatedDiffTag) {
157 16
                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 16
                    if ($openIsolatedDiffTags === 0) {
168 16
                        $isolatedDiffTagStart = $index;
169
                    }
170 16
                    ++$openIsolatedDiffTags;
171 16
                    $currentIsolatedDiffTag = $openIsolatedDiffTag;
172
                }
173 18
            } elseif ($openIsolatedDiffTags > 0 && $this->isClosingIsolatedDiffTag($word, $currentIsolatedDiffTag)) {
174 16
                --$openIsolatedDiffTags;
175 16
                if ($openIsolatedDiffTags == 0) {
176 16
                    $isolatedDiffTagIndices[] = array('start' => $isolatedDiffTagStart, 'length' => $index - $isolatedDiffTagStart + 1, 'tagType' => $currentIsolatedDiffTag);
177 16
                    $currentIsolatedDiffTag = null;
178
                }
179
            }
180
        }
181 18
        $isolatedDiffTagScript = array();
182 18
        $offset = 0;
183 18
        foreach ($isolatedDiffTagIndices as $isolatedDiffTagIndex) {
184 16
            $start = $isolatedDiffTagIndex['start'] - $offset;
185 16
            $placeholderString = $this->config->getIsolatedDiffTagPlaceholder($isolatedDiffTagIndex['tagType']);
186 16
            $isolatedDiffTagScript[$start] = array_splice($words, $start, $isolatedDiffTagIndex['length'], $placeholderString);
187 16
            $offset += $isolatedDiffTagIndex['length'] - 1;
188
        }
189
190 18
        return $isolatedDiffTagScript;
191
    }
192
193
    /**
194
     * @param string      $item
195
     * @param null|string $currentIsolatedDiffTag
196
     *
197
     * @return false|string
198
     */
199 18
    protected function isOpeningIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
200
    {
201 18
        $tagsToMatch = $currentIsolatedDiffTag !== null
202 16
            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
203 18
            : $this->config->getIsolatedDiffTags();
204 18
        $pattern = '#<%s(\s+[^>]*)?>#iUu';
205 18
        foreach ($tagsToMatch as $key => $value) {
206 18
            if (preg_match(sprintf($pattern, $key), $item)) {
207 16
                return $key;
208
            }
209
        }
210
211 18
        return false;
212
    }
213
214 16
    protected function isSelfClosingTag($text)
215
    {
216 16
        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 16
    protected function isClosingIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
226
    {
227 16
        $tagsToMatch = $currentIsolatedDiffTag !== null
228 16
            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
229 16
            : $this->config->getIsolatedDiffTags();
230 16
        $pattern = '#</%s(\s+[^>]*)?>#iUu';
231 16
        foreach ($tagsToMatch as $key => $value) {
232 16
            if (preg_match(sprintf($pattern, $key), $item)) {
233 16
                return $key;
234
            }
235
        }
236
237 16
        return false;
238
    }
239
240
    /**
241
     * @param Operation $operation
242
     */
243 18
    protected function performOperation($operation)
244
    {
245 18
        switch ($operation->action) {
246 18
            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 18
                $this->processEqualOperation($operation);
248 18
                break;
249 15
            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 4
                $this->processDeleteOperation($operation, 'diffdel');
251 4
                break;
252 15
            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 10
                $this->processInsertOperation($operation, 'diffins');
254 10
                break;
255 11
            case 'replace':
256 11
                $this->processReplaceOperation($operation);
257 11
                break;
258
            default:
259
                break;
260
        }
261 18
    }
262
263
    /**
264
     * @param Operation $operation
265
     */
266 11
    protected function processReplaceOperation($operation)
267
    {
268 11
        $this->processDeleteOperation($operation, 'diffmod');
269 11
        $this->processInsertOperation($operation, 'diffmod');
270 11
    }
271
272
    /**
273
     * @param Operation $operation
274
     * @param string    $cssClass
275
     */
276 15
    protected function processInsertOperation($operation, $cssClass)
277
    {
278 15
        $text = array();
279 15
        foreach ($this->newWords as $pos => $s) {
280 15
            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
281 15
                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 15
                    $text[] = $s;
287
                }
288
            }
289
        }
290
291 15
        $this->insertTag('ins', $cssClass, $text);
292 15
    }
293
294
    /**
295
     * @param Operation $operation
296
     * @param string    $cssClass
297
     */
298 13
    protected function processDeleteOperation($operation, $cssClass)
299
    {
300 13
        $text = array();
301 13
        foreach ($this->oldWords as $pos => $s) {
302 13
            if ($pos >= $operation->startInOld && $pos < $operation->endInOld) {
303 13
                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 13
                    $text[] = $s;
309
                }
310
            }
311
        }
312 13
        $this->insertTag('del', $cssClass, $text);
313 13
    }
314
315
    /**
316
     * @param Operation $operation
317
     * @param int       $pos
318
     * @param string    $placeholder
319
     * @param bool      $stripWrappingTags
320
     *
321
     * @return string
322
     */
323 15
    protected function diffIsolatedPlaceholder($operation, $pos, $placeholder, $stripWrappingTags = true)
324
    {
325 15
        $oldText = implode('', $this->findIsolatedDiffTagsInOld($operation, $pos));
326 15
        $newText = implode('', $this->newIsolatedDiffTags[$pos]);
327
328 15
        if ($this->isListPlaceholder($placeholder)) {
329 8
            return $this->diffList($oldText, $newText);
330 11
        } elseif ($this->config->isUseTableDiffing() && $this->isTablePlaceholder($placeholder)) {
331 1
            return $this->diffTables($oldText, $newText);
332 10
        } elseif ($this->isLinkPlaceholder($placeholder)) {
333 1
            return $this->diffElementsByAttribute($oldText, $newText, 'href', 'a');
334 9
        } elseif ($this->isImagePlaceholder($placeholder)) {
335
            return $this->diffElementsByAttribute($oldText, $newText, 'src', 'img');
336
        }
337
338 9
        return $this->diffElements($oldText, $newText, $stripWrappingTags);
339
    }
340
341
    /**
342
     * @param string $oldText
343
     * @param string $newText
344
     * @param bool   $stripWrappingTags
345
     *
346
     * @return string
347
     */
348 10
    protected function diffElements($oldText, $newText, $stripWrappingTags = true)
349
    {
350 10
        $wrapStart = '';
351 10
        $wrapEnd = '';
352
353 10
        if ($stripWrappingTags) {
354 10
            $pattern = '/(^<[^>]+>)|(<\/[^>]+>$)/iu';
355 10
            $matches = array();
356
357 10
            if (preg_match_all($pattern, $newText, $matches)) {
358 10
                $wrapStart = isset($matches[0][0]) ? $matches[0][0] : '';
359 10
                $wrapEnd = isset($matches[0][1]) ? $matches[0][1] : '';
360
            }
361 10
            $oldText = preg_replace($pattern, '', $oldText);
362 10
            $newText = preg_replace($pattern, '', $newText);
363
        }
364
365 10
        $diff = self::create($oldText, $newText, $this->config);
366
367 10
        return $wrapStart.$diff->build().$wrapEnd;
368
    }
369
370
    /**
371
     * @param string $oldText
372
     * @param string $newText
373
     *
374
     * @return string
375
     */
376 8
    protected function diffList($oldText, $newText)
377
    {
378 8
        $diff = ListDiffLines::create($oldText, $newText, $this->config);
379
380 8
        return $diff->build();
381
    }
382
383
    /**
384
     * @param string $oldText
385
     * @param string $newText
386
     *
387
     * @return string
388
     */
389 1
    protected function diffTables($oldText, $newText)
390
    {
391 1
        $diff = TableDiff::create($oldText, $newText, $this->config);
392
393 1
        return $diff->build();
394
    }
395
396 1
    protected function diffElementsByAttribute($oldText, $newText, $attribute, $element)
397
    {
398 1
        $oldAttribute = $this->getAttributeFromTag($oldText, $attribute);
399 1
        $newAttribute = $this->getAttributeFromTag($newText, $attribute);
400
401 1
        if ($oldAttribute !== $newAttribute) {
402 1
            $diffClass = sprintf('diffmod diff%s diff%s', $element, $attribute);
403
404 1
            return sprintf(
405 1
                '%s%s',
406 1
                $this->wrapText($oldText, 'del', $diffClass),
407 1
                $this->wrapText($newText, 'ins', $diffClass)
408
            );
409
        }
410
411 1
        return $this->diffElements($oldText, $newText);
412
    }
413
414
    /**
415
     * @param Operation $operation
416
     */
417 18
    protected function processEqualOperation($operation)
418
    {
419 18
        $result = array();
420 18
        foreach ($this->newWords as $pos => $s) {
421 18
            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
422 18
                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
423 15
                    $result[] = $this->diffIsolatedPlaceholder($operation, $pos, $s);
424
                } else {
425 14
                    $result[] = $s;
426
                }
427
            }
428
        }
429 18
        $this->content .= implode('', $result);
430 18
    }
431
432
    /**
433
     * @param string $text
434
     * @param string $attribute
435
     *
436
     * @return null|string
437
     */
438 1
    protected function getAttributeFromTag($text, $attribute)
439
    {
440 1
        $matches = array();
441 1
        if (preg_match(sprintf('/<[^>]*\b%s\s*=\s*([\'"])(.*)\1[^>]*>/iu', $attribute), $text, $matches)) {
442 1
            return htmlspecialchars_decode($matches[2]);
443
        }
444
445
        return;
446
    }
447
448
    /**
449
     * @param string $text
450
     *
451
     * @return bool
452
     */
453 15
    protected function isListPlaceholder($text)
454
    {
455 15
        return $this->isPlaceholderType($text, ['ol', 'dl', 'ul']);
456
    }
457
458
    /**
459
     * @param string $text
460
     *
461
     * @return bool
462
     */
463 10
    public function isLinkPlaceholder($text)
464
    {
465 10
        return $this->isPlaceholderType($text, 'a');
466
    }
467
468
    /**
469
     * @param string $text
470
     *
471
     * @return bool
472
     */
473 9
    public function isImagePlaceholder($text)
474
    {
475 9
        return $this->isPlaceholderType($text, 'img');
476
    }
477
478
    /**
479
     * @param string       $text
480
     * @param array|string $types
481
     *
482
     * @return bool
483
     */
484 15
    protected function isPlaceholderType($text, $types)
485
    {
486 15
        if (is_array($types) === false) {
487 11
            $types = [$types];
488
        }
489
490 15
        $criteria = [];
491
492 15
        foreach ($types as $type) {
493 15
            if ($this->config->isIsolatedDiffTag($type) === true) {
494 15
                $criteria[] = $this->config->getIsolatedDiffTagPlaceholder($type);
495
            } else {
496
                $criteria[] = $type;
497
            }
498
        }
499
500 15
        return in_array($text, $criteria, true);
501
    }
502
503
    /**
504
     * @param string $text
505
     *
506
     * @return bool
507
     */
508 11
    protected function isTablePlaceholder($text)
509
    {
510 11
        return $this->isPlaceholderType($text, 'table');
511
    }
512
513
    /**
514
     * @param Operation $operation
515
     * @param int       $posInNew
516
     *
517
     * @return array
518
     */
519 15
    protected function findIsolatedDiffTagsInOld($operation, $posInNew)
520
    {
521 15
        $offset = $posInNew - $operation->startInNew;
522
523 15
        return $this->oldIsolatedDiffTags[$operation->startInOld + $offset];
524
    }
525
526
    /**
527
     * @param string $tag
528
     * @param string $cssClass
529
     * @param array  $words
530
     */
531 15
    protected function insertTag($tag, $cssClass, &$words)
532
    {
533 15
        while (count($words) > 0) {
534 15
            $nonTags = $this->extractConsecutiveWords($words, 'noTag');
535
536 15
            if (count($nonTags) > 0) {
537 15
                $this->content .= $this->wrapText(implode('', $nonTags), $tag, $cssClass);
538
            }
539
540 15
            if (count($words) === 0) {
541 14
                break;
542
            }
543
544 9
            $workTag = $this->extractConsecutiveWords($words, 'tag');
545
546
            if (
547 9
                isset($workTag[0]) === true &&
548 9
                $this->isOpeningTag($workTag[0]) === true &&
549 9
                $this->isClosingTag($workTag[0]) === false
550
            ) {
551 9
                if ($this->stringUtil->strpos($workTag[0], 'class=')) {
552 2
                    $workTag[0] = str_replace('class="', 'class="diffmod ', $workTag[0]);
553
                } else {
554 9
                    $isSelfClosing = $this->stringUtil->strpos($workTag[0], '/>') !== false;
555
556 9
                    if ($isSelfClosing === true) {
557 5
                        $workTag[0] = str_replace('/>', ' class="diffmod" />', $workTag[0]);
558
                    } else {
559 8
                        $workTag[0] = str_replace('>', ' class="diffmod">', $workTag[0]);
560
                    }
561
                }
562
            }
563
564 9
            $appendContent = implode('', $workTag);
565
566 9
            if (isset($workTag[0]) === true && $this->stringUtil->stripos($workTag[0], '<img') !== false) {
567
                $appendContent = $this->wrapText($appendContent, $tag, $cssClass);
568
            }
569
570 9
            $this->content .= $appendContent;
571
        }
572 15
    }
573
574
    /**
575
     * @param string $word
576
     * @param string $condition
577
     *
578
     * @return bool
579
     */
580 15
    protected function checkCondition($word, $condition)
581
    {
582 15
        return $condition == 'tag' ? $this->isTag($word) : !$this->isTag($word);
583
    }
584
585 16
    protected function wrapText(string $text, string $tagName, string $cssClass) : string
586
    {
587 16
        if (trim($text) === '') {
588 7
            return '';
589
        }
590
591 16
        return sprintf('<%1$s class="%2$s">%3$s</%1$s>', $tagName, $cssClass, $text);
592
    }
593
594
    /**
595
     * @param array  $words
596
     * @param string $condition
597
     *
598
     * @return array
599
     */
600 15
    protected function extractConsecutiveWords(&$words, $condition)
601
    {
602 15
        $indexOfFirstTag = null;
603 15
        $words = array_values($words);
604 15
        foreach ($words as $i => $word) {
605 15
            if (!$this->checkCondition($word, $condition)) {
606 9
                $indexOfFirstTag = $i;
607 9
                break;
608
            }
609
        }
610 15
        if ($indexOfFirstTag !== null) {
611 9
            $items = array();
612 9
            foreach ($words as $pos => $s) {
613 9
                if ($pos >= 0 && $pos < $indexOfFirstTag) {
614 9
                    $items[] = $s;
615
                }
616
            }
617 9
            if ($indexOfFirstTag > 0) {
618 9
                array_splice($words, 0, $indexOfFirstTag);
619
            }
620
621 9
            return $items;
622
        } else {
623 15
            $items = array();
624 15
            foreach ($words as $pos => $s) {
625 15
                if ($pos >= 0 && $pos <= count($words)) {
626 15
                    $items[] = $s;
627
                }
628
            }
629 15
            array_splice($words, 0, count($words));
630
631 15
            return $items;
632
        }
633
    }
634
635
    /**
636
     * @param string $item
637
     *
638
     * @return bool
639
     */
640 18
    protected function isTag($item)
641
    {
642 18
        return $this->isOpeningTag($item) || $this->isClosingTag($item);
643
    }
644
645 18
    protected function isOpeningTag($item) : bool
646
    {
647 18
        return preg_match('#<[^>]+>\\s*#iUu', $item) === 1;
648
    }
649
650 18
    protected function isClosingTag($item) : bool
651
    {
652 18
        return preg_match('#</[^>]+>\\s*#iUu', $item) === 1;
653
    }
654
655
    /**
656
     * @return Operation[]
657
     */
658 18
    protected function operations()
659
    {
660 18
        $positionInOld = 0;
661 18
        $positionInNew = 0;
662 18
        $operations = array();
663
664 18
        $matches   = $this->matchingBlocks();
665 18
        $matches[] = new MatchingBlock(count($this->oldWords), count($this->newWords), 0);
666
667 18
        foreach ($matches as $match) {
668 18
            $matchStartsAtCurrentPositionInOld = ($positionInOld === $match->startInOld);
669 18
            $matchStartsAtCurrentPositionInNew = ($positionInNew === $match->startInNew);
670
671 18
            if ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === false) {
672 11
                $action = 'replace';
673 18
            } elseif ($matchStartsAtCurrentPositionInOld === true && $matchStartsAtCurrentPositionInNew === false) {
674 10
                $action = 'insert';
675 18
            } elseif ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === true) {
676 4
                $action = 'delete';
677
            } else { // This occurs if the first few words are the same in both versions
678 18
                $action = 'none';
679
            }
680
681 18
            if ($action !== 'none') {
682 15
                $operations[] = new Operation($action, $positionInOld, $match->startInOld, $positionInNew, $match->startInNew);
683
            }
684
685 18
            if (count($match) !== 0) {
686 18
                $operations[] = new Operation('equal', $match->startInOld, $match->endInOld(), $match->startInNew, $match->endInNew());
687
            }
688
689 18
            $positionInOld = $match->endInOld();
690 18
            $positionInNew = $match->endInNew();
691
        }
692
693 18
        return $operations;
694
    }
695
696
    /**
697
     * @return MatchingBlock[]
698
     */
699 18
    protected function matchingBlocks()
700
    {
701 18
        $matchingBlocks = array();
702 18
        $this->findMatchingBlocks(0, count($this->oldWords), 0, count($this->newWords), $matchingBlocks);
703
704 18
        return $matchingBlocks;
705
    }
706
707
    /**
708
     * @param MatchingBlock[] $matchingBlocks
709
     */
710 18
    protected function findMatchingBlocks(int $startInOld, int $endInOld, int $startInNew, int $endInNew, array &$matchingBlocks) : void
711
    {
712 18
        $match = $this->findMatch($startInOld, $endInOld, $startInNew, $endInNew);
713
714 18
        if ($match === null) {
715 11
            return;
716
        }
717
718 18
        if ($startInOld < $match->startInOld && $startInNew < $match->startInNew) {
719 9
            $this->findMatchingBlocks($startInOld, $match->startInOld, $startInNew, $match->startInNew, $matchingBlocks);
720
        }
721
722 18
        $matchingBlocks[] = $match;
723
724 18
        if ($match->endInOld() < $endInOld && $match->endInNew() < $endInNew) {
725 11
            $this->findMatchingBlocks($match->endInOld(), $endInOld, $match->endInNew(), $endInNew, $matchingBlocks);
726
        }
727 18
    }
728
729
    /**
730
     * @param string $word
731
     *
732
     * @return string
733
     */
734 10
    protected function stripTagAttributes($word)
735
    {
736 10
        $space = $this->stringUtil->strpos($word, ' ', 1);
737
738 10
        if ($space > 0) {
739 7
            return '<' . $this->stringUtil->substr($word, 1, $space) . '>';
740
        }
741
742 7
        return trim($word, '<>');
743
    }
744
745 18
    protected function findMatch(int $startInOld, int $endInOld, int $startInNew, int $endInNew) : ?MatchingBlock
746
    {
747 18
        $groupDiffs     = $this->isGroupDiffs();
748 18
        $bestMatchInOld = $startInOld;
749 18
        $bestMatchInNew = $startInNew;
750 18
        $bestMatchSize = 0;
751 18
        $matchLengthAt = [];
752
753 18
        for ($indexInOld = $startInOld; $indexInOld < $endInOld; ++$indexInOld) {
754 18
            $newMatchLengthAt = [];
755
756 18
            $index = $this->oldWords[ $indexInOld ];
757
758 18
            if ($this->isTag($index) === true) {
759 7
                $index = $this->stripTagAttributes($index);
760
            }
761
762 18
            if (isset($this->wordIndices[$index]) === false) {
763 13
                $matchLengthAt = $newMatchLengthAt;
764
765 13
                continue;
766
            }
767
768 18
            foreach ($this->wordIndices[$index] as $indexInNew) {
769 18
                if ($indexInNew < $startInNew) {
770 8
                    continue;
771
                }
772
773 18
                if ($indexInNew >= $endInNew) {
774 9
                    break;
775
                }
776
777
                $newMatchLength =
778 18
                    (isset($matchLengthAt[$indexInNew - 1]) === true ? ($matchLengthAt[$indexInNew - 1] + 1) : 1);
779
780 18
                $newMatchLengthAt[$indexInNew] = $newMatchLength;
781
782 18
                if ($newMatchLength > $bestMatchSize ||
783
                    (
784 13
                        $groupDiffs === true &&
785 13
                        $bestMatchSize > 0 &&
786 18
                        $this->oldTextIsOnlyWhitespace($bestMatchInOld, $bestMatchSize) === true
787
                    )
788
                ) {
789 18
                    $bestMatchInOld = $indexInOld - $newMatchLength + 1;
790 18
                    $bestMatchInNew = $indexInNew - $newMatchLength + 1;
791 18
                    $bestMatchSize  = $newMatchLength;
792
                }
793
            }
794
795 18
            $matchLengthAt = $newMatchLengthAt;
796
        }
797
798
        // Skip match if none found or match consists only of whitespace
799 18
        if ($bestMatchSize !== 0 &&
800
            (
801 18
                $groupDiffs === false ||
802 18
                $this->oldTextIsOnlyWhitespace($bestMatchInOld, $bestMatchSize) === false
803
            )
804
        ) {
805 18
            return new MatchingBlock($bestMatchInOld, $bestMatchInNew, $bestMatchSize);
806
        }
807
808 11
        return null;
809
    }
810
811 18
    protected function oldTextIsOnlyWhitespace(int $startingAtWord, int $wordCount) : bool
812
    {
813 18
        $isWhitespace = true;
814
815
        // oldTextIsWhitespace get called consecutively by findMatch, with the same parameters.
816
        // by caching the previous result, we speed up the algorithm by more then 50%
817 18
        static $lastStartingWordOffset = null;
818 18
        static $lastWordCount          = null;
819 18
        static $cache                  = null;
820
821 18
        if ($this->resetCache === true) {
822 18
            $cache = null;
823
824 18
            $this->resetCache = false;
825
        }
826
827
        if (
828 18
            $cache !== null &&
829 18
            $lastWordCount === $wordCount &&
830 18
            $lastStartingWordOffset === $startingAtWord
831
        ) { // Hit
832 13
            return $cache;
833
        } // Miss
834
835 18
        for ($index = $startingAtWord; $index < ($startingAtWord + $wordCount); $index++) {
836
            // Assigning the oldWord to a variable is slightly faster then searching by reference twice
837
            // in the if statement
838 18
            $oldWord = $this->oldWords[$index];
839
840 18
            if ($oldWord !== '' && trim($oldWord) !== '') {
841 18
                $isWhitespace = false;
842
843 18
                break;
844
            }
845
        }
846
847 18
        $lastWordCount          = $wordCount;
848 18
        $lastStartingWordOffset = $startingAtWord;
849
850 18
        $cache = $isWhitespace;
851
852 18
        return $cache;
853
    }
854
}
855