Passed
Push — master ( aca63d...97e3ed )
by Josh
02:02
created

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, array('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
     * @param bool         $strict
482
     *
483
     * @return bool
484
     */
485 15
    protected function isPlaceholderType($text, $types, $strict = true)
486
    {
487 15
        if (!is_array($types)) {
488 11
            $types = array($types);
489
        }
490
491 15
        $criteria = array();
492 15
        foreach ($types as $type) {
493 15
            if ($this->config->isIsolatedDiffTag($type)) {
494 15
                $criteria[] = $this->config->getIsolatedDiffTagPlaceholder($type);
495
            } else {
496
                $criteria[] = $type;
497
            }
498
        }
499
500 15
        return in_array($text, $criteria, $strict);
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 (true) {
534 15
            if (count($words) === 0) {
535 9
                break;
536
            }
537
538 15
            $nonTags = $this->extractConsecutiveWords($words, 'noTag');
539
540 15
            $specialCaseTagInjection = '';
541 15
            $specialCaseTagInjectionIsBefore = false;
542
543 15
            if (count($nonTags) !== 0) {
544 15
                $this->content .= $this->wrapText(implode('', $nonTags), $tag, $cssClass);
545
            } else {
546 7
                $firstOrDefault = false;
547 7
                foreach ($this->config->getSpecialCaseOpeningTags() as $x) {
548
                    if (preg_match($x, $words[ 0 ])) {
549
                        $firstOrDefault = $x;
550
                        break;
551
                    }
552
                }
553 7
                if ($firstOrDefault) {
554
                    $specialCaseTagInjection = '<ins class="mod">';
555
                    if ($tag === 'del') {
556
                        unset($words[ 0 ]);
557
                    }
558 7
                } elseif (array_search($words[ 0 ], $this->config->getSpecialCaseClosingTags()) !== false) {
559
                    $specialCaseTagInjection = '</ins>';
560
                    $specialCaseTagInjectionIsBefore = true;
561
                    if ($tag === 'del') {
562
                        unset($words[ 0 ]);
563
                    }
564
                }
565
            }
566 15
            if (count($words) == 0 && $this->stringUtil->strlen($specialCaseTagInjection) == 0) {
567 14
                break;
568
            }
569 9
            if ($specialCaseTagInjectionIsBefore) {
570
                $this->content .= $specialCaseTagInjection . implode('', $this->extractConsecutiveWords($words, 'tag'));
571
            } else {
572 9
                $workTag = $this->extractConsecutiveWords($words, 'tag');
573
574
                if (
575 9
                    isset($workTag[0]) === true &&
576 9
                    $this->isOpeningTag($workTag[0]) === true &&
577 9
                    $this->isClosingTag($workTag[0]) === false
578
                ) {
579 9
                    if ($this->stringUtil->strpos($workTag[0], 'class=')) {
580 2
                        $workTag[0] = str_replace('class="', 'class="diffmod ', $workTag[0]);
581
                    } else {
582 9
                        $isSelfClosing = $this->stringUtil->strpos($workTag[0], '/>') !== false;
583
584 9
                        if ($isSelfClosing === true) {
585 5
                            $workTag[0] = str_replace('/>', ' class="diffmod" />', $workTag[0]);
586
                        } else {
587 8
                            $workTag[0] = str_replace('>', ' class="diffmod">', $workTag[0]);
588
                        }
589
                    }
590
                }
591
592 9
                $appendContent = implode('', $workTag) . $specialCaseTagInjection;
593
594 9
                if (isset($workTag[0]) === true && $this->stringUtil->stripos($workTag[0], '<img') !== false) {
595
                    $appendContent = $this->wrapText($appendContent, $tag, $cssClass);
596
                }
597
598 9
                $this->content .= $appendContent;
599
            }
600
        }
601 15
    }
602
603
    /**
604
     * @param string $word
605
     * @param string $condition
606
     *
607
     * @return bool
608
     */
609 15
    protected function checkCondition($word, $condition)
610
    {
611 15
        return $condition == 'tag' ? $this->isTag($word) : !$this->isTag($word);
612
    }
613
614 16
    protected function wrapText(string $text, string $tagName, string $cssClass) : string
615
    {
616 16
        if (trim($text) === '') {
617 7
            return '';
618
        }
619
620 16
        return sprintf('<%1$s class="%2$s">%3$s</%1$s>', $tagName, $cssClass, $text);
621
    }
622
623
    /**
624
     * @param array  $words
625
     * @param string $condition
626
     *
627
     * @return array
628
     */
629 15
    protected function extractConsecutiveWords(&$words, $condition)
630
    {
631 15
        $indexOfFirstTag = null;
632 15
        $words = array_values($words);
633 15
        foreach ($words as $i => $word) {
634 15
            if (!$this->checkCondition($word, $condition)) {
635 9
                $indexOfFirstTag = $i;
636 9
                break;
637
            }
638
        }
639 15
        if ($indexOfFirstTag !== null) {
640 9
            $items = array();
641 9
            foreach ($words as $pos => $s) {
642 9
                if ($pos >= 0 && $pos < $indexOfFirstTag) {
643 9
                    $items[] = $s;
644
                }
645
            }
646 9
            if ($indexOfFirstTag > 0) {
647 9
                array_splice($words, 0, $indexOfFirstTag);
648
            }
649
650 9
            return $items;
651
        } else {
652 15
            $items = array();
653 15
            foreach ($words as $pos => $s) {
654 15
                if ($pos >= 0 && $pos <= count($words)) {
655 15
                    $items[] = $s;
656
                }
657
            }
658 15
            array_splice($words, 0, count($words));
659
660 15
            return $items;
661
        }
662
    }
663
664
    /**
665
     * @param string $item
666
     *
667
     * @return bool
668
     */
669 18
    protected function isTag($item)
670
    {
671 18
        return $this->isOpeningTag($item) || $this->isClosingTag($item);
672
    }
673
674 18
    protected function isOpeningTag($item) : bool
675
    {
676 18
        return preg_match('#<[^>]+>\\s*#iUu', $item) === 1;
677
    }
678
679 18
    protected function isClosingTag($item) : bool
680
    {
681 18
        return preg_match('#</[^>]+>\\s*#iUu', $item) === 1;
682
    }
683
684
    /**
685
     * @return Operation[]
686
     */
687 18
    protected function operations()
688
    {
689 18
        $positionInOld = 0;
690 18
        $positionInNew = 0;
691 18
        $operations = array();
692
693 18
        $matches   = $this->matchingBlocks();
694 18
        $matches[] = new MatchingBlock(count($this->oldWords), count($this->newWords), 0);
695
696 18
        foreach ($matches as $match) {
697 18
            $matchStartsAtCurrentPositionInOld = ($positionInOld === $match->startInOld);
698 18
            $matchStartsAtCurrentPositionInNew = ($positionInNew === $match->startInNew);
699
700 18
            if ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === false) {
701 11
                $action = 'replace';
702 18
            } elseif ($matchStartsAtCurrentPositionInOld === true && $matchStartsAtCurrentPositionInNew === false) {
703 10
                $action = 'insert';
704 18
            } elseif ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === true) {
705 4
                $action = 'delete';
706
            } else { // This occurs if the first few words are the same in both versions
707 18
                $action = 'none';
708
            }
709
710 18
            if ($action !== 'none') {
711 15
                $operations[] = new Operation($action, $positionInOld, $match->startInOld, $positionInNew, $match->startInNew);
712
            }
713
714 18
            if (count($match) !== 0) {
715 18
                $operations[] = new Operation('equal', $match->startInOld, $match->endInOld(), $match->startInNew, $match->endInNew());
716
            }
717
718 18
            $positionInOld = $match->endInOld();
719 18
            $positionInNew = $match->endInNew();
720
        }
721
722 18
        return $operations;
723
    }
724
725
    /**
726
     * @return MatchingBlock[]
727
     */
728 18
    protected function matchingBlocks()
729
    {
730 18
        $matchingBlocks = array();
731 18
        $this->findMatchingBlocks(0, count($this->oldWords), 0, count($this->newWords), $matchingBlocks);
732
733 18
        return $matchingBlocks;
734
    }
735
736
    /**
737
     * @param MatchingBlock[] $matchingBlocks
738
     */
739 18
    protected function findMatchingBlocks(int $startInOld, int $endInOld, int $startInNew, int $endInNew, array &$matchingBlocks) : void
740
    {
741 18
        $match = $this->findMatch($startInOld, $endInOld, $startInNew, $endInNew);
742
743 18
        if ($match === null) {
744 11
            return;
745
        }
746
747 18
        if ($startInOld < $match->startInOld && $startInNew < $match->startInNew) {
748 9
            $this->findMatchingBlocks($startInOld, $match->startInOld, $startInNew, $match->startInNew, $matchingBlocks);
749
        }
750
751 18
        $matchingBlocks[] = $match;
752
753 18
        if ($match->endInOld() < $endInOld && $match->endInNew() < $endInNew) {
754 11
            $this->findMatchingBlocks($match->endInOld(), $endInOld, $match->endInNew(), $endInNew, $matchingBlocks);
755
        }
756 18
    }
757
758
    /**
759
     * @param string $word
760
     *
761
     * @return string
762
     */
763 10
    protected function stripTagAttributes($word)
764
    {
765 10
        $space = $this->stringUtil->strpos($word, ' ', 1);
766
767 10
        if ($space > 0) {
768 7
            return '<' . $this->stringUtil->substr($word, 1, $space) . '>';
769
        }
770
771 7
        return trim($word, '<>');
772
    }
773
774 18
    protected function findMatch(int $startInOld, int $endInOld, int $startInNew, int $endInNew) : ?MatchingBlock
775
    {
776 18
        $groupDiffs     = $this->isGroupDiffs();
777 18
        $bestMatchInOld = $startInOld;
778 18
        $bestMatchInNew = $startInNew;
779 18
        $bestMatchSize = 0;
780 18
        $matchLengthAt = [];
781
782 18
        for ($indexInOld = $startInOld; $indexInOld < $endInOld; ++$indexInOld) {
783 18
            $newMatchLengthAt = [];
784
785 18
            $index = $this->oldWords[ $indexInOld ];
786
787 18
            if ($this->isTag($index) === true) {
788 7
                $index = $this->stripTagAttributes($index);
789
            }
790
791 18
            if (isset($this->wordIndices[$index]) === false) {
792 13
                $matchLengthAt = $newMatchLengthAt;
793
794 13
                continue;
795
            }
796
797 18
            foreach ($this->wordIndices[$index] as $indexInNew) {
798 18
                if ($indexInNew < $startInNew) {
799 8
                    continue;
800
                }
801
802 18
                if ($indexInNew >= $endInNew) {
803 9
                    break;
804
                }
805
806
                $newMatchLength =
807 18
                    (isset($matchLengthAt[$indexInNew - 1]) === true ? ($matchLengthAt[$indexInNew - 1] + 1) : 1);
808
809 18
                $newMatchLengthAt[$indexInNew] = $newMatchLength;
810
811 18
                if ($newMatchLength > $bestMatchSize ||
812
                    (
813 13
                        $groupDiffs === true &&
814 13
                        $bestMatchSize > 0 &&
815 18
                        $this->oldTextIsOnlyWhitespace($bestMatchInOld, $bestMatchSize) === true
816
                    )
817
                ) {
818 18
                    $bestMatchInOld = $indexInOld - $newMatchLength + 1;
819 18
                    $bestMatchInNew = $indexInNew - $newMatchLength + 1;
820 18
                    $bestMatchSize  = $newMatchLength;
821
                }
822
            }
823
824 18
            $matchLengthAt = $newMatchLengthAt;
825
        }
826
827
        // Skip match if none found or match consists only of whitespace
828 18
        if ($bestMatchSize !== 0 &&
829
            (
830 18
                $groupDiffs === false ||
831 18
                $this->oldTextIsOnlyWhitespace($bestMatchInOld, $bestMatchSize) === false
832
            )
833
        ) {
834 18
            return new MatchingBlock($bestMatchInOld, $bestMatchInNew, $bestMatchSize);
835
        }
836
837 11
        return null;
838
    }
839
840 18
    protected function oldTextIsOnlyWhitespace(int $startingAtWord, int $wordCount) : bool
841
    {
842 18
        $isWhitespace = true;
843
844
        // oldTextIsWhitespace get called consecutively by findMatch, with the same parameters.
845
        // by caching the previous result, we speed up the algorithm by more then 50%
846 18
        static $lastStartingWordOffset = null;
847 18
        static $lastWordCount          = null;
848 18
        static $cache                  = null;
849
850 18
        if ($this->resetCache === true) {
851 18
            $cache = null;
852
853 18
            $this->resetCache = false;
854
        }
855
856
        if (
857 18
            $cache !== null &&
858 18
            $lastWordCount === $wordCount &&
859 18
            $lastStartingWordOffset === $startingAtWord
860
        ) { // Hit
861 13
            return $cache;
862
        } // Miss
863
864 18
        for ($index = $startingAtWord; $index < ($startingAtWord + $wordCount); $index++) {
865
            // Assigning the oldWord to a variable is slightly faster then searching by reference twice
866
            // in the if statement
867 18
            $oldWord = $this->oldWords[$index];
868
869 18
            if ($oldWord !== '' && trim($oldWord) !== '') {
870 18
                $isWhitespace = false;
871
872 18
                break;
873
            }
874
        }
875
876 18
        $lastWordCount          = $wordCount;
877 18
        $lastStartingWordOffset = $startingAtWord;
878
879 18
        $cache = $isWhitespace;
880
881 18
        return $cache;
882
    }
883
}
884